Fix chat send button behavior

This commit is contained in:
2026-03-02 03:22:19 +05:00
parent d1fcc04125
commit 8238fd1940
27 changed files with 3423 additions and 610 deletions

View File

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

View File

@@ -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:<aesChachaKey>`.
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<Data> = []
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
}
}

View File

@@ -37,9 +37,10 @@ enum Poly1305Engine {
var block = [UInt8](macInput[offset..<blockEnd])
while block.count < 16 { block.append(0) }
// hibit is 1 for data blocks, 0 for length fields
let isDataBlock = offset < data.count + padding
let bit: UInt64 = isDataBlock ? (1 << 24) : 0
// Poly1305 appends a 1-bit to each complete 16-byte block.
// In AEAD mode we process a fully padded stream
// (ciphertext + pad16 + aadLen + ctLen), so every block here is complete.
let bit: UInt64 = 1 << 24
let n = toLimbs26(block)
accumulator[0] = accumulator[0] &+ n[0]

View File

@@ -0,0 +1,21 @@
import Foundation
/// Single message inside a direct chat dialog.
struct ChatMessage: Identifiable, Codable, Sendable {
let id: String
var fromPublicKey: String
var toPublicKey: String
var text: String
var timestamp: Int64
var deliveryStatus: DeliveryStatus
var isRead: Bool
var attachments: [MessageAttachment]
func isFromMe(myPublicKey: String) -> Bool {
fromPublicKey == myPublicKey
}
var date: Date {
Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
}
}

View File

@@ -0,0 +1,8 @@
import Foundation
struct RecentSearch: Codable, Equatable, Sendable {
let publicKey: String
var title: String
var username: String
var lastSeenText: String
}

View File

@@ -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<T: Decodable>(_ 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<T: Encodable>(_ 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)
}
}

View File

@@ -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<Void, Never>?
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)
}
}

View File

@@ -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<String> = []
private var activeDialogs: Set<String> = []
private var messageToDialog: [String: String] = [:]
private var typingResetTasks: [String: Task<Void, Never>] = [:]
private var persistTask: Task<Void, Never>?
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)
}
}

View File

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

View File

@@ -22,6 +22,7 @@ enum PacketRegistry {
0x07: { PacketRead() },
0x08: { PacketDelivery() },
0x0B: { PacketTyping() },
0x17: { PacketDeviceList() },
0x19: { PacketSync() },
]

View File

@@ -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..<count {
parsed.append(
DeviceEntry(
deviceId: stream.readString(),
deviceName: stream.readString(),
deviceOs: stream.readString(),
deviceStatus: DeviceState(rawValue: stream.readInt8()) ?? .offline,
deviceVerify: DeviceVerifyState(rawValue: stream.readInt8()) ?? .verified
)
)
}
devices = parsed
}
}

View File

@@ -8,7 +8,7 @@ struct PacketMessage: Packet {
var toPublicKey: String = ""
var content: String = "" // XChaCha20-Poly1305 encrypted (hex)
var chachaKey: String = "" // ECDH-encrypted key+nonce
var timestamp: Int32 = 0
var timestamp: Int64 = 0
var privateKey: String = "" // Hash for server auth
var messageId: String = ""
var attachments: [MessageAttachment] = []
@@ -20,7 +20,7 @@ struct PacketMessage: Packet {
stream.writeString(toPublicKey)
stream.writeString(content)
stream.writeString(chachaKey)
stream.writeInt32(Int(timestamp))
stream.writeInt64(timestamp)
stream.writeString(privateKey)
stream.writeString(messageId)
stream.writeInt8(attachments.count)
@@ -31,7 +31,7 @@ struct PacketMessage: Packet {
stream.writeString(attachment.blob)
stream.writeInt8(attachment.type.rawValue)
}
// No aesChachaKey Android doesn't send it
stream.writeString(aesChachaKey)
}
mutating func read(from stream: Stream) {
@@ -39,7 +39,7 @@ struct PacketMessage: Packet {
toPublicKey = stream.readString()
content = stream.readString()
chachaKey = stream.readString()
timestamp = Int32(stream.readInt32())
timestamp = stream.readInt64()
privateKey = stream.readString()
messageId = stream.readString()
@@ -54,6 +54,6 @@ struct PacketMessage: Packet {
))
}
attachments = list
// No aesChachaKey Android doesn't read it
aesChachaKey = stream.readString()
}
}

View File

@@ -43,9 +43,13 @@ final class ProtocolManager: @unchecked Sendable {
private let client = WebSocketClient()
private var packetQueue: [any Packet] = []
private var queuedPacketKeys: Set<String> = []
private var handshakeComplete = false
private var heartbeatTask: Task<Void, Never>?
private var handshakeTimeoutTask: Task<Void, Never>?
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
}
}
}

View File

@@ -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..<length {
let code = readInt16()
if let scalar = Unicode.Scalar(code) {
result.append(Character(scalar))
}
let bitsAvailable = bytes.count * 8 - readPointer
let bytesAvailable = max(bitsAvailable / 8, 0)
if length < 0 || (length * 2) > bytesAvailable {
return ""
}
return result
var codeUnits = [UInt16]()
codeUnits.reserveCapacity(length)
for _ in 0..<length {
codeUnits.append(UInt16(truncatingIfNeeded: readInt16()))
}
return String(decoding: codeUnits, as: UTF16.self)
}
// MARK: - Bytes (Int32 length + raw Int8s)

View File

@@ -12,6 +12,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
private var isManuallyClosed = false
private var reconnectTask: Task<Void, Never>?
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()
}
}

View File

@@ -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<String> = []
private var lastReadReceiptSentAt: [String: Int64] = [:]
private var requestedUserInfoKeys: Set<String> = []
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
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)
}
}

View File

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

View File

@@ -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 (topbottom, 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: [

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -19,16 +19,14 @@ final class ChatListViewModel: ObservableObject {
private var searchTask: Task<Void, Never>?
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
}
}
}

View File

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

View File

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

View File

@@ -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<Void, Never>?
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
}
}
}

View File

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

View File

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