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
|
# Exclude from repo
|
||||||
|
.gitignore
|
||||||
|
.claude/
|
||||||
rosetta-android/
|
rosetta-android/
|
||||||
sprints/
|
sprints/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import CommonCrypto
|
import CommonCrypto
|
||||||
import Compression
|
|
||||||
import P256K
|
import P256K
|
||||||
|
|
||||||
// MARK: - Error Types
|
// MARK: - Error Types
|
||||||
@@ -34,6 +33,7 @@ enum CryptoError: LocalizedError {
|
|||||||
// MARK: - CryptoManager
|
// MARK: - CryptoManager
|
||||||
|
|
||||||
/// All methods are `nonisolated` — safe to call from any actor/thread.
|
/// All methods are `nonisolated` — safe to call from any actor/thread.
|
||||||
|
/// Low-level primitives are in `CryptoPrimitives`.
|
||||||
final class CryptoManager: @unchecked Sendable {
|
final class CryptoManager: @unchecked Sendable {
|
||||||
|
|
||||||
static let shared = CryptoManager()
|
static let shared = CryptoManager()
|
||||||
@@ -41,19 +41,13 @@ final class CryptoManager: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - BIP39: Mnemonic Generation
|
// MARK: - BIP39: Mnemonic Generation
|
||||||
|
|
||||||
/// Generates a cryptographically secure 12-word BIP39 mnemonic.
|
|
||||||
nonisolated func generateMnemonic() throws -> [String] {
|
nonisolated func generateMnemonic() throws -> [String] {
|
||||||
var entropy = Data(count: 16) // 128 bits
|
let entropy = try CryptoPrimitives.randomBytes(count: 16)
|
||||||
let status = entropy.withUnsafeMutableBytes { ptr in
|
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!)
|
|
||||||
}
|
|
||||||
guard status == errSecSuccess else { throw CryptoError.invalidEntropy }
|
|
||||||
return try mnemonicFromEntropy(entropy)
|
return try mnemonicFromEntropy(entropy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BIP39: Mnemonic Validation
|
// 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 {
|
nonisolated func validateMnemonic(_ words: [String]) -> Bool {
|
||||||
guard words.count == 12 else { return false }
|
guard words.count == 12 else { return false }
|
||||||
let normalized = words.map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
|
let normalized = words.map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
|
||||||
@@ -63,44 +57,37 @@ final class CryptoManager: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - BIP39: Mnemonic → Seed (PBKDF2-SHA512)
|
// 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 {
|
nonisolated func mnemonicToSeed(_ words: [String]) -> Data {
|
||||||
let phrase = words.joined(separator: " ")
|
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)
|
// 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) {
|
nonisolated func deriveKeyPair(from mnemonic: [String]) throws -> (privateKey: Data, publicKey: Data) {
|
||||||
let seed = mnemonicToSeed(mnemonic)
|
let seed = mnemonicToSeed(mnemonic)
|
||||||
let seedHex = seed.hexString
|
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 privateKey = Data(SHA256.hash(data: Data(seedHex.utf8)))
|
||||||
|
|
||||||
let publicKey = try deriveCompressedPublicKey(from: privateKey)
|
let publicKey = try deriveCompressedPublicKey(from: privateKey)
|
||||||
return (privateKey, publicKey)
|
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 {
|
nonisolated func encryptWithPassword(_ data: Data, password: String) throws -> String {
|
||||||
let compressed = try rawDeflate(data)
|
let compressed = try CryptoPrimitives.rawDeflate(data)
|
||||||
let key = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1))
|
let key = CryptoPrimitives.pbkdf2(
|
||||||
let iv = try randomBytes(count: 16)
|
password: password, salt: "rosetta", iterations: 1000,
|
||||||
let ciphertext = try aesCBCEncrypt(compressed, key: key, iv: iv)
|
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())"
|
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 {
|
nonisolated func decryptWithPassword(_ encrypted: String, password: String) throws -> Data {
|
||||||
let parts = encrypted.components(separatedBy: ":")
|
let parts = encrypted.components(separatedBy: ":")
|
||||||
guard parts.count == 2,
|
guard parts.count == 2,
|
||||||
@@ -111,16 +98,22 @@ final class CryptoManager: @unchecked Sendable {
|
|||||||
|
|
||||||
// Try current format: PBKDF2-SHA1 + AES-CBC + zlib inflate
|
// Try current format: PBKDF2-SHA1 + AES-CBC + zlib inflate
|
||||||
if let result = try? {
|
if let result = try? {
|
||||||
let key = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1))
|
let key = CryptoPrimitives.pbkdf2(
|
||||||
let decrypted = try aesCBCDecrypt(ciphertext, key: key, iv: iv)
|
password: password, salt: "rosetta", iterations: 1000,
|
||||||
return try rawInflate(decrypted)
|
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
|
||||||
|
)
|
||||||
|
let decrypted = try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: key, iv: iv)
|
||||||
|
return try CryptoPrimitives.rawInflate(decrypted)
|
||||||
}() {
|
}() {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: legacy iOS format (PBKDF2-SHA256, no compression)
|
// Fallback: legacy iOS format (PBKDF2-SHA256, no compression)
|
||||||
let legacyKey = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256))
|
let legacyKey = CryptoPrimitives.pbkdf2(
|
||||||
return try aesCBCDecrypt(ciphertext, key: legacyKey, iv: iv)
|
password: password, salt: "rosetta", iterations: 1000,
|
||||||
|
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
|
||||||
|
)
|
||||||
|
return try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: legacyKey, iv: iv)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Utilities
|
// MARK: - Utilities
|
||||||
@@ -129,8 +122,6 @@ final class CryptoManager: @unchecked Sendable {
|
|||||||
Data(SHA256.hash(data: data))
|
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 {
|
nonisolated func generatePrivateKeyHash(privateKeyHex: String) -> String {
|
||||||
let combined = Data((privateKeyHex + "rosetta").utf8)
|
let combined = Data((privateKeyHex + "rosetta").utf8)
|
||||||
return sha256(combined).hexString
|
return sha256(combined).hexString
|
||||||
@@ -147,7 +138,6 @@ private extension CryptoManager {
|
|||||||
let hashBytes = Data(SHA256.hash(data: entropy))
|
let hashBytes = Data(SHA256.hash(data: entropy))
|
||||||
let checksumByte = hashBytes[0]
|
let checksumByte = hashBytes[0]
|
||||||
|
|
||||||
// Build bit array: 128 entropy bits + 4 checksum bits = 132 bits
|
|
||||||
var bits = [Bool]()
|
var bits = [Bool]()
|
||||||
bits.reserveCapacity(132)
|
bits.reserveCapacity(132)
|
||||||
for byte in entropy {
|
for byte in entropy {
|
||||||
@@ -155,12 +145,10 @@ private extension CryptoManager {
|
|||||||
bits.append((byte >> shift) & 1 == 1)
|
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) {
|
for shift in stride(from: 7, through: 4, by: -1) {
|
||||||
bits.append((checksumByte >> shift) & 1 == 1)
|
bits.append((checksumByte >> shift) & 1 == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split into 12 × 11-bit groups, map to words
|
|
||||||
return try (0..<12).map { chunk in
|
return try (0..<12).map { chunk in
|
||||||
let index = (0..<11).reduce(0) { acc, bit in
|
let index = (0..<11).reduce(0) { acc, bit in
|
||||||
acc * 2 + (bits[chunk * 11 + bit] ? 1 : 0)
|
acc * 2 + (bits[chunk * 11 + bit] ? 1 : 0)
|
||||||
@@ -173,7 +161,6 @@ private extension CryptoManager {
|
|||||||
func entropyFromMnemonic(_ words: [String]) throws -> Data {
|
func entropyFromMnemonic(_ words: [String]) throws -> Data {
|
||||||
guard words.count == 12 else { throw CryptoError.invalidMnemonic }
|
guard words.count == 12 else { throw CryptoError.invalidMnemonic }
|
||||||
|
|
||||||
// Convert 12 × 11-bit word indices into a 132-bit array
|
|
||||||
var bits = [Bool]()
|
var bits = [Bool]()
|
||||||
bits.reserveCapacity(132)
|
bits.reserveCapacity(132)
|
||||||
for word in words {
|
for word in words {
|
||||||
@@ -183,7 +170,6 @@ private extension CryptoManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// First 128 bits = entropy, last 4 bits = checksum
|
|
||||||
var entropy = Data(count: 16)
|
var entropy = Data(count: 16)
|
||||||
for byteIdx in 0..<16 {
|
for byteIdx in 0..<16 {
|
||||||
let value: UInt8 = (0..<8).reduce(0) { acc, bit in
|
let value: UInt8 = (0..<8).reduce(0) { acc, bit in
|
||||||
@@ -192,7 +178,6 @@ private extension CryptoManager {
|
|||||||
entropy[byteIdx] = value
|
entropy[byteIdx] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify checksum
|
|
||||||
let hashBytes = Data(SHA256.hash(data: entropy))
|
let hashBytes = Data(SHA256.hash(data: entropy))
|
||||||
let expectedTopNibble = hashBytes[0] >> 4
|
let expectedTopNibble = hashBytes[0] >> 4
|
||||||
let actualTopNibble: UInt8 = (0..<4).reduce(0) { acc, bit in
|
let actualTopNibble: UInt8 = (0..<4).reduce(0) { acc, bit in
|
||||||
@@ -208,172 +193,9 @@ private extension CryptoManager {
|
|||||||
|
|
||||||
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 {
|
nonisolated func deriveCompressedPublicKey(from privateKey: Data) throws -> Data {
|
||||||
guard privateKey.count == 32 else { throw CryptoError.invalidPrivateKey }
|
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)
|
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: privateKey, format: .compressed)
|
||||||
// .compressed format → dataRepresentation returns 33-byte compressed public key
|
|
||||||
return signingKey.publicKey.dataRepresentation
|
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.
|
/// Handles message-level encryption/decryption using XChaCha20-Poly1305 + ECDH.
|
||||||
/// Matches the Android `MessageCrypto` implementation for cross-platform compatibility.
|
/// 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 {
|
enum MessageCrypto {
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
@@ -21,28 +26,23 @@ enum MessageCrypto {
|
|||||||
encryptedKey: String,
|
encryptedKey: String,
|
||||||
myPrivateKeyHex: String
|
myPrivateKeyHex: String
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
// Step 1: ECDH decrypt the XChaCha20 key+nonce
|
|
||||||
let keyAndNonce = try decryptKeyFromSender(encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex)
|
let keyAndNonce = try decryptKeyFromSender(encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex)
|
||||||
|
|
||||||
guard keyAndNonce.count >= 56 else {
|
guard keyAndNonce.count >= 56 else {
|
||||||
throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(keyAndNonce.count)")
|
throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(keyAndNonce.count)")
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = keyAndNonce[0..<32] // 32-byte XChaCha20 key
|
let key = keyAndNonce[0..<32]
|
||||||
let nonce = keyAndNonce[32..<56] // 24-byte XChaCha20 nonce
|
let nonce = keyAndNonce[32..<56]
|
||||||
|
|
||||||
// Step 2: XChaCha20-Poly1305 decrypt
|
|
||||||
let ciphertextData = Data(hexString: ciphertext)
|
let ciphertextData = Data(hexString: ciphertext)
|
||||||
let plaintext = try xchacha20Poly1305Decrypt(
|
let plaintext = try XChaCha20Engine.decrypt(
|
||||||
ciphertextWithTag: ciphertextData,
|
ciphertextWithTag: ciphertextData, key: Data(key), nonce: Data(nonce)
|
||||||
key: Data(key),
|
|
||||||
nonce: Data(nonce)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let text = String(data: plaintext, encoding: .utf8) else {
|
guard let text = String(data: plaintext, encoding: .utf8) else {
|
||||||
throw CryptoError.invalidData("Decrypted data is not valid UTF-8")
|
throw CryptoError.invalidData("Decrypted data is not valid UTF-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,32 +61,25 @@ enum MessageCrypto {
|
|||||||
throw CryptoError.invalidData("Cannot encode plaintext as UTF-8")
|
throw CryptoError.invalidData("Cannot encode plaintext as UTF-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate random 32-byte key + 24-byte nonce
|
let key = try CryptoPrimitives.randomBytes(count: 32)
|
||||||
let key = try randomBytes(count: 32)
|
let nonce = try CryptoPrimitives.randomBytes(count: 24)
|
||||||
let nonce = try randomBytes(count: 24)
|
|
||||||
let keyAndNonce = key + nonce
|
let keyAndNonce = key + nonce
|
||||||
|
|
||||||
// XChaCha20-Poly1305 encrypt
|
let ciphertextWithTag = try XChaCha20Engine.encrypt(
|
||||||
let ciphertextWithTag = try xchacha20Poly1305Encrypt(
|
|
||||||
plaintext: plaintextData, key: key, nonce: nonce
|
plaintext: plaintextData, key: key, nonce: nonce
|
||||||
)
|
)
|
||||||
|
|
||||||
// Encrypt key+nonce for recipient via ECDH
|
|
||||||
let chachaKey = try encryptKeyForRecipient(
|
let chachaKey = try encryptKeyForRecipient(
|
||||||
keyAndNonce: keyAndNonce,
|
keyAndNonce: keyAndNonce, recipientPublicKeyHex: recipientPublicKeyHex
|
||||||
recipientPublicKeyHex: recipientPublicKeyHex
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Encrypt key+nonce for sender (self) via ECDH with sender's own public key
|
|
||||||
let senderPrivKey = try P256K.Signing.PrivateKey(
|
let senderPrivKey = try P256K.Signing.PrivateKey(
|
||||||
dataRepresentation: Data(hexString: senderPrivateKeyHex),
|
dataRepresentation: Data(hexString: senderPrivateKeyHex), format: .compressed
|
||||||
format: .compressed
|
|
||||||
)
|
)
|
||||||
let senderPublicKeyHex = senderPrivKey.publicKey.dataRepresentation.hexString
|
let senderPublicKeyHex = senderPrivKey.publicKey.dataRepresentation.hexString
|
||||||
|
|
||||||
let aesChachaKey = try encryptKeyForRecipient(
|
let aesChachaKey = try encryptKeyForRecipient(
|
||||||
keyAndNonce: keyAndNonce,
|
keyAndNonce: keyAndNonce, recipientPublicKeyHex: senderPublicKeyHex
|
||||||
recipientPublicKeyHex: senderPublicKeyHex
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -126,68 +119,43 @@ private extension MessageCrypto {
|
|||||||
let iv = Data(hexString: ivHex)
|
let iv = Data(hexString: ivHex)
|
||||||
let encryptedKeyData = Data(hexString: encryptedKeyHex)
|
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(
|
let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey(
|
||||||
dataRepresentation: ephemeralPrivKeyData, format: .compressed
|
dataRepresentation: Data(hexString: ephemeralPrivateKeyHex), format: .compressed
|
||||||
)
|
)
|
||||||
let myPrivKey = try P256K.KeyAgreement.PrivateKey(
|
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
|
// 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(
|
||||||
let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(with: myPublicKey, format: .compressed)
|
with: myPrivKey.publicKey, format: .compressed
|
||||||
|
)
|
||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
|
let sharedKey = extractXCoordinate(from: sharedSecretData)
|
||||||
// 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)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AES-256-CBC decrypt
|
// 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)
|
// 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 {
|
guard let utf8String = String(data: decryptedBytes, encoding: .utf8) else {
|
||||||
throw CryptoError.invalidData("Decrypted key is not valid UTF-8")
|
throw CryptoError.invalidData("Decrypted key is not valid UTF-8")
|
||||||
}
|
}
|
||||||
let originalBytes = Data(utf8String.unicodeScalars.map { UInt8(truncatingIfNeeded: $0.value) })
|
return Data(utf8String.unicodeScalars.map { UInt8(truncatingIfNeeded: $0.value) })
|
||||||
|
|
||||||
return originalBytes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypts the XChaCha20 key+nonce for a recipient using ECDH.
|
/// Encrypts the XChaCha20 key+nonce for a recipient using ECDH.
|
||||||
static func encryptKeyForRecipient(keyAndNonce: Data, recipientPublicKeyHex: String) throws -> String {
|
static func encryptKeyForRecipient(keyAndNonce: Data, recipientPublicKeyHex: String) throws -> String {
|
||||||
// Generate ephemeral key pair
|
|
||||||
let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey()
|
let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey()
|
||||||
|
|
||||||
// Parse recipient public key
|
|
||||||
let recipientPubKey = try P256K.KeyAgreement.PublicKey(
|
let recipientPubKey = try P256K.KeyAgreement.PublicKey(
|
||||||
dataRepresentation: Data(hexString: recipientPublicKeyHex), format: .compressed
|
dataRepresentation: Data(hexString: recipientPublicKeyHex), format: .compressed
|
||||||
)
|
)
|
||||||
|
|
||||||
// ECDH: ephemeralPrivKey × recipientPubKey → shared secret
|
let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(
|
||||||
let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(with: recipientPubKey, format: .compressed)
|
with: recipientPubKey, format: .compressed
|
||||||
|
)
|
||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
|
let sharedKey = extractXCoordinate(from: sharedSecretData)
|
||||||
// Extract x-coordinate
|
|
||||||
let sharedKey: Data
|
|
||||||
if sharedSecretData.count == 33 {
|
|
||||||
sharedKey = sharedSecretData[1..<33]
|
|
||||||
} else {
|
|
||||||
sharedKey = sharedSecretData
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert keyAndNonce bytes to UTF-8 string representation (JS Buffer compatibility)
|
// Convert keyAndNonce bytes to UTF-8 string representation (JS Buffer compatibility)
|
||||||
let utf8Representation = String(keyAndNonce.map { Character(UnicodeScalar($0)) })
|
let utf8Representation = String(keyAndNonce.map { Character(UnicodeScalar($0)) })
|
||||||
@@ -195,531 +163,22 @@ private extension MessageCrypto {
|
|||||||
throw CryptoError.encryptionFailed
|
throw CryptoError.encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// AES-256-CBC encrypt
|
let iv = try CryptoPrimitives.randomBytes(count: 16)
|
||||||
let iv = try randomBytes(count: 16)
|
let ciphertext = try CryptoPrimitives.aesCBCEncrypt(dataToEncrypt, key: sharedKey, iv: iv)
|
||||||
let ciphertext = try aesCBCEncrypt(dataToEncrypt, key: sharedKey, iv: iv)
|
|
||||||
|
|
||||||
// Get ephemeral private key hex
|
|
||||||
let ephemeralPrivKeyHex = ephemeralPrivKey.rawRepresentation.hexString
|
let ephemeralPrivKeyHex = ephemeralPrivKey.rawRepresentation.hexString
|
||||||
|
|
||||||
// Format: Base64(ivHex:ciphertextHex:ephemeralPrivateKeyHex)
|
|
||||||
let combined = "\(iv.hexString):\(ciphertext.hexString):\(ephemeralPrivKeyHex)"
|
let combined = "\(iv.hexString):\(ciphertext.hexString):\(ephemeralPrivKeyHex)"
|
||||||
guard let base64 = combined.data(using: .utf8)?.base64EncodedString() else {
|
guard let base64 = combined.data(using: .utf8)?.base64EncodedString() else {
|
||||||
throw CryptoError.encryptionFailed
|
throw CryptoError.encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
return base64
|
return base64
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - XChaCha20-Poly1305
|
/// Extracts the 32-byte x-coordinate from a compressed ECDH shared secret.
|
||||||
|
static func extractXCoordinate(from sharedSecretData: Data) -> Data {
|
||||||
private extension MessageCrypto {
|
if sharedSecretData.count == 33 {
|
||||||
|
return sharedSecretData[1..<33]
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
guard key.count == 32, nonce.count == 24 else {
|
return sharedSecretData.prefix(32)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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
|
// MARK: - DeliveryStatus
|
||||||
|
|
||||||
enum DeliveryStatus: Int, Codable {
|
enum DeliveryStatus: Int, Codable {
|
||||||
@@ -28,7 +42,7 @@ struct Dialog: Identifiable, Codable, Equatable {
|
|||||||
var isOnline: Bool
|
var isOnline: Bool
|
||||||
var lastSeen: Int64
|
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 iHaveSent: Bool // I have sent at least one message (chat vs request)
|
||||||
var isPinned: Bool
|
var isPinned: Bool
|
||||||
var isMuted: Bool
|
var isMuted: Bool
|
||||||
@@ -40,6 +54,20 @@ struct Dialog: Identifiable, Codable, Equatable {
|
|||||||
|
|
||||||
var isSavedMessages: Bool { opponentKey == account }
|
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 {
|
var avatarColorIndex: Int {
|
||||||
RosettaColors.avatarColorIndex(for: opponentKey)
|
RosettaColors.avatarColorIndex(for: opponentKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class DialogRepository {
|
|||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
isOnline: false,
|
isOnline: false,
|
||||||
lastSeen: 0,
|
lastSeen: 0,
|
||||||
isVerified: false,
|
verified: 0,
|
||||||
iHaveSent: false,
|
iHaveSent: false,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
@@ -78,10 +78,11 @@ final class DialogRepository {
|
|||||||
dialogs[opponentKey] = dialog
|
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 }
|
guard var dialog = dialogs[publicKey] else { return }
|
||||||
if !title.isEmpty { dialog.opponentTitle = title }
|
if !title.isEmpty { dialog.opponentTitle = title }
|
||||||
if !username.isEmpty { dialog.opponentUsername = username }
|
if !username.isEmpty { dialog.opponentUsername = username }
|
||||||
|
if verified > 0 { dialog.verified = max(dialog.verified, verified) }
|
||||||
dialogs[publicKey] = dialog
|
dialogs[publicKey] = dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,22 +176,25 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
// MARK: - Packet Handling
|
// MARK: - Packet Handling
|
||||||
|
|
||||||
private func handleIncomingData(_ data: Data) {
|
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 {
|
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 {
|
if data.count >= 2 {
|
||||||
let stream = Stream(data: data)
|
let stream = Stream(data: data)
|
||||||
let rawId = stream.readInt16()
|
let rawId = stream.readInt16()
|
||||||
print("[Protocol] Unknown packet ID: 0x\(String(rawId, radix: 16)) (\(rawId)), data size: \(data.count)")
|
Self.logger.debug("Unknown packet ID: 0x\(String(rawId, radix: 16)), size: \(data.count)")
|
||||||
} else {
|
|
||||||
print("[Protocol] Packet too small: \(data.count) bytes")
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
print("[Protocol] Received packet 0x\(String(packetId, radix: 16)) (\(type(of: packet)))")
|
|
||||||
|
|
||||||
switch packetId {
|
switch packetId {
|
||||||
case 0x00:
|
case 0x00:
|
||||||
if let p = packet as? PacketHandshake {
|
if let p = packet as? PacketHandshake {
|
||||||
@@ -199,17 +202,15 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
case 0x01:
|
case 0x01:
|
||||||
if let p = packet as? PacketUserInfo {
|
if let p = packet as? PacketUserInfo {
|
||||||
print("[Protocol] UserInfo received: username='\(p.username)', title='\(p.title)'")
|
|
||||||
onUserInfoReceived?(p)
|
onUserInfoReceived?(p)
|
||||||
}
|
}
|
||||||
case 0x02:
|
case 0x02:
|
||||||
if let p = packet as? PacketResult {
|
if let p = packet as? PacketResult {
|
||||||
let code = ResultCode(rawValue: p.resultCode)
|
let _ = ResultCode(rawValue: p.resultCode)
|
||||||
print("[Protocol] Result received: code=\(p.resultCode) (\(code.map { "\($0)" } ?? "unknown"))")
|
|
||||||
}
|
}
|
||||||
case 0x03:
|
case 0x03:
|
||||||
if let p = packet as? PacketSearch {
|
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)
|
onSearchResult?(p)
|
||||||
}
|
}
|
||||||
case 0x05:
|
case 0x05:
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ final class SessionManager {
|
|||||||
proto.onUserInfoReceived = { [weak self] packet in
|
proto.onUserInfoReceived = { [weak self] packet in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
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 {
|
if !packet.title.isEmpty {
|
||||||
self.displayName = packet.title
|
self.displayName = packet.title
|
||||||
AccountManager.shared.updateProfile(displayName: packet.title, username: nil)
|
AccountManager.shared.updateProfile(displayName: packet.title, username: nil)
|
||||||
@@ -210,10 +210,9 @@ final class SessionManager {
|
|||||||
userInfoPacket.avatar = ""
|
userInfoPacket.avatar = ""
|
||||||
userInfoPacket.title = name
|
userInfoPacket.title = name
|
||||||
userInfoPacket.privateKey = hash
|
userInfoPacket.privateKey = hash
|
||||||
print("[Session] Sending UserInfo: username='\(uname)', title='\(name)'")
|
|
||||||
ProtocolManager.shared.sendPacket(userInfoPacket)
|
ProtocolManager.shared.sendPacket(userInfoPacket)
|
||||||
} else {
|
} 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
|
searchPill
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 25)
|
.padding(.horizontal, 25)
|
||||||
.padding(.top, 16)
|
.padding(.top, 4)
|
||||||
.padding(.bottom, safeAreaBottom > 0 ? safeAreaBottom : 25)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 {
|
struct AuthCoordinator: View {
|
||||||
let onAuthComplete: () -> Void
|
let onAuthComplete: () -> Void
|
||||||
|
var onBackToUnlock: (() -> Void)?
|
||||||
|
|
||||||
@State private var currentScreen: AuthScreen = .welcome
|
@State private var currentScreen: AuthScreen = .welcome
|
||||||
@State private var seedPhrase: [String] = []
|
@State private var seedPhrase: [String] = []
|
||||||
@@ -94,7 +95,8 @@ private extension AuthCoordinator {
|
|||||||
onImportSeed: {
|
onImportSeed: {
|
||||||
isImportMode = true
|
isImportMode = true
|
||||||
navigateTo(.importSeed)
|
navigateTo(.importSeed)
|
||||||
}
|
},
|
||||||
|
onBack: onBackToUnlock
|
||||||
)
|
)
|
||||||
|
|
||||||
case .seedPhrase:
|
case .seedPhrase:
|
||||||
@@ -146,11 +148,11 @@ private extension AuthCoordinator {
|
|||||||
case .welcome:
|
case .welcome:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
case .seedPhrase:
|
case .seedPhrase:
|
||||||
WelcomeView(onGenerateSeed: {}, onImportSeed: {})
|
WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock)
|
||||||
case .confirmSeed:
|
case .confirmSeed:
|
||||||
SeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
|
SeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
|
||||||
case .importSeed:
|
case .importSeed:
|
||||||
WelcomeView(onGenerateSeed: {}, onImportSeed: {})
|
WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock)
|
||||||
case .setPassword:
|
case .setPassword:
|
||||||
if isImportMode {
|
if isImportMode {
|
||||||
ImportSeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
|
ImportSeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ struct ConfirmSeedPhraseView: View {
|
|||||||
.padding(.bottom, 100)
|
.padding(.bottom, 100)
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onTapGesture { focusedInputIndex = nil }
|
.onTapGesture(count: 1) { focusedInputIndex = nil }
|
||||||
|
.simultaneousGesture(TapGesture().onEnded {})
|
||||||
|
|
||||||
confirmButton
|
confirmButton
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ struct ImportSeedPhraseView: View {
|
|||||||
.padding(.bottom, 100)
|
.padding(.bottom, 100)
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onTapGesture { focusedWordIndex = nil }
|
.onTapGesture(count: 1) { focusedWordIndex = nil }
|
||||||
|
.simultaneousGesture(TapGesture().onEnded {})
|
||||||
|
|
||||||
continueButton
|
continueButton
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|||||||
@@ -55,19 +55,18 @@ struct SetPasswordView: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createButton
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 100)
|
.padding(.bottom, 32)
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onTapGesture { focusedField = nil }
|
.onTapGesture(count: 1) { focusedField = nil }
|
||||||
|
.simultaneousGesture(TapGesture().onEnded {})
|
||||||
createButton
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
}
|
}
|
||||||
.geometryGroup()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Password unlock screen matching rosetta-android design with liquid glass styling.
|
/// Password unlock screen matching rosetta-android design.
|
||||||
struct UnlockView: View {
|
struct UnlockView: View {
|
||||||
let onUnlocked: () -> Void
|
let onUnlocked: () -> Void
|
||||||
|
var onCreateNewAccount: (() -> Void)?
|
||||||
|
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@State private var isUnlocking = false
|
@State private var isUnlocking = false
|
||||||
@@ -23,29 +24,26 @@ struct UnlockView: View {
|
|||||||
account?.publicKey ?? ""
|
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 {
|
private var avatarText: String {
|
||||||
RosettaColors.avatarText(publicKey: publicKey)
|
RosettaColors.avatarText(publicKey: publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Color index using Java-compatible hashCode — matching Android's `getAvatarColor()`.
|
|
||||||
private var avatarColorIndex: Int {
|
private var avatarColorIndex: Int {
|
||||||
RosettaColors.avatarColorIndex(for: publicKey)
|
RosettaColors.avatarColorIndex(for: publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display name, or first 20 chars of public key if no name set.
|
/// Short public key — 7 characters like Android (e.g. "0325a4d").
|
||||||
private var displayName: String {
|
private var shortPublicKey: String {
|
||||||
let name = account?.displayName ?? ""
|
guard publicKey.count >= 7 else { return publicKey }
|
||||||
if name.isEmpty {
|
return String(publicKey.prefix(7))
|
||||||
return publicKey.isEmpty ? "Rosetta" : String(publicKey.prefix(20)) + "..."
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Truncated public key for subtitle.
|
/// Display name or short public key.
|
||||||
private var publicKeyPreview: String {
|
private var displayTitle: String {
|
||||||
guard publicKey.count > 20 else { return publicKey }
|
let name = account?.displayName ?? ""
|
||||||
return String(publicKey.prefix(20)) + "..."
|
if !name.isEmpty { return name }
|
||||||
|
return shortPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -69,27 +67,17 @@ struct UnlockView: View {
|
|||||||
|
|
||||||
Spacer().frame(height: 20)
|
Spacer().frame(height: 20)
|
||||||
|
|
||||||
// Display name
|
// Short public key (7 chars like Android)
|
||||||
Text(displayName)
|
Text(shortPublicKey)
|
||||||
.font(.system(size: 24, weight: .bold))
|
.font(.system(size: 24, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.opacity(showTitle ? 1 : 0)
|
.opacity(showTitle ? 1 : 0)
|
||||||
.offset(y: showTitle ? 0 : 8)
|
.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)
|
Spacer().frame(height: 8)
|
||||||
|
|
||||||
// Subtitle
|
// Subtitle — matching Android
|
||||||
Text("Enter password to unlock")
|
Text("For unlock account enter password")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(RosettaColors.secondaryText)
|
.foregroundStyle(RosettaColors.secondaryText)
|
||||||
.opacity(showSubtitle ? 1 : 0)
|
.opacity(showSubtitle ? 1 : 0)
|
||||||
@@ -97,7 +85,39 @@ struct UnlockView: View {
|
|||||||
|
|
||||||
Spacer().frame(height: 40)
|
Spacer().frame(height: 40)
|
||||||
|
|
||||||
// Password input — glass card
|
// Password input
|
||||||
|
passwordField
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.opacity(showInput ? 1 : 0)
|
||||||
|
.offset(y: showInput ? 0 : 12)
|
||||||
|
|
||||||
|
Spacer().frame(height: 24)
|
||||||
|
|
||||||
|
// Enter button — matching onboarding style
|
||||||
|
unlockButton
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.opacity(showButton ? 1 : 0)
|
||||||
|
.offset(y: showButton ? 0 : 12)
|
||||||
|
|
||||||
|
Spacer().frame(height: 60)
|
||||||
|
|
||||||
|
// Footer — "You can also recover your password or create a new account."
|
||||||
|
footerView
|
||||||
|
.opacity(showFooter ? 1 : 0)
|
||||||
|
|
||||||
|
Spacer().frame(height: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
}
|
||||||
|
.onAppear { startAnimations() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Password Field
|
||||||
|
|
||||||
|
private extension UnlockView {
|
||||||
|
var passwordField: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
GlassCard(cornerRadius: 14, fillOpacity: 0.08) {
|
GlassCard(cornerRadius: 14, fillOpacity: 0.08) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -141,13 +161,13 @@ struct UnlockView: View {
|
|||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
}
|
||||||
.opacity(showInput ? 1 : 0)
|
}
|
||||||
.offset(y: showInput ? 0 : 12)
|
|
||||||
|
|
||||||
Spacer().frame(height: 24)
|
// MARK: - Unlock Button
|
||||||
|
|
||||||
// Unlock button
|
private extension UnlockView {
|
||||||
|
var unlockButton: some View {
|
||||||
Button(action: unlock) {
|
Button(action: unlock) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
if isUnlocking {
|
if isUnlocking {
|
||||||
@@ -157,77 +177,66 @@ struct UnlockView: View {
|
|||||||
} else {
|
} else {
|
||||||
Image(systemName: "lock.open.fill")
|
Image(systemName: "lock.open.fill")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
Text("Unlock")
|
Text("Enter")
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.system(size: 16, weight: .semibold))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 54)
|
.frame(height: 56)
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.fill(password.isEmpty ? RosettaColors.primaryBlue.opacity(0.4) : RosettaColors.primaryBlue)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(RosettaPrimaryButtonStyle(isEnabled: !password.isEmpty && !isUnlocking))
|
||||||
.disabled(password.isEmpty || isUnlocking)
|
.disabled(password.isEmpty || isUnlocking)
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.opacity(showButton ? 1 : 0)
|
|
||||||
.offset(y: showButton ? 0 : 12)
|
|
||||||
|
|
||||||
Spacer().frame(height: 40)
|
|
||||||
|
|
||||||
// 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)
|
}
|
||||||
|
|
||||||
|
// MARK: - Footer
|
||||||
|
|
||||||
|
private extension UnlockView {
|
||||||
|
var footerView: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("You can also ")
|
||||||
|
.foregroundStyle(RosettaColors.secondaryText)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
// TODO: Recover account flow
|
onCreateNewAccount?()
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 8) {
|
Text("recover your password")
|
||||||
Image(systemName: "key.fill")
|
.fontWeight(.semibold)
|
||||||
.font(.system(size: 14))
|
|
||||||
Text("Recover account")
|
|
||||||
.font(.system(size: 15, weight: .medium))
|
|
||||||
}
|
|
||||||
.foregroundStyle(RosettaColors.primaryBlue)
|
.foregroundStyle(RosettaColors.primaryBlue)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button {
|
Text(" or")
|
||||||
// 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)
|
.foregroundStyle(RosettaColors.secondaryText)
|
||||||
}
|
}
|
||||||
}
|
.font(.system(size: 15))
|
||||||
.opacity(showFooter ? 1 : 0)
|
|
||||||
|
|
||||||
Spacer().frame(height: 40)
|
HStack(spacing: 0) {
|
||||||
}
|
Text("create a ")
|
||||||
}
|
.foregroundStyle(RosettaColors.secondaryText)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
|
||||||
}
|
|
||||||
.onAppear { startAnimations() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
Button {
|
||||||
|
onCreateNewAccount?()
|
||||||
|
} label: {
|
||||||
|
Text("new account.")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(RosettaColors.primaryBlue)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.font(.system(size: 15))
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func unlock() {
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private extension UnlockView {
|
||||||
|
func unlock() {
|
||||||
guard !password.isEmpty, !isUnlocking else { return }
|
guard !password.isEmpty, !isUnlocking else { return }
|
||||||
isUnlocking = true
|
isUnlocking = true
|
||||||
errorMessage = nil
|
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)) { showAvatar = true }
|
||||||
withAnimation(.easeOut(duration: 0.3).delay(0.08)) { showTitle = true }
|
withAnimation(.easeOut(duration: 0.3).delay(0.08)) { showTitle = true }
|
||||||
withAnimation(.easeOut(duration: 0.3).delay(0.12)) { showSubtitle = true }
|
withAnimation(.easeOut(duration: 0.3).delay(0.12)) { showSubtitle = true }
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import Lottie
|
|||||||
struct WelcomeView: View {
|
struct WelcomeView: View {
|
||||||
let onGenerateSeed: () -> Void
|
let onGenerateSeed: () -> Void
|
||||||
let onImportSeed: () -> Void
|
let onImportSeed: () -> Void
|
||||||
|
var onBack: (() -> Void)?
|
||||||
|
|
||||||
@State private var isVisible = false
|
@State private var isVisible = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -30,6 +32,20 @@ struct WelcomeView: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.bottom, 16)
|
.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 {
|
.onAppear {
|
||||||
withAnimation(.easeOut(duration: 0.5)) { isVisible = true }
|
withAnimation(.easeOut(duration: 0.5)) { isVisible = true }
|
||||||
}
|
}
|
||||||
@@ -146,7 +162,7 @@ private extension WelcomeView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
WelcomeView(onGenerateSeed: {}, onImportSeed: {})
|
WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: {})
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.background(RosettaColors.authBackground)
|
.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
|
// MARK: - ChatListView
|
||||||
|
|
||||||
struct ChatListView: View {
|
struct ChatListView: View {
|
||||||
@State private var viewModel = ChatListViewModel()
|
@Binding var isSearchActive: Bool
|
||||||
|
@StateObject private var viewModel = ChatListViewModel()
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var isSearchPresented = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -13,7 +13,15 @@ struct ChatListView: View {
|
|||||||
RosettaColors.Adaptive.background
|
RosettaColors.Adaptive.background
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
chatContent
|
if isSearchActive {
|
||||||
|
ChatListSearchContent(
|
||||||
|
searchText: searchText,
|
||||||
|
viewModel: viewModel,
|
||||||
|
onSelectRecent: { searchText = $0 }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
normalContent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { toolbarContent }
|
.toolbar { toolbarContent }
|
||||||
@@ -21,7 +29,7 @@ struct ChatListView: View {
|
|||||||
.applyGlassNavBar()
|
.applyGlassNavBar()
|
||||||
.searchable(
|
.searchable(
|
||||||
text: $searchText,
|
text: $searchText,
|
||||||
isPresented: $isSearchPresented,
|
isPresented: $isSearchActive,
|
||||||
placement: .navigationBarDrawer(displayMode: .always),
|
placement: .navigationBarDrawer(displayMode: .always),
|
||||||
prompt: "Search"
|
prompt: "Search"
|
||||||
)
|
)
|
||||||
@@ -33,29 +41,19 @@ struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Glass Nav Bar Modifier
|
// MARK: - Normal Content
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
private extension ChatListView {
|
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 {
|
List {
|
||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
ForEach(0..<8, id: \.self) { _ in
|
ForEach(0..<8, id: \.self) { _ in
|
||||||
@@ -64,29 +62,19 @@ private extension ChatListView {
|
|||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
} else if viewModel.filteredDialogs.isEmpty && !viewModel.showServerResults {
|
|
||||||
emptyState
|
|
||||||
.listRowInsets(EdgeInsets())
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
} else {
|
} else {
|
||||||
// Local dialog results
|
|
||||||
if !viewModel.pinnedDialogs.isEmpty {
|
if !viewModel.pinnedDialogs.isEmpty {
|
||||||
pinnedSection
|
ForEach(viewModel.pinnedDialogs) { dialog in
|
||||||
|
chatRow(dialog)
|
||||||
|
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(viewModel.unpinnedDialogs) { dialog in
|
ForEach(viewModel.unpinnedDialogs) { dialog in
|
||||||
chatRow(dialog)
|
chatRow(dialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server search results
|
|
||||||
if viewModel.showServerResults {
|
|
||||||
serverSearchSection
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Color.clear
|
Color.clear.frame(height: 80)
|
||||||
.frame(height: 80)
|
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
@@ -95,17 +83,6 @@ private extension ChatListView {
|
|||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.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 {
|
func chatRow(_ dialog: Dialog) -> some View {
|
||||||
ChatRowView(dialog: dialog)
|
ChatRowView(dialog: dialog)
|
||||||
@@ -119,7 +96,6 @@ private extension ChatListView {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.toggleMute(dialog)
|
viewModel.toggleMute(dialog)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -137,7 +113,6 @@ private extension ChatListView {
|
|||||||
Label("Read", systemImage: "envelope.open")
|
Label("Read", systemImage: "envelope.open")
|
||||||
}
|
}
|
||||||
.tint(RosettaColors.figmaBlue)
|
.tint(RosettaColors.figmaBlue)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.togglePin(dialog)
|
viewModel.togglePin(dialog)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -154,9 +129,7 @@ private extension ChatListView {
|
|||||||
@ToolbarContentBuilder
|
@ToolbarContentBuilder
|
||||||
var toolbarContent: some ToolbarContent {
|
var toolbarContent: some ToolbarContent {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button {
|
Button { } label: {
|
||||||
// TODO: Edit mode
|
|
||||||
} label: {
|
|
||||||
Text("Edit")
|
Text("Edit")
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 17, weight: .medium))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
@@ -166,218 +139,41 @@ private extension ChatListView {
|
|||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
storiesAvatars
|
storiesAvatars
|
||||||
|
|
||||||
Text("Chats")
|
Text("Chats")
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
|
|
||||||
Image(systemName: "checkmark.seal.fill")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundStyle(RosettaColors.figmaBlue)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||||
Button {
|
HStack(spacing: 8) {
|
||||||
// TODO: Camera
|
Button { } label: {
|
||||||
} label: {
|
|
||||||
Image(systemName: "camera")
|
Image(systemName: "camera")
|
||||||
.font(.system(size: 18))
|
.font(.system(size: 16, weight: .regular))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Camera")
|
.accessibilityLabel("Camera")
|
||||||
|
Button { } label: {
|
||||||
Button {
|
|
||||||
// TODO: Compose new message
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "square.and.pencil")
|
Image(systemName: "square.and.pencil")
|
||||||
.font(.system(size: 18))
|
.font(.system(size: 17, weight: .regular))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
}
|
}
|
||||||
|
.padding(.bottom, 2)
|
||||||
.accessibilityLabel("New chat")
|
.accessibilityLabel("New chat")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var storiesAvatars: some View {
|
private var storiesAvatars: some View {
|
||||||
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
|
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
|
||||||
let initials = RosettaColors.initials(name: SessionManager.shared.displayName, publicKey: pk)
|
let initials = RosettaColors.initials(
|
||||||
|
name: SessionManager.shared.displayName, publicKey: pk
|
||||||
|
)
|
||||||
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
|
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
|
||||||
|
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
|
||||||
ZStack {
|
|
||||||
AvatarView(initials: initials, colorIndex: colorIdx, size: 28)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Server Search Results
|
#Preview { ChatListView(isSearchActive: .constant(false)) }
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ChatListView()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
// MARK: - ChatListViewModel
|
// MARK: - ChatListViewModel
|
||||||
|
|
||||||
@Observable
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ChatListViewModel {
|
final class ChatListViewModel: ObservableObject {
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "ChatListVM")
|
||||||
|
|
||||||
// MARK: - State
|
// MARK: - State
|
||||||
|
|
||||||
private(set) var isLoading = false
|
@Published var isLoading = false
|
||||||
private(set) var searchQuery = ""
|
@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 searchTask: Task<Void, Never>?
|
||||||
private var lastSearchedText = ""
|
private var lastSearchedText = ""
|
||||||
|
private static let maxRecent = 20
|
||||||
|
|
||||||
|
private var recentKey: String {
|
||||||
|
"rosetta_recent_searches_\(SessionManager.shared.currentPublicKey)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
loadRecentSearches()
|
||||||
setupSearchCallback()
|
setupSearchCallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +36,6 @@ final class ChatListViewModel {
|
|||||||
|
|
||||||
var filteredDialogs: [Dialog] {
|
var filteredDialogs: [Dialog] {
|
||||||
var result = DialogRepository.shared.sortedDialogs
|
var result = DialogRepository.shared.sortedDialogs
|
||||||
|
|
||||||
let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased()
|
let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased()
|
||||||
if !query.isEmpty {
|
if !query.isEmpty {
|
||||||
result = result.filter {
|
result = result.filter {
|
||||||
@@ -34,17 +44,11 @@ final class ChatListViewModel {
|
|||||||
|| $0.lastMessage.lowercased().contains(query)
|
|| $0.lastMessage.lowercased().contains(query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
var pinnedDialogs: [Dialog] {
|
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
|
||||||
filteredDialogs.filter(\.isPinned)
|
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
|
||||||
}
|
|
||||||
|
|
||||||
var unpinnedDialogs: [Dialog] {
|
|
||||||
filteredDialogs.filter { !$0.isPinned }
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalUnreadCount: Int {
|
var totalUnreadCount: Int {
|
||||||
DialogRepository.shared.sortedDialogs
|
DialogRepository.shared.sortedDialogs
|
||||||
@@ -54,12 +58,6 @@ final class ChatListViewModel {
|
|||||||
|
|
||||||
var hasUnread: Bool { totalUnreadCount > 0 }
|
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
|
// MARK: - Actions
|
||||||
|
|
||||||
func setSearchQuery(_ query: String) {
|
func setSearchQuery(_ query: String) {
|
||||||
@@ -85,6 +83,30 @@ final class ChatListViewModel {
|
|||||||
|
|
||||||
// MARK: - Server Search
|
// 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() {
|
private func triggerServerSearch() {
|
||||||
searchTask?.cancel()
|
searchTask?.cancel()
|
||||||
searchTask = nil
|
searchTask = nil
|
||||||
@@ -97,15 +119,11 @@ final class ChatListViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if trimmed == lastSearchedText {
|
if trimmed == lastSearchedText { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isServerSearching = true
|
isServerSearching = true
|
||||||
|
|
||||||
searchTask = Task { [weak self] in
|
searchTask = Task { [weak self] in
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
|
||||||
guard let self, !Task.isCancelled else { return }
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
|
||||||
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
||||||
@@ -113,41 +131,65 @@ final class ChatListViewModel {
|
|||||||
|
|
||||||
let connState = ProtocolManager.shared.connectionState
|
let connState = ProtocolManager.shared.connectionState
|
||||||
let hash = SessionManager.shared.privateKeyHash
|
let hash = SessionManager.shared.privateKeyHash
|
||||||
print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'")
|
|
||||||
|
|
||||||
guard connState == .authenticated, let hash else {
|
guard connState == .authenticated, let hash else {
|
||||||
print("[Search] NOT AUTHENTICATED - aborting")
|
|
||||||
self.isServerSearching = false
|
self.isServerSearching = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.lastSearchedText = currentQuery
|
self.lastSearchedText = currentQuery
|
||||||
|
|
||||||
var packet = PacketSearch()
|
var packet = PacketSearch()
|
||||||
packet.privateKey = hash
|
packet.privateKey = hash
|
||||||
packet.search = currentQuery
|
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)
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupSearchCallback() {
|
// MARK: - Recent Searches
|
||||||
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
|
|
||||||
|
|
||||||
for user in packet.users {
|
func addToRecent(_ user: SearchUser) {
|
||||||
DialogRepository.shared.updateUserInfo(
|
let recent = RecentSearch(
|
||||||
publicKey: user.publicKey,
|
publicKey: user.publicKey,
|
||||||
title: user.title,
|
title: user.title,
|
||||||
username: user.username
|
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
|
// MARK: - ChatRowView
|
||||||
|
|
||||||
/// Chat row matching Figma spec:
|
/// Chat row matching Figma "Row - Chats" component spec:
|
||||||
/// Row: paddingLeft=10, paddingRight=16, height=78
|
/// Row: height 78, paddingLeft 10, paddingRight 16, vertical center
|
||||||
/// Avatar: 62px + 10pt right padding
|
/// Avatar: 62px circle, 10pt trailing padding
|
||||||
/// Title: SFPro-Medium 17pt, message: SFPro-Regular 15pt
|
/// Title: SF Pro Medium 17pt, tracking -0.43, primary color
|
||||||
/// Time: SFPro-Regular 14pt, subtitle color: #3C3C43/60%
|
/// 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 {
|
struct ChatRowView: View {
|
||||||
let dialog: Dialog
|
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 {
|
private extension ChatRowView {
|
||||||
var contentSection: some View {
|
var contentSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
HStack(alignment: .center, spacing: 6) {
|
||||||
Spacer(minLength: 0)
|
// Left column: title + message
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
titleRow
|
titleRow
|
||||||
Spacer().frame(height: 3)
|
messageRow
|
||||||
subtitleRow
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
private extension ChatRowView {
|
||||||
var titleRow: some View {
|
var titleRow: some View {
|
||||||
@@ -63,10 +72,11 @@ private extension ChatRowView {
|
|||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
if dialog.isVerified {
|
if !dialog.isSavedMessages && dialog.effectiveVerified > 0 {
|
||||||
Image(systemName: "checkmark.seal.fill")
|
VerifiedBadge(
|
||||||
.font(.system(size: 14))
|
verified: dialog.effectiveVerified,
|
||||||
.foregroundStyle(RosettaColors.figmaBlue)
|
size: 12
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dialog.isMuted {
|
if dialog.isMuted {
|
||||||
@@ -74,39 +84,58 @@ private extension ChatRowView {
|
|||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(minLength: 4)
|
// MARK: - Message Row
|
||||||
|
|
||||||
|
private extension ChatRowView {
|
||||||
|
var messageRow: some View {
|
||||||
|
Text(messageText)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.tracking(-0.23)
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageText: String {
|
||||||
|
if dialog.lastMessage.isEmpty {
|
||||||
|
return "No messages yet"
|
||||||
|
}
|
||||||
|
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 {
|
if dialog.lastMessageFromMe && !dialog.isSavedMessages {
|
||||||
deliveryIcon
|
deliveryIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(formattedTime)
|
Text(formattedTime)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
|
.tracking(-0.23)
|
||||||
.foregroundStyle(
|
.foregroundStyle(
|
||||||
dialog.unreadCount > 0 && !dialog.isMuted
|
dialog.unreadCount > 0 && !dialog.isMuted
|
||||||
? RosettaColors.figmaBlue
|
? RosettaColors.figmaBlue
|
||||||
: RosettaColors.Adaptive.textSecondary
|
: RosettaColors.Adaptive.textSecondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
.padding(.top, 2)
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Subtitle Row (message + pin + badge)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
// Bottom: pin or unread badge
|
||||||
|
HStack(spacing: 8) {
|
||||||
if dialog.isPinned && dialog.unreadCount == 0 {
|
if dialog.isPinned && dialog.unreadCount == 0 {
|
||||||
Image(systemName: "pin.fill")
|
Image(systemName: "pin.fill")
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||||
.rotationEffect(.degrees(45))
|
.rotationEffect(.degrees(45))
|
||||||
}
|
}
|
||||||
@@ -115,13 +144,8 @@ private extension ChatRowView {
|
|||||||
unreadBadge
|
unreadBadge
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.bottom, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
var messageText: String {
|
|
||||||
if dialog.lastMessage.isEmpty {
|
|
||||||
return "No messages yet"
|
|
||||||
}
|
|
||||||
return dialog.lastMessage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -160,9 +184,11 @@ private extension ChatRowView {
|
|||||||
|
|
||||||
return Text(text)
|
return Text(text)
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
|
.tracking(-0.23)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.frame(minWidth: 20, minHeight: 20)
|
.frame(minWidth: 20, minHeight: 20)
|
||||||
|
.frame(maxWidth: 37)
|
||||||
.background {
|
.background {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
|
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
|
||||||
@@ -208,7 +234,7 @@ private extension ChatRowView {
|
|||||||
lastMessage: "Hey, how are you?",
|
lastMessage: "Hey, how are you?",
|
||||||
lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000),
|
lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000),
|
||||||
unreadCount: 3, isOnline: true, lastSeen: 0,
|
unreadCount: 3, isOnline: true, lastSeen: 0,
|
||||||
isVerified: true, iHaveSent: true,
|
verified: 1, iHaveSent: true,
|
||||||
isPinned: false, isMuted: false,
|
isPinned: false, isMuted: false,
|
||||||
lastMessageFromMe: true, lastMessageDelivered: .read
|
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 {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if searchText.isEmpty {
|
if searchText.isEmpty {
|
||||||
|
favoriteContactsRow
|
||||||
recentSection
|
recentSection
|
||||||
} else {
|
} else {
|
||||||
searchResultsContent
|
searchResultsContent
|
||||||
@@ -28,9 +29,13 @@ struct SearchView: View {
|
|||||||
searchBar
|
searchBar
|
||||||
}
|
}
|
||||||
.onChange(of: searchText) { _, newValue in
|
.onChange(of: searchText) { _, newValue in
|
||||||
print("[SearchView] onChange fired: '\(newValue)'")
|
|
||||||
viewModel.setSearchQuery(newValue)
|
viewModel.setSearchQuery(newValue)
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
// Auto-focus search field when the view appears
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
isSearchFocused = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,22 +116,45 @@ private extension SearchView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Glass Search Bar Modifier
|
|
||||||
|
|
||||||
private struct GlassSearchBarModifier: ViewModifier {
|
// MARK: - Favorite Contacts (Figma: horizontal scroll at top)
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if #available(iOS 26, *) {
|
private extension SearchView {
|
||||||
content
|
@ViewBuilder
|
||||||
.glassEffect(.regular, in: .capsule)
|
var favoriteContactsRow: some View {
|
||||||
} else {
|
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
|
||||||
content
|
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)
|
||||||
|
}
|
||||||
private extension View {
|
.padding(.top, 12)
|
||||||
func applyGlassSearchBar() -> some View {
|
}
|
||||||
modifier(GlassSearchBarModifier())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,93 +271,15 @@ private extension SearchView {
|
|||||||
// MARK: - Search Results Content
|
// MARK: - Search Results Content
|
||||||
|
|
||||||
private extension SearchView {
|
private extension SearchView {
|
||||||
@ViewBuilder
|
|
||||||
var searchResultsContent: some View {
|
var searchResultsContent: some View {
|
||||||
if viewModel.isSearching {
|
SearchResultsSection(
|
||||||
VStack(spacing: 12) {
|
isSearching: viewModel.isSearching,
|
||||||
Spacer().frame(height: 40)
|
searchResults: viewModel.searchResults,
|
||||||
ProgressView()
|
onSelectUser: { user in
|
||||||
.tint(RosettaColors.Adaptive.textSecondary)
|
|
||||||
Text("Searching...")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
||||||
}
|
|
||||||
.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)
|
viewModel.addToRecent(user)
|
||||||
// TODO: Navigate to ChatDetailView for user.publicKey
|
// 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 {
|
#Preview {
|
||||||
SearchView()
|
SearchView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ final class SearchViewModel {
|
|||||||
private var searchTask: Task<Void, Never>?
|
private var searchTask: Task<Void, Never>?
|
||||||
private var lastSearchedText = ""
|
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
|
private static let maxRecent = 20
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
@@ -42,7 +45,7 @@ final class SearchViewModel {
|
|||||||
// MARK: - Search Logic
|
// MARK: - Search Logic
|
||||||
|
|
||||||
func setSearchQuery(_ query: String) {
|
func setSearchQuery(_ query: String) {
|
||||||
print("[Search] setSearchQuery called: '\(query)'")
|
|
||||||
searchQuery = query
|
searchQuery = query
|
||||||
onSearchQueryChanged()
|
onSearchQueryChanged()
|
||||||
}
|
}
|
||||||
@@ -60,34 +63,34 @@ final class SearchViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if trimmed == lastSearchedText {
|
if trimmed == lastSearchedText {
|
||||||
print("[Search] Query unchanged, skipping")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isSearching = true
|
isSearching = true
|
||||||
print("[Search] Starting debounce for '\(trimmed)'")
|
|
||||||
|
|
||||||
// Debounce 1 second (like Android)
|
// Debounce 1 second (like Android)
|
||||||
searchTask = Task { [weak self] in
|
searchTask = Task { [weak self] in
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
|
||||||
guard let self, !Task.isCancelled else {
|
guard let self, !Task.isCancelled else {
|
||||||
print("[Search] Task cancelled during debounce")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
||||||
guard !currentQuery.isEmpty, currentQuery == trimmed else {
|
guard !currentQuery.isEmpty, currentQuery == trimmed else {
|
||||||
print("[Search] Query changed during debounce, aborting")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let connState = ProtocolManager.shared.connectionState
|
let connState = ProtocolManager.shared.connectionState
|
||||||
let hash = SessionManager.shared.privateKeyHash
|
let hash = SessionManager.shared.privateKeyHash
|
||||||
print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'")
|
|
||||||
|
|
||||||
guard connState == .authenticated, let hash else {
|
guard connState == .authenticated, let hash else {
|
||||||
print("[Search] NOT AUTHENTICATED - aborting search")
|
|
||||||
self.isSearching = false
|
self.isSearching = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,7 +100,7 @@ final class SearchViewModel {
|
|||||||
var packet = PacketSearch()
|
var packet = PacketSearch()
|
||||||
packet.privateKey = hash
|
packet.privateKey = hash
|
||||||
packet.search = currentQuery
|
packet.search = currentQuery
|
||||||
print("[Search] Sending PacketSearch for '\(currentQuery)' with hash prefix: \(String(hash.prefix(16)))...")
|
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,10 +117,8 @@ final class SearchViewModel {
|
|||||||
// MARK: - Search Callback
|
// MARK: - Search Callback
|
||||||
|
|
||||||
private func setupSearchCallback() {
|
private func setupSearchCallback() {
|
||||||
print("[Search] Setting up search callback on ProtocolManager")
|
|
||||||
ProtocolManager.shared.onSearchResult = { [weak self] packet in
|
ProtocolManager.shared.onSearchResult = { [weak self] packet in
|
||||||
print("[Search] CALLBACK: received \(packet.users.count) users")
|
DispatchQueue.main.async { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.searchResults = packet.users
|
self.searchResults = packet.users
|
||||||
self.isSearching = false
|
self.isSearching = false
|
||||||
@@ -127,7 +128,8 @@ final class SearchViewModel {
|
|||||||
DialogRepository.shared.updateUserInfo(
|
DialogRepository.shared.updateUserInfo(
|
||||||
publicKey: user.publicKey,
|
publicKey: user.publicKey,
|
||||||
title: user.title,
|
title: user.title,
|
||||||
username: user.username
|
username: user.username,
|
||||||
|
verified: user.verified
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,15 +168,23 @@ final class SearchViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadRecentSearches() {
|
private func loadRecentSearches() {
|
||||||
guard let data = UserDefaults.standard.data(forKey: Self.recentKey),
|
if let data = UserDefaults.standard.data(forKey: recentKey),
|
||||||
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) else {
|
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
|
||||||
|
recentSearches = list
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Migrate from old static key
|
||||||
|
let oldKey = "rosetta_recent_searches"
|
||||||
|
if let data = UserDefaults.standard.data(forKey: oldKey),
|
||||||
|
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
|
||||||
recentSearches = list
|
recentSearches = list
|
||||||
|
saveRecentSearches()
|
||||||
|
UserDefaults.standard.removeObject(forKey: oldKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveRecentSearches() {
|
private func saveRecentSearches() {
|
||||||
guard let data = try? JSONEncoder().encode(recentSearches) else { return }
|
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 {
|
struct MainTabView: View {
|
||||||
var onLogout: (() -> Void)?
|
var onLogout: (() -> Void)?
|
||||||
@State private var selectedTab: RosettaTab = .chats
|
@State private var selectedTab: RosettaTab = .chats
|
||||||
|
@State private var isChatSearchActive = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
@@ -13,24 +14,31 @@ struct MainTabView: View {
|
|||||||
Group {
|
Group {
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case .chats:
|
case .chats:
|
||||||
ChatListView()
|
ChatListView(isSearchActive: $isChatSearchActive)
|
||||||
|
.transition(.opacity)
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsView(onLogout: onLogout)
|
SettingsView(onLogout: onLogout)
|
||||||
|
.transition(.opacity)
|
||||||
case .search:
|
case .search:
|
||||||
SearchView()
|
SearchView()
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: selectedTab)
|
||||||
|
|
||||||
|
if !isChatSearchActive {
|
||||||
RosettaTabBar(
|
RosettaTabBar(
|
||||||
selectedTab: selectedTab,
|
selectedTab: selectedTab,
|
||||||
onTabSelected: { tab in
|
onTabSelected: { tab in
|
||||||
withAnimation(.easeInOut(duration: 0.15)) {
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
badges: tabBadges
|
badges: tabBadges
|
||||||
)
|
)
|
||||||
.ignoresSafeArea(.keyboard)
|
.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() {
|
init() {
|
||||||
UIWindow.appearance().backgroundColor = .systemBackground
|
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
|
// Preload Lottie animations early
|
||||||
Task.detached(priority: .userInitiated) {
|
Task.detached(priority: .userInitiated) {
|
||||||
LottieAnimationCache.shared.preload(OnboardingPages.all.map(\.animationName))
|
LottieAnimationCache.shared.preload(OnboardingPages.all.map(\.animationName))
|
||||||
@@ -25,6 +33,7 @@ struct RosettaApp: App {
|
|||||||
|
|
||||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||||
@AppStorage("isLoggedIn") private var isLoggedIn = false
|
@AppStorage("isLoggedIn") private var isLoggedIn = false
|
||||||
|
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
|
||||||
@State private var appState: AppState = .splash
|
@State private var appState: AppState = .splash
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -35,6 +44,7 @@ struct RosettaApp: App {
|
|||||||
|
|
||||||
rootView
|
rootView
|
||||||
}
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,21 +67,37 @@ struct RosettaApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case .auth:
|
case .auth:
|
||||||
AuthCoordinator {
|
AuthCoordinator(
|
||||||
|
onAuthComplete: {
|
||||||
withAnimation(.easeInOut(duration: 0.4)) {
|
withAnimation(.easeInOut(duration: 0.4)) {
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
// Start session automatically with the password from auth flow
|
|
||||||
appState = .main
|
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:
|
case .unlock:
|
||||||
UnlockView {
|
UnlockView(
|
||||||
|
onUnlocked: {
|
||||||
withAnimation(.easeInOut(duration: 0.4)) {
|
withAnimation(.easeInOut(duration: 0.4)) {
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
appState = .main
|
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:
|
case .main:
|
||||||
MainTabView(onLogout: {
|
MainTabView(onLogout: {
|
||||||
@@ -84,12 +110,12 @@ struct RosettaApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func determineNextState() {
|
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
|
// Existing user — unlock with password
|
||||||
appState = .unlock
|
appState = .unlock
|
||||||
} else if !hasCompletedOnboarding {
|
|
||||||
// New user — show onboarding first
|
|
||||||
appState = .onboarding
|
|
||||||
} else {
|
} else {
|
||||||
// Onboarding done but no account — go to auth
|
// Onboarding done but no account — go to auth
|
||||||
appState = .auth
|
appState = .auth
|
||||||
|
|||||||
Reference in New Issue
Block a user