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")
|
throw CryptoError.invalidData("Malformed encrypted string")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try current format: PBKDF2-SHA1 + AES-CBC + zlib inflate
|
let prfOrder: [CCPseudoRandomAlgorithm] = [
|
||||||
if let result = try? {
|
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // Desktop/Android current
|
||||||
let key = CryptoPrimitives.pbkdf2(
|
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), // Legacy iOS
|
||||||
password: password, salt: "rosetta", iterations: 1000,
|
]
|
||||||
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
|
|
||||||
)
|
// 1) Preferred path: AES-CBC + zlib inflate
|
||||||
let decrypted = try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: key, iv: iv)
|
for prf in prfOrder {
|
||||||
return try CryptoPrimitives.rawInflate(decrypted)
|
if let result = try? decryptWithPassword(
|
||||||
}() {
|
ciphertext: ciphertext,
|
||||||
return result
|
iv: iv,
|
||||||
|
password: password,
|
||||||
|
prf: prf,
|
||||||
|
expectsCompressed: true
|
||||||
|
) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: legacy iOS format (PBKDF2-SHA256, no compression)
|
// 2) Fallback: AES-CBC without compression (very old/legacy payloads)
|
||||||
let legacyKey = CryptoPrimitives.pbkdf2(
|
for prf in prfOrder {
|
||||||
password: password, salt: "rosetta", iterations: 1000,
|
if let result = try? decryptWithPassword(
|
||||||
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
|
ciphertext: ciphertext,
|
||||||
)
|
iv: iv,
|
||||||
return try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: legacyKey, iv: iv)
|
password: password,
|
||||||
|
prf: prf,
|
||||||
|
expectsCompressed: false
|
||||||
|
) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw CryptoError.decryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Utilities
|
// 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
|
// MARK: - BIP39 Internal
|
||||||
|
|
||||||
private extension CryptoManager {
|
private extension CryptoManager {
|
||||||
|
|||||||
@@ -26,37 +26,35 @@ enum MessageCrypto {
|
|||||||
encryptedKey: String,
|
encryptedKey: String,
|
||||||
myPrivateKeyHex: String
|
myPrivateKeyHex: String
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
let keyAndNonce = try decryptKeyFromSender(encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex)
|
let keyCandidates = try decryptKeyFromSenderCandidates(
|
||||||
|
encryptedKey: encryptedKey,
|
||||||
guard keyAndNonce.count >= 56 else {
|
myPrivateKeyHex: myPrivateKeyHex
|
||||||
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 {
|
var lastError: Error?
|
||||||
throw CryptoError.invalidData("Decrypted data is not valid UTF-8")
|
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.
|
/// Encrypts a message using XChaCha20-Poly1305 + ECDH for the recipient.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - plaintext: The message text.
|
/// - plaintext: The message text.
|
||||||
/// - recipientPublicKeyHex: Recipient's secp256k1 compressed public key (hex).
|
/// - 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, plainKeyAndNonce: raw key+nonce bytes).
|
||||||
/// - Returns: Tuple of (content: hex ciphertext+tag, chachaKey: base64 encrypted key for recipient, aesChachaKey: base64 encrypted key for sender).
|
|
||||||
static func encryptOutgoing(
|
static func encryptOutgoing(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
recipientPublicKeyHex: String,
|
recipientPublicKeyHex: String
|
||||||
senderPrivateKeyHex: String
|
) throws -> (content: String, chachaKey: String, plainKeyAndNonce: Data) {
|
||||||
) throws -> (content: String, chachaKey: String, aesChachaKey: String) {
|
|
||||||
guard let plaintextData = plaintext.data(using: .utf8) else {
|
guard let plaintextData = plaintext.data(using: .utf8) else {
|
||||||
throw CryptoError.invalidData("Cannot encode plaintext as UTF-8")
|
throw CryptoError.invalidData("Cannot encode plaintext as UTF-8")
|
||||||
}
|
}
|
||||||
@@ -73,34 +71,60 @@ enum MessageCrypto {
|
|||||||
keyAndNonce: keyAndNonce, recipientPublicKeyHex: recipientPublicKeyHex
|
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 (
|
return (
|
||||||
content: ciphertextWithTag.hexString,
|
content: ciphertextWithTag.hexString,
|
||||||
chachaKey: chachaKey,
|
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
|
// MARK: - ECDH Key Exchange
|
||||||
|
|
||||||
private extension MessageCrypto {
|
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)
|
/// Format: Base64(ivHex:encryptedKeyHex:ephemeralPrivateKeyHex)
|
||||||
static func decryptKeyFromSender(encryptedKey: String, myPrivateKeyHex: String) throws -> Data {
|
/// Supports Android sync shorthand `sync:<aesChachaKey>`.
|
||||||
guard let decoded = Data(base64Encoded: encryptedKey),
|
static func decryptKeyFromSenderCandidates(encryptedKey: String, myPrivateKeyHex: String) throws -> [Data] {
|
||||||
let combined = String(data: decoded, encoding: .utf8) else {
|
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")
|
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)
|
let parts = combined.split(separator: ":", maxSplits: 2).map(String.init)
|
||||||
guard parts.count == 3 else {
|
guard parts.count == 3 else {
|
||||||
@@ -119,11 +143,14 @@ private extension MessageCrypto {
|
|||||||
let iv = Data(hexString: ivHex)
|
let iv = Data(hexString: ivHex)
|
||||||
let encryptedKeyData = Data(hexString: encryptedKeyHex)
|
let encryptedKeyData = Data(hexString: encryptedKeyHex)
|
||||||
|
|
||||||
|
let normalizedEphemeralPrivateKeyHex = normalizePrivateKeyHex(ephemeralPrivateKeyHex)
|
||||||
|
let normalizedMyPrivateKeyHex = normalizePrivateKeyHex(myPrivateKeyHex)
|
||||||
|
|
||||||
let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey(
|
let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey(
|
||||||
dataRepresentation: Data(hexString: ephemeralPrivateKeyHex), format: .compressed
|
dataRepresentation: Data(hexString: normalizedEphemeralPrivateKeyHex), format: .compressed
|
||||||
)
|
)
|
||||||
let myPrivKey = try P256K.KeyAgreement.PrivateKey(
|
let myPrivKey = try P256K.KeyAgreement.PrivateKey(
|
||||||
dataRepresentation: Data(hexString: myPrivateKeyHex), format: .compressed
|
dataRepresentation: Data(hexString: normalizedMyPrivateKeyHex), format: .compressed
|
||||||
)
|
)
|
||||||
|
|
||||||
// ECDH: ephemeralPrivateKey × myPublicKey → shared point
|
// ECDH: ephemeralPrivateKey × myPublicKey → shared point
|
||||||
@@ -131,16 +158,37 @@ private extension MessageCrypto {
|
|||||||
with: myPrivKey.publicKey, format: .compressed
|
with: myPrivKey.publicKey, format: .compressed
|
||||||
)
|
)
|
||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
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 candidateSharedKeys: [Data] = sharedKeyLegacy == sharedKeyExact
|
||||||
let decryptedBytes = try CryptoPrimitives.aesCBCDecrypt(encryptedKeyData, key: sharedKey, iv: iv)
|
? [sharedKeyExact]
|
||||||
|
: [sharedKeyExact, sharedKeyLegacy]
|
||||||
|
|
||||||
// UTF-8 → Latin1 conversion (reverse of JS crypto-js compatibility)
|
var candidates: [Data] = []
|
||||||
guard let utf8String = String(data: decryptedBytes, encoding: .utf8) else {
|
var seen: Set<Data> = []
|
||||||
throw CryptoError.invalidData("Decrypted key is not valid UTF-8")
|
|
||||||
|
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.
|
/// Encrypts the XChaCha20 key+nonce for a recipient using ECDH.
|
||||||
@@ -157,8 +205,11 @@ private extension MessageCrypto {
|
|||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
let sharedKey = extractXCoordinate(from: sharedSecretData)
|
let sharedKey = extractXCoordinate(from: sharedSecretData)
|
||||||
|
|
||||||
// Convert keyAndNonce bytes to UTF-8 string representation (JS Buffer compatibility)
|
// Android parity: String(bytes, ISO_8859_1) -> UTF-8 bytes.
|
||||||
let utf8Representation = String(keyAndNonce.map { Character(UnicodeScalar($0)) })
|
guard let latin1String = String(data: keyAndNonce, encoding: .isoLatin1) else {
|
||||||
|
throw CryptoError.encryptionFailed
|
||||||
|
}
|
||||||
|
let utf8Representation = latin1String
|
||||||
guard let dataToEncrypt = utf8Representation.data(using: .utf8) else {
|
guard let dataToEncrypt = utf8Representation.data(using: .utf8) else {
|
||||||
throw CryptoError.encryptionFailed
|
throw CryptoError.encryptionFailed
|
||||||
}
|
}
|
||||||
@@ -174,11 +225,66 @@ private extension MessageCrypto {
|
|||||||
return base64
|
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 {
|
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 {
|
if sharedSecretData.count == 33 {
|
||||||
return sharedSecretData[1..<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])
|
var block = [UInt8](macInput[offset..<blockEnd])
|
||||||
while block.count < 16 { block.append(0) }
|
while block.count < 16 { block.append(0) }
|
||||||
|
|
||||||
// hibit is 1 for data blocks, 0 for length fields
|
// Poly1305 appends a 1-bit to each complete 16-byte block.
|
||||||
let isDataBlock = offset < data.count + padding
|
// In AEAD mode we process a fully padded stream
|
||||||
let bit: UInt64 = isDataBlock ? (1 << 24) : 0
|
// (ciphertext + pad16 + aadLen + ctLen), so every block here is complete.
|
||||||
|
let bit: UInt64 = 1 << 24
|
||||||
|
|
||||||
let n = toLimbs26(block)
|
let n = toLimbs26(block)
|
||||||
accumulator[0] = accumulator[0] &+ n[0]
|
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 Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
/// In-memory dialog store, updated from incoming protocol packets.
|
/// Account-scoped dialog store with disk persistence.
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class DialogRepository {
|
final class DialogRepository {
|
||||||
@@ -9,6 +9,8 @@ final class DialogRepository {
|
|||||||
static let shared = DialogRepository()
|
static let shared = DialogRepository()
|
||||||
|
|
||||||
private(set) var dialogs: [String: Dialog] = [:]
|
private(set) var dialogs: [String: Dialog] = [:]
|
||||||
|
private var currentAccount: String = ""
|
||||||
|
private var persistTask: Task<Void, Never>?
|
||||||
|
|
||||||
var sortedDialogs: [Dialog] {
|
var sortedDialogs: [Dialog] {
|
||||||
Array(dialogs.values).sorted {
|
Array(dialogs.values).sorted {
|
||||||
@@ -19,14 +21,61 @@ final class DialogRepository {
|
|||||||
|
|
||||||
private init() {}
|
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
|
// MARK: - Updates
|
||||||
|
|
||||||
func upsertDialog(_ dialog: Dialog) {
|
func upsertDialog(_ dialog: Dialog) {
|
||||||
|
if currentAccount.isEmpty {
|
||||||
|
currentAccount = dialog.account
|
||||||
|
}
|
||||||
dialogs[dialog.opponentKey] = dialog
|
dialogs[dialog.opponentKey] = dialog
|
||||||
|
schedulePersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates or updates a dialog from an incoming message packet.
|
/// Creates or updates a dialog from an incoming message packet.
|
||||||
func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) {
|
func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) {
|
||||||
|
if currentAccount.isEmpty {
|
||||||
|
currentAccount = myPublicKey
|
||||||
|
}
|
||||||
let fromMe = packet.fromPublicKey == myPublicKey
|
let fromMe = packet.fromPublicKey == myPublicKey
|
||||||
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
||||||
|
|
||||||
@@ -50,7 +99,7 @@ final class DialogRepository {
|
|||||||
)
|
)
|
||||||
|
|
||||||
dialog.lastMessage = decryptedText
|
dialog.lastMessage = decryptedText
|
||||||
dialog.lastMessageTimestamp = Int64(packet.timestamp)
|
dialog.lastMessageTimestamp = normalizeTimestamp(packet.timestamp)
|
||||||
dialog.lastMessageFromMe = fromMe
|
dialog.lastMessageFromMe = fromMe
|
||||||
dialog.lastMessageDelivered = fromMe ? .waiting : .delivered
|
dialog.lastMessageDelivered = fromMe ? .waiting : .delivered
|
||||||
|
|
||||||
@@ -61,6 +110,50 @@ final class DialogRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dialogs[opponentKey] = dialog
|
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) {
|
func updateOnlineState(publicKey: String, isOnline: Bool) {
|
||||||
@@ -70,12 +163,24 @@ final class DialogRepository {
|
|||||||
dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000)
|
dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
}
|
}
|
||||||
dialogs[publicKey] = dialog
|
dialogs[publicKey] = dialog
|
||||||
|
schedulePersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) {
|
func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) {
|
||||||
guard var dialog = dialogs[opponentKey] else { return }
|
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
|
dialog.lastMessageDelivered = status
|
||||||
dialogs[opponentKey] = dialog
|
dialogs[opponentKey] = dialog
|
||||||
|
schedulePersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0) {
|
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 !username.isEmpty { dialog.opponentUsername = username }
|
||||||
if verified > 0 { dialog.verified = max(dialog.verified, verified) }
|
if verified > 0 { dialog.verified = max(dialog.verified, verified) }
|
||||||
dialogs[publicKey] = dialog
|
dialogs[publicKey] = dialog
|
||||||
|
schedulePersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
func markAsRead(opponentKey: String) {
|
func markAsRead(opponentKey: String) {
|
||||||
guard var dialog = dialogs[opponentKey] else { return }
|
guard var dialog = dialogs[opponentKey] else { return }
|
||||||
|
guard dialog.unreadCount > 0 else { return }
|
||||||
dialog.unreadCount = 0
|
dialog.unreadCount = 0
|
||||||
dialogs[opponentKey] = dialog
|
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) {
|
func deleteDialog(opponentKey: String) {
|
||||||
dialogs.removeValue(forKey: opponentKey)
|
dialogs.removeValue(forKey: opponentKey)
|
||||||
|
schedulePersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
func togglePin(opponentKey: String) {
|
func togglePin(opponentKey: String) {
|
||||||
guard var dialog = dialogs[opponentKey] else { return }
|
guard var dialog = dialogs[opponentKey] else { return }
|
||||||
dialog.isPinned.toggle()
|
dialog.isPinned.toggle()
|
||||||
dialogs[opponentKey] = dialog
|
dialogs[opponentKey] = dialog
|
||||||
|
schedulePersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleMute(opponentKey: String) {
|
func toggleMute(opponentKey: String) {
|
||||||
guard var dialog = dialogs[opponentKey] else { return }
|
guard var dialog = dialogs[opponentKey] else { return }
|
||||||
dialog.isMuted.toggle()
|
dialog.isMuted.toggle()
|
||||||
dialogs[opponentKey] = dialog
|
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() },
|
0x07: { PacketRead() },
|
||||||
0x08: { PacketDelivery() },
|
0x08: { PacketDelivery() },
|
||||||
0x0B: { PacketTyping() },
|
0x0B: { PacketTyping() },
|
||||||
|
0x17: { PacketDeviceList() },
|
||||||
0x19: { PacketSync() },
|
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 toPublicKey: String = ""
|
||||||
var content: String = "" // XChaCha20-Poly1305 encrypted (hex)
|
var content: String = "" // XChaCha20-Poly1305 encrypted (hex)
|
||||||
var chachaKey: String = "" // ECDH-encrypted key+nonce
|
var chachaKey: String = "" // ECDH-encrypted key+nonce
|
||||||
var timestamp: Int32 = 0
|
var timestamp: Int64 = 0
|
||||||
var privateKey: String = "" // Hash for server auth
|
var privateKey: String = "" // Hash for server auth
|
||||||
var messageId: String = ""
|
var messageId: String = ""
|
||||||
var attachments: [MessageAttachment] = []
|
var attachments: [MessageAttachment] = []
|
||||||
@@ -20,7 +20,7 @@ struct PacketMessage: Packet {
|
|||||||
stream.writeString(toPublicKey)
|
stream.writeString(toPublicKey)
|
||||||
stream.writeString(content)
|
stream.writeString(content)
|
||||||
stream.writeString(chachaKey)
|
stream.writeString(chachaKey)
|
||||||
stream.writeInt32(Int(timestamp))
|
stream.writeInt64(timestamp)
|
||||||
stream.writeString(privateKey)
|
stream.writeString(privateKey)
|
||||||
stream.writeString(messageId)
|
stream.writeString(messageId)
|
||||||
stream.writeInt8(attachments.count)
|
stream.writeInt8(attachments.count)
|
||||||
@@ -31,7 +31,7 @@ struct PacketMessage: Packet {
|
|||||||
stream.writeString(attachment.blob)
|
stream.writeString(attachment.blob)
|
||||||
stream.writeInt8(attachment.type.rawValue)
|
stream.writeInt8(attachment.type.rawValue)
|
||||||
}
|
}
|
||||||
// No aesChachaKey — Android doesn't send it
|
stream.writeString(aesChachaKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func read(from stream: Stream) {
|
mutating func read(from stream: Stream) {
|
||||||
@@ -39,7 +39,7 @@ struct PacketMessage: Packet {
|
|||||||
toPublicKey = stream.readString()
|
toPublicKey = stream.readString()
|
||||||
content = stream.readString()
|
content = stream.readString()
|
||||||
chachaKey = stream.readString()
|
chachaKey = stream.readString()
|
||||||
timestamp = Int32(stream.readInt32())
|
timestamp = stream.readInt64()
|
||||||
privateKey = stream.readString()
|
privateKey = stream.readString()
|
||||||
messageId = stream.readString()
|
messageId = stream.readString()
|
||||||
|
|
||||||
@@ -54,6 +54,6 @@ struct PacketMessage: Packet {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
attachments = list
|
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 let client = WebSocketClient()
|
||||||
private var packetQueue: [any Packet] = []
|
private var packetQueue: [any Packet] = []
|
||||||
|
private var queuedPacketKeys: Set<String> = []
|
||||||
private var handshakeComplete = false
|
private var handshakeComplete = false
|
||||||
private var heartbeatTask: Task<Void, Never>?
|
private var heartbeatTask: Task<Void, Never>?
|
||||||
private var handshakeTimeoutTask: 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
|
// Saved credentials for auto-reconnect
|
||||||
private var savedPublicKey: String?
|
private var savedPublicKey: String?
|
||||||
@@ -88,14 +92,30 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
// MARK: - Sending
|
// MARK: - Sending
|
||||||
|
|
||||||
func sendPacket(_ packet: any Packet) {
|
func sendPacket(_ packet: any Packet) {
|
||||||
if !handshakeComplete && !(packet is PacketHandshake) {
|
if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected {
|
||||||
Self.logger.info("Queueing packet \(type(of: packet).packetId)")
|
enqueuePacket(packet)
|
||||||
packetQueue.append(packet)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendPacketDirect(packet)
|
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
|
// MARK: - Private Setup
|
||||||
|
|
||||||
private func setupClientCallbacks() {
|
private func setupClientCallbacks() {
|
||||||
@@ -212,6 +232,7 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
if let p = packet as? PacketSearch {
|
if let p = packet as? PacketSearch {
|
||||||
Self.logger.debug("📥 Search result: \(p.users.count) users, callback=\(self.onSearchResult != nil)")
|
Self.logger.debug("📥 Search result: \(p.users.count) users, callback=\(self.onSearchResult != nil)")
|
||||||
onSearchResult?(p)
|
onSearchResult?(p)
|
||||||
|
notifySearchResultHandlers(p)
|
||||||
}
|
}
|
||||||
case 0x05:
|
case 0x05:
|
||||||
if let p = packet as? PacketOnlineState {
|
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) {
|
private func handleHandshakeResponse(_ packet: PacketHandshake) {
|
||||||
// Set handshakeComplete BEFORE cancelling timeout to prevent race
|
|
||||||
handshakeComplete = true
|
|
||||||
handshakeTimeoutTask?.cancel()
|
handshakeTimeoutTask?.cancel()
|
||||||
handshakeTimeoutTask = nil
|
handshakeTimeoutTask = nil
|
||||||
|
|
||||||
switch packet.handshakeState {
|
switch packet.handshakeState {
|
||||||
case .completed:
|
case .completed:
|
||||||
|
handshakeComplete = true
|
||||||
Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s")
|
Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s")
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -261,7 +291,9 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
onHandshakeCompleted?(packet)
|
onHandshakeCompleted?(packet)
|
||||||
|
|
||||||
case .needDeviceVerification:
|
case .needDeviceVerification:
|
||||||
|
handshakeComplete = false
|
||||||
Self.logger.info("Server requires device verification")
|
Self.logger.info("Server requires device verification")
|
||||||
|
clearPacketQueue()
|
||||||
startHeartbeat(interval: packet.heartbeatInterval)
|
startHeartbeat(interval: packet.heartbeatInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,15 +321,71 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
private func sendPacketDirect(_ packet: any Packet) {
|
private func sendPacketDirect(_ packet: any Packet) {
|
||||||
let data = PacketRegistry.encode(packet)
|
let data = PacketRegistry.encode(packet)
|
||||||
Self.logger.info("Sending packet 0x\(String(type(of: packet).packetId, radix: 16)) (\(data.count) bytes)")
|
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() {
|
private func flushPacketQueue() {
|
||||||
Self.logger.info("Flushing \(self.packetQueue.count) queued packets")
|
let packets = drainPacketQueue()
|
||||||
let packets = packetQueue
|
Self.logger.info("Flushing \(packets.count) queued packets")
|
||||||
packetQueue.removeAll()
|
|
||||||
for packet in packets {
|
for packet in packets {
|
||||||
sendPacketDirect(packet)
|
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)
|
// MARK: - String (Int32 length + UTF-16 code units)
|
||||||
|
|
||||||
func writeString(_ value: String) {
|
func writeString(_ value: String) {
|
||||||
writeInt32(value.count)
|
let utf16Units = Array(value.utf16)
|
||||||
for char in value {
|
writeInt32(utf16Units.count)
|
||||||
for scalar in char.utf16 {
|
for codeUnit in utf16Units {
|
||||||
writeInt16(Int(scalar))
|
writeInt16(Int(codeUnit))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readString() -> String {
|
func readString() -> String {
|
||||||
let length = readInt32()
|
let length = readInt32()
|
||||||
var result = ""
|
let bitsAvailable = bytes.count * 8 - readPointer
|
||||||
result.reserveCapacity(length)
|
let bytesAvailable = max(bitsAvailable / 8, 0)
|
||||||
for _ in 0..<length {
|
if length < 0 || (length * 2) > bytesAvailable {
|
||||||
let code = readInt16()
|
return ""
|
||||||
if let scalar = Unicode.Scalar(code) {
|
|
||||||
result.append(Character(scalar))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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)
|
// MARK: - Bytes (Int32 length + raw Int8s)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
private var isManuallyClosed = false
|
private var isManuallyClosed = false
|
||||||
private var reconnectTask: Task<Void, Never>?
|
private var reconnectTask: Task<Void, Never>?
|
||||||
private var hasNotifiedConnected = false
|
private var hasNotifiedConnected = false
|
||||||
|
private(set) var isConnected = false
|
||||||
|
private var disconnectHandledForCurrentSocket = false
|
||||||
|
|
||||||
var onConnected: (() -> Void)?
|
var onConnected: (() -> Void)?
|
||||||
var onDisconnected: ((Error?) -> Void)?
|
var onDisconnected: ((Error?) -> Void)?
|
||||||
@@ -30,6 +32,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
guard webSocketTask == nil else { return }
|
guard webSocketTask == nil else { return }
|
||||||
isManuallyClosed = false
|
isManuallyClosed = false
|
||||||
hasNotifiedConnected = false
|
hasNotifiedConnected = false
|
||||||
|
isConnected = false
|
||||||
|
disconnectHandledForCurrentSocket = false
|
||||||
|
|
||||||
Self.logger.info("Connecting to \(self.url.absoluteString)")
|
Self.logger.info("Connecting to \(self.url.absoluteString)")
|
||||||
|
|
||||||
@@ -47,18 +51,24 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
reconnectTask = nil
|
reconnectTask = nil
|
||||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||||
webSocketTask = nil
|
webSocketTask = nil
|
||||||
|
isConnected = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func send(_ data: Data) {
|
@discardableResult
|
||||||
guard let task = webSocketTask else {
|
func send(_ data: Data, onFailure: ((Error?) -> Void)? = nil) -> Bool {
|
||||||
|
guard isConnected, let task = webSocketTask else {
|
||||||
Self.logger.warning("Cannot send: no active connection")
|
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 {
|
if let error {
|
||||||
Self.logger.error("Send error: \(error.localizedDescription)")
|
Self.logger.error("Send error: \(error.localizedDescription)")
|
||||||
|
onFailure?(error)
|
||||||
|
self.handleDisconnect(error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendText(_ text: String) {
|
func sendText(_ text: String) {
|
||||||
@@ -76,11 +86,16 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
Self.logger.info("WebSocket didOpen")
|
Self.logger.info("WebSocket didOpen")
|
||||||
guard !isManuallyClosed else { return }
|
guard !isManuallyClosed else { return }
|
||||||
hasNotifiedConnected = true
|
hasNotifiedConnected = true
|
||||||
|
isConnected = true
|
||||||
|
disconnectHandledForCurrentSocket = false
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
reconnectTask = nil
|
||||||
onConnected?()
|
onConnected?()
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||||
Self.logger.info("WebSocket didClose: \(closeCode.rawValue)")
|
Self.logger.info("WebSocket didClose: \(closeCode.rawValue)")
|
||||||
|
isConnected = false
|
||||||
handleDisconnect(error: nil)
|
handleDisconnect(error: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,16 +129,22 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
// MARK: - Reconnection
|
// MARK: - Reconnection
|
||||||
|
|
||||||
private func handleDisconnect(error: Error?) {
|
private func handleDisconnect(error: Error?) {
|
||||||
|
if disconnectHandledForCurrentSocket {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disconnectHandledForCurrentSocket = true
|
||||||
webSocketTask = nil
|
webSocketTask = nil
|
||||||
|
isConnected = false
|
||||||
onDisconnected?(error)
|
onDisconnected?(error)
|
||||||
|
|
||||||
guard !isManuallyClosed else { return }
|
guard !isManuallyClosed else { return }
|
||||||
|
|
||||||
reconnectTask?.cancel()
|
guard reconnectTask == nil else { return }
|
||||||
reconnectTask = Task { [weak self] in
|
reconnectTask = Task { [weak self] in
|
||||||
Self.logger.info("Reconnecting in 5 seconds...")
|
Self.logger.info("Reconnecting in 5 seconds...")
|
||||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||||
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
|
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
|
||||||
|
self.reconnectTask = nil
|
||||||
self.connect()
|
self.connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,22 @@ final class SessionManager {
|
|||||||
private(set) var privateKeyHash: String?
|
private(set) var privateKeyHash: String?
|
||||||
/// Hex-encoded raw private key, kept in memory for message decryption.
|
/// Hex-encoded raw private key, kept in memory for message decryption.
|
||||||
private(set) var privateKeyHex: String?
|
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() {
|
private init() {
|
||||||
setupProtocolCallbacks()
|
setupProtocolCallbacks()
|
||||||
@@ -45,6 +61,11 @@ final class SessionManager {
|
|||||||
displayName = account.displayName ?? ""
|
displayName = account.displayName ?? ""
|
||||||
username = account.username ?? ""
|
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
|
// Generate private key hash for handshake
|
||||||
let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex)
|
let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex)
|
||||||
privateKeyHash = hash
|
privateKeyHash = hash
|
||||||
@@ -65,33 +86,63 @@ final class SessionManager {
|
|||||||
throw CryptoError.decryptionFailed
|
throw CryptoError.decryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
let messageId = UUID().uuidString
|
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||||
let timestamp = Int32(Date().timeIntervalSince1970)
|
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let packet = try makeOutgoingPacket(
|
||||||
// Encrypt the message
|
text: text,
|
||||||
let encrypted = try MessageCrypto.encryptOutgoing(
|
toPublicKey: toPublicKey,
|
||||||
plaintext: text,
|
messageId: messageId,
|
||||||
recipientPublicKeyHex: toPublicKey,
|
timestamp: timestamp,
|
||||||
senderPrivateKeyHex: privKey
|
privateKeyHex: privKey,
|
||||||
|
privateKeyHash: hash
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build packet
|
DialogRepository.shared.ensureDialog(
|
||||||
var packet = PacketMessage()
|
opponentKey: toPublicKey,
|
||||||
packet.fromPublicKey = currentPublicKey
|
title: "",
|
||||||
packet.toPublicKey = toPublicKey
|
username: "",
|
||||||
packet.content = encrypted.content
|
myPublicKey: currentPublicKey
|
||||||
packet.chachaKey = encrypted.chachaKey
|
)
|
||||||
packet.timestamp = timestamp
|
|
||||||
packet.privateKey = hash
|
|
||||||
packet.messageId = messageId
|
|
||||||
|
|
||||||
// Optimistic UI update — show message immediately as "waiting"
|
// Optimistic UI update — show message immediately as "waiting"
|
||||||
DialogRepository.shared.updateFromMessage(
|
DialogRepository.shared.updateFromMessage(
|
||||||
packet, myPublicKey: currentPublicKey, decryptedText: text
|
packet, myPublicKey: currentPublicKey, decryptedText: text
|
||||||
)
|
)
|
||||||
|
MessageRepository.shared.upsertFromMessagePacket(
|
||||||
|
packet,
|
||||||
|
myPublicKey: currentPublicKey,
|
||||||
|
decryptedText: text
|
||||||
|
)
|
||||||
|
|
||||||
// Send via WebSocket
|
// Send via WebSocket
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
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.
|
/// Ends the session and disconnects.
|
||||||
@@ -99,10 +150,27 @@ final class SessionManager {
|
|||||||
ProtocolManager.shared.disconnect()
|
ProtocolManager.shared.disconnect()
|
||||||
privateKeyHash = nil
|
privateKeyHash = nil
|
||||||
privateKeyHex = 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
|
isAuthenticated = false
|
||||||
currentPublicKey = ""
|
currentPublicKey = ""
|
||||||
displayName = ""
|
displayName = ""
|
||||||
username = ""
|
username = ""
|
||||||
|
DialogRepository.shared.reset()
|
||||||
|
MessageRepository.shared.reset()
|
||||||
|
RecentSearchesRepository.shared.clearSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Protocol Callbacks
|
// MARK: - Protocol Callbacks
|
||||||
@@ -112,55 +180,71 @@ final class SessionManager {
|
|||||||
|
|
||||||
proto.onMessageReceived = { [weak self] packet in
|
proto.onMessageReceived = { [weak self] packet in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor [weak self] in
|
||||||
let myKey = self.currentPublicKey
|
self?.enqueueIncomingMessage(packet)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
proto.onDeliveryReceived = { packet in
|
proto.onDeliveryReceived = { [weak self] packet in
|
||||||
Task { @MainActor 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,
|
messageId: packet.messageId,
|
||||||
opponentKey: packet.toPublicKey,
|
|
||||||
status: .delivered
|
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
|
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 {
|
} else {
|
||||||
Self.logger.debug("Skipping UserInfo — no profile data to send")
|
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
|
lhs.isPlaying == rhs.isPlaying
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator {
|
||||||
|
var didPlayOnce = false
|
||||||
|
var lastAnimationName = ""
|
||||||
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> LottieAnimationView {
|
func makeUIView(context: Context) -> LottieAnimationView {
|
||||||
let animationView: LottieAnimationView
|
let animationView: LottieAnimationView
|
||||||
if let cached = LottieAnimationCache.shared.animation(named: animationName) {
|
if let cached = LottieAnimationCache.shared.animation(named: animationName) {
|
||||||
@@ -57,21 +66,45 @@ struct LottieView: UIViewRepresentable, Equatable {
|
|||||||
animationView.animationSpeed = animationSpeed
|
animationView.animationSpeed = animationSpeed
|
||||||
animationView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
animationView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
animationView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
animationView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||||
if isPlaying {
|
context.coordinator.lastAnimationName = animationName
|
||||||
animationView.play()
|
playIfNeeded(animationView, coordinator: context.coordinator)
|
||||||
}
|
|
||||||
return animationView
|
return animationView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
|
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
|
||||||
if isPlaying {
|
if context.coordinator.lastAnimationName != animationName {
|
||||||
if !uiView.isAnimationPlaying {
|
if let cached = LottieAnimationCache.shared.animation(named: animationName) {
|
||||||
uiView.play()
|
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 {
|
if uiView.isAnimationPlaying {
|
||||||
uiView.stop()
|
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 SwiftUI
|
||||||
import UIKit
|
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
|
// MARK: - Tab
|
||||||
|
|
||||||
enum RosettaTab: CaseIterable, Sendable {
|
enum RosettaTab: CaseIterable, Sendable {
|
||||||
@@ -23,11 +8,13 @@ enum RosettaTab: CaseIterable, Sendable {
|
|||||||
case settings
|
case settings
|
||||||
case search
|
case search
|
||||||
|
|
||||||
|
static let interactionOrder: [RosettaTab] = [.chats, .settings, .search]
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .chats: return "Chats"
|
case .chats: return "Chats"
|
||||||
case .settings: return "Settings"
|
case .settings: return "Settings"
|
||||||
case .search: return ""
|
case .search: return "Search"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +33,10 @@ enum RosettaTab: CaseIterable, Sendable {
|
|||||||
case .search: return "magnifyingglass"
|
case .search: return "magnifyingglass"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var interactionIndex: Int {
|
||||||
|
Self.interactionOrder.firstIndex(of: self) ?? 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tab Badge
|
// MARK: - Tab Badge
|
||||||
@@ -55,27 +46,47 @@ struct TabBadge {
|
|||||||
let text: String
|
let text: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TabBarSwipeState {
|
||||||
|
let fromTab: RosettaTab
|
||||||
|
let hoveredTab: RosettaTab
|
||||||
|
let fractionalIndex: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - RosettaTabBar
|
// MARK: - RosettaTabBar
|
||||||
|
|
||||||
struct RosettaTabBar: View {
|
struct RosettaTabBar: View {
|
||||||
let selectedTab: RosettaTab
|
let selectedTab: RosettaTab
|
||||||
var onTabSelected: ((RosettaTab) -> Void)?
|
var onTabSelected: ((RosettaTab) -> Void)?
|
||||||
|
var onSwipeStateChanged: ((TabBarSwipeState?) -> Void)?
|
||||||
var badges: [TabBadge] = []
|
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 {
|
var body: some View {
|
||||||
if #available(iOS 26, *) {
|
interactiveTabBarContent
|
||||||
GlassEffectContainer(spacing: 8) {
|
|
||||||
tabBarContent
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 25)
|
.padding(.horizontal, 25)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
} else {
|
}
|
||||||
tabBarContent
|
|
||||||
.padding(.horizontal, 25)
|
private var interactiveTabBarContent: some View {
|
||||||
.padding(.top, 4)
|
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 {
|
private var tabBarContent: some View {
|
||||||
@@ -84,29 +95,185 @@ struct RosettaTabBar: View {
|
|||||||
searchPill
|
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
|
// MARK: - Main Tabs Pill
|
||||||
|
|
||||||
private extension RosettaTabBar {
|
private extension RosettaTabBar {
|
||||||
var mainTabsPill: some View {
|
var mainTabsPill: some View {
|
||||||
// Content on top — NOT clipped (lens can pop out)
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in
|
ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in
|
||||||
TabItemButton(
|
TabItemView(
|
||||||
tab: tab,
|
tab: tab,
|
||||||
isSelected: tab == selectedTab,
|
isSelected: tab == visualSelectedTab,
|
||||||
badgeText: badges.first(where: { $0.tab == tab })?.text,
|
isCoveredByLens: isCoveredByLens(tab),
|
||||||
onTap: { onTabSelected?(tab) },
|
badgeText: badgeText(for: tab)
|
||||||
glassNamespace: glassNS
|
|
||||||
)
|
)
|
||||||
|
.tabFramePreference(tab: tab, in: Self.tabBarSpace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.padding(.top, 3)
|
.padding(.top, 3)
|
||||||
.padding(.bottom, 3)
|
.padding(.bottom, 3)
|
||||||
.frame(height: 62)
|
.frame(height: 62)
|
||||||
// Background clipped separately — content stays unclipped
|
|
||||||
.background {
|
.background {
|
||||||
mainPillGlass
|
mainPillGlass
|
||||||
}
|
}
|
||||||
@@ -114,106 +281,117 @@ private extension RosettaTabBar {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var mainPillGlass: some View {
|
var mainPillGlass: some View {
|
||||||
if #available(iOS 26, *) {
|
ZStack {
|
||||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
Capsule().fill(.ultraThinMaterial)
|
||||||
} else {
|
Capsule().fill(Color.black.opacity(0.34))
|
||||||
ZStack {
|
Capsule().fill(
|
||||||
// 1. Material
|
LinearGradient(
|
||||||
Capsule().fill(.ultraThinMaterial)
|
colors: [Color.white.opacity(0.08), .clear],
|
||||||
// 2. Dark tint
|
startPoint: .top,
|
||||||
Capsule().fill(Color.black.opacity(0.22))
|
endPoint: .bottom
|
||||||
// 3. Highlight
|
)
|
||||||
Capsule().fill(
|
).blendMode(.screen)
|
||||||
LinearGradient(
|
Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1)
|
||||||
colors: [Color.white.opacity(0.14), .clear],
|
Capsule().stroke(Color.white.opacity(0.08), lineWidth: 1).padding(1.5)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
.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 tab: RosettaTab
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
|
let isCoveredByLens: Bool
|
||||||
let badgeText: String?
|
let badgeText: String?
|
||||||
let onTap: () -> Void
|
|
||||||
var glassNamespace: Namespace.ID?
|
|
||||||
|
|
||||||
@State private var pressed = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTap) {
|
VStack(spacing: 1) {
|
||||||
VStack(spacing: 1) {
|
ZStack(alignment: .topTrailing) {
|
||||||
ZStack(alignment: .topTrailing) {
|
Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
|
||||||
Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
|
.font(.system(size: 22))
|
||||||
.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))
|
|
||||||
.foregroundStyle(tabColor)
|
.foregroundStyle(tabColor)
|
||||||
}
|
.frame(height: 30)
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background {
|
if let badgeText {
|
||||||
if isSelected && !pressed {
|
Text(badgeText)
|
||||||
RoundedRectangle(cornerRadius: 100)
|
.font(.system(size: 10, weight: .medium))
|
||||||
.fill(RosettaColors.adaptive(
|
.foregroundStyle(.white)
|
||||||
light: Color(hex: 0xEDEDED),
|
.padding(.horizontal, badgeText.count > 2 ? 4 : 0)
|
||||||
dark: Color.white.opacity(0.12)
|
.frame(minWidth: 18, minHeight: 18)
|
||||||
))
|
.background(Capsule().fill(RosettaColors.error))
|
||||||
.padding(.horizontal, -8)
|
.offset(x: 10, y: -4)
|
||||||
.padding(.vertical, -6)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text(tab.label)
|
||||||
|
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
|
||||||
|
.foregroundStyle(tabColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.frame(maxWidth: .infinity)
|
||||||
// Lens: padding → glass bubble → scale → lift
|
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.background {
|
.opacity(isCoveredByLens ? 0.07 : 1)
|
||||||
if pressed {
|
.animation(.easeInOut(duration: 0.14), value: isCoveredByLens)
|
||||||
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))
|
|
||||||
.accessibilityLabel(tab.label)
|
.accessibilityLabel(tab.label)
|
||||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||||
}
|
}
|
||||||
@@ -226,177 +404,126 @@ private struct TabItemButton: View {
|
|||||||
dark: Color(hex: 0x8E8E93)
|
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
|
// MARK: - Search Pill
|
||||||
|
|
||||||
private extension RosettaTabBar {
|
private extension RosettaTabBar {
|
||||||
var searchPill: some View {
|
var searchPill: some View {
|
||||||
SearchPillButton(
|
SearchPillView(
|
||||||
isSelected: selectedTab == .search,
|
isSelected: visualSelectedTab == .search,
|
||||||
onTap: { onTabSelected?(.search) },
|
isCoveredByLens: isCoveredByLens(.search)
|
||||||
glassNamespace: glassNS
|
|
||||||
)
|
)
|
||||||
|
.tabFramePreference(tab: .search, in: Self.tabBarSpace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SearchPillButton: View {
|
private struct SearchPillView: View {
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
let onTap: () -> Void
|
let isCoveredByLens: Bool
|
||||||
var glassNamespace: Namespace.ID?
|
|
||||||
|
|
||||||
@State private var pressed = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTap) {
|
Image(systemName: "magnifyingglass")
|
||||||
Image(systemName: "magnifyingglass")
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.foregroundStyle(
|
||||||
.foregroundStyle(
|
isSelected
|
||||||
isSelected
|
? RosettaColors.primaryBlue
|
||||||
? RosettaColors.primaryBlue
|
: RosettaColors.adaptive(
|
||||||
: RosettaColors.adaptive(
|
light: Color(hex: 0x404040),
|
||||||
light: Color(hex: 0x404040),
|
dark: Color(hex: 0x8E8E93)
|
||||||
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
|
|
||||||
)
|
)
|
||||||
).blendMode(.screen)
|
)
|
||||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
.frame(width: 62, height: 62)
|
||||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
.opacity(isCoveredByLens ? 0.08 : 1)
|
||||||
Circle().stroke(
|
.animation(.easeInOut(duration: 0.14), value: isCoveredByLens)
|
||||||
AngularGradient(
|
.background { searchPillGlass }
|
||||||
colors: [
|
.accessibilityLabel("Search")
|
||||||
Color.cyan.opacity(0.55),
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var searchPillGlass: some View {
|
private var searchPillGlass: some View {
|
||||||
if #available(iOS 26, *) {
|
ZStack {
|
||||||
Circle().fill(.clear).glassEffect(.regular, in: .circle)
|
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 {
|
} else {
|
||||||
ZStack {
|
VStack(spacing: 3) {
|
||||||
Circle().fill(.ultraThinMaterial)
|
ZStack(alignment: .topTrailing) {
|
||||||
Circle().fill(Color.black.opacity(0.22))
|
Image(systemName: tab.selectedIcon)
|
||||||
Circle().fill(
|
.font(.system(size: 30))
|
||||||
LinearGradient(
|
.foregroundStyle(.white)
|
||||||
colors: [Color.white.opacity(0.14), .clear],
|
.frame(height: 36)
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
if let badgeText {
|
||||||
)
|
Text(badgeText)
|
||||||
).blendMode(.screen)
|
.font(.system(size: 10, weight: .medium))
|
||||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
.foregroundStyle(.white)
|
||||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
.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) {
|
ZStack(alignment: .bottom) {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
Text("Hold a tab to see the lens")
|
|
||||||
.foregroundStyle(.white.opacity(0.5))
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
RosettaTabBar(
|
RosettaTabBar(
|
||||||
selectedTab: .chats,
|
selectedTab: .chats,
|
||||||
badges: [
|
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
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Chat List Search Content
|
// MARK: - Chat List Search Content
|
||||||
@@ -9,6 +8,7 @@ struct ChatListSearchContent: View {
|
|||||||
let searchText: String
|
let searchText: String
|
||||||
@ObservedObject var viewModel: ChatListViewModel
|
@ObservedObject var viewModel: ChatListViewModel
|
||||||
var onSelectRecent: (String) -> Void
|
var onSelectRecent: (String) -> Void
|
||||||
|
var onOpenDialog: (ChatRoute) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let trimmed = searchText.trimmingCharacters(in: .whitespaces)
|
let trimmed = searchText.trimmingCharacters(in: .whitespaces)
|
||||||
@@ -46,8 +46,11 @@ private extension ChatListSearchContent {
|
|||||||
var noResultsState: some View {
|
var noResultsState: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Spacer()
|
Spacer()
|
||||||
LottieView(animationName: "search", loopMode: .playOnce, animationSpeed: 1.0)
|
LottieView(
|
||||||
.frame(width: 120, height: 120)
|
animationName: "search",
|
||||||
|
animationSpeed: 1.0
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
Text("Search for users")
|
Text("Search for users")
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 17, weight: .medium))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
@@ -64,7 +67,12 @@ private extension ChatListSearchContent {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(localResults) { dialog in
|
ForEach(localResults) { dialog in
|
||||||
ChatRowView(dialog: dialog)
|
Button {
|
||||||
|
onOpenDialog(ChatRoute(dialog: dialog))
|
||||||
|
} label: {
|
||||||
|
ChatRowView(dialog: dialog)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(serverOnly, id: \.publicKey) { user in
|
ForEach(serverOnly, id: \.publicKey) { user in
|
||||||
@@ -79,7 +87,7 @@ private extension ChatListSearchContent {
|
|||||||
Spacer().frame(height: 80)
|
Spacer().frame(height: 80)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.immediately)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,15 +121,18 @@ private extension ChatListSearchContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.immediately)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchPlaceholder: some View {
|
var searchPlaceholder: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Spacer()
|
Spacer()
|
||||||
LottieView(animationName: "search", loopMode: .loop, animationSpeed: 1.0)
|
LottieView(
|
||||||
.frame(width: 120, height: 120)
|
animationName: "search",
|
||||||
|
animationSpeed: 1.0
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
Text("Search for users")
|
Text("Search for users")
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 17, weight: .medium))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
@@ -200,6 +211,7 @@ private extension ChatListSearchContent {
|
|||||||
|
|
||||||
return Button {
|
return Button {
|
||||||
viewModel.addToRecent(user)
|
viewModel.addToRecent(user)
|
||||||
|
onOpenDialog(ChatRoute(user: user))
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
AvatarView(
|
AvatarView(
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ChatListView: View {
|
struct ChatListView: View {
|
||||||
@Binding var isSearchActive: Bool
|
@Binding var isSearchActive: Bool
|
||||||
|
var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil
|
||||||
@StateObject private var viewModel = ChatListViewModel()
|
@StateObject private var viewModel = ChatListViewModel()
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
|
@State private var navigationPath: [ChatRoute] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack(path: $navigationPath) {
|
||||||
ZStack {
|
ZStack {
|
||||||
RosettaColors.Adaptive.background
|
RosettaColors.Adaptive.background
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
@@ -17,7 +19,12 @@ struct ChatListView: View {
|
|||||||
ChatListSearchContent(
|
ChatListSearchContent(
|
||||||
searchText: searchText,
|
searchText: searchText,
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
onSelectRecent: { searchText = $0 }
|
onSelectRecent: { searchText = $0 },
|
||||||
|
onOpenDialog: { route in
|
||||||
|
isSearchActive = false
|
||||||
|
searchText = ""
|
||||||
|
navigationPath.append(route)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
normalContent
|
normalContent
|
||||||
@@ -36,6 +43,20 @@ struct ChatListView: View {
|
|||||||
.onChange(of: searchText) { _, newValue in
|
.onChange(of: searchText) { _, newValue in
|
||||||
viewModel.setSearchQuery(newValue)
|
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)
|
.tint(RosettaColors.figmaBlue)
|
||||||
}
|
}
|
||||||
@@ -81,11 +102,14 @@ private extension ChatListView {
|
|||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.immediately)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatRow(_ dialog: Dialog) -> some View {
|
func chatRow(_ dialog: Dialog) -> some View {
|
||||||
ChatRowView(dialog: dialog)
|
NavigationLink(value: ChatRoute(dialog: dialog)) {
|
||||||
|
ChatRowView(dialog: dialog)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
.listRowSeparator(.visible)
|
.listRowSeparator(.visible)
|
||||||
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
|
.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 searchTask: Task<Void, Never>?
|
||||||
private var lastSearchedText = ""
|
private var lastSearchedText = ""
|
||||||
private static let maxRecent = 20
|
private var searchHandlerToken: UUID?
|
||||||
|
private var recentSearchesCancellable: AnyCancellable?
|
||||||
private var recentKey: String {
|
private let recentRepository = RecentSearchesRepository.shared
|
||||||
"rosetta_recent_searches_\(SessionManager.shared.currentPublicKey)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
loadRecentSearches()
|
configureRecentSearches()
|
||||||
setupSearchCallback()
|
setupSearchCallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +59,12 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
func setSearchQuery(_ query: String) {
|
func setSearchQuery(_ query: String) {
|
||||||
searchQuery = query
|
searchQuery = normalizeSearchInput(query)
|
||||||
triggerServerSearch()
|
triggerServerSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteDialog(_ dialog: Dialog) {
|
func deleteDialog(_ dialog: Dialog) {
|
||||||
|
MessageRepository.shared.deleteDialog(dialog.opponentKey)
|
||||||
DialogRepository.shared.deleteDialog(opponentKey: dialog.opponentKey)
|
DialogRepository.shared.deleteDialog(opponentKey: dialog.opponentKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +83,21 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
// MARK: - Server Search
|
// MARK: - Server Search
|
||||||
|
|
||||||
private func setupSearchCallback() {
|
private func setupSearchCallback() {
|
||||||
|
if let token = searchHandlerToken {
|
||||||
|
ProtocolManager.shared.removeSearchResultHandler(token)
|
||||||
|
}
|
||||||
|
|
||||||
Self.logger.debug("Setting up search callback")
|
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
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
Self.logger.debug("Search callback: self is nil")
|
Self.logger.debug("Search callback: self is nil")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard !self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
self.isServerSearching = false
|
||||||
|
return
|
||||||
|
}
|
||||||
Self.logger.debug("📥 Search results received: \(packet.users.count) users")
|
Self.logger.debug("📥 Search results received: \(packet.users.count) users")
|
||||||
self.serverSearchResults = packet.users
|
self.serverSearchResults = packet.users
|
||||||
self.isServerSearching = false
|
self.isServerSearching = false
|
||||||
@@ -130,7 +137,7 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
guard !currentQuery.isEmpty, currentQuery == trimmed else { return }
|
guard !currentQuery.isEmpty, currentQuery == trimmed else { return }
|
||||||
|
|
||||||
let connState = ProtocolManager.shared.connectionState
|
let connState = ProtocolManager.shared.connectionState
|
||||||
let hash = SessionManager.shared.privateKeyHash
|
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
|
||||||
|
|
||||||
guard connState == .authenticated, let hash else {
|
guard connState == .authenticated, let hash else {
|
||||||
self.isServerSearching = false
|
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
|
// MARK: - Recent Searches
|
||||||
|
|
||||||
func addToRecent(_ user: SearchUser) {
|
func addToRecent(_ user: SearchUser) {
|
||||||
let recent = RecentSearch(
|
recentRepository.add(user)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeRecentSearch(publicKey: String) {
|
func removeRecentSearch(publicKey: String) {
|
||||||
recentSearches.removeAll { $0.publicKey == publicKey }
|
recentRepository.remove(publicKey: publicKey)
|
||||||
saveRecentSearches()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearRecentSearches() {
|
func clearRecentSearches() {
|
||||||
recentSearches = []
|
recentRepository.clearAll()
|
||||||
saveRecentSearches()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadRecentSearches() {
|
private func configureRecentSearches() {
|
||||||
if let data = UserDefaults.standard.data(forKey: recentKey),
|
recentRepository.setAccount(SessionManager.shared.currentPublicKey)
|
||||||
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
|
recentSearches = recentRepository.recentSearches
|
||||||
recentSearches = list
|
recentSearchesCancellable = recentRepository.$recentSearches
|
||||||
return
|
.receive(on: DispatchQueue.main)
|
||||||
}
|
.sink { [weak self] list in
|
||||||
let oldKey = "rosetta_recent_searches"
|
self?.recentSearches = list
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
// MARK: - SearchView
|
||||||
|
|
||||||
struct SearchView: View {
|
struct SearchView: View {
|
||||||
|
var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil
|
||||||
@State private var viewModel = SearchViewModel()
|
@State private var viewModel = SearchViewModel()
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@FocusState private var isSearchFocused: Bool
|
@State private var navigationPath: [ChatRoute] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
NavigationStack(path: $navigationPath) {
|
||||||
RosettaColors.Adaptive.background
|
ZStack(alignment: .bottom) {
|
||||||
.ignoresSafeArea()
|
RosettaColors.Adaptive.background
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if searchText.isEmpty {
|
if searchText.isEmpty {
|
||||||
favoriteContactsRow
|
favoriteContactsRow
|
||||||
recentSection
|
recentSection
|
||||||
} else {
|
} else {
|
||||||
searchResultsContent
|
searchResultsContent
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer().frame(height: 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer().frame(height: 120)
|
|
||||||
}
|
}
|
||||||
}
|
.scrollDismissesKeyboard(.immediately)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
|
||||||
|
|
||||||
searchBar
|
searchBar
|
||||||
}
|
}
|
||||||
.onChange(of: searchText) { _, newValue in
|
.onChange(of: searchText) { _, newValue in
|
||||||
viewModel.setSearchQuery(newValue)
|
viewModel.setSearchQuery(newValue)
|
||||||
}
|
}
|
||||||
.task {
|
.navigationDestination(for: ChatRoute.self) { route in
|
||||||
// Auto-focus search field when the view appears
|
ChatDetailView(
|
||||||
try? await Task.sleep(for: .milliseconds(300))
|
route: route,
|
||||||
isSearchFocused = true
|
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)
|
TextField("Search", text: $searchText)
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 17, weight: .medium))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
.focused($isSearchFocused)
|
|
||||||
.submitLabel(.search)
|
.submitLabel(.search)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
@@ -128,7 +139,7 @@ private extension SearchView {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
ForEach(Array(dialogs), id: \.id) { dialog in
|
ForEach(Array(dialogs), id: \.id) { dialog in
|
||||||
Button {
|
Button {
|
||||||
// TODO: Navigate to chat
|
navigationPath.append(ChatRoute(dialog: dialog))
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
AvatarView(
|
AvatarView(
|
||||||
@@ -197,9 +208,11 @@ private extension SearchView {
|
|||||||
|
|
||||||
var emptyState: some View {
|
var emptyState: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "magnifyingglass")
|
LottieView(
|
||||||
.font(.system(size: 52))
|
animationName: "search",
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
animationSpeed: 1.0
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
.padding(.top, 100)
|
.padding(.top, 100)
|
||||||
|
|
||||||
Text("Search for users")
|
Text("Search for users")
|
||||||
@@ -221,7 +234,7 @@ private extension SearchView {
|
|||||||
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
|
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
|
||||||
|
|
||||||
return Button {
|
return Button {
|
||||||
searchText = user.username.isEmpty ? user.publicKey : user.username
|
navigationPath.append(ChatRoute(recent: user))
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
@@ -277,7 +290,7 @@ private extension SearchView {
|
|||||||
searchResults: viewModel.searchResults,
|
searchResults: viewModel.searchResults,
|
||||||
onSelectUser: { user in
|
onSelectUser: { user in
|
||||||
viewModel.addToRecent(user)
|
viewModel.addToRecent(user)
|
||||||
// TODO: Navigate to ChatDetailView for user.publicKey
|
navigationPath.append(ChatRoute(user: user))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -286,6 +299,5 @@ private extension SearchView {
|
|||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SearchView()
|
SearchView(onChatDetailVisibilityChange: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
|
|
||||||
// MARK: - Recent Search Model
|
|
||||||
|
|
||||||
struct RecentSearch: Codable, Equatable {
|
|
||||||
let publicKey: String
|
|
||||||
var title: String
|
|
||||||
var username: String
|
|
||||||
var lastSeenText: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - SearchViewModel
|
// MARK: - SearchViewModel
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -28,25 +20,21 @@ final class SearchViewModel {
|
|||||||
|
|
||||||
private var searchTask: Task<Void, Never>?
|
private var searchTask: Task<Void, Never>?
|
||||||
private var lastSearchedText = ""
|
private var lastSearchedText = ""
|
||||||
|
private var searchHandlerToken: UUID?
|
||||||
private var recentKey: String {
|
private var recentSearchesCancellable: AnyCancellable?
|
||||||
let pk = SessionManager.shared.currentPublicKey ?? ""
|
private let recentRepository = RecentSearchesRepository.shared
|
||||||
return "rosetta_recent_searches_\(pk)"
|
|
||||||
}
|
|
||||||
private static let maxRecent = 20
|
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
loadRecentSearches()
|
configureRecentSearches()
|
||||||
setupSearchCallback()
|
setupSearchCallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Search Logic
|
// MARK: - Search Logic
|
||||||
|
|
||||||
func setSearchQuery(_ query: String) {
|
func setSearchQuery(_ query: String) {
|
||||||
|
searchQuery = normalizeSearchInput(query)
|
||||||
searchQuery = query
|
|
||||||
onSearchQueryChanged()
|
onSearchQueryChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +74,7 @@ final class SearchViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let connState = ProtocolManager.shared.connectionState
|
let connState = ProtocolManager.shared.connectionState
|
||||||
let hash = SessionManager.shared.privateKeyHash
|
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
|
||||||
|
|
||||||
|
|
||||||
guard connState == .authenticated, let hash else {
|
guard connState == .authenticated, let hash else {
|
||||||
@@ -117,9 +105,18 @@ final class SearchViewModel {
|
|||||||
// MARK: - Search Callback
|
// MARK: - Search Callback
|
||||||
|
|
||||||
private func setupSearchCallback() {
|
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
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
guard !self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
self.isSearching = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
self.searchResults = packet.users
|
self.searchResults = packet.users
|
||||||
self.isSearching = false
|
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
|
// MARK: - Recent Searches
|
||||||
|
|
||||||
func addToRecent(_ user: SearchUser) {
|
func addToRecent(_ user: SearchUser) {
|
||||||
let recent = RecentSearch(
|
recentRepository.add(user)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeRecentSearch(publicKey: String) {
|
func removeRecentSearch(publicKey: String) {
|
||||||
recentSearches.removeAll { $0.publicKey == publicKey }
|
recentRepository.remove(publicKey: publicKey)
|
||||||
saveRecentSearches()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearRecentSearches() {
|
func clearRecentSearches() {
|
||||||
recentSearches = []
|
recentRepository.clearAll()
|
||||||
saveRecentSearches()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadRecentSearches() {
|
private func configureRecentSearches() {
|
||||||
if let data = UserDefaults.standard.data(forKey: recentKey),
|
recentRepository.setAccount(SessionManager.shared.currentPublicKey)
|
||||||
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
|
recentSearches = recentRepository.recentSearches
|
||||||
recentSearches = list
|
recentSearchesCancellable = recentRepository.$recentSearches
|
||||||
return
|
.receive(on: DispatchQueue.main)
|
||||||
}
|
.sink { [weak self] list in
|
||||||
// Migrate from old static key
|
self?.recentSearches = list
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,52 +5,159 @@ struct MainTabView: View {
|
|||||||
var onLogout: (() -> Void)?
|
var onLogout: (() -> Void)?
|
||||||
@State private var selectedTab: RosettaTab = .chats
|
@State private var selectedTab: RosettaTab = .chats
|
||||||
@State private var isChatSearchActive = false
|
@State private var isChatSearchActive = false
|
||||||
|
@State private var tabSwipeState: TabBarSwipeState?
|
||||||
|
@State private var isChatListDetailPresented = false
|
||||||
|
@State private var isSearchDetailPresented = false
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
ZStack(alignment: .bottom) {
|
||||||
RosettaColors.Adaptive.background
|
RosettaColors.Adaptive.background
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
Group {
|
GeometryReader { geometry in
|
||||||
switch selectedTab {
|
tabPager(availableSize: geometry.size)
|
||||||
case .chats:
|
|
||||||
ChatListView(isSearchActive: $isChatSearchActive)
|
|
||||||
.transition(.opacity)
|
|
||||||
case .settings:
|
|
||||||
SettingsView(onLogout: onLogout)
|
|
||||||
.transition(.opacity)
|
|
||||||
case .search:
|
|
||||||
SearchView()
|
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.3), value: selectedTab)
|
|
||||||
|
|
||||||
if !isChatSearchActive {
|
if !isChatSearchActive && !isAnyChatDetailPresented {
|
||||||
RosettaTabBar(
|
RosettaTabBar(
|
||||||
selectedTab: selectedTab,
|
selectedTab: selectedTab,
|
||||||
onTabSelected: { tab in
|
onTabSelected: { tab in
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
tabSwipeState = nil
|
||||||
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSwipeStateChanged: { state in
|
||||||
|
tabSwipeState = state
|
||||||
|
},
|
||||||
badges: tabBadges
|
badges: tabBadges
|
||||||
)
|
)
|
||||||
.ignoresSafeArea(.keyboard)
|
.ignoresSafeArea(.keyboard)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.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] {
|
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
|
let unread = DialogRepository.shared.sortedDialogs
|
||||||
.filter { !$0.isMuted }
|
.filter { !$0.isMuted }
|
||||||
.reduce(0) { $0 + $1.unreadCount }
|
.reduce(0) { $0 + $1.unreadCount }
|
||||||
if unread > 0 {
|
if unread <= 0 {
|
||||||
result.append(TabBadge(tab: .chats, text: unread > 999 ? "\(unread / 1000)K" : "\(unread)"))
|
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")
|
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preload Lottie animations early
|
// Avoid heavy startup work on MainActor; Lottie assets load lazily on first use.
|
||||||
Task.detached(priority: .userInitiated) {
|
|
||||||
LottieAnimationCache.shared.preload(OnboardingPages.all.map(\.animationName))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||||
|
|||||||
Reference in New Issue
Block a user