Пересылка фото: перешифровка + загрузка на CDN, коллаж для пересланных фото, открытие в просмотрщике

This commit is contained in:
2026-03-20 21:20:11 +05:00
parent e75c6bac12
commit 224b8a2b54
8 changed files with 599 additions and 290 deletions

View File

@@ -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 {

View File

@@ -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
}