diff --git a/Rosetta/Core/Crypto/CryptoManager.swift b/Rosetta/Core/Crypto/CryptoManager.swift index 686d1e3..ee1bb65 100644 --- a/Rosetta/Core/Crypto/CryptoManager.swift +++ b/Rosetta/Core/Crypto/CryptoManager.swift @@ -96,24 +96,38 @@ final class CryptoManager: @unchecked Sendable { throw CryptoError.invalidData("Malformed encrypted string") } - // Try current format: PBKDF2-SHA1 + AES-CBC + zlib inflate - if let result = try? { - let key = CryptoPrimitives.pbkdf2( - password: password, salt: "rosetta", iterations: 1000, - keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1) - ) - let decrypted = try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: key, iv: iv) - return try CryptoPrimitives.rawInflate(decrypted) - }() { - return result + let prfOrder: [CCPseudoRandomAlgorithm] = [ + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // Desktop/Android current + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), // Legacy iOS + ] + + // 1) Preferred path: AES-CBC + zlib inflate + for prf in prfOrder { + if let result = try? decryptWithPassword( + ciphertext: ciphertext, + iv: iv, + password: password, + prf: prf, + expectsCompressed: true + ) { + return result + } } - // Fallback: legacy iOS format (PBKDF2-SHA256, no compression) - let legacyKey = CryptoPrimitives.pbkdf2( - password: password, salt: "rosetta", iterations: 1000, - keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) - ) - return try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: legacyKey, iv: iv) + // 2) Fallback: AES-CBC without compression (very old/legacy payloads) + for prf in prfOrder { + if let result = try? decryptWithPassword( + ciphertext: ciphertext, + iv: iv, + password: password, + prf: prf, + expectsCompressed: false + ) { + return result + } + } + + throw CryptoError.decryptionFailed } // MARK: - Utilities @@ -128,6 +142,29 @@ final class CryptoManager: @unchecked Sendable { } } +private extension CryptoManager { + func decryptWithPassword( + ciphertext: Data, + iv: Data, + password: String, + prf: CCPseudoRandomAlgorithm, + expectsCompressed: Bool + ) throws -> Data { + let key = CryptoPrimitives.pbkdf2( + password: password, + salt: "rosetta", + iterations: 1000, + keyLength: 32, + prf: prf + ) + let decrypted = try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: key, iv: iv) + if expectsCompressed { + return try CryptoPrimitives.rawInflate(decrypted) + } + return decrypted + } +} + // MARK: - BIP39 Internal private extension CryptoManager { diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift index 3e11ed4..bbf5e71 100644 --- a/Rosetta/Core/Crypto/MessageCrypto.swift +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -26,37 +26,35 @@ enum MessageCrypto { encryptedKey: String, myPrivateKeyHex: String ) throws -> String { - let keyAndNonce = try decryptKeyFromSender(encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex) - - guard keyAndNonce.count >= 56 else { - throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(keyAndNonce.count)") - } - - let key = keyAndNonce[0..<32] - let nonce = keyAndNonce[32..<56] - - let ciphertextData = Data(hexString: ciphertext) - let plaintext = try XChaCha20Engine.decrypt( - ciphertextWithTag: ciphertextData, key: Data(key), nonce: Data(nonce) + let keyCandidates = try decryptKeyFromSenderCandidates( + encryptedKey: encryptedKey, + myPrivateKeyHex: myPrivateKeyHex ) - guard let text = String(data: plaintext, encoding: .utf8) else { - throw CryptoError.invalidData("Decrypted data is not valid UTF-8") + var lastError: Error? + for keyAndNonce in keyCandidates where keyAndNonce.count >= 56 { + do { + return try decryptWithKeyAndNonce(ciphertext: ciphertext, keyAndNonce: keyAndNonce) + } catch { + lastError = error + } } - return text + + if let lastError { + throw lastError + } + throw CryptoError.invalidData("Failed to decrypt message content with all key candidates") } /// Encrypts a message using XChaCha20-Poly1305 + ECDH for the recipient. /// - Parameters: /// - plaintext: The message text. /// - recipientPublicKeyHex: Recipient's secp256k1 compressed public key (hex). - /// - senderPrivateKeyHex: Sender's private key (hex) — used to also encrypt for self. - /// - Returns: Tuple of (content: hex ciphertext+tag, chachaKey: base64 encrypted key for recipient, aesChachaKey: base64 encrypted key for sender). + /// - Returns: Tuple of (content: hex ciphertext+tag, chachaKey: base64 encrypted key for recipient, plainKeyAndNonce: raw key+nonce bytes). static func encryptOutgoing( plaintext: String, - recipientPublicKeyHex: String, - senderPrivateKeyHex: String - ) throws -> (content: String, chachaKey: String, aesChachaKey: String) { + recipientPublicKeyHex: String + ) throws -> (content: String, chachaKey: String, plainKeyAndNonce: Data) { guard let plaintextData = plaintext.data(using: .utf8) else { throw CryptoError.invalidData("Cannot encode plaintext as UTF-8") } @@ -73,34 +71,60 @@ enum MessageCrypto { keyAndNonce: keyAndNonce, recipientPublicKeyHex: recipientPublicKeyHex ) - let senderPrivKey = try P256K.Signing.PrivateKey( - dataRepresentation: Data(hexString: senderPrivateKeyHex), format: .compressed - ) - let senderPublicKeyHex = senderPrivKey.publicKey.dataRepresentation.hexString - - let aesChachaKey = try encryptKeyForRecipient( - keyAndNonce: keyAndNonce, recipientPublicKeyHex: senderPublicKeyHex - ) - return ( content: ciphertextWithTag.hexString, chachaKey: chachaKey, - aesChachaKey: aesChachaKey + plainKeyAndNonce: keyAndNonce ) } + + /// Decrypts an incoming message using already decrypted key+nonce bytes. + /// Mirrors Android `decryptIncomingWithPlainKey`. + static func decryptIncomingWithPlainKey( + ciphertext: String, + plainKeyAndNonce: Data + ) throws -> String { + guard plainKeyAndNonce.count >= 56 else { + throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(plainKeyAndNonce.count)") + } + return try decryptWithKeyAndNonce(ciphertext: ciphertext, keyAndNonce: plainKeyAndNonce) + } + + /// Android parity helper: + /// `String(bytes, UTF_8)` (replacement for invalid sequences) + `toByteArray(ISO_8859_1)`. + static func androidUtf8BytesToLatin1Bytes(_ utf8Bytes: Data) -> Data { + let decoded = String(decoding: utf8Bytes, as: UTF8.self) + if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) { + return latin1 + } + return Data(decoded.unicodeScalars.map { $0.value <= 0xFF ? UInt8($0.value) : UInt8(ascii: "?") }) + } } // MARK: - ECDH Key Exchange private extension MessageCrypto { - /// Decrypts the XChaCha20 key+nonce from the sender using ECDH. + /// Decrypts and returns candidate XChaCha20 key+nonce buffers. /// Format: Base64(ivHex:encryptedKeyHex:ephemeralPrivateKeyHex) - static func decryptKeyFromSender(encryptedKey: String, myPrivateKeyHex: String) throws -> Data { - guard let decoded = Data(base64Encoded: encryptedKey), - let combined = String(data: decoded, encoding: .utf8) else { + /// Supports Android sync shorthand `sync:`. + static func decryptKeyFromSenderCandidates(encryptedKey: String, myPrivateKeyHex: String) throws -> [Data] { + if encryptedKey.hasPrefix("sync:") { + let aesChachaKey = String(encryptedKey.dropFirst("sync:".count)) + guard !aesChachaKey.isEmpty else { + throw CryptoError.invalidData("Invalid sync key format") + } + let decrypted = try CryptoManager.shared.decryptWithPassword( + aesChachaKey, + password: myPrivateKeyHex + ) + return [androidUtf8BytesToLatin1Bytes(decrypted)] + } + + guard let decoded = Data(base64Encoded: encryptedKey) else { throw CryptoError.invalidData("Cannot decode Base64 key") } + let combined = String(decoding: decoded, as: UTF8.self) let parts = combined.split(separator: ":", maxSplits: 2).map(String.init) guard parts.count == 3 else { @@ -119,11 +143,14 @@ private extension MessageCrypto { let iv = Data(hexString: ivHex) let encryptedKeyData = Data(hexString: encryptedKeyHex) + let normalizedEphemeralPrivateKeyHex = normalizePrivateKeyHex(ephemeralPrivateKeyHex) + let normalizedMyPrivateKeyHex = normalizePrivateKeyHex(myPrivateKeyHex) + let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey( - dataRepresentation: Data(hexString: ephemeralPrivateKeyHex), format: .compressed + dataRepresentation: Data(hexString: normalizedEphemeralPrivateKeyHex), format: .compressed ) let myPrivKey = try P256K.KeyAgreement.PrivateKey( - dataRepresentation: Data(hexString: myPrivateKeyHex), format: .compressed + dataRepresentation: Data(hexString: normalizedMyPrivateKeyHex), format: .compressed ) // ECDH: ephemeralPrivateKey × myPublicKey → shared point @@ -131,16 +158,37 @@ private extension MessageCrypto { with: myPrivKey.publicKey, format: .compressed ) let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } - let sharedKey = extractXCoordinate(from: sharedSecretData) + let sharedKeyExact = extractXCoordinate(from: sharedSecretData) + let sharedKeyLegacy = legacySharedKey(fromExactX: sharedKeyExact) - // AES-256-CBC decrypt - let decryptedBytes = try CryptoPrimitives.aesCBCDecrypt(encryptedKeyData, key: sharedKey, iv: iv) + let candidateSharedKeys: [Data] = sharedKeyLegacy == sharedKeyExact + ? [sharedKeyExact] + : [sharedKeyExact, sharedKeyLegacy] - // UTF-8 → Latin1 conversion (reverse of JS crypto-js compatibility) - guard let utf8String = String(data: decryptedBytes, encoding: .utf8) else { - throw CryptoError.invalidData("Decrypted key is not valid UTF-8") + var candidates: [Data] = [] + var seen: Set = [] + + for sharedKey in candidateSharedKeys { + guard let decryptedBytes = try? CryptoPrimitives.aesCBCDecrypt( + encryptedKeyData, + key: sharedKey, + iv: iv + ) else { + continue + } + + // Android parity: String(bytes, UTF_8) + toByteArray(ISO_8859_1) + let originalBytes = androidUtf8BytesToLatin1Bytes(decryptedBytes) + guard originalBytes.count >= 56 else { continue } + if seen.insert(originalBytes).inserted { + candidates.append(originalBytes) + } } - return Data(utf8String.unicodeScalars.map { UInt8(truncatingIfNeeded: $0.value) }) + + guard !candidates.isEmpty else { + throw CryptoError.invalidData("Failed to decrypt key with all shared secret variants") + } + return candidates } /// Encrypts the XChaCha20 key+nonce for a recipient using ECDH. @@ -157,8 +205,11 @@ private extension MessageCrypto { let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } let sharedKey = extractXCoordinate(from: sharedSecretData) - // Convert keyAndNonce bytes to UTF-8 string representation (JS Buffer compatibility) - let utf8Representation = String(keyAndNonce.map { Character(UnicodeScalar($0)) }) + // Android parity: String(bytes, ISO_8859_1) -> UTF-8 bytes. + guard let latin1String = String(data: keyAndNonce, encoding: .isoLatin1) else { + throw CryptoError.encryptionFailed + } + let utf8Representation = latin1String guard let dataToEncrypt = utf8Representation.data(using: .utf8) else { throw CryptoError.encryptionFailed } @@ -174,11 +225,66 @@ private extension MessageCrypto { return base64 } - /// Extracts the 32-byte x-coordinate from a compressed ECDH shared secret. + /// Extracts the 32-byte x-coordinate from ECDH shared secret bytes. static func extractXCoordinate(from sharedSecretData: Data) -> Data { + // Uncompressed point: 0x04 || X(32) || Y(32) + if sharedSecretData.count == 65, sharedSecretData.first == 0x04 { + return sharedSecretData[1..<33] + } + // Compressed point: 0x02/0x03 || X(32) if sharedSecretData.count == 33 { return sharedSecretData[1..<33] } - return sharedSecretData.prefix(32) + // Raw X coordinate (32 bytes) + if sharedSecretData.count == 32 { + return sharedSecretData + } + // Defensive fallback: keep last 32 bytes if shape is unexpected. + if sharedSecretData.count > 32 { + return sharedSecretData.suffix(32) + } + // Left-pad short variants. + return Data(repeating: 0, count: 32 - sharedSecretData.count) + sharedSecretData + } + + /// Legacy Android compatibility: x-coordinate serialized through BigInteger loses leading zeros. + static func legacySharedKey(fromExactX exactX: Data) -> Data { + let trimmed = exactX.drop(while: { $0 == 0 }) + return trimmed.isEmpty ? Data([0]) : Data(trimmed) + } + + /// JS/Android compatibility: private key hex can arrive without leading zero bytes. + static func normalizePrivateKeyHex(_ rawHex: String) -> String { + var hex = rawHex + if hex.count % 2 != 0 { + hex = "0" + hex + } + if hex.count < 64 { + return String(repeating: "0", count: 64 - hex.count) + hex + } + if hex.count > 64 { + return String(hex.suffix(64)) + } + return hex + } + + static func decryptWithKeyAndNonce(ciphertext: String, keyAndNonce: Data) throws -> String { + guard keyAndNonce.count >= 56 else { + throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(keyAndNonce.count)") + } + + let key = keyAndNonce[0..<32] + let nonce = keyAndNonce[32..<56] + let ciphertextData = Data(hexString: ciphertext) + let plaintext = try XChaCha20Engine.decrypt( + ciphertextWithTag: ciphertextData, + key: Data(key), + nonce: Data(nonce) + ) + + guard let text = String(data: plaintext, encoding: .utf8) else { + throw CryptoError.invalidData("Decrypted data is not valid UTF-8") + } + return text } } diff --git a/Rosetta/Core/Crypto/Poly1305Engine.swift b/Rosetta/Core/Crypto/Poly1305Engine.swift index bbb06a9..736b61b 100644 --- a/Rosetta/Core/Crypto/Poly1305Engine.swift +++ b/Rosetta/Core/Crypto/Poly1305Engine.swift @@ -37,9 +37,10 @@ enum Poly1305Engine { var block = [UInt8](macInput[offset.. Bool { + fromPublicKey == myPublicKey + } + + var date: Date { + Date(timeIntervalSince1970: Double(timestamp) / 1000.0) + } +} diff --git a/Rosetta/Core/Data/Models/RecentSearch.swift b/Rosetta/Core/Data/Models/RecentSearch.swift new file mode 100644 index 0000000..f31081c --- /dev/null +++ b/Rosetta/Core/Data/Models/RecentSearch.swift @@ -0,0 +1,8 @@ +import Foundation + +struct RecentSearch: Codable, Equatable, Sendable { + let publicKey: String + var title: String + var username: String + var lastSeenText: String +} diff --git a/Rosetta/Core/Data/Repositories/ChatPersistenceStore.swift b/Rosetta/Core/Data/Repositories/ChatPersistenceStore.swift new file mode 100644 index 0000000..e1bb5d4 --- /dev/null +++ b/Rosetta/Core/Data/Repositories/ChatPersistenceStore.swift @@ -0,0 +1,59 @@ +import Foundation + +actor ChatPersistenceStore { + static let shared = ChatPersistenceStore() + + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private let rootDirectory: URL + + private init() { + let fileManager = FileManager.default + let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + + let directory = baseURL + .appendingPathComponent("Rosetta", isDirectory: true) + .appendingPathComponent("ChatState", isDirectory: true) + + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + rootDirectory = directory + } + + func load(_ type: T.Type, fileName: String) -> T? { + let fileURL = rootDirectory.appendingPathComponent(fileName, isDirectory: false) + guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } + guard let data = try? Data(contentsOf: fileURL) else { return nil } + return try? decoder.decode(type, from: data) + } + + func save(_ value: T, fileName: String) { + let fileURL = rootDirectory.appendingPathComponent(fileName, isDirectory: false) + guard let data = try? encoder.encode(value) else { return } + try? data.write(to: fileURL, options: [.atomic]) + } + + func remove(fileName: String) { + let fileURL = rootDirectory.appendingPathComponent(fileName, isDirectory: false) + try? FileManager.default.removeItem(at: fileURL) + } + + nonisolated static func accountScopedFileName(prefix: String, accountPublicKey: String) -> String { + "\(prefix)_\(normalizedAccountKey(accountPublicKey)).json" + } + + private nonisolated static func normalizedAccountKey(_ accountPublicKey: String) -> String { + let trimmed = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return "anonymous" + } + let scalars = trimmed.unicodeScalars.map { scalar -> Character in + if CharacterSet.alphanumerics.contains(scalar) { + return Character(scalar) + } + return "_" + } + return String(scalars) + } +} diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index f61094c..f993a50 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -1,7 +1,7 @@ import Foundation import Observation -/// In-memory dialog store, updated from incoming protocol packets. +/// Account-scoped dialog store with disk persistence. @Observable @MainActor final class DialogRepository { @@ -9,6 +9,8 @@ final class DialogRepository { static let shared = DialogRepository() private(set) var dialogs: [String: Dialog] = [:] + private var currentAccount: String = "" + private var persistTask: Task? var sortedDialogs: [Dialog] { Array(dialogs.values).sorted { @@ -19,14 +21,61 @@ final class DialogRepository { private init() {} + func bootstrap(accountPublicKey: String) async { + let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !account.isEmpty else { + reset() + return + } + + if currentAccount == account, !dialogs.isEmpty { + return + } + + currentAccount = account + persistTask?.cancel() + persistTask = nil + + let fileName = Self.dialogsFileName(for: account) + let stored = await ChatPersistenceStore.shared.load([Dialog].self, fileName: fileName) ?? [] + dialogs = Dictionary( + uniqueKeysWithValues: stored + .filter { $0.account == account } + .map { ($0.opponentKey, $0) } + ) + } + + func reset(clearPersisted: Bool = false) { + persistTask?.cancel() + persistTask = nil + dialogs.removeAll() + + guard !currentAccount.isEmpty else { return } + let accountToReset = currentAccount + currentAccount = "" + + guard clearPersisted else { return } + let fileName = Self.dialogsFileName(for: accountToReset) + Task(priority: .utility) { + await ChatPersistenceStore.shared.remove(fileName: fileName) + } + } + // MARK: - Updates func upsertDialog(_ dialog: Dialog) { + if currentAccount.isEmpty { + currentAccount = dialog.account + } dialogs[dialog.opponentKey] = dialog + schedulePersist() } /// Creates or updates a dialog from an incoming message packet. func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) { + if currentAccount.isEmpty { + currentAccount = myPublicKey + } let fromMe = packet.fromPublicKey == myPublicKey let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey @@ -50,7 +99,7 @@ final class DialogRepository { ) dialog.lastMessage = decryptedText - dialog.lastMessageTimestamp = Int64(packet.timestamp) + dialog.lastMessageTimestamp = normalizeTimestamp(packet.timestamp) dialog.lastMessageFromMe = fromMe dialog.lastMessageDelivered = fromMe ? .waiting : .delivered @@ -61,6 +110,50 @@ final class DialogRepository { } dialogs[opponentKey] = dialog + schedulePersist() + } + + func ensureDialog( + opponentKey: String, + title: String, + username: String, + verified: Int = 0, + myPublicKey: String + ) { + if var existing = dialogs[opponentKey] { + if !title.isEmpty { + existing.opponentTitle = title + } + if !username.isEmpty { + existing.opponentUsername = username + } + if verified > existing.verified { + existing.verified = verified + } + dialogs[opponentKey] = existing + schedulePersist() + return + } + + dialogs[opponentKey] = Dialog( + id: opponentKey, + account: myPublicKey, + opponentKey: opponentKey, + opponentTitle: title, + opponentUsername: username, + lastMessage: "", + lastMessageTimestamp: 0, + unreadCount: 0, + isOnline: false, + lastSeen: 0, + verified: verified, + iHaveSent: false, + isPinned: false, + isMuted: false, + lastMessageFromMe: false, + lastMessageDelivered: .waiting + ) + schedulePersist() } func updateOnlineState(publicKey: String, isOnline: Bool) { @@ -70,12 +163,24 @@ final class DialogRepository { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) } dialogs[publicKey] = dialog + schedulePersist() } func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) { guard var dialog = dialogs[opponentKey] else { return } + let current = dialog.lastMessageDelivered + if current == .read, status == .delivered { + return + } + if current == .read, status == .waiting { + return + } + if current == .delivered, status == .waiting { + return + } dialog.lastMessageDelivered = status dialogs[opponentKey] = dialog + schedulePersist() } func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0) { @@ -84,27 +189,63 @@ final class DialogRepository { if !username.isEmpty { dialog.opponentUsername = username } if verified > 0 { dialog.verified = max(dialog.verified, verified) } dialogs[publicKey] = dialog + schedulePersist() } func markAsRead(opponentKey: String) { guard var dialog = dialogs[opponentKey] else { return } + guard dialog.unreadCount > 0 else { return } dialog.unreadCount = 0 dialogs[opponentKey] = dialog + schedulePersist() + } + + func markOutgoingAsRead(opponentKey: String) { + guard var dialog = dialogs[opponentKey] else { return } + if dialog.lastMessageFromMe { + dialog.lastMessageDelivered = .read + } + dialogs[opponentKey] = dialog + schedulePersist() } func deleteDialog(opponentKey: String) { dialogs.removeValue(forKey: opponentKey) + schedulePersist() } func togglePin(opponentKey: String) { guard var dialog = dialogs[opponentKey] else { return } dialog.isPinned.toggle() dialogs[opponentKey] = dialog + schedulePersist() } func toggleMute(opponentKey: String) { guard var dialog = dialogs[opponentKey] else { return } dialog.isMuted.toggle() dialogs[opponentKey] = dialog + schedulePersist() + } + + private func normalizeTimestamp(_ raw: Int64) -> Int64 { + raw < 1_000_000_000_000 ? raw * 1000 : raw + } + + private func schedulePersist() { + guard !currentAccount.isEmpty else { return } + + let snapshot = Array(dialogs.values) + let fileName = Self.dialogsFileName(for: currentAccount) + persistTask?.cancel() + persistTask = Task(priority: .utility) { + try? await Task.sleep(for: .milliseconds(180)) + guard !Task.isCancelled else { return } + await ChatPersistenceStore.shared.save(snapshot, fileName: fileName) + } + } + + private static func dialogsFileName(for accountPublicKey: String) -> String { + ChatPersistenceStore.accountScopedFileName(prefix: "dialogs", accountPublicKey: accountPublicKey) } } diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift new file mode 100644 index 0000000..9a2ef99 --- /dev/null +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -0,0 +1,317 @@ +import Foundation +import Combine + +/// Account-scoped message store with disk persistence. +@MainActor +final class MessageRepository: ObservableObject { + static let shared = MessageRepository() + // Android keeps full history in DB; keep a much larger in-memory cap to avoid visible message loss. + private let maxMessagesPerDialog = 5_000 + + @Published private var messagesByDialog: [String: [ChatMessage]] = [:] + @Published private var typingDialogs: Set = [] + + private var activeDialogs: Set = [] + private var messageToDialog: [String: String] = [:] + private var typingResetTasks: [String: Task] = [:] + private var persistTask: Task? + private var currentAccount: String = "" + + private init() {} + + func bootstrap(accountPublicKey: String) async { + let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !account.isEmpty else { + reset() + return + } + + if currentAccount == account, !messagesByDialog.isEmpty { + return + } + + currentAccount = account + persistTask?.cancel() + persistTask = nil + activeDialogs.removeAll() + typingDialogs.removeAll() + for task in typingResetTasks.values { + task.cancel() + } + typingResetTasks.removeAll() + messageToDialog.removeAll() + + let fileName = Self.messagesFileName(for: account) + let stored = await ChatPersistenceStore.shared.load([String: [ChatMessage]].self, fileName: fileName) ?? [:] + var restored: [String: [ChatMessage]] = [:] + for (dialogKey, list) in stored { + var sorted = list.sorted { + if $0.timestamp != $1.timestamp { + return $0.timestamp < $1.timestamp + } + return $0.id < $1.id + } + if sorted.count > maxMessagesPerDialog { + sorted = Array(sorted.suffix(maxMessagesPerDialog)) + } + restored[dialogKey] = sorted + for message in sorted { + messageToDialog[message.id] = dialogKey + } + } + messagesByDialog = restored + } + + // MARK: - Dialog State + + func messages(for dialogKey: String) -> [ChatMessage] { + messagesByDialog[dialogKey] ?? [] + } + + func dialogKey(forMessageId messageId: String) -> String? { + messageToDialog[messageId] + } + + func hasMessage(_ messageId: String) -> Bool { + messageToDialog[messageId] != nil + } + + func isLatestMessage(_ messageId: String, in dialogKey: String) -> Bool { + messagesByDialog[dialogKey]?.last?.id == messageId + } + + func isDialogActive(_ dialogKey: String) -> Bool { + activeDialogs.contains(dialogKey) + } + + func setDialogActive(_ dialogKey: String, isActive: Bool) { + if isActive { + activeDialogs.insert(dialogKey) + } else { + activeDialogs.remove(dialogKey) + typingDialogs.remove(dialogKey) + typingResetTasks[dialogKey]?.cancel() + typingResetTasks[dialogKey] = nil + } + } + + // MARK: - Message Updates + + func upsertFromMessagePacket(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) { + let fromMe = packet.fromPublicKey == myPublicKey + let dialogKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId + let timestamp = normalizeTimestamp(packet.timestamp) + let incomingRead = !fromMe && activeDialogs.contains(dialogKey) + + messageToDialog[messageId] = dialogKey + + updateMessages(for: dialogKey) { messages in + if let existingIndex = messages.firstIndex(where: { $0.id == messageId }) { + messages[existingIndex].text = decryptedText + messages[existingIndex].timestamp = timestamp + messages[existingIndex].attachments = packet.attachments + if fromMe, messages[existingIndex].deliveryStatus == .error { + messages[existingIndex].deliveryStatus = .waiting + } + if incomingRead { + messages[existingIndex].isRead = true + } + return + } + + messages.append( + ChatMessage( + id: messageId, + fromPublicKey: packet.fromPublicKey, + toPublicKey: packet.toPublicKey, + text: decryptedText, + timestamp: timestamp, + deliveryStatus: fromMe ? .waiting : .delivered, + isRead: incomingRead || fromMe, + attachments: packet.attachments + ) + ) + } + } + + func updateDeliveryStatus(messageId: String, status: DeliveryStatus) { + guard let dialogKey = messageToDialog[messageId] else { return } + updateMessages(for: dialogKey) { messages in + guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return } + let current = messages[index].deliveryStatus + if current == .read, status == .delivered { + return + } + if current == .read, status == .waiting { + return + } + if current == .delivered, status == .waiting { + return + } + messages[index].deliveryStatus = status + } + } + + func markIncomingAsRead(opponentKey: String, myPublicKey: String) { + updateMessages(for: opponentKey) { messages in + for index in messages.indices { + if messages[index].fromPublicKey == opponentKey, + messages[index].toPublicKey == myPublicKey + { + messages[index].isRead = true + } + } + } + } + + func markOutgoingAsRead(opponentKey: String, myPublicKey: String) { + updateMessages(for: opponentKey) { messages in + for index in messages.indices { + if messages[index].fromPublicKey == myPublicKey, + messages[index].toPublicKey == opponentKey + { + messages[index].deliveryStatus = .read + } + } + } + } + + // MARK: - Typing + + func markTyping(from opponentKey: String) { + typingDialogs.insert(opponentKey) + typingResetTasks[opponentKey]?.cancel() + typingResetTasks[opponentKey] = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(3)) + guard let self, !Task.isCancelled else { return } + self.typingDialogs.remove(opponentKey) + self.typingResetTasks[opponentKey] = nil + } + } + + func isTyping(dialogKey: String) -> Bool { + typingDialogs.contains(dialogKey) + } + + func deleteDialog(_ dialogKey: String) { + guard messagesByDialog.removeValue(forKey: dialogKey) != nil else { return } + activeDialogs.remove(dialogKey) + typingDialogs.remove(dialogKey) + typingResetTasks[dialogKey]?.cancel() + typingResetTasks[dialogKey] = nil + messageToDialog = messageToDialog.filter { $0.value != dialogKey } + 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( + myPublicKey: String, + nowMs: Int64, + maxAgeMs: Int64 + ) -> (retryable: [ChatMessage], expired: [(messageId: String, dialogKey: String)]) { + var retryable: [ChatMessage] = [] + var expired: [(messageId: String, dialogKey: String)] = [] + var hasMutations = false + let cutoff = nowMs - maxAgeMs + + for (dialogKey, currentMessages) in messagesByDialog { + var messages = currentMessages + var mutated = false + + for index in messages.indices { + let message = messages[index] + guard message.fromPublicKey == myPublicKey else { continue } + guard message.deliveryStatus == .waiting else { continue } + + if message.timestamp < cutoff { + messages[index].deliveryStatus = .error + expired.append((messageId: message.id, dialogKey: dialogKey)) + mutated = true + } else { + retryable.append(message) + } + } + + if mutated { + messagesByDialog[dialogKey] = messages + hasMutations = true + } + } + + if hasMutations { + schedulePersist() + } + + return (retryable: retryable, expired: expired) + } + + func reset(clearPersisted: Bool = false) { + persistTask?.cancel() + persistTask = nil + for task in typingResetTasks.values { + task.cancel() + } + typingResetTasks.removeAll() + messagesByDialog.removeAll() + typingDialogs.removeAll() + activeDialogs.removeAll() + messageToDialog.removeAll() + + guard !currentAccount.isEmpty else { return } + let accountToReset = currentAccount + currentAccount = "" + + guard clearPersisted else { return } + let fileName = Self.messagesFileName(for: accountToReset) + Task(priority: .utility) { + await ChatPersistenceStore.shared.remove(fileName: fileName) + } + } + + // MARK: - Private + + private func updateMessages(for dialogKey: String, mutate: (inout [ChatMessage]) -> Void) { + var messages = messagesByDialog[dialogKey] ?? [] + mutate(&messages) + messages.sort { + if $0.timestamp != $1.timestamp { + return $0.timestamp < $1.timestamp + } + return $0.id < $1.id + } + if messages.count > maxMessagesPerDialog { + let overflow = messages.count - maxMessagesPerDialog + let dropped = messages.prefix(overflow) + for message in dropped { + messageToDialog.removeValue(forKey: message.id) + } + messages.removeFirst(overflow) + } + messagesByDialog[dialogKey] = messages + schedulePersist() + } + + private func normalizeTimestamp(_ raw: Int64) -> Int64 { + // Some peers still send seconds, while Android path uses milliseconds. + raw < 1_000_000_000_000 ? raw * 1000 : raw + } + + private func schedulePersist() { + guard !currentAccount.isEmpty else { return } + + let snapshot = messagesByDialog + let fileName = Self.messagesFileName(for: currentAccount) + persistTask?.cancel() + persistTask = Task(priority: .utility) { + try? await Task.sleep(for: .milliseconds(220)) + guard !Task.isCancelled else { return } + await ChatPersistenceStore.shared.save(snapshot, fileName: fileName) + } + } + + private static func messagesFileName(for accountPublicKey: String) -> String { + ChatPersistenceStore.accountScopedFileName(prefix: "messages", accountPublicKey: accountPublicKey) + } +} diff --git a/Rosetta/Core/Data/Repositories/RecentSearchesRepository.swift b/Rosetta/Core/Data/Repositories/RecentSearchesRepository.swift new file mode 100644 index 0000000..d0e79d3 --- /dev/null +++ b/Rosetta/Core/Data/Repositories/RecentSearchesRepository.swift @@ -0,0 +1,97 @@ +import Combine +import Foundation + +@MainActor +final class RecentSearchesRepository: ObservableObject { + static let shared = RecentSearchesRepository() + + @Published private(set) var recentSearches: [RecentSearch] = [] + + private static let maxRecent = 15 + private static let legacyKey = "rosetta_recent_searches" + private var currentAccount: String = "" + + private init() {} + + func setAccount(_ accountPublicKey: String) { + let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard account != currentAccount else { return } + currentAccount = account + load() + } + + func clearSession() { + currentAccount = "" + recentSearches = [] + } + + func add(_ user: SearchUser) { + let recent = RecentSearch( + publicKey: user.publicKey, + title: user.title, + username: user.username, + lastSeenText: user.online == 1 ? "online" : "last seen recently" + ) + add(recent) + } + + func add(_ recent: RecentSearch) { + guard !currentAccount.isEmpty else { return } + recentSearches.removeAll { $0.publicKey == recent.publicKey } + recentSearches.insert(recent, at: 0) + if recentSearches.count > Self.maxRecent { + recentSearches = Array(recentSearches.prefix(Self.maxRecent)) + } + persist() + } + + func remove(publicKey: String) { + guard !currentAccount.isEmpty else { return } + recentSearches.removeAll { $0.publicKey == publicKey } + persist() + } + + func clearAll() { + guard !currentAccount.isEmpty else { + recentSearches = [] + return + } + recentSearches = [] + UserDefaults.standard.removeObject(forKey: scopedKey) + } + + private var scopedKey: String { + "rosetta_recent_searches_\(currentAccount)" + } + + private func load() { + guard !currentAccount.isEmpty else { + recentSearches = [] + return + } + + if let data = UserDefaults.standard.data(forKey: scopedKey), + let list = try? JSONDecoder().decode([RecentSearch].self, from: data) + { + recentSearches = Array(list.prefix(Self.maxRecent)) + return + } + + if let data = UserDefaults.standard.data(forKey: Self.legacyKey), + let list = try? JSONDecoder().decode([RecentSearch].self, from: data) + { + recentSearches = Array(list.prefix(Self.maxRecent)) + persist() + UserDefaults.standard.removeObject(forKey: Self.legacyKey) + return + } + + recentSearches = [] + } + + private func persist() { + guard !currentAccount.isEmpty else { return } + guard let data = try? JSONEncoder().encode(recentSearches) else { return } + UserDefaults.standard.set(data, forKey: scopedKey) + } +} diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift index 2de9f7a..c5378d8 100644 --- a/Rosetta/Core/Network/Protocol/Packets/Packet.swift +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -22,6 +22,7 @@ enum PacketRegistry { 0x07: { PacketRead() }, 0x08: { PacketDelivery() }, 0x0B: { PacketTyping() }, + 0x17: { PacketDeviceList() }, 0x19: { PacketSync() }, ] diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketDeviceList.swift b/Rosetta/Core/Network/Protocol/Packets/PacketDeviceList.swift new file mode 100644 index 0000000..f916280 --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketDeviceList.swift @@ -0,0 +1,55 @@ +import Foundation + +enum DeviceState: Int { + case online = 0 + case offline = 1 +} + +enum DeviceVerifyState: Int { + case verified = 0 + case notVerified = 1 +} + +struct DeviceEntry { + var deviceId: String + var deviceName: String + var deviceOs: String + var deviceStatus: DeviceState + var deviceVerify: DeviceVerifyState +} + +/// Device list packet (0x17). +struct PacketDeviceList: Packet { + static let packetId = 0x17 + + var devices: [DeviceEntry] = [] + + func write(to stream: Stream) { + stream.writeInt16(devices.count) + for device in devices { + stream.writeString(device.deviceId) + stream.writeString(device.deviceName) + stream.writeString(device.deviceOs) + stream.writeInt8(device.deviceStatus.rawValue) + stream.writeInt8(device.deviceVerify.rawValue) + } + } + + mutating func read(from stream: Stream) { + let count = stream.readInt16() + var parsed: [DeviceEntry] = [] + parsed.reserveCapacity(max(0, count)) + for _ in 0.. = [] private var handshakeComplete = false private var heartbeatTask: Task? private var handshakeTimeoutTask: Task? + private let searchHandlersLock = NSLock() + private let packetQueueLock = NSLock() + private var searchResultHandlers: [UUID: (PacketSearch) -> Void] = [:] // Saved credentials for auto-reconnect private var savedPublicKey: String? @@ -88,14 +92,30 @@ final class ProtocolManager: @unchecked Sendable { // MARK: - Sending func sendPacket(_ packet: any Packet) { - if !handshakeComplete && !(packet is PacketHandshake) { - Self.logger.info("Queueing packet \(type(of: packet).packetId)") - packetQueue.append(packet) + if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected { + enqueuePacket(packet) return } sendPacketDirect(packet) } + // MARK: - Search Handlers (Android-like wait/unwait) + + @discardableResult + func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID { + let id = UUID() + searchHandlersLock.lock() + searchResultHandlers[id] = handler + searchHandlersLock.unlock() + return id + } + + func removeSearchResultHandler(_ id: UUID) { + searchHandlersLock.lock() + searchResultHandlers.removeValue(forKey: id) + searchHandlersLock.unlock() + } + // MARK: - Private Setup private func setupClientCallbacks() { @@ -212,6 +232,7 @@ final class ProtocolManager: @unchecked Sendable { if let p = packet as? PacketSearch { Self.logger.debug("📥 Search result: \(p.users.count) users, callback=\(self.onSearchResult != nil)") onSearchResult?(p) + notifySearchResultHandlers(p) } case 0x05: if let p = packet as? PacketOnlineState { @@ -242,14 +263,23 @@ final class ProtocolManager: @unchecked Sendable { } } + private func notifySearchResultHandlers(_ packet: PacketSearch) { + searchHandlersLock.lock() + let handlers = Array(searchResultHandlers.values) + searchHandlersLock.unlock() + + for handler in handlers { + handler(packet) + } + } + private func handleHandshakeResponse(_ packet: PacketHandshake) { - // Set handshakeComplete BEFORE cancelling timeout to prevent race - handshakeComplete = true handshakeTimeoutTask?.cancel() handshakeTimeoutTask = nil switch packet.handshakeState { case .completed: + handshakeComplete = true Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s") Task { @MainActor in @@ -261,7 +291,9 @@ final class ProtocolManager: @unchecked Sendable { onHandshakeCompleted?(packet) case .needDeviceVerification: + handshakeComplete = false Self.logger.info("Server requires device verification") + clearPacketQueue() startHeartbeat(interval: packet.heartbeatInterval) } } @@ -289,15 +321,71 @@ final class ProtocolManager: @unchecked Sendable { private func sendPacketDirect(_ packet: any Packet) { let data = PacketRegistry.encode(packet) Self.logger.info("Sending packet 0x\(String(type(of: packet).packetId, radix: 16)) (\(data.count) bytes)") - client.send(data) + if !client.send(data, onFailure: { [weak self] _ in + guard let self else { return } + Self.logger.warning("Send failed, re-queueing packet 0x\(String(type(of: packet).packetId, radix: 16))") + self.enqueuePacket(packet) + }) { + Self.logger.warning("WebSocket unavailable, re-queueing packet 0x\(String(type(of: packet).packetId, radix: 16))") + enqueuePacket(packet) + } } private func flushPacketQueue() { - Self.logger.info("Flushing \(self.packetQueue.count) queued packets") - let packets = packetQueue - packetQueue.removeAll() + let packets = drainPacketQueue() + Self.logger.info("Flushing \(packets.count) queued packets") for packet in packets { sendPacketDirect(packet) } } + + private func enqueuePacket(_ packet: any Packet) { + packetQueueLock.lock() + if let key = packetQueueKey(packet), queuedPacketKeys.contains(key) { + packetQueueLock.unlock() + return + } + packetQueue.append(packet) + if let key = packetQueueKey(packet) { + queuedPacketKeys.insert(key) + } + let count = packetQueue.count + packetQueueLock.unlock() + Self.logger.info("Queueing packet 0x\(String(type(of: packet).packetId, radix: 16)) (queue=\(count))") + } + + private func drainPacketQueue() -> [any Packet] { + packetQueueLock.lock() + let packets = packetQueue + packetQueue.removeAll() + queuedPacketKeys.removeAll() + packetQueueLock.unlock() + return packets + } + + private func clearPacketQueue() { + packetQueueLock.lock() + packetQueue.removeAll() + queuedPacketKeys.removeAll() + packetQueueLock.unlock() + } + + private func packetQueueKey(_ packet: any Packet) -> String? { + switch packet { + case let message as PacketMessage: + return message.messageId.isEmpty ? nil : "0x06:\(message.messageId)" + case let delivery as PacketDelivery: + return delivery.messageId.isEmpty ? nil : "0x08:\(delivery.toPublicKey):\(delivery.messageId)" + case let read as PacketRead: + guard !read.fromPublicKey.isEmpty, !read.toPublicKey.isEmpty else { return nil } + return "0x07:\(read.fromPublicKey):\(read.toPublicKey)" + case let typing as PacketTyping: + guard !typing.fromPublicKey.isEmpty, !typing.toPublicKey.isEmpty else { return nil } + return "0x0b:\(typing.fromPublicKey):\(typing.toPublicKey)" + case let sync as PacketSync: + return "0x19:\(sync.status.rawValue):\(sync.timestamp)" + default: + return nil + } + } } diff --git a/Rosetta/Core/Network/Protocol/Stream.swift b/Rosetta/Core/Network/Protocol/Stream.swift index a9c78de..e57760f 100644 --- a/Rosetta/Core/Network/Protocol/Stream.swift +++ b/Rosetta/Core/Network/Protocol/Stream.swift @@ -127,25 +127,27 @@ final class Stream: @unchecked Sendable { // MARK: - String (Int32 length + UTF-16 code units) func writeString(_ value: String) { - writeInt32(value.count) - for char in value { - for scalar in char.utf16 { - writeInt16(Int(scalar)) - } + let utf16Units = Array(value.utf16) + writeInt32(utf16Units.count) + for codeUnit in utf16Units { + writeInt16(Int(codeUnit)) } } func readString() -> String { let length = readInt32() - var result = "" - result.reserveCapacity(length) - for _ in 0.. bytesAvailable { + return "" } - return result + + var codeUnits = [UInt16]() + codeUnits.reserveCapacity(length) + for _ in 0..? private var hasNotifiedConnected = false + private(set) var isConnected = false + private var disconnectHandledForCurrentSocket = false var onConnected: (() -> Void)? var onDisconnected: ((Error?) -> Void)? @@ -30,6 +32,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD guard webSocketTask == nil else { return } isManuallyClosed = false hasNotifiedConnected = false + isConnected = false + disconnectHandledForCurrentSocket = false Self.logger.info("Connecting to \(self.url.absoluteString)") @@ -47,18 +51,24 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD reconnectTask = nil webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask = nil + isConnected = false } - func send(_ data: Data) { - guard let task = webSocketTask else { + @discardableResult + func send(_ data: Data, onFailure: ((Error?) -> Void)? = nil) -> Bool { + guard isConnected, let task = webSocketTask else { Self.logger.warning("Cannot send: no active connection") - return + return false } - task.send(.data(data)) { error in + task.send(.data(data)) { [weak self] error in + guard let self else { return } if let error { Self.logger.error("Send error: \(error.localizedDescription)") + onFailure?(error) + self.handleDisconnect(error: error) } } + return true } func sendText(_ text: String) { @@ -76,11 +86,16 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD Self.logger.info("WebSocket didOpen") guard !isManuallyClosed else { return } hasNotifiedConnected = true + isConnected = true + disconnectHandledForCurrentSocket = false + reconnectTask?.cancel() + reconnectTask = nil onConnected?() } nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { Self.logger.info("WebSocket didClose: \(closeCode.rawValue)") + isConnected = false handleDisconnect(error: nil) } @@ -114,16 +129,22 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD // MARK: - Reconnection private func handleDisconnect(error: Error?) { + if disconnectHandledForCurrentSocket { + return + } + disconnectHandledForCurrentSocket = true webSocketTask = nil + isConnected = false onDisconnected?(error) guard !isManuallyClosed else { return } - reconnectTask?.cancel() + guard reconnectTask == nil else { return } reconnectTask = Task { [weak self] in Self.logger.info("Reconnecting in 5 seconds...") try? await Task.sleep(nanoseconds: 5_000_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 6b153b5..5f358a0 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -20,6 +20,22 @@ final class SessionManager { private(set) var privateKeyHash: String? /// Hex-encoded raw private key, kept in memory for message decryption. private(set) var privateKeyHex: String? + private var lastTypingSentAt: [String: Int64] = [:] + private var syncBatchInProgress = false + private var syncRequestInFlight = false + private var stalledSyncBatchCount = 0 + private let maxStalledSyncBatches = 12 + private var latestSyncBatchMessageTimestamp: Int64 = 0 + private var pendingIncomingMessages: [PacketMessage] = [] + private var isProcessingIncomingMessages = false + private var pendingReadReceiptKeys: Set = [] + private var lastReadReceiptSentAt: [String: Int64] = [:] + private var requestedUserInfoKeys: Set = [] + private var pendingOutgoingRetryTasks: [String: Task] = [:] + private var pendingOutgoingPackets: [String: PacketMessage] = [:] + private var pendingOutgoingAttempts: [String: Int] = [:] + private let maxOutgoingRetryAttempts = 3 + private let maxOutgoingWaitingLifetimeMs: Int64 = 80_000 private init() { setupProtocolCallbacks() @@ -45,6 +61,11 @@ final class SessionManager { displayName = account.displayName ?? "" username = account.username ?? "" + // Warm local state immediately, then let network sync reconcile updates. + await DialogRepository.shared.bootstrap(accountPublicKey: account.publicKey) + await MessageRepository.shared.bootstrap(accountPublicKey: account.publicKey) + RecentSearchesRepository.shared.setAccount(account.publicKey) + // Generate private key hash for handshake let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex) privateKeyHash = hash @@ -65,33 +86,63 @@ final class SessionManager { throw CryptoError.decryptionFailed } - let messageId = UUID().uuidString - let timestamp = Int32(Date().timeIntervalSince1970) - - // Encrypt the message - let encrypted = try MessageCrypto.encryptOutgoing( - plaintext: text, - recipientPublicKeyHex: toPublicKey, - senderPrivateKeyHex: privKey + let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + let timestamp = Int64(Date().timeIntervalSince1970 * 1000) + let packet = try makeOutgoingPacket( + text: text, + toPublicKey: toPublicKey, + messageId: messageId, + timestamp: timestamp, + privateKeyHex: privKey, + privateKeyHash: hash ) - // Build packet - 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 + DialogRepository.shared.ensureDialog( + opponentKey: toPublicKey, + title: "", + username: "", + myPublicKey: currentPublicKey + ) // Optimistic UI update — show message immediately as "waiting" DialogRepository.shared.updateFromMessage( packet, myPublicKey: currentPublicKey, decryptedText: text ) + MessageRepository.shared.upsertFromMessagePacket( + packet, + myPublicKey: currentPublicKey, + decryptedText: text + ) // Send via WebSocket ProtocolManager.shared.sendPacket(packet) + registerOutgoingRetry(for: packet) + } + + /// Sends typing indicator with throttling (Android parity: max once per 2s per dialog). + func sendTypingIndicator(toPublicKey: String) { + guard toPublicKey != currentPublicKey, + let hash = privateKeyHash, + ProtocolManager.shared.connectionState == .authenticated + else { return } + + let now = Int64(Date().timeIntervalSince1970 * 1000) + let lastSent = lastTypingSentAt[toPublicKey] ?? 0 + if now - lastSent < 2_000 { + return + } + lastTypingSentAt[toPublicKey] = now + + var packet = PacketTyping() + packet.privateKey = hash + packet.fromPublicKey = currentPublicKey + packet.toPublicKey = toPublicKey + ProtocolManager.shared.sendPacket(packet) + } + + /// Sends read receipt for direct dialog. + func sendReadReceipt(toPublicKey: String) { + sendReadReceipt(toPublicKey: toPublicKey, force: false) } /// Ends the session and disconnects. @@ -99,10 +150,27 @@ final class SessionManager { ProtocolManager.shared.disconnect() privateKeyHash = nil privateKeyHex = nil + lastTypingSentAt.removeAll() + syncBatchInProgress = false + syncRequestInFlight = false + stalledSyncBatchCount = 0 + latestSyncBatchMessageTimestamp = 0 + pendingIncomingMessages.removeAll() + isProcessingIncomingMessages = false + pendingReadReceiptKeys.removeAll() + lastReadReceiptSentAt.removeAll() + requestedUserInfoKeys.removeAll() + pendingOutgoingRetryTasks.values.forEach { $0.cancel() } + pendingOutgoingRetryTasks.removeAll() + pendingOutgoingPackets.removeAll() + pendingOutgoingAttempts.removeAll() isAuthenticated = false currentPublicKey = "" displayName = "" username = "" + DialogRepository.shared.reset() + MessageRepository.shared.reset() + RecentSearchesRepository.shared.clearSession() } // MARK: - Protocol Callbacks @@ -112,55 +180,71 @@ final class SessionManager { proto.onMessageReceived = { [weak self] packet in guard let self else { return } - Task { @MainActor in - let myKey = self.currentPublicKey - var text: String - - if let privKey = self.privateKeyHex, !packet.content.isEmpty, !packet.chachaKey.isEmpty { - do { - text = try MessageCrypto.decryptIncoming( - ciphertext: packet.content, - encryptedKey: packet.chachaKey, - myPrivateKeyHex: privKey - ) - } catch { - Self.logger.error("Message decryption failed: \(error.localizedDescription)") - text = "(decryption failed)" - } - } else { - text = packet.content.isEmpty ? "" : "(encrypted)" - } - - DialogRepository.shared.updateFromMessage( - packet, myPublicKey: myKey, decryptedText: text - ) - - // Request user info for unknown opponents - let fromMe = packet.fromPublicKey == myKey - let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey - let dialog = DialogRepository.shared.dialogs[opponentKey] - if dialog?.opponentTitle.isEmpty == true, let hash = self.privateKeyHash { - var searchPacket = PacketSearch() - searchPacket.privateKey = hash - searchPacket.search = opponentKey - ProtocolManager.shared.sendPacket(searchPacket) - } + Task { @MainActor [weak self] in + self?.enqueueIncomingMessage(packet) } } - proto.onDeliveryReceived = { packet in + proto.onDeliveryReceived = { [weak self] packet in Task { @MainActor in - DialogRepository.shared.updateDeliveryStatus( + let opponentKey = MessageRepository.shared.dialogKey(forMessageId: packet.messageId) + ?? packet.toPublicKey + if MessageRepository.shared.isLatestMessage(packet.messageId, in: opponentKey) { + DialogRepository.shared.updateDeliveryStatus( + messageId: packet.messageId, + opponentKey: opponentKey, + status: .delivered + ) + } + MessageRepository.shared.updateDeliveryStatus( messageId: packet.messageId, - opponentKey: packet.toPublicKey, status: .delivered ) + self?.resolveOutgoingRetry(messageId: packet.messageId) } } - proto.onReadReceived = { packet in + proto.onReadReceived = { [weak self] packet in + guard let self else { return } Task { @MainActor in - DialogRepository.shared.markAsRead(opponentKey: packet.toPublicKey) + guard Self.isSupportedDirectReadPacket(packet, ownKey: self.currentPublicKey) else { + Self.logger.debug( + "Skipping unsupported read packet: from=\(packet.fromPublicKey), to=\(packet.toPublicKey)" + ) + return + } + + let fromKey = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + let toKey = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + let ownKey = self.currentPublicKey + let isOwnReadSync = fromKey == ownKey + let opponentKey = isOwnReadSync ? toKey : fromKey + guard !opponentKey.isEmpty else { return } + + if isOwnReadSync { + // Android parity: read sync from another own device means + // incoming messages in this dialog should become read locally. + DialogRepository.shared.markAsRead(opponentKey: opponentKey) + MessageRepository.shared.markIncomingAsRead( + opponentKey: opponentKey, + myPublicKey: ownKey + ) + return + } + + DialogRepository.shared.markOutgoingAsRead(opponentKey: opponentKey) + MessageRepository.shared.markOutgoingAsRead( + opponentKey: opponentKey, + myPublicKey: ownKey + ) + } + } + + proto.onTypingReceived = { [weak self] packet in + guard let self else { return } + Task { @MainActor in + guard packet.toPublicKey == self.currentPublicKey else { return } + MessageRepository.shared.markTyping(from: packet.fromPublicKey) } } @@ -214,7 +298,477 @@ final class SessionManager { } else { Self.logger.debug("Skipping UserInfo — no profile data to send") } + + // Android parity: request message synchronization after authentication. + self.requestSynchronize() + self.retryWaitingOutgoingMessagesAfterReconnect() + } + } + + proto.onSyncReceived = { [weak self] packet in + guard let self else { return } + Task { @MainActor in + self.syncRequestInFlight = false + + switch packet.status { + case .batchStart: + self.syncBatchInProgress = true + self.stalledSyncBatchCount = 0 + self.latestSyncBatchMessageTimestamp = 0 + Self.logger.debug("SYNC BATCH_START") + + case .batchEnd: + // Desktop/Android parity: never advance sync cursor + // before all inbound message tasks from this batch finish. + let queueDrained = await self.waitForInboundQueueToDrain() + if !queueDrained { + Self.logger.warning("SYNC BATCH_END timed out waiting inbound queue; requesting next batch with local cursor") + } + + let localCursor = self.loadLastSyncTimestamp() + let serverCursor = self.normalizeSyncTimestamp(packet.timestamp) + let batchCursor = self.latestSyncBatchMessageTimestamp + let nextCursor = max(localCursor, max(serverCursor, batchCursor)) + + if nextCursor > localCursor { + self.saveLastSyncTimestamp(nextCursor) + self.stalledSyncBatchCount = 0 + } else { + self.stalledSyncBatchCount += 1 + } + + if self.stalledSyncBatchCount >= self.maxStalledSyncBatches { + Self.logger.debug("SYNC stopped after stalled batches") + self.syncBatchInProgress = false + self.flushPendingReadReceipts() + self.stalledSyncBatchCount = 0 + return + } + + self.flushPendingReadReceipts() + self.requestSynchronize(cursor: nextCursor) + + case .notNeeded: + self.syncBatchInProgress = false + self.flushPendingReadReceipts() + self.stalledSyncBatchCount = 0 + Self.logger.debug("SYNC NOT_NEEDED") + } } } } + + private func enqueueIncomingMessage(_ packet: PacketMessage) { + pendingIncomingMessages.append(packet) + guard !isProcessingIncomingMessages else { return } + isProcessingIncomingMessages = true + + Task { @MainActor [weak self] in + guard let self else { return } + await self.processIncomingMessagesQueue() + } + } + + private func processIncomingMessagesQueue() async { + while !pendingIncomingMessages.isEmpty { + let batch = pendingIncomingMessages + pendingIncomingMessages.removeAll(keepingCapacity: true) + for packet in batch { + await processIncomingMessage(packet) + await Task.yield() + } + } + isProcessingIncomingMessages = false + } + + private func processIncomingMessage(_ packet: PacketMessage) async { + let myKey = currentPublicKey + let currentPrivateKeyHex = self.privateKeyHex + let currentPrivateKeyHash = self.privateKeyHash + + guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else { + Self.logger.debug( + "Skipping unsupported message packet: from=\(packet.fromPublicKey), to=\(packet.toPublicKey)" + ) + return + } + + let fromMe = packet.fromPublicKey == myKey + let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId) + + let decryptedText = Self.decryptIncomingMessage( + packet: packet, + myPublicKey: myKey, + privateKeyHex: currentPrivateKeyHex + ) + + guard let text = decryptedText else { + Self.logger.error( + "Incoming message dropped: \(packet.messageId), own=\(packet.fromPublicKey == myKey)" + ) + return + } + + DialogRepository.shared.updateFromMessage( + packet, myPublicKey: myKey, decryptedText: text + ) + MessageRepository.shared.upsertFromMessagePacket( + packet, + myPublicKey: myKey, + decryptedText: text + ) + + let dialog = DialogRepository.shared.dialogs[opponentKey] + + if dialog?.opponentTitle.isEmpty == true { + requestUserInfoIfNeeded(opponentKey: opponentKey, privateKeyHash: currentPrivateKeyHash) + } + + if !fromMe && !wasKnownBefore { + var deliveryPacket = PacketDelivery() + deliveryPacket.toPublicKey = packet.fromPublicKey + deliveryPacket.messageId = packet.messageId + ProtocolManager.shared.sendPacket(deliveryPacket) + } + + if MessageRepository.shared.isDialogActive(opponentKey) { + DialogRepository.shared.markAsRead(opponentKey: opponentKey) + MessageRepository.shared.markIncomingAsRead( + opponentKey: opponentKey, + myPublicKey: myKey + ) + if !fromMe && !wasKnownBefore { + if syncBatchInProgress { + pendingReadReceiptKeys.insert(opponentKey) + } else { + sendReadReceipt(toPublicKey: opponentKey) + } + } + } + + if syncBatchInProgress { + latestSyncBatchMessageTimestamp = max( + latestSyncBatchMessageTimestamp, + normalizeSyncTimestamp(packet.timestamp) + ) + } else { + saveLastSyncTimestamp(packet.timestamp) + } + } + + private func waitForInboundQueueToDrain(timeoutMs: UInt64 = 5_000) async -> Bool { + let started = DispatchTime.now().uptimeNanoseconds + let timeoutNs = timeoutMs * 1_000_000 + + while isProcessingIncomingMessages || !pendingIncomingMessages.isEmpty { + if DispatchTime.now().uptimeNanoseconds - started >= timeoutNs { + return false + } + try? await Task.sleep(for: .milliseconds(20)) + } + + return true + } + + private var syncCursorKey: String { + "rosetta_last_sync_\(currentPublicKey)" + } + + private func requestSynchronize(cursor: Int64? = nil) { + guard ProtocolManager.shared.connectionState == .authenticated else { return } + guard !syncRequestInFlight else { return } + + syncRequestInFlight = true + let lastSync = normalizeSyncTimestamp(cursor ?? loadLastSyncTimestamp()) + + var packet = PacketSync() + packet.status = .notNeeded + packet.timestamp = lastSync + + Self.logger.debug("Requesting sync with timestamp \(lastSync)") + ProtocolManager.shared.sendPacket(packet) + } + + private func loadLastSyncTimestamp() -> Int64 { + guard !currentPublicKey.isEmpty else { return 0 } + return Int64(UserDefaults.standard.integer(forKey: syncCursorKey)) + } + + private func saveLastSyncTimestamp(_ raw: Int64) { + guard !currentPublicKey.isEmpty else { return } + let normalized = normalizeSyncTimestamp(raw) + guard normalized > 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 + } + + private static func decryptIncomingMessage( + packet: PacketMessage, + myPublicKey: String, + privateKeyHex: String? + ) -> String? { + guard let privateKeyHex, + !packet.content.isEmpty + else { + return nil + } + + // Android parity for own sync packets: prefer aesChachaKey if present. + if packet.fromPublicKey == myPublicKey, + !packet.aesChachaKey.isEmpty, + let decryptedPayload = try? CryptoManager.shared.decryptWithPassword( + packet.aesChachaKey, + password: privateKeyHex + ) + { + let keyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(decryptedPayload) + if let text = try? MessageCrypto.decryptIncomingWithPlainKey( + ciphertext: packet.content, + plainKeyAndNonce: keyAndNonce + ) { + return text + } + Self.logger.debug("Own message fallback: aesChachaKey decoded but payload decryption failed for \(packet.messageId)") + } else if packet.fromPublicKey == myPublicKey, !packet.aesChachaKey.isEmpty { + Self.logger.debug("Own message fallback: failed to decode aesChachaKey for \(packet.messageId)") + } + + guard !packet.chachaKey.isEmpty else { + return nil + } + + do { + return try MessageCrypto.decryptIncoming( + ciphertext: packet.content, + encryptedKey: packet.chachaKey, + myPrivateKeyHex: privateKeyHex + ) + } catch { + Self.logger.error("Message decryption failed: \(error.localizedDescription)") + return nil + } + } + + private static func isUnsupportedDialogKey(_ value: String) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized.isEmpty { return true } + return normalized.hasPrefix("#") + || normalized.hasPrefix("group:") + || normalized.hasPrefix("conversation:") + } + + private static func isSupportedDirectPeerKey(_ peerKey: String, ownKey: String) -> Bool { + let normalized = peerKey.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.isEmpty { return false } + if normalized == ownKey { return true } + if SystemAccounts.isSystemAccount(normalized) { return true } + return !isUnsupportedDialogKey(normalized) + } + + private static func isSupportedDirectMessagePacket(_ packet: PacketMessage, ownKey: String) -> Bool { + if ownKey.isEmpty { return false } + let from = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + let to = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + if from.isEmpty || to.isEmpty { return false } + + if from == ownKey { + return isSupportedDirectPeerKey(to, ownKey: ownKey) + } + if to == ownKey { + return isSupportedDirectPeerKey(from, ownKey: ownKey) + } + return false + } + + private static func isSupportedDirectReadPacket(_ packet: PacketRead, ownKey: String) -> Bool { + if ownKey.isEmpty { return false } + let from = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + let to = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + if from.isEmpty || to.isEmpty { return false } + + if from == ownKey { + return isSupportedDirectPeerKey(to, ownKey: ownKey) + } + if to == ownKey { + return isSupportedDirectPeerKey(from, ownKey: ownKey) + } + return false + } + + private func requestUserInfoIfNeeded(opponentKey: String, privateKeyHash: String?) { + guard let privateKeyHash else { return } + let normalized = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + guard !requestedUserInfoKeys.contains(normalized) else { return } + + requestedUserInfoKeys.insert(normalized) + + var searchPacket = PacketSearch() + searchPacket.privateKey = privateKeyHash + searchPacket.search = normalized + ProtocolManager.shared.sendPacket(searchPacket) + } + + private func makeOutgoingPacket( + text: String, + toPublicKey: String, + messageId: String, + timestamp: Int64, + privateKeyHex: String, + privateKeyHash: String + ) throws -> PacketMessage { + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: text, + recipientPublicKeyHex: toPublicKey + ) + + guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { + throw CryptoError.encryptionFailed + } + let aesChachaPayload = Data(latin1String.utf8) + let aesChachaKey = try CryptoManager.shared.encryptWithPassword( + aesChachaPayload, + password: privateKeyHex + ) + + var packet = PacketMessage() + packet.fromPublicKey = currentPublicKey + packet.toPublicKey = toPublicKey + packet.content = encrypted.content + packet.chachaKey = encrypted.chachaKey + packet.timestamp = timestamp + packet.privateKey = privateKeyHash + packet.messageId = messageId + packet.aesChachaKey = aesChachaKey + return packet + } + + private func retryWaitingOutgoingMessagesAfterReconnect() { + guard !currentPublicKey.isEmpty, + let privateKeyHex, + let privateKeyHash + else { return } + + let now = Int64(Date().timeIntervalSince1970 * 1000) + let result = MessageRepository.shared.resolveWaitingOutgoingMessages( + myPublicKey: currentPublicKey, + nowMs: now, + maxAgeMs: maxOutgoingWaitingLifetimeMs + ) + + for expired in result.expired { + if MessageRepository.shared.isLatestMessage(expired.messageId, in: expired.dialogKey) { + DialogRepository.shared.updateDeliveryStatus( + messageId: expired.messageId, + opponentKey: expired.dialogKey, + status: .error + ) + } + resolveOutgoingRetry(messageId: expired.messageId) + } + + for message in result.retryable { + if message.toPublicKey == currentPublicKey { + continue + } + + let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { continue } + + do { + let packet = try makeOutgoingPacket( + text: text, + toPublicKey: message.toPublicKey, + messageId: message.id, + timestamp: message.timestamp, + privateKeyHex: privateKeyHex, + privateKeyHash: privateKeyHash + ) + ProtocolManager.shared.sendPacket(packet) + registerOutgoingRetry(for: packet) + } catch { + Self.logger.error("Failed to retry waiting message \(message.id): \(error.localizedDescription)") + } + } + } + + private func flushPendingReadReceipts() { + guard !pendingReadReceiptKeys.isEmpty else { return } + let keys = pendingReadReceiptKeys + pendingReadReceiptKeys.removeAll() + for key in keys { + sendReadReceipt(toPublicKey: key, force: true) + } + } + + private func sendReadReceipt(toPublicKey: String, force: Bool) { + let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized != currentPublicKey, + !normalized.isEmpty, + let hash = privateKeyHash, + ProtocolManager.shared.connectionState == .authenticated + else { return } + + let now = Int64(Date().timeIntervalSince1970 * 1000) + if !force { + let lastSent = lastReadReceiptSentAt[normalized] ?? 0 + if now - lastSent < 400 { + return + } + } + lastReadReceiptSentAt[normalized] = now + + var packet = PacketRead() + packet.privateKey = hash + packet.fromPublicKey = currentPublicKey + packet.toPublicKey = normalized + ProtocolManager.shared.sendPacket(packet) + } + + private func registerOutgoingRetry(for packet: PacketMessage) { + let messageId = packet.messageId + pendingOutgoingRetryTasks[messageId]?.cancel() + pendingOutgoingPackets[messageId] = packet + pendingOutgoingAttempts[messageId] = 0 + scheduleOutgoingRetry(messageId: messageId) + } + + private func scheduleOutgoingRetry(messageId: String) { + pendingOutgoingRetryTasks[messageId]?.cancel() + pendingOutgoingRetryTasks[messageId] = Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(for: .seconds(4)) + + guard let packet = self.pendingOutgoingPackets[messageId] else { return } + let attempts = self.pendingOutgoingAttempts[messageId] ?? 0 + guard attempts < self.maxOutgoingRetryAttempts else { + self.resolveOutgoingRetry(messageId: messageId) + return + } + + guard ProtocolManager.shared.connectionState == .authenticated else { + self.scheduleOutgoingRetry(messageId: messageId) + return + } + + let nextAttempt = attempts + 1 + self.pendingOutgoingAttempts[messageId] = nextAttempt + Self.logger.warning("Retrying message \(messageId), attempt \(nextAttempt)") + ProtocolManager.shared.sendPacket(packet) + self.scheduleOutgoingRetry(messageId: messageId) + } + } + + private func resolveOutgoingRetry(messageId: String) { + pendingOutgoingRetryTasks[messageId]?.cancel() + pendingOutgoingRetryTasks.removeValue(forKey: messageId) + pendingOutgoingPackets.removeValue(forKey: messageId) + pendingOutgoingAttempts.removeValue(forKey: messageId) + } } diff --git a/Rosetta/DesignSystem/Components/LottieView.swift b/Rosetta/DesignSystem/Components/LottieView.swift index ac1166c..906a102 100644 --- a/Rosetta/DesignSystem/Components/LottieView.swift +++ b/Rosetta/DesignSystem/Components/LottieView.swift @@ -45,6 +45,15 @@ struct LottieView: UIViewRepresentable, Equatable { lhs.isPlaying == rhs.isPlaying } + func makeCoordinator() -> Coordinator { + Coordinator() + } + + final class Coordinator { + var didPlayOnce = false + var lastAnimationName = "" + } + func makeUIView(context: Context) -> LottieAnimationView { let animationView: LottieAnimationView if let cached = LottieAnimationCache.shared.animation(named: animationName) { @@ -57,21 +66,45 @@ struct LottieView: UIViewRepresentable, Equatable { animationView.animationSpeed = animationSpeed animationView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) animationView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - if isPlaying { - animationView.play() - } + context.coordinator.lastAnimationName = animationName + playIfNeeded(animationView, coordinator: context.coordinator) return animationView } func updateUIView(_ uiView: LottieAnimationView, context: Context) { - if isPlaying { - if !uiView.isAnimationPlaying { - uiView.play() + if context.coordinator.lastAnimationName != animationName { + if let cached = LottieAnimationCache.shared.animation(named: animationName) { + uiView.animation = cached + } else { + uiView.animation = LottieAnimation.named(animationName) } - } else { + context.coordinator.lastAnimationName = animationName + context.coordinator.didPlayOnce = false + } + + uiView.loopMode = loopMode + uiView.animationSpeed = animationSpeed + + if !isPlaying { + context.coordinator.didPlayOnce = false if uiView.isAnimationPlaying { uiView.stop() } + return + } + + playIfNeeded(uiView, coordinator: context.coordinator) + } + + private func playIfNeeded(_ view: LottieAnimationView, coordinator: Coordinator) { + if loopMode == .playOnce { + guard !coordinator.didPlayOnce else { return } + coordinator.didPlayOnce = true + view.play() + return + } + if !view.isAnimationPlaying { + view.play() } } } diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index 4bac4ee..1a3ebf4 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -1,21 +1,6 @@ import SwiftUI import UIKit -// MARK: - Glass Effect ID Modifier (iOS 26+) - -private struct GlassEffectIDModifier: ViewModifier { - let id: String - let namespace: Namespace.ID? - - nonisolated func body(content: Content) -> some View { - if #available(iOS 26, *), let namespace { - content.glassEffectID(id, in: namespace) - } else { - content - } - } -} - // MARK: - Tab enum RosettaTab: CaseIterable, Sendable { @@ -23,11 +8,13 @@ enum RosettaTab: CaseIterable, Sendable { case settings case search + static let interactionOrder: [RosettaTab] = [.chats, .settings, .search] + var label: String { switch self { case .chats: return "Chats" case .settings: return "Settings" - case .search: return "" + case .search: return "Search" } } @@ -46,6 +33,10 @@ enum RosettaTab: CaseIterable, Sendable { case .search: return "magnifyingglass" } } + + var interactionIndex: Int { + Self.interactionOrder.firstIndex(of: self) ?? 0 + } } // MARK: - Tab Badge @@ -55,27 +46,47 @@ struct TabBadge { let text: String } +struct TabBarSwipeState { + let fromTab: RosettaTab + let hoveredTab: RosettaTab + let fractionalIndex: CGFloat +} + // MARK: - RosettaTabBar struct RosettaTabBar: View { let selectedTab: RosettaTab var onTabSelected: ((RosettaTab) -> Void)? + var onSwipeStateChanged: ((TabBarSwipeState?) -> Void)? var badges: [TabBadge] = [] - @Namespace private var glassNS + @State private var tabFrames: [RosettaTab: CGRect] = [:] + @State private var interactionState: TabPressInteraction? + + private static let tabBarSpace = "RosettaTabBarSpace" + private let lensLiftOffset: CGFloat = 12 var body: some View { - if #available(iOS 26, *) { - GlassEffectContainer(spacing: 8) { - tabBarContent - } + interactiveTabBarContent .padding(.horizontal, 25) .padding(.top, 4) - } else { - tabBarContent - .padding(.horizontal, 25) - .padding(.top, 4) - } + } + + private var interactiveTabBarContent: some View { + tabBarContent + .coordinateSpace(name: Self.tabBarSpace) + .onPreferenceChange(TabFramePreferenceKey.self) { frames in + tabFrames = frames + } + .contentShape(Rectangle()) + .gesture(tabSelectionGesture) + .overlay(alignment: .topLeading) { + liftedLensOverlay + } + .onDisappear { + interactionState = nil + onSwipeStateChanged?(nil) + } } private var tabBarContent: some View { @@ -84,29 +95,185 @@ struct RosettaTabBar: View { searchPill } } + + private var visualSelectedTab: RosettaTab { + if let interactionState, interactionState.isLifted { + return interactionState.hoveredTab + } + return selectedTab + } + + private var tabSelectionGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .named(Self.tabBarSpace)) + .onChanged(handleGestureChanged) + .onEnded(handleGestureEnded) + } + + private func handleGestureChanged(_ value: DragGesture.Value) { + guard !tabFrames.isEmpty else { + return + } + + if interactionState == nil { + guard let startTab = tabAtStart(location: value.startLocation), + let startFrame = tabFrames[startTab] + else { + return + } + + let state = TabPressInteraction( + id: UUID(), + startTab: startTab, + startCenterX: startFrame.midX, + currentCenterX: startFrame.midX, + hoveredTab: startTab, + isLifted: true + ) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + interactionState = state + publishSwipeState(for: state) + return + } + + guard var state = interactionState else { + return + } + + state.currentCenterX = clampedCenterX(state.startCenterX + value.translation.width) + + if let nearest = nearestTab(toX: state.currentCenterX), nearest != state.hoveredTab { + state.hoveredTab = nearest + if state.isLifted { + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + } + } + + interactionState = state + publishSwipeState(for: state) + } + + private func handleGestureEnded(_ value: DragGesture.Value) { + guard let state = interactionState else { + return + } + + let targetTab = nearestTab(toX: value.location.x) ?? state.hoveredTab + + withAnimation(.spring(response: 0.34, dampingFraction: 0.72)) { + interactionState = nil + } + + onSwipeStateChanged?(nil) + onTabSelected?(targetTab) + } + + private func publishSwipeState(for state: TabPressInteraction) { + guard state.isLifted, + let fractionalIndex = fractionalIndex(for: state.currentCenterX) + else { + onSwipeStateChanged?(nil) + return + } + + onSwipeStateChanged?( + TabBarSwipeState( + fromTab: state.startTab, + hoveredTab: state.hoveredTab, + fractionalIndex: fractionalIndex + ) + ) + } + + private func tabAtStart(location: CGPoint) -> RosettaTab? { + guard let nearest = nearestTab(toX: location.x), + let frame = tabFrames[nearest] + else { + return nil + } + + return frame.insetBy(dx: -18, dy: -18).contains(location) ? nearest : nil + } + + private func nearestTab(toX x: CGFloat) -> RosettaTab? { + tabFrames.min { lhs, rhs in + abs(lhs.value.midX - x) < abs(rhs.value.midX - x) + }?.key + } + + private func clampedCenterX(_ value: CGFloat) -> CGFloat { + let centers = tabFrames.values.map(\.midX) + guard let minX = centers.min(), let maxX = centers.max() else { + return value + } + return min(max(value, minX), maxX) + } + + private func fractionalIndex(for centerX: CGFloat) -> CGFloat? { + let centers = RosettaTab.interactionOrder.compactMap { tab -> CGFloat? in + tabFrames[tab]?.midX + } + + guard centers.count == RosettaTab.interactionOrder.count else { + return nil + } + + if centerX <= centers[0] { + return 0 + } + if centerX >= centers[centers.count - 1] { + return CGFloat(centers.count - 1) + } + + for index in 0 ..< centers.count - 1 { + let left = centers[index] + let right = centers[index + 1] + guard centerX >= left, centerX <= right else { + continue + } + let progress = (centerX - left) / max(1, right - left) + return CGFloat(index) + progress + } + + return nil + } + + private func badgeText(for tab: RosettaTab) -> String? { + badges.first(where: { $0.tab == tab })?.text + } + + private func isCoveredByLens(_ tab: RosettaTab) -> Bool { + interactionState?.isLifted == true && interactionState?.hoveredTab == tab + } + + private func lensDiameter(for tab: RosettaTab) -> CGFloat { + switch tab { + case .search: + return 88 + default: + return 104 + } + } } // MARK: - Main Tabs Pill private extension RosettaTabBar { var mainTabsPill: some View { - // Content on top — NOT clipped (lens can pop out) HStack(spacing: 0) { ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in - TabItemButton( + TabItemView( tab: tab, - isSelected: tab == selectedTab, - badgeText: badges.first(where: { $0.tab == tab })?.text, - onTap: { onTabSelected?(tab) }, - glassNamespace: glassNS + isSelected: tab == visualSelectedTab, + isCoveredByLens: isCoveredByLens(tab), + badgeText: badgeText(for: tab) ) + .tabFramePreference(tab: tab, in: Self.tabBarSpace) } } .padding(.horizontal, 4) .padding(.top, 3) .padding(.bottom, 3) .frame(height: 62) - // Background clipped separately — content stays unclipped .background { mainPillGlass } @@ -114,106 +281,117 @@ private extension RosettaTabBar { @ViewBuilder var mainPillGlass: some View { - if #available(iOS 26, *) { - Capsule().fill(.clear).glassEffect(.regular, in: .capsule) - } else { - ZStack { - // 1. Material - Capsule().fill(.ultraThinMaterial) - // 2. Dark tint - Capsule().fill(Color.black.opacity(0.22)) - // 3. Highlight - Capsule().fill( - LinearGradient( - colors: [Color.white.opacity(0.14), .clear], - startPoint: .top, - endPoint: .bottom - ) - ).blendMode(.screen) - // 4a. Outer stroke - Capsule().stroke(Color.white.opacity(0.10), lineWidth: 1) - // 4b. Inner stroke - Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) - } - // 5. Shadows - .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) + ZStack { + Capsule().fill(.ultraThinMaterial) + Capsule().fill(Color.black.opacity(0.34)) + Capsule().fill( + LinearGradient( + colors: [Color.white.opacity(0.08), .clear], + startPoint: .top, + endPoint: .bottom + ) + ).blendMode(.screen) + Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1) + Capsule().stroke(Color.white.opacity(0.08), lineWidth: 1).padding(1.5) } + .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) + } + + @ViewBuilder + var liftedLensOverlay: some View { + if let state = interactionState, + state.isLifted, + let hoveredFrame = tabFrames[state.hoveredTab] + { + let diameter = lensDiameter(for: state.hoveredTab) + + ZStack { + lensBubble + LensTabContentView( + tab: state.hoveredTab, + badgeText: badgeText(for: state.hoveredTab) + ) + .padding(.top, state.hoveredTab == .search ? 0 : 8) + } + .frame(width: diameter, height: diameter) + .position(x: state.currentCenterX, y: hoveredFrame.midY - lensLiftOffset) + .shadow(color: .black.opacity(0.42), radius: 24, y: 15) + .shadow(color: Color.cyan.opacity(0.10), radius: 20, y: 1) + .allowsHitTesting(false) + .transition(.scale(scale: 0.86).combined(with: .opacity)) + .animation(.spring(response: 0.34, dampingFraction: 0.74), value: state.isLifted) + .zIndex(20) + } + } + + @ViewBuilder + var lensBubble: some View { + ZStack { + Circle().fill(.ultraThinMaterial) + Circle().fill(Color.black.opacity(0.38)) + Circle().fill( + LinearGradient( + colors: [Color.white.opacity(0.08), Color.white.opacity(0.01), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + Circle().stroke(Color.white.opacity(0.16), lineWidth: 1) + Circle().stroke(Color.white.opacity(0.06), lineWidth: 1).padding(1.6) + Circle().stroke( + AngularGradient( + colors: [ + Color.cyan.opacity(0.34), + Color.blue.opacity(0.28), + Color.pink.opacity(0.28), + Color.orange.opacity(0.30), + Color.yellow.opacity(0.20), + Color.cyan.opacity(0.34), + ], + center: .center + ), + lineWidth: 1.1 + ).blendMode(.screen) + } + .compositingGroup() } } -// MARK: - Tab Item Button +// MARK: - Tab Item -private struct TabItemButton: View { +private struct TabItemView: View { let tab: RosettaTab let isSelected: Bool + let isCoveredByLens: Bool let badgeText: String? - let onTap: () -> Void - var glassNamespace: Namespace.ID? - - @State private var pressed = false var body: some View { - Button(action: onTap) { - VStack(spacing: 1) { - ZStack(alignment: .topTrailing) { - Image(systemName: isSelected ? tab.selectedIcon : tab.icon) - .font(.system(size: 22)) - .foregroundStyle(tabColor) - .frame(height: 30) - - if let badgeText { - Text(badgeText) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white) - .padding(.horizontal, badgeText.count > 2 ? 4 : 0) - .frame(minWidth: 18, minHeight: 18) - .background(Capsule().fill(RosettaColors.error)) - .offset(x: 10, y: -4) - } - } - - Text(tab.label) - .font(.system(size: 10, weight: isSelected ? .bold : .medium)) + VStack(spacing: 1) { + ZStack(alignment: .topTrailing) { + Image(systemName: isSelected ? tab.selectedIcon : tab.icon) + .font(.system(size: 22)) .foregroundStyle(tabColor) - } - .frame(maxWidth: .infinity) - .background { - if isSelected && !pressed { - RoundedRectangle(cornerRadius: 100) - .fill(RosettaColors.adaptive( - light: Color(hex: 0xEDEDED), - dark: Color.white.opacity(0.12) - )) - .padding(.horizontal, -8) - .padding(.vertical, -6) + .frame(height: 30) + + if let badgeText { + Text(badgeText) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, badgeText.count > 2 ? 4 : 0) + .frame(minWidth: 18, minHeight: 18) + .background(Capsule().fill(RosettaColors.error)) + .offset(x: 10, y: -4) } } + + Text(tab.label) + .font(.system(size: 10, weight: isSelected ? .bold : .medium)) + .foregroundStyle(tabColor) } - .buttonStyle(.plain) - // Lens: padding → glass bubble → scale → lift + .frame(maxWidth: .infinity) .padding(14) - .background { - if pressed { - lensBubble - .transition(.scale(scale: 0.8).combined(with: .opacity)) - } - } - .scaleEffect(pressed ? 1.12 : 1) - .offset(y: pressed ? -28 : 0) - .shadow(color: .black.opacity(pressed ? 0.45 : 0), radius: 22, y: 14) - .shadow(color: Color.cyan.opacity(pressed ? 0.12 : 0), radius: 20, y: 0) - .animation(.spring(response: 0.34, dampingFraction: 0.65), value: pressed) - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - if !pressed { - pressed = true - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - } - .onEnded { _ in pressed = false } - ) - .modifier(GlassEffectIDModifier(id: "\(tab)", namespace: glassNamespace)) + .opacity(isCoveredByLens ? 0.07 : 1) + .animation(.easeInOut(duration: 0.14), value: isCoveredByLens) .accessibilityLabel(tab.label) .accessibilityAddTraits(isSelected ? .isSelected : []) } @@ -226,177 +404,126 @@ private struct TabItemButton: View { dark: Color(hex: 0x8E8E93) ) } - - // MARK: Lens Bubble - - @ViewBuilder - private var lensBubble: some View { - if #available(iOS 26, *) { - Circle() - .fill(.clear) - .glassEffect(.regular.interactive(), in: .circle) - } else { - ZStack { - // 1. Material - Circle().fill(.ultraThinMaterial) - // 2. Dark tint - Circle().fill(Color.black.opacity(0.22)) - // 3. Highlight (top→bottom, screen blend) - Circle().fill( - LinearGradient( - colors: [Color.white.opacity(0.14), .clear], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ).blendMode(.screen) - // 4a. Outer stroke - Circle().stroke(Color.white.opacity(0.10), lineWidth: 1) - // 4b. Inner stroke - Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) - // 6. Rainbow (thin, subtle, screen blend) - Circle().stroke( - AngularGradient( - colors: [ - Color.cyan.opacity(0.55), - Color.blue.opacity(0.55), - Color.purple.opacity(0.55), - Color.pink.opacity(0.55), - Color.orange.opacity(0.55), - Color.yellow.opacity(0.45), - Color.cyan.opacity(0.55), - ], - center: .center - ), - lineWidth: 1.4 - ).blendMode(.screen) - } - } - } } // MARK: - Search Pill private extension RosettaTabBar { var searchPill: some View { - SearchPillButton( - isSelected: selectedTab == .search, - onTap: { onTabSelected?(.search) }, - glassNamespace: glassNS + SearchPillView( + isSelected: visualSelectedTab == .search, + isCoveredByLens: isCoveredByLens(.search) ) + .tabFramePreference(tab: .search, in: Self.tabBarSpace) } } -private struct SearchPillButton: View { +private struct SearchPillView: View { let isSelected: Bool - let onTap: () -> Void - var glassNamespace: Namespace.ID? - - @State private var pressed = false + let isCoveredByLens: Bool var body: some View { - Button(action: onTap) { - Image(systemName: "magnifyingglass") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle( - isSelected - ? RosettaColors.primaryBlue - : RosettaColors.adaptive( - light: Color(hex: 0x404040), - dark: Color(hex: 0x8E8E93) - ) - ) - } - .buttonStyle(.plain) - // Lens - .padding(14) - .background { - if pressed { - searchLensBubble - .transition(.scale(scale: 0.8).combined(with: .opacity)) - } - } - .scaleEffect(pressed ? 1.15 : 1) - .offset(y: pressed ? -28 : 0) - .shadow(color: .black.opacity(pressed ? 0.45 : 0), radius: 22, y: 14) - .shadow(color: Color.cyan.opacity(pressed ? 0.12 : 0), radius: 20, y: 0) - .animation(.spring(response: 0.34, dampingFraction: 0.65), value: pressed) - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - if !pressed { - pressed = true - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - } - .onEnded { _ in pressed = false } - ) - .frame(width: 62, height: 62) - // Background clipped separately - .background { searchPillGlass } - .modifier(GlassEffectIDModifier(id: "search", namespace: glassNamespace)) - .accessibilityLabel("Search") - .accessibilityAddTraits(isSelected ? .isSelected : []) - } - - // MARK: Lens for search - - @ViewBuilder - private var searchLensBubble: some View { - if #available(iOS 26, *) { - Circle() - .fill(.clear) - .glassEffect(.regular.interactive(), in: .circle) - } else { - ZStack { - Circle().fill(.ultraThinMaterial) - Circle().fill(Color.black.opacity(0.22)) - Circle().fill( - LinearGradient( - colors: [Color.white.opacity(0.14), .clear], - startPoint: .topLeading, - endPoint: .bottomTrailing + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle( + isSelected + ? RosettaColors.primaryBlue + : RosettaColors.adaptive( + light: Color(hex: 0x404040), + dark: Color(hex: 0x8E8E93) ) - ).blendMode(.screen) - Circle().stroke(Color.white.opacity(0.10), lineWidth: 1) - Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) - Circle().stroke( - AngularGradient( - colors: [ - Color.cyan.opacity(0.55), - Color.blue.opacity(0.55), - Color.purple.opacity(0.55), - Color.pink.opacity(0.55), - Color.orange.opacity(0.55), - Color.yellow.opacity(0.45), - Color.cyan.opacity(0.55), - ], - center: .center - ), - lineWidth: 1.4 - ).blendMode(.screen) - } - } + ) + .frame(width: 62, height: 62) + .opacity(isCoveredByLens ? 0.08 : 1) + .animation(.easeInOut(duration: 0.14), value: isCoveredByLens) + .background { searchPillGlass } + .accessibilityLabel("Search") + .accessibilityAddTraits(isSelected ? .isSelected : []) } @ViewBuilder private var searchPillGlass: some View { - if #available(iOS 26, *) { - Circle().fill(.clear).glassEffect(.regular, in: .circle) + ZStack { + Circle().fill(.ultraThinMaterial) + Circle().fill(Color.black.opacity(0.34)) + Circle().fill( + LinearGradient( + colors: [Color.white.opacity(0.08), .clear], + startPoint: .top, + endPoint: .bottom + ) + ).blendMode(.screen) + Circle().stroke(Color.white.opacity(0.12), lineWidth: 1) + Circle().stroke(Color.white.opacity(0.08), lineWidth: 1).padding(1.5) + } + .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) + } +} + +private struct LensTabContentView: View { + let tab: RosettaTab + let badgeText: String? + + var body: some View { + if tab == .search { + Image(systemName: "magnifyingglass") + .font(.system(size: 29, weight: .semibold)) + .foregroundStyle(RosettaColors.primaryBlue) } else { - ZStack { - Circle().fill(.ultraThinMaterial) - Circle().fill(Color.black.opacity(0.22)) - Circle().fill( - LinearGradient( - colors: [Color.white.opacity(0.14), .clear], - startPoint: .top, - endPoint: .bottom - ) - ).blendMode(.screen) - Circle().stroke(Color.white.opacity(0.10), lineWidth: 1) - Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) + VStack(spacing: 3) { + ZStack(alignment: .topTrailing) { + Image(systemName: tab.selectedIcon) + .font(.system(size: 30)) + .foregroundStyle(.white) + .frame(height: 36) + + if let badgeText { + Text(badgeText) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, badgeText.count > 2 ? 5 : 0) + .frame(minWidth: 20, minHeight: 20) + .background(Capsule().fill(RosettaColors.error)) + .offset(x: 16, y: -9) + } + } + + Text(tab.label) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.primaryBlue) + } + } + } +} + +// MARK: - Geometry Helpers + +private struct TabPressInteraction { + let id: UUID + let startTab: RosettaTab + let startCenterX: CGFloat + var currentCenterX: CGFloat + var hoveredTab: RosettaTab + var isLifted: Bool +} + +private struct TabFramePreferenceKey: PreferenceKey { + static var defaultValue: [RosettaTab: CGRect] = [:] + + static func reduce(value: inout [RosettaTab: CGRect], nextValue: () -> [RosettaTab: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +private extension View { + func tabFramePreference(tab: RosettaTab, in coordinateSpace: String) -> some View { + background { + GeometryReader { proxy in + Color.clear.preference( + key: TabFramePreferenceKey.self, + value: [tab: proxy.frame(in: .named(coordinateSpace))] + ) } - .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) } } } @@ -407,13 +534,6 @@ private struct SearchPillButton: View { ZStack(alignment: .bottom) { Color.black.ignoresSafeArea() - VStack { - Spacer() - Text("Hold a tab to see the lens") - .foregroundStyle(.white.opacity(0.5)) - Spacer() - } - RosettaTabBar( selectedTab: .chats, badges: [ diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift new file mode 100644 index 0000000..459e673 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -0,0 +1,978 @@ +import SwiftUI + +struct ChatDetailView: View { + let route: ChatRoute + var onPresentedChange: ((Bool) -> Void)? = nil + + @Environment(\.dismiss) private var dismiss + @ObservedObject private var messageRepository = MessageRepository.shared + @State private var dialogRepository = DialogRepository.shared + + @State private var messageText = "" + @State private var sendError: String? + @FocusState private var isInputFocused: Bool + + private var currentPublicKey: String { + SessionManager.shared.currentPublicKey + } + + private var dialog: Dialog? { + dialogRepository.dialogs[route.publicKey] + } + + private var messages: [ChatMessage] { + messageRepository.messages(for: route.publicKey) + } + + private var isTyping: Bool { + messageRepository.isTyping(dialogKey: route.publicKey) + } + + private var titleText: String { + if route.isSavedMessages { + return "Saved Messages" + } + if let dialog, !dialog.opponentTitle.isEmpty { + return dialog.opponentTitle + } + if !route.title.isEmpty { + return route.title + } + if let dialog, !dialog.opponentUsername.isEmpty { + return "@\(dialog.opponentUsername)" + } + if !route.username.isEmpty { + return "@\(route.username)" + } + return String(route.publicKey.prefix(12)) + } + + private var subtitleText: String { + if isTyping { + return "typing..." + } + if let dialog, dialog.isOnline { + return "online" + } + if let dialog, !dialog.opponentUsername.isEmpty { + return "@\(dialog.opponentUsername)" + } + if !route.username.isEmpty { + return "@\(route.username)" + } + return String(route.publicKey.prefix(12)) + } + + private var trimmedMessage: String { + messageText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSend: Bool { + !trimmedMessage.isEmpty + } + + private var shouldShowSendButton: Bool { + !messageText.isEmpty + } + + private var composerHorizontalPadding: CGFloat { + isInputFocused ? 16 : 28 + } + + private var composerAnimation: Animation { + .spring(response: 0.28, dampingFraction: 0.9) + } + + private var messagesTopInset: CGFloat { 6 } + + private static let scrollBottomAnchorId = "chat_detail_bottom_anchor" + + var body: some View { + GeometryReader { geometry in + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140)) + } + .safeAreaInset(edge: .top, spacing: 0) { + chatHeaderContainer + } + .safeAreaInset(edge: .bottom, spacing: 0) { + composer + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { + onPresentedChange?(true) + activateDialog() + markDialogAsRead() + } + .onDisappear { + onPresentedChange?(false) + messageRepository.setDialogActive(route.publicKey, isActive: false) + } + .onChange(of: messageText) { _, newValue in + if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey) + } + } + } + } +} + +private extension ChatDetailView { + var avatarInitials: String { + if route.isSavedMessages { + return "S" + } + return RosettaColors.initials(name: titleText, publicKey: route.publicKey) + } + + var avatarColorIndex: Int { + RosettaColors.avatarColorIndex(for: route.publicKey) + } + + var incomingBubbleFill: Color { + RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E)) + } + + var chatHeader: some View { + HStack(spacing: 10) { + Button { + dismiss() + } label: { + TelegramVectorIcon( + pathData: TelegramIconPath.backChevron, + viewBox: CGSize(width: 11, height: 20), + color: .white + ) + .frame(width: 11, height: 20) + .frame(width: 44, height: 44) + .background { + headerCircleBackground(strokeOpacity: 0.22) + } + } + .accessibilityLabel("Back") + .buttonStyle(ChatDetailGlassPressButtonStyle()) + + Spacer(minLength: 6) + + VStack(spacing: 0) { + Text(titleText) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + Text(subtitleText) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle( + isTyping || (dialog?.isOnline == true) + ? RosettaColors.online + : RosettaColors.Adaptive.textSecondary + ) + .lineLimit(1) + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background { + headerCapsuleBackground(strokeOpacity: 0.20) + } + + Spacer(minLength: 6) + + AvatarView( + initials: avatarInitials, + colorIndex: avatarColorIndex, + size: 38, + isOnline: dialog?.isOnline ?? false, + isSavedMessages: route.isSavedMessages + ) + .frame(width: 44, height: 44) + .background { + headerCircleBackground(strokeOpacity: 0.22) + } + } + .padding(.vertical, 2) + .background(Color.clear) + } + + @ViewBuilder + var chatHeaderContainer: some View { + VStack(spacing: 0) { + chatHeader + .padding(.horizontal, 12) + .padding(.top, 0) + .padding(.bottom, 6) + + Rectangle() + .fill(Color.white.opacity(0.06)) + .frame(height: 0.5) + } + } + + @ViewBuilder + func messagesList(maxBubbleWidth: CGFloat) -> some View { + ScrollViewReader { proxy in + let scroll = ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 6) { + ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in + messageRow( + message, + maxBubbleWidth: maxBubbleWidth, + isTailVisible: isTailVisible(for: index) + ) + .id(message.id) + } + + if messages.isEmpty { + VStack(spacing: 10) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 34, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.45)) + Text("Start messaging") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + .padding(.top, 24) + } + + Color.clear + .frame(height: 1) + .id(Self.scrollBottomAnchorId) + } + .padding(.horizontal, 10) + .padding(.top, messagesTopInset) + .padding(.bottom, 10) + } + .scrollDismissesKeyboard(.interactively) + .onTapGesture { + isInputFocused = false + } + .onAppear { + DispatchQueue.main.async { + scrollToBottom(proxy: proxy, animated: false) + } + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(120)) + scrollToBottom(proxy: proxy, animated: false) + } + } + .onChange(of: messages.count) { _, _ in + scrollToBottom(proxy: proxy, animated: true) + markDialogAsRead() + } + .onChange(of: isInputFocused) { _, focused in + guard focused else { return } + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(80)) + scrollToBottom(proxy: proxy, animated: true) + } + } + + scroll + .defaultScrollAnchor(.bottom) + .scrollIndicators(.hidden) + } + } + + @ViewBuilder + func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, isTailVisible: Bool) -> some View { + let outgoing = message.isFromMe(myPublicKey: currentPublicKey) + let messageText = message.text.isEmpty ? " " : message.text + let textMaxWidth = max(maxBubbleWidth - 28, 40) + + HStack(spacing: 0) { + if outgoing { + Spacer(minLength: 56) + } + + VStack(alignment: .leading, spacing: 3) { + Text(messageText) + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) + .multilineTextAlignment(.leading) + .lineSpacing(0) + .frame(maxWidth: textMaxWidth, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 4) { + Text(messageTime(message.timestamp)) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle( + outgoing ? Color.white.opacity(0.72) : RosettaColors.Adaptive.textSecondary + ) + + if outgoing { + deliveryIndicator(message.deliveryStatus) + } + } + .frame(maxWidth: textMaxWidth, alignment: .trailing) + } + .padding(.horizontal, 14) + .padding(.top, 8) + .padding(.bottom, 6) + .background { + bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) + } + .frame(maxWidth: maxBubbleWidth, alignment: .leading) + + if !outgoing { + Spacer(minLength: 56) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 1) + } + + var composer: some View { + VStack(spacing: 6) { + if let sendError { + Text(sendError) + .font(.system(size: 12)) + .foregroundStyle(RosettaColors.error) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + } + + HStack(alignment: .bottom, spacing: 6) { + Button { + // Placeholder for attachment picker + } label: { + TelegramVectorIcon( + pathData: TelegramIconPath.paperclip, + viewBox: CGSize(width: 21, height: 24), + color: Color.white + ) + .frame(width: 21, height: 24) + .frame(width: 42, height: 42) + .background { floatingCircleBackground(strokeOpacity: 0.18) } + } + .accessibilityLabel("Attach") + .buttonStyle(ChatDetailGlassPressButtonStyle()) + + HStack(alignment: .bottom, spacing: 0) { + TextField("Message", text: $messageText, axis: .vertical) + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1...5) + .focused($isInputFocused) + .textInputAutocapitalization(.sentences) + .autocorrectionDisabled() + .padding(.leading, 6) + .padding(.top, 6) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading) + + HStack(alignment: .center, spacing: 8) { + Button { + // Placeholder for quick actions + } label: { + TelegramVectorIcon( + pathData: TelegramIconPath.emojiMoon, + viewBox: CGSize(width: 19, height: 19), + color: RosettaColors.Adaptive.textSecondary + ) + .frame(width: 19, height: 19) + .frame(width: 20, height: 36) + } + .accessibilityLabel("Quick actions") + .buttonStyle(ChatDetailGlassPressButtonStyle()) + } + .padding(.trailing, 8) + .frame(height: 36, alignment: .center) + + if shouldShowSendButton { + Button(action: sendCurrentMessage) { + ZStack { + // Mirrors the layered blend stack from the original SVG icon. + TelegramVectorIcon( + pathData: TelegramIconPath.sendPlane, + viewBox: CGSize(width: 22, height: 19), + color: .white + ) + .blendMode(.difference) + + TelegramVectorIcon( + pathData: TelegramIconPath.sendPlane, + viewBox: CGSize(width: 22, height: 19), + color: .white + ) + .blendMode(.saturation) + + TelegramVectorIcon( + pathData: TelegramIconPath.sendPlane, + viewBox: CGSize(width: 22, height: 19), + color: .white + ) + .blendMode(.overlay) + + TelegramVectorIcon( + pathData: TelegramIconPath.sendPlane, + viewBox: CGSize(width: 22, height: 19), + color: .black + ) + .blendMode(.overlay) + + TelegramVectorIcon( + pathData: TelegramIconPath.sendPlane, + viewBox: CGSize(width: 22, height: 19), + color: .white + ) + .blendMode(.overlay) + + TelegramVectorIcon( + pathData: TelegramIconPath.sendPlane, + viewBox: CGSize(width: 22, height: 19), + color: .black + ) + .blendMode(.overlay) + } + .compositingGroup() + .frame(width: 22, height: 19) + .frame(width: 44, height: 36) + .background { + Capsule().fill(Color(hex: 0x008BFF)) + } + } + .accessibilityLabel("Send") + .disabled(!canSend) + .buttonStyle(ChatDetailGlassPressButtonStyle()) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .padding(3) + .frame(minHeight: 42, alignment: .bottom) + .background { + floatingComposerInputBackground( + strokeOpacity: 0.18 + ) + } + + if !shouldShowSendButton { + Button(action: trailingAction) { + TelegramVectorIcon( + pathData: TelegramIconPath.microphone, + viewBox: CGSize(width: 18, height: 24), + color: Color.white + ) + .frame(width: 18, height: 24) + .frame(width: 42, height: 42) + .background { floatingCircleBackground(strokeOpacity: 0.18) } + } + .accessibilityLabel("Voice message") + .buttonStyle(ChatDetailGlassPressButtonStyle()) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .padding(.horizontal, composerHorizontalPadding) + .padding(.top, 4) + .padding(.bottom, isInputFocused ? 8 : 0) + .animation(composerAnimation, value: canSend) + .animation(composerAnimation, value: shouldShowSendButton) + .animation(composerAnimation, value: isInputFocused) + } + .background(Color.clear) + } + + @ViewBuilder + func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View { + let nearRadius: CGFloat = isTailVisible ? 6 : 17 + let bubbleRadius: CGFloat = 17 + let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill + if #available(iOS 17.0, *) { + UnevenRoundedRectangle( + cornerRadii: .init( + topLeading: bubbleRadius, + bottomLeading: outgoing ? bubbleRadius : nearRadius, + bottomTrailing: outgoing ? nearRadius : bubbleRadius, + topTrailing: bubbleRadius + ), + style: .continuous + ) + .fill(fill) + } else { + RoundedRectangle(cornerRadius: bubbleRadius, style: .continuous) + .fill(fill) + } + } + + @ViewBuilder + func floatingCapsuleBackground(strokeOpacity: Double) -> some View { + if #available(iOS 26.0, *) { + Color.clear + .glassEffect(.regular, in: .capsule) + } else { + Capsule() + .fill(.ultraThinMaterial) + .overlay( + Capsule() + .stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8) + ) + } + } + + @ViewBuilder + func floatingComposerInputBackground(strokeOpacity: Double) -> some View { + if #available(iOS 26.0, *) { + let shape = RoundedRectangle(cornerRadius: 21, style: .continuous) + shape + .fill(.clear) + .glassEffect(.regular, in: .rect(cornerRadius: 21)) + } else { + let shape = RoundedRectangle(cornerRadius: 21, style: .continuous) + shape + .fill(.ultraThinMaterial) + .overlay( + shape + .stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8) + ) + } + } + + @ViewBuilder + func headerCapsuleBackground(strokeOpacity: Double) -> some View { + if #available(iOS 26.0, *) { + Color.clear + .glassEffect(.regular, in: .capsule) + } else { + Capsule() + .fill(.ultraThinMaterial) + .overlay( + Capsule() + .stroke(Color.white.opacity(strokeOpacity), lineWidth: 0.8) + ) + } + } + + @ViewBuilder + func headerCircleBackground(strokeOpacity: Double) -> some View { + if #available(iOS 26.0, *) { + Color.clear + .glassEffect(.regular, in: .circle) + } else { + Circle() + .fill(.ultraThinMaterial) + .overlay( + Circle() + .stroke(Color.white.opacity(strokeOpacity), lineWidth: 0.8) + ) + } + } + + @ViewBuilder + func floatingCircleBackground(strokeOpacity: Double) -> some View { + if #available(iOS 26.0, *) { + Color.clear + .glassEffect(.regular, in: .circle) + } else { + Circle() + .fill(.ultraThinMaterial) + .overlay( + Circle() + .stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8) + ) + } + } + + func trailingAction() { + if canSend { + sendCurrentMessage() + } else { + isInputFocused = true + } + } + + func deliveryTint(_ status: DeliveryStatus) -> Color { + switch status { + case .read: + return Color(hex: 0xA4E2FF) + case .delivered: + return Color.white.opacity(0.94) + case .error: + return RosettaColors.error + default: + return Color.white.opacity(0.78) + } + } + + func deliveryIcon(_ status: DeliveryStatus) -> String { + switch status { + case .waiting: return "clock" + case .delivered: return "checkmark" + case .read: return "checkmark" + case .error: return "exclamationmark.circle.fill" + } + } + + @ViewBuilder + func deliveryIndicator(_ status: DeliveryStatus) -> some View { + switch status { + case .read: + ZStack { + Image(systemName: "checkmark") + .offset(x: 3) + Image(systemName: "checkmark") + } + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(deliveryTint(status)) + .frame(width: 13, alignment: .trailing) + default: + Image(systemName: deliveryIcon(status)) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(deliveryTint(status)) + } + } + + func messageTime(_ timestamp: Int64) -> String { + Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000)) + } + + func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { + if animated { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(Self.scrollBottomAnchorId, anchor: .bottom) + } + } else { + proxy.scrollTo(Self.scrollBottomAnchorId, anchor: .bottom) + } + } + + func isTailVisible(for index: Int) -> Bool { + guard index < messages.count else { return true } + let current = messages[index] + guard index + 1 < messages.count else { return true } + let next = messages[index + 1] + let sameSender = current.isFromMe(myPublicKey: currentPublicKey) == next.isFromMe(myPublicKey: currentPublicKey) + + // Group only plain text bubbles from the same side. + let currentIsPlainText = current.attachments.isEmpty + let nextIsPlainText = next.attachments.isEmpty + + return !(sameSender && currentIsPlainText && nextIsPlainText) + } + + func activateDialog() { + DialogRepository.shared.ensureDialog( + opponentKey: route.publicKey, + title: route.title, + username: route.username, + verified: route.verified, + myPublicKey: currentPublicKey + ) + messageRepository.setDialogActive(route.publicKey, isActive: true) + } + + func markDialogAsRead() { + DialogRepository.shared.markAsRead(opponentKey: route.publicKey) + messageRepository.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey) + SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey) + } + + func sendCurrentMessage() { + let message = trimmedMessage + guard !message.isEmpty else { return } + messageText = "" + sendError = nil + + Task { @MainActor in + do { + try await SessionManager.shared.sendMessage(text: message, toPublicKey: route.publicKey) + } catch { + sendError = "Failed to send message" + if messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + messageText = message + } + } + } + } + + static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + return formatter + }() +} + +private struct ChatDetailGlassPressButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 1.04 : 1.0) + .brightness(configuration.isPressed ? 0.06 : 0) + .overlay { + if configuration.isPressed { + Capsule(style: .continuous) + .fill(Color.white.opacity(0.16)) + .padding(1) + .allowsHitTesting(false) + } + } + .animation(.spring(response: 0.22, dampingFraction: 0.72), value: configuration.isPressed) + } +} + +private struct TelegramVectorIcon: View { + let pathData: String + let viewBox: CGSize + let color: Color + + var body: some View { + SVGPathShape(pathData: pathData, viewBox: viewBox) + .fill(color) + } +} + +private struct SVGPathShape: Shape { + let pathData: String + let viewBox: CGSize + + func path(in rect: CGRect) -> Path { + guard viewBox.width > 0, viewBox.height > 0 else { + return Path() + } + + var parser = SVGPathParser(pathData: pathData) + var output = Path(parser.parse()) + output = output.applying( + .init(scaleX: rect.width / viewBox.width, y: rect.height / viewBox.height) + ) + return output + } +} + +private enum SVGPathToken { + case command(Character) + case number(CGFloat) +} + +private struct SVGPathTokenizer { + static func tokenize(_ source: String) -> [SVGPathToken] { + var tokens: [SVGPathToken] = [] + let chars = Array(source) + var index = 0 + + while index < chars.count { + let ch = chars[index] + + if ch.isWhitespace || ch == "," { + index += 1 + continue + } + + if ch.isLetter { + tokens.append(.command(ch)) + index += 1 + continue + } + + if ch.isNumber || ch == "-" || ch == "+" || ch == "." { + let start = index + index += 1 + + while index < chars.count { + let c = chars[index] + let prev = chars[index - 1] + + if c.isNumber || c == "." || c == "e" || c == "E" { + index += 1 + continue + } + + if (c == "-" || c == "+"), (prev == "e" || prev == "E") { + index += 1 + continue + } + + break + } + + let fragment = String(chars[start.. CGPath { + while index < tokens.count { + let command = readCommandOrReuse() + switch command { + case "M", "m": + parseMove(command) + case "L", "l": + parseLine(command) + case "H", "h": + parseHorizontal(command) + case "V", "v": + parseVertical(command) + case "C", "c": + parseCubic(command) + case "Z", "z": + cgPath.closeSubpath() + current = subpathStart + default: + skipToNextCommand() + } + } + return cgPath.copy() ?? CGMutablePath() + } + + private var isAtCommand: Bool { + guard index < tokens.count else { return false } + if case .command = tokens[index] { + return true + } + return false + } + + private mutating func readCommandOrReuse() -> Character { + guard index < tokens.count else { return lastCommand } + if case let .command(command) = tokens[index] { + index += 1 + lastCommand = command + return command + } + return lastCommand + } + + private mutating func readNumber() -> CGFloat? { + guard index < tokens.count else { return nil } + if case let .number(value) = tokens[index] { + index += 1 + return value + } + return nil + } + + private func resolvedPoint(x: CGFloat, y: CGFloat, relative: Bool) -> CGPoint { + if relative { + return CGPoint(x: current.x + x, y: current.y + y) + } + return CGPoint(x: x, y: y) + } + + private mutating func readPoint(relative: Bool) -> CGPoint? { + guard let x = readNumber(), let y = readNumber() else { return nil } + return resolvedPoint(x: x, y: y, relative: relative) + } + + private mutating func parseMove(_ command: Character) { + let relative = command.isLowercase + guard let first = readPoint(relative: relative) else { return } + cgPath.move(to: first) + current = first + subpathStart = first + + while !isAtCommand, let point = readPoint(relative: relative) { + cgPath.addLine(to: point) + current = point + } + + lastCommand = relative ? "l" : "L" + } + + private mutating func parseLine(_ command: Character) { + let relative = command.isLowercase + while !isAtCommand, let point = readPoint(relative: relative) { + cgPath.addLine(to: point) + current = point + } + } + + private mutating func parseHorizontal(_ command: Character) { + let relative = command.isLowercase + while !isAtCommand, let value = readNumber() { + current = CGPoint( + x: relative ? current.x + value : value, + y: current.y + ) + cgPath.addLine(to: current) + } + } + + private mutating func parseVertical(_ command: Character) { + let relative = command.isLowercase + while !isAtCommand, let value = readNumber() { + current = CGPoint( + x: current.x, + y: relative ? current.y + value : value + ) + cgPath.addLine(to: current) + } + } + + private mutating func parseCubic(_ command: Character) { + let relative = command.isLowercase + while !isAtCommand { + guard let x1 = readNumber(), + let y1 = readNumber(), + let x2 = readNumber(), + let y2 = readNumber(), + let x = readNumber(), + let y = readNumber() + else { return } + + let c1 = resolvedPoint(x: x1, y: y1, relative: relative) + let c2 = resolvedPoint(x: x2, y: y2, relative: relative) + let end = resolvedPoint(x: x, y: y, relative: relative) + + cgPath.addCurve(to: end, control1: c1, control2: c2) + current = end + } + } + + private mutating func skipToNextCommand() { + while index < tokens.count { + if case .command = tokens[index] { + return + } + index += 1 + } + } +} + +private enum TelegramIconPath { + static let backChevron = #"M0.317383 10.5957C0.203451 10.498 0.12207 10.376 0.0732422 10.2295C0.0244141 10.0993 0 9.96094 0 9.81445C0 9.66797 0.0244141 9.52962 0.0732422 9.39941C0.12207 9.25293 0.203451 9.13086 0.317383 9.0332L8.83789 0.317383C8.93555 0.219727 9.05762 0.138346 9.2041 0.0732422C9.33431 0.0244141 9.47266 0 9.61914 0C9.74935 0 9.87956 0.0244141 10.0098 0.0732422C10.1562 0.138346 10.2783 0.219727 10.376 0.317383C10.4899 0.431315 10.5713 0.553385 10.6201 0.683594C10.6689 0.830078 10.6934 0.976562 10.6934 1.12305C10.6934 1.25326 10.6689 1.3916 10.6201 1.53809C10.5713 1.66829 10.4899 1.79036 10.376 1.9043L2.63672 9.81445L10.376 17.7246C10.4899 17.8385 10.5713 17.9606 10.6201 18.0908C10.6689 18.2373 10.6934 18.3757 10.6934 18.5059C10.6934 18.6523 10.6689 18.7988 10.6201 18.9453C10.5713 19.0755 10.4899 19.1976 10.376 19.3115C10.2783 19.4092 10.1562 19.4906 10.0098 19.5557C9.87956 19.6045 9.74935 19.6289 9.61914 19.6289C9.47266 19.6289 9.33431 19.6045 9.2041 19.5557C9.05762 19.4906 8.93555 19.4092 8.83789 19.3115L0.317383 10.5957Z"# + + static let paperclip = #"M11.0156 17.9297L9.84375 16.7871L17.4316 9.11133C17.8418 8.70117 18.1543 8.22266 18.3691 7.67578C18.584 7.14844 18.6914 6.5918 18.6914 6.00586C18.6914 5.43945 18.584 4.88281 18.3691 4.33594C18.1348 3.80859 17.8125 3.33984 17.4023 2.92969C16.9922 2.51953 16.5137 2.20703 15.9668 1.99219C15.4395 1.75781 14.8828 1.65039 14.2969 1.66992C13.7109 1.66992 13.1543 1.77734 12.627 1.99219C12.0801 2.22656 11.6016 2.54883 11.1914 2.95898L3.60352 10.6055C2.97852 11.2305 2.5 11.9531 2.16797 12.7734C1.83594 13.5742 1.66992 14.4141 1.66992 15.293C1.66992 16.1719 1.8457 17.0215 2.19727 17.8418C2.5293 18.6426 3.00781 19.3555 3.63281 19.9805C4.25781 20.6055 4.98047 21.084 5.80078 21.416C6.62109 21.748 7.4707 21.9141 8.34961 21.9141C9.22852 21.8945 10.0684 21.7188 10.8691 21.3867C11.6895 21.0547 12.4121 20.5762 13.0371 19.9512L18.5449 14.3848C18.7012 14.2285 18.8965 14.1504 19.1309 14.1504C19.3652 14.1504 19.5605 14.2285 19.7168 14.3848C19.873 14.541 19.9512 14.7363 19.9512 14.9707C19.9707 15.2051 19.8926 15.4004 19.7168 15.5566L14.1211 21.1816C13.3594 21.9434 12.4805 22.5293 11.4844 22.9395C10.4688 23.3496 9.42383 23.5547 8.34961 23.5547C8.33008 23.5547 8.04688 23.5547 7.5 23.5547C6.95312 23.5547 6.17188 23.3496 5.15625 22.9395C4.14062 22.5293 3.24219 21.9336 2.46094 21.1523C1.67969 20.3906 1.07422 19.502 0.644531 18.4863C0.234375 17.4707 0.0195312 16.4062 0 15.293V15.2637C0 14.1699 0.214844 13.125 0.644531 12.1289C1.05469 11.1133 1.64062 10.2148 2.40234 9.43359L10.0195 1.78711C10.5859 1.2207 11.2402 0.78125 11.9824 0.46875C12.7246 0.15625 13.4961 0 14.2969 0H14.3262C15.1074 0 15.8691 0.146484 16.6113 0.439453C17.3535 0.751953 18.0078 1.18164 18.5742 1.72852C19.1406 2.29492 19.5801 2.94922 19.8926 3.69141C20.2051 4.43359 20.3613 5.20508 20.3613 6.00586V6.03516C20.3613 6.83594 20.2148 7.59766 19.9219 8.32031C19.6094 9.0625 19.1699 9.7168 18.6035 10.2832L11.0156 17.9297ZM10.957 6.88477C11.0352 6.80664 11.1328 6.74805 11.25 6.70898C11.3477 6.66992 11.4453 6.65039 11.543 6.65039C11.6602 6.65039 11.7676 6.66992 11.8652 6.70898C11.9629 6.74805 12.0508 6.80664 12.1289 6.88477C12.207 6.96289 12.2754 7.05078 12.334 7.14844C12.373 7.24609 12.3926 7.35352 12.3926 7.4707C12.3926 7.56836 12.373 7.67578 12.334 7.79297C12.2754 7.89063 12.207 7.97852 12.1289 8.05664L6.62109 13.623C6.40625 13.8184 6.25 14.0527 6.15234 14.3262C6.03516 14.6191 5.97656 14.9121 5.97656 15.2051C5.97656 15.498 6.03516 15.7812 6.15234 16.0547C6.26953 16.3281 6.43555 16.5723 6.65039 16.7871C6.86523 17.002 7.10938 17.168 7.38281 17.2852C7.65625 17.3828 7.93945 17.4316 8.23242 17.4316C8.54492 17.4316 8.83789 17.373 9.11133 17.2559C9.38477 17.1387 9.62891 16.9824 9.84375 16.7871L11.0156 17.9297C10.6445 18.3008 10.2246 18.584 9.75586 18.7793C9.26758 18.9941 8.75977 19.1016 8.23242 19.1016C7.70508 19.1016 7.20703 19.0039 6.73828 18.8086C6.26953 18.6133 5.84961 18.3301 5.47852 17.959C5.10742 17.5879 4.82422 17.168 4.62891 16.6992C4.41406 16.2305 4.30664 15.7324 4.30664 15.2051V15.1758C4.30664 14.6875 4.4043 14.209 4.59961 13.7402C4.77539 13.291 5.0293 12.8809 5.36133 12.5098L10.957 6.88477Z"# + + static let emojiMoon = #"M5.79492 9.92773C5.8099 9.92773 5.82487 9.92773 5.83984 9.92773C5.85482 9.91276 5.86979 9.90527 5.88477 9.90527C5.95964 9.90527 6.03451 9.92025 6.10938 9.9502C6.16927 9.96517 6.22917 9.99512 6.28906 10.04C6.36393 10.0999 6.43132 10.1673 6.49121 10.2422C6.55111 10.3171 6.59603 10.3994 6.62598 10.4893C6.6709 10.5941 6.71582 10.6914 6.76074 10.7812C6.82064 10.8711 6.88053 10.9535 6.94043 11.0283C7.00033 11.1182 7.06771 11.2005 7.14258 11.2754C7.21745 11.3652 7.29232 11.4401 7.36719 11.5C7.50195 11.6198 7.63672 11.7171 7.77148 11.792C7.90625 11.8818 8.0485 11.9492 8.19824 11.9941C8.36296 12.054 8.52767 12.099 8.69238 12.1289C8.84212 12.1589 8.99935 12.1738 9.16406 12.1738C9.32878 12.1738 9.49349 12.1589 9.6582 12.1289C9.82292 12.099 9.98014 12.054 10.1299 11.9941C10.2796 11.9492 10.4219 11.8893 10.5566 11.8145C10.7064 11.7246 10.8486 11.6273 10.9834 11.5225C11.0583 11.4626 11.1331 11.3877 11.208 11.2979C11.2679 11.223 11.3278 11.1406 11.3877 11.0508C11.4626 10.9609 11.5225 10.8711 11.5674 10.7812C11.6273 10.6914 11.6797 10.5941 11.7246 10.4893C11.7546 10.3994 11.7995 10.3245 11.8594 10.2646C11.9043 10.1898 11.9642 10.1224 12.0391 10.0625C12.099 10.0176 12.1663 9.98763 12.2412 9.97266C12.3011 9.94271 12.3685 9.92773 12.4434 9.92773C12.4583 9.92773 12.4733 9.92773 12.4883 9.92773C12.5033 9.92773 12.5182 9.93522 12.5332 9.9502C12.638 9.9502 12.7204 9.97266 12.7803 10.0176C12.8551 10.0625 12.9225 10.1224 12.9824 10.1973C13.0273 10.2572 13.0573 10.3096 13.0723 10.3545C13.0872 10.4144 13.0947 10.4743 13.0947 10.5342C13.0947 10.5641 13.0947 10.5866 13.0947 10.6016C13.0798 10.6315 13.0723 10.6615 13.0723 10.6914C13.0124 10.8711 12.9375 11.0433 12.8477 11.208C12.7728 11.3877 12.6829 11.5524 12.5781 11.7021C12.4733 11.8669 12.361 12.0166 12.2412 12.1514C12.1214 12.3011 11.9867 12.4359 11.8369 12.5557C11.6423 12.7204 11.4401 12.8626 11.2305 12.9824C11.0208 13.1022 10.8037 13.207 10.5791 13.2969C10.3545 13.3867 10.1224 13.4541 9.88281 13.499C9.64323 13.5439 9.39616 13.5664 9.1416 13.5664C8.90202 13.5664 8.66243 13.5439 8.42285 13.499C8.18327 13.4541 7.94368 13.3867 7.7041 13.2969C7.47949 13.207 7.26237 13.1022 7.05273 12.9824C6.8431 12.8477 6.64095 12.7054 6.44629 12.5557C6.31152 12.4209 6.18424 12.2861 6.06445 12.1514C5.92969 12.0016 5.8099 11.8444 5.70508 11.6797C5.61523 11.5299 5.52539 11.3727 5.43555 11.208C5.36068 11.0433 5.29329 10.8711 5.2334 10.6914C5.21842 10.6465 5.21094 10.609 5.21094 10.5791C5.21094 10.5492 5.21094 10.5192 5.21094 10.4893C5.21094 10.4443 5.21842 10.3919 5.2334 10.332C5.24837 10.2871 5.27083 10.2422 5.30078 10.1973C5.42057 10.0326 5.58529 9.94271 5.79492 9.92773ZM6.44629 6.08691C6.59603 6.08691 6.73079 6.11686 6.85059 6.17676C6.98535 6.23665 7.09766 6.3265 7.1875 6.44629C7.29232 6.56608 7.37467 6.69336 7.43457 6.82812C7.47949 6.96289 7.50195 7.10514 7.50195 7.25488C7.50195 7.4196 7.47949 7.57682 7.43457 7.72656C7.37467 7.86133 7.29232 7.98861 7.1875 8.1084C7.09766 8.21322 6.98535 8.29557 6.85059 8.35547C6.73079 8.41536 6.59603 8.44531 6.44629 8.44531C6.29655 8.44531 6.16178 8.41536 6.04199 8.35547C5.9222 8.29557 5.80241 8.21322 5.68262 8.1084C5.59277 7.98861 5.5179 7.86133 5.45801 7.72656C5.41309 7.57682 5.39062 7.4196 5.39062 7.25488C5.39062 7.10514 5.41309 6.96289 5.45801 6.82812C5.5179 6.69336 5.59277 6.56608 5.68262 6.44629C5.80241 6.3265 5.9222 6.23665 6.04199 6.17676C6.16178 6.11686 6.29655 6.08691 6.44629 6.08691ZM11.6123 6.08691C11.7471 6.08691 11.8818 6.11686 12.0166 6.17676C12.1364 6.23665 12.2487 6.3265 12.3535 6.44629C12.4583 6.56608 12.5332 6.69336 12.5781 6.82812C12.638 6.96289 12.668 7.10514 12.668 7.25488C12.668 7.4196 12.638 7.57682 12.5781 7.72656C12.5332 7.86133 12.4583 7.98861 12.3535 8.1084C12.2487 8.21322 12.1364 8.29557 12.0166 8.35547C11.8818 8.41536 11.7471 8.44531 11.6123 8.44531C11.4626 8.44531 11.3203 8.41536 11.1855 8.35547C11.0658 8.29557 10.9535 8.21322 10.8486 8.1084C10.7438 7.98861 10.6689 7.86133 10.624 7.72656C10.5791 7.57682 10.5566 7.4196 10.5566 7.25488C10.5566 7.10514 10.5791 6.96289 10.624 6.82812C10.6689 6.69336 10.7438 6.56608 10.8486 6.44629C10.9535 6.3265 11.0658 6.23665 11.1855 6.17676C11.3203 6.11686 11.4626 6.08691 11.6123 6.08691ZM6.06445 1.99902C5.60026 2.19368 5.16602 2.43327 4.76172 2.71777C4.35742 2.9873 3.98307 3.29427 3.63867 3.63867C3.29427 3.98307 2.9873 4.35742 2.71777 4.76172C2.44824 5.16602 2.20866 5.60026 1.99902 6.06445C1.6097 6.97786 1.41504 7.96615 1.41504 9.0293C1.41504 9.55339 1.45996 10.0625 1.5498 10.5566C1.65462 11.0508 1.80436 11.5299 1.99902 11.9941C2.20866 12.4583 2.44824 12.8926 2.71777 13.2969C2.9873 13.7012 3.29427 14.0755 3.63867 14.4199C3.98307 14.7643 4.35742 15.0713 4.76172 15.3408C5.16602 15.6104 5.60026 15.8499 6.06445 16.0596C6.52865 16.2542 7.00781 16.404 7.50195 16.5088C7.99609 16.5986 8.50521 16.6436 9.0293 16.6436C9.55339 16.6436 10.0625 16.5986 10.5566 16.5088C11.0508 16.404 11.5299 16.2542 11.9941 16.0596C12.4583 15.8499 12.8926 15.6104 13.2969 15.3408C13.7012 15.0713 14.0755 14.7643 14.4199 14.4199C14.7643 14.0755 15.0713 13.7012 15.3408 13.2969C15.6104 12.8926 15.8499 12.4583 16.0596 11.9941C16.2542 11.5299 16.404 11.0508 16.5088 10.5566C16.5986 10.0625 16.6436 9.55339 16.6436 9.0293C16.6436 8.49023 16.5986 7.97363 16.5088 7.47949C16.404 6.98535 16.2542 6.51367 16.0596 6.06445C15.8499 5.60026 15.6104 5.16602 15.3408 4.76172C15.0713 4.35742 14.7643 3.98307 14.4199 3.63867C14.0755 3.29427 13.7012 2.9873 13.2969 2.71777C12.8926 2.43327 12.4583 2.19368 11.9941 1.99902C11.5299 1.80436 11.0508 1.65462 10.5566 1.5498C10.0625 1.44499 9.55339 1.39258 9.0293 1.39258C8.50521 1.39258 7.99609 1.44499 7.50195 1.5498C7.00781 1.65462 6.52865 1.80436 6.06445 1.99902ZM12.5332 0.696289C13.0872 0.935872 13.6038 1.21289 14.083 1.52734C14.5622 1.85677 15.0039 2.22363 15.4082 2.62793C15.8275 3.0472 16.1943 3.49642 16.5088 3.97559C16.8382 4.45475 17.1227 4.96387 17.3623 5.50293C17.6019 6.05697 17.7741 6.62598 17.8789 7.20996C17.9987 7.79395 18.0586 8.40039 18.0586 9.0293C18.0586 9.64323 17.9987 10.2497 17.8789 10.8486C17.7741 11.4326 17.6019 11.9941 17.3623 12.5332C17.1227 13.0872 16.8382 13.6038 16.5088 14.083C16.1943 14.5771 15.8275 15.0264 15.4082 15.4307C14.5996 16.2393 13.6413 16.8831 12.5332 17.3623C11.9941 17.5869 11.4326 17.7591 10.8486 17.8789C10.2646 17.9987 9.6582 18.0586 9.0293 18.0586C8.40039 18.0586 7.79395 17.9987 7.20996 17.8789C6.62598 17.7591 6.06445 17.5869 5.52539 17.3623C4.97135 17.1227 4.44727 16.8457 3.95312 16.5312C3.47396 16.2018 3.03223 15.835 2.62793 15.4307C1.81934 14.6071 1.17546 13.6413 0.696289 12.5332C0.456706 11.9941 0.284505 11.4326 0.179688 10.8486C0.0598958 10.2497 0 9.64323 0 9.0293C0 8.40039 0.0598958 7.79395 0.179688 7.20996C0.284505 6.62598 0.456706 6.05697 0.696289 5.50293C0.935872 4.96387 1.21289 4.45475 1.52734 3.97559C1.85677 3.49642 2.22363 3.0472 2.62793 2.62793C3.03223 2.22363 3.47396 1.85677 3.95312 1.52734C4.44727 1.21289 4.97135 0.935872 5.52539 0.696289C6.06445 0.456706 6.62598 0.277018 7.20996 0.157227C7.79395 0.0524089 8.40039 0 9.0293 0C9.6582 0 10.2646 0.0524089 10.8486 0.157227C11.4326 0.277018 11.9941 0.456706 12.5332 0.696289Z"# + + static let sendPlane = #"M1.47656 7.84766C4.42969 6.57161 6.89062 5.50521 8.85938 4.64844C10.8281 3.8099 12.3047 3.18099 13.2891 2.76172C15.1849 1.97786 16.6159 1.39453 17.582 1.01172C18.5664 0.628906 19.3047 0.364583 19.7969 0.21875C20.2344 0.0729167 20.5807 0 20.8359 0H20.8633C20.9727 0 21.0911 0.0182292 21.2188 0.0546875C21.3828 0.0911458 21.5195 0.154948 21.6289 0.246094C21.7201 0.31901 21.793 0.410156 21.8477 0.519531C21.8659 0.592448 21.8932 0.683594 21.9297 0.792969C21.9297 0.865885 21.9388 0.947917 21.957 1.03906C21.957 1.14844 21.957 1.2487 21.957 1.33984C21.957 1.39453 21.957 1.4401 21.957 1.47656C21.957 1.51302 21.957 1.54948 21.957 1.58594C21.8112 3.02604 21.474 5.40495 20.9453 8.72266C20.4896 11.4753 20.0612 13.9544 19.6602 16.1602C19.5326 16.8529 19.332 17.3815 19.0586 17.7461C18.8398 18.0378 18.5755 18.2018 18.2656 18.2383C18.2292 18.2383 18.2018 18.2383 18.1836 18.2383C18.1654 18.2383 18.138 18.2383 18.1016 18.2383C17.8464 18.2383 17.5911 18.1927 17.3359 18.1016C17.099 18.0104 16.8529 17.8919 16.5977 17.7461C16.4154 17.6367 16.1693 17.4727 15.8594 17.2539C15.4948 16.9805 15.2396 16.7982 15.0938 16.707C14.474 16.306 13.7083 15.7956 12.7969 15.1758C11.8672 14.5378 11.1198 14.0365 10.5547 13.6719C10.1536 13.3984 9.87109 13.1341 9.70703 12.8789C9.5612 12.6602 9.50651 12.4414 9.54297 12.2227C9.57943 12.0221 9.69792 11.8034 9.89844 11.5664C10.0078 11.4206 10.2174 11.2018 10.5273 10.9102C10.6367 10.819 10.7188 10.7461 10.7734 10.6914C10.8646 10.6003 10.9375 10.5182 10.9922 10.4453C11.0286 10.4089 11.5482 9.92578 12.5508 8.99609C13.681 7.95703 14.5469 7.13672 15.1484 6.53516C16.0781 5.6237 16.5612 5.10417 16.5977 4.97656C16.5977 4.9401 16.5977 4.89453 16.5977 4.83984C16.5794 4.7487 16.543 4.67578 16.4883 4.62109C16.4336 4.58464 16.3698 4.56641 16.2969 4.56641C16.2422 4.56641 16.1693 4.57552 16.0781 4.59375C15.987 4.61198 15.2305 5.09505 13.8086 6.04297C12.4049 6.95443 10.3086 8.34896 7.51953 10.2266C7.11849 10.5182 6.73568 10.7279 6.37109 10.8555C6.00651 10.9831 5.66016 11.0469 5.33203 11.0469C5.00391 11.0469 4.52083 10.9648 3.88281 10.8008C3.3724 10.6732 2.80729 10.5091 2.1875 10.3086C2.27865 10.3451 2.04167 10.2721 1.47656 10.0898C1.22135 9.9987 1.02083 9.92578 0.875 9.87109C0.692708 9.79818 0.53776 9.72526 0.410156 9.65234C0.264323 9.57943 0.164062 9.48828 0.109375 9.37891C0.0364583 9.28776 0 9.17839 0 9.05078C0 9.03255 0 9.02344 0 9.02344C0 9.00521 0 8.98698 0 8.96875C0.0182292 8.78646 0.154948 8.60417 0.410156 8.42188C0.665365 8.23958 1.02083 8.04818 1.47656 7.84766Z"# + + 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"# +} + + +#Preview { + ChatDetailView( + route: ChatRoute( + publicKey: "demo_public_key", + title: "Demo User", + username: "demo", + verified: 0 + ) + ) +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index 3213033..db51350 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -1,4 +1,3 @@ -import Lottie import SwiftUI // MARK: - Chat List Search Content @@ -9,6 +8,7 @@ struct ChatListSearchContent: View { let searchText: String @ObservedObject var viewModel: ChatListViewModel var onSelectRecent: (String) -> Void + var onOpenDialog: (ChatRoute) -> Void var body: some View { let trimmed = searchText.trimmingCharacters(in: .whitespaces) @@ -46,8 +46,11 @@ private extension ChatListSearchContent { var noResultsState: some View { VStack(spacing: 20) { Spacer() - LottieView(animationName: "search", loopMode: .playOnce, animationSpeed: 1.0) - .frame(width: 120, height: 120) + LottieView( + animationName: "search", + animationSpeed: 1.0 + ) + .frame(width: 120, height: 120) Text("Search for users") .font(.system(size: 17, weight: .medium)) .foregroundStyle(RosettaColors.Adaptive.text) @@ -64,7 +67,12 @@ private extension ChatListSearchContent { ScrollView { LazyVStack(spacing: 0) { ForEach(localResults) { dialog in - ChatRowView(dialog: dialog) + Button { + onOpenDialog(ChatRoute(dialog: dialog)) + } label: { + ChatRowView(dialog: dialog) + } + .buttonStyle(.plain) } ForEach(serverOnly, id: \.publicKey) { user in @@ -79,7 +87,7 @@ private extension ChatListSearchContent { Spacer().frame(height: 80) } } - .scrollDismissesKeyboard(.interactively) + .scrollDismissesKeyboard(.immediately) } } @@ -113,15 +121,18 @@ private extension ChatListSearchContent { } } } - .scrollDismissesKeyboard(.interactively) + .scrollDismissesKeyboard(.immediately) } } var searchPlaceholder: some View { VStack(spacing: 20) { Spacer() - LottieView(animationName: "search", loopMode: .loop, animationSpeed: 1.0) - .frame(width: 120, height: 120) + LottieView( + animationName: "search", + animationSpeed: 1.0 + ) + .frame(width: 120, height: 120) Text("Search for users") .font(.system(size: 17, weight: .medium)) .foregroundStyle(RosettaColors.Adaptive.text) @@ -200,6 +211,7 @@ private extension ChatListSearchContent { return Button { viewModel.addToRecent(user) + onOpenDialog(ChatRoute(user: user)) } label: { HStack(spacing: 12) { AvatarView( diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 4f4de9e..d12ea5d 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -4,11 +4,13 @@ import SwiftUI struct ChatListView: View { @Binding var isSearchActive: Bool + var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil @StateObject private var viewModel = ChatListViewModel() @State private var searchText = "" + @State private var navigationPath: [ChatRoute] = [] var body: some View { - NavigationStack { + NavigationStack(path: $navigationPath) { ZStack { RosettaColors.Adaptive.background .ignoresSafeArea() @@ -17,7 +19,12 @@ struct ChatListView: View { ChatListSearchContent( searchText: searchText, viewModel: viewModel, - onSelectRecent: { searchText = $0 } + onSelectRecent: { searchText = $0 }, + onOpenDialog: { route in + isSearchActive = false + searchText = "" + navigationPath.append(route) + } ) } else { normalContent @@ -36,6 +43,20 @@ struct ChatListView: View { .onChange(of: searchText) { _, newValue in viewModel.setSearchQuery(newValue) } + .navigationDestination(for: ChatRoute.self) { route in + ChatDetailView( + route: route, + onPresentedChange: { isPresented in + onChatDetailVisibilityChange?(isPresented) + } + ) + } + .onAppear { + onChatDetailVisibilityChange?(!navigationPath.isEmpty) + } + .onChange(of: navigationPath) { _, newPath in + onChatDetailVisibilityChange?(!newPath.isEmpty) + } } .tint(RosettaColors.figmaBlue) } @@ -81,11 +102,14 @@ private extension ChatListView { } .listStyle(.plain) .scrollContentBackground(.hidden) - .scrollDismissesKeyboard(.interactively) + .scrollDismissesKeyboard(.immediately) } func chatRow(_ dialog: Dialog) -> some View { - ChatRowView(dialog: dialog) + NavigationLink(value: ChatRoute(dialog: dialog)) { + ChatRowView(dialog: dialog) + } + .buttonStyle(.plain) .listRowInsets(EdgeInsets()) .listRowSeparator(.visible) .listRowSeparatorTint(RosettaColors.Adaptive.divider) @@ -175,5 +199,4 @@ private extension ChatListView { } } -#Preview { ChatListView(isSearchActive: .constant(false)) } - +#Preview { ChatListView(isSearchActive: .constant(false), onChatDetailVisibilityChange: nil) } diff --git a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift index f66e83b..32e134b 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -19,16 +19,14 @@ final class ChatListViewModel: ObservableObject { private var searchTask: Task? private var lastSearchedText = "" - private static let maxRecent = 20 - - private var recentKey: String { - "rosetta_recent_searches_\(SessionManager.shared.currentPublicKey)" - } + private var searchHandlerToken: UUID? + private var recentSearchesCancellable: AnyCancellable? + private let recentRepository = RecentSearchesRepository.shared // MARK: - Init init() { - loadRecentSearches() + configureRecentSearches() setupSearchCallback() } @@ -61,11 +59,12 @@ final class ChatListViewModel: ObservableObject { // MARK: - Actions func setSearchQuery(_ query: String) { - searchQuery = query + searchQuery = normalizeSearchInput(query) triggerServerSearch() } func deleteDialog(_ dialog: Dialog) { + MessageRepository.shared.deleteDialog(dialog.opponentKey) DialogRepository.shared.deleteDialog(opponentKey: dialog.opponentKey) } @@ -84,13 +83,21 @@ final class ChatListViewModel: ObservableObject { // MARK: - Server Search private func setupSearchCallback() { + if let token = searchHandlerToken { + ProtocolManager.shared.removeSearchResultHandler(token) + } + Self.logger.debug("Setting up search callback") - ProtocolManager.shared.onSearchResult = { [weak self] packet in + searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in DispatchQueue.main.async { [weak self] in guard let self else { Self.logger.debug("Search callback: self is nil") return } + guard !self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + self.isServerSearching = false + return + } Self.logger.debug("📥 Search results received: \(packet.users.count) users") self.serverSearchResults = packet.users self.isServerSearching = false @@ -130,7 +137,7 @@ final class ChatListViewModel: ObservableObject { guard !currentQuery.isEmpty, currentQuery == trimmed else { return } let connState = ProtocolManager.shared.connectionState - let hash = SessionManager.shared.privateKeyHash + let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash guard connState == .authenticated, let hash else { self.isServerSearching = false @@ -146,50 +153,32 @@ final class ChatListViewModel: ObservableObject { } } + private func normalizeSearchInput(_ input: String) -> String { + input.replacingOccurrences(of: "@", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + // MARK: - Recent Searches func addToRecent(_ user: SearchUser) { - let recent = RecentSearch( - publicKey: user.publicKey, - title: user.title, - username: user.username, - lastSeenText: user.online == 1 ? "online" : "last seen recently" - ) - recentSearches.removeAll { $0.publicKey == user.publicKey } - recentSearches.insert(recent, at: 0) - if recentSearches.count > Self.maxRecent { - recentSearches = Array(recentSearches.prefix(Self.maxRecent)) - } - saveRecentSearches() + recentRepository.add(user) } func removeRecentSearch(publicKey: String) { - recentSearches.removeAll { $0.publicKey == publicKey } - saveRecentSearches() + recentRepository.remove(publicKey: publicKey) } func clearRecentSearches() { - recentSearches = [] - saveRecentSearches() + recentRepository.clearAll() } - private func loadRecentSearches() { - if let data = UserDefaults.standard.data(forKey: recentKey), - let list = try? JSONDecoder().decode([RecentSearch].self, from: data) { - recentSearches = list - return - } - let oldKey = "rosetta_recent_searches" - if let data = UserDefaults.standard.data(forKey: oldKey), - let list = try? JSONDecoder().decode([RecentSearch].self, from: data) { - recentSearches = list - saveRecentSearches() - UserDefaults.standard.removeObject(forKey: oldKey) - } - } - - private func saveRecentSearches() { - guard let data = try? JSONEncoder().encode(recentSearches) else { return } - UserDefaults.standard.set(data, forKey: recentKey) + private func configureRecentSearches() { + recentRepository.setAccount(SessionManager.shared.currentPublicKey) + recentSearches = recentRepository.recentSearches + recentSearchesCancellable = recentRepository.$recentSearches + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.recentSearches = list + } } } diff --git a/Rosetta/Features/Chats/ChatRoute.swift b/Rosetta/Features/Chats/ChatRoute.swift new file mode 100644 index 0000000..364aa46 --- /dev/null +++ b/Rosetta/Features/Chats/ChatRoute.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Navigation payload for opening a direct chat. +struct ChatRoute: Hashable { + let publicKey: String + let title: String + let username: String + let verified: Int + + init(publicKey: String, title: String, username: String, verified: Int) { + self.publicKey = publicKey + self.title = title + self.username = username + self.verified = verified + } + + init(dialog: Dialog) { + self.init( + publicKey: dialog.opponentKey, + title: dialog.opponentTitle, + username: dialog.opponentUsername, + verified: dialog.verified + ) + } + + init(user: SearchUser) { + self.init( + publicKey: user.publicKey, + title: user.title, + username: user.username, + verified: user.verified + ) + } + + init(recent: RecentSearch) { + self.init( + publicKey: recent.publicKey, + title: recent.title, + username: recent.username, + verified: 0 + ) + } + + var isSavedMessages: Bool { + publicKey == SessionManager.shared.currentPublicKey + } + + var displayTitle: String { + if isSavedMessages { + return "Saved Messages" + } + if !title.isEmpty { + return title + } + if !username.isEmpty { + return "@\(username)" + } + return String(publicKey.prefix(12)) + } +} diff --git a/Rosetta/Features/Chats/Search/SearchView.swift b/Rosetta/Features/Chats/Search/SearchView.swift index 4e05400..b60ffe8 100644 --- a/Rosetta/Features/Chats/Search/SearchView.swift +++ b/Rosetta/Features/Chats/Search/SearchView.swift @@ -3,38 +3,50 @@ import SwiftUI // MARK: - SearchView struct SearchView: View { + var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil @State private var viewModel = SearchViewModel() @State private var searchText = "" - @FocusState private var isSearchFocused: Bool + @State private var navigationPath: [ChatRoute] = [] var body: some View { - ZStack(alignment: .bottom) { - RosettaColors.Adaptive.background - .ignoresSafeArea() + NavigationStack(path: $navigationPath) { + ZStack(alignment: .bottom) { + RosettaColors.Adaptive.background + .ignoresSafeArea() - ScrollView { - VStack(spacing: 0) { - if searchText.isEmpty { - favoriteContactsRow - recentSection - } else { - searchResultsContent + ScrollView { + VStack(spacing: 0) { + if searchText.isEmpty { + favoriteContactsRow + recentSection + } else { + searchResultsContent + } + + Spacer().frame(height: 120) } - - Spacer().frame(height: 120) } - } - .scrollDismissesKeyboard(.interactively) + .scrollDismissesKeyboard(.immediately) - searchBar - } - .onChange(of: searchText) { _, newValue in - viewModel.setSearchQuery(newValue) - } - .task { - // Auto-focus search field when the view appears - try? await Task.sleep(for: .milliseconds(300)) - isSearchFocused = true + searchBar + } + .onChange(of: searchText) { _, newValue in + viewModel.setSearchQuery(newValue) + } + .navigationDestination(for: ChatRoute.self) { route in + ChatDetailView( + route: route, + onPresentedChange: { isPresented in + onChatDetailVisibilityChange?(isPresented) + } + ) + } + .onAppear { + onChatDetailVisibilityChange?(!navigationPath.isEmpty) + } + .onChange(of: navigationPath) { _, newPath in + onChatDetailVisibilityChange?(!newPath.isEmpty) + } } } } @@ -55,7 +67,6 @@ private extension SearchView { TextField("Search", text: $searchText) .font(.system(size: 17, weight: .medium)) .foregroundStyle(RosettaColors.Adaptive.text) - .focused($isSearchFocused) .submitLabel(.search) .autocorrectionDisabled() .textInputAutocapitalization(.never) @@ -128,7 +139,7 @@ private extension SearchView { HStack(spacing: 4) { ForEach(Array(dialogs), id: \.id) { dialog in Button { - // TODO: Navigate to chat + navigationPath.append(ChatRoute(dialog: dialog)) } label: { VStack(spacing: 4) { AvatarView( @@ -197,9 +208,11 @@ private extension SearchView { var emptyState: some View { VStack(spacing: 16) { - Image(systemName: "magnifyingglass") - .font(.system(size: 52)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5)) + LottieView( + animationName: "search", + animationSpeed: 1.0 + ) + .frame(width: 120, height: 120) .padding(.top, 100) Text("Search for users") @@ -221,7 +234,7 @@ private extension SearchView { let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) return Button { - searchText = user.username.isEmpty ? user.publicKey : user.username + navigationPath.append(ChatRoute(recent: user)) } label: { HStack(spacing: 12) { ZStack(alignment: .topTrailing) { @@ -277,7 +290,7 @@ private extension SearchView { searchResults: viewModel.searchResults, onSelectUser: { user in viewModel.addToRecent(user) - // TODO: Navigate to ChatDetailView for user.publicKey + navigationPath.append(ChatRoute(user: user)) } ) } @@ -286,6 +299,5 @@ private extension SearchView { // MARK: - Preview #Preview { - SearchView() + SearchView(onChatDetailVisibilityChange: nil) } - diff --git a/Rosetta/Features/Chats/Search/SearchViewModel.swift b/Rosetta/Features/Chats/Search/SearchViewModel.swift index 9612bc6..76f032a 100644 --- a/Rosetta/Features/Chats/Search/SearchViewModel.swift +++ b/Rosetta/Features/Chats/Search/SearchViewModel.swift @@ -1,15 +1,7 @@ +import Combine import Foundation import os -// MARK: - Recent Search Model - -struct RecentSearch: Codable, Equatable { - let publicKey: String - var title: String - var username: String - var lastSeenText: String -} - // MARK: - SearchViewModel @Observable @@ -28,25 +20,21 @@ final class SearchViewModel { private var searchTask: Task? private var lastSearchedText = "" - - private var recentKey: String { - let pk = SessionManager.shared.currentPublicKey ?? "" - return "rosetta_recent_searches_\(pk)" - } - private static let maxRecent = 20 + private var searchHandlerToken: UUID? + private var recentSearchesCancellable: AnyCancellable? + private let recentRepository = RecentSearchesRepository.shared // MARK: - Init init() { - loadRecentSearches() + configureRecentSearches() setupSearchCallback() } // MARK: - Search Logic func setSearchQuery(_ query: String) { - - searchQuery = query + searchQuery = normalizeSearchInput(query) onSearchQueryChanged() } @@ -86,7 +74,7 @@ final class SearchViewModel { } let connState = ProtocolManager.shared.connectionState - let hash = SessionManager.shared.privateKeyHash + let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash guard connState == .authenticated, let hash else { @@ -117,9 +105,18 @@ final class SearchViewModel { // MARK: - Search Callback private func setupSearchCallback() { - ProtocolManager.shared.onSearchResult = { [weak self] packet in + if let token = searchHandlerToken { + ProtocolManager.shared.removeSearchResultHandler(token) + } + + searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in DispatchQueue.main.async { [weak self] in guard let self else { return } + guard !self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + self.isSearching = false + return + } + self.searchResults = packet.users self.isSearching = false @@ -136,55 +133,32 @@ final class SearchViewModel { } } + private func normalizeSearchInput(_ input: String) -> String { + input.replacingOccurrences(of: "@", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + // MARK: - Recent Searches func addToRecent(_ user: SearchUser) { - let recent = RecentSearch( - publicKey: user.publicKey, - title: user.title, - username: user.username, - lastSeenText: user.online == 1 ? "online" : "last seen recently" - ) - - // Remove duplicate if exists - recentSearches.removeAll { $0.publicKey == user.publicKey } - // Insert at top - recentSearches.insert(recent, at: 0) - // Keep max - if recentSearches.count > Self.maxRecent { - recentSearches = Array(recentSearches.prefix(Self.maxRecent)) - } - saveRecentSearches() + recentRepository.add(user) } func removeRecentSearch(publicKey: String) { - recentSearches.removeAll { $0.publicKey == publicKey } - saveRecentSearches() + recentRepository.remove(publicKey: publicKey) } func clearRecentSearches() { - recentSearches = [] - saveRecentSearches() + recentRepository.clearAll() } - private func loadRecentSearches() { - if let data = UserDefaults.standard.data(forKey: recentKey), - let list = try? JSONDecoder().decode([RecentSearch].self, from: data) { - recentSearches = list - return - } - // Migrate from old static key - let oldKey = "rosetta_recent_searches" - if let data = UserDefaults.standard.data(forKey: oldKey), - let list = try? JSONDecoder().decode([RecentSearch].self, from: data) { - recentSearches = list - saveRecentSearches() - UserDefaults.standard.removeObject(forKey: oldKey) - } - } - - private func saveRecentSearches() { - guard let data = try? JSONEncoder().encode(recentSearches) else { return } - UserDefaults.standard.set(data, forKey: recentKey) + private func configureRecentSearches() { + recentRepository.setAccount(SessionManager.shared.currentPublicKey) + recentSearches = recentRepository.recentSearches + recentSearchesCancellable = recentRepository.$recentSearches + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.recentSearches = list + } } } diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index e5bc334..4e7df6c 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -5,52 +5,159 @@ struct MainTabView: View { var onLogout: (() -> Void)? @State private var selectedTab: RosettaTab = .chats @State private var isChatSearchActive = false + @State private var tabSwipeState: TabBarSwipeState? + @State private var isChatListDetailPresented = false + @State private var isSearchDetailPresented = false var body: some View { + Group { + if #available(iOS 26.0, *) { + systemTabView + } else { + legacyTabView + } + } + } + + @available(iOS 26.0, *) + private var systemTabView: some View { + TabView(selection: $selectedTab) { + ChatListView( + isSearchActive: $isChatSearchActive, + onChatDetailVisibilityChange: { isPresented in + isChatListDetailPresented = isPresented + } + ) + .tabItem { + Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon) + } + .tag(RosettaTab.chats) + .badgeIfNeeded(chatUnreadBadge) + + SettingsView(onLogout: onLogout) + .tabItem { + Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon) + } + .tag(RosettaTab.settings) + + SearchView(onChatDetailVisibilityChange: { isPresented in + isSearchDetailPresented = isPresented + }) + .tabItem { + Label(RosettaTab.search.label, systemImage: RosettaTab.search.icon) + } + .tag(RosettaTab.search) + } + .tint(RosettaColors.primaryBlue) + } + + private var legacyTabView: some View { ZStack(alignment: .bottom) { RosettaColors.Adaptive.background .ignoresSafeArea() - Group { - switch selectedTab { - case .chats: - ChatListView(isSearchActive: $isChatSearchActive) - .transition(.opacity) - case .settings: - SettingsView(onLogout: onLogout) - .transition(.opacity) - case .search: - SearchView() - .transition(.move(edge: .bottom).combined(with: .opacity)) - } + GeometryReader { geometry in + tabPager(availableSize: geometry.size) } - .animation(.easeInOut(duration: 0.3), value: selectedTab) - if !isChatSearchActive { + if !isChatSearchActive && !isAnyChatDetailPresented { RosettaTabBar( selectedTab: selectedTab, onTabSelected: { tab in - withAnimation(.easeInOut(duration: 0.3)) { + tabSwipeState = nil + withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) { selectedTab = tab } }, + onSwipeStateChanged: { state in + tabSwipeState = state + }, badges: tabBadges ) .ignoresSafeArea(.keyboard) .transition(.move(edge: .bottom).combined(with: .opacity)) } } + .onChange(of: isChatSearchActive) { _, isActive in + if isActive { + tabSwipeState = nil + } + } + } + + private var currentPageIndex: CGFloat { + if let tabSwipeState { + return max(0, min(CGFloat(RosettaTab.interactionOrder.count - 1), tabSwipeState.fractionalIndex)) + } + return CGFloat(selectedTab.interactionIndex) + } + + @ViewBuilder + private func tabPager(availableSize: CGSize) -> some View { + let width = max(1, availableSize.width) + let totalWidth = width * CGFloat(RosettaTab.interactionOrder.count) + + HStack(spacing: 0) { + ForEach(RosettaTab.interactionOrder, id: \.self) { tab in + tabView(for: tab) + .frame(width: width, height: availableSize.height) + } + } + .frame(width: totalWidth, alignment: .leading) + .offset(x: -currentPageIndex * width) + .animation(tabSwipeState == nil ? .spring(response: 0.34, dampingFraction: 0.82) : nil, value: currentPageIndex) + .clipped() + } + + @ViewBuilder + private func tabView(for tab: RosettaTab) -> some View { + switch tab { + case .chats: + ChatListView( + isSearchActive: $isChatSearchActive, + onChatDetailVisibilityChange: { isPresented in + isChatListDetailPresented = isPresented + } + ) + case .settings: + SettingsView(onLogout: onLogout) + case .search: + SearchView(onChatDetailVisibilityChange: { isPresented in + isSearchDetailPresented = isPresented + }) + } + } + + private var isAnyChatDetailPresented: Bool { + isChatListDetailPresented || isSearchDetailPresented } private var tabBadges: [TabBadge] { - var result: [TabBadge] = [] + guard let chatUnreadBadge else { + return [] + } + return [TabBadge(tab: .chats, text: chatUnreadBadge)] + } + + private var chatUnreadBadge: String? { let unread = DialogRepository.shared.sortedDialogs .filter { !$0.isMuted } .reduce(0) { $0 + $1.unreadCount } - if unread > 0 { - result.append(TabBadge(tab: .chats, text: unread > 999 ? "\(unread / 1000)K" : "\(unread)")) + if unread <= 0 { + return nil + } + return unread > 999 ? "\(unread / 1000)K" : "\(unread)" + } +} + +private extension View { + @ViewBuilder + func badgeIfNeeded(_ value: String?) -> some View { + if let value { + badge(value) + } else { + self } - return result } } diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 769c332..cccc235 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -25,10 +25,7 @@ struct RosettaApp: App { UserDefaults.standard.set(true, forKey: "hasLaunchedBefore") } - // Preload Lottie animations early - Task.detached(priority: .userInitiated) { - LottieAnimationCache.shared.preload(OnboardingPages.all.map(\.animationName)) - } + // Avoid heavy startup work on MainActor; Lottie assets load lazily on first use. } @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false