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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
# Exclude from repo
|
||||
.gitignore
|
||||
.claude/
|
||||
rosetta-android/
|
||||
sprints/
|
||||
CLAUDE.md
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
185
Rosetta/Core/Crypto/CryptoPrimitives.swift
Normal file
185
Rosetta/Core/Crypto/CryptoPrimitives.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
153
Rosetta/Core/Crypto/Poly1305Engine.swift
Normal file
153
Rosetta/Core/Crypto/Poly1305Engine.swift
Normal 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
|
||||
}
|
||||
}
|
||||
201
Rosetta/Core/Crypto/XChaCha20Engine.swift
Normal file
201
Rosetta/Core/Crypto/XChaCha20Engine.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
Rosetta/Core/Utils/SystemAccountHelpers.swift
Normal file
12
Rosetta/Core/Utils/SystemAccountHelpers.swift
Normal 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)
|
||||
}
|
||||
41
Rosetta/DesignSystem/Components/GlassModifiers.swift
Normal file
41
Rosetta/DesignSystem/Components/GlassModifiers.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,7 @@ struct RosettaTabBar: View {
|
||||
searchPill
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, safeAreaBottom > 0 ? safeAreaBottom : 25)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
76
Rosetta/DesignSystem/Components/VerifiedBadge.swift
Normal file
76
Rosetta/DesignSystem/Components/VerifiedBadge.swift
Normal 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()
|
||||
}
|
||||
@@ -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: {})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
67
Rosetta/Features/Chats/ChatList/ChatEmptyStateView.swift
Normal file
67
Rosetta/Features/Chats/ChatList/ChatEmptyStateView.swift
Normal 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: "")
|
||||
}
|
||||
244
Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift
Normal file
244
Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
50
Rosetta/Features/Chats/ChatList/ChatRowShimmerView.swift
Normal file
50
Rosetta/Features/Chats/ChatList/ChatRowShimmerView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
86
Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift
Normal file
86
Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
123
Rosetta/Features/Chats/Search/SearchResultsSection.swift
Normal file
123
Rosetta/Features/Chats/Search/SearchResultsSection.swift
Normal 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 }
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
Rosetta/Resources/Lottie/search.json
Normal file
1
Rosetta/Resources/Lottie/search.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user