From 224b8a2b545d7e895f749da5f999682c4b75ba0c Mon Sep 17 00:00:00 2001 From: senseiGai Date: Fri, 20 Mar 2026 21:20:11 +0500 Subject: [PATCH] =?UTF-8?q?=20=20=D0=9F=D0=B5=D1=80=D0=B5=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=20=D1=84=D0=BE=D1=82=D0=BE:=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?+=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20CDN,=20=D0=BA=D0=BE=D0=BB=D0=BB=D0=B0=D0=B6=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=84=D0=BE=D1=82=D0=BE,=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D1=89=D0=B8=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta.xcodeproj/project.pbxproj | 8 +- Rosetta/Core/Crypto/MessageCrypto.swift | 201 ++++++------ .../Data/Repositories/MessageRepository.swift | 12 + Rosetta/Core/Services/SessionManager.swift | 292 ++++++++++-------- Rosetta/Core/Utils/ReleaseNotes.swift | 8 +- .../Components/ChatTextInput.swift | 2 +- .../Chats/ChatDetail/ChatDetailView.swift | 237 ++++++++++++-- .../Chats/ChatDetail/MessageAvatarView.swift | 129 ++++++-- 8 files changed, 599 insertions(+), 290 deletions(-) diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 0898773..4cf1641 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -417,7 +417,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -433,7 +433,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = 1.2.1; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -456,7 +456,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -472,7 +472,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = 1.2.1; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift index d8cae12..34985a5 100644 --- a/Rosetta/Core/Crypto/MessageCrypto.swift +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -130,7 +130,11 @@ enum MessageCrypto { /// Android parity helper: /// `String(bytes, UTF_8)` (replacement for invalid sequences) + `toByteArray(ISO_8859_1)`. + /// Emulates Android's `String(bytes, UTF_8).toByteArray(ISO_8859_1)` round-trip. + /// Uses BOTH WHATWG and Android UTF-8 decoders — returns candidates for each. + /// WHATWG and Android decoders handle invalid UTF-8 differently → different bytes. static func androidUtf8BytesToLatin1Bytes(_ utf8Bytes: Data) -> Data { + // Primary: WHATWG decoder (matches Java's Modified UTF-8 for most cases) let decoded = String(decoding: utf8Bytes, as: UTF8.self) if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) { return latin1 @@ -138,6 +142,15 @@ enum MessageCrypto { return Data(decoded.unicodeScalars.map { $0.value <= 0xFF ? UInt8($0.value) : UInt8(ascii: "?") }) } + /// Alternative key recovery using Android UTF-8 decoder. + static func androidUtf8BytesToLatin1BytesAlt(_ utf8Bytes: Data) -> Data { + let decoded = bytesToAndroidUtf8String(utf8Bytes) + if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) { + return latin1 + } + return Data(decoded.unicodeScalars.map { $0.value <= 0xFF ? UInt8($0.value) : UInt8(ascii: "?") }) + } + // MARK: - Attachment Password Helpers /// Returns password candidates from a stored attachment password string. @@ -176,122 +189,83 @@ enum MessageCrypto { /// /// Used for PBKDF2 password derivation from random key+nonce bytes /// to match Android's attachment encryption. + /// Exact port of Android's `bytesToBufferPolyfillUtf8String()` from MessageCrypto.kt:1419-1502. + /// Uses feross/buffer npm polyfill UTF-8 decoding semantics. + /// Key difference from previous implementation: on invalid sequence, ALWAYS advance by 1 byte + /// and emit 1× U+FFFD (not variable bytes/U+FFFD count). static func bytesToAndroidUtf8String(_ bytes: Data) -> String { - var result = "" - result.reserveCapacity(bytes.count) - var i = bytes.startIndex + var codePoints: [Int] = [] + codePoints.reserveCapacity(bytes.count) + var index = 0 + let end = bytes.count - while i < bytes.endIndex { - let b0 = Int(bytes[i]) + while index < end { + let firstByte = Int(bytes[index]) & 0xFF + var codePoint: Int? = nil + var bytesPerSequence: Int - if b0 <= 0x7F { - // ASCII - result.append(Character(UnicodeScalar(b0)!)) - i += 1 - } else if b0 <= 0xBF { - // Orphan continuation byte - result.append("\u{FFFD}") - i += 1 - } else if b0 <= 0xDF { - // 2-byte sequence - if i + 1 >= bytes.endIndex { - result.append("\u{FFFD}") - i += 1 - } else { - let b1 = Int(bytes[i + 1]) - if b1 & 0xC0 != 0x80 { - result.append("\u{FFFD}") - i += 1 - } else { - let cp = ((b0 & 0x1F) << 6) | (b1 & 0x3F) - if cp < 0x80 || b0 == 0xC0 || b0 == 0xC1 { - // Overlong - result.append("\u{FFFD}") - result.append("\u{FFFD}") - } else { - result.append(Character(UnicodeScalar(cp)!)) - } - i += 2 - } - } - } else if b0 <= 0xEF { - // 3-byte sequence - if i + 2 >= bytes.endIndex { - let remaining = bytes.endIndex - i - for _ in 0..= 0xD800 && cp <= 0xDFFF) { - // Overlong or surrogate - result.append("\u{FFFD}") - result.append("\u{FFFD}") - result.append("\u{FFFD}") - } else { - result.append(Character(UnicodeScalar(cp)!)) - } - i += 3 - } - } - } else if b0 <= 0xF7 { - // 4-byte sequence - if i + 3 >= bytes.endIndex { - let remaining = bytes.endIndex - i - for _ in 0.. 0x10FFFF { - // Overlong or out-of-range - result.append("\u{FFFD}") - result.append("\u{FFFD}") - result.append("\u{FFFD}") - result.append("\u{FFFD}") - } else { - result.append(Character(UnicodeScalar(cp)!)) - } - i += 4 - } - } + if firstByte > 0xEF { + bytesPerSequence = 4 + } else if firstByte > 0xDF { + bytesPerSequence = 3 + } else if firstByte > 0xBF { + bytesPerSequence = 2 } else { - // Invalid starter byte (0xF8-0xFF) - result.append("\u{FFFD}") - i += 1 + bytesPerSequence = 1 } + + if index + bytesPerSequence <= end { + switch bytesPerSequence { + case 1: + if firstByte < 0x80 { + codePoint = firstByte + } + case 2: + let secondByte = Int(bytes[index + 1]) & 0xFF + if (secondByte & 0xC0) == 0x80 { + let temp = ((firstByte & 0x1F) << 6) | (secondByte & 0x3F) + if temp > 0x7F { + codePoint = temp + } + } + case 3: + let secondByte = Int(bytes[index + 1]) & 0xFF + let thirdByte = Int(bytes[index + 2]) & 0xFF + if (secondByte & 0xC0) == 0x80 && (thirdByte & 0xC0) == 0x80 { + let temp = ((firstByte & 0x0F) << 12) | ((secondByte & 0x3F) << 6) | (thirdByte & 0x3F) + if temp > 0x7FF && (temp < 0xD800 || temp > 0xDFFF) { + codePoint = temp + } + } + case 4: + let secondByte = Int(bytes[index + 1]) & 0xFF + let thirdByte = Int(bytes[index + 2]) & 0xFF + let fourthByte = Int(bytes[index + 3]) & 0xFF + if (secondByte & 0xC0) == 0x80 && (thirdByte & 0xC0) == 0x80 && (fourthByte & 0xC0) == 0x80 { + let temp = ((firstByte & 0x0F) << 18) | ((secondByte & 0x3F) << 12) | ((thirdByte & 0x3F) << 6) | (fourthByte & 0x3F) + if temp > 0xFFFF && temp < 0x110000 { + codePoint = temp + } + } + default: + break + } + } + + if codePoint == nil { + codePoint = 0xFFFD + bytesPerSequence = 1 + } else if codePoint! > 0xFFFF { + let adjusted = codePoint! - 0x10000 + codePoints.append(((adjusted >> 10) & 0x3FF) | 0xD800) + codePoint = 0xDC00 | (adjusted & 0x3FF) + } + + codePoints.append(codePoint!) + index += bytesPerSequence } - return result + return String(codePoints.map { Character(UnicodeScalar($0 < 0xD800 || $0 > 0xDFFF ? $0 : 0xFFFD)!) }) } } @@ -372,11 +346,16 @@ private extension MessageCrypto { } // Android parity: String(bytes, UTF_8) + toByteArray(ISO_8859_1) + // Try BOTH WHATWG and Android UTF-8 decoders — they handle invalid + // sequences differently, producing different raw key bytes. let originalBytes = androidUtf8BytesToLatin1Bytes(decryptedBytes) - guard originalBytes.count >= 56 else { continue } - if seen.insert(originalBytes).inserted { + if originalBytes.count >= 56, seen.insert(originalBytes).inserted { candidates.append(originalBytes) } + let altBytes = androidUtf8BytesToLatin1BytesAlt(decryptedBytes) + if altBytes.count >= 56, altBytes != originalBytes, seen.insert(altBytes).inserted { + candidates.append(altBytes) + } } guard !candidates.isEmpty else { diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 38f6de0..27b99b2 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -369,6 +369,18 @@ final class MessageRepository: ObservableObject { } } + // MARK: - Attachment Update (post-upload) + + /// Updates attachment previews after CDN upload completes. + /// Called from sendMessageWithAttachments after optimistic message was shown. + func updateAttachments(messageId: String, attachments: [MessageAttachment]) { + guard let dialogKey = messageToDialog[messageId] else { return } + updateMessages(for: dialogKey) { messages in + guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return } + messages[index].attachments = attachments + } + } + // MARK: - Stress Test (Debug only) /// Inserts a pre-built message for stress testing. Skips encryption/dedup. diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 86644bf..5e886f6 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -229,7 +229,10 @@ final class SessionManager { recipientPublicKeyHex: toPublicKey ) - // Attachment password: Android-style UTF-8 decoder (1:1 parity with Android). + // Attachment password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8') + // used by Desktop in useDialogFiber.ts and useSynchronize.ts). + // Node.js Buffer.toString('utf-8') ≈ feross/buffer polyfill ≈ bytesToAndroidUtf8String. + // WHATWG TextDecoder differs for ~47% of random 56-byte keys. let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce) // aesChachaKey = Latin-1 encoding (matches desktop sync chain: @@ -365,10 +368,10 @@ final class SessionManager { recipientPublicKeyHex: toPublicKey ) - // Attachment password: Android-style UTF-8 decoder (feross/buffer polyfill) for 1:1 parity. - // Android uses bytesToJsUtf8String() which emits ONE U+FFFD per consumed byte in - // failed multi-byte sequences. Desktop WHATWG is slightly different but both work - // because Desktop tries both variants when decrypting. + // Attachment password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8') + // used by Desktop in useDialogFiber.ts and useSynchronize.ts). + // Node.js Buffer.toString('utf-8') ≈ feross/buffer polyfill ≈ bytesToAndroidUtf8String. + // WHATWG TextDecoder differs for ~47% of random 56-byte keys. let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce) #if DEBUG @@ -430,46 +433,24 @@ final class SessionManager { )) } - // Android parity: cache original images BEFORE upload so they display - // instantly in the chat bubble. Without this, photo doesn't appear until - // upload completes (can take seconds on slow connection). + // Android parity: cache original images and show optimistic message BEFORE upload. + // Android: addMessageSafely(optimisticMessage) → then background upload. + // Without this, photo doesn't appear until upload completes (5-10 seconds). for item in encryptedAttachments { if item.original.type == .image, let image = UIImage(data: item.original.data) { AttachmentCache.shared.saveImage(image, forAttachmentId: item.original.id) } } - // Phase 2: Upload all attachments concurrently (Android parity: backgroundUploadScope). - let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup( - of: (Int, String).self - ) { group in - for (index, item) in encryptedAttachments.enumerated() { - group.addTask { - let tag = try await TransportManager.shared.uploadFile( - id: item.original.id, - content: item.encryptedData - ) - return (index, tag) - } - } - - // Collect results, preserving original order. - var tags = [Int: String]() - for try await (index, tag) in group { - tags[index] = tag - } - - return encryptedAttachments.enumerated().map { index, item in - let tag = tags[index] ?? "" - let preview = item.preview.isEmpty ? "\(tag)::" : "\(tag)::\(item.preview)" - Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(tag)") - return MessageAttachment( - id: item.original.id, - preview: preview, - blob: "", - type: item.original.type - ) - } + // Build placeholder attachments (no tag yet — filled after upload). + // preview = "::blurhash" (no tag prefix), blob = "". + let placeholderAttachments = encryptedAttachments.map { item in + MessageAttachment( + id: item.original.id, + preview: item.preview.isEmpty ? "" : "::\(item.preview)", + blob: "", + type: item.original.type + ) } // Build aesChachaKey (for sync/backup — same encoding as makeOutgoingPacket). @@ -507,62 +488,78 @@ final class SessionManager { } #endif - // Build PacketMessage with attachments - var packet = PacketMessage() - packet.fromPublicKey = currentPublicKey - packet.toPublicKey = toPublicKey - packet.content = encrypted.content - packet.chachaKey = encrypted.chachaKey - packet.timestamp = timestamp - packet.privateKey = hash - packet.messageId = messageId - packet.aesChachaKey = aesChachaKey - packet.attachments = messageAttachments + // ── Optimistic UI: show message INSTANTLY with placeholder attachments ── + // Android parity: addMessageSafely(optimisticMessage) before upload. + var optimisticPacket = PacketMessage() + optimisticPacket.fromPublicKey = currentPublicKey + optimisticPacket.toPublicKey = toPublicKey + optimisticPacket.content = encrypted.content + optimisticPacket.chachaKey = encrypted.chachaKey + optimisticPacket.timestamp = timestamp + optimisticPacket.privateKey = hash + optimisticPacket.messageId = messageId + optimisticPacket.aesChachaKey = aesChachaKey + optimisticPacket.attachments = placeholderAttachments - // Ensure dialog exists let existingDialog = DialogRepository.shared.dialogs[toPublicKey] let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "") let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "") DialogRepository.shared.ensureDialog( - opponentKey: toPublicKey, - title: title, - username: username, - myPublicKey: currentPublicKey + opponentKey: toPublicKey, title: title, username: username, myPublicKey: currentPublicKey ) - // Optimistic UI update let isConnected = ProtocolManager.shared.connectionState == .authenticated let offlineAsSend = !isConnected - - // messageText is already trimmed — "" for no caption, triggers "Photo"/"File" in updateFromMessage. let displayText = messageText + DialogRepository.shared.updateFromMessage( - packet, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend + optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend ) MessageRepository.shared.upsertFromMessagePacket( - packet, - myPublicKey: currentPublicKey, - decryptedText: displayText, - attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, - fromSync: offlineAsSend + optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText, + attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: offlineAsSend ) + MessageRepository.shared.persistNow() if offlineAsSend { MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) - DialogRepository.shared.updateDeliveryStatus( - messageId: messageId, opponentKey: toPublicKey, status: .error - ) + DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error) } - - // Saved Messages: local-only, no server send if toPublicKey == currentPublicKey { MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) - DialogRepository.shared.updateDeliveryStatus( - messageId: messageId, opponentKey: toPublicKey, status: .delivered - ) + DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered) return } + // ── Phase 2: Upload in background, then send packet ── + let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup( + of: (Int, String).self + ) { group in + for (index, item) in encryptedAttachments.enumerated() { + group.addTask { + let tag = try await TransportManager.shared.uploadFile( + id: item.original.id, content: item.encryptedData + ) + return (index, tag) + } + } + var tags = [Int: String]() + for try await (index, tag) in group { tags[index] = tag } + return encryptedAttachments.enumerated().map { index, item in + let tag = tags[index] ?? "" + let preview = item.preview.isEmpty ? "\(tag)::" : "\(tag)::\(item.preview)" + Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(tag)") + return MessageAttachment(id: item.original.id, preview: preview, blob: "", type: item.original.type) + } + } + + // Update message with real attachment tags (preview with CDN tag) + MessageRepository.shared.updateAttachments(messageId: messageId, attachments: messageAttachments) + + // Build final packet for WebSocket send + var packet = optimisticPacket + packet.attachments = messageAttachments + ProtocolManager.shared.sendPacket(packet) registerOutgoingRetry(for: packet) MessageRepository.shared.persistNow() @@ -613,7 +610,8 @@ final class SessionManager { replyMessages: [ReplyMessageData], toPublicKey: String, opponentTitle: String = "", - opponentUsername: String = "" + opponentUsername: String = "", + forwardedImages: [String: Data] = [:] // [originalAttachmentId: jpegData] ) async throws { guard let privKey = privateKeyHex, let hash = privateKeyHash else { Self.logger.error("📤 Cannot send reply — missing keys") @@ -630,12 +628,96 @@ final class SessionManager { recipientPublicKeyHex: toPublicKey ) - // Android parity: reply blob password = Android-style UTF-8 of raw plainKeyAndNonce bytes. - // Same as attachment password derivation. + // Reply password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8') + // used by Desktop in useDialogFiber.ts and useSynchronize.ts). let replyPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce) + #if DEBUG + Self.logger.debug("📤 Reply password rawKey: \(encrypted.plainKeyAndNonce.hexString)") + Self.logger.debug("📤 Reply password WHATWG UTF-8 (\(Array(replyPassword.utf8).count)b): \(Data(replyPassword.utf8).hexString)") + #endif + + // ── Android parity: re-upload forwarded photos to CDN ── + // Android: ChatViewModel lines 2434-2477 — re-encrypts + uploads each photo. + // Desktop: DialogProvider.tsx prepareAttachmentsToSend() — same pattern. + // Without this, recipient tries to decrypt CDN blob with the wrong key. + var attachmentIdMap: [String: (newId: String, newPreview: String)] = [:] + + if !forwardedImages.isEmpty && toPublicKey != currentPublicKey { + var fwdIndex = 0 + for (originalId, jpegData) in forwardedImages { + let newAttId = "fwd_\(timestamp)_\(fwdIndex)" + fwdIndex += 1 + + let dataURI = "data:image/jpeg;base64,\(jpegData.base64EncodedString())" + let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(dataURI.utf8), + password: replyPassword + ) + + #if DEBUG + Self.logger.debug("📤 Forward re-upload: \(originalId) → \(newAttId) (\(jpegData.count) bytes JPEG, \(encryptedBlob.count) encrypted)") + #endif + + let tag = try await TransportManager.shared.uploadFile( + id: newAttId, + content: Data(encryptedBlob.utf8) + ) + + // Extract blurhash from original preview (format: "tag::blurhash") + let originalPreview = replyMessages + .flatMap { $0.attachments } + .first(where: { $0.id == originalId })?.preview ?? "" + let blurhash: String + if let range = originalPreview.range(of: "::") { + blurhash = String(originalPreview[range.upperBound...]) + } else { + blurhash = "" + } + + let newPreview = "\(tag)::\(blurhash)" + attachmentIdMap[originalId] = (newAttId, newPreview) + + // Cache locally under new ID for ForwardedImagePreviewCell + if let image = UIImage(data: jpegData) { + AttachmentCache.shared.saveImage(image, forAttachmentId: newAttId) + } + + #if DEBUG + Self.logger.debug("📤 Forward re-upload OK: \(newAttId) tag=\(tag) preview=\(newPreview)") + #endif + } + } + + // ── Update reply messages with new attachment IDs/previews ── + let finalReplyMessages: [ReplyMessageData] + if attachmentIdMap.isEmpty { + finalReplyMessages = replyMessages + } else { + finalReplyMessages = replyMessages.map { msg in + let updatedAttachments = msg.attachments.map { att -> ReplyAttachmentData in + if let mapped = attachmentIdMap[att.id] { + return ReplyAttachmentData( + id: mapped.newId, + type: att.type, + preview: mapped.newPreview, + blob: "" + ) + } + return att + } + return ReplyMessageData( + message_id: msg.message_id, + publicKey: msg.publicKey, + message: msg.message, + timestamp: msg.timestamp, + attachments: updatedAttachments + ) + } + } + // Build the reply JSON blob - let replyJSON = try JSONEncoder().encode(replyMessages) + let replyJSON = try JSONEncoder().encode(finalReplyMessages) guard let replyJSONString = String(data: replyJSON, encoding: .utf8) else { throw CryptoError.encryptionFailed } @@ -648,6 +730,14 @@ final class SessionManager { #if DEBUG Self.logger.debug("📤 Reply blob: \(replyJSON.count) raw → \(encryptedReplyBlob.count) encrypted bytes") + // Self-test: decrypt with the same WHATWG password + if let selfTestData = try? CryptoManager.shared.decryptWithPassword( + encryptedReplyBlob, password: replyPassword, requireCompression: true + ), String(data: selfTestData, encoding: .utf8) != nil { + Self.logger.debug("📤 Reply blob self-test PASSED") + } else { + Self.logger.error("📤 Reply blob self-test FAILED") + } #endif // Build reply attachment @@ -737,7 +827,7 @@ final class SessionManager { ProtocolManager.shared.sendPacket(packet) registerOutgoingRetry(for: packet) MessageRepository.shared.persistNow() - Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)") + Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s), \(forwardedImages.count) re-uploaded photos") } /// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog). @@ -1222,52 +1312,8 @@ final class SessionManager { } } - // Desktop parity: auto-download AVATAR attachments from transport server. - let crypto = CryptoManager.shared - for attachment in processedPacket.attachments where attachment.type == .avatar { - let senderKey = packet.fromPublicKey - let preview = attachment.preview - let tag = preview.components(separatedBy: "::").first ?? preview - guard !tag.isEmpty else { continue } - let passwords = passwordCandidates - - Task { - do { - let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) - let encryptedString = String(decoding: encryptedData, as: UTF8.self) - var decryptedData: Data? - for password in passwords { - if let data = try? crypto.decryptWithPassword( - encryptedString, password: password, requireCompression: true - ) { - decryptedData = data - break - } - } - // Fallback: try without requireCompression (legacy) - if decryptedData == nil { - for password in passwords { - if let data = try? crypto.decryptWithPassword( - encryptedString, password: password - ) { - decryptedData = data - break - } - } - } - guard let decryptedData else { throw TransportError.invalidResponse } - if let base64String = String(data: decryptedData, encoding: .utf8) { - AvatarRepository.shared.saveAvatarFromBase64( - base64String, publicKey: senderKey - ) - } - } catch { - Self.logger.error( - "Failed to download/decrypt avatar from \(senderKey.prefix(12))…: \(error.localizedDescription)" - ) - } - } - } + // Android parity: avatar attachments are NOT auto-downloaded here. + // They are downloaded on-demand when MessageAvatarView renders in chat. } // For outgoing messages received from the server (sent by another device diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index 245dcfd..4cf650e 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -20,11 +20,17 @@ enum ReleaseNotes { **Фото и файлы** Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз. + **Аватарки** + Превью аватарки отображается как размытое изображение до скачивания. Плавная анимация при загрузке. Скачивание по тапу. + + **Шифрование** + Улучшена совместимость шифрования фото и файлов между устройствами. Пересланные фото корректно расшифровываются получателем. + **Производительность** Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства. **Исправления** - Исправлены нерабочие кнопки на iOS 26+. Увеличен отступ строки ввода. Исправлен счётчик непрочитанных после синхронизации. Saved Messages: иконка закладки вместо аватара. + Исправлена отправка фото и аватаров на Desktop. Исправлено шифрование пересланных сообщений. Исправлен бейдж непрочитанных в tab bar. Исправлен счётчик после синхронизации. Исправлены кнопки на iOS 26+. Группировка баблов для фото-сообщений. Saved Messages: иконка закладки. """ ) ] diff --git a/Rosetta/DesignSystem/Components/ChatTextInput.swift b/Rosetta/DesignSystem/Components/ChatTextInput.swift index 42eca36..bd27808 100644 --- a/Rosetta/DesignSystem/Components/ChatTextInput.swift +++ b/Rosetta/DesignSystem/Components/ChatTextInput.swift @@ -125,7 +125,7 @@ struct ChatTextInput: UIViewRepresentable { tv.backgroundColor = .clear tv.tintColor = UIColor(RosettaColors.primaryBlue) tv.isScrollEnabled = false - tv.textContainerInset = UIEdgeInsets(top: 6, left: 2, bottom: 8, right: 0) + tv.textContainerInset = UIEdgeInsets(top: 7, left: 2, bottom: 7, right: 0) tv.textContainer.lineFragmentPadding = 0 tv.autocapitalizationType = .sentences tv.autocorrectionType = .default diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 731efae..c601ff5 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -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 { diff --git a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift index e2a4f22..0f014d2 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift @@ -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 }