diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 2b3ab9e..782141b 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; }; F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; }; + F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -34,6 +35,7 @@ 853F29A02F4B63D20092AD05 /* P256K in Frameworks */, F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */, F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */, + F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -80,6 +82,7 @@ 853F29A12F4B63D20092AD05 /* P256K */, F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */, F1A000042F6F00010092AD05 /* FirebaseMessaging */, + F1A000072F6F00010092AD05 /* FirebaseCrashlytics */, ); productName = Rosetta; productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; @@ -272,7 +275,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -288,7 +291,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.5; + MARKETING_VERSION = 1.1.6; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -311,7 +314,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -327,7 +330,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.5; + MARKETING_VERSION = 1.1.6; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -413,6 +416,11 @@ package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseMessaging; }; + F1A000072F6F00010092AD05 /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 853F295A2F4B50410092AD05 /* Project object */; diff --git a/Rosetta/Core/Crypto/CryptoManager.swift b/Rosetta/Core/Crypto/CryptoManager.swift index b138d68..171895e 100644 --- a/Rosetta/Core/Crypto/CryptoManager.swift +++ b/Rosetta/Core/Crypto/CryptoManager.swift @@ -105,58 +105,51 @@ final class CryptoManager: @unchecked Sendable { return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())" } - nonisolated func decryptWithPassword(_ encrypted: String, password: String) throws -> Data { + /// - Parameter requireCompression: When `true`, skips the uncompressed fallback. + /// Use for attachment blobs which are ALWAYS compressed (zlibDeflate/rawDeflate). + /// The uncompressed fallback accepts ANY AES-CBC output as valid, which returns + /// garbage when the password is wrong — breaking multi-candidate password loops. + nonisolated func decryptWithPassword( + _ encrypted: String, + password: String, + requireCompression: Bool = false + ) throws -> Data { let parts = encrypted.components(separatedBy: ":") guard parts.count == 2, let iv = Data(base64Encoded: parts[0]), let ciphertext = Data(base64Encoded: parts[1]) else { - print("🔐 [decrypt] ❌ Malformed: parts=\(encrypted.components(separatedBy: ":").count) encrypted.prefix=\(encrypted.prefix(60))…") throw CryptoError.invalidData("Malformed encrypted string") } - print("🔐 [decrypt] iv=\(iv.count)bytes ciphertext=\(ciphertext.count)bytes passwordUTF8=\(Array(password.utf8).count)bytes passwordChars=\(password.count)") - - // SHA256 first: desktop CryptoJS v4 + both iOS encrypt functions use SHA256. - // SHA1 fallback: legacy messages encrypted before CryptoJS v4 migration. - // ⚠️ SHA256 MUST be first — wrong-key AES-CBC can randomly produce valid - // PKCS7 padding (~1/256 chance) and garbage may survive zlib inflate, - // causing false-positive decryption with corrupt data. let prfOrder: [CCPseudoRandomAlgorithm] = [ - CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // Desktop CryptoJS v4 + iOS - CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), // Legacy fallback + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), ] // 1) Preferred path: AES-CBC + inflate (handles both rawDeflate and zlibDeflate) for prf in prfOrder { - let prfName = prf == CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) ? "SHA256" : "SHA1" do { - let result = try decryptWithPassword( + return try decryptWithPassword( ciphertext: ciphertext, iv: iv, password: password, prf: prf, expectsCompressed: true ) - print("🔐 [decrypt] ✅ \(prfName)+compressed succeeded, result=\(result.count)bytes, first4=\(result.prefix(4).map { String(format: "%02x", $0) }.joined())") - return result - } catch { - print("🔐 [decrypt] ⚠️ \(prfName)+compressed failed: \(error)") + } catch { } + } + + // 2) Fallback: AES-CBC without compression (very old/legacy payloads). + // Skipped when requireCompression is true — prevents wrong-password garbage + // from being accepted as valid data. + if !requireCompression { + for prf in prfOrder { + do { + return try decryptWithPassword( + ciphertext: ciphertext, iv: iv, password: password, + prf: prf, expectsCompressed: false + ) + } catch { } } } - // 2) Fallback: AES-CBC without compression (very old/legacy payloads) - for prf in prfOrder { - let prfName = prf == CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) ? "SHA256" : "SHA1" - do { - let result = try decryptWithPassword( - ciphertext: ciphertext, iv: iv, password: password, - prf: prf, expectsCompressed: false - ) - print("🔐 [decrypt] ✅ \(prfName)+uncompressed succeeded, result=\(result.count)bytes, first4=\(result.prefix(4).map { String(format: "%02x", $0) }.joined())") - return result - } catch { - print("🔐 [decrypt] ⚠️ \(prfName)+uncompressed failed: \(error)") - } - } - - print("🔐 [decrypt] ❌ ALL paths failed") throw CryptoError.decryptionFailed } @@ -180,7 +173,6 @@ private extension CryptoManager { prf: CCPseudoRandomAlgorithm, expectsCompressed: Bool ) throws -> Data { - let prfName = prf == CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) ? "SHA256" : "SHA1" let key = CryptoPrimitives.pbkdf2( password: password, salt: "rosetta", @@ -188,9 +180,7 @@ private extension CryptoManager { keyLength: 32, prf: prf ) - print("🔐 [decrypt-inner] \(prfName) pbkdf2Key=\(key.prefix(8).map { String(format: "%02x", $0) }.joined()) passwordUTF8Len=\(Array(password.utf8).count)") let decrypted = try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: key, iv: iv) - print("🔐 [decrypt-inner] \(prfName) aesDecrypted=\(decrypted.count)bytes first16=\(decrypted.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " "))") if expectsCompressed { return try CryptoPrimitives.rawInflate(decrypted) } diff --git a/Rosetta/Core/Crypto/CryptoPrimitives.swift b/Rosetta/Core/Crypto/CryptoPrimitives.swift index ea9d464..3c81afd 100644 --- a/Rosetta/Core/Crypto/CryptoPrimitives.swift +++ b/Rosetta/Core/Crypto/CryptoPrimitives.swift @@ -1,6 +1,7 @@ import Foundation import CommonCrypto import Compression +import zlib // MARK: - CryptoPrimitives @@ -122,34 +123,28 @@ enum CryptoPrimitives { extension CryptoPrimitives { - /// Zlib-wrapped deflate compression (0x78 header + raw deflate + adler32 trailer). - /// Compatible with desktop `pako.deflate()` and Node.js `zlib.deflateSync()`. - /// Desktop CryptoJS uses `pako.deflate()` which produces zlib-wrapped output; - /// `pako.inflate()` on the desktop expects this format — raw deflate will fail. + /// Zlib-wrapped deflate compression using the system C zlib library (`compress2()`). + /// Produces standard RFC 1950 zlib output compatible with desktop `pako.inflate()` + /// and Node.js `zlib.inflateSync()`. + /// + /// Previously used Apple's `compression_encode_buffer(COMPRESSION_ZLIB)` (raw deflate) + /// with a manual zlib wrapper — that output was incompatible with pako.inflate(). static func zlibDeflate(_ data: Data) throws -> Data { - let raw = try rawDeflate(data) - var result = Data(capacity: 2 + raw.count + 4) - // zlib header: CMF=0x78 (deflate method, 32K window), FLG=0x9C (default level, check bits) - result.append(contentsOf: [0x78, 0x9C] as [UInt8]) - result.append(raw) - // Adler-32 checksum of the original uncompressed data (big-endian) - let checksum = adler32(data) - result.append(UInt8((checksum >> 24) & 0xFF)) - result.append(UInt8((checksum >> 16) & 0xFF)) - result.append(UInt8((checksum >> 8) & 0xFF)) - result.append(UInt8(checksum & 0xFF)) - return result - } + let sourceLen = uLong(data.count) + var destLen = compressBound(sourceLen) + var dest = Data(count: Int(destLen)) - /// Adler-32 checksum (used for zlib trailer). - private static func adler32(_ data: Data) -> UInt32 { - var a: UInt32 = 1 - var b: UInt32 = 0 - for byte in data { - a = (a &+ UInt32(byte)) % 65521 - b = (b &+ a) % 65521 + let status = dest.withUnsafeMutableBytes { destPtr in + data.withUnsafeBytes { srcPtr in + guard let dBase = destPtr.bindMemory(to: Bytef.self).baseAddress, + let sBase = srcPtr.bindMemory(to: Bytef.self).baseAddress else { + return Z_MEM_ERROR + } + return compress2(dBase, &destLen, sBase, sourceLen, Z_DEFAULT_COMPRESSION) + } } - return (b << 16) | a + guard status == Z_OK else { throw CryptoError.compressionFailed } + return dest.prefix(Int(destLen)) } /// Raw deflate compression (no zlib header, compatible with Java Deflater(nowrap=true)). diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift index 317c643..d8cae12 100644 --- a/Rosetta/Core/Crypto/MessageCrypto.swift +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -21,11 +21,14 @@ enum MessageCrypto { /// - encryptedKey: Base64-encoded ECDH-encrypted key+nonce (`iv:encryptedKey:ephemeralPrivateKey`). /// - myPrivateKeyHex: Recipient's secp256k1 private key (hex). /// - Returns: Decrypted plaintext string. - static func decryptIncoming( + /// Decrypts an incoming message and returns both plaintext and the working key+nonce. + /// The returned `keyAndNonce` is the candidate that successfully decrypted the message — + /// critical for deriving the correct attachment password. + static func decryptIncomingFull( ciphertext: String, encryptedKey: String, myPrivateKeyHex: String - ) throws -> String { + ) throws -> (text: String, keyAndNonce: Data) { let keyCandidates = try decryptKeyFromSenderCandidates( encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex @@ -34,7 +37,8 @@ enum MessageCrypto { var lastError: Error? for keyAndNonce in keyCandidates where keyAndNonce.count >= 56 { do { - return try decryptWithKeyAndNonce(ciphertext: ciphertext, keyAndNonce: keyAndNonce) + let text = try decryptWithKeyAndNonce(ciphertext: ciphertext, keyAndNonce: keyAndNonce) + return (text, keyAndNonce) } catch { lastError = error } @@ -46,6 +50,18 @@ enum MessageCrypto { throw CryptoError.invalidData("Failed to decrypt message content with all key candidates") } + static func decryptIncoming( + ciphertext: String, + encryptedKey: String, + myPrivateKeyHex: String + ) throws -> String { + try decryptIncomingFull( + ciphertext: ciphertext, + encryptedKey: encryptedKey, + myPrivateKeyHex: myPrivateKeyHex + ).text + } + /// Encrypts a message using XChaCha20-Poly1305 + ECDH for the recipient. /// - Parameters: /// - plaintext: The message text. @@ -91,14 +107,24 @@ enum MessageCrypto { } /// Extract raw decrypted key+nonce data from an encrypted key string (ECDH path). - /// Used for decrypting MESSAGES-type attachment blobs (desktop parity). + /// Verifies each candidate by attempting XChaCha20 decryption to find the correct one. + /// Falls back to first candidate if ciphertext is unavailable. static func extractDecryptedKeyData( encryptedKey: String, - myPrivateKeyHex: String + myPrivateKeyHex: String, + verifyCiphertext: String? = nil ) -> Data? { guard let candidates = try? decryptKeyFromSenderCandidates( encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex ) else { return nil } + // When ciphertext is available, verify each candidate via XChaCha20 decryption + if let ciphertext = verifyCiphertext { + for candidate in candidates where candidate.count >= 56 { + if (try? decryptWithKeyAndNonce(ciphertext: ciphertext, keyAndNonce: candidate)) != nil { + return candidate + } + } + } return candidates.first } @@ -111,6 +137,162 @@ enum MessageCrypto { } 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. + /// New format: `"rawkey:"` → derives Android (`bytesToJsUtf8String`) + WHATWG passwords. + /// Legacy format: plain string → used as-is (backward compat with persisted messages). + static func attachmentPasswordCandidates(from stored: String) -> [String] { + if stored.hasPrefix("rawkey:") { + let hex = String(stored.dropFirst("rawkey:".count)) + let keyData = Data(hexString: hex) + let androidPwd = bytesToAndroidUtf8String(keyData) + let whatwgPwd = String(decoding: keyData, as: UTF8.self) + // Latin-1 variant: backward compat with iOS builds that used .isoLatin1 encoding + let latin1Pwd = String(bytes: keyData, encoding: .isoLatin1) + var candidates = [androidPwd, whatwgPwd] + if let latin1Pwd, latin1Pwd != androidPwd, latin1Pwd != whatwgPwd { + candidates.append(latin1Pwd) + } + // Deduplicate while preserving order + var seen = Set() + return candidates.filter { seen.insert($0).inserted } + } + return [stored] + } + + // MARK: - Android-Compatible UTF-8 Decoder + + /// Port of Android `bytesToJsUtf8String()` — manual UTF-8 decoder that emits + /// ONE U+FFFD PER BYTE consumed in failed multi-byte sequences. + /// + /// This differs from WHATWG (Swift's `String(decoding:as:UTF8.self)`) which emits + /// ONE U+FFFD per "maximal subpart" of an ill-formed subsequence. + /// + /// Example: bytes `[0xEF, 0x98, 0xF7]` (3-byte starter, valid continuation, invalid 2nd): + /// - WHATWG: 1× U+FFFD (for maximal subpart [EF,98]), then re-scan F7 + /// - Android: 2× U+FFFD (one for EF, one for 98), then re-scan F7 + /// + /// Used for PBKDF2 password derivation from random key+nonce bytes + /// to match Android's attachment encryption. + static func bytesToAndroidUtf8String(_ bytes: Data) -> String { + var result = "" + result.reserveCapacity(bytes.count) + var i = bytes.startIndex + + while i < bytes.endIndex { + let b0 = Int(bytes[i]) + + 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 + } + } + } else { + // Invalid starter byte (0xF8-0xFF) + result.append("\u{FFFD}") + i += 1 + } + } + + return result + } } // MARK: - ECDH Key Exchange diff --git a/Rosetta/Core/Data/Models/ChatMessage.swift b/Rosetta/Core/Data/Models/ChatMessage.swift index 54f2a6e..7a081e3 100644 --- a/Rosetta/Core/Data/Models/ChatMessage.swift +++ b/Rosetta/Core/Data/Models/ChatMessage.swift @@ -1,7 +1,7 @@ import Foundation /// Single message inside a direct chat dialog. -struct ChatMessage: Identifiable, Codable, Sendable { +struct ChatMessage: Identifiable, Codable, Sendable, Equatable { let id: String var fromPublicKey: String var toPublicKey: String diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 2766ee2..4a60fc4 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -90,6 +90,7 @@ final class DialogRepository { _sortedKeysCache = nil storagePassword = "" UNUserNotificationCenter.current().setBadgeCount(0) + UserDefaults.standard.set(0, forKey: "app_badge_count") guard !currentAccount.isEmpty else { return } let accountToReset = currentAccount @@ -487,6 +488,7 @@ final class DialogRepository { private func updateAppBadge() { let total = dialogs.values.reduce(0) { $0 + $1.unreadCount } UNUserNotificationCenter.current().setBadgeCount(total) + UserDefaults.standard.set(total, forKey: "app_badge_count") } private static func dialogsFileName(for accountPublicKey: String) -> String { diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index e03f371..3cb580b 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -136,6 +136,12 @@ final class MessageRepository: ObservableObject { // no ACK will arrive → treat as .delivered immediately. let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered) + // Check BEFORE inserting — if this ID was already known (from a previous + // session or evicted from the 40-cap), the message was previously seen. + // During sync, treat previously-seen opponent messages as read to avoid + // inflating unread counts (server doesn't re-send PacketRead during sync). + let wasKnownBefore = allKnownMessageIds.contains(messageId) + messageToDialog[messageId] = dialogKey allKnownMessageIds.insert(messageId) @@ -157,6 +163,11 @@ final class MessageRepository: ObservableObject { return } + // During sync, previously-known messages (evicted from 40-cap but still + // in allKnownMessageIds) are historical — mark as read. Without this, + // reconcileUnreadCounts() would count them as unread and show wrong badges. + let syncRestoredRead = fromSync && wasKnownBefore && !fromMe + messages.append( ChatMessage( id: messageId, @@ -165,7 +176,7 @@ final class MessageRepository: ObservableObject { text: decryptedText, timestamp: timestamp, deliveryStatus: outgoingStatus, - isRead: incomingRead || fromMe, + isRead: incomingRead || fromMe || syncRestoredRead, attachments: packet.attachments, attachmentPassword: attachmentPassword ) @@ -253,17 +264,18 @@ final class MessageRepository: ObservableObject { schedulePersist() } - /// Android parity: resolve outgoing WAITING messages after reconnect. - /// Returns messages safe to retry and ids that expired (must be marked as error in dialog state). - func resolveWaitingOutgoingMessages( + /// Resolve outgoing messages that need retry after reconnect. + /// Includes both `.waiting` and `.error` messages — error messages are + /// retried on reconnect because they likely failed due to connection loss. + /// Messages older than `maxRetryAgeMs` are skipped (stale, user should retry manually). + func resolveRetryableOutgoingMessages( myPublicKey: String, nowMs: Int64, - maxAgeMs: Int64 - ) -> (retryable: [ChatMessage], expired: [(messageId: String, dialogKey: String)]) { + maxRetryAgeMs: Int64 + ) -> [ChatMessage] { var retryable: [ChatMessage] = [] - var expired: [(messageId: String, dialogKey: String)] = [] var hasMutations = false - let cutoff = nowMs - maxAgeMs + let cutoff = nowMs - maxRetryAgeMs for (dialogKey, currentMessages) in messagesByDialog { var messages = currentMessages @@ -272,14 +284,16 @@ final class MessageRepository: ObservableObject { for index in messages.indices { let message = messages[index] guard message.fromPublicKey == myPublicKey else { continue } - guard message.deliveryStatus == .waiting else { continue } + guard message.deliveryStatus == .waiting || message.deliveryStatus == .error else { continue } + // Skip messages older than the retry window (stale). + guard message.timestamp >= cutoff else { continue } - if message.timestamp < cutoff { - messages[index].deliveryStatus = .error - expired.append((messageId: message.id, dialogKey: dialogKey)) + retryable.append(message) + + // Reset .error back to .waiting so UI shows clock icon during retry. + if message.deliveryStatus == .error { + messages[index].deliveryStatus = .waiting mutated = true - } else { - retryable.append(message) } } @@ -293,7 +307,7 @@ final class MessageRepository: ObservableObject { schedulePersist() } - return (retryable: retryable, expired: expired) + return retryable } /// Delete a single message by ID and persist the change. diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift index 59bee97..a9353ef 100644 --- a/Rosetta/Core/Network/Protocol/Packets/Packet.swift +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -63,13 +63,34 @@ enum AttachmentType: Int, Codable { case avatar = 3 } -struct MessageAttachment: Codable { +struct MessageAttachment: Codable, Equatable { var id: String = "" var preview: String = "" var blob: String = "" var type: AttachmentType = .image } +// MARK: - Reply Message Data (desktop parity) + +/// JSON structure for reply/forward message blobs (AttachmentType.messages). +/// Desktop: `MessageReply` in `useReplyMessages.ts`. +/// Android: `ReplyMessage` in `ChatViewModel.kt`. +struct ReplyMessageData: Codable { + let message_id: String + let publicKey: String + let message: String + let timestamp: Int64 + let attachments: [ReplyAttachmentData] +} + +/// Attachment inside a reply/forward blob. +struct ReplyAttachmentData: Codable { + let id: String + let type: Int + let preview: String + let blob: String +} + // MARK: - Search User struct SearchUser { diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 407380d..8655e16 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -105,50 +105,17 @@ final class ProtocolManager: @unchecked Sendable { } /// Verify connection health after returning from background. - /// If connection appears alive, sends a WebSocket ping to confirm. - /// If ping fails or times out (2s), forces immediate reconnection. + /// Always force reconnect — after background, the socket is likely dead + /// and a 2s ping timeout just delays the inevitable. func reconnectIfNeeded() { guard savedPublicKey != nil, savedPrivateHash != nil else { return } // Don't interrupt active handshake if connectionState == .handshaking { return } - if connectionState == .authenticated && client.isConnected { - // Connection looks alive — verify with ping (2s timeout) - Self.logger.info("Verifying connection with ping...") - - let pingTimeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 2_000_000_000) - guard !Task.isCancelled, let self else { return } - Self.logger.info("Ping timeout — connection dead, force reconnecting") - self.handshakeComplete = false - self.heartbeatTask?.cancel() - Task { @MainActor in - self.connectionState = .connecting - } - self.client.forceReconnect() - } - - client.sendPing { [weak self] error in - pingTimeoutTask.cancel() - guard let self else { return } - if let error { - Self.logger.info("Ping failed: \(error.localizedDescription) — force reconnecting") - self.handshakeComplete = false - self.heartbeatTask?.cancel() - Task { @MainActor in - self.connectionState = .connecting - } - self.client.forceReconnect() - } else { - Self.logger.info("Ping succeeded — connection alive") - } - } - return - } - - // Not authenticated or not connected — force reconnect immediately - Self.logger.info("Force reconnect from foreground") + Self.logger.info("Foreground reconnect — force reconnecting") + handshakeComplete = false + heartbeatTask?.cancel() connectionState = .connecting client.forceReconnect() } @@ -234,6 +201,18 @@ final class ProtocolManager: @unchecked Sendable { client.onDataReceived = { [weak self] data in self?.handleIncomingData(data) } + + // Instant reconnect when network is restored (Wi-Fi ↔ cellular, airplane mode off, etc.) + client.onNetworkRestored = { [weak self] in + guard let self, self.savedPublicKey != nil else { return } + Self.logger.info("Network restored — force reconnecting") + self.handshakeComplete = false + self.heartbeatTask?.cancel() + Task { @MainActor in + self.connectionState = .connecting + } + self.client.forceReconnect() + } } // MARK: - Handshake @@ -399,17 +378,21 @@ final class ProtocolManager: @unchecked Sendable { handshakeComplete = true Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s") - Task { @MainActor in - self.connectionState = .authenticated - } - flushPacketQueue() startHeartbeat(interval: packet.heartbeatInterval) // Desktop parity: request transport server URL after handshake. sendPacketDirect(PacketRequestTransport()) - onHandshakeCompleted?(packet) + // CRITICAL: set .authenticated and fire callback in ONE MainActor task. + // Previously these were separate tasks — Swift doesn't guarantee FIFO + // ordering of unstructured tasks, so requestSynchronize() could race + // with the state change and silently drop the sync request. + let callback = self.onHandshakeCompleted + Task { @MainActor in + self.connectionState = .authenticated + callback?(packet) + } case .needDeviceVerification: handshakeComplete = false @@ -429,8 +412,8 @@ final class ProtocolManager: @unchecked Sendable { private func startHeartbeat(interval: Int) { heartbeatTask?.cancel() - // Desktop parity: heartbeat at half the server-specified interval. - let intervalNs = UInt64(interval) * 1_000_000_000 / 2 + // Android parity: heartbeat at 1/3 the server-specified interval (more aggressive keep-alive). + let intervalNs = UInt64(interval) * 1_000_000_000 / 3 heartbeatTask = Task { // Send first heartbeat immediately diff --git a/Rosetta/Core/Network/Protocol/WebSocketClient.swift b/Rosetta/Core/Network/Protocol/WebSocketClient.swift index 3bb88eb..5f37a44 100644 --- a/Rosetta/Core/Network/Protocol/WebSocketClient.swift +++ b/Rosetta/Core/Network/Protocol/WebSocketClient.swift @@ -1,4 +1,5 @@ import Foundation +import Network import os /// Native URLSession-based WebSocket client for Rosetta protocol. @@ -14,16 +15,41 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD private var hasNotifiedConnected = false private(set) var isConnected = false private var disconnectHandledForCurrentSocket = false + /// Android parity: exponential backoff counter, reset on successful connection. + private var reconnectAttempts = 0 + + /// NWPathMonitor for instant reconnect on network changes (Wi-Fi ↔ cellular, etc.). + private let networkMonitor = NWPathMonitor() + private var lastNetworkPath: NWPath.Status? var onConnected: (() -> Void)? var onDisconnected: ((Error?) -> Void)? var onDataReceived: ((Data) -> Void)? + /// Called when network becomes available and we need to reconnect. + var onNetworkRestored: (() -> Void)? override init() { super.init() let config = URLSessionConfiguration.default config.waitsForConnectivity = true session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + startNetworkMonitor() + } + + private func startNetworkMonitor() { + networkMonitor.pathUpdateHandler = { [weak self] path in + guard let self else { return } + let previous = self.lastNetworkPath + self.lastNetworkPath = path.status + + // Only trigger on transition to .satisfied (network restored). + // Skip the initial callback (previous == nil). + if path.status == .satisfied, previous != nil, previous != .satisfied { + Self.logger.info("Network restored — triggering reconnect") + self.onNetworkRestored?() + } + } + networkMonitor.start(queue: DispatchQueue(label: "com.rosetta.networkMonitor")) } // MARK: - Connection @@ -119,6 +145,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD disconnectHandledForCurrentSocket = false reconnectTask?.cancel() reconnectTask = nil + reconnectAttempts = 0 onConnected?() } @@ -169,11 +196,13 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD guard !isManuallyClosed else { return } guard reconnectTask == nil else { return } - // Fixed 5-second reconnect interval (desktop parity) - let delaySeconds: Double = 5.0 + // Android parity: exponential backoff — 1s, 2s, 4s, 8s, 16s (cap). + reconnectAttempts += 1 + let exponent = min(reconnectAttempts - 1, 4) + let delayMs = min(1000 * (1 << exponent), 16000) reconnectTask = Task { [weak self] in - Self.logger.info("Reconnecting in 5s...") - try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000)) + Self.logger.info("Reconnecting in \(delayMs)ms (attempt #\(self?.reconnectAttempts ?? 0))...") + try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) guard let self, !isManuallyClosed, !Task.isCancelled else { return } self.reconnectTask = nil self.connect() diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index b34b6fd..f110eaf 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -2,6 +2,7 @@ import Foundation import Observation import os import UIKit +import CommonCrypto /// Bridges AccountManager, CryptoManager, and ProtocolManager into a unified session lifecycle. @Observable @@ -239,7 +240,7 @@ final class SessionManager { // Desktop parity: preview = "tag::blurhash" (same as IMAGE attachments) let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) - let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 4)) ?? "" + let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? "" let preview = "\(tag)::\(blurhash)" // Build aesChachaKey (same as regular messages) @@ -287,7 +288,8 @@ final class SessionManager { ) MessageRepository.shared.upsertFromMessagePacket( packet, myPublicKey: currentPublicKey, decryptedText: " ", - attachmentPassword: latin1String, fromSync: offlineAsSend + attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, + fromSync: offlineAsSend ) if offlineAsSend { @@ -340,11 +342,24 @@ final class SessionManager { recipientPublicKeyHex: toPublicKey ) - // Derive attachment password from plainKeyAndNonce (desktop: key.toString('utf-8')) - // Must use UTF-8 decoding with replacement characters (U+FFFD for invalid sequences) - // to match Node.js Buffer.toString('utf-8') behavior exactly. - // Previously used .isoLatin1 which produced different PBKDF2 keys for bytes > 0x7F. - let latin1String = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self) + // Attachment password: WHATWG UTF-8 of raw key+nonce bytes. + // Matches desktop's Buffer.from(rawBytes).toString('utf-8') for PBKDF2 password derivation. + let attachmentPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self) + + #if DEBUG + // Full diagnostic: log values needed to verify PBKDF2 key matches CryptoJS. + // To verify on desktop, run in dev console: + // CryptoJS.PBKDF2("", "rosetta", {keySize:8, iterations:1000}).toString() + // and compare with the pbkdf2Key logged below. + let pwdUtf8Bytes = Array(attachmentPassword.utf8) + let pbkdf2Key = CryptoPrimitives.pbkdf2( + password: attachmentPassword, salt: "rosetta", iterations: 1000, + keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) + ) + Self.logger.debug("📎 rawKey: \(encrypted.plainKeyAndNonce.hexString)") + Self.logger.debug("📎 pwdUTF8(\(pwdUtf8Bytes.count)b): \(Data(pwdUtf8Bytes).hexString)") + Self.logger.debug("📎 pbkdf2Key: \(pbkdf2Key.hexString)") + #endif // Process each attachment: encrypt → upload → build metadata var messageAttachments: [MessageAttachment] = [] @@ -352,25 +367,70 @@ final class SessionManager { // Build data URI (desktop: FileReader.readAsDataURL) let dataURI = buildDataURI(attachment) + #if DEBUG + Self.logger.debug("📎 DataURI prefix: \(String(dataURI.prefix(40)))… (\(dataURI.count) chars)") + #endif + // Encrypt blob with desktop-compatible encryption let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( Data(dataURI.utf8), - password: latin1String + password: attachmentPassword ) + #if DEBUG + // Log IV and ciphertext prefix for cross-platform verification. + let blobParts = encryptedBlob.components(separatedBy: ":") + if blobParts.count == 2, let ivData = Data(base64Encoded: blobParts[0]) { + Self.logger.debug("📎 blob IV: \(ivData.hexString), ct(\(blobParts[1].count) b64chars)") + } + // Self-test: decrypt with the SAME WHATWG password. + if let selfTestData = try? CryptoManager.shared.decryptWithPassword( + encryptedBlob, password: attachmentPassword, requireCompression: true + ), String(data: selfTestData, encoding: .utf8)?.hasPrefix("data:") == true { + Self.logger.debug("📎 Blob self-test PASSED") + } else { + Self.logger.error("📎 Blob self-test FAILED — blob may not decrypt on desktop") + } + #endif + // Upload to transport server + let uploadData = Data(encryptedBlob.utf8) + let uploadHash = CryptoManager.shared.sha256(uploadData) let tag = try await TransportManager.shared.uploadFile( id: attachment.id, - content: Data(encryptedBlob.utf8) + content: uploadData ) + #if DEBUG + Self.logger.debug("📎 Uploaded tag=\(tag), \(uploadData.count) bytes, sha256=\(uploadHash.hexString)") + + // Transport round-trip verification: download the blob back and compare SHA256. + // This catches CDN corruption, partial uploads, and encoding issues. + do { + let verifyData = try await TransportManager.shared.downloadFile(tag: tag) + let verifyHash = CryptoManager.shared.sha256(verifyData) + if uploadHash == verifyHash { + Self.logger.debug("📎 Transport verify PASS: tag=\(tag), \(verifyData.count) bytes") + } else { + Self.logger.error("📎 ❌ TRANSPORT MISMATCH tag=\(tag): uploaded \(uploadData.count)b sha=\(uploadHash.hexString), downloaded \(verifyData.count)b sha=\(verifyHash.hexString)") + // Log first 100 bytes of each for comparison + let upPrefix = String(data: uploadData.prefix(100), encoding: .utf8) ?? "" + let downStr = String(data: verifyData.prefix(100), encoding: .utf8) ?? "" + Self.logger.error("📎 ❌ Upload prefix: \(upPrefix)") + Self.logger.error("📎 ❌ Download prefix: \(downStr)") + } + } catch { + Self.logger.error("📎 ❌ Transport verify FAILED to download tag=\(tag): \(error)") + } + #endif + // Build preview string (format depends on type) // Desktop parity: preview = "tag::blurhash" for images, "tag::size::filename" for files let preview: String switch attachment.type { case .image: - // Generate blurhash from thumbnail (desktop: encode(imageData, width, height, 4, 4)) - let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 4)) ?? "" + // Generate blurhash from thumbnail (android: BlurHash.encode(bitmap, 4, 3)) + let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? "" preview = "\(tag)::\(blurhash)" case .file: // Desktop: preview = "tag::size::filename" @@ -389,13 +449,41 @@ final class SessionManager { Self.logger.info("📤 Attachment uploaded: type=\(String(describing: attachment.type)), tag=\(tag)") } - // Build aesChachaKey (for sync/backup — same as regular messages) - let aesChachaPayload = Data(latin1String.utf8) + // Build aesChachaKey (for sync/backup — same encoding as makeOutgoingPacket). + // MUST use Latin-1 (not WHATWG UTF-8) so desktop can recover original raw bytes + // via Buffer.from(decryptedString, 'binary') which takes the low byte of each char. + // Latin-1 maps every byte 0x00-0xFF to its codepoint losslessly. + // WHATWG UTF-8 replaces invalid sequences with U+FFFD (codepoint 0xFFFD) — + // Buffer.from('\uFFFD', 'binary') recovers 0xFD, not the original byte. + guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { + throw CryptoError.encryptionFailed + } + let aesChachaPayload = Data(latin1ForSync.utf8) let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( aesChachaPayload, password: privKey ) + #if DEBUG + // aesChachaKey round-trip self-test: simulates EXACT desktop sync chain. + do { + let rtDecrypted = try CryptoManager.shared.decryptWithPassword( + aesChachaKey, password: privKey, requireCompression: true + ) + guard let rtString = String(data: rtDecrypted, encoding: .utf8) else { + Self.logger.error("📎 aesChachaKey FAILED — not valid UTF-8") + throw CryptoError.decryptionFailed + } + // Simulate Buffer.from(string, 'binary').toString('utf-8') + let rtRawBytes = Data(rtString.unicodeScalars.map { UInt8($0.value & 0xFF) }) + let rtPassword = String(decoding: rtRawBytes, as: UTF8.self) + let match = rtPassword == attachmentPassword + Self.logger.debug("📎 aesChachaKey roundtrip: \(match ? "PASS" : "FAIL") (\(rtRawBytes.count) bytes recovered)") + } catch { + Self.logger.error("📎 aesChachaKey roundtrip FAILED: \(error)") + } + #endif + // Build PacketMessage with attachments var packet = PacketMessage() packet.fromPublicKey = currentPublicKey @@ -432,7 +520,7 @@ final class SessionManager { packet, myPublicKey: currentPublicKey, decryptedText: displayText, - attachmentPassword: latin1String, + attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: offlineAsSend ) @@ -488,6 +576,145 @@ final class SessionManager { } } + /// Sends a message with a reply/forward blob (AttachmentType.messages). + /// + /// Desktop parity: `prepareAttachmentsToSend()` in `DialogProvider.tsx` → + /// for MESSAGES type: `encodeWithPassword(chacha_key_utf8, JSON.stringify(reply))`. + /// Android: `ChatViewModel.sendMessageWithReply()`. + /// + /// The reply blob is a JSON array of `ReplyMessageData` objects, encrypted with + /// `encryptWithPasswordDesktopCompat()` using the WHATWG UTF-8 of plainKeyAndNonce. + func sendMessageWithReply( + text: String, + replyMessages: [ReplyMessageData], + toPublicKey: String, + opponentTitle: String = "", + opponentUsername: String = "" + ) async throws { + guard let privKey = privateKeyHex, let hash = privateKeyHash else { + Self.logger.error("📤 Cannot send reply — missing keys") + throw CryptoError.decryptionFailed + } + + let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + let timestamp = Int64(Date().timeIntervalSince1970 * 1000) + + // Encrypt message text (use single space if empty — desktop parity) + let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? " " : text + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: messageText, + recipientPublicKeyHex: toPublicKey + ) + + // Desktop parity: reply blob password = WHATWG UTF-8 of raw plainKeyAndNonce bytes. + // Same as attachment password derivation. + let replyPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self) + + // Build the reply JSON blob + let replyJSON = try JSONEncoder().encode(replyMessages) + guard let replyJSONString = String(data: replyJSON, encoding: .utf8) else { + throw CryptoError.encryptionFailed + } + + // Encrypt reply blob with desktop-compatible encryption + let encryptedReplyBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + replyJSON, + password: replyPassword + ) + + #if DEBUG + Self.logger.debug("📤 Reply blob: \(replyJSON.count) raw → \(encryptedReplyBlob.count) encrypted bytes") + #endif + + // Build reply attachment + let replyAttachmentId = "reply_\(timestamp)" + let replyAttachment = MessageAttachment( + id: replyAttachmentId, + preview: "", + blob: encryptedReplyBlob, + type: .messages + ) + + // Build aesChachaKey (same as sendMessageWithAttachments) + guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { + throw CryptoError.encryptionFailed + } + let aesChachaPayload = Data(latin1ForSync.utf8) + let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + aesChachaPayload, + password: privKey + ) + + // Build packet for wire (encrypted blob) + 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 = [replyAttachment] + + // Build a local copy with decrypted blob for UI storage + // (incoming messages get decrypted in handleIncomingMessage; outgoing must be pre-decrypted) + let localReplyAttachment = MessageAttachment( + id: replyAttachmentId, + preview: "", + blob: replyJSONString, // Decrypted JSON for local rendering + type: .messages + ) + var localPacket = packet + localPacket.attachments = [localReplyAttachment] + + // 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 + ) + + // Optimistic UI update — use localPacket (decrypted blob) for storage + let isConnected = ProtocolManager.shared.connectionState == .authenticated + let offlineAsSend = !isConnected + let displayText = messageText == " " ? " " : messageText + DialogRepository.shared.updateFromMessage( + localPacket, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend + ) + MessageRepository.shared.upsertFromMessagePacket( + localPacket, + myPublicKey: currentPublicKey, + decryptedText: displayText, + attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, + fromSync: offlineAsSend + ) + + if offlineAsSend { + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) + DialogRepository.shared.updateDeliveryStatus( + messageId: messageId, opponentKey: toPublicKey, status: .error + ) + } + + // Saved Messages: local-only + if toPublicKey == currentPublicKey { + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) + DialogRepository.shared.updateDeliveryStatus( + messageId: messageId, opponentKey: toPublicKey, status: .delivered + ) + return + } + + ProtocolManager.shared.sendPacket(packet) + registerOutgoingRetry(for: packet) + Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)") + } + /// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog). func sendTypingIndicator(toPublicKey: String) { guard toPublicKey != currentPublicKey, @@ -735,7 +962,7 @@ final class SessionManager { // Desktop parity (useSynchronize.ts): await whenFinish() then // save server cursor and request next batch. await self.waitForInboundQueueToDrain() - let serverCursor = self.normalizeSyncTimestamp(packet.timestamp) + let serverCursor = packet.timestamp self.saveLastSyncTimestamp(serverCursor) Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)") self.requestSynchronize(cursor: serverCursor) @@ -846,11 +1073,7 @@ final class SessionManager { let fromMe = packet.fromPublicKey == myKey - // DEBUG: log every call to processIncomingMessage - print("🔍 [processIncoming] msgId=\(packet.messageId.prefix(8))… from=\(packet.fromPublicKey.prefix(12))… to=\(packet.toPublicKey.prefix(12))… fromMe=\(fromMe) content=\(packet.content.count)chars chacha=\(packet.chachaKey.count)chars aesChacha=\(packet.aesChachaKey.count)chars attachments=\(packet.attachments.count)") - guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else { - print("🔍 [processIncoming] ❌ SKIPPED by isSupportedDirectMessagePacket — myKey=\(myKey.prefix(12))…") return } @@ -864,7 +1087,7 @@ final class SessionManager { ) guard let result = decryptResult else { - print("🔍 [processIncoming] ❌ decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8))… isOwnMessage=\(fromMe) privateKeyHex=\(currentPrivateKeyHex != nil ? "present" : "NIL") content.isEmpty=\(packet.content.isEmpty) chachaKey.isEmpty=\(packet.chachaKey.isEmpty) aesChachaKey.isEmpty=\(packet.aesChachaKey.isEmpty)") + Self.logger.warning("processIncoming: decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8))…") return } let text = result.text @@ -883,42 +1106,78 @@ final class SessionManager { } if let keyData = result.rawKeyData { - // Desktop parity: Buffer.toString('utf-8') — replaces invalid UTF-8 bytes with U+FFFD. - // Must match sending side encoding for correct PBKDF2 key derivation. - let attachmentPassword = String(decoding: keyData, as: UTF8.self) - resolvedAttachmentPassword = attachmentPassword - print("🔑 [attachPwd] rawKeyData(\(keyData.count)bytes)=\(keyData.map { String(format: "%02x", $0) }.joined(separator: " "))") - print("🔑 [attachPwd] passwordUTF8(\(Array(attachmentPassword.utf8).count)bytes)=\(Array(attachmentPassword.utf8).map { String(format: "%02x", $0) }.joined(separator: " "))") - print("🔑 [attachPwd] passwordChars=\(attachmentPassword.count)") + // Store raw key data as hex for on-demand password derivation at download time. + // Android and Desktop/iOS use different UTF-8 decoders for password derivation, + // so we need both variants. `attachmentPasswordCandidates()` derives them. + resolvedAttachmentPassword = "rawkey:" + keyData.hexString + let passwordCandidates = MessageCrypto.attachmentPasswordCandidates( + from: resolvedAttachmentPassword! + ) + Self.logger.debug("attachPwd: rawKeyData(\(keyData.count)bytes) candidates=\(passwordCandidates.count)") + for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages { let blob = processedPacket.attachments[i].blob - if !blob.isEmpty, - let decrypted = try? CryptoManager.shared.decryptWithPassword(blob, password: attachmentPassword), - let decryptedString = String(data: decrypted, encoding: .utf8) { - processedPacket.attachments[i].blob = decryptedString + guard !blob.isEmpty else { continue } + var decrypted = false + // Try with requireCompression first to avoid wrong-key garbage + for password in passwordCandidates { + if let data = try? CryptoManager.shared.decryptWithPassword( + blob, password: password, requireCompression: true + ), + let decryptedString = String(data: data, encoding: .utf8) { + processedPacket.attachments[i].blob = decryptedString + decrypted = true + break + } + } + // Fallback: try without requireCompression (legacy uncompressed) + if !decrypted { + for password in passwordCandidates { + if let data = try? CryptoManager.shared.decryptWithPassword( + blob, password: password + ), + let decryptedString = String(data: data, encoding: .utf8) { + processedPacket.attachments[i].blob = decryptedString + break + } + } } } // Desktop parity: auto-download AVATAR attachments from transport server. - // Flow: extract tag from preview → download from transport → decrypt with chacha key → save. let crypto = CryptoManager.shared for attachment in processedPacket.attachments where attachment.type == .avatar { let senderKey = packet.fromPublicKey let preview = attachment.preview - // Desktop parity: preview = "tag::blurhash" let tag = preview.components(separatedBy: "::").first ?? preview guard !tag.isEmpty else { continue } - let password = attachmentPassword + let passwords = passwordCandidates Task { do { let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) let encryptedString = String(decoding: encryptedData, as: UTF8.self) - // Decrypt with the same password used for MESSAGES attachments - let decryptedData = try crypto.decryptWithPassword( - encryptedString, password: password - ) - // Decrypted data is the base64-encoded avatar image + 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 @@ -933,16 +1192,21 @@ final class SessionManager { } } + // For outgoing messages received from the server (sent by another device + // on the same account), treat as sync-equivalent so status = .delivered. + // Without this, real-time fromMe messages get .waiting → timeout → .error. + let effectiveFromSync = syncBatchInProgress || fromMe + DialogRepository.shared.updateFromMessage( processedPacket, myPublicKey: myKey, decryptedText: text, - fromSync: syncBatchInProgress, isNewMessage: !wasKnownBefore + fromSync: effectiveFromSync, isNewMessage: !wasKnownBefore ) MessageRepository.shared.upsertFromMessagePacket( processedPacket, myPublicKey: myKey, decryptedText: text, attachmentPassword: resolvedAttachmentPassword, - fromSync: syncBatchInProgress + fromSync: effectiveFromSync ) // Desktop parity: if we received a message from the opponent (not our own), @@ -1055,11 +1319,18 @@ final class SessionManager { } private func requestSynchronize(cursor: Int64? = nil) { - guard ProtocolManager.shared.connectionState == .authenticated else { return } + // No connectionState guard: this method is only called from (1) handshake + // completion handler and (2) BATCH_END handler — both inherently authenticated. + // The old `connectionState == .authenticated` guard caused a race condition: + // ProtocolManager sets .authenticated in a separate MainActor task, so if + // requestSynchronize() ran first, the guard silently dropped the sync request. guard !syncRequestInFlight else { return } syncRequestInFlight = true - let lastSync = normalizeSyncTimestamp(cursor ?? loadLastSyncTimestamp()) + // Desktop parity: pass server cursor as-is (seconds). NO normalization — + // server uses seconds, converting to milliseconds made the server see a + // "future" cursor and respond NOT_NEEDED, breaking all subsequent syncs. + let lastSync = cursor ?? loadLastSyncTimestamp() var packet = PacketSync() packet.status = .notNeeded @@ -1071,20 +1342,24 @@ final class SessionManager { private func loadLastSyncTimestamp() -> Int64 { guard !currentPublicKey.isEmpty else { return 0 } - return Int64(UserDefaults.standard.integer(forKey: syncCursorKey)) + let stored = Int64(UserDefaults.standard.integer(forKey: syncCursorKey)) + // Migration: old code normalized seconds → milliseconds. If the stored value + // is in milliseconds (>= 1 trillion), convert back to seconds for server parity. + if stored >= 1_000_000_000_000 { + let corrected = stored / 1000 + UserDefaults.standard.set(Int(corrected), forKey: syncCursorKey) + return corrected + } + return stored } private func saveLastSyncTimestamp(_ raw: Int64) { guard !currentPublicKey.isEmpty else { return } - let normalized = normalizeSyncTimestamp(raw) - guard normalized > 0 else { return } + // Desktop parity: store server cursor as-is (seconds), no normalization. + guard raw > 0 else { return } let existing = loadLastSyncTimestamp() - guard normalized > existing else { return } - UserDefaults.standard.set(Int(normalized), forKey: syncCursorKey) - } - - private func normalizeSyncTimestamp(_ raw: Int64) -> Int64 { - raw < 1_000_000_000_000 ? raw * 1000 : raw + guard raw > existing else { return } + UserDefaults.standard.set(Int(raw), forKey: syncCursorKey) } /// Returns (decryptedText, rawKeyData) where rawKeyData can be used for attachment blob decryption. @@ -1096,51 +1371,45 @@ final class SessionManager { let isOwnMessage = packet.fromPublicKey == myPublicKey guard let privateKeyHex, !packet.content.isEmpty else { - print("🔍 [decrypt] ❌ Early return: privateKeyHex=\(privateKeyHex != nil ? "present" : "NIL") content.isEmpty=\(packet.content.isEmpty)") return nil } // Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce). if isOwnMessage, !packet.aesChachaKey.isEmpty { - print("🔍 [decrypt] Trying AES-CHACHA path (own sync) for msgId=\(packet.messageId.prefix(8))…") do { let decryptedPayload = try CryptoManager.shared.decryptWithPassword( packet.aesChachaKey, password: privateKeyHex ) + // decryptedPayload = UTF-8 bytes of Latin-1 string. + // androidUtf8BytesToLatin1Bytes recovers the raw 56-byte key+nonce. let keyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(decryptedPayload) let text = try MessageCrypto.decryptIncomingWithPlainKey( ciphertext: packet.content, plainKeyAndNonce: keyAndNonce ) - print("🔍 [decrypt] ✅ AES-CHACHA path succeeded, text=\(text.prefix(30))… rawKeyData=\(decryptedPayload.count) bytes") - return (text, decryptedPayload) + // Return raw 56 bytes (not the UTF-8 payload) so + // attachmentPasswordCandidates can correctly derive WHATWG/Android passwords. + return (text, keyAndNonce) } catch { - print("🔍 [decrypt] ⚠️ AES-CHACHA path failed: \(error). Falling through to ECDH…") + Self.logger.debug("AES-CHACHA sync path failed, falling through to ECDH…") } } // ECDH path (works for opponent messages, may work for own if chachaKey targets us) guard !packet.chachaKey.isEmpty else { - print("🔍 [decrypt] ❌ chachaKey is empty, no ECDH path available. isOwnMessage=\(isOwnMessage)") return nil } - print("🔍 [decrypt] Trying ECDH path for msgId=\(packet.messageId.prefix(8))… chachaKey=\(packet.chachaKey.prefix(30))…") do { - let text = try MessageCrypto.decryptIncoming( + let (text, keyAndNonce) = try MessageCrypto.decryptIncomingFull( ciphertext: packet.content, encryptedKey: packet.chachaKey, myPrivateKeyHex: privateKeyHex ) - let rawKeyData = try? MessageCrypto.extractDecryptedKeyData( - encryptedKey: packet.chachaKey, - myPrivateKeyHex: privateKeyHex - ) - print("🔍 [decrypt] ✅ ECDH path succeeded, text=\(text.prefix(30))… rawKeyData=\(rawKeyData != nil ? "\(rawKeyData!.count) bytes" : "nil")") - return (text, rawKeyData) + return (text, keyAndNonce) } catch { - print("🔍 [decrypt] ❌ ECDH path failed: \(error)") + Self.logger.warning("ECDH decrypt failed for msgId=\(packet.messageId.prefix(8))…: \(error)") return nil } } @@ -1338,24 +1607,16 @@ final class SessionManager { else { return } let now = Int64(Date().timeIntervalSince1970 * 1000) - let result = MessageRepository.shared.resolveWaitingOutgoingMessages( + // Retry both .waiting and .error messages within a 5-minute window. + // Messages older than 5 minutes are stale — user can retry manually. + let maxRetryAgeMs: Int64 = 5 * 60 * 1000 + let retryable = MessageRepository.shared.resolveRetryableOutgoingMessages( myPublicKey: currentPublicKey, nowMs: now, - maxAgeMs: maxOutgoingWaitingLifetimeMs + maxRetryAgeMs: maxRetryAgeMs ) - for expired in result.expired { - // Update dialog status to error — downgrade guards in - // DialogRepository.updateDeliveryStatus prevent regressions. - DialogRepository.shared.updateDeliveryStatus( - messageId: expired.messageId, - opponentKey: expired.dialogKey, - status: .error - ) - resolveOutgoingRetry(messageId: expired.messageId) - } - - for message in result.retryable { + for message in retryable { if message.toPublicKey == currentPublicKey { continue } @@ -1363,6 +1624,13 @@ final class SessionManager { let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { continue } + // Update dialog delivery status back to .waiting (shows clock icon). + DialogRepository.shared.updateDeliveryStatus( + messageId: message.id, + opponentKey: message.toPublicKey, + status: .waiting + ) + do { let packet = try makeOutgoingPacket( text: text, @@ -1374,19 +1642,17 @@ final class SessionManager { ) ProtocolManager.shared.sendPacket(packet) registerOutgoingRetry(for: packet) + Self.logger.info("Retrying message \(message.id.prefix(8))… to \(message.toPublicKey.prefix(12))…") } catch { - Self.logger.error("Failed to retry waiting message \(message.id): \(error.localizedDescription)") - // Mark message as error so it doesn't stay stuck at .waiting forever. + Self.logger.error("Failed to retry message \(message.id): \(error.localizedDescription)") MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .error) - let opponentKey = message.toPublicKey DialogRepository.shared.updateDeliveryStatus( messageId: message.id, - opponentKey: opponentKey, + opponentKey: message.toPublicKey, status: .error ) } } - } private func flushPendingReadReceipts() { diff --git a/Rosetta/Core/Utils/BlurHash.swift b/Rosetta/Core/Utils/BlurHash.swift index ce225c2..345becc 100644 --- a/Rosetta/Core/Utils/BlurHash.swift +++ b/Rosetta/Core/Utils/BlurHash.swift @@ -13,9 +13,9 @@ enum BlurHashEncoder { /// /// - Parameters: /// - image: Source image (will be downscaled internally for performance). - /// - numberOfComponents: AC components (x, y). Desktop uses (4, 4). + /// - numberOfComponents: AC components (x, y). Android parity: `BlurHash.encode(bitmap, 4, 3)`. /// - Returns: BlurHash string, or `nil` if encoding fails. - static func encode(image: UIImage, numberOfComponents components: (Int, Int) = (4, 4)) -> String? { + static func encode(image: UIImage, numberOfComponents components: (Int, Int) = (4, 3)) -> String? { let (componentX, componentY) = components guard componentX >= 1, componentX <= 9, componentY >= 1, componentY <= 9 else { return nil } @@ -116,7 +116,7 @@ enum BlurHashEncoder { // MARK: - sRGB <-> Linear - private static func sRGBToLinear(_ value: UInt8) -> Float { + static func sRGBToLinear(_ value: UInt8) -> Float { let v = Float(value) / 255 if v <= 0.04045 { return v / 12.92 @@ -125,7 +125,7 @@ enum BlurHashEncoder { } } - private static func linearToSRGB(_ value: Float) -> Int { + static func linearToSRGB(_ value: Float) -> Int { let v = max(0, min(1, value)) if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) @@ -178,12 +178,156 @@ enum BlurHashEncoder { } } +// MARK: - BlurHash Decoder + +/// Pure Swift BlurHash decoder matching the canonical woltapp/blurhash reference implementation. +/// https://github.com/woltapp/blurhash/blob/master/Swift/BlurHashDecode.swift +/// +/// Key differences from previous broken implementation: +/// - Length formula: `4 + 2 * numX * numY` (NOT `4 + 2 * (numX * numY - 1)` which was off by 2) +/// - Pixel format: 3 bytes/pixel RGB with CGImageAlphaInfo.none (canonical) +/// - Buffer: CFDataCreateMutable (canonical) +/// - Punch parameter for color intensity control (Android parity) +enum BlurHashDecoder { + + /// Decodes a BlurHash string into a UIImage. + /// + /// - Parameters: + /// - blurHash: The BlurHash string to decode. + /// - width: Output image width in pixels (default 32 — small, blurred anyway). + /// - height: Output image height in pixels (default 32). + /// - punch: Color intensity multiplier (default 1). Android parity. + /// - Returns: A UIImage placeholder, or `nil` if decoding fails. + static func decode(blurHash: String, width: Int = 32, height: Int = 32, punch: Float = 1) -> UIImage? { + guard blurHash.count >= 6 else { return nil } + + let sizeFlag = decodeBase83(blurHash, from: 0, length: 1) + let numY = (sizeFlag / 9) + 1 + let numX = (sizeFlag % 9) + 1 + + // Canonical formula: 1(sizeFlag) + 1(maxVal) + 4(DC) + 2*(N-1)(AC) = 4 + 2*N + let expectedLength = 4 + 2 * numX * numY + guard blurHash.count == expectedLength else { return nil } + + let quantisedMaximumValue = decodeBase83(blurHash, from: 1, length: 1) + let maximumValue = Float(quantisedMaximumValue + 1) / 166 + + let colors: [(Float, Float, Float)] = (0.. (Float, Float, Float) { + let r = value >> 16 + let g = (value >> 8) & 255 + let b = value & 255 + return (sRGBToLinear(r), sRGBToLinear(g), sRGBToLinear(b)) + } + + private static func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) { + let quantR = value / (19 * 19) + let quantG = (value / 19) % 19 + let quantB = value % 19 + return ( + signPow((Float(quantR) - 9) / 9, 2) * maximumValue, + signPow((Float(quantG) - 9) / 9, 2) * maximumValue, + signPow((Float(quantB) - 9) / 9, 2) * maximumValue + ) + } + + private static func signPow(_ value: Float, _ exp: Float) -> Float { + copysign(pow(abs(value), exp), value) + } + + private static func sRGBToLinear(_ value: T) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } + } + + // MARK: - Base83 Decoding (string-index based, matching canonical) + + private static let base83Lookup: [Character: Int] = { + let chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + var lookup = [Character: Int]() + for (i, ch) in chars.enumerated() { + lookup[ch] = i + } + return lookup + }() + + private static func decodeBase83(_ string: String, from start: Int, length: Int) -> Int { + let startIdx = string.index(string.startIndex, offsetBy: start) + let endIdx = string.index(startIdx, offsetBy: length) + var value = 0 + for char in string[startIdx.. String? { + /// Android parity: `BlurHash.encode(bitmap, 4, 3)`. + func blurHash(numberOfComponents components: (Int, Int) = (4, 3)) -> String? { return BlurHashEncoder.encode(image: self, numberOfComponents: components) } + + /// Creates a UIImage from a BlurHash string. + /// Canonical woltapp/blurhash decoder with punch parameter (Android parity). + static func fromBlurHash(_ blurHash: String, width: Int = 32, height: Int = 32, punch: Float = 1) -> UIImage? { + return BlurHashDecoder.decode(blurHash: blurHash, width: width, height: height, punch: punch) + } } diff --git a/Rosetta/Core/Utils/ProtocolConstants.swift b/Rosetta/Core/Utils/ProtocolConstants.swift index ca9c8f0..7ca0f57 100644 --- a/Rosetta/Core/Utils/ProtocolConstants.swift +++ b/Rosetta/Core/Utils/ProtocolConstants.swift @@ -2,8 +2,8 @@ import Foundation /// Centralized protocol constants matching the Desktop reference implementation. enum ProtocolConstants { - /// Auto-reconnect delay in seconds. - static let reconnectIntervalS: TimeInterval = 5 + /// Auto-reconnect initial delay in seconds (exponential backoff: 1, 2, 4, 8, 16). + static let reconnectIntervalS: TimeInterval = 1 /// Number of messages loaded per batch (scroll-to-top pagination). static let maxMessagesLoad = 20 diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index d084b29..2fcbfcd 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -5,22 +5,30 @@ import Foundation enum ReleaseNotes { /// Current release notes entries, newest first. - /// Each entry contains a version string and a list of changes. + /// Each entry contains a version string and either a `body` (free-form markdown) + /// or a `changes` list (auto-formatted as bullets). static let entries: [Entry] = [ Entry( version: appVersion, - changes: [ - "Performance optimizations for chat list rendering", - "Improved empty chat placeholder centering", - "Added release notes in Updates settings", - "Optimized dialog sort cache for faster UI updates", - "Draft messages with cross-session persistence", - "Chat pinning, muting, and swipe actions", - "Biometric authentication (Face ID / Touch ID)", - "Online status and typing indicators", - "Push notifications support", - "End-to-end encrypted messaging with XChaCha20-Poly1305" - ] + body: """ + **Синхронизация** + - Исправлена критическая ошибка, из-за которой синхронизация могла не запускаться после подключения к серверу + - Исправлена ошибка с курсором синхронизации — теперь курсор передаётся без преобразования, как в Desktop + - Исправлены ложные непрочитанные сообщения после синхронизации + + **Мульти-девайс** + - Сообщения с другого устройства того же аккаунта теперь корректно показываются со статусом «доставлено» + + **UI (iOS 26)** + - Исправлен баг с размытием экрана чата при скролле + + **Swipe-to-Reply** — свайп влево по сообщению для ответа, как в Telegram + **Reply Quote** — обновлённый дизайн цитаты ответа. Если ответ на фото — миниатюра из BlurHash + **Навигация по цитате** — тап на цитату скроллит к оригиналу с плавной подсветкой + **Коллаж фотографий** — несколько фото в сообщении отображаются в сетке в стиле Telegram + **Рамка вокруг фото** — фото обрамлены цветом пузырька с точным совпадением углов + **Просмотр фото** — полноэкранный просмотрщик с зумом, перетаскиванием и свайпом вниз для закрытия + """ ) ] @@ -38,6 +46,12 @@ enum ReleaseNotes { /// Sent as a system message from the "Rosetta Updates" account. static var releaseNoticeText: String { guard let latest = entries.first else { return "" } + + if let body = latest.body { + return "**Update v\(latest.version)**\n\n\(body)" + } + + // Fallback: auto-format from changes array. var lines = ["**Update v\(latest.version)**"] for change in latest.changes { lines.append("- \(change)") @@ -49,7 +63,8 @@ enum ReleaseNotes { struct Entry: Identifiable { let version: String - let changes: [String] + var changes: [String] = [] + var body: String? = nil var id: String { version } } diff --git a/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift b/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift new file mode 100644 index 0000000..5c9eb57 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift @@ -0,0 +1,187 @@ +import SwiftUI +import UIKit + +/// Action model for context menu buttons. +struct BubbleContextAction { + let title: String + let image: UIImage? + let role: UIMenuElement.Attributes + let handler: () -> Void +} + +/// Transparent overlay that attaches UIContextMenuInteraction to a message bubble. +/// +/// Uses a **window snapshot** approach instead of UIHostingController preview: +/// 1. On long-press, captures a pixel-perfect screenshot of the bubble from the window +/// 2. Uses this snapshot as `UITargetedPreview` with `previewProvider: nil` +/// 3. UIKit lifts the snapshot in-place — no horizontal shift, no re-rendering issues +/// +/// Also supports an optional `onTap` callback that fires on single tap. +/// This is needed because the overlay UIView intercepts all touch events, +/// preventing SwiftUI `onTapGesture` on content below from firing. +struct BubbleContextMenuOverlay: UIViewRepresentable { + let actions: [BubbleContextAction] + let previewShape: MessageBubbleShape + let readStatusText: String? + + /// Called when user single-taps the bubble (e.g., to open fullscreen image). + var onTap: (() -> Void)? + + /// Height of the reply quote area at the top of the bubble (0 = no reply quote). + /// Taps within this region call `onReplyQuoteTap` instead of `onTap`. + var replyQuoteHeight: CGFloat = 0 + + /// Called when user taps the reply quote area at the top of the bubble. + var onReplyQuoteTap: (() -> Void)? + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + let interaction = UIContextMenuInteraction(delegate: context.coordinator) + view.addInteraction(interaction) + + // Single tap recognizer — coexists with context menu's long press. + let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) + view.addGestureRecognizer(tap) + + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.actions = actions + context.coordinator.previewShape = previewShape + context.coordinator.readStatusText = readStatusText + context.coordinator.onTap = onTap + context.coordinator.replyQuoteHeight = replyQuoteHeight + context.coordinator.onReplyQuoteTap = onReplyQuoteTap + } + + func makeCoordinator() -> Coordinator { Coordinator(overlay: self) } + + final class Coordinator: NSObject, UIContextMenuInteractionDelegate { + var actions: [BubbleContextAction] + var previewShape: MessageBubbleShape + var readStatusText: String? + var onTap: (() -> Void)? + var replyQuoteHeight: CGFloat = 0 + var onReplyQuoteTap: (() -> Void)? + private var snapshotView: UIImageView? + + init(overlay: BubbleContextMenuOverlay) { + self.actions = overlay.actions + self.previewShape = overlay.previewShape + self.readStatusText = overlay.readStatusText + self.onTap = overlay.onTap + self.replyQuoteHeight = overlay.replyQuoteHeight + self.onReplyQuoteTap = overlay.onReplyQuoteTap + } + + @objc func handleTap(_ recognizer: UITapGestureRecognizer) { + // Route taps in the reply quote region to the reply handler. + if replyQuoteHeight > 0, let view = recognizer.view { + let location = recognizer.location(in: view) + if location.y < replyQuoteHeight { + onReplyQuoteTap?() + return + } + } + onTap?() + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + captureSnapshot(for: interaction) + + return UIContextMenuConfiguration( + identifier: nil, + previewProvider: nil, + actionProvider: { [weak self] _ in + self?.buildMenu() + } + ) + } + + // MARK: - Snapshot + + private func captureSnapshot(for interaction: UIContextMenuInteraction) { + guard let view = interaction.view, let window = view.window else { return } + let frameInWindow = view.convert(view.bounds, to: window) + let renderer = UIGraphicsImageRenderer(size: view.bounds.size) + let image = renderer.image { ctx in + ctx.cgContext.translateBy(x: -frameInWindow.origin.x, y: -frameInWindow.origin.y) + window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) + } + let sv = UIImageView(image: image) + sv.frame = view.bounds + view.addSubview(sv) + self.snapshotView = sv + } + + // MARK: - Menu + + private func buildMenu() -> UIMenu { + var sections: [UIMenuElement] = [] + + if let readStatus = readStatusText { + let readAction = UIAction( + title: readStatus, + image: UIImage(systemName: "checkmark"), + attributes: .disabled + ) { _ in } + sections.append(UIMenu(options: .displayInline, children: [readAction])) + } + + let menuActions = actions.map { action in + UIAction( + title: action.title, + image: action.image, + attributes: action.role + ) { _ in + action.handler() + } + } + sections.append(UIMenu(options: .displayInline, children: menuActions)) + + return UIMenu(children: sections) + } + + // MARK: - Targeted Preview (lift & dismiss) + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + guard let sv = snapshotView else { return nil } + return makeTargetedPreview(for: sv) + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + guard let sv = snapshotView else { return nil } + return makeTargetedPreview(for: sv) + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + willEndFor configuration: UIContextMenuConfiguration, + animator: (any UIContextMenuInteractionAnimating)? + ) { + animator?.addCompletion { [weak self] in + self?.snapshotView?.removeFromSuperview() + self?.snapshotView = nil + } + } + + private func makeTargetedPreview(for view: UIView) -> UITargetedPreview { + let params = UIPreviewParameters() + let shapePath = previewShape.path(in: view.bounds) + params.visiblePath = UIBezierPath(cgPath: shapePath.cgPath) + params.backgroundColor = .clear + return UITargetedPreview(view: view, parameters: params) + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 7a84183..8beb9ff 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -82,10 +82,20 @@ struct ChatDetailView: View { @State private var showAttachmentPanel = false @State private var pendingAttachments: [PendingAttachment] = [] @State private var showOpponentProfile = false + @State private var replyingToMessage: ChatMessage? + @State private var showForwardPicker = false + @State private var forwardingMessage: ChatMessage? + @State private var messageToDelete: ChatMessage? + /// Attachment ID for full-screen image viewer (nil = dismissed). + @State private var fullScreenAttachmentId: String? + /// ID of message to scroll to (set when tapping a reply quote). + @State private var scrollToMessageId: String? + /// ID of message currently highlighted after scroll-to-reply navigation. + @State private var highlightedMessageId: String? - private var currentPublicKey: String { - SessionManager.shared.currentPublicKey - } + /// Cached at view init — never changes during a session. Avoids @Observable + /// observation on SessionManager that would re-render all cells on any state change. + private let currentPublicKey: String = SessionManager.shared.currentPublicKey private var dialog: Dialog? { DialogRepository.shared.dialogs[route.publicKey] @@ -262,6 +272,39 @@ struct ChatDetailView: View { .navigationDestination(isPresented: $showOpponentProfile) { OpponentProfileView(route: route) } + .sheet(isPresented: $showForwardPicker) { + ForwardChatPickerView { targetRoute in + showForwardPicker = false + guard let message = forwardingMessage else { return } + forwardingMessage = nil + forwardMessage(message, to: targetRoute) + } + } + .fullScreenCover(isPresented: Binding( + get: { fullScreenAttachmentId != nil }, + set: { if !$0 { fullScreenAttachmentId = nil } } + )) { + FullScreenImageFromCache( + attachmentId: fullScreenAttachmentId ?? "", + onDismiss: { fullScreenAttachmentId = nil } + ) + } + .alert("Delete Message", isPresented: Binding( + get: { messageToDelete != nil }, + set: { if !$0 { messageToDelete = nil } } + )) { + Button("Delete", role: .destructive) { + if let message = messageToDelete { + removeMessage(message) + messageToDelete = nil + } + } + Button("Cancel", role: .cancel) { + messageToDelete = nil + } + } message: { + Text("Are you sure you want to delete this message? This action cannot be undone.") + } } } @@ -630,15 +673,16 @@ private extension ChatDetailView { .onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } } .onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } } - ForEach(messages.indices.reversed(), id: \.self) { index in - let message = messages[index] + // PERF: use message.id as ForEach identity (stable). + // Integer indices shift on every insert, forcing full diff. + ForEach(Array(messages.enumerated()).reversed(), id: \.element.id) { index, message in + let position = bubblePosition(for: index) messageRow( message, maxBubbleWidth: maxBubbleWidth, - position: bubblePosition(for: index) + position: position ) .scaleEffect(x: 1, y: -1) // flip each row back to normal - .id(message.id) // Unread Messages separator (Telegram style). // In inverted scroll, "above" visually = after in code. @@ -652,6 +696,9 @@ private extension ChatDetailView { .padding(.horizontal, 10) .padding(.bottom, messagesTopInset) // visual top (near nav bar) } + // iOS 26: disable default scroll edge blur — in inverted scroll the top+bottom + // effects overlap and blur the entire screen. + .modifier(DisableScrollEdgeEffectModifier()) .scaleEffect(x: 1, y: -1) // INVERTED SCROLL — bottom-anchored by nature // Parent .ignoresSafeArea(.keyboard) handles keyboard — no scroll-level ignore needed. // Composer is overlay (not safeAreaInset), so no .container ignore needed either. @@ -675,6 +722,25 @@ private extension ChatDetailView { guard focused else { return } SessionManager.shared.recordUserInteraction() } + // Scroll-to-reply: navigate to the original message and highlight it briefly. + .onChange(of: scrollToMessageId) { _, targetId in + guard let targetId else { return } + scrollToMessageId = nil + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(targetId, anchor: .center) + } + // Brief highlight glow after scroll completes. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + withAnimation(.easeIn(duration: 0.2)) { + highlightedMessageId = targetId + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + withAnimation(.easeOut(duration: 0.5)) { + highlightedMessageId = nil + } + } + } + } // No keyboard scroll handlers needed — inverted scroll keeps bottom anchored. scroll .scrollIndicators(.hidden) @@ -723,50 +789,185 @@ private extension ChatDetailView { // Desktop parity: render image, file, and avatar attachments in the bubble. let visibleAttachments = message.attachments.filter { $0.type == .image || $0.type == .file || $0.type == .avatar } - if visibleAttachments.isEmpty { - // Text-only message (original path) - textOnlyBubble(message: message, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) - } else { - // Attachment message: images/files + optional caption - attachmentBubble(message: message, attachments: visibleAttachments, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) + Group { + if visibleAttachments.isEmpty { + // Text-only message (original path) + textOnlyBubble(message: message, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) + } else { + // Attachment message: images/files + optional caption + attachmentBubble(message: message, attachments: visibleAttachments, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) + } } + // Telegram-style swipe-to-reply: skip gesture entirely for system chats. + .modifier(ConditionalSwipeToReply( + enabled: !route.isSavedMessages && !route.isSystemAccount, + onReply: { + self.replyingToMessage = message + self.isInputFocused = true + } + )) + // Highlight overlay for scroll-to-reply navigation. + .overlay { + if highlightedMessageId == message.id { + RoundedRectangle(cornerRadius: 16) + .fill(Color.white.opacity(0.12)) + .allowsHitTesting(false) + } + } + .frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading) + .padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0) + .padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0) + .padding(.top, (position == .single || position == .top) ? 6 : 2) + .padding(.bottom, 0) } /// Text-only message bubble (original design). + /// If the message has a MESSAGES attachment (reply/forward), shows the quoted message above text. @ViewBuilder private func textOnlyBubble(message: ChatMessage, outgoing: Bool, hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View { let messageText = message.text.isEmpty ? " " : message.text + let replyAttachment = message.attachments.first(where: { $0.type == .messages }) + let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) }?.first - // Telegram-style compact bubble: inline time+status at bottom-trailing. - // Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming). - Text(parsedMarkdown(messageText)) - .font(.system(size: 17, weight: .regular)) - .tracking(-0.43) - .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) - .multilineTextAlignment(.leading) - .lineSpacing(0) - .fixedSize(horizontal: false, vertical: true) - .padding(.leading, 11) - .padding(.trailing, outgoing ? 64 : 48) - .padding(.vertical, 5) - .frame(minWidth: outgoing ? 86 : 66, alignment: .leading) - .overlay(alignment: .bottomTrailing) { - timestampOverlay(message: message, outgoing: outgoing) + VStack(alignment: .leading, spacing: 0) { + // Reply/forward quote (if present) + if let reply = replyData { + replyQuoteView(reply: reply, outgoing: outgoing) } - // Tail protrusion space: the unified shape draws the tail in this padding area - .padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) - .padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) - // Single unified background: body + tail drawn in one fill (no seam) - .background { bubbleBackground(outgoing: outgoing, position: position) } - .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) - .frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading) - .padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0) - .padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0) - .padding(.top, (position == .single || position == .top) ? 6 : 2) - .padding(.bottom, 0) + + // Telegram-style compact bubble: inline time+status at bottom-trailing. + // Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming). + Text(parsedMarkdown(messageText)) + .font(.system(size: 17, weight: .regular)) + .tracking(-0.43) + .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) + .multilineTextAlignment(.leading) + .lineSpacing(0) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 11) + .padding(.trailing, outgoing ? 64 : 48) + .padding(.vertical, 5) + } + .frame(minWidth: outgoing ? 86 : 66, alignment: .leading) + .overlay(alignment: .bottomTrailing) { + timestampOverlay(message: message, outgoing: outgoing) + } + // Tail protrusion space: the unified shape draws the tail in this padding area + .padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) + .padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) + // Single unified background: body + tail drawn in one fill (no seam) + .background { bubbleBackground(outgoing: outgoing, position: position) } + .overlay { + BubbleContextMenuOverlay( + actions: bubbleActions(for: message), + previewShape: MessageBubbleShape(position: position, outgoing: outgoing), + readStatusText: contextMenuReadStatus(for: message), + replyQuoteHeight: replyData != nil ? 46 : 0, + onReplyQuoteTap: replyData.map { reply in + { [reply] in self.scrollToMessageId = reply.message_id } + } + ) + } + .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) + } + + /// PERF: static cache for decoded reply blobs — avoids JSON decode on every re-render. + @MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:] + + /// Parses a decrypted MESSAGES blob into `ReplyMessageData` array. + private func parseReplyBlob(_ blob: String) -> [ReplyMessageData]? { + guard !blob.isEmpty else { return nil } + if let cached = Self.replyBlobCache[blob] { return cached } + guard let data = blob.data(using: .utf8) else { return nil } + guard let result = try? JSONDecoder().decode([ReplyMessageData].self, from: data) else { return nil } + if Self.replyBlobCache.count > 200 { Self.replyBlobCache.removeAll(keepingCapacity: true) } + Self.replyBlobCache[blob] = result + return result + } + + /// Telegram-style reply quote rendered above message text inside the bubble. + /// Matches Figma spec: 4px corners, 3px accent bar, 15pt font, semi-transparent bg. + /// Tapping scrolls to the original message and briefly highlights it. + @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 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 }) + let blurHash: String? = { + guard let att = imageAttachment, !att.preview.isEmpty else { return nil } + let parts = att.preview.components(separatedBy: "::") + let hash = parts.count > 1 ? parts[1] : att.preview + return hash.isEmpty ? nil : hash + }() + + // Tap is handled at UIKit level via BubbleContextMenuOverlay.onReplyQuoteTap. + HStack(spacing: 0) { + // 3px accent bar + RoundedRectangle(cornerRadius: 1.5) + .fill(accentColor) + .frame(width: 3) + .padding(.vertical, 4) + + // Optional image thumbnail for media replies (32×32) + // PERF: uses static cache — BlurHash decode is expensive (DCT transform). + if let hash = blurHash, + let image = Self.cachedBlurHash(hash, width: 32, height: 32) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(.leading, 6) + } + + VStack(alignment: .leading, spacing: 1) { + Text(senderName) + .font(.system(size: 15, weight: .semibold)) + .tracking(-0.23) + .foregroundStyle(outgoing ? Color.white.opacity(0.85) : RosettaColors.figmaBlue) + .lineLimit(1) + Text(previewText) + .font(.system(size: 15, weight: .regular)) + .tracking(-0.23) + .foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + } + .padding(.leading, 6) + + Spacer(minLength: 0) + } + .frame(height: 41) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06)) + ) + .padding(.horizontal, 5) + .padding(.top, 5) + .padding(.bottom, 0) + } + + /// Resolves a public key to a display name for reply/forward quotes. + /// PERF: avoids reading DialogRepository.shared.dialogs (Observable) in the + /// body path — uses route data instead. Only the current opponent is resolved. + private func senderDisplayName(for publicKey: String) -> String { + if publicKey == currentPublicKey { + return "You" + } + // Current chat opponent — use route (non-observable, stable). + if publicKey == route.publicKey { + return route.title.isEmpty ? String(publicKey.prefix(8)) + "…" : route.title + } + return String(publicKey.prefix(8)) + "…" } /// Attachment message bubble: images/files with optional text caption. + /// + /// Telegram-style layout: + /// - **Image-only**: image fills bubble edge-to-edge, timestamp overlaid as dark pill + /// - **Image + text**: image at top, caption below, normal timestamp in caption area + /// - **File/Avatar**: padded inside bubble, normal timestamp @ViewBuilder private func attachmentBubble( message: ChatMessage, @@ -777,26 +978,32 @@ private extension ChatDetailView { position: BubblePosition ) -> some View { let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " " + let imageAttachments = attachments.filter { $0.type == .image } + let otherAttachments = attachments.filter { $0.type != .image } + let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption VStack(alignment: .leading, spacing: 0) { - // Attachment views - ForEach(attachments, id: \.id) { attachment in + // Image attachments — Telegram-style collage layout + if !imageAttachments.isEmpty { + PhotoCollageView( + attachments: imageAttachments, + message: message, + outgoing: outgoing, + maxWidth: maxBubbleWidth - (hasTail ? MessageBubbleShape.tailProtrusion : 0), + position: position + ) + } + + // Non-image attachments (file, avatar) — padded + ForEach(otherAttachments, id: \.id) { attachment in switch attachment.type { - case .image: - MessageImageView( - attachment: attachment, - message: message, - outgoing: outgoing, - maxWidth: maxBubbleWidth - ) - .padding(.horizontal, 4) - .padding(.top, 4) case .file: MessageFileView( attachment: attachment, message: message, outgoing: outgoing ) + .padding(.horizontal, 4) .padding(.top, 4) case .avatar: MessageAvatarView( @@ -811,7 +1018,7 @@ private extension ChatDetailView { } } - // Caption text (if any) + // Caption text below image if hasCaption { Text(parsedMarkdown(message.text)) .font(.system(size: 17, weight: .regular)) @@ -822,23 +1029,37 @@ private extension ChatDetailView { .fixedSize(horizontal: false, vertical: true) .padding(.leading, 11) .padding(.trailing, outgoing ? 64 : 48) - .padding(.top, 4) + .padding(.top, 6) .padding(.bottom, 5) } } .frame(minWidth: outgoing ? 86 : 66, alignment: .leading) .overlay(alignment: .bottomTrailing) { - timestampOverlay(message: message, outgoing: outgoing) + if isImageOnly { + // Telegram-style: dark pill overlay on image + 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) .background { bubbleBackground(outgoing: outgoing, position: position) } + .clipShape(MessageBubbleShape(position: position, outgoing: outgoing)) + .overlay { + BubbleContextMenuOverlay( + actions: bubbleActions(for: message), + previewShape: MessageBubbleShape(position: position, outgoing: outgoing), + readStatusText: contextMenuReadStatus(for: message), + onTap: !imageAttachments.isEmpty ? { + // Open the first image attachment in fullscreen viewer + if let firstImage = imageAttachments.first { + fullScreenAttachmentId = firstImage.id + } + } : nil + ) + } .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) - .frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading) - .padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0) - .padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0) - .padding(.top, (position == .single || position == .top) ? 6 : 2) - .padding(.bottom, 0) } /// Timestamp + delivery status overlay for both text and attachment bubbles. @@ -865,6 +1086,46 @@ private extension ChatDetailView { .padding(.bottom, 5) } + /// Figma "Media=True" timestamp: dark semi-transparent pill overlaid on images. + @ViewBuilder + private func mediaTimestampOverlay(message: ChatMessage, outgoing: Bool) -> some View { + HStack(spacing: 3) { + Text(messageTime(message.timestamp)) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.white) + + if outgoing { + if message.deliveryStatus == .error { + errorMenu(for: message) + } else { + mediaDeliveryIndicator(message.deliveryStatus) + } + } + } + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.black.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(.trailing, 6) + .padding(.bottom, 6) + } + + // MARK: - BlurHash Cache + + /// PERF: static cache for decoded BlurHash images. Hash strings are immutable, + /// so results never need invalidation. Avoids DCT decode on every re-render. + @MainActor private static var blurHashCache: [String: UIImage] = [:] + + @MainActor + private static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? { + let key = "\(hash)_\(width)x\(height)" + if let cached = blurHashCache[key] { return cached } + guard let image = UIImage.fromBlurHash(hash, width: width, height: height) else { return nil } + if blurHashCache.count > 100 { blurHashCache.removeAll(keepingCapacity: true) } + blurHashCache[key] = image + return image + } + // MARK: - Text Parsing (Markdown + Emoji) /// Static cache for parsed markdown + emoji. Message text is immutable, @@ -919,6 +1180,11 @@ private extension ChatDetailView { var composer: some View { VStack(spacing: 6) { + // Reply preview bar (Telegram-style) + if let replyMessage = replyingToMessage { + replyBar(for: replyMessage) + } + // Attachment preview strip — shows selected images/files before send if !pendingAttachments.isEmpty { AttachmentPreviewStrip(pendingAttachments: $pendingAttachments) @@ -1200,6 +1466,29 @@ private extension ChatDetailView { } } + /// Delivery indicator with white tint for on-image media overlay. + @ViewBuilder + func mediaDeliveryIndicator(_ status: DeliveryStatus) -> some View { + switch status { + case .read: + DoubleCheckmarkShape() + .fill(Color.white) + .frame(width: 16, height: 8.7) + case .delivered: + SingleCheckmarkShape() + .fill(Color.white.opacity(0.8)) + .frame(width: 12, height: 8.8) + case .waiting: + Image(systemName: "clock") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + case .error: + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(RosettaColors.error) + } + } + @ViewBuilder func errorMenu(for message: ChatMessage) -> some View { Menu { @@ -1220,6 +1509,139 @@ private extension ChatDetailView { } } + // MARK: - Context Menu + + /// Clean bubble preview for context menu — no `.frame(maxWidth: .infinity)`, no outer paddings. + @ViewBuilder + func bubblePreview(message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View { + let outgoing = message.isFromMe(myPublicKey: currentPublicKey) + let hasTail = position == .single || position == .bottom + let visibleAttachments = message.attachments.filter { $0.type == .image || $0.type == .file || $0.type == .avatar } + + if visibleAttachments.isEmpty { + let messageText = message.text.isEmpty ? " " : message.text + Text(parsedMarkdown(messageText)) + .font(.system(size: 17, weight: .regular)) + .tracking(-0.43) + .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) + .multilineTextAlignment(.leading) + .lineSpacing(0) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 11) + .padding(.trailing, outgoing ? 64 : 48) + .padding(.vertical, 5) + .frame(minWidth: outgoing ? 86 : 66, alignment: .leading) + .overlay(alignment: .bottomTrailing) { + timestampOverlay(message: message, outgoing: outgoing) + } + .padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) + .padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) + .background { bubbleBackground(outgoing: outgoing, position: position) } + .contentShape(.contextMenuPreview, MessageBubbleShape(position: position, outgoing: outgoing)) + .frame(maxWidth: maxBubbleWidth) + } else { + // Attachment preview — reuse full bubble, clip to shape + let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " " + let imageAttachments = visibleAttachments.filter { $0.type == .image } + let otherAttachments = visibleAttachments.filter { $0.type != .image } + let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption + + VStack(alignment: .leading, spacing: 0) { + if !imageAttachments.isEmpty { + PhotoCollageView( + attachments: imageAttachments, + message: message, + outgoing: outgoing, + maxWidth: maxBubbleWidth - (hasTail ? MessageBubbleShape.tailProtrusion : 0), + position: position + ) + } + ForEach(otherAttachments, id: \.id) { attachment in + switch attachment.type { + case .file: + MessageFileView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 4).padding(.top, 4) + case .avatar: + MessageAvatarView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 6).padding(.top, 4) + default: EmptyView() + } + } + if hasCaption { + Text(parsedMarkdown(message.text)) + .font(.system(size: 17, weight: .regular)) + .tracking(-0.43) + .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) + .multilineTextAlignment(.leading) + .lineSpacing(0) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 11) + .padding(.trailing, outgoing ? 64 : 48) + .padding(.top, 6) + .padding(.bottom, 5) + } + } + .frame(minWidth: outgoing ? 86 : 66, alignment: .leading) + .overlay(alignment: .bottomTrailing) { + if isImageOnly { + 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) + .background { bubbleBackground(outgoing: outgoing, position: position) } + .clipShape(MessageBubbleShape(position: position, outgoing: outgoing)) + .contentShape(.contextMenuPreview, MessageBubbleShape(position: position, outgoing: outgoing)) + .frame(maxWidth: maxBubbleWidth) + } + } + + private func contextMenuReadStatus(for message: ChatMessage) -> String? { + let outgoing = message.isFromMe(myPublicKey: currentPublicKey) + guard outgoing, message.deliveryStatus == .read else { return nil } + return "Read" + } + + func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] { + var actions: [BubbleContextAction] = [] + + actions.append(BubbleContextAction( + title: "Reply", + image: UIImage(systemName: "arrowshape.turn.up.left"), + role: [] + ) { + self.replyingToMessage = message + self.isInputFocused = true + }) + + actions.append(BubbleContextAction( + title: "Copy", + image: UIImage(systemName: "doc.on.doc"), + role: [] + ) { + UIPasteboard.general.string = message.text + }) + + actions.append(BubbleContextAction( + title: "Forward", + image: UIImage(systemName: "arrowshape.turn.up.right"), + role: [] + ) { + self.forwardingMessage = message + self.showForwardPicker = true + }) + + actions.append(BubbleContextAction( + title: "Delete", + image: UIImage(systemName: "trash"), + role: .destructive + ) { + self.messageToDelete = message + }) + + return actions + } + func retryMessage(_ message: ChatMessage) { let text = message.text let toKey = message.toPublicKey @@ -1235,8 +1657,107 @@ private extension ChatDetailView { DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey) } + // MARK: - Reply Bar + + @ViewBuilder + func replyBar(for message: ChatMessage) -> some View { + let senderName = message.isFromMe(myPublicKey: currentPublicKey) + ? "You" + : (dialog?.opponentTitle ?? route.title) + let previewText = message.text.isEmpty + ? (message.attachments.isEmpty ? "" : "Attachment") + : message.text + + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 1.5) + .fill(RosettaColors.figmaBlue) + .frame(width: 3, height: 36) + + VStack(alignment: .leading, spacing: 1) { + Text(senderName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(RosettaColors.figmaBlue) + .lineLimit(1) + Text(previewText) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + } + .padding(.leading, 8) + + Spacer() + + Button { + withAnimation(.easeOut(duration: 0.15)) { + replyingToMessage = nil + } + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .frame(width: 30, height: 30) + } + } + .padding(.horizontal, 16) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // 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) + 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], + toPublicKey: targetKey, + opponentTitle: targetRoute.title, + opponentUsername: targetRoute.username + ) + } catch { + sendError = "Failed to forward message" + } + } + } + + /// Builds a `ReplyMessageData` from a `ChatMessage` for reply/forward encoding. + /// Desktop parity: `MessageReply` in `useReplyMessages.ts`. + private func buildReplyData(from message: ChatMessage) -> ReplyMessageData { + // Convert ChatMessage attachments to ReplyAttachmentData (text-only for now) + let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in + // Skip MESSAGES attachments in nested replies (don't nest replies recursively) + guard att.type != .messages else { return nil } + return ReplyAttachmentData( + id: att.id, + type: att.type.rawValue, + preview: att.preview, + blob: "" // Blob cleared for reply (desktop parity) + ) + } + + return ReplyMessageData( + message_id: message.id, + publicKey: message.fromPublicKey, + message: message.text, + timestamp: message.timestamp, + attachments: replyAttachments + ) + } + + /// PERF: static cache for formatted timestamps — avoids Date + DateFormatter per cell per render. + @MainActor private static var timeCache: [Int64: String] = [:] + func messageTime(_ timestamp: Int64) -> String { - Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000)) + if let cached = Self.timeCache[timestamp] { return cached } + let result = Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000)) + if Self.timeCache.count > 200 { Self.timeCache.removeAll(keepingCapacity: true) } + Self.timeCache[timestamp] = result + return result } func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { @@ -1297,6 +1818,7 @@ private extension ChatDetailView { func sendCurrentMessage() { let message = trimmedMessage let attachments = pendingAttachments + let replyMessage = replyingToMessage // Must have either text or attachments guard !message.isEmpty || !attachments.isEmpty else { return } @@ -1306,6 +1828,7 @@ private extension ChatDetailView { shouldScrollOnNextMessage = true messageText = "" pendingAttachments = [] + replyingToMessage = nil sendError = nil // Desktop parity: delete draft after sending. DraftManager.shared.deleteDraft(for: route.publicKey) @@ -1321,6 +1844,16 @@ private extension ChatDetailView { opponentTitle: route.title, opponentUsername: route.username ) + } else if let replyMsg = replyMessage { + // Desktop parity: reply sends MESSAGES attachment with quoted message JSON + let replyData = buildReplyData(from: replyMsg) + try await SessionManager.shared.sendMessageWithReply( + text: message, + replyMessages: [replyData], + toPublicKey: route.publicKey, + opponentTitle: route.title, + opponentUsername: route.username + ) } else { // Text-only message (existing path) try await SessionManager.shared.sendMessage( @@ -1465,6 +1998,8 @@ enum TelegramIconPath { static let chevronDown = #"M11.8854 11.6408C11.3964 12.1197 10.6036 12.1197 10.1145 11.6408L0.366765 2.09366C-0.122255 1.61471 -0.122255 0.838169 0.366765 0.359215C0.855786 -0.119739 1.64864 -0.119739 2.13767 0.359215L11 9.03912L19.8623 0.359215C20.3514 -0.119739 21.1442 -0.119739 21.6332 0.359215C22.1223 0.838169 22.1223 1.61471 21.6332 2.09366L11.8854 11.6408Z"# + static let replyArrow = #"M7.73438 12.6367C7.8125 12.5586 7.87109 12.4674 7.91016 12.3633C7.94922 12.2721 7.96875 12.168 7.96875 12.0508V9.375C9.375 9.375 10.5599 9.58333 11.5234 10C12.6172 10.4948 13.4635 11.276 14.0625 12.3438C14.1536 12.513 14.2773 12.6432 14.4336 12.7344C14.5768 12.8255 14.7526 12.8711 14.9609 12.8711C15.1172 12.8711 15.2604 12.819 15.3906 12.7148C15.4948 12.6237 15.5729 12.5065 15.625 12.3633C15.6771 12.2201 15.7031 12.0768 15.7031 11.9336C15.7031 10.6185 15.5599 9.45312 15.2734 8.4375C14.974 7.39583 14.5182 6.51042 13.9062 5.78125C13.6068 5.41667 13.2617 5.09115 12.8711 4.80469C12.4805 4.53125 12.0508 4.29688 11.582 4.10156C11.0482 3.88021 10.4557 3.72396 9.80469 3.63281C9.27083 3.55469 8.65885 3.51562 7.96875 3.51562V0.859375C7.96875 0.703125 7.92969 0.559896 7.85156 0.429688C7.78646 0.299479 7.68229 0.195312 7.53906 0.117188C7.40885 0.0390625 7.26562 0 7.10938 0C6.92708 0 6.74479 0.0520833 6.5625 0.15625C6.43229 0.234375 6.28255 0.351562 6.11328 0.507812L0.371094 5.68359C0.292969 5.7487 0.234375 5.8138 0.195312 5.87891C0.143229 5.94401 0.104167 6.00911 0.078125 6.07422C0.0520833 6.13932 0.0325521 6.19792 0.0195312 6.25C0.00651042 6.3151 0 6.3737 0 6.42578C0 6.49089 0.00651042 6.55599 0.0195312 6.62109C0.0325521 6.67318 0.0520833 6.73177 0.078125 6.79688C0.104167 6.86198 0.143229 6.92708 0.195312 6.99219C0.234375 7.05729 0.292969 7.1224 0.371094 7.1875L6.11328 12.4023C6.29557 12.5716 6.46484 12.6888 6.62109 12.7539C6.69922 12.793 6.77734 12.819 6.85547 12.832C6.94661 12.8581 7.03125 12.8711 7.10938 12.8711C7.22656 12.8711 7.33724 12.8516 7.44141 12.8125C7.55859 12.7734 7.65625 12.7148 7.73438 12.6367Z"# + static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"# } @@ -1480,6 +2015,21 @@ private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier { } } +/// iOS 26: scroll edge blur is on by default — in inverted scroll (scaleEffect y: -1) +/// both top+bottom edge effects overlap and blur the entire screen. +/// Hide only the ScrollView's top edge (= visual bottom after inversion, near composer). +/// Keep ScrollView's bottom edge (= visual top after inversion, near nav bar) for a +/// nice fade effect when scrolling through older messages. +private struct DisableScrollEdgeEffectModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content.scrollEdgeEffectHidden(true, for: .top) + } else { + content + } + } +} + #Preview { NavigationStack { diff --git a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift new file mode 100644 index 0000000..99dc6fa --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +/// Sheet for picking a chat to forward a message to. +/// Shows all existing dialogs sorted by last message time. +struct ForwardChatPickerView: View { + let onSelect: (ChatRoute) -> Void + @Environment(\.dismiss) private var dismiss + + private var dialogs: [Dialog] { + DialogRepository.shared.sortedDialogs.filter { + $0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey) + } + } + + var body: some View { + let _ = print("[ForwardPicker] body — dialogs count: \(dialogs.count)") + NavigationStack { + List(dialogs) { dialog in + Button { + onSelect(ChatRoute(dialog: dialog)) + } label: { + HStack(spacing: 12) { + AvatarView( + initials: dialog.initials, + colorIndex: dialog.avatarColorIndex, + size: 42, + isSavedMessages: dialog.isSavedMessages + ) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + if dialog.effectiveVerified > 0 && !dialog.isSavedMessages { + VerifiedBadge(verified: dialog.effectiveVerified, size: 14) + } + } + + if !dialog.opponentUsername.isEmpty && !dialog.isSavedMessages { + Text("@\(dialog.opponentUsername)") + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + } + } + + Spacer() + } + .contentShape(Rectangle()) + } + .listRowBackground(RosettaColors.Dark.surface) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(RosettaColors.Dark.background) + .navigationTitle("Forward to...") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + } + .preferredColorScheme(.dark) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift b/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift new file mode 100644 index 0000000..6c9caf3 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift @@ -0,0 +1,204 @@ +import SwiftUI + +// MARK: - FullScreenImageViewer + +/// Full-screen image viewer with pinch zoom, double-tap zoom, and swipe-to-dismiss. +/// +/// Android parity: `ImageViewerScreen.kt` — zoom (1x–5x), double-tap (2.5x), +/// vertical swipe dismiss, background fade, tap to toggle controls. +struct FullScreenImageViewer: View { + + let image: UIImage + let onDismiss: () -> Void + + /// Current zoom scale (1.0 = fit, up to maxScale). + @State private var scale: CGFloat = 1.0 + @State private var lastScale: CGFloat = 1.0 + + /// Pan offset when zoomed. + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + + /// Vertical drag offset for dismiss gesture (only when not zoomed). + @State private var dismissOffset: CGFloat = 0 + + /// Whether the UI controls (close button) are visible. + @State private var showControls = true + + private let minScale: CGFloat = 1.0 + private let maxScale: CGFloat = 5.0 + private let doubleTapScale: CGFloat = 2.5 + private let dismissThreshold: CGFloat = 150 + + var body: some View { + ZStack { + // Background: fades as user drags to dismiss + Color.black + .opacity(backgroundOpacity) + .ignoresSafeArea() + + // Zoomable image + Image(uiImage: image) + .resizable() + .scaledToFit() + .scaleEffect(scale) + .offset(x: offset.width, y: offset.height + dismissOffset) + .gesture(dragGesture) + .gesture(pinchGesture) + .onTapGesture(count: 2) { + doubleTap() + } + .onTapGesture(count: 1) { + withAnimation(.easeInOut(duration: 0.2)) { + showControls.toggle() + } + } + + // Close button + if showControls { + VStack { + HStack { + Spacer() + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(Color.white.opacity(0.2)) + .clipShape(Circle()) + } + .padding(.trailing, 16) + .padding(.top, 8) + } + Spacer() + } + .transition(.opacity) + } + } + } + + // MARK: - Background Opacity + + private var backgroundOpacity: Double { + let progress = min(abs(dismissOffset) / 300, 1.0) + return 1.0 - progress * 0.6 + } + + // MARK: - Double Tap Zoom + + private func doubleTap() { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + if scale > 1.05 { + scale = 1.0 + lastScale = 1.0 + offset = .zero + lastOffset = .zero + } else { + scale = doubleTapScale + lastScale = doubleTapScale + offset = .zero + lastOffset = .zero + } + } + } + + // MARK: - Pinch Gesture + + private var pinchGesture: some Gesture { + MagnificationGesture() + .onChanged { value in + let newScale = lastScale * value + scale = min(max(newScale, minScale * 0.5), maxScale) + } + .onEnded { _ in + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + if scale < minScale { scale = minScale } + lastScale = scale + if scale <= 1.0 { + offset = .zero + lastOffset = .zero + } + } + } + } + + // MARK: - Drag Gesture + + private var dragGesture: some Gesture { + DragGesture() + .onChanged { value in + if scale > 1.05 { + offset = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + } else { + dismissOffset = value.translation.height + } + } + .onEnded { _ in + if scale > 1.05 { + lastOffset = offset + } else { + if abs(dismissOffset) > dismissThreshold { + onDismiss() + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + dismissOffset = 0 + } + } + } + } + } +} + +// MARK: - FullScreenImageFromCache + +/// Wrapper that loads an image from `AttachmentCache` by attachment ID and +/// presents it in `FullScreenImageViewer`. Handles cache-miss gracefully. +/// +/// Used as `fullScreenCover` content — the attachment ID is a stable value +/// passed as a parameter, avoiding @State capture issues with UIImage. +struct FullScreenImageFromCache: View { + let attachmentId: String + let onDismiss: () -> Void + + var body: some View { + if let image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) { + FullScreenImageViewer(image: image, onDismiss: onDismiss) + } else { + // Cache miss — show error with close button + ZStack { + Color.black.ignoresSafeArea() + VStack(spacing: 16) { + Image(systemName: "photo") + .font(.system(size: 48)) + .foregroundStyle(.white.opacity(0.3)) + Text("Image not available") + .font(.system(size: 15)) + .foregroundStyle(.white.opacity(0.5)) + } + VStack { + HStack { + Spacer() + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(Color.white.opacity(0.2)) + .clipShape(Circle()) + } + .padding(.trailing, 16) + .padding(.top, 8) + } + Spacer() + } + } + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift index 1cd4e04..6a46771 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift @@ -130,13 +130,11 @@ struct MessageAvatarView: View { return } - guard let password = message.attachmentPassword, !password.isEmpty else { - print("🎭 [AvatarView] NO password for attachment \(attachment.id)") + guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { downloadError = true return } - print("🎭 [AvatarView] Downloading avatar \(attachment.id), tag=\(tag.prefix(20))…") isDownloading = true downloadError = false @@ -145,32 +143,11 @@ struct MessageAvatarView: View { let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) let encryptedString = String(decoding: encryptedData, as: UTF8.self) - let decryptedData = try CryptoManager.shared.decryptWithPassword( - encryptedString, password: password + let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) + let downloadedImage = decryptAndParseImage( + encryptedString: encryptedString, passwords: passwords ) - guard let decryptedString = String(data: decryptedData, encoding: .utf8) else { - throw TransportError.invalidResponse - } - - let downloadedImage: UIImage? - if decryptedString.hasPrefix("data:") { - if let commaIndex = decryptedString.firstIndex(of: ",") { - let base64Part = String(decryptedString[decryptedString.index(after: commaIndex)...]) - if let imageData = Data(base64Encoded: base64Part) { - downloadedImage = UIImage(data: imageData) - } else { - downloadedImage = nil - } - } else { - downloadedImage = nil - } - } else if let imageData = Data(base64Encoded: decryptedString) { - downloadedImage = UIImage(data: imageData) - } else { - downloadedImage = UIImage(data: decryptedData) - } - await MainActor.run { if let downloadedImage { avatarImage = downloadedImage @@ -189,6 +166,43 @@ struct MessageAvatarView: View { } } + /// Tries each password candidate and validates the decrypted content is a real image. + private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? { + let crypto = CryptoManager.shared + for password in passwords { + guard let data = try? crypto.decryptWithPassword( + encryptedString, password: password, requireCompression: true + ) else { continue } + if let img = parseImageData(data) { return img } + } + // Fallback: try without requireCompression (legacy uncompressed payloads) + for password in passwords { + guard let data = try? crypto.decryptWithPassword( + encryptedString, password: password + ) else { continue } + if let img = parseImageData(data) { return img } + } + return nil + } + + /// Parses decrypted data as an image: data URI, plain base64, or raw image bytes. + private func parseImageData(_ data: Data) -> UIImage? { + if let str = String(data: data, encoding: .utf8) { + if str.hasPrefix("data:"), + let commaIndex = str.firstIndex(of: ",") { + let base64Part = String(str[str.index(after: commaIndex)...]) + if let imageData = Data(base64Encoded: base64Part), + let img = UIImage(data: imageData) { + return img + } + } else if let imageData = Data(base64Encoded: str), + let img = UIImage(data: imageData) { + return img + } + } + return UIImage(data: data) + } + /// Extracts the server tag from preview string. /// Format: "tag::blurhash" → returns "tag". private func extractTag(from preview: String) -> String { diff --git a/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift b/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift index afb1236..eb04bba 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift @@ -130,7 +130,7 @@ struct MessageFileView: View { private func downloadFile() { guard !isDownloading, !fileTag.isEmpty else { return } - guard let password = message.attachmentPassword, !password.isEmpty else { + guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { downloadError = true return } @@ -142,9 +142,12 @@ struct MessageFileView: View { do { let encryptedData = try await TransportManager.shared.downloadFile(tag: fileTag) let encryptedString = String(decoding: encryptedData, as: UTF8.self) - let decryptedData = try CryptoManager.shared.decryptWithPassword( - encryptedString, password: password + + let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) + let decryptedData = decryptFileData( + encryptedString: encryptedString, passwords: passwords ) + guard let decryptedData else { throw TransportError.invalidResponse } // Parse data URI if present, otherwise use raw data let fileData: Data @@ -175,6 +178,27 @@ struct MessageFileView: View { } } + /// Tries each password candidate with requireCompression to avoid wrong-key garbage. + private func decryptFileData(encryptedString: String, passwords: [String]) -> Data? { + let crypto = CryptoManager.shared + for password in passwords { + if let data = try? crypto.decryptWithPassword( + encryptedString, password: password, requireCompression: true + ) { + return data + } + } + // Fallback: try without requireCompression (legacy uncompressed payloads) + for password in passwords { + if let data = try? crypto.decryptWithPassword( + encryptedString, password: password + ) { + return data + } + } + return nil + } + // MARK: - Share private func shareFile(_ url: URL) { diff --git a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift index 7a97021..bad5983 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift @@ -7,9 +7,13 @@ import SwiftUI /// Desktop parity: `MessageImage.tsx` — shows blur placeholder while downloading, /// full image after download, "Image expired" on error. /// +/// Modes: +/// - **Standalone** (`collageSize == nil`): uses own min/max constraints + aspect ratio. +/// - **Collage cell** (`collageSize != nil`): fills the given frame (parent controls size). +/// /// States: /// 1. **Cached** — image already in AttachmentCache, display immediately -/// 2. **Downloading** — show placeholder + spinner +/// 2. **Downloading** — show blurhash placeholder + spinner /// 3. **Downloaded** — display image, tap for full-screen (future) /// 4. **Error** — "Image expired" or download error struct MessageImageView: View { @@ -17,51 +21,53 @@ struct MessageImageView: View { let attachment: MessageAttachment let message: ChatMessage let outgoing: Bool + + /// When set, the image fills this exact frame (used inside PhotoCollageView). + /// When nil, standalone mode with own size constraints. + var collageSize: CGSize? = nil + let maxWidth: CGFloat + /// Called when user taps a loaded image (opens full-screen viewer). + var onImageTap: ((UIImage) -> Void)? + @State private var image: UIImage? + @State private var blurImage: UIImage? @State private var isDownloading = false @State private var downloadError = false - /// Desktop parity: image bubble max dimensions. - private let maxImageWidth: CGFloat = 240 - private let maxImageHeight: CGFloat = 280 + /// Whether this image is inside a collage (fills parent frame). + private var isCollageCell: Bool { collageSize != nil } + + /// Telegram-style image constraints (standalone mode only). + private let maxImageWidth: CGFloat = 270 + private let maxImageHeight: CGFloat = 320 + private let minImageWidth: CGFloat = 140 + private let minImageHeight: CGFloat = 100 + + /// Default placeholder size (standalone mode). + private let placeholderWidth: CGFloat = 200 + private let placeholderHeight: CGFloat = 200 var body: some View { Group { if let image { - Image(uiImage: image) - .resizable() - .scaledToFit() - .frame(maxWidth: min(maxImageWidth, maxWidth - 20)) - .frame(maxHeight: maxImageHeight) - .clipShape(RoundedRectangle(cornerRadius: 12)) + imageContent(image) } else if isDownloading { - placeholder - .overlay { ProgressView().tint(.white) } + placeholderView + .overlay { downloadingOverlay } } else if downloadError { - placeholder - .overlay { - VStack(spacing: 4) { - Image(systemName: "flame.fill") - .font(.system(size: 20)) - .foregroundStyle(.white.opacity(0.5)) - Text("Image expired") - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.4)) - } - } + placeholderView + .overlay { errorOverlay } } else { - placeholder - .overlay { - Image(systemName: "arrow.down.circle") - .font(.system(size: 24)) - .foregroundStyle(.white.opacity(0.6)) - } + placeholderView + .overlay { downloadArrowOverlay } .onTapGesture { downloadImage() } } } .task { + // Decode blurhash once (like Android's LaunchedEffect + Dispatchers.IO) + decodeBlurHash() loadFromCache() if image == nil { downloadImage() @@ -69,12 +75,121 @@ struct MessageImageView: View { } } + // MARK: - Overlay States (Desktop parity: MessageImage.tsx) + + /// Desktop: dark 40x40 circle with ProgressView spinner. + private var downloadingOverlay: some View { + Circle() + .fill(Color.black.opacity(0.3)) + .frame(width: 40, height: 40) + .overlay { + ProgressView() + .tint(.white) + .scaleEffect(0.9) + } + } + + /// Desktop: dark rounded pill with "Image expired" + flame icon. + private var errorOverlay: some View { + HStack(spacing: 4) { + Text("Image expired") + .font(.system(size: 11)) + .foregroundStyle(.white) + Image(systemName: "flame.fill") + .font(.system(size: 12)) + .foregroundStyle(.white) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.black.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + /// Desktop: dark 40x40 circle with download arrow icon. + private var downloadArrowOverlay: some View { + Circle() + .fill(Color.black.opacity(0.3)) + .frame(width: 40, height: 40) + .overlay { + Image(systemName: "arrow.down") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.white) + } + } + + // MARK: - Image Content + + @ViewBuilder + private func imageContent(_ img: UIImage) -> some View { + if let size = collageSize { + // Collage mode: fill the given cell frame + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: size.width, height: size.height) + .clipped() + .contentShape(Rectangle()) + .onTapGesture { onImageTap?(img) } + } else { + // Standalone mode: respect aspect ratio constraints + let size = constrainedSize(for: img) + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: size.width, height: size.height) + .clipped() + .contentShape(Rectangle()) + .onTapGesture { onImageTap?(img) } + } + } + + /// Calculates display size respecting min/max constraints and aspect ratio (standalone mode). + private func constrainedSize(for img: UIImage) -> CGSize { + let constrainedWidth = min(maxImageWidth, maxWidth) + let aspectRatio = img.size.width / max(img.size.height, 1) + let displayWidth = min(constrainedWidth, max(minImageWidth, img.size.width)) + let displayHeight = min(maxImageHeight, max(minImageHeight, displayWidth / aspectRatio)) + let finalWidth = min(constrainedWidth, displayHeight * aspectRatio) + return CGSize(width: finalWidth, height: displayHeight) + } + // MARK: - Placeholder - private var placeholder: some View { - RoundedRectangle(cornerRadius: 12) - .fill(Color.white.opacity(0.08)) - .frame(width: 200, height: 150) + @ViewBuilder + private var placeholderView: some View { + let size = resolvedPlaceholderSize + if let blurImage { + Image(uiImage: blurImage) + .resizable() + .scaledToFill() + .frame(width: size.width, height: size.height) + .clipped() + } else { + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(width: size.width, height: size.height) + } + } + + /// Placeholder size: collage cell size if in collage, otherwise square default. + private var resolvedPlaceholderSize: CGSize { + if let size = collageSize { + return size + } + let w = min(placeholderWidth, min(maxImageWidth, maxWidth)) + return CGSize(width: w, height: w) + } + + // MARK: - BlurHash Decoding + + /// Decodes the blurhash from the attachment preview string once and caches in @State. + /// Android parity: `LaunchedEffect(preview) { BlurHash.decode(preview, 200, 200) }`. + private func decodeBlurHash() { + let hash = extractBlurHash(from: attachment.preview) + guard !hash.isEmpty else { return } + if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) { + blurImage = result + } } // MARK: - Download @@ -88,80 +203,42 @@ struct MessageImageView: View { private func downloadImage() { guard !isDownloading, image == nil else { return } - // Extract tag from preview ("tag::blurhash" → tag) let tag = extractTag(from: attachment.preview) guard !tag.isEmpty else { - print("🖼️ [ImageView] tag is empty for attachment \(attachment.id)") downloadError = true return } - guard let password = message.attachmentPassword, !password.isEmpty else { - print("🖼️ [ImageView] NO password for attachment \(attachment.id), preview=\(attachment.preview.prefix(40))") + guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { downloadError = true return } - print("🖼️ [ImageView] Downloading attachment \(attachment.id), tag=\(tag.prefix(20))…, passwordLen=\(password.count)") - isDownloading = true downloadError = false Task { do { - // Download encrypted blob from transport server let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) let encryptedString = String(decoding: encryptedData, as: UTF8.self) - print("🖼️ [ImageView] Downloaded \(encryptedData.count) bytes, encryptedString.prefix=\(encryptedString.prefix(80))…") - print("🖼️ [ImageView] Password UTF-8 bytes: \(Array(password.utf8).prefix(20).map { String(format: "%02x", $0) }.joined(separator: " "))") - // Decrypt with attachment password - let decryptedData = try CryptoManager.shared.decryptWithPassword( - encryptedString, password: password + // Try each password candidate; validate decrypted content to avoid false positives + // from wrong-key AES-CBC that randomly produces valid PKCS7 + passable inflate. + let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) + let downloadedImage = decryptAndParseImage( + encryptedString: encryptedString, passwords: passwords ) - print("🖼️ [ImageView] Decrypted \(decryptedData.count) bytes, first20hex=\(decryptedData.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " "))") - - // Parse data URI → extract base64 → UIImage - guard let decryptedString = String(data: decryptedData, encoding: .utf8) else { - print("🖼️ [ImageView] ❌ Decrypted data is NOT valid UTF-8! first50hex=\(decryptedData.prefix(50).map { String(format: "%02x", $0) }.joined(separator: " "))") - throw TransportError.invalidResponse - } - - let downloadedImage: UIImage? - if decryptedString.hasPrefix("data:") { - // Data URI format: "data:image/jpeg;base64,..." - if let commaIndex = decryptedString.firstIndex(of: ",") { - let base64Part = String(decryptedString[decryptedString.index(after: commaIndex)...]) - if let imageData = Data(base64Encoded: base64Part) { - downloadedImage = UIImage(data: imageData) - } else { - downloadedImage = nil - } - } else { - downloadedImage = nil - } - } else if let imageData = Data(base64Encoded: decryptedString) { - // Plain base64 (fallback) - downloadedImage = UIImage(data: imageData) - } else { - // Raw image data - downloadedImage = UIImage(data: decryptedData) - } - await MainActor.run { if let downloadedImage { - print("🖼️ [ImageView] ✅ Image decoded successfully for \(attachment.id)") image = downloadedImage AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id) } else { - print("🖼️ [ImageView] ❌ Failed to decode image data for \(attachment.id)") downloadError = true } isDownloading = false } } catch { - print("🖼️ [ImageView] ❌ Error for \(attachment.id): \(error.localizedDescription)") await MainActor.run { downloadError = true isDownloading = false @@ -170,10 +247,59 @@ struct MessageImageView: View { } } + /// Tries each password candidate and validates the decrypted content is a real image. + private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? { + let crypto = CryptoManager.shared + for password in passwords { + guard let data = try? crypto.decryptWithPassword( + encryptedString, password: password, requireCompression: true + ) else { continue } + + if let img = parseImageData(data) { return img } + } + // Fallback: try without requireCompression (legacy uncompressed payloads) + for password in passwords { + guard let data = try? crypto.decryptWithPassword( + encryptedString, password: password + ) else { continue } + + if let img = parseImageData(data) { return img } + } + return nil + } + + /// Parses decrypted data as an image: data URI, plain base64, or raw image bytes. + private func parseImageData(_ data: Data) -> UIImage? { + if let str = String(data: data, encoding: .utf8) { + if str.hasPrefix("data:"), + let commaIndex = str.firstIndex(of: ",") { + let base64Part = String(str[str.index(after: commaIndex)...]) + if let imageData = Data(base64Encoded: base64Part), + let img = UIImage(data: imageData) { + return img + } + } else if let imageData = Data(base64Encoded: str), + let img = UIImage(data: imageData) { + return img + } + } + // Raw image data + return UIImage(data: data) + } + + // MARK: - Preview Parsing + /// Extracts the server tag from preview string. /// Format: "tag::blurhash" or "tag::" → returns "tag". private func extractTag(from preview: String) -> String { let parts = preview.components(separatedBy: "::") return parts.first ?? preview } + + /// 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] : "" + } } diff --git a/Rosetta/Features/Chats/ChatDetail/PhotoCollageView.swift b/Rosetta/Features/Chats/ChatDetail/PhotoCollageView.swift new file mode 100644 index 0000000..64a825b --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/PhotoCollageView.swift @@ -0,0 +1,243 @@ +import SwiftUI + +// MARK: - PhotoCollageView + +/// Telegram-style photo collage layout for 1–5 image attachments. +/// +/// Patterns: +/// - 1 photo: full width, aspect ratio preserved +/// - 2 photos: side by side, equal width, same height +/// - 3 photos: large left (2/3) + two stacked right (1/3) +/// - 4 photos: 2×2 grid +/// - 5 photos: 2 top + 3 bottom +/// +/// A thin `borderWidth` padding lets the parent bubble's background color +/// show through as a colored border around the images (Telegram-style). +/// Inner corners match the outer `MessageBubbleShape` radii minus `borderWidth`. +struct PhotoCollageView: View { + + let attachments: [MessageAttachment] + let message: ChatMessage + let outgoing: Bool + let maxWidth: CGFloat + let position: BubblePosition + + /// Called when user taps a loaded image. + var onImageTap: ((UIImage) -> Void)? + + /// Padding between images and bubble edge — bubble background shows through. + private let borderWidth: CGFloat = 2 + + /// Gap between images in a multi-image grid. + private let spacing: CGFloat = 2 + + /// Bubble fill color — used as gap color between collage cells. + private var bubbleColor: Color { + outgoing ? RosettaColors.figmaBlue : Color(hex: 0x2C2C2E) + } + + /// Maximum collage height. + private let maxCollageHeight: CGFloat = 320 + + var body: some View { + let contentWidth = maxWidth - borderWidth * 2 + + collageContent(contentWidth: contentWidth) + .clipShape(InnerBubbleClipShape(position: position, outgoing: outgoing, inset: borderWidth)) + .padding(borderWidth) + } + + // MARK: - Content Router + + @ViewBuilder + private func collageContent(contentWidth: CGFloat) -> some View { + switch attachments.count { + case 0: + EmptyView() + case 1: + singleImage(contentWidth: contentWidth) + case 2: + twoImages(contentWidth: contentWidth) + case 3: + threeImages(contentWidth: contentWidth) + case 4: + fourImages(contentWidth: contentWidth) + default: + fiveImages(contentWidth: contentWidth) + } + } + + // MARK: - 1 Photo: Full Width + + private func singleImage(contentWidth: CGFloat) -> some View { + MessageImageView( + attachment: attachments[0], + message: message, + outgoing: outgoing, + maxWidth: contentWidth, + onImageTap: onImageTap + ) + } + + // MARK: - 2 Photos: Side by Side + + private func twoImages(contentWidth: CGFloat) -> some View { + let cellWidth = (contentWidth - spacing) / 2 + let cellHeight = min(cellWidth * 1.2, maxCollageHeight) + + return HStack(spacing: spacing) { + collageCell(attachments[0], width: cellWidth, height: cellHeight) + collageCell(attachments[1], width: cellWidth, height: cellHeight) + } + .frame(width: contentWidth, height: cellHeight) + .background(bubbleColor) + } + + // MARK: - 3 Photos: 1 Large Left + 2 Stacked Right + + private func threeImages(contentWidth: CGFloat) -> some View { + let rightWidth = contentWidth * 0.34 + let leftWidth = contentWidth - spacing - rightWidth + let totalHeight = min(leftWidth * 1.1, maxCollageHeight) + let rightCellHeight = (totalHeight - spacing) / 2 + + return HStack(spacing: spacing) { + collageCell(attachments[0], width: leftWidth, height: totalHeight) + VStack(spacing: spacing) { + collageCell(attachments[1], width: rightWidth, height: rightCellHeight) + collageCell(attachments[2], width: rightWidth, height: rightCellHeight) + } + } + .frame(width: contentWidth, height: totalHeight) + .background(bubbleColor) + } + + // MARK: - 4 Photos: 2×2 Grid + + private func fourImages(contentWidth: CGFloat) -> some View { + let cellWidth = (contentWidth - spacing) / 2 + let cellHeight = min(cellWidth * 0.85, maxCollageHeight / 2) + let totalHeight = cellHeight * 2 + spacing + + return VStack(spacing: spacing) { + HStack(spacing: spacing) { + collageCell(attachments[0], width: cellWidth, height: cellHeight) + collageCell(attachments[1], width: cellWidth, height: cellHeight) + } + HStack(spacing: spacing) { + collageCell(attachments[2], width: cellWidth, height: cellHeight) + collageCell(attachments[3], width: cellWidth, height: cellHeight) + } + } + .frame(width: contentWidth, height: totalHeight) + .background(bubbleColor) + } + + // MARK: - 5 Photos: 2 Top + 3 Bottom + + private func fiveImages(contentWidth: CGFloat) -> some View { + 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) + let totalHeight = topHeight + spacing + bottomHeight + + return VStack(spacing: spacing) { + HStack(spacing: spacing) { + collageCell(attachments[0], width: topCellWidth, height: topHeight) + collageCell(attachments[1], width: topCellWidth, height: topHeight) + } + HStack(spacing: spacing) { + collageCell(attachments[2], width: bottomCellWidth, height: bottomHeight) + collageCell(attachments[3], width: bottomCellWidth, height: bottomHeight) + collageCell(attachments[4], width: bottomCellWidth, height: bottomHeight) + } + } + .frame(width: contentWidth, height: totalHeight) + .background(bubbleColor) + } + + // MARK: - Collage Cell + + @ViewBuilder + private func collageCell( + _ attachment: MessageAttachment, + width: CGFloat, + height: CGFloat + ) -> some View { + MessageImageView( + attachment: attachment, + message: message, + outgoing: outgoing, + collageSize: CGSize(width: width, height: height), + maxWidth: width, + onImageTap: onImageTap + ) + .frame(width: width, height: height) + .clipped() + } +} + +// MARK: - Inner Bubble Clip Shape + +/// Rounded rect that mirrors `MessageBubbleShape` corner radii but inset by `inset`. +/// Used to clip images inside the bubble so the border gap has matching corners. +struct InnerBubbleClipShape: Shape { + let position: BubblePosition + let outgoing: Bool + let inset: CGFloat + + func path(in rect: CGRect) -> Path { + let r: CGFloat = max(18 - inset, 0) + let s: CGFloat = max(8 - inset, 0) + let (tl, tr, bl, br) = cornerRadii(r: r, s: s) + + let maxR = min(rect.width, rect.height) / 2 + let cTL = min(tl, maxR) + let cTR = min(tr, maxR) + let cBL = min(bl, maxR) + let cBR = min(br, maxR) + + var p = Path() + p.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY)) + + p.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY)) + p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY), + tangent2End: CGPoint(x: rect.maxX, y: rect.minY + cTR), + radius: cTR) + + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR)) + p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), + tangent2End: CGPoint(x: rect.maxX - cBR, y: rect.maxY), + radius: cBR) + + p.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY)) + p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY), + tangent2End: CGPoint(x: rect.minX, y: rect.maxY - cBL), + radius: cBL) + + p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL)) + p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY), + tangent2End: CGPoint(x: rect.minX + cTL, y: rect.minY), + radius: cTL) + + p.closeSubpath() + return p + } + + /// Same logic as `MessageBubbleShape.cornerRadii` but with inset-adjusted radii. + private func cornerRadii(r: CGFloat, s: CGFloat) + -> (topLeading: CGFloat, topTrailing: CGFloat, + bottomLeading: CGFloat, bottomTrailing: CGFloat) { + switch position { + case .single: + return (r, r, r, r) + case .top: + return outgoing ? (r, r, r, s) : (r, r, s, r) + case .mid: + return outgoing ? (r, s, r, s) : (s, r, s, r) + case .bottom: + return outgoing ? (r, s, r, r) : (s, r, r, r) + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/SwipeToReplyModifier.swift b/Rosetta/Features/Chats/ChatDetail/SwipeToReplyModifier.swift new file mode 100644 index 0000000..23e3703 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/SwipeToReplyModifier.swift @@ -0,0 +1,143 @@ +import SwiftUI +import UIKit + +/// Telegram-style swipe-to-reply modifier for message bubbles. +/// Adds a left-swipe gesture that offsets the bubble and reveals a reply arrow icon. +/// On threshold crossing: light haptic feedback. On release past threshold: triggers reply. +/// +/// Architecture: applied BETWEEN the inner bubble (`.frame(maxWidth: maxBubbleWidth)`) +/// and the outer full-width frame (`.frame(maxWidth: .infinity)`). The `.offset(x:)` +/// is visual-only (does not affect layout), so the `.overlay(alignment: .trailing)` +/// added after it is positioned at the bubble's ORIGINAL trailing edge. As the bubble +/// shifts left, the icon is revealed in the gap between the shifted bubble and the +/// original position. +struct SwipeToReplyModifier: ViewModifier { + let onReply: () -> Void + + @State private var offset: CGFloat = 0 + @State private var hasTriggeredHaptic = false + @State private var lockedAxis: SwipeAxis? + + private enum SwipeAxis { case horizontal, vertical } + + /// Minimum drag distance to trigger reply action. + private let threshold: CGFloat = 50 + /// Offset where elastic resistance begins. + private let elasticCap: CGFloat = 80 + /// Reply icon circle diameter. + private let iconSize: CGFloat = 34 + + func body(content: Content) -> some View { + content + .offset(x: offset) + .overlay(alignment: .trailing) { + replyIndicator + } + .simultaneousGesture(dragGesture) + } + + // MARK: - Icon + + /// Progress from 0 (hidden) to 1 (fully visible) based on drag offset. + private var iconProgress: CGFloat { + let absOffset = abs(offset) + guard absOffset > 4 else { return 0 } + return min((absOffset - 4) / (threshold - 4), 1) + } + + @ViewBuilder + private var replyIndicator: some View { + Circle() + .fill(Color.white.opacity(0.12)) + .frame(width: iconSize, height: iconSize) + .overlay { + TelegramVectorIcon( + pathData: TelegramIconPath.replyArrow, + viewBox: CGSize(width: 16, height: 13), + color: .white + ) + .frame(width: 14, height: 11) + } + .scaleEffect(iconProgress) + .opacity(iconProgress) + } + + // MARK: - Gesture + + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 16, coordinateSpace: .local) + .onChanged { value in + // Lock axis on first significant movement to avoid + // interfering with vertical scroll or back-swipe navigation. + if lockedAxis == nil { + let dx = abs(value.translation.width) + let dy = abs(value.translation.height) + if dx > 16 || dy > 16 { + // Require clear horizontal dominance (2:1 ratio) + // AND must be leftward — right swipe is back navigation. + let isLeftward = value.translation.width < 0 + lockedAxis = (dx > dy * 2 && isLeftward) ? .horizontal : .vertical + } + } + + guard lockedAxis == .horizontal else { return } + + // Only left swipe (negative) + let raw = min(value.translation.width, 0) + guard raw < 0 else { + if offset != 0 { offset = 0 } + return + } + + // Elastic resistance past cap + let absRaw = abs(raw) + if absRaw > elasticCap { + let excess = absRaw - elasticCap + offset = -(elasticCap + excess * 0.15) + } else { + offset = raw + } + + // Haptic at threshold (once per gesture) + if abs(offset) >= threshold, !hasTriggeredHaptic { + hasTriggeredHaptic = true + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + } + .onEnded { _ in + let shouldReply = abs(offset) >= threshold + withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) { + offset = 0 + } + lockedAxis = nil + hasTriggeredHaptic = false + if shouldReply { + onReply() + } + } + } +} + +/// Conditionally applies swipe-to-reply. When disabled, passes content through unchanged +/// (no gesture, no icon, no animation). +struct ConditionalSwipeToReply: ViewModifier { + let enabled: Bool + let onReply: () -> Void + + func body(content: Content) -> some View { + if enabled { + content.modifier(SwipeToReplyModifier(onReply: onReply)) + } else { + content + } + } +} + +// MARK: - View Extension + +extension View { + /// Adds Telegram-style swipe-to-reply gesture to a message bubble. + func swipeToReply(onReply: @escaping () -> Void) -> some View { + modifier(SwipeToReplyModifier(onReply: onReply)) + } +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 2343a00..d9a51e6 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -383,44 +383,56 @@ private struct ToolbarTitleView: View { } } -/// Desktop parity: circular spinner + status text (Mantine `` equivalent). +/// Status text label without spinner (spinner is in ToolbarStoriesAvatar). private struct ToolbarStatusLabel: View { let title: String - @State private var isSpinning = false var body: some View { - HStack(spacing: 5) { - Circle() - .trim(from: 0.05, to: 0.75) - .stroke(RosettaColors.Adaptive.text, style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) - .frame(width: 12, height: 12) - .rotationEffect(.degrees(isSpinning ? 360 : 0)) - .animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: isSpinning) - - Text(title) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - } - .onAppear { isSpinning = true } + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) } } // MARK: - Toolbar Stories Avatar (observation-isolated) -/// Reads `AccountManager` and `SessionManager` in its own observation scope. -/// Changes to these `@Observable` singletons only re-render this small view, -/// not the parent ChatListView / NavigationStack. +/// Reads `AccountManager`, `SessionManager`, and `ProtocolManager` in its own observation scope. +/// Shows a spinning arc loader during connecting/syncing, then crossfades to avatar. private struct ToolbarStoriesAvatar: View { + @State private var isSpinning = false + var body: some View { let pk = AccountManager.shared.currentAccount?.publicKey ?? "" + let state = ProtocolManager.shared.connectionState + let isSyncing = SessionManager.shared.syncBatchInProgress + let isLoading = state != .authenticated || isSyncing + let initials = RosettaColors.initials( name: SessionManager.shared.displayName, publicKey: pk ) let colorIdx = RosettaColors.avatarColorIndex(for: SessionManager.shared.displayName, publicKey: pk) - // Reading avatarVersion triggers observation — re-renders when any avatar is saved/removed. let _ = AvatarRepository.shared.avatarVersion let avatar = AvatarRepository.shared.loadAvatar(publicKey: pk) - ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28, image: avatar) } + + ZStack { + // Avatar — visible when loaded + AvatarView(initials: initials, colorIndex: colorIdx, size: 28, image: avatar) + .opacity(isLoading ? 0 : 1) + + // Spinning arc loader — visible during connecting/syncing + Circle() + .trim(from: 0.05, to: 0.78) + .stroke( + RosettaColors.figmaBlue, + style: StrokeStyle(lineWidth: 2, lineCap: .round) + ) + .frame(width: 20, height: 20) + .rotationEffect(.degrees(isSpinning ? 360 : 0)) + .opacity(isLoading ? 1 : 0) + } + .animation(.easeInOut(duration: 0.3), value: isLoading) + .onAppear { isSpinning = true } + .animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isSpinning) } } diff --git a/Rosetta/GoogleService-Info.plist b/Rosetta/GoogleService-Info.plist index efbe2f9..2c4da6a 100644 --- a/Rosetta/GoogleService-Info.plist +++ b/Rosetta/GoogleService-Info.plist @@ -17,7 +17,7 @@ IS_ADS_ENABLED IS_ANALYTICS_ENABLED - + IS_APPINVITE_ENABLED IS_GCM_ENABLED diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 6c09f76..3001ae7 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -1,4 +1,5 @@ import FirebaseCore +import FirebaseCrashlytics import FirebaseMessaging import SwiftUI import UserNotifications @@ -60,6 +61,63 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent Messaging.messaging().apnsToken = deviceToken } + // MARK: - Background Push (Badge + Local Notification with Sound) + + /// Called when a push notification arrives with `content-available: 1`. + /// Server does NOT send `sound` in APNs payload — we always create a local + /// notification with `.default` sound to ensure vibration works. + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + // Foreground: WebSocket handles messages + haptic feedback — skip. + guard application.applicationState != .active else { + completionHandler(.noData) + return + } + + // Background/inactive: increment badge from persisted count. + let currentBadge = UserDefaults.standard.integer(forKey: "app_badge_count") + let newBadge = currentBadge + 1 + UserDefaults.standard.set(newBadge, forKey: "app_badge_count") + UNUserNotificationCenter.current().setBadgeCount(newBadge) + + let senderKey = userInfo["sender_public_key"] as? String ?? "" + let senderName = userInfo["sender_name"] as? String ?? "New message" + let messageText = userInfo["message"] as? String ?? "New message" + + // Don't notify for muted chats. + let isMuted = Task { @MainActor in + DialogRepository.shared.dialogs[senderKey]?.isMuted == true + } + Task { + let muted = await isMuted.value + guard !muted else { + completionHandler(.newData) + return + } + + let content = UNMutableNotificationContent() + content.title = senderName + content.body = messageText + content.sound = .default + content.badge = NSNumber(value: newBadge) + content.categoryIdentifier = "message" + if !senderKey.isEmpty { + content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName] + } + + let request = UNNotificationRequest( + identifier: "msg_\(senderKey)_\(Int(Date().timeIntervalSince1970))", + content: content, + trigger: nil + ) + try? await UNUserNotificationCenter.current().add(request) + completionHandler(.newData) + } + } + // MARK: - MessagingDelegate /// Called when FCM token is received or refreshed.