Пересылка фото: перешифровка + загрузка на CDN, коллаж для пересланных фото, открытие в просмотрщике
This commit is contained in:
@@ -429,6 +429,7 @@ private struct ChatDetailToolbarAvatar: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let _ = AvatarRepository.shared.avatarVersion // observation dependency for reactive updates
|
||||
let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
|
||||
let initials = route.isSavedMessages ? "S" : RosettaColors.initials(name: titleText, publicKey: route.publicKey)
|
||||
let colorIndex = RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
|
||||
@@ -944,7 +945,7 @@ private extension ChatDetailView {
|
||||
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
|
||||
if let file = fileAttachments.first { return file.id.isEmpty ? "File" : file.id }
|
||||
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
|
||||
return " "
|
||||
return "Message"
|
||||
}()
|
||||
|
||||
let imageContentWidth = maxBubbleWidth - 22 - (outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) - (!outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
@@ -973,11 +974,16 @@ private extension ChatDetailView {
|
||||
.padding(.leading, 11)
|
||||
.padding(.top, 3)
|
||||
|
||||
// Forwarded image attachments — blurhash thumbnails (Android parity: ForwardedImagePreview).
|
||||
ForEach(imageAttachments, id: \.id) { att in
|
||||
forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
// Forwarded image attachments — Telegram-style collage (same layout as PhotoCollageView).
|
||||
if !imageAttachments.isEmpty {
|
||||
ForwardedPhotoCollageView(
|
||||
attachments: imageAttachments,
|
||||
outgoing: outgoing,
|
||||
maxWidth: imageContentWidth,
|
||||
onImageTap: { attId in openImageViewer(attachmentId: attId) }
|
||||
)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Forwarded file attachments.
|
||||
@@ -1015,9 +1021,14 @@ private extension ChatDetailView {
|
||||
Spacer().frame(height: 5)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||
.frame(minWidth: 130, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
if !imageAttachments.isEmpty && !hasCaption {
|
||||
// Photo-only forward: dark pill overlay (same as regular photo messages)
|
||||
mediaTimestampOverlay(message: message, outgoing: outgoing)
|
||||
} else {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
@@ -1026,19 +1037,25 @@ private extension ChatDetailView {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message)
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
onTap: !imageAttachments.isEmpty ? { _ in
|
||||
// Open first forwarded image — user can swipe in gallery.
|
||||
if let firstId = imageAttachments.first?.id {
|
||||
openImageViewer(attachmentId: firstId)
|
||||
}
|
||||
} : nil
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
}
|
||||
|
||||
/// Wrapper that delegates to `ForwardedImagePreviewCell` — a proper View struct with
|
||||
/// `@State` so the image updates when `AttachmentCache` is populated by `MessageImageView`.
|
||||
/// Wrapper that delegates to `ForwardedImagePreviewCell` — used by `forwardedFilePreview`.
|
||||
@ViewBuilder
|
||||
private func forwardedImagePreview(attachment: ReplyAttachmentData, width: CGFloat, outgoing: Bool) -> some View {
|
||||
ForwardedImagePreviewCell(
|
||||
attachment: attachment,
|
||||
width: width,
|
||||
fixedHeight: nil,
|
||||
outgoing: outgoing,
|
||||
onTapCachedImage: { openImageViewer(attachmentId: attachment.id) }
|
||||
)
|
||||
@@ -1088,7 +1105,14 @@ private extension ChatDetailView {
|
||||
@ViewBuilder
|
||||
private func replyQuoteView(reply: ReplyMessageData, outgoing: Bool) -> some View {
|
||||
let senderName = senderDisplayName(for: reply.publicKey)
|
||||
let previewText = reply.message.isEmpty ? "Attachment" : reply.message
|
||||
let previewText: String = {
|
||||
let trimmed = reply.message.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty { return reply.message }
|
||||
if reply.attachments.contains(where: { $0.type == 0 }) { return "Photo" }
|
||||
if reply.attachments.contains(where: { $0.type == 2 }) { return "File" }
|
||||
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
|
||||
return "Attachment"
|
||||
}()
|
||||
let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue
|
||||
// Check for image attachment to show thumbnail
|
||||
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
|
||||
@@ -1869,14 +1893,17 @@ private extension ChatDetailView {
|
||||
UIPasteboard.general.string = message.text
|
||||
})
|
||||
|
||||
actions.append(BubbleContextAction(
|
||||
title: "Forward",
|
||||
image: UIImage(systemName: "arrowshape.turn.up.right"),
|
||||
role: []
|
||||
) {
|
||||
self.forwardingMessage = message
|
||||
self.showForwardPicker = true
|
||||
})
|
||||
// No forward for avatar messages (Android parity)
|
||||
if !message.attachments.contains(where: { $0.type == .avatar }) {
|
||||
actions.append(BubbleContextAction(
|
||||
title: "Forward",
|
||||
image: UIImage(systemName: "arrowshape.turn.up.right"),
|
||||
role: []
|
||||
) {
|
||||
self.forwardingMessage = message
|
||||
self.showForwardPicker = true
|
||||
})
|
||||
}
|
||||
|
||||
actions.append(BubbleContextAction(
|
||||
title: "Delete",
|
||||
@@ -1946,6 +1973,8 @@ private extension ChatDetailView {
|
||||
for message in messages {
|
||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||
let timestamp = Date(timeIntervalSince1970: Double(message.timestamp) / 1000)
|
||||
|
||||
// Regular image attachments on the message itself.
|
||||
for attachment in message.attachments where attachment.type == .image {
|
||||
allImages.append(ViewableImageInfo(
|
||||
attachmentId: attachment.id,
|
||||
@@ -1954,6 +1983,24 @@ private extension ChatDetailView {
|
||||
caption: message.text
|
||||
))
|
||||
}
|
||||
|
||||
// Forwarded image attachments inside reply/forward blobs.
|
||||
for attachment in message.attachments where attachment.type == .messages {
|
||||
if let replyMessages = parseReplyBlob(attachment.blob) {
|
||||
for reply in replyMessages {
|
||||
let fwdSenderName = senderDisplayName(for: reply.publicKey)
|
||||
let fwdTimestamp = Date(timeIntervalSince1970: Double(reply.timestamp) / 1000)
|
||||
for att in reply.attachments where att.type == AttachmentType.image.rawValue {
|
||||
allImages.append(ViewableImageInfo(
|
||||
attachmentId: att.id,
|
||||
senderName: fwdSenderName,
|
||||
timestamp: fwdTimestamp,
|
||||
caption: reply.message
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
|
||||
let state = ImageViewerState(images: allImages, initialIndex: index)
|
||||
@@ -2044,19 +2091,55 @@ private extension ChatDetailView {
|
||||
// MARK: - Forward
|
||||
|
||||
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
|
||||
// Desktop parity: forward uses same MESSAGES attachment as reply.
|
||||
// The forwarded message is encoded as a ReplyMessageData JSON blob.
|
||||
let forwardData = buildReplyData(from: message)
|
||||
// Android parity: unwrap nested forwards.
|
||||
// If the message being forwarded is itself a forward, extract the inner
|
||||
// forwarded messages and re-forward them directly (flatten).
|
||||
let forwardDataList: [ReplyMessageData]
|
||||
|
||||
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
||||
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|
||||
if isForward,
|
||||
let att = replyAttachment,
|
||||
let innerMessages = parseReplyBlob(att.blob),
|
||||
!innerMessages.isEmpty {
|
||||
// Unwrap: forward the original messages, not the wrapper
|
||||
forwardDataList = innerMessages
|
||||
} else {
|
||||
// Regular message — forward as-is
|
||||
forwardDataList = [buildReplyData(from: message)]
|
||||
}
|
||||
|
||||
// Android parity: collect cached images for re-upload.
|
||||
// Android re-encrypts + re-uploads each photo with the new message key.
|
||||
// Without this, Desktop tries to decrypt CDN blob with the wrong key.
|
||||
var forwardedImages: [String: Data] = [:]
|
||||
for replyData in forwardDataList {
|
||||
for att in replyData.attachments where att.type == AttachmentType.image.rawValue {
|
||||
if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id),
|
||||
let jpegData = image.jpegData(compressionQuality: 0.85) {
|
||||
forwardedImages[att.id] = jpegData
|
||||
#if DEBUG
|
||||
print("📤 Forward: collected image \(att.id) (\(jpegData.count) bytes)")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("📤 Forward: image \(att.id) NOT in cache — will skip re-upload")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let targetKey = targetRoute.publicKey
|
||||
Task { @MainActor in
|
||||
do {
|
||||
// Forward sends a space as text with the forwarded message as MESSAGES attachment
|
||||
try await SessionManager.shared.sendMessageWithReply(
|
||||
text: " ",
|
||||
replyMessages: [forwardData],
|
||||
replyMessages: forwardDataList,
|
||||
toPublicKey: targetKey,
|
||||
opponentTitle: targetRoute.title,
|
||||
opponentUsername: targetRoute.username
|
||||
opponentUsername: targetRoute.username,
|
||||
forwardedImages: forwardedImages
|
||||
)
|
||||
} catch {
|
||||
sendError = "Failed to forward message"
|
||||
@@ -2371,6 +2454,100 @@ private struct DisableScrollEdgeEffectModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ForwardedPhotoCollageView
|
||||
|
||||
/// Telegram-style collage layout for forwarded image attachments (same patterns as PhotoCollageView).
|
||||
/// Uses ForwardedImagePreviewCell for each cell instead of MessageImageView.
|
||||
private struct ForwardedPhotoCollageView: View {
|
||||
let attachments: [ReplyAttachmentData]
|
||||
let outgoing: Bool
|
||||
let maxWidth: CGFloat
|
||||
var onImageTap: ((String) -> Void)?
|
||||
|
||||
private let spacing: CGFloat = 2
|
||||
private let maxCollageHeight: CGFloat = 320
|
||||
|
||||
var body: some View {
|
||||
collageContent(contentWidth: maxWidth)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func collageContent(contentWidth: CGFloat) -> some View {
|
||||
switch attachments.count {
|
||||
case 0:
|
||||
EmptyView()
|
||||
case 1:
|
||||
cell(attachments[0], width: contentWidth, height: min(contentWidth * 0.75, maxCollageHeight))
|
||||
case 2:
|
||||
let cellWidth = (contentWidth - spacing) / 2
|
||||
let cellHeight = min(cellWidth * 1.2, maxCollageHeight)
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[0], width: cellWidth, height: cellHeight)
|
||||
cell(attachments[1], width: cellWidth, height: cellHeight)
|
||||
}
|
||||
case 3:
|
||||
let rightWidth = contentWidth * 0.34
|
||||
let leftWidth = contentWidth - spacing - rightWidth
|
||||
let totalHeight = min(leftWidth * 1.1, maxCollageHeight)
|
||||
let rightCellHeight = (totalHeight - spacing) / 2
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[0], width: leftWidth, height: totalHeight)
|
||||
VStack(spacing: spacing) {
|
||||
cell(attachments[1], width: rightWidth, height: rightCellHeight)
|
||||
cell(attachments[2], width: rightWidth, height: rightCellHeight)
|
||||
}
|
||||
}
|
||||
case 4:
|
||||
let cellWidth = (contentWidth - spacing) / 2
|
||||
let cellHeight = min(cellWidth * 0.85, maxCollageHeight / 2)
|
||||
VStack(spacing: spacing) {
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[0], width: cellWidth, height: cellHeight)
|
||||
cell(attachments[1], width: cellWidth, height: cellHeight)
|
||||
}
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[2], width: cellWidth, height: cellHeight)
|
||||
cell(attachments[3], width: cellWidth, height: cellHeight)
|
||||
}
|
||||
}
|
||||
default:
|
||||
let topCellWidth = (contentWidth - spacing) / 2
|
||||
let bottomCellWidth = (contentWidth - spacing * 2) / 3
|
||||
let topHeight = min(topCellWidth * 0.85, maxCollageHeight * 0.55)
|
||||
let bottomHeight = min(bottomCellWidth * 0.85, maxCollageHeight * 0.45)
|
||||
VStack(spacing: spacing) {
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[0], width: topCellWidth, height: topHeight)
|
||||
cell(attachments[1], width: topCellWidth, height: topHeight)
|
||||
}
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[2], width: bottomCellWidth, height: bottomHeight)
|
||||
if attachments.count > 3 {
|
||||
cell(attachments[3], width: bottomCellWidth, height: bottomHeight)
|
||||
}
|
||||
if attachments.count > 4 {
|
||||
cell(attachments[4], width: bottomCellWidth, height: bottomHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cell(_ attachment: ReplyAttachmentData, width: CGFloat, height: CGFloat) -> some View {
|
||||
ForwardedImagePreviewCell(
|
||||
attachment: attachment,
|
||||
width: width,
|
||||
fixedHeight: height,
|
||||
outgoing: outgoing,
|
||||
onTapCachedImage: { onImageTap?(attachment.id) }
|
||||
)
|
||||
.frame(width: width, height: height)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ForwardedImagePreviewCell
|
||||
|
||||
/// Proper View struct for forwarded image previews — uses `@State` + `.task` so the image
|
||||
@@ -2380,13 +2557,14 @@ private struct DisableScrollEdgeEffectModifier: ViewModifier {
|
||||
private struct ForwardedImagePreviewCell: View {
|
||||
let attachment: ReplyAttachmentData
|
||||
let width: CGFloat
|
||||
var fixedHeight: CGFloat?
|
||||
let outgoing: Bool
|
||||
let onTapCachedImage: () -> Void
|
||||
|
||||
@State private var cachedImage: UIImage?
|
||||
@State private var blurImage: UIImage?
|
||||
|
||||
private var imageHeight: CGFloat { min(width * 0.75, 200) }
|
||||
private var imageHeight: CGFloat { fixedHeight ?? min(width * 0.75, 200) }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -2396,7 +2574,6 @@ private struct ForwardedImagePreviewCell: View {
|
||||
.scaledToFill()
|
||||
.frame(width: width, height: imageHeight)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTapCachedImage() }
|
||||
} else if let blur = blurImage {
|
||||
@@ -2405,10 +2582,8 @@ private struct ForwardedImagePreviewCell: View {
|
||||
.scaledToFill()
|
||||
.frame(width: width, height: imageHeight)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
} else {
|
||||
// No image at all — show placeholder.
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
Rectangle()
|
||||
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
||||
.frame(width: width, height: imageHeight)
|
||||
.overlay {
|
||||
|
||||
@@ -4,14 +4,15 @@ import SwiftUI
|
||||
|
||||
/// Displays an avatar attachment inside a message bubble.
|
||||
///
|
||||
/// Desktop parity: `MessageAvatar.tsx` — shows a bordered card with circular avatar
|
||||
/// preview, "Avatar" title with lock icon, and descriptive text.
|
||||
/// Android parity: `AvatarAttachment` composable — download only on user tap,
|
||||
/// NOT auto-download. After successful download, saves to AvatarRepository.
|
||||
///
|
||||
/// States:
|
||||
/// 1. **Cached** — avatar already in AttachmentCache, display immediately
|
||||
/// 2. **Downloading** — show placeholder + spinner
|
||||
/// 3. **Downloaded** — display avatar, auto-saved to AvatarRepository
|
||||
/// 4. **Error** — "Avatar expired" or download error
|
||||
/// 2. **Not downloaded** — show placeholder + "Tap to download"
|
||||
/// 3. **Downloading** — show placeholder + spinner
|
||||
/// 4. **Downloaded** — display avatar, saved to AvatarRepository
|
||||
/// 5. **Error** — "Tap to retry"
|
||||
struct MessageAvatarView: View {
|
||||
|
||||
let attachment: MessageAttachment
|
||||
@@ -19,12 +20,19 @@ struct MessageAvatarView: View {
|
||||
let outgoing: Bool
|
||||
|
||||
@State private var avatarImage: UIImage?
|
||||
@State private var blurImage: UIImage?
|
||||
@State private var isDownloading = false
|
||||
@State private var downloadError = false
|
||||
@State private var showAvatar = false
|
||||
|
||||
/// Avatar circle diameter (desktop parity: 60px).
|
||||
private let avatarSize: CGFloat = 56
|
||||
|
||||
/// Whether the avatar needs to be downloaded from CDN.
|
||||
private var needsDownload: Bool {
|
||||
avatarImage == nil && !isDownloading && !downloadError
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
// Avatar circle (left side)
|
||||
@@ -43,13 +51,7 @@ struct MessageAvatarView: View {
|
||||
.foregroundStyle(Color.green.opacity(0.8))
|
||||
}
|
||||
|
||||
// Description
|
||||
Text("An avatar image shared in the message.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(2)
|
||||
|
||||
// Download state indicator
|
||||
// Description / status
|
||||
if isDownloading {
|
||||
HStack(spacing: 4) {
|
||||
ProgressView()
|
||||
@@ -59,12 +61,19 @@ struct MessageAvatarView: View {
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(outgoing ? .white.opacity(0.4) : RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
} else if downloadError {
|
||||
Text("Avatar expired")
|
||||
.font(.system(size: 11))
|
||||
Text("Tap to retry")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
.padding(.top, 2)
|
||||
} else if avatarImage != nil {
|
||||
Text("Shared profile photo.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Tap to download")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,10 +81,20 @@ struct MessageAvatarView: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
// BubbleContextMenuOverlay blocks all SwiftUI onTapGesture —
|
||||
// download triggered via notification from overlay's onTap callback.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
|
||||
if let id = notif.object as? String, id == attachment.id {
|
||||
if needsDownload || downloadError {
|
||||
downloadAvatar()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
loadFromCache()
|
||||
if avatarImage == nil {
|
||||
downloadAvatar()
|
||||
decodeBlurHash()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +110,37 @@ struct MessageAvatarView: View {
|
||||
.scaledToFill()
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
.scaleEffect(showAvatar ? 1.0 : 0.5)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.5), value: showAvatar)
|
||||
.onAppear { showAvatar = true }
|
||||
} else if let blurImage {
|
||||
// Android parity: blurhash preview in circle before download
|
||||
Image(uiImage: blurImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
.overlay {
|
||||
if isDownloading {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.4))
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.8)
|
||||
} else if downloadError {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.4))
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.4))
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Circle()
|
||||
.fill(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.08))
|
||||
@@ -99,9 +149,13 @@ struct MessageAvatarView: View {
|
||||
if isDownloading {
|
||||
ProgressView()
|
||||
.tint(.white.opacity(0.5))
|
||||
} else if downloadError {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
} else {
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: 22))
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.white.opacity(0.3))
|
||||
}
|
||||
}
|
||||
@@ -109,16 +163,47 @@ struct MessageAvatarView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BlurHash Decoding
|
||||
|
||||
/// Android parity: decode blurhash from preview as 32×32 placeholder.
|
||||
/// Shared static cache with half-eviction (same pattern as MessageImageView).
|
||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||
|
||||
private func decodeBlurHash() {
|
||||
let hash = extractBlurHash(from: attachment.preview)
|
||||
guard !hash.isEmpty else { return }
|
||||
if let cached = Self.blurHashCache[hash] {
|
||||
blurImage = cached
|
||||
return
|
||||
}
|
||||
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) {
|
||||
if Self.blurHashCache.count > 200 {
|
||||
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100))
|
||||
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.blurHashCache[hash] = result
|
||||
blurImage = result
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the blurhash from preview string.
|
||||
/// Format: "tag::blurhash" → returns "blurhash".
|
||||
private func extractBlurHash(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.count > 1 ? parts[1] : ""
|
||||
}
|
||||
|
||||
// MARK: - Download
|
||||
|
||||
private func loadFromCache() {
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
avatarImage = cached
|
||||
showAvatar = true // No animation for cached — show immediately
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAvatar() {
|
||||
guard !isDownloading, avatarImage == nil else { return }
|
||||
guard !isDownloading else { return }
|
||||
|
||||
let tag = extractTag(from: attachment.preview)
|
||||
guard !tag.isEmpty else {
|
||||
@@ -148,6 +233,12 @@ struct MessageAvatarView: View {
|
||||
if let downloadedImage {
|
||||
avatarImage = downloadedImage
|
||||
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
||||
// Android parity: save avatar to sender's profile after download
|
||||
let senderKey = message.fromPublicKey
|
||||
if let jpegData = downloadedImage.jpegData(compressionQuality: 0.85) {
|
||||
let base64 = jpegData.base64EncodedString()
|
||||
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey)
|
||||
}
|
||||
} else {
|
||||
downloadError = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user