Fix chat send button behavior
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
21
Rosetta/Core/Data/Models/ChatMessage.swift
Normal file
21
Rosetta/Core/Data/Models/ChatMessage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
8
Rosetta/Core/Data/Models/RecentSearch.swift
Normal file
8
Rosetta/Core/Data/Models/RecentSearch.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
struct RecentSearch: Codable, Equatable, Sendable {
|
||||
let publicKey: String
|
||||
var title: String
|
||||
var username: String
|
||||
var lastSeenText: String
|
||||
}
|
||||
59
Rosetta/Core/Data/Repositories/ChatPersistenceStore.swift
Normal file
59
Rosetta/Core/Data/Repositories/ChatPersistenceStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
317
Rosetta/Core/Data/Repositories/MessageRepository.swift
Normal file
317
Rosetta/Core/Data/Repositories/MessageRepository.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ enum PacketRegistry {
|
||||
0x07: { PacketRead() },
|
||||
0x08: { PacketDelivery() },
|
||||
0x0B: { PacketTyping() },
|
||||
0x17: { PacketDeviceList() },
|
||||
0x19: { PacketSync() },
|
||||
]
|
||||
|
||||
|
||||
55
Rosetta/Core/Network/Protocol/Packets/PacketDeviceList.swift
Normal file
55
Rosetta/Core/Network/Protocol/Packets/PacketDeviceList.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - Glass Effect ID Modifier (iOS 26+)
|
||||
|
||||
private struct GlassEffectIDModifier: ViewModifier {
|
||||
let id: String
|
||||
let namespace: Namespace.ID?
|
||||
|
||||
nonisolated func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *), let namespace {
|
||||
content.glassEffectID(id, in: namespace)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab
|
||||
|
||||
enum RosettaTab: CaseIterable, Sendable {
|
||||
@@ -23,11 +8,13 @@ enum RosettaTab: CaseIterable, Sendable {
|
||||
case settings
|
||||
case search
|
||||
|
||||
static let interactionOrder: [RosettaTab] = [.chats, .settings, .search]
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .chats: return "Chats"
|
||||
case .settings: return "Settings"
|
||||
case .search: return ""
|
||||
case .search: return "Search"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +33,10 @@ enum RosettaTab: CaseIterable, Sendable {
|
||||
case .search: return "magnifyingglass"
|
||||
}
|
||||
}
|
||||
|
||||
var interactionIndex: Int {
|
||||
Self.interactionOrder.firstIndex(of: self) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Badge
|
||||
@@ -55,27 +46,47 @@ struct TabBadge {
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct TabBarSwipeState {
|
||||
let fromTab: RosettaTab
|
||||
let hoveredTab: RosettaTab
|
||||
let fractionalIndex: CGFloat
|
||||
}
|
||||
|
||||
// MARK: - RosettaTabBar
|
||||
|
||||
struct RosettaTabBar: View {
|
||||
let selectedTab: RosettaTab
|
||||
var onTabSelected: ((RosettaTab) -> Void)?
|
||||
var onSwipeStateChanged: ((TabBarSwipeState?) -> Void)?
|
||||
var badges: [TabBadge] = []
|
||||
|
||||
@Namespace private var glassNS
|
||||
@State private var tabFrames: [RosettaTab: CGRect] = [:]
|
||||
@State private var interactionState: TabPressInteraction?
|
||||
|
||||
private static let tabBarSpace = "RosettaTabBarSpace"
|
||||
private let lensLiftOffset: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26, *) {
|
||||
GlassEffectContainer(spacing: 8) {
|
||||
tabBarContent
|
||||
}
|
||||
interactiveTabBarContent
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
tabBarContent
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var interactiveTabBarContent: some View {
|
||||
tabBarContent
|
||||
.coordinateSpace(name: Self.tabBarSpace)
|
||||
.onPreferenceChange(TabFramePreferenceKey.self) { frames in
|
||||
tabFrames = frames
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(tabSelectionGesture)
|
||||
.overlay(alignment: .topLeading) {
|
||||
liftedLensOverlay
|
||||
}
|
||||
.onDisappear {
|
||||
interactionState = nil
|
||||
onSwipeStateChanged?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private var tabBarContent: some View {
|
||||
@@ -84,29 +95,185 @@ struct RosettaTabBar: View {
|
||||
searchPill
|
||||
}
|
||||
}
|
||||
|
||||
private var visualSelectedTab: RosettaTab {
|
||||
if let interactionState, interactionState.isLifted {
|
||||
return interactionState.hoveredTab
|
||||
}
|
||||
return selectedTab
|
||||
}
|
||||
|
||||
private var tabSelectionGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .named(Self.tabBarSpace))
|
||||
.onChanged(handleGestureChanged)
|
||||
.onEnded(handleGestureEnded)
|
||||
}
|
||||
|
||||
private func handleGestureChanged(_ value: DragGesture.Value) {
|
||||
guard !tabFrames.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
if interactionState == nil {
|
||||
guard let startTab = tabAtStart(location: value.startLocation),
|
||||
let startFrame = tabFrames[startTab]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let state = TabPressInteraction(
|
||||
id: UUID(),
|
||||
startTab: startTab,
|
||||
startCenterX: startFrame.midX,
|
||||
currentCenterX: startFrame.midX,
|
||||
hoveredTab: startTab,
|
||||
isLifted: true
|
||||
)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
interactionState = state
|
||||
publishSwipeState(for: state)
|
||||
return
|
||||
}
|
||||
|
||||
guard var state = interactionState else {
|
||||
return
|
||||
}
|
||||
|
||||
state.currentCenterX = clampedCenterX(state.startCenterX + value.translation.width)
|
||||
|
||||
if let nearest = nearestTab(toX: state.currentCenterX), nearest != state.hoveredTab {
|
||||
state.hoveredTab = nearest
|
||||
if state.isLifted {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
interactionState = state
|
||||
publishSwipeState(for: state)
|
||||
}
|
||||
|
||||
private func handleGestureEnded(_ value: DragGesture.Value) {
|
||||
guard let state = interactionState else {
|
||||
return
|
||||
}
|
||||
|
||||
let targetTab = nearestTab(toX: value.location.x) ?? state.hoveredTab
|
||||
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.72)) {
|
||||
interactionState = nil
|
||||
}
|
||||
|
||||
onSwipeStateChanged?(nil)
|
||||
onTabSelected?(targetTab)
|
||||
}
|
||||
|
||||
private func publishSwipeState(for state: TabPressInteraction) {
|
||||
guard state.isLifted,
|
||||
let fractionalIndex = fractionalIndex(for: state.currentCenterX)
|
||||
else {
|
||||
onSwipeStateChanged?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
onSwipeStateChanged?(
|
||||
TabBarSwipeState(
|
||||
fromTab: state.startTab,
|
||||
hoveredTab: state.hoveredTab,
|
||||
fractionalIndex: fractionalIndex
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func tabAtStart(location: CGPoint) -> RosettaTab? {
|
||||
guard let nearest = nearestTab(toX: location.x),
|
||||
let frame = tabFrames[nearest]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return frame.insetBy(dx: -18, dy: -18).contains(location) ? nearest : nil
|
||||
}
|
||||
|
||||
private func nearestTab(toX x: CGFloat) -> RosettaTab? {
|
||||
tabFrames.min { lhs, rhs in
|
||||
abs(lhs.value.midX - x) < abs(rhs.value.midX - x)
|
||||
}?.key
|
||||
}
|
||||
|
||||
private func clampedCenterX(_ value: CGFloat) -> CGFloat {
|
||||
let centers = tabFrames.values.map(\.midX)
|
||||
guard let minX = centers.min(), let maxX = centers.max() else {
|
||||
return value
|
||||
}
|
||||
return min(max(value, minX), maxX)
|
||||
}
|
||||
|
||||
private func fractionalIndex(for centerX: CGFloat) -> CGFloat? {
|
||||
let centers = RosettaTab.interactionOrder.compactMap { tab -> CGFloat? in
|
||||
tabFrames[tab]?.midX
|
||||
}
|
||||
|
||||
guard centers.count == RosettaTab.interactionOrder.count else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if centerX <= centers[0] {
|
||||
return 0
|
||||
}
|
||||
if centerX >= centers[centers.count - 1] {
|
||||
return CGFloat(centers.count - 1)
|
||||
}
|
||||
|
||||
for index in 0 ..< centers.count - 1 {
|
||||
let left = centers[index]
|
||||
let right = centers[index + 1]
|
||||
guard centerX >= left, centerX <= right else {
|
||||
continue
|
||||
}
|
||||
let progress = (centerX - left) / max(1, right - left)
|
||||
return CGFloat(index) + progress
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func badgeText(for tab: RosettaTab) -> String? {
|
||||
badges.first(where: { $0.tab == tab })?.text
|
||||
}
|
||||
|
||||
private func isCoveredByLens(_ tab: RosettaTab) -> Bool {
|
||||
interactionState?.isLifted == true && interactionState?.hoveredTab == tab
|
||||
}
|
||||
|
||||
private func lensDiameter(for tab: RosettaTab) -> CGFloat {
|
||||
switch tab {
|
||||
case .search:
|
||||
return 88
|
||||
default:
|
||||
return 104
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Tabs Pill
|
||||
|
||||
private extension RosettaTabBar {
|
||||
var mainTabsPill: some View {
|
||||
// Content on top — NOT clipped (lens can pop out)
|
||||
HStack(spacing: 0) {
|
||||
ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in
|
||||
TabItemButton(
|
||||
TabItemView(
|
||||
tab: tab,
|
||||
isSelected: tab == selectedTab,
|
||||
badgeText: badges.first(where: { $0.tab == tab })?.text,
|
||||
onTap: { onTabSelected?(tab) },
|
||||
glassNamespace: glassNS
|
||||
isSelected: tab == visualSelectedTab,
|
||||
isCoveredByLens: isCoveredByLens(tab),
|
||||
badgeText: badgeText(for: tab)
|
||||
)
|
||||
.tabFramePreference(tab: tab, in: Self.tabBarSpace)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 3)
|
||||
.frame(height: 62)
|
||||
// Background clipped separately — content stays unclipped
|
||||
.background {
|
||||
mainPillGlass
|
||||
}
|
||||
@@ -114,106 +281,117 @@ private extension RosettaTabBar {
|
||||
|
||||
@ViewBuilder
|
||||
var mainPillGlass: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
ZStack {
|
||||
// 1. Material
|
||||
Capsule().fill(.ultraThinMaterial)
|
||||
// 2. Dark tint
|
||||
Capsule().fill(Color.black.opacity(0.22))
|
||||
// 3. Highlight
|
||||
Capsule().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
// 4a. Outer stroke
|
||||
Capsule().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
// 4b. Inner stroke
|
||||
Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
// 5. Shadows
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
ZStack {
|
||||
Capsule().fill(.ultraThinMaterial)
|
||||
Capsule().fill(Color.black.opacity(0.34))
|
||||
Capsule().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.08), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1)
|
||||
Capsule().stroke(Color.white.opacity(0.08), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var liftedLensOverlay: some View {
|
||||
if let state = interactionState,
|
||||
state.isLifted,
|
||||
let hoveredFrame = tabFrames[state.hoveredTab]
|
||||
{
|
||||
let diameter = lensDiameter(for: state.hoveredTab)
|
||||
|
||||
ZStack {
|
||||
lensBubble
|
||||
LensTabContentView(
|
||||
tab: state.hoveredTab,
|
||||
badgeText: badgeText(for: state.hoveredTab)
|
||||
)
|
||||
.padding(.top, state.hoveredTab == .search ? 0 : 8)
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.position(x: state.currentCenterX, y: hoveredFrame.midY - lensLiftOffset)
|
||||
.shadow(color: .black.opacity(0.42), radius: 24, y: 15)
|
||||
.shadow(color: Color.cyan.opacity(0.10), radius: 20, y: 1)
|
||||
.allowsHitTesting(false)
|
||||
.transition(.scale(scale: 0.86).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.74), value: state.isLifted)
|
||||
.zIndex(20)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var lensBubble: some View {
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
Circle().fill(Color.black.opacity(0.38))
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.08), Color.white.opacity(0.01), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
Circle().stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.06), lineWidth: 1).padding(1.6)
|
||||
Circle().stroke(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
Color.cyan.opacity(0.34),
|
||||
Color.blue.opacity(0.28),
|
||||
Color.pink.opacity(0.28),
|
||||
Color.orange.opacity(0.30),
|
||||
Color.yellow.opacity(0.20),
|
||||
Color.cyan.opacity(0.34),
|
||||
],
|
||||
center: .center
|
||||
),
|
||||
lineWidth: 1.1
|
||||
).blendMode(.screen)
|
||||
}
|
||||
.compositingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Item Button
|
||||
// MARK: - Tab Item
|
||||
|
||||
private struct TabItemButton: View {
|
||||
private struct TabItemView: View {
|
||||
let tab: RosettaTab
|
||||
let isSelected: Bool
|
||||
let isCoveredByLens: Bool
|
||||
let badgeText: String?
|
||||
let onTap: () -> Void
|
||||
var glassNamespace: Namespace.ID?
|
||||
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(spacing: 1) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(tabColor)
|
||||
.frame(height: 30)
|
||||
|
||||
if let badgeText {
|
||||
Text(badgeText)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, badgeText.count > 2 ? 4 : 0)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(Capsule().fill(RosettaColors.error))
|
||||
.offset(x: 10, y: -4)
|
||||
}
|
||||
}
|
||||
|
||||
Text(tab.label)
|
||||
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
|
||||
VStack(spacing: 1) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(tabColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if isSelected && !pressed {
|
||||
RoundedRectangle(cornerRadius: 100)
|
||||
.fill(RosettaColors.adaptive(
|
||||
light: Color(hex: 0xEDEDED),
|
||||
dark: Color.white.opacity(0.12)
|
||||
))
|
||||
.padding(.horizontal, -8)
|
||||
.padding(.vertical, -6)
|
||||
.frame(height: 30)
|
||||
|
||||
if let badgeText {
|
||||
Text(badgeText)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, badgeText.count > 2 ? 4 : 0)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(Capsule().fill(RosettaColors.error))
|
||||
.offset(x: 10, y: -4)
|
||||
}
|
||||
}
|
||||
|
||||
Text(tab.label)
|
||||
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
|
||||
.foregroundStyle(tabColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Lens: padding → glass bubble → scale → lift
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(14)
|
||||
.background {
|
||||
if pressed {
|
||||
lensBubble
|
||||
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.scaleEffect(pressed ? 1.12 : 1)
|
||||
.offset(y: pressed ? -28 : 0)
|
||||
.shadow(color: .black.opacity(pressed ? 0.45 : 0), radius: 22, y: 14)
|
||||
.shadow(color: Color.cyan.opacity(pressed ? 0.12 : 0), radius: 20, y: 0)
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.65), value: pressed)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
if !pressed {
|
||||
pressed = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in pressed = false }
|
||||
)
|
||||
.modifier(GlassEffectIDModifier(id: "\(tab)", namespace: glassNamespace))
|
||||
.opacity(isCoveredByLens ? 0.07 : 1)
|
||||
.animation(.easeInOut(duration: 0.14), value: isCoveredByLens)
|
||||
.accessibilityLabel(tab.label)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
@@ -226,177 +404,126 @@ private struct TabItemButton: View {
|
||||
dark: Color(hex: 0x8E8E93)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Lens Bubble
|
||||
|
||||
@ViewBuilder
|
||||
private var lensBubble: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Circle()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular.interactive(), in: .circle)
|
||||
} else {
|
||||
ZStack {
|
||||
// 1. Material
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
// 2. Dark tint
|
||||
Circle().fill(Color.black.opacity(0.22))
|
||||
// 3. Highlight (top→bottom, screen blend)
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
).blendMode(.screen)
|
||||
// 4a. Outer stroke
|
||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
// 4b. Inner stroke
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
// 6. Rainbow (thin, subtle, screen blend)
|
||||
Circle().stroke(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
Color.cyan.opacity(0.55),
|
||||
Color.blue.opacity(0.55),
|
||||
Color.purple.opacity(0.55),
|
||||
Color.pink.opacity(0.55),
|
||||
Color.orange.opacity(0.55),
|
||||
Color.yellow.opacity(0.45),
|
||||
Color.cyan.opacity(0.55),
|
||||
],
|
||||
center: .center
|
||||
),
|
||||
lineWidth: 1.4
|
||||
).blendMode(.screen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Pill
|
||||
|
||||
private extension RosettaTabBar {
|
||||
var searchPill: some View {
|
||||
SearchPillButton(
|
||||
isSelected: selectedTab == .search,
|
||||
onTap: { onTabSelected?(.search) },
|
||||
glassNamespace: glassNS
|
||||
SearchPillView(
|
||||
isSelected: visualSelectedTab == .search,
|
||||
isCoveredByLens: isCoveredByLens(.search)
|
||||
)
|
||||
.tabFramePreference(tab: .search, in: Self.tabBarSpace)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SearchPillButton: View {
|
||||
private struct SearchPillView: View {
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
var glassNamespace: Namespace.ID?
|
||||
|
||||
@State private var pressed = false
|
||||
let isCoveredByLens: Bool
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
isSelected
|
||||
? RosettaColors.primaryBlue
|
||||
: RosettaColors.adaptive(
|
||||
light: Color(hex: 0x404040),
|
||||
dark: Color(hex: 0x8E8E93)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Lens
|
||||
.padding(14)
|
||||
.background {
|
||||
if pressed {
|
||||
searchLensBubble
|
||||
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.scaleEffect(pressed ? 1.15 : 1)
|
||||
.offset(y: pressed ? -28 : 0)
|
||||
.shadow(color: .black.opacity(pressed ? 0.45 : 0), radius: 22, y: 14)
|
||||
.shadow(color: Color.cyan.opacity(pressed ? 0.12 : 0), radius: 20, y: 0)
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.65), value: pressed)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
if !pressed {
|
||||
pressed = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in pressed = false }
|
||||
)
|
||||
.frame(width: 62, height: 62)
|
||||
// Background clipped separately
|
||||
.background { searchPillGlass }
|
||||
.modifier(GlassEffectIDModifier(id: "search", namespace: glassNamespace))
|
||||
.accessibilityLabel("Search")
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
|
||||
// MARK: Lens for search
|
||||
|
||||
@ViewBuilder
|
||||
private var searchLensBubble: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Circle()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular.interactive(), in: .circle)
|
||||
} else {
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
Circle().fill(Color.black.opacity(0.22))
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
isSelected
|
||||
? RosettaColors.primaryBlue
|
||||
: RosettaColors.adaptive(
|
||||
light: Color(hex: 0x404040),
|
||||
dark: Color(hex: 0x8E8E93)
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
Circle().stroke(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
Color.cyan.opacity(0.55),
|
||||
Color.blue.opacity(0.55),
|
||||
Color.purple.opacity(0.55),
|
||||
Color.pink.opacity(0.55),
|
||||
Color.orange.opacity(0.55),
|
||||
Color.yellow.opacity(0.45),
|
||||
Color.cyan.opacity(0.55),
|
||||
],
|
||||
center: .center
|
||||
),
|
||||
lineWidth: 1.4
|
||||
).blendMode(.screen)
|
||||
}
|
||||
}
|
||||
)
|
||||
.frame(width: 62, height: 62)
|
||||
.opacity(isCoveredByLens ? 0.08 : 1)
|
||||
.animation(.easeInOut(duration: 0.14), value: isCoveredByLens)
|
||||
.background { searchPillGlass }
|
||||
.accessibilityLabel("Search")
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var searchPillGlass: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Circle().fill(.clear).glassEffect(.regular, in: .circle)
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
Circle().fill(Color.black.opacity(0.34))
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.08), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Circle().stroke(Color.white.opacity(0.12), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.08), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LensTabContentView: View {
|
||||
let tab: RosettaTab
|
||||
let badgeText: String?
|
||||
|
||||
var body: some View {
|
||||
if tab == .search {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 29, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
} else {
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
Circle().fill(Color.black.opacity(0.22))
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
VStack(spacing: 3) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: tab.selectedIcon)
|
||||
.font(.system(size: 30))
|
||||
.foregroundStyle(.white)
|
||||
.frame(height: 36)
|
||||
|
||||
if let badgeText {
|
||||
Text(badgeText)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, badgeText.count > 2 ? 5 : 0)
|
||||
.frame(minWidth: 20, minHeight: 20)
|
||||
.background(Capsule().fill(RosettaColors.error))
|
||||
.offset(x: 16, y: -9)
|
||||
}
|
||||
}
|
||||
|
||||
Text(tab.label)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geometry Helpers
|
||||
|
||||
private struct TabPressInteraction {
|
||||
let id: UUID
|
||||
let startTab: RosettaTab
|
||||
let startCenterX: CGFloat
|
||||
var currentCenterX: CGFloat
|
||||
var hoveredTab: RosettaTab
|
||||
var isLifted: Bool
|
||||
}
|
||||
|
||||
private struct TabFramePreferenceKey: PreferenceKey {
|
||||
static var defaultValue: [RosettaTab: CGRect] = [:]
|
||||
|
||||
static func reduce(value: inout [RosettaTab: CGRect], nextValue: () -> [RosettaTab: CGRect]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func tabFramePreference(tab: RosettaTab, in coordinateSpace: String) -> some View {
|
||||
background {
|
||||
GeometryReader { proxy in
|
||||
Color.clear.preference(
|
||||
key: TabFramePreferenceKey.self,
|
||||
value: [tab: proxy.frame(in: .named(coordinateSpace))]
|
||||
)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,13 +534,6 @@ private struct SearchPillButton: View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Hold a tab to see the lens")
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
RosettaTabBar(
|
||||
selectedTab: .chats,
|
||||
badges: [
|
||||
|
||||
978
Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift
Normal file
978
Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift
Normal file
File diff suppressed because one or more lines are too long
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
Rosetta/Features/Chats/ChatRoute.swift
Normal file
60
Rosetta/Features/Chats/ChatRoute.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user