feat: Introduce system accounts and verification badges

- Added SystemAccounts enum to manage system account keys and titles.
- Refactored Dialog model to replace isVerified with verified level.
- Implemented effective verification logic for UI display in Dialog.
- Updated DialogRepository to handle user verification levels.
- Enhanced ProtocolManager and SessionManager to log user info with verification.
- Modified AuthCoordinator to support back navigation to unlock screen.
- Improved UnlockView and WelcomeView with new account creation flow.
- Added VerifiedBadge component to visually represent account verification levels.
- Updated ChatListView and SearchView to display verification badges for users.
- Cleaned up debug print statements across various components.
This commit is contained in:
2026-02-26 01:57:15 +05:00
parent 99a35302fa
commit 5f163af1d8
33 changed files with 1903 additions and 1466 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
# Exclude from repo
.gitignore
.claude/
rosetta-android/
sprints/
CLAUDE.md

View File

@@ -1,7 +1,6 @@
import Foundation
import CryptoKit
import CommonCrypto
import Compression
import P256K
// MARK: - Error Types
@@ -34,6 +33,7 @@ enum CryptoError: LocalizedError {
// MARK: - CryptoManager
/// All methods are `nonisolated` safe to call from any actor/thread.
/// Low-level primitives are in `CryptoPrimitives`.
final class CryptoManager: @unchecked Sendable {
static let shared = CryptoManager()
@@ -41,19 +41,13 @@ final class CryptoManager: @unchecked Sendable {
// MARK: - BIP39: Mnemonic Generation
/// Generates a cryptographically secure 12-word BIP39 mnemonic.
nonisolated func generateMnemonic() throws -> [String] {
var entropy = Data(count: 16) // 128 bits
let status = entropy.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!)
}
guard status == errSecSuccess else { throw CryptoError.invalidEntropy }
let entropy = try CryptoPrimitives.randomBytes(count: 16)
return try mnemonicFromEntropy(entropy)
}
// MARK: - BIP39: Mnemonic Validation
/// Returns `true` if all 12 words are in the BIP39 word list and the checksum is valid.
nonisolated func validateMnemonic(_ words: [String]) -> Bool {
guard words.count == 12 else { return false }
let normalized = words.map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
@@ -63,44 +57,37 @@ final class CryptoManager: @unchecked Sendable {
// MARK: - BIP39: Mnemonic Seed (PBKDF2-SHA512)
/// Derives the 64-byte seed from a mnemonic using PBKDF2-SHA512 with 2048 iterations.
/// Compatible with BIP39 specification (no passphrase).
nonisolated func mnemonicToSeed(_ words: [String]) -> Data {
let phrase = words.joined(separator: " ")
return pbkdf2(password: phrase, salt: "mnemonic", iterations: 2048, keyLength: 64, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512))
return CryptoPrimitives.pbkdf2(
password: phrase, salt: "mnemonic", iterations: 2048,
keyLength: 64, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512)
)
}
// MARK: - Key Pair Derivation (secp256k1)
/// Derives a secp256k1 key pair from a mnemonic phrase.
/// Returns (privateKey: 32 bytes, publicKey: 33 bytes compressed).
nonisolated func deriveKeyPair(from mnemonic: [String]) throws -> (privateKey: Data, publicKey: Data) {
let seed = mnemonicToSeed(mnemonic)
let seedHex = seed.hexString
// SHA256 of the UTF-8 bytes of the hex-encoded seed string
let privateKey = Data(SHA256.hash(data: Data(seedHex.utf8)))
let publicKey = try deriveCompressedPublicKey(from: privateKey)
return (privateKey, publicKey)
}
// MARK: - Account Encryption (PBKDF2-SHA1 + zlib + AES-256-CBC)
// MARK: - Account Encryption (PBKDF2 + zlib + AES-256-CBC)
/// Encrypts `data` with a password using PBKDF2-HMAC-SHA1 + zlib deflate + AES-256-CBC.
/// Compatible with Android (crypto-js uses SHA1 by default) and JS (pako.deflate).
/// Output format: `Base64(IV):Base64(ciphertext)`.
nonisolated func encryptWithPassword(_ data: Data, password: String) throws -> String {
let compressed = try rawDeflate(data)
let key = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1))
let iv = try randomBytes(count: 16)
let ciphertext = try aesCBCEncrypt(compressed, key: key, iv: iv)
let compressed = try CryptoPrimitives.rawDeflate(data)
let key = CryptoPrimitives.pbkdf2(
password: password, salt: "rosetta", iterations: 1000,
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
)
let iv = try CryptoPrimitives.randomBytes(count: 16)
let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv)
return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())"
}
/// Decrypts data encrypted with `encryptWithPassword(_:password:)`.
/// Tries PBKDF2-HMAC-SHA1 + zlib (Android-compatible) first, then falls back to
/// legacy PBKDF2-HMAC-SHA256 without compression (old iOS format) for migration.
nonisolated func decryptWithPassword(_ encrypted: String, password: String) throws -> Data {
let parts = encrypted.components(separatedBy: ":")
guard parts.count == 2,
@@ -111,16 +98,22 @@ final class CryptoManager: @unchecked Sendable {
// Try current format: PBKDF2-SHA1 + AES-CBC + zlib inflate
if let result = try? {
let key = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1))
let decrypted = try aesCBCDecrypt(ciphertext, key: key, iv: iv)
return try rawInflate(decrypted)
let key = CryptoPrimitives.pbkdf2(
password: password, salt: "rosetta", iterations: 1000,
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
)
let decrypted = try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: key, iv: iv)
return try CryptoPrimitives.rawInflate(decrypted)
}() {
return result
}
// Fallback: legacy iOS format (PBKDF2-SHA256, no compression)
let legacyKey = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256))
return try aesCBCDecrypt(ciphertext, key: legacyKey, iv: iv)
let legacyKey = CryptoPrimitives.pbkdf2(
password: password, salt: "rosetta", iterations: 1000,
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
)
return try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: legacyKey, iv: iv)
}
// MARK: - Utilities
@@ -129,8 +122,6 @@ final class CryptoManager: @unchecked Sendable {
Data(SHA256.hash(data: data))
}
/// Generates the private key hash used for server handshake authentication.
/// Formula: SHA256(privateKeyHex + "rosetta") lowercase hex string.
nonisolated func generatePrivateKeyHash(privateKeyHex: String) -> String {
let combined = Data((privateKeyHex + "rosetta").utf8)
return sha256(combined).hexString
@@ -147,7 +138,6 @@ private extension CryptoManager {
let hashBytes = Data(SHA256.hash(data: entropy))
let checksumByte = hashBytes[0]
// Build bit array: 128 entropy bits + 4 checksum bits = 132 bits
var bits = [Bool]()
bits.reserveCapacity(132)
for byte in entropy {
@@ -155,12 +145,10 @@ private extension CryptoManager {
bits.append((byte >> shift) & 1 == 1)
}
}
// Top 4 bits of SHA256 hash are the checksum
for shift in stride(from: 7, through: 4, by: -1) {
bits.append((checksumByte >> shift) & 1 == 1)
}
// Split into 12 × 11-bit groups, map to words
return try (0..<12).map { chunk in
let index = (0..<11).reduce(0) { acc, bit in
acc * 2 + (bits[chunk * 11 + bit] ? 1 : 0)
@@ -173,7 +161,6 @@ private extension CryptoManager {
func entropyFromMnemonic(_ words: [String]) throws -> Data {
guard words.count == 12 else { throw CryptoError.invalidMnemonic }
// Convert 12 × 11-bit word indices into a 132-bit array
var bits = [Bool]()
bits.reserveCapacity(132)
for word in words {
@@ -183,7 +170,6 @@ private extension CryptoManager {
}
}
// First 128 bits = entropy, last 4 bits = checksum
var entropy = Data(count: 16)
for byteIdx in 0..<16 {
let value: UInt8 = (0..<8).reduce(0) { acc, bit in
@@ -192,7 +178,6 @@ private extension CryptoManager {
entropy[byteIdx] = value
}
// Verify checksum
let hashBytes = Data(SHA256.hash(data: entropy))
let expectedTopNibble = hashBytes[0] >> 4
let actualTopNibble: UInt8 = (0..<4).reduce(0) { acc, bit in
@@ -208,172 +193,9 @@ private extension CryptoManager {
extension CryptoManager {
/// Computes the 33-byte compressed secp256k1 public key from a 32-byte private key.
nonisolated func deriveCompressedPublicKey(from privateKey: Data) throws -> Data {
guard privateKey.count == 32 else { throw CryptoError.invalidPrivateKey }
// P256K v0.21+: init is `dataRepresentation:format:`, public key via `dataRepresentation`
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: privateKey, format: .compressed)
// .compressed format dataRepresentation returns 33-byte compressed public key
return signingKey.publicKey.dataRepresentation
}
}
// MARK: - Crypto Primitives
private extension CryptoManager {
func pbkdf2(
password: String,
salt: String,
iterations: Int,
keyLength: Int,
prf: CCPseudoRandomAlgorithm
) -> Data {
var derivedKey = Data(repeating: 0, count: keyLength)
derivedKey.withUnsafeMutableBytes { keyPtr in
password.withCString { passPtr in
salt.withCString { saltPtr in
_ = CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passPtr, strlen(passPtr),
saltPtr, strlen(saltPtr),
prf,
UInt32(iterations),
keyPtr.bindMemory(to: UInt8.self).baseAddress!,
keyLength
)
}
}
}
return derivedKey
}
func aesCBCEncrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var ciphertext = Data(count: outputSize)
var numBytes = 0
let status = ciphertext.withUnsafeMutableBytes { ciphertextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
CCCrypt(
CCOperation(kCCEncrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
keyPtr.baseAddress!, key.count,
ivPtr.baseAddress!,
dataPtr.baseAddress!, data.count,
ciphertextPtr.baseAddress!, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.encryptionFailed }
return ciphertext.prefix(numBytes)
}
func aesCBCDecrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var plaintext = Data(count: outputSize)
var numBytes = 0
let status = plaintext.withUnsafeMutableBytes { plaintextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
CCCrypt(
CCOperation(kCCDecrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
keyPtr.baseAddress!, key.count,
ivPtr.baseAddress!,
dataPtr.baseAddress!, data.count,
plaintextPtr.baseAddress!, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.decryptionFailed }
return plaintext.prefix(numBytes)
}
func randomBytes(count: Int) throws -> Data {
var data = Data(count: count)
let status = data.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!)
}
guard status == errSecSuccess else { throw CryptoError.invalidEntropy }
return data
}
// MARK: - zlib Raw Deflate / Inflate
/// Raw deflate compression (no zlib header, compatible with pako.deflate / Java Deflater(nowrap=true)).
func rawDeflate(_ data: Data) throws -> Data {
// Compression framework uses COMPRESSION_ZLIB which is raw deflate
let sourceSize = data.count
// Worst case: input size + 512 bytes overhead
let destinationSize = sourceSize + 512
var destination = Data(count: destinationSize)
let compressedSize = destination.withUnsafeMutableBytes { destPtr in
data.withUnsafeBytes { srcPtr in
compression_encode_buffer(
destPtr.bindMemory(to: UInt8.self).baseAddress!,
destinationSize,
srcPtr.bindMemory(to: UInt8.self).baseAddress!,
sourceSize,
nil,
COMPRESSION_ZLIB
)
}
}
guard compressedSize > 0 else { throw CryptoError.compressionFailed }
return destination.prefix(compressedSize)
}
/// Raw inflate decompression (no zlib header, compatible with pako.inflate / Java Inflater(nowrap=true)).
func rawInflate(_ data: Data) throws -> Data {
let sourceSize = data.count
// Decompressed data can be much larger; start with 4x, retry if needed
var destinationSize = sourceSize * 4
if destinationSize < 256 { destinationSize = 256 }
for multiplier in [4, 8, 16, 32] {
destinationSize = sourceSize * multiplier
if destinationSize < 256 { destinationSize = 256 }
var destination = Data(count: destinationSize)
let decompressedSize = destination.withUnsafeMutableBytes { destPtr in
data.withUnsafeBytes { srcPtr in
compression_decode_buffer(
destPtr.bindMemory(to: UInt8.self).baseAddress!,
destinationSize,
srcPtr.bindMemory(to: UInt8.self).baseAddress!,
sourceSize,
nil,
COMPRESSION_ZLIB
)
}
}
if decompressedSize > 0 && decompressedSize < destinationSize {
return destination.prefix(decompressedSize)
}
}
throw CryptoError.compressionFailed
}
}
// MARK: - Data Extension
extension Data {
nonisolated var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
}

View File

@@ -0,0 +1,185 @@
import Foundation
import CommonCrypto
import Compression
// MARK: - CryptoPrimitives
/// Shared low-level cryptographic primitives used by both `CryptoManager` and `MessageCrypto`.
/// Centralizes AES-CBC, PBKDF2, random bytes, and zlib to avoid duplication.
enum CryptoPrimitives {
// MARK: - AES-256-CBC
static func aesCBCEncrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var ciphertext = Data(count: outputSize)
var numBytes = 0
let status = ciphertext.withUnsafeMutableBytes { ciphertextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
guard let cBase = ciphertextPtr.baseAddress,
let dBase = dataPtr.baseAddress,
let kBase = keyPtr.baseAddress,
let iBase = ivPtr.baseAddress else { return CCCryptorStatus(kCCParamError) }
return CCCrypt(
CCOperation(kCCEncrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
kBase, key.count,
iBase,
dBase, data.count,
cBase, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.encryptionFailed }
return ciphertext.prefix(numBytes)
}
static func aesCBCDecrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var plaintext = Data(count: outputSize)
var numBytes = 0
let status = plaintext.withUnsafeMutableBytes { plaintextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
guard let pBase = plaintextPtr.baseAddress,
let dBase = dataPtr.baseAddress,
let kBase = keyPtr.baseAddress,
let iBase = ivPtr.baseAddress else { return CCCryptorStatus(kCCParamError) }
return CCCrypt(
CCOperation(kCCDecrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
kBase, key.count,
iBase,
dBase, data.count,
pBase, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.decryptionFailed }
return plaintext.prefix(numBytes)
}
// MARK: - PBKDF2
static func pbkdf2(
password: String,
salt: String,
iterations: Int,
keyLength: Int,
prf: CCPseudoRandomAlgorithm
) -> Data {
var derivedKey = Data(repeating: 0, count: keyLength)
derivedKey.withUnsafeMutableBytes { keyPtr in
guard let keyBase = keyPtr.bindMemory(to: UInt8.self).baseAddress else { return }
password.withCString { passPtr in
salt.withCString { saltPtr in
_ = CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passPtr, strlen(passPtr),
saltPtr, strlen(saltPtr),
prf,
UInt32(iterations),
keyBase,
keyLength
)
}
}
}
return derivedKey
}
// MARK: - Random Bytes
static func randomBytes(count: Int) throws -> Data {
var data = Data(count: count)
let status = data.withUnsafeMutableBytes { ptr -> OSStatus in
guard let base = ptr.baseAddress else { return errSecAllocate }
return SecRandomCopyBytes(kSecRandomDefault, count, base)
}
guard status == errSecSuccess else { throw CryptoError.invalidEntropy }
return data
}
}
// MARK: - zlib Raw Deflate / Inflate
extension CryptoPrimitives {
/// Raw deflate compression (no zlib header, compatible with pako.deflate / Java Deflater(nowrap=true)).
static func rawDeflate(_ data: Data) throws -> Data {
let sourceSize = data.count
let destinationSize = sourceSize + 512
var destination = Data(count: destinationSize)
let compressedSize = destination.withUnsafeMutableBytes { destPtr in
data.withUnsafeBytes { srcPtr in
guard let dBase = destPtr.bindMemory(to: UInt8.self).baseAddress,
let sBase = srcPtr.bindMemory(to: UInt8.self).baseAddress else { return 0 }
return compression_encode_buffer(dBase, destinationSize, sBase, sourceSize, nil, COMPRESSION_ZLIB)
}
}
guard compressedSize > 0 else { throw CryptoError.compressionFailed }
return destination.prefix(compressedSize)
}
/// Raw inflate decompression (no zlib header, compatible with pako.inflate / Java Inflater(nowrap=true)).
static func rawInflate(_ data: Data) throws -> Data {
let sourceSize = data.count
for multiplier in [4, 8, 16, 32] {
var destinationSize = max(sourceSize * multiplier, 256)
var destination = Data(count: destinationSize)
let decompressedSize = destination.withUnsafeMutableBytes { destPtr in
data.withUnsafeBytes { srcPtr in
guard let dBase = destPtr.bindMemory(to: UInt8.self).baseAddress,
let sBase = srcPtr.bindMemory(to: UInt8.self).baseAddress else { return 0 }
return compression_decode_buffer(dBase, destinationSize, sBase, sourceSize, nil, COMPRESSION_ZLIB)
}
}
if decompressedSize > 0 && decompressedSize < destinationSize {
return destination.prefix(decompressedSize)
}
}
throw CryptoError.compressionFailed
}
}
// MARK: - Data Extensions
extension Data {
/// Lowercase hex string representation.
nonisolated var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
/// Initialize from a hex string (case-insensitive).
init(hexString: String) {
let hex = hexString.lowercased()
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
if nextIndex == hex.endIndex && hex.distance(from: index, to: nextIndex) < 2 {
let byte = UInt8(hex[index...index], radix: 16) ?? 0
data.append(byte)
} else {
let byte = UInt8(hex[index..<nextIndex], radix: 16) ?? 0
data.append(byte)
}
index = nextIndex
}
self = data
}
}

View File

@@ -6,6 +6,11 @@ import P256K
/// Handles message-level encryption/decryption using XChaCha20-Poly1305 + ECDH.
/// Matches the Android `MessageCrypto` implementation for cross-platform compatibility.
///
/// Crypto engines are split into dedicated files:
/// - `XChaCha20Engine` ChaCha20 stream cipher + XChaCha20-Poly1305 AEAD
/// - `Poly1305Engine` Poly1305 MAC
/// - `CryptoPrimitives` Shared AES-CBC, PBKDF2, zlib, hex helpers
enum MessageCrypto {
// MARK: - Public API
@@ -21,28 +26,23 @@ enum MessageCrypto {
encryptedKey: String,
myPrivateKeyHex: String
) throws -> String {
// Step 1: ECDH decrypt the XChaCha20 key+nonce
let keyAndNonce = try decryptKeyFromSender(encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex)
guard keyAndNonce.count >= 56 else {
throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(keyAndNonce.count)")
}
let key = keyAndNonce[0..<32] // 32-byte XChaCha20 key
let nonce = keyAndNonce[32..<56] // 24-byte XChaCha20 nonce
let key = keyAndNonce[0..<32]
let nonce = keyAndNonce[32..<56]
// Step 2: XChaCha20-Poly1305 decrypt
let ciphertextData = Data(hexString: ciphertext)
let plaintext = try xchacha20Poly1305Decrypt(
ciphertextWithTag: ciphertextData,
key: Data(key),
nonce: Data(nonce)
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
}
@@ -61,32 +61,25 @@ enum MessageCrypto {
throw CryptoError.invalidData("Cannot encode plaintext as UTF-8")
}
// Generate random 32-byte key + 24-byte nonce
let key = try randomBytes(count: 32)
let nonce = try randomBytes(count: 24)
let key = try CryptoPrimitives.randomBytes(count: 32)
let nonce = try CryptoPrimitives.randomBytes(count: 24)
let keyAndNonce = key + nonce
// XChaCha20-Poly1305 encrypt
let ciphertextWithTag = try xchacha20Poly1305Encrypt(
let ciphertextWithTag = try XChaCha20Engine.encrypt(
plaintext: plaintextData, key: key, nonce: nonce
)
// Encrypt key+nonce for recipient via ECDH
let chachaKey = try encryptKeyForRecipient(
keyAndNonce: keyAndNonce,
recipientPublicKeyHex: recipientPublicKeyHex
keyAndNonce: keyAndNonce, recipientPublicKeyHex: recipientPublicKeyHex
)
// Encrypt key+nonce for sender (self) via ECDH with sender's own public key
let senderPrivKey = try P256K.Signing.PrivateKey(
dataRepresentation: Data(hexString: senderPrivateKeyHex),
format: .compressed
dataRepresentation: Data(hexString: senderPrivateKeyHex), format: .compressed
)
let senderPublicKeyHex = senderPrivKey.publicKey.dataRepresentation.hexString
let aesChachaKey = try encryptKeyForRecipient(
keyAndNonce: keyAndNonce,
recipientPublicKeyHex: senderPublicKeyHex
keyAndNonce: keyAndNonce, recipientPublicKeyHex: senderPublicKeyHex
)
return (
@@ -126,68 +119,43 @@ private extension MessageCrypto {
let iv = Data(hexString: ivHex)
let encryptedKeyData = Data(hexString: encryptedKeyHex)
// ECDH: compute shared secret = myPublicKey × ephemeralPrivateKey
// Using P256K: create ephemeral private key, derive my public key, compute shared secret
let ephemeralPrivKeyData = Data(hexString: ephemeralPrivateKeyHex)
let myPrivKeyData = Data(hexString: myPrivateKeyHex)
let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey(
dataRepresentation: ephemeralPrivKeyData, format: .compressed
dataRepresentation: Data(hexString: ephemeralPrivateKeyHex), format: .compressed
)
let myPrivKey = try P256K.KeyAgreement.PrivateKey(
dataRepresentation: myPrivKeyData, format: .compressed
dataRepresentation: Data(hexString: myPrivateKeyHex), format: .compressed
)
let myPublicKey = myPrivKey.publicKey
// ECDH: ephemeralPrivateKey × myPublicKey shared point
// P256K returns compressed format (1 + 32 bytes), we need just x-coordinate (bytes 1...32)
let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(with: myPublicKey, format: .compressed)
let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(
with: myPrivKey.publicKey, format: .compressed
)
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Extract x-coordinate (skip the 1-byte prefix)
let sharedKey: Data
if sharedSecretData.count == 33 {
sharedKey = sharedSecretData[1..<33]
} else if sharedSecretData.count == 32 {
sharedKey = sharedSecretData
} else {
throw CryptoError.invalidData("Unexpected shared secret length: \(sharedSecretData.count)")
}
let sharedKey = extractXCoordinate(from: sharedSecretData)
// AES-256-CBC decrypt
let decryptedBytes = try aesCBCDecrypt(encryptedKeyData, key: sharedKey, iv: iv)
let decryptedBytes = try CryptoPrimitives.aesCBCDecrypt(encryptedKeyData, key: sharedKey, iv: iv)
// UTF-8 Latin1 conversion (reverse of JS crypto-js compatibility)
// The Android code does: String(bytes, UTF-8) toByteArray(ISO_8859_1)
guard let utf8String = String(data: decryptedBytes, encoding: .utf8) else {
throw CryptoError.invalidData("Decrypted key is not valid UTF-8")
}
let originalBytes = Data(utf8String.unicodeScalars.map { UInt8(truncatingIfNeeded: $0.value) })
return originalBytes
return Data(utf8String.unicodeScalars.map { UInt8(truncatingIfNeeded: $0.value) })
}
/// Encrypts the XChaCha20 key+nonce for a recipient using ECDH.
static func encryptKeyForRecipient(keyAndNonce: Data, recipientPublicKeyHex: String) throws -> String {
// Generate ephemeral key pair
let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey()
// Parse recipient public key
let recipientPubKey = try P256K.KeyAgreement.PublicKey(
dataRepresentation: Data(hexString: recipientPublicKeyHex), format: .compressed
)
// ECDH: ephemeralPrivKey × recipientPubKey shared secret
let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(with: recipientPubKey, format: .compressed)
let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(
with: recipientPubKey, format: .compressed
)
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Extract x-coordinate
let sharedKey: Data
if sharedSecretData.count == 33 {
sharedKey = sharedSecretData[1..<33]
} else {
sharedKey = sharedSecretData
}
let sharedKey = extractXCoordinate(from: sharedSecretData)
// Convert keyAndNonce bytes to UTF-8 string representation (JS Buffer compatibility)
let utf8Representation = String(keyAndNonce.map { Character(UnicodeScalar($0)) })
@@ -195,531 +163,22 @@ private extension MessageCrypto {
throw CryptoError.encryptionFailed
}
// AES-256-CBC encrypt
let iv = try randomBytes(count: 16)
let ciphertext = try aesCBCEncrypt(dataToEncrypt, key: sharedKey, iv: iv)
// Get ephemeral private key hex
let iv = try CryptoPrimitives.randomBytes(count: 16)
let ciphertext = try CryptoPrimitives.aesCBCEncrypt(dataToEncrypt, key: sharedKey, iv: iv)
let ephemeralPrivKeyHex = ephemeralPrivKey.rawRepresentation.hexString
// Format: Base64(ivHex:ciphertextHex:ephemeralPrivateKeyHex)
let combined = "\(iv.hexString):\(ciphertext.hexString):\(ephemeralPrivKeyHex)"
guard let base64 = combined.data(using: .utf8)?.base64EncodedString() else {
throw CryptoError.encryptionFailed
}
return base64
}
}
// MARK: - XChaCha20-Poly1305
private extension MessageCrypto {
static let poly1305TagSize = 16
/// XChaCha20-Poly1305 decryption matching Android implementation.
static func xchacha20Poly1305Decrypt(ciphertextWithTag: Data, key: Data, nonce: Data) throws -> Data {
guard ciphertextWithTag.count > poly1305TagSize else {
throw CryptoError.invalidData("Ciphertext too short for Poly1305 tag")
/// Extracts the 32-byte x-coordinate from a compressed ECDH shared secret.
static func extractXCoordinate(from sharedSecretData: Data) -> Data {
if sharedSecretData.count == 33 {
return sharedSecretData[1..<33]
}
guard key.count == 32, nonce.count == 24 else {
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
}
let ciphertext = ciphertextWithTag[0..<(ciphertextWithTag.count - poly1305TagSize)]
let tag = ciphertextWithTag[(ciphertextWithTag.count - poly1305TagSize)...]
// Step 1: HChaCha20 derive subkey from key + first 16 bytes of nonce
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
// Step 2: Build ChaCha20 nonce: [0,0,0,0] + nonce[16..<24]
var chacha20Nonce = Data(repeating: 0, count: 12)
chacha20Nonce[4..<12] = nonce[16..<24]
// Step 3: Generate Poly1305 key from first 64 bytes of keystream (counter=0)
let poly1305Key = chacha20Block(key: subkey, nonce: chacha20Nonce, counter: 0)[0..<32]
// Step 4: Verify Poly1305 tag
let computedTag = poly1305MAC(
data: Data(ciphertext),
key: Data(poly1305Key)
)
guard constantTimeEqual(Data(tag), computedTag) else {
throw CryptoError.decryptionFailed
}
// Step 5: Decrypt with ChaCha20 (counter starts at 1)
let plaintext = chacha20Encrypt(
data: Data(ciphertext),
key: subkey,
nonce: chacha20Nonce,
initialCounter: 1
)
return plaintext
}
/// XChaCha20-Poly1305 encryption.
static func xchacha20Poly1305Encrypt(plaintext: Data, key: Data, nonce: Data) throws -> Data {
guard key.count == 32, nonce.count == 24 else {
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
}
// Step 1: HChaCha20 derive subkey
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
// Step 2: Build ChaCha20 nonce
var chacha20Nonce = Data(repeating: 0, count: 12)
chacha20Nonce[4..<12] = nonce[16..<24]
// Step 3: Generate Poly1305 key
let poly1305Key = chacha20Block(key: subkey, nonce: chacha20Nonce, counter: 0)[0..<32]
// Step 4: Encrypt with ChaCha20 (counter starts at 1)
let ciphertext = chacha20Encrypt(
data: plaintext,
key: subkey,
nonce: chacha20Nonce,
initialCounter: 1
)
// Step 5: Compute Poly1305 tag
let tag = poly1305MAC(data: ciphertext, key: Data(poly1305Key))
return ciphertext + tag
}
}
// MARK: - ChaCha20 Core
private extension MessageCrypto {
/// ChaCha20 quarter round.
static func quarterRound(_ state: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 16) | (state[d] >> 16)
state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 12) | (state[b] >> 20)
state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 8) | (state[d] >> 24)
state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 7) | (state[b] >> 25)
}
/// Generates a 64-byte ChaCha20 block.
static func chacha20Block(key: Data, nonce: Data, counter: UInt32) -> Data {
var state = [UInt32](repeating: 0, count: 16)
// Constants: "expand 32-byte k"
state[0] = 0x61707865
state[1] = 0x3320646e
state[2] = 0x79622d32
state[3] = 0x6b206574
// Key
for i in 0..<8 {
state[4 + i] = key.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
// Counter
state[12] = counter
// Nonce
for i in 0..<3 {
state[13 + i] = nonce.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
var working = state
// 20 rounds (10 double rounds)
for _ in 0..<10 {
quarterRound(&working, 0, 4, 8, 12)
quarterRound(&working, 1, 5, 9, 13)
quarterRound(&working, 2, 6, 10, 14)
quarterRound(&working, 3, 7, 11, 15)
quarterRound(&working, 0, 5, 10, 15)
quarterRound(&working, 1, 6, 11, 12)
quarterRound(&working, 2, 7, 8, 13)
quarterRound(&working, 3, 4, 9, 14)
}
// Add initial state
for i in 0..<16 {
working[i] = working[i] &+ state[i]
}
// Serialize to bytes (little-endian)
var result = Data(count: 64)
for i in 0..<16 {
let val = working[i].littleEndian
result[i * 4] = UInt8(truncatingIfNeeded: val)
result[i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
return result
}
/// ChaCha20 stream cipher encryption/decryption.
static func chacha20Encrypt(data: Data, key: Data, nonce: Data, initialCounter: UInt32) -> Data {
var result = Data(count: data.count)
var counter = initialCounter
for offset in stride(from: 0, to: data.count, by: 64) {
let block = chacha20Block(key: key, nonce: nonce, counter: counter)
let blockSize = min(64, data.count - offset)
for i in 0..<blockSize {
result[offset + i] = data[data.startIndex + offset + i] ^ block[i]
}
counter &+= 1
}
return result
}
/// HChaCha20: derives a 32-byte subkey from a 32-byte key and 16-byte nonce.
static func hchacha20(key: Data, nonce: Data) -> Data {
var state = [UInt32](repeating: 0, count: 16)
// Constants
state[0] = 0x61707865
state[1] = 0x3320646e
state[2] = 0x79622d32
state[3] = 0x6b206574
// Key
for i in 0..<8 {
state[4 + i] = key.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
// Nonce (16 bytes 4 uint32s)
for i in 0..<4 {
state[12 + i] = nonce.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
// 20 rounds
for _ in 0..<10 {
quarterRound(&state, 0, 4, 8, 12)
quarterRound(&state, 1, 5, 9, 13)
quarterRound(&state, 2, 6, 10, 14)
quarterRound(&state, 3, 7, 11, 15)
quarterRound(&state, 0, 5, 10, 15)
quarterRound(&state, 1, 6, 11, 12)
quarterRound(&state, 2, 7, 8, 13)
quarterRound(&state, 3, 4, 9, 14)
}
// Output: first 4 words + last 4 words
var result = Data(count: 32)
for i in 0..<4 {
let val = state[i].littleEndian
result[i * 4] = UInt8(truncatingIfNeeded: val)
result[i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
for i in 0..<4 {
let val = state[12 + i].littleEndian
result[16 + i * 4] = UInt8(truncatingIfNeeded: val)
result[16 + i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[16 + i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[16 + i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
return result
}
}
// MARK: - Poly1305
private extension MessageCrypto {
/// Poly1305 MAC computation matching the AEAD construction.
static func poly1305MAC(data: Data, key: Data) -> Data {
// Clamp r (first 16 bytes of key)
var r = [UInt8](key[0..<16])
r[3] &= 15; r[7] &= 15; r[11] &= 15; r[15] &= 15
r[4] &= 252; r[8] &= 252; r[12] &= 252
// s = last 16 bytes of key
let s = [UInt8](key[16..<32])
// Convert r and s to big integers using UInt128-like arithmetic
var rVal: (UInt64, UInt64) = (0, 0) // (low, high)
for i in stride(from: 15, through: 8, by: -1) {
rVal.1 = rVal.1 << 8 | UInt64(r[i])
}
for i in stride(from: 7, through: 0, by: -1) {
rVal.0 = rVal.0 << 8 | UInt64(r[i])
}
// Use arrays for big number arithmetic (limbs approach)
// For simplicity and correctness, use a big-number representation
var accumulator = [UInt64](repeating: 0, count: 5) // 130-bit number in 26-bit limbs
let rLimbs = toLimbs26(r)
let p: UInt64 = (1 << 26) // 2^26
// Build padded data: data + padding to 16-byte boundary + lengths
var macInput = Data(data)
let padding = (16 - (data.count % 16)) % 16
if padding > 0 {
macInput.append(Data(repeating: 0, count: padding))
}
// AAD length (0 for our case no associated data)
macInput.append(Data(repeating: 0, count: 8))
// Ciphertext length (little-endian 64-bit)
var ctLen = UInt64(data.count).littleEndian
macInput.append(Data(bytes: &ctLen, count: 8))
// Process in 16-byte blocks
for offset in stride(from: 0, to: macInput.count, by: 16) {
let blockEnd = min(offset + 16, macInput.count)
var block = [UInt8](macInput[offset..<blockEnd])
while block.count < 16 { block.append(0) }
// Add block to accumulator (with hibit for message blocks, not for length blocks)
let hibit: UInt64 = offset < (macInput.count - 16) ? (1 << 24) : (offset < data.count + padding ? (1 << 24) : 0)
// For AEAD: hibit is 1 for actual data blocks, 0 for length fields
let isDataBlock = offset < data.count + padding
let bit: UInt64 = isDataBlock ? (1 << 24) : 0
let n = toLimbs26(block)
accumulator[0] = accumulator[0] &+ n[0]
accumulator[1] = accumulator[1] &+ n[1]
accumulator[2] = accumulator[2] &+ n[2]
accumulator[3] = accumulator[3] &+ n[3]
accumulator[4] = accumulator[4] &+ n[4] &+ bit
// Multiply accumulator by r
accumulator = poly1305Multiply(accumulator, rLimbs)
}
// Freeze: reduce mod 2^130 - 5, then add s
let result = poly1305Freeze(accumulator, s: s)
return Data(result)
}
/// Convert 16 bytes to 5 limbs of 26 bits each.
static func toLimbs26(_ bytes: [UInt8]) -> [UInt64] {
let b = bytes.count >= 16 ? bytes : bytes + [UInt8](repeating: 0, count: 16 - bytes.count)
var val: UInt64 = 0
var limbs = [UInt64](repeating: 0, count: 5)
// Read as little-endian 128-bit number
for i in stride(from: 15, through: 0, by: -1) {
val = val << 8 | UInt64(b[i])
if i == 0 {
limbs[0] = val & 0x3FFFFFF
limbs[1] = (val >> 26) & 0x3FFFFFF
limbs[2] = (val >> 52) & 0x3FFFFFF
}
}
// Re-read properly
var full = [UInt8](repeating: 0, count: 17)
for i in 0..<min(16, b.count) { full[i] = b[i] }
let lo = UInt64(full[0]) | UInt64(full[1]) << 8 | UInt64(full[2]) << 16 | UInt64(full[3]) << 24 |
UInt64(full[4]) << 32 | UInt64(full[5]) << 40 | UInt64(full[6]) << 48 | UInt64(full[7]) << 56
let hi = UInt64(full[8]) | UInt64(full[9]) << 8 | UInt64(full[10]) << 16 | UInt64(full[11]) << 24 |
UInt64(full[12]) << 32 | UInt64(full[13]) << 40 | UInt64(full[14]) << 48 | UInt64(full[15]) << 56
limbs[0] = lo & 0x3FFFFFF
limbs[1] = (lo >> 26) & 0x3FFFFFF
limbs[2] = ((lo >> 52) | (hi << 12)) & 0x3FFFFFF
limbs[3] = (hi >> 14) & 0x3FFFFFF
limbs[4] = (hi >> 40) & 0x3FFFFFF
return limbs
}
/// Multiply two numbers in 26-bit limb form, reduce mod 2^130 - 5.
static func poly1305Multiply(_ a: [UInt64], _ r: [UInt64]) -> [UInt64] {
// Full multiply into 10 limbs, then reduce
let r0 = r[0], r1 = r[1], r2 = r[2], r3 = r[3], r4 = r[4]
let s1 = r1 * 5, s2 = r2 * 5, s3 = r3 * 5, s4 = r4 * 5
let a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4]
var h0 = a0 * r0 + a1 * s4 + a2 * s3 + a3 * s2 + a4 * s1
var h1 = a0 * r1 + a1 * r0 + a2 * s4 + a3 * s3 + a4 * s2
var h2 = a0 * r2 + a1 * r1 + a2 * r0 + a3 * s4 + a4 * s3
var h3 = a0 * r3 + a1 * r2 + a2 * r1 + a3 * r0 + a4 * s4
var h4 = a0 * r4 + a1 * r3 + a2 * r2 + a3 * r1 + a4 * r0
// Carry propagation
var c: UInt64
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
c = h1 >> 26; h2 += c; h1 &= 0x3FFFFFF
c = h2 >> 26; h3 += c; h2 &= 0x3FFFFFF
c = h3 >> 26; h4 += c; h3 &= 0x3FFFFFF
c = h4 >> 26; h0 += c * 5; h4 &= 0x3FFFFFF
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
return [h0, h1, h2, h3, h4]
}
/// Final reduction and add s.
static func poly1305Freeze(_ h: [UInt64], s: [UInt8]) -> [UInt8] {
var h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], h4 = h[4]
// Full carry
var c: UInt64
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
c = h1 >> 26; h2 += c; h1 &= 0x3FFFFFF
c = h2 >> 26; h3 += c; h2 &= 0x3FFFFFF
c = h3 >> 26; h4 += c; h3 &= 0x3FFFFFF
c = h4 >> 26; h0 += c * 5; h4 &= 0x3FFFFFF
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
// Compute h + -(2^130 - 5) = h - p
var g0 = h0 &+ 5; c = g0 >> 26; g0 &= 0x3FFFFFF
var g1 = h1 &+ c; c = g1 >> 26; g1 &= 0x3FFFFFF
var g2 = h2 &+ c; c = g2 >> 26; g2 &= 0x3FFFFFF
var g3 = h3 &+ c; c = g3 >> 26; g3 &= 0x3FFFFFF
let g4 = h4 &+ c &- (1 << 26)
// If g4 didn't underflow (bit 63 not set), use g (h >= p)
let mask = (g4 >> 63) &- 1 // 0 if g4 underflowed, 0xFFF...F otherwise
let nmask = ~mask
h0 = (h0 & nmask) | (g0 & mask)
h1 = (h1 & nmask) | (g1 & mask)
h2 = (h2 & nmask) | (g2 & mask)
h3 = (h3 & nmask) | (g3 & mask)
h4 = (h4 & nmask) | (g4 & mask)
// Reassemble into 128-bit number
let f0 = h0 | (h1 << 26)
let f1 = (h1 >> 38) | (h2 << 12) | (h3 << 38)
let f2 = (h3 >> 26) | (h4 << 0) // unused high bits
// Convert to two 64-bit values
let lo = h0 | (h1 << 26) | (h2 << 52)
let hi = (h2 >> 12) | (h3 << 14) | (h4 << 40)
// Add s (little-endian)
var sLo: UInt64 = 0
var sHi: UInt64 = 0
for i in stride(from: 7, through: 0, by: -1) {
sLo = sLo << 8 | UInt64(s[i])
}
for i in stride(from: 15, through: 8, by: -1) {
sHi = sHi << 8 | UInt64(s[i])
}
var resultLo = lo &+ sLo
var carry: UInt64 = resultLo < lo ? 1 : 0
var resultHi = hi &+ sHi &+ carry
// Output 16 bytes little-endian
var output = [UInt8](repeating: 0, count: 16)
for i in 0..<8 {
output[i] = UInt8(truncatingIfNeeded: resultLo >> (i * 8))
}
for i in 0..<8 {
output[8 + i] = UInt8(truncatingIfNeeded: resultHi >> (i * 8))
}
return output
}
static func constantTimeEqual(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var result: UInt8 = 0
for i in 0..<a.count {
result |= a[a.startIndex + i] ^ b[b.startIndex + i]
}
return result == 0
}
}
// MARK: - AES-CBC Helpers
private extension MessageCrypto {
static func aesCBCEncrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var ciphertext = Data(count: outputSize)
var numBytes = 0
let status = ciphertext.withUnsafeMutableBytes { ciphertextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
CCCrypt(
CCOperation(kCCEncrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
keyPtr.baseAddress!, key.count,
ivPtr.baseAddress!,
dataPtr.baseAddress!, data.count,
ciphertextPtr.baseAddress!, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.encryptionFailed }
return ciphertext.prefix(numBytes)
}
static func aesCBCDecrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var plaintext = Data(count: outputSize)
var numBytes = 0
let status = plaintext.withUnsafeMutableBytes { plaintextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
CCCrypt(
CCOperation(kCCDecrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
keyPtr.baseAddress!, key.count,
ivPtr.baseAddress!,
dataPtr.baseAddress!, data.count,
plaintextPtr.baseAddress!, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.decryptionFailed }
return plaintext.prefix(numBytes)
}
static func randomBytes(count: Int) throws -> Data {
var data = Data(count: count)
let status = data.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!)
}
guard status == errSecSuccess else { throw CryptoError.invalidEntropy }
return data
}
}
// MARK: - Data Hex Extension
extension Data {
/// Initialize from a hex string.
init(hexString: String) {
let hex = hexString.lowercased()
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
if nextIndex == hex.endIndex && hex.distance(from: index, to: nextIndex) < 2 {
// Odd hex character at end
let byte = UInt8(hex[index...index], radix: 16) ?? 0
data.append(byte)
} else {
let byte = UInt8(hex[index..<nextIndex], radix: 16) ?? 0
data.append(byte)
}
index = nextIndex
}
self = data
return sharedSecretData.prefix(32)
}
}

View File

@@ -0,0 +1,153 @@
import Foundation
// MARK: - Poly1305Engine
/// Poly1305 message authentication code implementation.
/// Used by XChaCha20-Poly1305 AEAD construction.
enum Poly1305Engine {
/// Computes a Poly1305 MAC matching the AEAD construction.
static func mac(data: Data, key: Data) -> Data {
// Clamp r (first 16 bytes of key)
var r = [UInt8](key[0..<16])
r[3] &= 15; r[7] &= 15; r[11] &= 15; r[15] &= 15
r[4] &= 252; r[8] &= 252; r[12] &= 252
// s = last 16 bytes of key
let s = [UInt8](key[16..<32])
var accumulator = [UInt64](repeating: 0, count: 5)
let rLimbs = toLimbs26(r)
// Build padded data: data + padding to 16-byte boundary + lengths
var macInput = Data(data)
let padding = (16 - (data.count % 16)) % 16
if padding > 0 {
macInput.append(Data(repeating: 0, count: padding))
}
// AAD length (0 no associated data)
macInput.append(Data(repeating: 0, count: 8))
// Ciphertext length (little-endian 64-bit)
var ctLen = UInt64(data.count).littleEndian
macInput.append(Data(bytes: &ctLen, count: 8))
// Process in 16-byte blocks
for offset in stride(from: 0, to: macInput.count, by: 16) {
let blockEnd = min(offset + 16, macInput.count)
var block = [UInt8](macInput[offset..<blockEnd])
while block.count < 16 { block.append(0) }
// hibit is 1 for data blocks, 0 for length fields
let isDataBlock = offset < data.count + padding
let bit: UInt64 = isDataBlock ? (1 << 24) : 0
let n = toLimbs26(block)
accumulator[0] = accumulator[0] &+ n[0]
accumulator[1] = accumulator[1] &+ n[1]
accumulator[2] = accumulator[2] &+ n[2]
accumulator[3] = accumulator[3] &+ n[3]
accumulator[4] = accumulator[4] &+ n[4] &+ bit
accumulator = multiply(accumulator, rLimbs)
}
return Data(freeze(accumulator, s: s))
}
}
// MARK: - Limb Arithmetic
private extension Poly1305Engine {
/// Convert 16 bytes to 5 limbs of 26 bits each (little-endian).
static func toLimbs26(_ bytes: [UInt8]) -> [UInt64] {
let b = bytes.count >= 16 ? bytes : bytes + [UInt8](repeating: 0, count: 16 - bytes.count)
var full = [UInt8](repeating: 0, count: 17)
for i in 0..<min(16, b.count) { full[i] = b[i] }
let lo = UInt64(full[0]) | UInt64(full[1]) << 8 | UInt64(full[2]) << 16 | UInt64(full[3]) << 24 |
UInt64(full[4]) << 32 | UInt64(full[5]) << 40 | UInt64(full[6]) << 48 | UInt64(full[7]) << 56
let hi = UInt64(full[8]) | UInt64(full[9]) << 8 | UInt64(full[10]) << 16 | UInt64(full[11]) << 24 |
UInt64(full[12]) << 32 | UInt64(full[13]) << 40 | UInt64(full[14]) << 48 | UInt64(full[15]) << 56
return [
lo & 0x3FFFFFF,
(lo >> 26) & 0x3FFFFFF,
((lo >> 52) | (hi << 12)) & 0x3FFFFFF,
(hi >> 14) & 0x3FFFFFF,
(hi >> 40) & 0x3FFFFFF
]
}
/// Multiply two numbers in 26-bit limb form, reduce mod 2^130 - 5.
static func multiply(_ a: [UInt64], _ r: [UInt64]) -> [UInt64] {
let r0 = r[0], r1 = r[1], r2 = r[2], r3 = r[3], r4 = r[4]
let s1 = r1 * 5, s2 = r2 * 5, s3 = r3 * 5, s4 = r4 * 5
let a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4]
var h0 = a0 * r0 + a1 * s4 + a2 * s3 + a3 * s2 + a4 * s1
var h1 = a0 * r1 + a1 * r0 + a2 * s4 + a3 * s3 + a4 * s2
var h2 = a0 * r2 + a1 * r1 + a2 * r0 + a3 * s4 + a4 * s3
var h3 = a0 * r3 + a1 * r2 + a2 * r1 + a3 * r0 + a4 * s4
var h4 = a0 * r4 + a1 * r3 + a2 * r2 + a3 * r1 + a4 * r0
var c: UInt64
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
c = h1 >> 26; h2 += c; h1 &= 0x3FFFFFF
c = h2 >> 26; h3 += c; h2 &= 0x3FFFFFF
c = h3 >> 26; h4 += c; h3 &= 0x3FFFFFF
c = h4 >> 26; h0 += c * 5; h4 &= 0x3FFFFFF
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
return [h0, h1, h2, h3, h4]
}
/// Final reduction mod 2^130-5 and add s.
static func freeze(_ h: [UInt64], s: [UInt8]) -> [UInt8] {
var h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], h4 = h[4]
var c: UInt64
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
c = h1 >> 26; h2 += c; h1 &= 0x3FFFFFF
c = h2 >> 26; h3 += c; h2 &= 0x3FFFFFF
c = h3 >> 26; h4 += c; h3 &= 0x3FFFFFF
c = h4 >> 26; h0 += c * 5; h4 &= 0x3FFFFFF
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
// Compute h + -(2^130 - 5) = h - p
var g0 = h0 &+ 5; c = g0 >> 26; g0 &= 0x3FFFFFF
var g1 = h1 &+ c; c = g1 >> 26; g1 &= 0x3FFFFFF
var g2 = h2 &+ c; c = g2 >> 26; g2 &= 0x3FFFFFF
var g3 = h3 &+ c; c = g3 >> 26; g3 &= 0x3FFFFFF
let g4 = h4 &+ c &- (1 << 26)
// If g4 didn't underflow, use g (h >= p)
let mask = (g4 >> 63) &- 1
let nmask = ~mask
h0 = (h0 & nmask) | (g0 & mask)
h1 = (h1 & nmask) | (g1 & mask)
h2 = (h2 & nmask) | (g2 & mask)
h3 = (h3 & nmask) | (g3 & mask)
h4 = (h4 & nmask) | (g4 & mask)
// Convert to two 64-bit values
let lo = h0 | (h1 << 26) | (h2 << 52)
let hi = (h2 >> 12) | (h3 << 14) | (h4 << 40)
// Add s (little-endian)
var sLo: UInt64 = 0
var sHi: UInt64 = 0
for i in stride(from: 7, through: 0, by: -1) { sLo = sLo << 8 | UInt64(s[i]) }
for i in stride(from: 15, through: 8, by: -1) { sHi = sHi << 8 | UInt64(s[i]) }
let resultLo = lo &+ sLo
let carry: UInt64 = resultLo < lo ? 1 : 0
let resultHi = hi &+ sHi &+ carry
var output = [UInt8](repeating: 0, count: 16)
for i in 0..<8 { output[i] = UInt8(truncatingIfNeeded: resultLo >> (i * 8)) }
for i in 0..<8 { output[8 + i] = UInt8(truncatingIfNeeded: resultHi >> (i * 8)) }
return output
}
}

View File

@@ -0,0 +1,201 @@
import Foundation
// MARK: - XChaCha20Engine
/// XChaCha20-Poly1305 AEAD cipher implementation.
/// Matches the Android `MessageCrypto` XChaCha20 implementation for cross-platform compatibility.
enum XChaCha20Engine {
static let poly1305TagSize = 16
// MARK: - XChaCha20-Poly1305 Decrypt
/// Decrypts ciphertext+tag using XChaCha20-Poly1305.
static func decrypt(ciphertextWithTag: Data, key: Data, nonce: Data) throws -> Data {
guard ciphertextWithTag.count > poly1305TagSize else {
throw CryptoError.invalidData("Ciphertext too short for Poly1305 tag")
}
guard key.count == 32, nonce.count == 24 else {
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
}
let ciphertext = ciphertextWithTag[0..<(ciphertextWithTag.count - poly1305TagSize)]
let tag = ciphertextWithTag[(ciphertextWithTag.count - poly1305TagSize)...]
// Step 1: HChaCha20 derive subkey from key + first 16 bytes of nonce
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
// Step 2: Build ChaCha20 nonce: [0,0,0,0] + nonce[16..<24]
var chacha20Nonce = Data(repeating: 0, count: 12)
chacha20Nonce[4..<12] = nonce[16..<24]
// Step 3: Generate Poly1305 key from first 64 bytes of keystream (counter=0)
let poly1305Key = chacha20Block(key: subkey, nonce: chacha20Nonce, counter: 0)[0..<32]
// Step 4: Verify Poly1305 tag
let computedTag = Poly1305Engine.mac(data: Data(ciphertext), key: Data(poly1305Key))
guard constantTimeEqual(Data(tag), computedTag) else {
throw CryptoError.decryptionFailed
}
// Step 5: Decrypt with ChaCha20 (counter starts at 1)
return chacha20Encrypt(
data: Data(ciphertext), key: subkey, nonce: chacha20Nonce, initialCounter: 1
)
}
// MARK: - XChaCha20-Poly1305 Encrypt
/// Encrypts plaintext using XChaCha20-Poly1305.
static func encrypt(plaintext: Data, key: Data, nonce: Data) throws -> Data {
guard key.count == 32, nonce.count == 24 else {
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
}
// Step 1: HChaCha20 derive subkey
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
// Step 2: Build ChaCha20 nonce
var chacha20Nonce = Data(repeating: 0, count: 12)
chacha20Nonce[4..<12] = nonce[16..<24]
// Step 3: Generate Poly1305 key
let poly1305Key = chacha20Block(key: subkey, nonce: chacha20Nonce, counter: 0)[0..<32]
// Step 4: Encrypt with ChaCha20 (counter starts at 1)
let ciphertext = chacha20Encrypt(
data: plaintext, key: subkey, nonce: chacha20Nonce, initialCounter: 1
)
// Step 5: Compute Poly1305 tag
let tag = Poly1305Engine.mac(data: ciphertext, key: Data(poly1305Key))
return ciphertext + tag
}
}
// MARK: - ChaCha20 Core
extension XChaCha20Engine {
/// ChaCha20 quarter round.
static func quarterRound(_ state: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 16) | (state[d] >> 16)
state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 12) | (state[b] >> 20)
state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 8) | (state[d] >> 24)
state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 7) | (state[b] >> 25)
}
/// Generates a 64-byte ChaCha20 block.
static func chacha20Block(key: Data, nonce: Data, counter: UInt32) -> Data {
var state = [UInt32](repeating: 0, count: 16)
// Constants: "expand 32-byte k"
state[0] = 0x61707865; state[1] = 0x3320646e
state[2] = 0x79622d32; state[3] = 0x6b206574
// Key (8 × UInt32, little-endian)
for i in 0..<8 {
state[4 + i] = key.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
state[12] = counter
// Nonce (3 × UInt32, little-endian)
for i in 0..<3 {
state[13 + i] = nonce.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
var working = state
// 20 rounds (10 double rounds)
for _ in 0..<10 {
quarterRound(&working, 0, 4, 8, 12); quarterRound(&working, 1, 5, 9, 13)
quarterRound(&working, 2, 6, 10, 14); quarterRound(&working, 3, 7, 11, 15)
quarterRound(&working, 0, 5, 10, 15); quarterRound(&working, 1, 6, 11, 12)
quarterRound(&working, 2, 7, 8, 13); quarterRound(&working, 3, 4, 9, 14)
}
// Add initial state
for i in 0..<16 { working[i] = working[i] &+ state[i] }
// Serialize to bytes (little-endian)
var result = Data(count: 64)
for i in 0..<16 {
let val = working[i].littleEndian
result[i * 4] = UInt8(truncatingIfNeeded: val)
result[i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
return result
}
/// ChaCha20 stream cipher encryption/decryption.
static func chacha20Encrypt(data: Data, key: Data, nonce: Data, initialCounter: UInt32) -> Data {
var result = Data(count: data.count)
var counter = initialCounter
for offset in stride(from: 0, to: data.count, by: 64) {
let block = chacha20Block(key: key, nonce: nonce, counter: counter)
let blockSize = min(64, data.count - offset)
for i in 0..<blockSize {
result[offset + i] = data[data.startIndex + offset + i] ^ block[i]
}
counter &+= 1
}
return result
}
/// HChaCha20: derives a 32-byte subkey from a 32-byte key and 16-byte nonce.
static func hchacha20(key: Data, nonce: Data) -> Data {
var state = [UInt32](repeating: 0, count: 16)
state[0] = 0x61707865; state[1] = 0x3320646e
state[2] = 0x79622d32; state[3] = 0x6b206574
for i in 0..<8 {
state[4 + i] = key.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
for i in 0..<4 {
state[12 + i] = nonce.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
for _ in 0..<10 {
quarterRound(&state, 0, 4, 8, 12); quarterRound(&state, 1, 5, 9, 13)
quarterRound(&state, 2, 6, 10, 14); quarterRound(&state, 3, 7, 11, 15)
quarterRound(&state, 0, 5, 10, 15); quarterRound(&state, 1, 6, 11, 12)
quarterRound(&state, 2, 7, 8, 13); quarterRound(&state, 3, 4, 9, 14)
}
// Output: first 4 words + last 4 words
var result = Data(count: 32)
for i in 0..<4 {
let val = state[i].littleEndian
result[i * 4] = UInt8(truncatingIfNeeded: val)
result[i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
for i in 0..<4 {
let val = state[12 + i].littleEndian
result[16 + i * 4] = UInt8(truncatingIfNeeded: val)
result[16 + i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[16 + i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[16 + i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
return result
}
/// Constant-time comparison of two Data objects.
static func constantTimeEqual(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var result: UInt8 = 0
for i in 0..<a.count {
result |= a[a.startIndex + i] ^ b[b.startIndex + i]
}
return result == 0
}
}

View File

@@ -1,5 +1,19 @@
import Foundation
// MARK: - System Accounts
enum SystemAccounts {
static let safePublicKey = "0x000000000000000000000000000000000000000002"
static let safeTitle = "Safe"
static let updatesPublicKey = "0x000000000000000000000000000000000000000001"
static let updatesTitle = "Rosetta Updates"
static let systemKeys: Set<String> = [safePublicKey, updatesPublicKey]
static func isSystemAccount(_ publicKey: String) -> Bool {
systemKeys.contains(publicKey)
}
}
// MARK: - DeliveryStatus
enum DeliveryStatus: Int, Codable {
@@ -28,7 +42,7 @@ struct Dialog: Identifiable, Codable, Equatable {
var isOnline: Bool
var lastSeen: Int64
var isVerified: Bool
var verified: Int // 0 = none, 1 = public figure/brand, 2 = Rosetta admin, 3+ = group admin
var iHaveSent: Bool // I have sent at least one message (chat vs request)
var isPinned: Bool
var isMuted: Bool
@@ -40,6 +54,20 @@ struct Dialog: Identifiable, Codable, Equatable {
var isSavedMessages: Bool { opponentKey == account }
/// Client-side heuristic matching Android: badge shown if verified > 0 OR isRosettaOfficial.
var isRosettaOfficial: Bool {
opponentTitle.caseInsensitiveCompare("Rosetta") == .orderedSame ||
opponentUsername.caseInsensitiveCompare("rosetta") == .orderedSame ||
SystemAccounts.isSystemAccount(opponentKey)
}
/// Effective verification level for UI display.
var effectiveVerified: Int {
if verified > 0 { return verified }
if isRosettaOfficial { return 1 }
return 0
}
var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: opponentKey)
}

View File

@@ -41,7 +41,7 @@ final class DialogRepository {
unreadCount: 0,
isOnline: false,
lastSeen: 0,
isVerified: false,
verified: 0,
iHaveSent: false,
isPinned: false,
isMuted: false,
@@ -78,10 +78,11 @@ final class DialogRepository {
dialogs[opponentKey] = dialog
}
func updateUserInfo(publicKey: String, title: String, username: String) {
func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0) {
guard var dialog = dialogs[publicKey] else { return }
if !title.isEmpty { dialog.opponentTitle = title }
if !username.isEmpty { dialog.opponentUsername = username }
if verified > 0 { dialog.verified = max(dialog.verified, verified) }
dialogs[publicKey] = dialog
}

View File

@@ -176,22 +176,25 @@ final class ProtocolManager: @unchecked Sendable {
// MARK: - Packet Handling
private func handleIncomingData(_ data: Data) {
print("[Protocol] Incoming data: \(data.count) bytes, first bytes: \(data.prefix(min(8, data.count)).map { String(format: "%02x", $0) }.joined(separator: " "))")
#if DEBUG
if data.count >= 2 {
let peekStream = Stream(data: data)
let rawId = peekStream.readInt16()
Self.logger.debug("📥 Incoming packet 0x\(String(rawId, radix: 16)), size: \(data.count)")
}
#endif
guard let (packetId, packet) = PacketRegistry.decode(from: data) else {
// Try to read the packet ID manually to see what it is
#if DEBUG
if data.count >= 2 {
let stream = Stream(data: data)
let rawId = stream.readInt16()
print("[Protocol] Unknown packet ID: 0x\(String(rawId, radix: 16)) (\(rawId)), data size: \(data.count)")
} else {
print("[Protocol] Packet too small: \(data.count) bytes")
Self.logger.debug("Unknown packet ID: 0x\(String(rawId, radix: 16)), size: \(data.count)")
}
#endif
return
}
print("[Protocol] Received packet 0x\(String(packetId, radix: 16)) (\(type(of: packet)))")
switch packetId {
case 0x00:
if let p = packet as? PacketHandshake {
@@ -199,17 +202,15 @@ final class ProtocolManager: @unchecked Sendable {
}
case 0x01:
if let p = packet as? PacketUserInfo {
print("[Protocol] UserInfo received: username='\(p.username)', title='\(p.title)'")
onUserInfoReceived?(p)
}
case 0x02:
if let p = packet as? PacketResult {
let code = ResultCode(rawValue: p.resultCode)
print("[Protocol] Result received: code=\(p.resultCode) (\(code.map { "\($0)" } ?? "unknown"))")
let _ = ResultCode(rawValue: p.resultCode)
}
case 0x03:
if let p = packet as? PacketSearch {
print("[Protocol] Search result received: \(p.users.count) users")
Self.logger.debug("📥 Search result: \(p.users.count) users, callback=\(self.onSearchResult != nil)")
onSearchResult?(p)
}
case 0x05:

View File

@@ -178,7 +178,7 @@ final class SessionManager {
proto.onUserInfoReceived = { [weak self] packet in
guard let self else { return }
Task { @MainActor in
print("[Session] UserInfo received: username='\(packet.username)', title='\(packet.title)'")
Self.logger.debug("UserInfo received: username='\(packet.username)', title='\(packet.title)'")
if !packet.title.isEmpty {
self.displayName = packet.title
AccountManager.shared.updateProfile(displayName: packet.title, username: nil)
@@ -210,10 +210,9 @@ final class SessionManager {
userInfoPacket.avatar = ""
userInfoPacket.title = name
userInfoPacket.privateKey = hash
print("[Session] Sending UserInfo: username='\(uname)', title='\(name)'")
ProtocolManager.shared.sendPacket(userInfoPacket)
} else {
print("[Session] Skipping UserInfo — no profile data to send")
Self.logger.debug("Skipping UserInfo — no profile data to send")
}
}
}

View File

@@ -0,0 +1,12 @@
import Foundation
// MARK: - System Account Helpers
/// Client-side heuristic for Rosetta-official accounts (matches Android logic).
/// Returns `true` if the user's title or username matches "Rosetta" (case-insensitive)
/// or if the public key belongs to a system account.
func isRosettaOfficial(_ user: SearchUser) -> Bool {
user.title.caseInsensitiveCompare("Rosetta") == .orderedSame ||
user.username.caseInsensitiveCompare("rosetta") == .orderedSame ||
SystemAccounts.isSystemAccount(user.publicKey)
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
// MARK: - Glass Navigation Bar Modifier
/// Applies glassmorphism effect to the navigation bar on iOS 26+, falling back to ultra-thin material.
struct GlassNavBarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
}
}
}
extension View {
func applyGlassNavBar() -> some View {
modifier(GlassNavBarModifier())
}
}
// MARK: - Glass Search Bar Modifier
/// Applies glassmorphism capsule effect on iOS 26+.
struct GlassSearchBarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.glassEffect(.regular, in: .capsule)
} else {
content
}
}
}
extension View {
func applyGlassSearchBar() -> some View {
modifier(GlassSearchBarModifier())
}
}

View File

@@ -60,8 +60,7 @@ struct RosettaTabBar: View {
searchPill
}
.padding(.horizontal, 25)
.padding(.top, 16)
.padding(.bottom, safeAreaBottom > 0 ? safeAreaBottom : 25)
.padding(.top, 4)
}
}

View File

@@ -0,0 +1,76 @@
import SwiftUI
// MARK: - VerifiedBadge
/// Displays a verified rosette badge for accounts with server-assigned verification.
///
/// Verification levels (from server `verified` field):
/// - `0` not verified (badge hidden)
/// - `1` public figure, brand, or organization
/// - `2` official Rosetta administration account
/// - `3+` group administrator
///
/// Tapping the badge presents a dialog explaining the verification level.
struct VerifiedBadge: View {
let verified: Int
var size: CGFloat = 16
var badgeTint: Color?
@Environment(\.colorScheme) private var colorScheme
@State private var showExplanation = false
var body: some View {
if verified > 0 {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: size))
.foregroundStyle(resolvedColor)
.onTapGesture { showExplanation = true }
.accessibilityLabel("Verified account")
.alert("Verified Account", isPresented: $showExplanation) {
Button("OK", role: .cancel) {}
} message: {
Text(annotationText)
}
}
}
// MARK: - Private
private var resolvedColor: Color {
if let badgeTint { return badgeTint }
return colorScheme == .dark
? RosettaColors.primaryBlue // #248AE6
: Color(hex: 0xACD2F9) // soft blue (light theme)
}
private var annotationText: String {
switch verified {
case 1:
return "This is an official account belonging to a public figure, brand, or organization."
case 2:
return "This is an official account belonging to the administration of Rosetta."
default:
return "This user is an administrator of this group."
}
}
}
// MARK: - Preview
#Preview {
VStack(spacing: 16) {
HStack {
Text("Level 1")
VerifiedBadge(verified: 1, size: 16)
}
HStack {
Text("Level 2")
VerifiedBadge(verified: 2, size: 20)
}
HStack {
Text("Not verified")
VerifiedBadge(verified: 0, size: 16)
}
}
.padding()
}

View File

@@ -14,6 +14,7 @@ enum AuthScreen: Equatable {
struct AuthCoordinator: View {
let onAuthComplete: () -> Void
var onBackToUnlock: (() -> Void)?
@State private var currentScreen: AuthScreen = .welcome
@State private var seedPhrase: [String] = []
@@ -94,7 +95,8 @@ private extension AuthCoordinator {
onImportSeed: {
isImportMode = true
navigateTo(.importSeed)
}
},
onBack: onBackToUnlock
)
case .seedPhrase:
@@ -146,11 +148,11 @@ private extension AuthCoordinator {
case .welcome:
EmptyView()
case .seedPhrase:
WelcomeView(onGenerateSeed: {}, onImportSeed: {})
WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock)
case .confirmSeed:
SeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
case .importSeed:
WelcomeView(onGenerateSeed: {}, onImportSeed: {})
WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock)
case .setPassword:
if isImportMode {
ImportSeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})

View File

@@ -37,7 +37,8 @@ struct ConfirmSeedPhraseView: View {
.padding(.bottom, 100)
}
.scrollDismissesKeyboard(.interactively)
.onTapGesture { focusedInputIndex = nil }
.onTapGesture(count: 1) { focusedInputIndex = nil }
.simultaneousGesture(TapGesture().onEnded {})
confirmButton
.padding(.horizontal, 24)

View File

@@ -30,7 +30,8 @@ struct ImportSeedPhraseView: View {
.padding(.bottom, 100)
}
.scrollDismissesKeyboard(.interactively)
.onTapGesture { focusedWordIndex = nil }
.onTapGesture(count: 1) { focusedWordIndex = nil }
.simultaneousGesture(TapGesture().onEnded {})
continueButton
.padding(.horizontal, 24)

View File

@@ -55,19 +55,18 @@ struct SetPasswordView: View {
.multilineTextAlignment(.center)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
createButton
.padding(.top, 4)
}
.padding(.horizontal, 24)
.padding(.top, 8)
.padding(.bottom, 100)
.padding(.bottom, 32)
}
.scrollDismissesKeyboard(.interactively)
.onTapGesture { focusedField = nil }
createButton
.padding(.horizontal, 24)
.padding(.bottom, 16)
.onTapGesture(count: 1) { focusedField = nil }
.simultaneousGesture(TapGesture().onEnded {})
}
.geometryGroup()
}
}

View File

@@ -1,8 +1,9 @@
import SwiftUI
/// Password unlock screen matching rosetta-android design with liquid glass styling.
/// Password unlock screen matching rosetta-android design.
struct UnlockView: View {
let onUnlocked: () -> Void
var onCreateNewAccount: (() -> Void)?
@State private var password = ""
@State private var isUnlocking = false
@@ -23,29 +24,26 @@ struct UnlockView: View {
account?.publicKey ?? ""
}
/// First 2 chars of public key, uppercased matching Android's `getAvatarText()`.
/// First 2 chars of public key matching Android's avatar text.
private var avatarText: String {
RosettaColors.avatarText(publicKey: publicKey)
}
/// Color index using Java-compatible hashCode matching Android's `getAvatarColor()`.
private var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: publicKey)
}
/// Display name, or first 20 chars of public key if no name set.
private var displayName: String {
let name = account?.displayName ?? ""
if name.isEmpty {
return publicKey.isEmpty ? "Rosetta" : String(publicKey.prefix(20)) + "..."
}
return name
/// Short public key 7 characters like Android (e.g. "0325a4d").
private var shortPublicKey: String {
guard publicKey.count >= 7 else { return publicKey }
return String(publicKey.prefix(7))
}
/// Truncated public key for subtitle.
private var publicKeyPreview: String {
guard publicKey.count > 20 else { return publicKey }
return String(publicKey.prefix(20)) + "..."
/// Display name or short public key.
private var displayTitle: String {
let name = account?.displayName ?? ""
if !name.isEmpty { return name }
return shortPublicKey
}
var body: some View {
@@ -69,27 +67,17 @@ struct UnlockView: View {
Spacer().frame(height: 20)
// Display name
Text(displayName)
// Short public key (7 chars like Android)
Text(shortPublicKey)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
.opacity(showTitle ? 1 : 0)
.offset(y: showTitle ? 0 : 8)
// Public key preview (below name)
if !(account?.displayName ?? "").isEmpty {
Text(publicKeyPreview)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.secondaryText)
.padding(.top, 4)
.opacity(showTitle ? 1 : 0)
.offset(y: showTitle ? 0 : 8)
}
Spacer().frame(height: 8)
// Subtitle
Text("Enter password to unlock")
// Subtitle matching Android
Text("For unlock account enter password")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.secondaryText)
.opacity(showSubtitle ? 1 : 0)
@@ -97,125 +85,25 @@ struct UnlockView: View {
Spacer().frame(height: 40)
// Password input glass card
VStack(alignment: .leading, spacing: 8) {
GlassCard(cornerRadius: 14, fillOpacity: 0.08) {
HStack(spacing: 12) {
Group {
if showPassword {
TextField("Password", text: $password)
} else {
SecureField("Password", text: $password)
}
}
.font(.system(size: 16))
.foregroundStyle(.white)
.textContentType(.password)
.submitLabel(.done)
.onSubmit { unlock() }
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash" : "eye")
.font(.system(size: 18))
.foregroundStyle(Color(white: 0.45))
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.overlay {
RoundedRectangle(cornerRadius: 14)
.stroke(
errorMessage != nil ? RosettaColors.error : Color.clear,
lineWidth: 1
)
}
if let error = errorMessage {
Text(error)
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
.padding(.leading, 4)
.transition(.opacity)
}
}
.padding(.horizontal, 24)
.opacity(showInput ? 1 : 0)
.offset(y: showInput ? 0 : 12)
// Password input
passwordField
.padding(.horizontal, 24)
.opacity(showInput ? 1 : 0)
.offset(y: showInput ? 0 : 12)
Spacer().frame(height: 24)
// Unlock button
Button(action: unlock) {
HStack(spacing: 10) {
if isUnlocking {
ProgressView()
.tint(.white)
.scaleEffect(0.9)
} else {
Image(systemName: "lock.open.fill")
.font(.system(size: 16))
Text("Unlock")
.font(.system(size: 16, weight: .semibold))
}
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(password.isEmpty ? RosettaColors.primaryBlue.opacity(0.4) : RosettaColors.primaryBlue)
)
}
.disabled(password.isEmpty || isUnlocking)
.padding(.horizontal, 24)
.opacity(showButton ? 1 : 0)
.offset(y: showButton ? 0 : 12)
// Enter button matching onboarding style
unlockButton
.padding(.horizontal, 24)
.opacity(showButton ? 1 : 0)
.offset(y: showButton ? 0 : 12)
Spacer().frame(height: 40)
Spacer().frame(height: 60)
// Footer "or" divider + secondary actions
VStack(spacing: 16) {
HStack(spacing: 12) {
Rectangle()
.fill(RosettaColors.secondaryText.opacity(0.3))
.frame(height: 0.5)
Text("or")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.secondaryText)
Rectangle()
.fill(RosettaColors.secondaryText.opacity(0.3))
.frame(height: 0.5)
}
.padding(.horizontal, 40)
Button {
// TODO: Recover account flow
} label: {
HStack(spacing: 8) {
Image(systemName: "key.fill")
.font(.system(size: 14))
Text("Recover account")
.font(.system(size: 15, weight: .medium))
}
.foregroundStyle(RosettaColors.primaryBlue)
}
Button {
// TODO: Create new account flow
} label: {
HStack(spacing: 8) {
Image(systemName: "person.badge.plus")
.font(.system(size: 14))
Text("Create new account")
.font(.system(size: 15, weight: .medium))
}
.foregroundStyle(RosettaColors.secondaryText)
}
}
.opacity(showFooter ? 1 : 0)
// Footer "You can also recover your password or create a new account."
footerView
.opacity(showFooter ? 1 : 0)
Spacer().frame(height: 40)
}
@@ -224,10 +112,131 @@ struct UnlockView: View {
}
.onAppear { startAnimations() }
}
}
// MARK: - Actions
// MARK: - Password Field
private func unlock() {
private extension UnlockView {
var passwordField: some View {
VStack(alignment: .leading, spacing: 8) {
GlassCard(cornerRadius: 14, fillOpacity: 0.08) {
HStack(spacing: 12) {
Group {
if showPassword {
TextField("Password", text: $password)
} else {
SecureField("Password", text: $password)
}
}
.font(.system(size: 16))
.foregroundStyle(.white)
.textContentType(.password)
.submitLabel(.done)
.onSubmit { unlock() }
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash" : "eye")
.font(.system(size: 18))
.foregroundStyle(Color(white: 0.45))
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.overlay {
RoundedRectangle(cornerRadius: 14)
.stroke(
errorMessage != nil ? RosettaColors.error : Color.clear,
lineWidth: 1
)
}
if let error = errorMessage {
Text(error)
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
.padding(.leading, 4)
.transition(.opacity)
}
}
}
}
// MARK: - Unlock Button
private extension UnlockView {
var unlockButton: some View {
Button(action: unlock) {
HStack(spacing: 10) {
if isUnlocking {
ProgressView()
.tint(.white)
.scaleEffect(0.9)
} else {
Image(systemName: "lock.open.fill")
.font(.system(size: 16))
Text("Enter")
.font(.system(size: 16, weight: .semibold))
}
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 56)
}
.buttonStyle(RosettaPrimaryButtonStyle(isEnabled: !password.isEmpty && !isUnlocking))
.disabled(password.isEmpty || isUnlocking)
}
}
// MARK: - Footer
private extension UnlockView {
var footerView: some View {
VStack(spacing: 4) {
HStack(spacing: 0) {
Text("You can also ")
.foregroundStyle(RosettaColors.secondaryText)
Button {
onCreateNewAccount?()
} label: {
Text("recover your password")
.fontWeight(.semibold)
.foregroundStyle(RosettaColors.primaryBlue)
}
.buttonStyle(.plain)
Text(" or")
.foregroundStyle(RosettaColors.secondaryText)
}
.font(.system(size: 15))
HStack(spacing: 0) {
Text("create a ")
.foregroundStyle(RosettaColors.secondaryText)
Button {
onCreateNewAccount?()
} label: {
Text("new account.")
.fontWeight(.semibold)
.foregroundStyle(RosettaColors.primaryBlue)
}
.buttonStyle(.plain)
}
.font(.system(size: 15))
}
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
}
// MARK: - Actions
private extension UnlockView {
func unlock() {
guard !password.isEmpty, !isUnlocking else { return }
isUnlocking = true
errorMessage = nil
@@ -245,7 +254,7 @@ struct UnlockView: View {
}
}
private func startAnimations() {
func startAnimations() {
withAnimation(.easeOut(duration: 0.3)) { showAvatar = true }
withAnimation(.easeOut(duration: 0.3).delay(0.08)) { showTitle = true }
withAnimation(.easeOut(duration: 0.3).delay(0.12)) { showSubtitle = true }

View File

@@ -4,31 +4,47 @@ import Lottie
struct WelcomeView: View {
let onGenerateSeed: () -> Void
let onImportSeed: () -> Void
var onBack: (() -> Void)?
@State private var isVisible = false
var body: some View {
VStack(spacing: 0) {
Spacer()
ZStack(alignment: .topLeading) {
VStack(spacing: 0) {
Spacer()
lockAnimation
.padding(.bottom, 32)
lockAnimation
.padding(.bottom, 32)
titleSection
.padding(.bottom, 16)
titleSection
.padding(.bottom, 16)
subtitleSection
.padding(.bottom, 24)
subtitleSection
.padding(.bottom, 24)
featureBadges
.padding(.bottom, 32)
featureBadges
.padding(.bottom, 32)
Spacer()
Spacer().frame(height: 16)
Spacer()
Spacer().frame(height: 16)
buttonsSection
.padding(.horizontal, 24)
.padding(.bottom, 16)
buttonsSection
.padding(.horizontal, 24)
.padding(.bottom, 16)
}
// Back button (only shows when coming from Unlock screen)
if let onBack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
.padding(.leading, 12)
.padding(.top, 8)
.opacity(isVisible ? 1.0 : 0.0)
}
}
.onAppear {
withAnimation(.easeOut(duration: 0.5)) { isVisible = true }
@@ -146,7 +162,7 @@ private extension WelcomeView {
}
#Preview {
WelcomeView(onGenerateSeed: {}, onImportSeed: {})
WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: {})
.preferredColorScheme(.dark)
.background(RosettaColors.authBackground)
}

View File

@@ -0,0 +1,67 @@
import SwiftUI
import Lottie
// MARK: - ChatEmptyStateView
struct ChatEmptyStateView: View {
let searchText: String
var body: some View {
VStack(spacing: 0) {
if searchText.isEmpty {
noConversationsContent
} else {
noSearchResultsContent
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: -40)
}
}
// MARK: - Content Variants
private extension ChatEmptyStateView {
var noConversationsContent: some View {
Group {
LottieView(animationName: "letter", loopMode: .playOnce, animationSpeed: 1.0)
.frame(width: 150, height: 150)
Spacer().frame(height: 24)
Text("No conversations yet")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
Spacer().frame(height: 8)
Text("Start a new conversation to get started")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
var noSearchResultsContent: some View {
Group {
Image(systemName: "magnifyingglass")
.font(.system(size: 52))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
Spacer().frame(height: 16)
Text("No results for \"\(searchText)\"")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
}
}
}
// MARK: - Preview
#Preview {
ChatEmptyStateView(searchText: "")
}

View File

@@ -0,0 +1,244 @@
import Lottie
import SwiftUI
// MARK: - Chat List Search Content
/// Search overlay for ChatListView shows recent searches or search results.
/// Matches Android's three-state pattern: skeleton empty results.
struct ChatListSearchContent: View {
let searchText: String
@ObservedObject var viewModel: ChatListViewModel
var onSelectRecent: (String) -> Void
var body: some View {
let trimmed = searchText.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
recentSearchesSection
} else {
activeSearchContent
}
}
}
// MARK: - Active Search (Three States)
private extension ChatListSearchContent {
/// Android-style: skeleton empty results only one visible at a time.
@ViewBuilder
var activeSearchContent: some View {
let localResults = viewModel.filteredDialogs
let localKeys = Set(localResults.map(\.opponentKey))
let serverOnly = viewModel.serverSearchResults.filter {
!localKeys.contains($0.publicKey)
}
let hasAnyResult = !localResults.isEmpty || !serverOnly.isEmpty
if viewModel.isServerSearching && !hasAnyResult {
SearchSkeletonView()
} else if !viewModel.isServerSearching && !hasAnyResult {
noResultsState
} else {
resultsList(localResults: localResults, serverOnly: serverOnly)
}
}
/// Lottie animation + "No results found" matches Android's empty state.
var noResultsState: some View {
VStack(spacing: 20) {
Spacer()
LottieView(animationName: "search", loopMode: .playOnce, animationSpeed: 1.0)
.frame(width: 120, height: 120)
Text("Search for users")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Enter username or public key")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Scrollable list of local dialogs + server results.
func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(localResults) { dialog in
ChatRowView(dialog: dialog)
}
ForEach(serverOnly, id: \.publicKey) { user in
serverUserRow(user)
if user.publicKey != serverOnly.last?.publicKey {
Divider()
.padding(.leading, 76)
.foregroundStyle(RosettaColors.Adaptive.divider)
}
}
Spacer().frame(height: 80)
}
}
.scrollDismissesKeyboard(.interactively)
}
}
// MARK: - Recent Searches
private extension ChatListSearchContent {
@ViewBuilder
var recentSearchesSection: some View {
if viewModel.recentSearches.isEmpty {
searchPlaceholder
} else {
ScrollView {
VStack(spacing: 0) {
HStack {
Text("RECENT")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
Button { viewModel.clearRecentSearches() } label: {
Text("Clear")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 6)
ForEach(viewModel.recentSearches, id: \.publicKey) { recent in
recentRow(recent)
}
}
}
.scrollDismissesKeyboard(.interactively)
}
}
var searchPlaceholder: some View {
VStack(spacing: 20) {
Spacer()
LottieView(animationName: "search", loopMode: .loop, animationSpeed: 1.0)
.frame(width: 120, height: 120)
Text("Search for users")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Find people by username or public key")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
func recentRow(_ user: RecentSearch) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(
name: user.title, publicKey: user.publicKey
)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
onSelectRecent(user.username.isEmpty ? user.publicKey : user.username)
} label: {
HStack(spacing: 10) {
ZStack(alignment: .topTrailing) {
AvatarView(
initials: initials, colorIndex: colorIdx,
size: 42, isSavedMessages: isSelf
)
Button {
viewModel.removeRecentSearch(publicKey: user.publicKey)
} label: {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.frame(width: 18, height: 18)
.background(Circle().fill(RosettaColors.figmaBlue))
}
.offset(x: 4, y: -4)
}
VStack(alignment: .leading, spacing: 1) {
Text(isSelf ? "Saved Messages" : (
user.title.isEmpty
? String(user.publicKey.prefix(16)) + "..."
: user.title
))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !user.lastSeenText.isEmpty {
Text(user.lastSeenText)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
}
.buttonStyle(.plain)
}
}
// MARK: - Server User Row
private extension ChatListSearchContent {
func serverUserRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(
name: user.title, publicKey: user.publicKey
)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
viewModel.addToRecent(user)
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials, colorIndex: colorIdx,
size: 48, isOnline: user.online == 1,
isSavedMessages: isSelf
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(isSelf ? "Saved Messages" : (
user.title.isEmpty
? String(user.publicKey.prefix(10))
: user.title
))
.font(.system(size: 16, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !isSelf && (user.verified > 0 || isRosettaOfficial(user)) {
VerifiedBadge(
verified: user.verified > 0 ? user.verified : 1,
size: 16
)
}
}
Text(isSelf ? "Notes" : (
user.username.isEmpty
? "@\(String(user.publicKey.prefix(10)))..."
: "@\(user.username)"
))
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
}

View File

@@ -3,9 +3,9 @@ import SwiftUI
// MARK: - ChatListView
struct ChatListView: View {
@State private var viewModel = ChatListViewModel()
@Binding var isSearchActive: Bool
@StateObject private var viewModel = ChatListViewModel()
@State private var searchText = ""
@State private var isSearchPresented = false
var body: some View {
NavigationStack {
@@ -13,7 +13,15 @@ struct ChatListView: View {
RosettaColors.Adaptive.background
.ignoresSafeArea()
chatContent
if isSearchActive {
ChatListSearchContent(
searchText: searchText,
viewModel: viewModel,
onSelectRecent: { searchText = $0 }
)
} else {
normalContent
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
@@ -21,7 +29,7 @@ struct ChatListView: View {
.applyGlassNavBar()
.searchable(
text: $searchText,
isPresented: $isSearchPresented,
isPresented: $isSearchActive,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search"
)
@@ -33,29 +41,19 @@ struct ChatListView: View {
}
}
// MARK: - Glass Nav Bar Modifier
private struct GlassNavBarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
}
}
}
private extension View {
func applyGlassNavBar() -> some View {
modifier(GlassNavBarModifier())
}
}
// MARK: - Chat Content
// MARK: - Normal Content
private extension ChatListView {
var chatContent: some View {
@ViewBuilder
var normalContent: some View {
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
} else {
dialogList
}
}
var dialogList: some View {
List {
if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in
@@ -64,29 +62,19 @@ private extension ChatListView {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
} else if viewModel.filteredDialogs.isEmpty && !viewModel.showServerResults {
emptyState
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else {
// Local dialog results
if !viewModel.pinnedDialogs.isEmpty {
pinnedSection
ForEach(viewModel.pinnedDialogs) { dialog in
chatRow(dialog)
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
}
}
ForEach(viewModel.unpinnedDialogs) { dialog in
chatRow(dialog)
}
// Server search results
if viewModel.showServerResults {
serverSearchSection
}
}
Color.clear
.frame(height: 80)
Color.clear.frame(height: 80)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
@@ -95,17 +83,6 @@ private extension ChatListView {
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.interactively)
}
}
// MARK: - Pinned Section
private extension ChatListView {
var pinnedSection: some View {
ForEach(viewModel.pinnedDialogs) { dialog in
chatRow(dialog)
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
}
}
func chatRow(_ dialog: Dialog) -> some View {
ChatRowView(dialog: dialog)
@@ -119,7 +96,6 @@ private extension ChatListView {
} label: {
Label("Delete", systemImage: "trash")
}
Button {
viewModel.toggleMute(dialog)
} label: {
@@ -137,7 +113,6 @@ private extension ChatListView {
Label("Read", systemImage: "envelope.open")
}
.tint(RosettaColors.figmaBlue)
Button {
viewModel.togglePin(dialog)
} label: {
@@ -154,9 +129,7 @@ private extension ChatListView {
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button {
// TODO: Edit mode
} label: {
Button { } label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
@@ -166,218 +139,41 @@ private extension ChatListView {
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
storiesAvatars
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.figmaBlue)
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button {
// TODO: Camera
} label: {
Image(systemName: "camera")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.Adaptive.text)
HStack(spacing: 8) {
Button { } label: {
Image(systemName: "camera")
.font(.system(size: 16, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("Camera")
Button { } label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.padding(.bottom, 2)
.accessibilityLabel("New chat")
}
.accessibilityLabel("Camera")
Button {
// TODO: Compose new message
} label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("New chat")
}
}
@ViewBuilder
private var storiesAvatars: some View {
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
let initials = RosettaColors.initials(name: SessionManager.shared.displayName, publicKey: pk)
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
ZStack {
AvatarView(initials: initials, colorIndex: colorIdx, size: 28)
}
}
}
// MARK: - Server Search Results
private extension ChatListView {
@ViewBuilder
var serverSearchSection: some View {
if viewModel.isServerSearching {
HStack {
Spacer()
ProgressView()
.tint(RosettaColors.Adaptive.textSecondary)
Text("Searching users...")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
}
.padding(.vertical, 16)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else if !viewModel.serverSearchResults.isEmpty {
Section {
ForEach(viewModel.serverSearchResults, id: \.publicKey) { user in
serverSearchRow(user)
}
} header: {
Text("GLOBAL SEARCH")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
} else if viewModel.filteredDialogs.isEmpty {
emptyState
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
func serverSearchRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
// TODO: Navigate to ChatDetailView
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 52,
isOnline: user.online == 1,
isSavedMessages: isSelf
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if user.verified > 0 {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.figmaBlue)
}
}
if !user.username.isEmpty {
Text("@\(user.username)")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
if user.online == 1 {
Circle()
.fill(RosettaColors.online)
.frame(width: 8, height: 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets())
}
}
// MARK: - Empty State
private extension ChatListView {
var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: searchText.isEmpty ? "bubble.left.and.bubble.right" : "magnifyingglass")
.font(.system(size: 52))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
.padding(.top, 80)
Text(searchText.isEmpty ? "No chats yet" : "No results for \"\(searchText)\"")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
if searchText.isEmpty {
Text("Start a conversation by tapping the search tab")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Shimmer Row
private struct ChatRowShimmerView: View {
@State private var phase: CGFloat = 0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient)
.frame(width: 62, height: 62)
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 140, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 200, height: 12)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.onAppear {
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
var shimmerGradient: LinearGradient {
let baseOpacity = colorScheme == .dark ? 0.06 : 0.08
let peakOpacity = colorScheme == .dark ? 0.12 : 0.16
return LinearGradient(
colors: [
Color.gray.opacity(baseOpacity),
Color.gray.opacity(peakOpacity),
Color.gray.opacity(baseOpacity),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
let initials = RosettaColors.initials(
name: SessionManager.shared.displayName, publicKey: pk
)
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
}
}
// MARK: - Preview
#Preview { ChatListView(isSearchActive: .constant(false)) }
#Preview {
ChatListView()
}

View File

@@ -1,23 +1,34 @@
import Combine
import Foundation
import os
// MARK: - ChatListViewModel
@Observable
@MainActor
final class ChatListViewModel {
final class ChatListViewModel: ObservableObject {
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "ChatListVM")
// MARK: - State
private(set) var isLoading = false
private(set) var searchQuery = ""
@Published var isLoading = false
@Published var searchQuery = ""
@Published var serverSearchResults: [SearchUser] = []
@Published var isServerSearching = false
@Published var recentSearches: [RecentSearch] = []
// Server search state
private(set) var serverSearchResults: [SearchUser] = []
private(set) var isServerSearching = false
private var searchTask: Task<Void, Never>?
private var lastSearchedText = ""
private static let maxRecent = 20
private var recentKey: String {
"rosetta_recent_searches_\(SessionManager.shared.currentPublicKey)"
}
// MARK: - Init
init() {
loadRecentSearches()
setupSearchCallback()
}
@@ -25,7 +36,6 @@ final class ChatListViewModel {
var filteredDialogs: [Dialog] {
var result = DialogRepository.shared.sortedDialogs
let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased()
if !query.isEmpty {
result = result.filter {
@@ -34,17 +44,11 @@ final class ChatListViewModel {
|| $0.lastMessage.lowercased().contains(query)
}
}
return result
}
var pinnedDialogs: [Dialog] {
filteredDialogs.filter(\.isPinned)
}
var unpinnedDialogs: [Dialog] {
filteredDialogs.filter { !$0.isPinned }
}
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
var totalUnreadCount: Int {
DialogRepository.shared.sortedDialogs
@@ -54,12 +58,6 @@ final class ChatListViewModel {
var hasUnread: Bool { totalUnreadCount > 0 }
/// True when searching and no local results shows server results section
var showServerResults: Bool {
let query = searchQuery.trimmingCharacters(in: .whitespaces)
return !query.isEmpty
}
// MARK: - Actions
func setSearchQuery(_ query: String) {
@@ -85,6 +83,30 @@ final class ChatListViewModel {
// MARK: - Server Search
private func setupSearchCallback() {
Self.logger.debug("Setting up search callback")
ProtocolManager.shared.onSearchResult = { [weak self] packet in
DispatchQueue.main.async { [weak self] in
guard let self else {
Self.logger.debug("Search callback: self is nil")
return
}
Self.logger.debug("📥 Search results received: \(packet.users.count) users")
self.serverSearchResults = packet.users
self.isServerSearching = false
Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)")
for user in packet.users {
DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey,
title: user.title,
username: user.username,
verified: user.verified
)
}
}
}
}
private func triggerServerSearch() {
searchTask?.cancel()
searchTask = nil
@@ -97,15 +119,11 @@ final class ChatListViewModel {
return
}
if trimmed == lastSearchedText {
return
}
if trimmed == lastSearchedText { return }
isServerSearching = true
searchTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else { return }
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
@@ -113,41 +131,65 @@ final class ChatListViewModel {
let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash
print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'")
guard connState == .authenticated, let hash else {
print("[Search] NOT AUTHENTICATED - aborting")
self.isServerSearching = false
return
}
self.lastSearchedText = currentQuery
var packet = PacketSearch()
packet.privateKey = hash
packet.search = currentQuery
print("[Search] Sending PacketSearch for '\(currentQuery)'")
Self.logger.debug("📤 Sending search packet for '\(currentQuery)' with hash \(hash.prefix(10))...")
ProtocolManager.shared.sendPacket(packet)
}
}
private func setupSearchCallback() {
print("[Search] Setting up search callback")
ProtocolManager.shared.onSearchResult = { [weak self] packet in
print("[Search] CALLBACK: received \(packet.users.count) users")
Task { @MainActor [weak self] in
guard let self else { return }
self.serverSearchResults = packet.users
self.isServerSearching = false
// MARK: - Recent Searches
for user in packet.users {
DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey,
title: user.title,
username: user.username
)
}
}
func addToRecent(_ user: SearchUser) {
let recent = RecentSearch(
publicKey: user.publicKey,
title: user.title,
username: user.username,
lastSeenText: user.online == 1 ? "online" : "last seen recently"
)
recentSearches.removeAll { $0.publicKey == user.publicKey }
recentSearches.insert(recent, at: 0)
if recentSearches.count > Self.maxRecent {
recentSearches = Array(recentSearches.prefix(Self.maxRecent))
}
saveRecentSearches()
}
func removeRecentSearch(publicKey: String) {
recentSearches.removeAll { $0.publicKey == publicKey }
saveRecentSearches()
}
func clearRecentSearches() {
recentSearches = []
saveRecentSearches()
}
private func loadRecentSearches() {
if let data = UserDefaults.standard.data(forKey: recentKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
recentSearches = list
return
}
let oldKey = "rosetta_recent_searches"
if let data = UserDefaults.standard.data(forKey: oldKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
recentSearches = list
saveRecentSearches()
UserDefaults.standard.removeObject(forKey: oldKey)
}
}
private func saveRecentSearches() {
guard let data = try? JSONEncoder().encode(recentSearches) else { return }
UserDefaults.standard.set(data, forKey: recentKey)
}
}

View File

@@ -0,0 +1,50 @@
import SwiftUI
// MARK: - ChatRowShimmerView
/// Placeholder shimmer row displayed during chat list loading.
struct ChatRowShimmerView: View {
@State private var phase: CGFloat = 0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient)
.frame(width: 62, height: 62)
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 140, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 200, height: 12)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.task {
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
private var shimmerGradient: LinearGradient {
let baseOpacity = colorScheme == .dark ? 0.06 : 0.08
let peakOpacity = colorScheme == .dark ? 0.12 : 0.16
return LinearGradient(
colors: [
Color.gray.opacity(baseOpacity),
Color.gray.opacity(peakOpacity),
Color.gray.opacity(baseOpacity),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}

View File

@@ -2,11 +2,14 @@ import SwiftUI
// MARK: - ChatRowView
/// Chat row matching Figma spec:
/// Row: paddingLeft=10, paddingRight=16, height=78
/// Avatar: 62px + 10pt right padding
/// Title: SFPro-Medium 17pt, message: SFPro-Regular 15pt
/// Time: SFPro-Regular 14pt, subtitle color: #3C3C43/60%
/// Chat row matching Figma "Row - Chats" component spec:
/// Row: height 78, paddingLeft 10, paddingRight 16, vertical center
/// Avatar: 62px circle, 10pt trailing padding
/// Title: SF Pro Medium 17pt, tracking -0.43, primary color
/// Message: SF Pro Regular 15pt, tracking -0.23, secondary color
/// Time: SF Pro Regular 14pt, tracking -0.23, secondary color
/// Badges gap: 6pt verified 12px, muted 12px
/// Trailing: pt 8, pb 14 readStatus + time (gap 2), pin/count at bottom
struct ChatRowView: View {
let dialog: Dialog
@@ -38,21 +41,27 @@ private extension ChatRowView {
}
}
// MARK: - Content Section
// MARK: - Content Section (two-column: title+detail | trailing accessories)
private extension ChatRowView {
var contentSection: some View {
VStack(alignment: .leading, spacing: 0) {
Spacer(minLength: 0)
titleRow
Spacer().frame(height: 3)
subtitleRow
Spacer(minLength: 0)
HStack(alignment: .center, spacing: 6) {
// Left column: title + message
VStack(alignment: .leading, spacing: 2) {
titleRow
messageRow
}
.frame(maxWidth: .infinity, alignment: .leading)
.clipped()
// Right column: time + pin/badge
trailingColumn
}
.frame(height: 63)
}
}
// MARK: - Title Row (name + badges + delivery + time)
// MARK: - Title Row (name + badges)
private extension ChatRowView {
var titleRow: some View {
@@ -63,10 +72,11 @@ private extension ChatRowView {
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if dialog.isVerified {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.figmaBlue)
if !dialog.isSavedMessages && dialog.effectiveVerified > 0 {
VerifiedBadge(
verified: dialog.effectiveVerified,
size: 12
)
}
if dialog.isMuted {
@@ -74,47 +84,19 @@ private extension ChatRowView {
.font(.system(size: 12))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
Spacer(minLength: 4)
if dialog.lastMessageFromMe && !dialog.isSavedMessages {
deliveryIcon
}
Text(formattedTime)
.font(.system(size: 14))
.foregroundStyle(
dialog.unreadCount > 0 && !dialog.isMuted
? RosettaColors.figmaBlue
: RosettaColors.Adaptive.textSecondary
)
}
}
}
// MARK: - Subtitle Row (message + pin + badge)
// MARK: - Message Row
private extension ChatRowView {
var subtitleRow: some View {
HStack(spacing: 4) {
Text(messageText)
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
Spacer(minLength: 4)
if dialog.isPinned && dialog.unreadCount == 0 {
Image(systemName: "pin.fill")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.rotationEffect(.degrees(45))
}
if dialog.unreadCount > 0 {
unreadBadge
}
}
var messageRow: some View {
Text(messageText)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
var messageText: String {
@@ -123,6 +105,48 @@ private extension ChatRowView {
}
return dialog.lastMessage
}
}
// MARK: - Trailing Column (time + delivery on top, pin/badge on bottom)
private extension ChatRowView {
var trailingColumn: some View {
VStack(alignment: .trailing, spacing: 0) {
// Top: read status + time
HStack(spacing: 2) {
if dialog.lastMessageFromMe && !dialog.isSavedMessages {
deliveryIcon
}
Text(formattedTime)
.font(.system(size: 14))
.tracking(-0.23)
.foregroundStyle(
dialog.unreadCount > 0 && !dialog.isMuted
? RosettaColors.figmaBlue
: RosettaColors.Adaptive.textSecondary
)
}
.padding(.top, 2)
Spacer(minLength: 0)
// Bottom: pin or unread badge
HStack(spacing: 8) {
if dialog.isPinned && dialog.unreadCount == 0 {
Image(systemName: "pin.fill")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.rotationEffect(.degrees(45))
}
if dialog.unreadCount > 0 {
unreadBadge
}
}
.padding(.bottom, 2)
}
}
@ViewBuilder
var deliveryIcon: some View {
@@ -160,9 +184,11 @@ private extension ChatRowView {
return Text(text)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(.white)
.padding(.horizontal, 4)
.frame(minWidth: 20, minHeight: 20)
.frame(maxWidth: 37)
.background {
Capsule()
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
@@ -208,7 +234,7 @@ private extension ChatRowView {
lastMessage: "Hey, how are you?",
lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000),
unreadCount: 3, isOnline: true, lastSeen: 0,
isVerified: true, iHaveSent: true,
verified: 1, iHaveSent: true,
isPinned: false, isMuted: false,
lastMessageFromMe: true, lastMessageDelivered: .read
)

View File

@@ -0,0 +1,86 @@
import SwiftUI
// MARK: - SearchSkeletonView
/// Telegram-style skeleton loading for search results.
/// Matches the Figma chat row layout: 62px avatar, two-line text, trailing time.
struct SearchSkeletonView: View {
@State private var phase: CGFloat = 0
var body: some View {
ScrollView {
VStack(spacing: 0) {
ForEach(0..<7, id: \.self) { index in
skeletonRow(index: index)
if index < 6 {
Divider()
.foregroundStyle(RosettaColors.Adaptive.divider)
.padding(.leading, 82)
}
}
}
}
.scrollDisabled(true)
.task {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
private func skeletonRow(index: Int) -> some View {
HStack(spacing: 0) {
// Avatar 62pt circle matching Figma
Circle()
.fill(shimmerGradient)
.frame(width: 62, height: 62)
.padding(.leading, 10)
.padding(.trailing, 10)
// Text block two lines matching Figma row heights
VStack(alignment: .leading, spacing: 8) {
// Title line name width varies per row
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: titleWidth(for: index), height: 16)
// Subtitle line message preview
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: subtitleWidth(for: index), height: 14)
}
Spacer()
// Trailing time placeholder
RoundedRectangle(cornerRadius: 3)
.fill(shimmerGradient)
.frame(width: 40, height: 12)
.padding(.trailing, 16)
}
.frame(height: 78)
}
// Vary widths to look natural (not uniform blocks)
private func titleWidth(for index: Int) -> CGFloat {
let widths: [CGFloat] = [130, 100, 160, 90, 140, 110, 150]
return widths[index % widths.count]
}
private func subtitleWidth(for index: Int) -> CGFloat {
let widths: [CGFloat] = [200, 170, 220, 150, 190, 180, 210]
return widths[index % widths.count]
}
private var shimmerGradient: LinearGradient {
LinearGradient(
colors: [
Color.gray.opacity(0.08),
Color.gray.opacity(0.15),
Color.gray.opacity(0.08),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
// MARK: - SearchResultsSection
struct SearchResultsSection: View {
let isSearching: Bool
let searchResults: [SearchUser]
var onSelectUser: (SearchUser) -> Void
var body: some View {
if isSearching {
loadingState
} else if searchResults.isEmpty {
noResultsState
} else {
VStack(spacing: 0) {
ForEach(searchResults, id: \.publicKey) { user in
searchResultRow(user)
}
}
}
}
}
// MARK: - States
private extension SearchResultsSection {
var loadingState: some View {
VStack(spacing: 12) {
Spacer().frame(height: 40)
ProgressView()
.tint(RosettaColors.Adaptive.textSecondary)
Text("Searching...")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
.frame(maxWidth: .infinity)
}
var noResultsState: some View {
VStack(spacing: 12) {
Spacer().frame(height: 40)
Image(systemName: "person.slash")
.font(.system(size: 40))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
Text("No users found")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text("Try a different username or public key")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Result Row
private extension SearchResultsSection {
func searchResultRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
onSelectUser(user)
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 42,
isOnline: user.online == 1,
isSavedMessages: isSelf
)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if user.verified > 0 || isRosettaOfficial(user) {
VerifiedBadge(
verified: user.verified > 0 ? user.verified : 1,
size: 12
)
}
}
if !user.username.isEmpty {
Text("@\(user.username)")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
if user.online == 1 {
Circle()
.fill(RosettaColors.online)
.frame(width: 8, height: 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
}
.buttonStyle(.plain)
}
}
// MARK: - Preview
#Preview {
SearchResultsSection(
isSearching: false,
searchResults: [],
onSelectUser: { _ in }
)
}

View File

@@ -15,6 +15,7 @@ struct SearchView: View {
ScrollView {
VStack(spacing: 0) {
if searchText.isEmpty {
favoriteContactsRow
recentSection
} else {
searchResultsContent
@@ -28,9 +29,13 @@ struct SearchView: View {
searchBar
}
.onChange(of: searchText) { _, newValue in
print("[SearchView] onChange fired: '\(newValue)'")
viewModel.setSearchQuery(newValue)
}
.task {
// Auto-focus search field when the view appears
try? await Task.sleep(for: .milliseconds(300))
isSearchFocused = true
}
}
}
@@ -111,25 +116,48 @@ private extension SearchView {
}
}
// MARK: - Glass Search Bar Modifier
private struct GlassSearchBarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.glassEffect(.regular, in: .capsule)
} else {
content
// MARK: - Favorite Contacts (Figma: horizontal scroll at top)
private extension SearchView {
@ViewBuilder
var favoriteContactsRow: some View {
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
if !dialogs.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(Array(dialogs), id: \.id) { dialog in
Button {
// TODO: Navigate to chat
} label: {
VStack(spacing: 4) {
AvatarView(
initials: dialog.initials,
colorIndex: dialog.avatarColorIndex,
size: 62,
isOnline: dialog.isOnline,
isSavedMessages: dialog.isSavedMessages
)
Text(dialog.isSavedMessages ? "Saved" : dialog.opponentTitle.components(separatedBy: " ").first ?? "")
.font(.system(size: 11))
.tracking(0.06)
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
.frame(width: 78)
}
.frame(width: 78)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 2)
}
.padding(.top, 12)
}
}
}
private extension View {
func applyGlassSearchBar() -> some View {
modifier(GlassSearchBarModifier())
}
}
// MARK: - Recent Section
private extension SearchView {
@@ -243,93 +271,15 @@ private extension SearchView {
// MARK: - Search Results Content
private extension SearchView {
@ViewBuilder
var searchResultsContent: some View {
if viewModel.isSearching {
VStack(spacing: 12) {
Spacer().frame(height: 40)
ProgressView()
.tint(RosettaColors.Adaptive.textSecondary)
Text("Searching...")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
SearchResultsSection(
isSearching: viewModel.isSearching,
searchResults: viewModel.searchResults,
onSelectUser: { user in
viewModel.addToRecent(user)
// TODO: Navigate to ChatDetailView for user.publicKey
}
.frame(maxWidth: .infinity)
} else if viewModel.searchResults.isEmpty {
VStack(spacing: 12) {
Spacer().frame(height: 40)
Image(systemName: "person.slash")
.font(.system(size: 40))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
Text("No users found")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text("Try a different username or public key")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
}
.frame(maxWidth: .infinity)
} else {
VStack(spacing: 0) {
ForEach(viewModel.searchResults, id: \.publicKey) { user in
searchResultRow(user)
}
}
}
}
func searchResultRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
viewModel.addToRecent(user)
// TODO: Navigate to ChatDetailView for user.publicKey
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 42,
isOnline: user.online == 1,
isSavedMessages: isSelf
)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if user.verified > 0 {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.figmaBlue)
}
}
if !user.username.isEmpty {
Text("@\(user.username)")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
if user.online == 1 {
Circle()
.fill(RosettaColors.online)
.frame(width: 8, height: 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
}
.buttonStyle(.plain)
)
}
}
@@ -338,3 +288,4 @@ private extension SearchView {
#Preview {
SearchView()
}

View File

@@ -29,7 +29,10 @@ final class SearchViewModel {
private var searchTask: Task<Void, Never>?
private var lastSearchedText = ""
private static let recentKey = "rosetta_recent_searches"
private var recentKey: String {
let pk = SessionManager.shared.currentPublicKey ?? ""
return "rosetta_recent_searches_\(pk)"
}
private static let maxRecent = 20
// MARK: - Init
@@ -42,7 +45,7 @@ final class SearchViewModel {
// MARK: - Search Logic
func setSearchQuery(_ query: String) {
print("[Search] setSearchQuery called: '\(query)'")
searchQuery = query
onSearchQueryChanged()
}
@@ -60,34 +63,34 @@ final class SearchViewModel {
}
if trimmed == lastSearchedText {
print("[Search] Query unchanged, skipping")
return
}
isSearching = true
print("[Search] Starting debounce for '\(trimmed)'")
// Debounce 1 second (like Android)
searchTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else {
print("[Search] Task cancelled during debounce")
return
}
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
guard !currentQuery.isEmpty, currentQuery == trimmed else {
print("[Search] Query changed during debounce, aborting")
return
}
let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash
print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'")
guard connState == .authenticated, let hash else {
print("[Search] NOT AUTHENTICATED - aborting search")
self.isSearching = false
return
}
@@ -97,7 +100,7 @@ final class SearchViewModel {
var packet = PacketSearch()
packet.privateKey = hash
packet.search = currentQuery
print("[Search] Sending PacketSearch for '\(currentQuery)' with hash prefix: \(String(hash.prefix(16)))...")
ProtocolManager.shared.sendPacket(packet)
}
}
@@ -114,10 +117,8 @@ final class SearchViewModel {
// MARK: - Search Callback
private func setupSearchCallback() {
print("[Search] Setting up search callback on ProtocolManager")
ProtocolManager.shared.onSearchResult = { [weak self] packet in
print("[Search] CALLBACK: received \(packet.users.count) users")
Task { @MainActor [weak self] in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.searchResults = packet.users
self.isSearching = false
@@ -127,7 +128,8 @@ final class SearchViewModel {
DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey,
title: user.title,
username: user.username
username: user.username,
verified: user.verified
)
}
}
@@ -166,15 +168,23 @@ final class SearchViewModel {
}
private func loadRecentSearches() {
guard let data = UserDefaults.standard.data(forKey: Self.recentKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) else {
if let data = UserDefaults.standard.data(forKey: recentKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
recentSearches = list
return
}
recentSearches = list
// Migrate from old static key
let oldKey = "rosetta_recent_searches"
if let data = UserDefaults.standard.data(forKey: oldKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
recentSearches = list
saveRecentSearches()
UserDefaults.standard.removeObject(forKey: oldKey)
}
}
private func saveRecentSearches() {
guard let data = try? JSONEncoder().encode(recentSearches) else { return }
UserDefaults.standard.set(data, forKey: Self.recentKey)
UserDefaults.standard.set(data, forKey: recentKey)
}
}

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct MainTabView: View {
var onLogout: (() -> Void)?
@State private var selectedTab: RosettaTab = .chats
@State private var isChatSearchActive = false
var body: some View {
ZStack(alignment: .bottom) {
@@ -13,24 +14,31 @@ struct MainTabView: View {
Group {
switch selectedTab {
case .chats:
ChatListView()
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)
RosettaTabBar(
selectedTab: selectedTab,
onTabSelected: { tab in
withAnimation(.easeInOut(duration: 0.15)) {
selectedTab = tab
}
},
badges: tabBadges
)
.ignoresSafeArea(.keyboard)
if !isChatSearchActive {
RosettaTabBar(
selectedTab: selectedTab,
onTabSelected: { tab in
withAnimation(.easeInOut(duration: 0.3)) {
selectedTab = tab
}
},
badges: tabBadges
)
.ignoresSafeArea(.keyboard)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -17,6 +17,14 @@ struct RosettaApp: App {
init() {
UIWindow.appearance().backgroundColor = .systemBackground
// Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not.
// If this is the first launch after install, clear any stale Keychain data.
if !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") {
try? AccountManager.shared.deleteAccount()
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
}
// Preload Lottie animations early
Task.detached(priority: .userInitiated) {
LottieAnimationCache.shared.preload(OnboardingPages.all.map(\.animationName))
@@ -25,6 +33,7 @@ struct RosettaApp: App {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@AppStorage("isLoggedIn") private var isLoggedIn = false
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
@State private var appState: AppState = .splash
var body: some Scene {
@@ -35,6 +44,7 @@ struct RosettaApp: App {
rootView
}
.preferredColorScheme(.dark)
}
}
@@ -57,21 +67,37 @@ struct RosettaApp: App {
}
case .auth:
AuthCoordinator {
withAnimation(.easeInOut(duration: 0.4)) {
isLoggedIn = true
// Start session automatically with the password from auth flow
appState = .main
}
}
AuthCoordinator(
onAuthComplete: {
withAnimation(.easeInOut(duration: 0.4)) {
isLoggedIn = true
appState = .main
}
},
onBackToUnlock: AccountManager.shared.hasAccount ? {
// Go back to unlock screen if an account exists
withAnimation(.easeInOut(duration: 0.4)) {
appState = .unlock
}
} : nil
)
case .unlock:
UnlockView {
withAnimation(.easeInOut(duration: 0.4)) {
isLoggedIn = true
appState = .main
UnlockView(
onUnlocked: {
withAnimation(.easeInOut(duration: 0.4)) {
isLoggedIn = true
appState = .main
}
},
onCreateNewAccount: {
// Go to auth flow (Welcome screen with back button)
// Does NOT delete the old account Android keeps multiple accounts
withAnimation(.easeInOut(duration: 0.4)) {
appState = .auth
}
}
}
)
case .main:
MainTabView(onLogout: {
@@ -84,12 +110,12 @@ struct RosettaApp: App {
}
private func determineNextState() {
if AccountManager.shared.hasAccount {
if !hasCompletedOnboarding {
// New install or fresh user show onboarding first
appState = .onboarding
} else if AccountManager.shared.hasAccount {
// Existing user unlock with password
appState = .unlock
} else if !hasCompletedOnboarding {
// New user show onboarding first
appState = .onboarding
} else {
// Onboarding done but no account go to auth
appState = .auth