encryptWithPassword возвращён к SHA256+rawDeflate (iOS-only данные)
Добавлен encryptWithPasswordDesktopCompat (SHA1+zlibDeflate) для кросс-платформенных данных (aesChachaKey, аватар) 3 вызова в SessionManager переведены на desktop-compatible путь Добавлен Notification.Name.profileDidUpdate для мгновенного обновления имени в Settings Удалены debug-логи из CryptoManager и SessionManager
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Rosetta uses Face ID to unlock your account securely without entering your password.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
21
Rosetta/Assets.xcassets/safe-avatar.imageset/Contents.json
vendored
Normal file
21
Rosetta/Assets.xcassets/safe-avatar.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "safe.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Rosetta/Assets.xcassets/safe-avatar.imageset/safe.png
vendored
Normal file
BIN
Rosetta/Assets.xcassets/safe-avatar.imageset/safe.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
301
Rosetta/Core/Crypto/BiometricAuthManager.swift
Normal file
301
Rosetta/Core/Crypto/BiometricAuthManager.swift
Normal file
@@ -0,0 +1,301 @@
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import Security
|
||||
|
||||
// MARK: - BiometricError
|
||||
|
||||
enum BiometricError: LocalizedError {
|
||||
case notAvailable
|
||||
case authenticationFailed
|
||||
case saveFailed(OSStatus)
|
||||
case loadFailed
|
||||
case cancelled
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAvailable:
|
||||
return "Biometric authentication is not available on this device."
|
||||
case .authenticationFailed:
|
||||
return "Biometric authentication failed."
|
||||
case .saveFailed(let status):
|
||||
return "Failed to save biometric data (OSStatus \(status))."
|
||||
case .loadFailed:
|
||||
return "Failed to load biometric data."
|
||||
case .cancelled:
|
||||
return "Biometric authentication was cancelled."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BiometricType
|
||||
|
||||
enum BiometricType {
|
||||
case faceID
|
||||
case touchID
|
||||
case none
|
||||
}
|
||||
|
||||
// MARK: - BiometricAuthManager
|
||||
|
||||
/// Manages biometric authentication (Face ID / Touch ID) for account unlock.
|
||||
///
|
||||
/// **Security model (iOS canonical, Secure Enclave):**
|
||||
/// - Password is stored in Keychain with `SecAccessControl(.biometryCurrentSet)`.
|
||||
/// - The Secure Enclave hardware protects the item — it is physically inaccessible
|
||||
/// without successful biometric authentication, even on jailbroken devices.
|
||||
/// - `.biometryCurrentSet` invalidates the item if biometric enrollment changes
|
||||
/// (new face/fingerprint added or all removed).
|
||||
/// - `.accessibleWhenPasscodeSetThisDeviceOnly` ensures the item is deleted
|
||||
/// if the user removes their device passcode.
|
||||
/// - Reading the item via `SecItemCopyMatching` with an `LAContext` automatically
|
||||
/// triggers the system Face ID / Touch ID prompt — no separate `evaluatePolicy` needed.
|
||||
///
|
||||
/// **Flow:**
|
||||
/// 1. User enables biometric in Settings → enters password → password saved to
|
||||
/// biometric-protected Keychain via `savePassword()`
|
||||
/// 2. On next unlock → `loadPassword()` triggers Face ID via Secure Enclave →
|
||||
/// system shows biometric prompt → on success, password is returned
|
||||
/// 3. Password is passed to `SessionManager.startSession(password:)`
|
||||
final class BiometricAuthManager: @unchecked Sendable {
|
||||
|
||||
static let shared = BiometricAuthManager()
|
||||
private init() {}
|
||||
|
||||
private let keychainService = "com.rosetta.messenger.biometric"
|
||||
private let enabledKeyPrefix = "biometric_enabled_"
|
||||
|
||||
// MARK: - Availability
|
||||
|
||||
/// Returns the type of biometric authentication available on the device.
|
||||
var availableBiometricType: BiometricType {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
guard context.canEvaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
error: &error
|
||||
) else {
|
||||
return .none
|
||||
}
|
||||
switch context.biometryType {
|
||||
case .faceID: return .faceID
|
||||
case .touchID: return .touchID
|
||||
default: return .none
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any biometric authentication is available on the device.
|
||||
var isBiometricAvailable: Bool {
|
||||
availableBiometricType != .none
|
||||
}
|
||||
|
||||
/// Localized name for the available biometric type ("Face ID" / "Touch ID").
|
||||
var biometricName: String {
|
||||
switch availableBiometricType {
|
||||
case .faceID: return "Face ID"
|
||||
case .touchID: return "Touch ID"
|
||||
case .none: return "Biometric"
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbol name for the available biometric type.
|
||||
var biometricIconName: String {
|
||||
switch availableBiometricType {
|
||||
case .faceID: return "faceid"
|
||||
case .touchID: return "touchid"
|
||||
case .none: return "lock.open.fill"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enabled State (UserDefaults)
|
||||
|
||||
/// Whether biometric unlock is enabled for the given account.
|
||||
func isBiometricEnabled(forAccount publicKey: String) -> Bool {
|
||||
UserDefaults.standard.bool(forKey: enabledKeyPrefix + publicKey)
|
||||
}
|
||||
|
||||
/// Sets the biometric enabled flag for the given account.
|
||||
func setBiometricEnabled(_ enabled: Bool, forAccount publicKey: String) {
|
||||
UserDefaults.standard.set(enabled, forKey: enabledKeyPrefix + publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Password Storage (Keychain + Secure Enclave)
|
||||
|
||||
/// Saves the account password to Keychain with biometric protection.
|
||||
///
|
||||
/// The item is protected by `SecAccessControl` with `.biometryCurrentSet`,
|
||||
/// meaning it can only be read after successful Face ID / Touch ID authentication,
|
||||
/// and is invalidated if biometric enrollment changes.
|
||||
func savePassword(_ password: String, forAccount publicKey: String) throws {
|
||||
guard let data = password.data(using: .utf8) else { return }
|
||||
|
||||
let key = passwordKey(for: publicKey)
|
||||
|
||||
// Delete existing entry first
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
// Create biometric-protected access control (Secure Enclave)
|
||||
var accessError: Unmanaged<CFError>?
|
||||
guard let accessControl = SecAccessControlCreateWithFlags(
|
||||
nil,
|
||||
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
|
||||
.biometryCurrentSet,
|
||||
&accessError
|
||||
) else {
|
||||
throw BiometricError.saveFailed(-1)
|
||||
}
|
||||
|
||||
// Save with biometric protection — no LAContext needed for write,
|
||||
// biometric is only required on read.
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessControl as String: accessControl,
|
||||
]
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw BiometricError.saveFailed(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the stored password from Keychain. Triggers Face ID / Touch ID automatically
|
||||
/// via Secure Enclave — the system biometric prompt is shown as part of the Keychain read.
|
||||
///
|
||||
/// Must be called via `async` — runs on background thread because `SecItemCopyMatching`
|
||||
/// blocks while the biometric UI is displayed.
|
||||
func loadPassword(forAccount publicKey: String) async throws -> String {
|
||||
let key = passwordKey(for: publicKey)
|
||||
let serviceName = keychainService
|
||||
|
||||
return try await Task.detached(priority: .userInitiated) {
|
||||
let context = LAContext()
|
||||
context.localizedReason = "Unlock Rosetta"
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecUseAuthenticationContext as String: context,
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
guard let data = result as? Data,
|
||||
let password = String(data: data, encoding: .utf8) else {
|
||||
throw BiometricError.loadFailed
|
||||
}
|
||||
return password
|
||||
case errSecUserCanceled:
|
||||
throw BiometricError.cancelled
|
||||
case errSecAuthFailed:
|
||||
throw BiometricError.authenticationFailed
|
||||
case errSecItemNotFound:
|
||||
throw BiometricError.loadFailed
|
||||
default:
|
||||
throw BiometricError.loadFailed
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Deletes the stored password for the given account.
|
||||
func deletePassword(forAccount publicKey: String) {
|
||||
let key = passwordKey(for: publicKey)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
/// Whether a biometric-protected password exists for the given account.
|
||||
/// Uses `kSecUseAuthenticationUIFail` to check existence WITHOUT triggering Face ID.
|
||||
func hasStoredPassword(forAccount publicKey: String) -> Bool {
|
||||
let key = passwordKey(for: publicKey)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail,
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
// errSecInteractionNotAllowed = item exists but requires biometric auth
|
||||
return status == errSecInteractionNotAllowed || status == errSecSuccess
|
||||
}
|
||||
|
||||
// MARK: - Combined Unlock
|
||||
|
||||
/// Loads stored password with biometric authentication (triggers Face ID / Touch ID).
|
||||
/// The Secure Enclave handles the entire biometric flow — no separate `evaluatePolicy` needed.
|
||||
/// Returns the password string for use with `SessionManager.startSession(password:)`.
|
||||
func unlockWithBiometric(forAccount publicKey: String) async throws -> String {
|
||||
try await loadPassword(forAccount: publicKey)
|
||||
}
|
||||
|
||||
/// Whether biometric unlock can be used right now for the given account.
|
||||
func canUseBiometric(forAccount publicKey: String) -> Bool {
|
||||
isBiometricAvailable
|
||||
&& isBiometricEnabled(forAccount: publicKey)
|
||||
&& hasStoredPassword(forAccount: publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Standalone Authentication
|
||||
|
||||
/// Shows the system biometric prompt (Face ID / Touch ID) for identity confirmation.
|
||||
/// Use this when you need biometric verification without reading a Keychain item.
|
||||
func authenticate(reason: String = "Unlock Rosetta") async throws {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
|
||||
guard context.canEvaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
error: &error
|
||||
) else {
|
||||
throw BiometricError.notAvailable
|
||||
}
|
||||
|
||||
do {
|
||||
let success = try await context.evaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
localizedReason: reason
|
||||
)
|
||||
guard success else { throw BiometricError.authenticationFailed }
|
||||
} catch let laError as LAError {
|
||||
switch laError.code {
|
||||
case .userCancel, .appCancel, .systemCancel:
|
||||
throw BiometricError.cancelled
|
||||
case .biometryNotAvailable, .biometryNotEnrolled:
|
||||
throw BiometricError.notAvailable
|
||||
default:
|
||||
throw BiometricError.authenticationFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/// Removes all biometric data for the given account.
|
||||
func clearAll(forAccount publicKey: String) {
|
||||
deletePassword(forAccount: publicKey)
|
||||
setBiometricEnabled(false, forAccount: publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func passwordKey(for publicKey: String) -> String {
|
||||
"biometric_password_\(publicKey)"
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,10 @@ final class CryptoManager: @unchecked Sendable {
|
||||
return (privateKey, publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Account Encryption (PBKDF2 + zlib + AES-256-CBC)
|
||||
// MARK: - Account Encryption (PBKDF2-SHA256 + rawDeflate + AES-256-CBC)
|
||||
|
||||
/// iOS-only encryption for account keys, device identity, persistence snapshots.
|
||||
/// Uses PBKDF2-HMAC-SHA256 + raw deflate (no zlib wrapper).
|
||||
nonisolated func encryptWithPassword(_ data: Data, password: String) throws -> String {
|
||||
let compressed = try CryptoPrimitives.rawDeflate(data)
|
||||
let key = CryptoPrimitives.pbkdf2(
|
||||
@@ -88,6 +90,21 @@ final class CryptoManager: @unchecked Sendable {
|
||||
return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())"
|
||||
}
|
||||
|
||||
// MARK: - Desktop-Compatible Encryption (PBKDF2-SHA1 + zlibDeflate + AES-256-CBC)
|
||||
|
||||
/// Desktop parity: CryptoJS PBKDF2 defaults to HMAC-SHA1, pako.deflate() is zlib-wrapped.
|
||||
/// Use ONLY for cross-platform data: aesChachaKey, avatar blobs sent to server.
|
||||
nonisolated func encryptWithPasswordDesktopCompat(_ data: Data, password: String) throws -> String {
|
||||
let compressed = try CryptoPrimitives.zlibDeflate(data)
|
||||
let key = CryptoPrimitives.pbkdf2(
|
||||
password: password, salt: "rosetta", iterations: 1000,
|
||||
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
|
||||
)
|
||||
let iv = try CryptoPrimitives.randomBytes(count: 16)
|
||||
let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv)
|
||||
return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())"
|
||||
}
|
||||
|
||||
nonisolated func decryptWithPassword(_ encrypted: String, password: String) throws -> Data {
|
||||
let parts = encrypted.components(separatedBy: ":")
|
||||
guard parts.count == 2,
|
||||
@@ -96,19 +113,21 @@ final class CryptoManager: @unchecked Sendable {
|
||||
throw CryptoError.invalidData("Malformed encrypted string")
|
||||
}
|
||||
|
||||
// SHA256 first: all existing iOS data was encrypted with SHA256.
|
||||
// SHA1 fallback: desktop CryptoJS default + encryptWithPasswordDesktopCompat.
|
||||
// ⚠️ SHA256 MUST be first — wrong-key AES-CBC can randomly produce valid
|
||||
// PKCS7 padding (~1/256 chance) and garbage may survive zlib inflate,
|
||||
// causing false-positive decryption with corrupt data.
|
||||
let prfOrder: [CCPseudoRandomAlgorithm] = [
|
||||
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // Desktop/Android current
|
||||
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), // Legacy iOS
|
||||
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // iOS encryptWithPassword
|
||||
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), // Desktop / encryptWithPasswordDesktopCompat
|
||||
]
|
||||
|
||||
// 1) Preferred path: AES-CBC + zlib inflate
|
||||
// 1) Preferred path: AES-CBC + inflate (handles both rawDeflate and zlibDeflate)
|
||||
for prf in prfOrder {
|
||||
if let result = try? decryptWithPassword(
|
||||
ciphertext: ciphertext,
|
||||
iv: iv,
|
||||
password: password,
|
||||
prf: prf,
|
||||
expectsCompressed: true
|
||||
ciphertext: ciphertext, iv: iv, password: password,
|
||||
prf: prf, expectsCompressed: true
|
||||
) {
|
||||
return result
|
||||
}
|
||||
@@ -117,11 +136,8 @@ final class CryptoManager: @unchecked Sendable {
|
||||
// 2) Fallback: AES-CBC without compression (very old/legacy payloads)
|
||||
for prf in prfOrder {
|
||||
if let result = try? decryptWithPassword(
|
||||
ciphertext: ciphertext,
|
||||
iv: iv,
|
||||
password: password,
|
||||
prf: prf,
|
||||
expectsCompressed: false
|
||||
ciphertext: ciphertext, iv: iv, password: password,
|
||||
prf: prf, expectsCompressed: false
|
||||
) {
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -112,10 +112,41 @@ enum CryptoPrimitives {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - zlib Raw Deflate / Inflate
|
||||
// MARK: - zlib Deflate / Inflate
|
||||
|
||||
extension CryptoPrimitives {
|
||||
/// Raw deflate compression (no zlib header, compatible with pako.deflate / Java Deflater(nowrap=true)).
|
||||
|
||||
/// Zlib-wrapped deflate compression (0x78 header + raw deflate + adler32 trailer).
|
||||
/// Compatible with desktop `pako.deflate()` and Node.js `zlib.deflateSync()`.
|
||||
/// Desktop CryptoJS uses `pako.deflate()` which produces zlib-wrapped output;
|
||||
/// `pako.inflate()` on the desktop expects this format — raw deflate will fail.
|
||||
static func zlibDeflate(_ data: Data) throws -> Data {
|
||||
let raw = try rawDeflate(data)
|
||||
var result = Data(capacity: 2 + raw.count + 4)
|
||||
// zlib header: CMF=0x78 (deflate method, 32K window), FLG=0x9C (default level, check bits)
|
||||
result.append(contentsOf: [0x78, 0x9C] as [UInt8])
|
||||
result.append(raw)
|
||||
// Adler-32 checksum of the original uncompressed data (big-endian)
|
||||
let checksum = adler32(data)
|
||||
result.append(UInt8((checksum >> 24) & 0xFF))
|
||||
result.append(UInt8((checksum >> 16) & 0xFF))
|
||||
result.append(UInt8((checksum >> 8) & 0xFF))
|
||||
result.append(UInt8(checksum & 0xFF))
|
||||
return result
|
||||
}
|
||||
|
||||
/// Adler-32 checksum (used for zlib trailer).
|
||||
private static func adler32(_ data: Data) -> UInt32 {
|
||||
var a: UInt32 = 1
|
||||
var b: UInt32 = 0
|
||||
for byte in data {
|
||||
a = (a &+ UInt32(byte)) % 65521
|
||||
b = (b &+ a) % 65521
|
||||
}
|
||||
return (b << 16) | a
|
||||
}
|
||||
|
||||
/// Raw deflate compression (no zlib header, compatible with Java Deflater(nowrap=true)).
|
||||
static func rawDeflate(_ data: Data) throws -> Data {
|
||||
let sourceSize = data.count
|
||||
let destinationSize = sourceSize + 512
|
||||
@@ -132,14 +163,36 @@ extension CryptoPrimitives {
|
||||
return destination.prefix(compressedSize)
|
||||
}
|
||||
|
||||
/// Raw inflate decompression (no zlib header, compatible with pako.inflate / Java Inflater(nowrap=true)).
|
||||
/// Inflate decompression supporting both raw deflate and zlib-wrapped formats.
|
||||
/// Apple's `COMPRESSION_ZLIB` uses windowBits=-15 (raw deflate only).
|
||||
/// Desktop `pako.deflate()` / iOS `zlibDeflate()` produce zlib-wrapped data
|
||||
/// (0x78 header + raw deflate + adler32 trailer).
|
||||
///
|
||||
/// ⚠️ zlib-wrapped data MUST be stripped FIRST — `tryRawInflate` can produce
|
||||
/// garbage output from zlib header bytes (0x78 0x9C are valid but meaningless
|
||||
/// raw deflate instructions), causing false-positive decompression.
|
||||
static func rawInflate(_ data: Data) throws -> Data {
|
||||
// 1. Strip zlib wrapper FIRST if present (iOS zlibDeflate / desktop pako.deflate).
|
||||
// Must be tried before raw inflate to avoid false-positive decompression
|
||||
// where raw inflate interprets the zlib header as deflate instructions.
|
||||
if data.count > 6, data[data.startIndex] == 0x78 {
|
||||
let stripped = Data(data[(data.startIndex + 2) ..< (data.endIndex - 4)])
|
||||
if let result = tryRawInflate(stripped) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
// 2. Try raw deflate (legacy iOS rawDeflate / Android-produced)
|
||||
if let result = tryRawInflate(data) {
|
||||
return result
|
||||
}
|
||||
throw CryptoError.compressionFailed
|
||||
}
|
||||
|
||||
private static func tryRawInflate(_ data: Data) -> Data? {
|
||||
let sourceSize = data.count
|
||||
|
||||
for multiplier in [4, 8, 16, 32] {
|
||||
var destinationSize = max(sourceSize * multiplier, 256)
|
||||
let 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,
|
||||
@@ -147,12 +200,11 @@ extension CryptoPrimitives {
|
||||
return compression_decode_buffer(dBase, destinationSize, sBase, sourceSize, nil, COMPRESSION_ZLIB)
|
||||
}
|
||||
}
|
||||
|
||||
if decompressedSize > 0 && decompressedSize < destinationSize {
|
||||
if decompressedSize > 0, decompressedSize < destinationSize {
|
||||
return destination.prefix(decompressedSize)
|
||||
}
|
||||
}
|
||||
throw CryptoError.compressionFailed
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,18 @@ enum MessageCrypto {
|
||||
return try decryptWithKeyAndNonce(ciphertext: ciphertext, keyAndNonce: plainKeyAndNonce)
|
||||
}
|
||||
|
||||
/// Extract raw decrypted key+nonce data from an encrypted key string (ECDH path).
|
||||
/// Used for decrypting MESSAGES-type attachment blobs (desktop parity).
|
||||
static func extractDecryptedKeyData(
|
||||
encryptedKey: String,
|
||||
myPrivateKeyHex: String
|
||||
) -> Data? {
|
||||
guard let candidates = try? decryptKeyFromSenderCandidates(
|
||||
encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex
|
||||
) else { return nil }
|
||||
return candidates.first
|
||||
}
|
||||
|
||||
/// Android parity helper:
|
||||
/// `String(bytes, UTF_8)` (replacement for invalid sequences) + `toByteArray(ISO_8859_1)`.
|
||||
static func androidUtf8BytesToLatin1Bytes(_ utf8Bytes: Data) -> Data {
|
||||
|
||||
@@ -35,6 +35,11 @@ extension Account {
|
||||
|
||||
extension Account {
|
||||
enum KeychainKey {
|
||||
/// Legacy single-account Keychain key (kept for backward compatibility / migration).
|
||||
static let account = "currentAccount"
|
||||
/// Multi-account: array of all accounts stored in Keychain.
|
||||
static let allAccounts = "allAccounts"
|
||||
/// UserDefaults key tracking which account is currently active.
|
||||
static let activeAccountKey = "activeAccountPublicKey"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import Foundation
|
||||
// MARK: - System Accounts
|
||||
|
||||
enum SystemAccounts {
|
||||
static let safePublicKey = "0x000000000000000000000000000000000000000002"
|
||||
static let safePublicKey = Account.safePublicKey
|
||||
static let safeTitle = "Safe"
|
||||
static let updatesPublicKey = "0x000000000000000000000000000000000000000001"
|
||||
static let updatesPublicKey = Account.updatesPublicKey
|
||||
static let updatesTitle = "Rosetta Updates"
|
||||
static let systemKeys: Set<String> = [safePublicKey, updatesPublicKey]
|
||||
|
||||
|
||||
@@ -4,5 +4,26 @@ struct RecentSearch: Codable, Equatable, Sendable {
|
||||
let publicKey: String
|
||||
var title: String
|
||||
var username: String
|
||||
var lastSeenText: String
|
||||
/// Verification level from server (0 = not verified).
|
||||
var verified: Int = 0
|
||||
|
||||
// MARK: - Migration: ignore unknown keys from old persisted data
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case publicKey, title, username, verified
|
||||
}
|
||||
|
||||
init(publicKey: String, title: String, username: String, verified: Int = 0) {
|
||||
self.publicKey = publicKey
|
||||
self.title = title
|
||||
self.username = username
|
||||
self.verified = verified
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
publicKey = try container.decode(String.self, forKey: .publicKey)
|
||||
title = try container.decode(String.self, forKey: .title)
|
||||
username = try container.decode(String.self, forKey: .username)
|
||||
verified = (try? container.decode(Int.self, forKey: .verified)) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
136
Rosetta/Core/Data/Repositories/AvatarRepository.swift
Normal file
136
Rosetta/Core/Data/Repositories/AvatarRepository.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import UIKit
|
||||
|
||||
/// Manages avatar image storage on disk with in-memory cache.
|
||||
///
|
||||
/// Desktop parity: `AvatarProvider.tsx` stores avatars encrypted with
|
||||
/// `AVATAR_PASSWORD_TO_ENCODE = "rosetta-a"`. iOS relies on iOS Data Protection
|
||||
/// (sandbox encryption) instead — adding AES would add complexity without security benefit.
|
||||
///
|
||||
/// Storage: `Application Support/Rosetta/Avatars/{normalizedKey}.jpg`
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AvatarRepository {
|
||||
|
||||
static let shared = AvatarRepository()
|
||||
private init() {}
|
||||
|
||||
/// Incremented on every avatar save/remove — views that read this property
|
||||
/// will re-render and pick up the latest avatar from cache.
|
||||
private(set) var avatarVersion: UInt = 0
|
||||
|
||||
/// In-memory cache for decoded UIImages — keyed by normalized public key.
|
||||
private let cache = NSCache<NSString, UIImage>()
|
||||
|
||||
/// JPEG compression quality (0.8 = reasonable size for avatars).
|
||||
private let compressionQuality: CGFloat = 0.8
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Saves an avatar image for the given public key.
|
||||
/// Compresses to JPEG, writes to disk, updates cache.
|
||||
func saveAvatar(publicKey: String, image: UIImage) {
|
||||
let key = normalizedKey(publicKey)
|
||||
guard let data = image.jpegData(compressionQuality: compressionQuality) else { return }
|
||||
let url = avatarURL(for: key)
|
||||
ensureDirectoryExists()
|
||||
try? data.write(to: url, options: .atomic)
|
||||
cache.setObject(image, forKey: key as NSString)
|
||||
avatarVersion += 1
|
||||
}
|
||||
|
||||
/// Saves an avatar from a base64-encoded image string (used when receiving from network).
|
||||
/// Desktop parity: re-encodes with `AVATAR_PASSWORD_TO_ENCODE` — iOS stores as plain JPEG.
|
||||
func saveAvatarFromBase64(_ base64: String, publicKey: String) {
|
||||
guard let data = Data(base64Encoded: base64),
|
||||
let image = UIImage(data: data) else { return }
|
||||
saveAvatar(publicKey: publicKey, image: image)
|
||||
}
|
||||
|
||||
/// Loads avatar for the given public key.
|
||||
/// System accounts return a bundled static avatar (desktop parity).
|
||||
/// Regular accounts check cache first, then disk.
|
||||
func loadAvatar(publicKey: String) -> UIImage? {
|
||||
// Desktop parity: system accounts have hardcoded avatars
|
||||
if let systemAvatar = systemAccountAvatar(for: publicKey) {
|
||||
return systemAvatar
|
||||
}
|
||||
let key = normalizedKey(publicKey)
|
||||
if let cached = cache.object(forKey: key as NSString) {
|
||||
return cached
|
||||
}
|
||||
let url = avatarURL(for: key)
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let image = UIImage(data: data) else {
|
||||
return nil
|
||||
}
|
||||
cache.setObject(image, forKey: key as NSString)
|
||||
return image
|
||||
}
|
||||
|
||||
/// Returns bundled avatar for system accounts, nil for regular accounts.
|
||||
private func systemAccountAvatar(for publicKey: String) -> UIImage? {
|
||||
if publicKey == SystemAccounts.safePublicKey {
|
||||
return UIImage(named: "safe-avatar")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns base64-encoded JPEG string for sending over the network.
|
||||
/// Desktop parity: `imagePrepareForNetworkTransfer()` returns base64.
|
||||
func loadAvatarBase64(publicKey: String) -> String? {
|
||||
guard let image = loadAvatar(publicKey: publicKey),
|
||||
let data = image.jpegData(compressionQuality: compressionQuality) else {
|
||||
return nil
|
||||
}
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
/// Removes the avatar for the given public key from disk and cache.
|
||||
func removeAvatar(publicKey: String) {
|
||||
let key = normalizedKey(publicKey)
|
||||
cache.removeObject(forKey: key as NSString)
|
||||
let url = avatarURL(for: key)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
avatarVersion += 1
|
||||
}
|
||||
|
||||
/// Clears entire avatar cache (used on full data reset).
|
||||
func clearAll() {
|
||||
cache.removeAllObjects()
|
||||
if let directory = avatarsDirectory {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
avatarVersion += 1
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var avatarsDirectory: URL? {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
|
||||
.first?
|
||||
.appendingPathComponent("Rosetta/Avatars", isDirectory: true)
|
||||
}
|
||||
|
||||
private func avatarURL(for normalizedKey: String) -> URL {
|
||||
avatarsDirectory!
|
||||
.appendingPathComponent("\(normalizedKey).jpg")
|
||||
}
|
||||
|
||||
private func normalizedKey(_ publicKey: String) -> String {
|
||||
publicKey
|
||||
.replacingOccurrences(of: "0x", with: "")
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
private func ensureDirectoryExists() {
|
||||
guard let directory = avatarsDirectory else { return }
|
||||
if !FileManager.default.fileExists(atPath: directory.path) {
|
||||
try? FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,11 @@ actor ChatPersistenceStore {
|
||||
let fileURL = rootDirectory.appendingPathComponent(fileName, isDirectory: false)
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
if let password,
|
||||
let encryptedSnapshot = String(data: data, encoding: .utf8),
|
||||
if let password {
|
||||
guard let encryptedSnapshot = String(data: data, encoding: .utf8),
|
||||
let decrypted = try? CryptoManager.shared.decryptWithPassword(encryptedSnapshot, password: password),
|
||||
let decoded = try? decoder.decode(type, from: decrypted) {
|
||||
let decoded = try? decoder.decode(type, from: decrypted)
|
||||
else { return nil }
|
||||
return decoded
|
||||
}
|
||||
return try? decoder.decode(type, from: data)
|
||||
|
||||
@@ -14,16 +14,37 @@ final class DialogRepository {
|
||||
private var currentAccount: String = ""
|
||||
private var storagePassword: String = ""
|
||||
private var persistTask: Task<Void, Never>?
|
||||
private var _sortedDialogsCache: [Dialog]?
|
||||
|
||||
// MARK: - Sort Caches
|
||||
|
||||
/// Cached sort order (opponent keys). Invalidated only when sort-affecting
|
||||
/// fields change (isPinned, lastMessageTimestamp, dialog added/removed).
|
||||
/// Non-sort mutations (delivery status, online, unread) preserve this cache,
|
||||
/// downgrading the sort cost from O(n log n) to O(n) compactMap.
|
||||
@ObservationIgnored private var _sortedKeysCache: [String]?
|
||||
|
||||
/// Cached sorted dialog array. Invalidated on every `dialogs` mutation via didSet.
|
||||
/// Multiple reads within the same SwiftUI body evaluation return this reference.
|
||||
@ObservationIgnored private var _sortedDialogsCache: [Dialog]?
|
||||
|
||||
var sortedDialogs: [Dialog] {
|
||||
if let cached = _sortedDialogsCache { return cached }
|
||||
let sorted = Array(dialogs.values).sorted {
|
||||
|
||||
let result: [Dialog]
|
||||
if let keys = _sortedKeysCache {
|
||||
// Sort order still valid — rebuild values from fresh dialogs (O(n) lookups).
|
||||
result = keys.compactMap { dialogs[$0] }
|
||||
} else {
|
||||
// Full re-sort needed (O(n log n)) — only when sort-affecting fields changed.
|
||||
result = Array(dialogs.values).sorted {
|
||||
if $0.isPinned != $1.isPinned { return $0.isPinned }
|
||||
return $0.lastMessageTimestamp > $1.lastMessageTimestamp
|
||||
}
|
||||
_sortedDialogsCache = sorted
|
||||
return sorted
|
||||
_sortedKeysCache = result.map(\.opponentKey)
|
||||
}
|
||||
|
||||
_sortedDialogsCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
private init() {}
|
||||
@@ -57,12 +78,14 @@ final class DialogRepository {
|
||||
.filter { $0.account == account }
|
||||
.map { ($0.opponentKey, $0) }
|
||||
)
|
||||
_sortedKeysCache = nil
|
||||
}
|
||||
|
||||
func reset(clearPersisted: Bool = false) {
|
||||
persistTask?.cancel()
|
||||
persistTask = nil
|
||||
dialogs.removeAll()
|
||||
_sortedKeysCache = nil
|
||||
storagePassword = ""
|
||||
|
||||
guard !currentAccount.isEmpty else { return }
|
||||
@@ -83,6 +106,7 @@ final class DialogRepository {
|
||||
currentAccount = dialog.account
|
||||
}
|
||||
dialogs[dialog.opponentKey] = dialog
|
||||
_sortedKeysCache = nil
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
@@ -137,6 +161,7 @@ final class DialogRepository {
|
||||
}
|
||||
|
||||
dialogs[opponentKey] = dialog
|
||||
_sortedKeysCache = nil
|
||||
schedulePersist()
|
||||
|
||||
// Desktop parity: re-evaluate request status based on last N messages.
|
||||
@@ -151,15 +176,20 @@ final class DialogRepository {
|
||||
myPublicKey: String
|
||||
) {
|
||||
if var existing = dialogs[opponentKey] {
|
||||
if !title.isEmpty {
|
||||
var changed = false
|
||||
if !title.isEmpty, existing.opponentTitle != title {
|
||||
existing.opponentTitle = title
|
||||
changed = true
|
||||
}
|
||||
if !username.isEmpty {
|
||||
if !username.isEmpty, existing.opponentUsername != username {
|
||||
existing.opponentUsername = username
|
||||
changed = true
|
||||
}
|
||||
if verified > existing.verified {
|
||||
existing.verified = verified
|
||||
changed = true
|
||||
}
|
||||
guard changed else { return }
|
||||
dialogs[opponentKey] = existing
|
||||
schedulePersist()
|
||||
return
|
||||
@@ -183,11 +213,13 @@ final class DialogRepository {
|
||||
lastMessageFromMe: false,
|
||||
lastMessageDelivered: .waiting
|
||||
)
|
||||
_sortedKeysCache = nil
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
func updateOnlineState(publicKey: String, isOnline: Bool) {
|
||||
guard var dialog = dialogs[publicKey] else { return }
|
||||
guard dialog.isOnline != isOnline else { return }
|
||||
dialog.isOnline = isOnline
|
||||
if !isOnline {
|
||||
dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
@@ -219,15 +251,30 @@ final class DialogRepository {
|
||||
|
||||
func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0, online: Int = -1) {
|
||||
guard var dialog = dialogs[publicKey] else { return }
|
||||
if !title.isEmpty { dialog.opponentTitle = title }
|
||||
if !username.isEmpty { dialog.opponentUsername = username }
|
||||
if verified > 0 { dialog.verified = max(dialog.verified, verified) }
|
||||
var changed = false
|
||||
if !title.isEmpty, dialog.opponentTitle != title {
|
||||
dialog.opponentTitle = title
|
||||
changed = true
|
||||
}
|
||||
if !username.isEmpty, dialog.opponentUsername != username {
|
||||
dialog.opponentUsername = username
|
||||
changed = true
|
||||
}
|
||||
if verified > 0, dialog.verified < verified {
|
||||
dialog.verified = verified
|
||||
changed = true
|
||||
}
|
||||
// Server protocol: 0 = ONLINE, 1 = OFFLINE (matches desktop OnlineState enum)
|
||||
// -1 = not provided (don't update)
|
||||
if online >= 0 {
|
||||
dialog.isOnline = online == 0
|
||||
if !dialog.isOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) }
|
||||
let newOnline = online == 0
|
||||
if dialog.isOnline != newOnline {
|
||||
dialog.isOnline = newOnline
|
||||
if !newOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) }
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
guard changed else { return }
|
||||
dialogs[publicKey] = dialog
|
||||
schedulePersist()
|
||||
}
|
||||
@@ -242,15 +289,15 @@ final class DialogRepository {
|
||||
|
||||
func markOutgoingAsRead(opponentKey: String) {
|
||||
guard var dialog = dialogs[opponentKey] else { return }
|
||||
if dialog.lastMessageFromMe {
|
||||
guard dialog.lastMessageFromMe, dialog.lastMessageDelivered != .read else { return }
|
||||
dialog.lastMessageDelivered = .read
|
||||
}
|
||||
dialogs[opponentKey] = dialog
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
func deleteDialog(opponentKey: String) {
|
||||
dialogs.removeValue(forKey: opponentKey)
|
||||
_sortedKeysCache = nil
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
@@ -262,6 +309,7 @@ final class DialogRepository {
|
||||
|
||||
guard let lastMsg = messages.last else {
|
||||
dialogs.removeValue(forKey: opponentKey)
|
||||
_sortedKeysCache = nil
|
||||
schedulePersist()
|
||||
return
|
||||
}
|
||||
@@ -271,6 +319,7 @@ final class DialogRepository {
|
||||
dialog.lastMessageFromMe = lastMsg.fromPublicKey == currentAccount
|
||||
dialog.lastMessageDelivered = lastMsg.deliveryStatus
|
||||
dialogs[opponentKey] = dialog
|
||||
_sortedKeysCache = nil
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
@@ -305,6 +354,7 @@ final class DialogRepository {
|
||||
let messages = MessageRepository.shared.messages(for: opponentKey)
|
||||
if messages.isEmpty {
|
||||
dialogs.removeValue(forKey: opponentKey)
|
||||
_sortedKeysCache = nil
|
||||
schedulePersist()
|
||||
}
|
||||
}
|
||||
@@ -322,6 +372,7 @@ final class DialogRepository {
|
||||
|
||||
dialog.isPinned.toggle()
|
||||
dialogs[opponentKey] = dialog
|
||||
_sortedKeysCache = nil
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
@@ -390,7 +441,7 @@ final class DialogRepository {
|
||||
let storagePassword = self.storagePassword
|
||||
persistTask?.cancel()
|
||||
persistTask = Task(priority: .utility) {
|
||||
try? await Task.sleep(for: .milliseconds(180))
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard !Task.isCancelled else { return }
|
||||
await ChatPersistenceStore.shared.save(
|
||||
snapshot,
|
||||
|
||||
@@ -5,14 +5,15 @@ import Combine
|
||||
@MainActor
|
||||
final class MessageRepository: ObservableObject {
|
||||
static let shared = MessageRepository()
|
||||
// Desktop parity: MESSAGE_MAX_LOADED = 40 per dialog.
|
||||
private let maxMessagesPerDialog = ProtocolConstants.messageMaxCached
|
||||
|
||||
@Published private(set) var messagesByDialog: [String: [ChatMessage]] = [:]
|
||||
@Published private(set) var typingDialogs: Set<String> = []
|
||||
|
||||
private var activeDialogs: Set<String> = []
|
||||
private var messageToDialog: [String: String] = [:]
|
||||
/// Persistent set of all message IDs ever seen — survives cap eviction.
|
||||
/// Prevents duplicate messages during repeated sync cycles.
|
||||
private var allKnownMessageIds: Set<String> = []
|
||||
private var typingResetTasks: [String: Task<Void, Never>] = [:]
|
||||
private var persistTask: Task<Void, Never>?
|
||||
private var currentAccount: String = ""
|
||||
@@ -44,6 +45,7 @@ final class MessageRepository: ObservableObject {
|
||||
}
|
||||
typingResetTasks.removeAll()
|
||||
messageToDialog.removeAll()
|
||||
allKnownMessageIds.removeAll()
|
||||
|
||||
let fileName = Self.messagesFileName(for: account)
|
||||
let stored = await ChatPersistenceStore.shared.load(
|
||||
@@ -59,14 +61,20 @@ final class MessageRepository: ObservableObject {
|
||||
}
|
||||
return $0.id < $1.id
|
||||
}
|
||||
if sorted.count > maxMessagesPerDialog {
|
||||
sorted = Array(sorted.suffix(maxMessagesPerDialog))
|
||||
}
|
||||
restored[dialogKey] = sorted
|
||||
for message in sorted {
|
||||
messageToDialog[message.id] = dialogKey
|
||||
allKnownMessageIds.insert(message.id)
|
||||
}
|
||||
}
|
||||
// Restore persisted known IDs (includes evicted message IDs)
|
||||
if let savedIds = await ChatPersistenceStore.shared.load(
|
||||
Set<String>.self,
|
||||
fileName: Self.knownIdsFileName(for: account),
|
||||
password: storagePassword
|
||||
) {
|
||||
allKnownMessageIds.formUnion(savedIds)
|
||||
}
|
||||
messagesByDialog = restored
|
||||
}
|
||||
|
||||
@@ -81,7 +89,7 @@ final class MessageRepository: ObservableObject {
|
||||
}
|
||||
|
||||
func hasMessage(_ messageId: String) -> Bool {
|
||||
messageToDialog[messageId] != nil
|
||||
allKnownMessageIds.contains(messageId)
|
||||
}
|
||||
|
||||
func isLatestMessage(_ messageId: String, in dialogKey: String) -> Bool {
|
||||
@@ -123,6 +131,7 @@ final class MessageRepository: ObservableObject {
|
||||
let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered)
|
||||
|
||||
messageToDialog[messageId] = dialogKey
|
||||
allKnownMessageIds.insert(messageId)
|
||||
|
||||
updateMessages(for: dialogKey) { messages in
|
||||
if let existingIndex = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
@@ -291,6 +300,7 @@ final class MessageRepository: ObservableObject {
|
||||
typingDialogs.removeAll()
|
||||
activeDialogs.removeAll()
|
||||
messageToDialog.removeAll()
|
||||
allKnownMessageIds.removeAll()
|
||||
storagePassword = ""
|
||||
|
||||
guard !currentAccount.isEmpty else { return }
|
||||
@@ -298,9 +308,11 @@ final class MessageRepository: ObservableObject {
|
||||
currentAccount = ""
|
||||
|
||||
guard clearPersisted else { return }
|
||||
let fileName = Self.messagesFileName(for: accountToReset)
|
||||
let messagesFile = Self.messagesFileName(for: accountToReset)
|
||||
let knownIdsFile = Self.knownIdsFileName(for: accountToReset)
|
||||
Task(priority: .utility) {
|
||||
await ChatPersistenceStore.shared.remove(fileName: fileName)
|
||||
await ChatPersistenceStore.shared.remove(fileName: messagesFile)
|
||||
await ChatPersistenceStore.shared.remove(fileName: knownIdsFile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,14 +331,6 @@ final class MessageRepository: ObservableObject {
|
||||
return $0.id < $1.id
|
||||
}
|
||||
}
|
||||
if messages.count > maxMessagesPerDialog {
|
||||
let overflow = messages.count - maxMessagesPerDialog
|
||||
let dropped = messages.prefix(overflow)
|
||||
for message in dropped {
|
||||
messageToDialog.removeValue(forKey: message.id)
|
||||
}
|
||||
messages.removeFirst(overflow)
|
||||
}
|
||||
messagesByDialog[dialogKey] = messages
|
||||
schedulePersist()
|
||||
}
|
||||
@@ -340,21 +344,25 @@ final class MessageRepository: ObservableObject {
|
||||
guard !currentAccount.isEmpty else { return }
|
||||
|
||||
let snapshot = messagesByDialog
|
||||
let fileName = Self.messagesFileName(for: currentAccount)
|
||||
let idsSnapshot = allKnownMessageIds
|
||||
let messagesFile = Self.messagesFileName(for: currentAccount)
|
||||
let knownIdsFile = Self.knownIdsFileName(for: currentAccount)
|
||||
let storagePassword = self.storagePassword
|
||||
let password = storagePassword.isEmpty ? nil : storagePassword
|
||||
persistTask?.cancel()
|
||||
persistTask = Task(priority: .utility) {
|
||||
try? await Task.sleep(for: .milliseconds(220))
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
guard !Task.isCancelled else { return }
|
||||
await ChatPersistenceStore.shared.save(
|
||||
snapshot,
|
||||
fileName: fileName,
|
||||
password: storagePassword.isEmpty ? nil : storagePassword
|
||||
)
|
||||
await ChatPersistenceStore.shared.save(snapshot, fileName: messagesFile, password: password)
|
||||
await ChatPersistenceStore.shared.save(idsSnapshot, fileName: knownIdsFile, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
private static func messagesFileName(for accountPublicKey: String) -> String {
|
||||
ChatPersistenceStore.accountScopedFileName(prefix: "messages", accountPublicKey: accountPublicKey)
|
||||
}
|
||||
|
||||
private static func knownIdsFileName(for accountPublicKey: String) -> String {
|
||||
ChatPersistenceStore.accountScopedFileName(prefix: "known_ids", accountPublicKey: accountPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ final class RecentSearchesRepository: ObservableObject {
|
||||
publicKey: user.publicKey,
|
||||
title: user.title,
|
||||
username: user.username,
|
||||
lastSeenText: user.online == 0 ? "online" : "last seen recently"
|
||||
verified: Int(user.verified)
|
||||
)
|
||||
add(recent)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ enum PacketRegistry {
|
||||
0x06: { PacketMessage() },
|
||||
0x07: { PacketRead() },
|
||||
0x08: { PacketDelivery() },
|
||||
0x09: { PacketDeviceNew() },
|
||||
0x0B: { PacketTyping() },
|
||||
0x0F: { PacketRequestTransport() },
|
||||
0x10: { PacketPushNotification() },
|
||||
0x17: { PacketDeviceList() },
|
||||
0x18: { PacketDeviceResolve() },
|
||||
@@ -58,6 +60,7 @@ enum AttachmentType: Int, Codable {
|
||||
case image = 0
|
||||
case messages = 1
|
||||
case file = 2
|
||||
case avatar = 3
|
||||
}
|
||||
|
||||
struct MessageAttachment: Codable {
|
||||
|
||||
26
Rosetta/Core/Network/Protocol/Packets/PacketDeviceNew.swift
Normal file
26
Rosetta/Core/Network/Protocol/Packets/PacketDeviceNew.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// Device new login packet (0x09) — server notifies all devices when a new device logs in.
|
||||
/// Field order matches TypeScript: ipAddress, deviceId, deviceName, deviceOs.
|
||||
struct PacketDeviceNew: Packet {
|
||||
static let packetId = 0x09
|
||||
|
||||
var ipAddress: String = ""
|
||||
var deviceId: String = ""
|
||||
var deviceName: String = ""
|
||||
var deviceOs: String = ""
|
||||
|
||||
func write(to stream: Stream) {
|
||||
stream.writeString(ipAddress)
|
||||
stream.writeString(deviceId)
|
||||
stream.writeString(deviceName)
|
||||
stream.writeString(deviceOs)
|
||||
}
|
||||
|
||||
mutating func read(from stream: Stream) {
|
||||
ipAddress = stream.readString()
|
||||
deviceId = stream.readString()
|
||||
deviceName = stream.readString()
|
||||
deviceOs = stream.readString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
/// Transport server request/response packet (0x0F).
|
||||
/// Desktop parity: `packet.requesttransport.ts`.
|
||||
///
|
||||
/// Client sends empty packet to request transport server URL.
|
||||
/// Server responds with the URL for file upload/download.
|
||||
struct PacketRequestTransport: Packet {
|
||||
static let packetId = 0x0F
|
||||
|
||||
var transportServer: String = ""
|
||||
|
||||
func write(to stream: Stream) {
|
||||
stream.writeString(transportServer)
|
||||
}
|
||||
|
||||
mutating func read(from stream: Stream) {
|
||||
transportServer = stream.readString()
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
var onSearchResult: ((PacketSearch) -> Void)?
|
||||
var onTypingReceived: ((PacketTyping) -> Void)?
|
||||
var onSyncReceived: ((PacketSync) -> Void)?
|
||||
var onDeviceNewReceived: ((PacketDeviceNew) -> Void)?
|
||||
var onHandshakeCompleted: ((PacketHandshake) -> Void)?
|
||||
|
||||
// MARK: - Private
|
||||
@@ -98,6 +99,9 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
connectionState = .disconnected
|
||||
savedPublicKey = nil
|
||||
savedPrivateHash = nil
|
||||
Task { @MainActor in
|
||||
TransportManager.shared.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/// Immediately reconnect after returning from background, bypassing backoff.
|
||||
@@ -292,10 +296,21 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
if let p = packet as? PacketDelivery {
|
||||
onDeliveryReceived?(p)
|
||||
}
|
||||
case 0x09:
|
||||
if let p = packet as? PacketDeviceNew {
|
||||
onDeviceNewReceived?(p)
|
||||
}
|
||||
case 0x0B:
|
||||
if let p = packet as? PacketTyping {
|
||||
onTypingReceived?(p)
|
||||
}
|
||||
case 0x0F:
|
||||
if let p = packet as? PacketRequestTransport {
|
||||
Self.logger.info("📥 Transport server: \(p.transportServer)")
|
||||
Task { @MainActor in
|
||||
TransportManager.shared.setTransportServer(p.transportServer)
|
||||
}
|
||||
}
|
||||
case 0x17:
|
||||
if let p = packet as? PacketDeviceList {
|
||||
handleDeviceList(p)
|
||||
@@ -350,6 +365,10 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
|
||||
flushPacketQueue()
|
||||
startHeartbeat(interval: packet.heartbeatInterval)
|
||||
|
||||
// Desktop parity: request transport server URL after handshake.
|
||||
sendPacketDirect(PacketRequestTransport())
|
||||
|
||||
onHandshakeCompleted?(packet)
|
||||
|
||||
case .needDeviceVerification:
|
||||
|
||||
159
Rosetta/Core/Network/TransportManager.swift
Normal file
159
Rosetta/Core/Network/TransportManager.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import os
|
||||
import Observation
|
||||
|
||||
// MARK: - TransportError
|
||||
|
||||
enum TransportError: LocalizedError {
|
||||
case noTransportServer
|
||||
case uploadFailed(statusCode: Int)
|
||||
case downloadFailed(statusCode: Int)
|
||||
case invalidResponse
|
||||
case missingTag
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noTransportServer: return "Transport server URL not set"
|
||||
case .uploadFailed(let code): return "Upload failed (HTTP \(code))"
|
||||
case .downloadFailed(let code): return "Download failed (HTTP \(code))"
|
||||
case .invalidResponse: return "Invalid response from transport server"
|
||||
case .missingTag: return "Upload response missing file tag"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TransportManager
|
||||
|
||||
/// Manages file upload/download to the transport server.
|
||||
/// Desktop parity: `TransportProvider.tsx`.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. After handshake, client sends `PacketRequestTransport` (0x0F)
|
||||
/// 2. Server responds with transport server URL
|
||||
/// 3. Upload: `POST {url}/u` with multipart form data → returns `{"t": "tag"}`
|
||||
/// 4. Download: `GET {url}/d/{tag}` → returns file content
|
||||
@Observable
|
||||
final class TransportManager: @unchecked Sendable {
|
||||
|
||||
static let shared = TransportManager()
|
||||
|
||||
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Transport")
|
||||
|
||||
/// Transport server URL received from server via PacketRequestTransport (0x0F).
|
||||
private(set) var transportServer: String?
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 60
|
||||
config.timeoutIntervalForResource = 300
|
||||
session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Called when PacketRequestTransport (0x0F) response arrives.
|
||||
@MainActor
|
||||
func setTransportServer(_ url: String) {
|
||||
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
transportServer = trimmed
|
||||
Self.logger.info("Transport server set: \(trimmed)")
|
||||
}
|
||||
|
||||
/// Resets transport server (on disconnect).
|
||||
@MainActor
|
||||
func reset() {
|
||||
transportServer = nil
|
||||
}
|
||||
|
||||
// MARK: - Upload
|
||||
|
||||
/// Uploads file content to the transport server.
|
||||
/// Desktop parity: `TransportProvider.tsx` `uploadFile()`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: Unique file identifier (used as filename in multipart).
|
||||
/// - content: Raw file content to upload.
|
||||
/// - Returns: Server-assigned tag for later download.
|
||||
func uploadFile(id: String, content: Data) async throws -> String {
|
||||
guard let serverUrl = await MainActor.run(body: { transportServer }) else {
|
||||
throw TransportError.noTransportServer
|
||||
}
|
||||
|
||||
guard let url = URL(string: "\(serverUrl)/u") else {
|
||||
throw TransportError.noTransportServer
|
||||
}
|
||||
|
||||
Self.logger.info("Uploading file \(id) (\(content.count) bytes) to \(serverUrl)/u")
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
// Build multipart body (matches desktop: FormData.append('file', Blob, id))
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(id)\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(content)
|
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
request.httpBody = body
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw TransportError.invalidResponse
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
Self.logger.error("Upload failed: HTTP \(httpResponse.statusCode)")
|
||||
throw TransportError.uploadFailed(statusCode: httpResponse.statusCode)
|
||||
}
|
||||
|
||||
// Parse JSON response: {"t": "tag"}
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let tag = json["t"] as? String else {
|
||||
throw TransportError.missingTag
|
||||
}
|
||||
|
||||
Self.logger.info("Upload complete: id=\(id), tag=\(tag)")
|
||||
return tag
|
||||
}
|
||||
|
||||
// MARK: - Download
|
||||
|
||||
/// Downloads file content from the transport server.
|
||||
/// Desktop parity: `TransportProvider.tsx` `downloadFile()`.
|
||||
///
|
||||
/// - Parameter tag: Server-assigned file tag from upload response.
|
||||
/// - Returns: Raw file content.
|
||||
func downloadFile(tag: String) async throws -> Data {
|
||||
guard let serverUrl = await MainActor.run(body: { transportServer }) else {
|
||||
throw TransportError.noTransportServer
|
||||
}
|
||||
|
||||
guard let url = URL(string: "\(serverUrl)/d/\(tag)") else {
|
||||
throw TransportError.noTransportServer
|
||||
}
|
||||
|
||||
Self.logger.info("Downloading file tag=\(tag) from \(serverUrl)/d/\(tag)")
|
||||
|
||||
let request = URLRequest(url: url)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw TransportError.invalidResponse
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
Self.logger.error("Download failed: HTTP \(httpResponse.statusCode)")
|
||||
throw TransportError.downloadFailed(statusCode: httpResponse.statusCode)
|
||||
}
|
||||
|
||||
Self.logger.info("Download complete: tag=\(tag), \(data.count) bytes")
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,45 @@ import os
|
||||
|
||||
// MARK: - AccountManager
|
||||
|
||||
/// Manages the current user account: creation, import, persistence, and retrieval.
|
||||
/// Persists encrypted account data to the iOS Keychain.
|
||||
/// Manages user accounts: creation, import, persistence, switching, and retrieval.
|
||||
/// Desktop parity: `AccountProvider.tsx` with `allAccounts`, `loginedAccount`, `loginDiceAccount`.
|
||||
///
|
||||
/// Supports multiple accounts stored as `[Account]` in Keychain.
|
||||
/// Active account tracked via UserDefaults key.
|
||||
/// Migrates from legacy single-account format on first access.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AccountManager {
|
||||
|
||||
static let shared = AccountManager()
|
||||
|
||||
/// The currently active account (selected for unlock/session).
|
||||
/// Desktop parity: `loginedAccount` in `AccountProvider.tsx`.
|
||||
private(set) var currentAccount: Account?
|
||||
|
||||
/// All stored accounts (encrypted, not yet unlocked).
|
||||
/// Desktop parity: `allAccounts` in `AccountProvider.tsx`.
|
||||
private(set) var allAccounts: [Account] = []
|
||||
|
||||
private let crypto = CryptoManager.shared
|
||||
private let keychain = KeychainManager.shared
|
||||
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AccountManager")
|
||||
|
||||
private init() {
|
||||
currentAccount = loadCachedAccount()
|
||||
migrateFromSingleAccount()
|
||||
allAccounts = loadAllAccounts()
|
||||
|
||||
// Restore active account from UserDefaults (desktop: localStorage.last_logined_account)
|
||||
let activeKey = UserDefaults.standard.string(forKey: Account.KeychainKey.activeAccountKey)
|
||||
currentAccount = allAccounts.first(where: { $0.publicKey == activeKey })
|
||||
?? allAccounts.first
|
||||
}
|
||||
|
||||
// MARK: - Account Creation
|
||||
|
||||
/// Creates a new account from a BIP39 mnemonic and password.
|
||||
/// Derives secp256k1 key pair, encrypts private key and seed phrase, saves to Keychain.
|
||||
/// Desktop parity: `createAccount()` in `AccountProvider.tsx` — INSERT into accounts table.
|
||||
func createAccount(seedPhrase: [String], password: String) async throws -> Account {
|
||||
let account = try await Task.detached(priority: .userInitiated) { [crypto] in
|
||||
let (privateKey, publicKey) = try crypto.deriveKeyPair(from: seedPhrase)
|
||||
@@ -42,8 +60,8 @@ final class AccountManager {
|
||||
)
|
||||
}.value
|
||||
|
||||
try saveAccount(account)
|
||||
currentAccount = account
|
||||
addAccount(account)
|
||||
setActiveAccount(publicKey: account.publicKey)
|
||||
return account
|
||||
}
|
||||
|
||||
@@ -69,8 +87,6 @@ final class AccountManager {
|
||||
|
||||
/// Decrypts the private key and returns the raw hex string.
|
||||
/// Used to derive the handshake hash for server authentication.
|
||||
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AccountManager")
|
||||
|
||||
func decryptPrivateKey(password: String) async throws -> String {
|
||||
guard let account = currentAccount else {
|
||||
Self.logger.error("No account found for decryption")
|
||||
@@ -97,33 +113,119 @@ final class AccountManager {
|
||||
return privateKeyData.hexString
|
||||
}
|
||||
|
||||
// MARK: - Profile Update
|
||||
|
||||
/// Updates the display name and username on the current account.
|
||||
func updateProfile(displayName: String?, username: String?) {
|
||||
guard var account = currentAccount else { return }
|
||||
if let displayName { account.displayName = displayName }
|
||||
if let username { account.username = username }
|
||||
currentAccount = account
|
||||
try? saveAccount(account)
|
||||
addAccount(account) // Updates in array + persists to Keychain
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
// MARK: - Multi-Account Management
|
||||
|
||||
func saveAccount(_ account: Account) throws {
|
||||
try keychain.saveCodable(account, forKey: Account.KeychainKey.account)
|
||||
/// Adds an account to the stored array. Deduplicates by publicKey.
|
||||
/// Desktop parity: `INSERT INTO accounts` in `AccountProvider.tsx`.
|
||||
func addAccount(_ account: Account) {
|
||||
var accounts = loadAllAccounts()
|
||||
accounts.removeAll { $0.publicKey == account.publicKey }
|
||||
accounts.append(account)
|
||||
try? keychain.saveCodable(accounts, forKey: Account.KeychainKey.allAccounts)
|
||||
allAccounts = accounts
|
||||
|
||||
// Update legacy key for backward compatibility
|
||||
if account.publicKey == activeAccountPublicKey {
|
||||
try? keychain.saveCodable(account, forKey: Account.KeychainKey.account)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAccount() throws {
|
||||
try keychain.delete(forKey: Account.KeychainKey.account)
|
||||
/// Sets which account is active (shown on unlock, used for session).
|
||||
/// Desktop parity: `selectAccountToLoginDice()` + `localStorage.last_logined_account`.
|
||||
func setActiveAccount(publicKey: String) {
|
||||
UserDefaults.standard.set(publicKey, forKey: Account.KeychainKey.activeAccountKey)
|
||||
currentAccount = allAccounts.first(where: { $0.publicKey == publicKey })
|
||||
|
||||
// Update legacy key for backward compatibility
|
||||
if let account = currentAccount {
|
||||
try? keychain.saveCodable(account, forKey: Account.KeychainKey.account)
|
||||
}
|
||||
}
|
||||
|
||||
/// The active account's public key from UserDefaults.
|
||||
var activeAccountPublicKey: String? {
|
||||
UserDefaults.standard.string(forKey: Account.KeychainKey.activeAccountKey)
|
||||
}
|
||||
|
||||
/// Removes a specific account from the stored array.
|
||||
/// Desktop parity: `DELETE FROM accounts WHERE public_key = ?` — local only, no server packet.
|
||||
func removeAccount(publicKey: String) {
|
||||
var accounts = loadAllAccounts()
|
||||
accounts.removeAll { $0.publicKey == publicKey }
|
||||
try? keychain.saveCodable(accounts, forKey: Account.KeychainKey.allAccounts)
|
||||
allAccounts = accounts
|
||||
|
||||
// If removing the active account, switch to the next available
|
||||
if activeAccountPublicKey == publicKey {
|
||||
if let next = accounts.first {
|
||||
setActiveAccount(publicKey: next.publicKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: Account.KeychainKey.activeAccountKey)
|
||||
currentAccount = nil
|
||||
try? keychain.delete(forKey: Account.KeychainKey.account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Queries
|
||||
|
||||
/// Whether any accounts are stored.
|
||||
var hasAccount: Bool {
|
||||
keychain.contains(key: Account.KeychainKey.account)
|
||||
!allAccounts.isEmpty
|
||||
}
|
||||
|
||||
/// Whether multiple accounts are stored (enables account picker on unlock).
|
||||
var hasMultipleAccounts: Bool {
|
||||
allAccounts.count > 1
|
||||
}
|
||||
|
||||
// MARK: - Legacy Compatibility
|
||||
|
||||
/// Saves account to both legacy and new format.
|
||||
func saveAccount(_ account: Account) throws {
|
||||
addAccount(account)
|
||||
}
|
||||
|
||||
/// Deletes the current (active) account.
|
||||
/// Backward-compatible wrapper around `removeAccount(publicKey:)`.
|
||||
func deleteAccount() throws {
|
||||
guard let key = currentAccount?.publicKey else { return }
|
||||
removeAccount(publicKey: key)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func loadCachedAccount() -> Account? {
|
||||
try? keychain.loadCodable(Account.self, forKey: Account.KeychainKey.account)
|
||||
private func loadAllAccounts() -> [Account] {
|
||||
(try? keychain.loadCodable([Account].self, forKey: Account.KeychainKey.allAccounts)) ?? []
|
||||
}
|
||||
|
||||
/// One-time migration: if legacy `"currentAccount"` exists but `"allAccounts"` does not,
|
||||
/// move the single account into the array format.
|
||||
/// Desktop parity: desktop always used array in SQLite; iOS started with single entry.
|
||||
private func migrateFromSingleAccount() {
|
||||
// Already migrated
|
||||
if keychain.contains(key: Account.KeychainKey.allAccounts) { return }
|
||||
|
||||
// Check for legacy single account
|
||||
guard let legacy = try? keychain.loadCodable(Account.self, forKey: Account.KeychainKey.account) else { return }
|
||||
|
||||
Self.logger.info("Migrating single account to multi-account format")
|
||||
|
||||
// Migrate to array format
|
||||
try? keychain.saveCodable([legacy], forKey: Account.KeychainKey.allAccounts)
|
||||
|
||||
// Set as active account
|
||||
UserDefaults.standard.set(legacy.publicKey, forKey: Account.KeychainKey.activeAccountKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ final class SessionManager {
|
||||
/// Desktop parity: exposed so chat list can suppress unread badges during sync.
|
||||
private(set) var syncBatchInProgress = false
|
||||
private var syncRequestInFlight = false
|
||||
private var stalledSyncBatchCount = 0
|
||||
private let maxStalledSyncBatches = 12
|
||||
private var latestSyncBatchMessageTimestamp: Int64 = 0
|
||||
private var pendingIncomingMessages: [PacketMessage] = []
|
||||
private var isProcessingIncomingMessages = false
|
||||
private var pendingReadReceiptKeys: Set<String> = []
|
||||
@@ -191,6 +188,117 @@ final class SessionManager {
|
||||
registerOutgoingRetry(for: packet)
|
||||
}
|
||||
|
||||
/// Sends current user's avatar to a chat as a message attachment.
|
||||
/// Desktop parity: `onClickCamera()` in `DialogInput.tsx` → loads avatar → attaches as AVATAR type
|
||||
/// → `prepareAttachmentsToSend()` encrypts blob → uploads to transport → sends PacketMessage.
|
||||
func sendAvatar(toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "") async throws {
|
||||
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
|
||||
Self.logger.error("📤 Cannot send avatar — missing keys")
|
||||
throw CryptoError.decryptionFailed
|
||||
}
|
||||
|
||||
// Load avatar from local storage as base64 (desktop: avatars[0].avatar)
|
||||
guard let avatarBase64 = AvatarRepository.shared.loadAvatarBase64(publicKey: currentPublicKey) else {
|
||||
Self.logger.error("📤 No avatar to send")
|
||||
return
|
||||
}
|
||||
|
||||
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let attachmentId = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
|
||||
|
||||
// Generate ECDH keys + encrypt empty text (avatar messages can have empty text)
|
||||
let encrypted = try MessageCrypto.encryptOutgoing(
|
||||
plaintext: " ",
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
|
||||
// Desktop parity: attachment password = plainKeyAndNonce interpreted as UTF-8 string
|
||||
// (same derivation as aesChachaKey: key.toString('utf-8') in useDialog.ts)
|
||||
guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
||||
throw CryptoError.encryptionFailed
|
||||
}
|
||||
|
||||
// Encrypt avatar blob with the plainKeyAndNonce password (desktop: encodeWithPassword)
|
||||
let avatarData = Data(avatarBase64.utf8)
|
||||
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
avatarData,
|
||||
password: latin1String
|
||||
)
|
||||
|
||||
// Upload encrypted blob to transport server (desktop: uploadFile)
|
||||
let tag = try await TransportManager.shared.uploadFile(
|
||||
id: attachmentId,
|
||||
content: Data(encryptedBlob.utf8)
|
||||
)
|
||||
|
||||
// Desktop parity: preview = "tag::blurhash" (blurhash optional, skip for now)
|
||||
let preview = "\(tag)::"
|
||||
|
||||
// Build aesChachaKey (same as regular messages)
|
||||
let aesChachaPayload = Data(latin1String.utf8)
|
||||
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
aesChachaPayload,
|
||||
password: privKey
|
||||
)
|
||||
|
||||
// Build packet with avatar attachment
|
||||
var packet = PacketMessage()
|
||||
packet.fromPublicKey = currentPublicKey
|
||||
packet.toPublicKey = toPublicKey
|
||||
packet.content = encrypted.content
|
||||
packet.chachaKey = encrypted.chachaKey
|
||||
packet.timestamp = timestamp
|
||||
packet.privateKey = hash
|
||||
packet.messageId = messageId
|
||||
packet.aesChachaKey = aesChachaKey
|
||||
packet.attachments = [
|
||||
MessageAttachment(
|
||||
id: attachmentId,
|
||||
preview: preview,
|
||||
blob: "", // Desktop parity: blob cleared after upload
|
||||
type: .avatar
|
||||
),
|
||||
]
|
||||
|
||||
// Ensure dialog exists
|
||||
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
|
||||
let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
|
||||
let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
|
||||
DialogRepository.shared.ensureDialog(
|
||||
opponentKey: toPublicKey,
|
||||
title: title,
|
||||
username: username,
|
||||
myPublicKey: currentPublicKey
|
||||
)
|
||||
|
||||
// Optimistic UI
|
||||
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
||||
let offlineAsSend = !isConnected
|
||||
DialogRepository.shared.updateFromMessage(
|
||||
packet, myPublicKey: currentPublicKey, decryptedText: " ", fromSync: offlineAsSend
|
||||
)
|
||||
MessageRepository.shared.upsertFromMessagePacket(
|
||||
packet, myPublicKey: currentPublicKey, decryptedText: " ", fromSync: offlineAsSend
|
||||
)
|
||||
|
||||
if offlineAsSend {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
|
||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
|
||||
}
|
||||
|
||||
// Saved Messages — local only
|
||||
if toPublicKey == currentPublicKey {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
|
||||
return
|
||||
}
|
||||
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
registerOutgoingRetry(for: packet)
|
||||
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(tag)")
|
||||
}
|
||||
|
||||
/// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog).
|
||||
func sendTypingIndicator(toPublicKey: String) {
|
||||
guard toPublicKey != currentPublicKey,
|
||||
@@ -231,8 +339,6 @@ final class SessionManager {
|
||||
lastTypingSentAt.removeAll()
|
||||
syncBatchInProgress = false
|
||||
syncRequestInFlight = false
|
||||
stalledSyncBatchCount = 0
|
||||
latestSyncBatchMessageTimestamp = 0
|
||||
pendingIncomingMessages.removeAll()
|
||||
isProcessingIncomingMessages = false
|
||||
pendingReadReceiptKeys.removeAll()
|
||||
@@ -357,6 +463,7 @@ final class SessionManager {
|
||||
self.username = packet.username
|
||||
AccountManager.shared.updateProfile(displayName: nil, username: packet.username)
|
||||
}
|
||||
NotificationCenter.default.post(name: .profileDidUpdate, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,14 +488,19 @@ final class SessionManager {
|
||||
userInfoPacket.privateKey = hash
|
||||
ProtocolManager.shared.sendPacket(userInfoPacket)
|
||||
} else {
|
||||
Self.logger.debug("Skipping UserInfo — no profile data to send")
|
||||
// No local profile — fetch from server (e.g. after import via seed phrase).
|
||||
// Server may have our profile from a previous session on another device.
|
||||
Self.logger.debug("Skipping UserInfo send — requesting own profile from server")
|
||||
var searchPacket = PacketSearch()
|
||||
searchPacket.privateKey = hash
|
||||
searchPacket.search = self.currentPublicKey
|
||||
ProtocolManager.shared.sendPacket(searchPacket)
|
||||
}
|
||||
|
||||
// Reset sync state — if a previous connection dropped mid-sync,
|
||||
// syncRequestInFlight would stay true and block all future syncs.
|
||||
self.syncRequestInFlight = false
|
||||
self.syncBatchInProgress = false
|
||||
self.stalledSyncBatchCount = 0
|
||||
self.pendingIncomingMessages.removeAll()
|
||||
self.isProcessingIncomingMessages = false
|
||||
|
||||
@@ -428,54 +540,22 @@ final class SessionManager {
|
||||
switch packet.status {
|
||||
case .batchStart:
|
||||
self.syncBatchInProgress = true
|
||||
self.stalledSyncBatchCount = 0
|
||||
self.latestSyncBatchMessageTimestamp = 0
|
||||
Self.logger.debug("SYNC BATCH_START")
|
||||
|
||||
case .batchEnd:
|
||||
// Desktop/Android parity: never advance sync cursor
|
||||
// before all inbound message tasks from this batch finish.
|
||||
let queueDrained = await self.waitForInboundQueueToDrain()
|
||||
if !queueDrained {
|
||||
Self.logger.warning("SYNC BATCH_END timed out waiting inbound queue; requesting next batch with local cursor")
|
||||
}
|
||||
|
||||
let localCursor = self.loadLastSyncTimestamp()
|
||||
// Desktop parity (useSynchronize.ts): await whenFinish() then
|
||||
// save server cursor and request next batch.
|
||||
await self.waitForInboundQueueToDrain()
|
||||
let serverCursor = self.normalizeSyncTimestamp(packet.timestamp)
|
||||
let batchCursor = self.latestSyncBatchMessageTimestamp
|
||||
let nextCursor = max(localCursor, max(serverCursor, batchCursor))
|
||||
|
||||
if nextCursor > localCursor {
|
||||
self.saveLastSyncTimestamp(nextCursor)
|
||||
self.stalledSyncBatchCount = 0
|
||||
} else {
|
||||
self.stalledSyncBatchCount += 1
|
||||
}
|
||||
|
||||
if self.stalledSyncBatchCount >= self.maxStalledSyncBatches {
|
||||
Self.logger.debug("SYNC stopped after stalled batches")
|
||||
self.syncBatchInProgress = false
|
||||
self.flushPendingReadReceipts()
|
||||
DialogRepository.shared.reconcileDeliveryStatuses()
|
||||
DialogRepository.shared.reconcileUnreadCounts()
|
||||
self.stalledSyncBatchCount = 0
|
||||
// Refresh user info now that sync is done (desktop parity: lazy per-component).
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
await self?.refreshOnlineStatusForAllDialogs()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.flushPendingReadReceipts()
|
||||
self.requestSynchronize(cursor: nextCursor)
|
||||
self.saveLastSyncTimestamp(serverCursor)
|
||||
Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)")
|
||||
self.requestSynchronize(cursor: serverCursor)
|
||||
|
||||
case .notNeeded:
|
||||
self.syncBatchInProgress = false
|
||||
self.flushPendingReadReceipts()
|
||||
DialogRepository.shared.reconcileDeliveryStatuses()
|
||||
DialogRepository.shared.reconcileUnreadCounts()
|
||||
self.stalledSyncBatchCount = 0
|
||||
Self.logger.debug("SYNC NOT_NEEDED")
|
||||
// Refresh user info now that sync is done.
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -485,6 +565,65 @@ final class SessionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
proto.onDeviceNewReceived = { [weak self] packet in
|
||||
Task { @MainActor in
|
||||
self?.handleDeviceNewLogin(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeviceNewLogin(_ packet: PacketDeviceNew) {
|
||||
let myKey = currentPublicKey
|
||||
guard !myKey.isEmpty else { return }
|
||||
|
||||
// Desktop parity: dotCenterIfNeeded(deviceId, 12, 4)
|
||||
let truncId: String
|
||||
if packet.deviceId.count > 12 {
|
||||
truncId = "\(packet.deviceId.prefix(4))...\(packet.deviceId.suffix(4))"
|
||||
} else {
|
||||
truncId = packet.deviceId
|
||||
}
|
||||
|
||||
// Desktop parity: useDeviceMessage.ts messageTemplate
|
||||
let text = """
|
||||
**Attempt to login from a new device**
|
||||
|
||||
We detected a login to your account from **\(packet.ipAddress)** a new device **by seed phrase**. If this was you, you can safely ignore this message.
|
||||
|
||||
**Arch:** \(packet.deviceOs)
|
||||
**IP:** \(packet.ipAddress)
|
||||
**Device:** \(packet.deviceName)
|
||||
**ID:** \(truncId)
|
||||
"""
|
||||
|
||||
let safeKey = SystemAccounts.safePublicKey
|
||||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let messageId = UUID().uuidString
|
||||
|
||||
// Desktop parity: Safe account has verified: 1 (public figure/brand)
|
||||
DialogRepository.shared.ensureDialog(
|
||||
opponentKey: safeKey,
|
||||
title: SystemAccounts.safeTitle,
|
||||
username: "safe",
|
||||
verified: 1,
|
||||
myPublicKey: myKey
|
||||
)
|
||||
|
||||
var fakePacket = PacketMessage()
|
||||
fakePacket.fromPublicKey = safeKey
|
||||
fakePacket.toPublicKey = myKey
|
||||
fakePacket.messageId = messageId
|
||||
fakePacket.timestamp = timestamp
|
||||
|
||||
DialogRepository.shared.updateFromMessage(
|
||||
fakePacket, myPublicKey: myKey,
|
||||
decryptedText: text, fromSync: false, isNewMessage: true
|
||||
)
|
||||
MessageRepository.shared.upsertFromMessagePacket(
|
||||
fakePacket, myPublicKey: myKey,
|
||||
decryptedText: text, fromSync: false
|
||||
)
|
||||
}
|
||||
|
||||
private func enqueueIncomingMessage(_ packet: PacketMessage) {
|
||||
@@ -516,36 +655,75 @@ final class SessionManager {
|
||||
let currentPrivateKeyHex = self.privateKeyHex
|
||||
let currentPrivateKeyHash = self.privateKeyHash
|
||||
|
||||
guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else {
|
||||
Self.logger.debug(
|
||||
"Skipping unsupported message packet: from=\(packet.fromPublicKey), to=\(packet.toPublicKey)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let fromMe = packet.fromPublicKey == myKey
|
||||
|
||||
guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else { return }
|
||||
|
||||
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
||||
let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId)
|
||||
|
||||
let decryptedText = Self.decryptIncomingMessage(
|
||||
let decryptResult = Self.decryptIncomingMessage(
|
||||
packet: packet,
|
||||
myPublicKey: myKey,
|
||||
privateKeyHex: currentPrivateKeyHex
|
||||
)
|
||||
|
||||
guard let text = decryptedText else {
|
||||
Self.logger.error(
|
||||
"Incoming message dropped: \(packet.messageId), own=\(packet.fromPublicKey == myKey)"
|
||||
guard let result = decryptResult else { return }
|
||||
let text = result.text
|
||||
|
||||
// Desktop parity: decrypt MESSAGES-type attachment blobs inline.
|
||||
var processedPacket = packet
|
||||
if let keyData = result.rawKeyData {
|
||||
let attachmentPassword = String(decoding: keyData, as: UTF8.self)
|
||||
for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages {
|
||||
let blob = processedPacket.attachments[i].blob
|
||||
if !blob.isEmpty,
|
||||
let decrypted = try? CryptoManager.shared.decryptWithPassword(blob, password: attachmentPassword),
|
||||
let decryptedString = String(data: decrypted, encoding: .utf8) {
|
||||
processedPacket.attachments[i].blob = decryptedString
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop parity: auto-download AVATAR attachments from transport server.
|
||||
// Flow: extract tag from preview → download from transport → decrypt with chacha key → save.
|
||||
let crypto = CryptoManager.shared
|
||||
for attachment in processedPacket.attachments where attachment.type == .avatar {
|
||||
let senderKey = packet.fromPublicKey
|
||||
let preview = attachment.preview
|
||||
// Desktop parity: preview = "tag::blurhash"
|
||||
let tag = preview.components(separatedBy: "::").first ?? preview
|
||||
guard !tag.isEmpty else { continue }
|
||||
let password = attachmentPassword
|
||||
|
||||
Task {
|
||||
do {
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
// Decrypt with the same password used for MESSAGES attachments
|
||||
let decryptedData = try crypto.decryptWithPassword(
|
||||
encryptedString, password: password
|
||||
)
|
||||
return
|
||||
// Decrypted data is the base64-encoded avatar image
|
||||
if let base64String = String(data: decryptedData, encoding: .utf8) {
|
||||
AvatarRepository.shared.saveAvatarFromBase64(
|
||||
base64String, publicKey: senderKey
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
Self.logger.error(
|
||||
"Failed to download/decrypt avatar from \(senderKey.prefix(12))…: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DialogRepository.shared.updateFromMessage(
|
||||
packet, myPublicKey: myKey, decryptedText: text,
|
||||
processedPacket, myPublicKey: myKey, decryptedText: text,
|
||||
fromSync: syncBatchInProgress, isNewMessage: !wasKnownBefore
|
||||
)
|
||||
MessageRepository.shared.upsertFromMessagePacket(
|
||||
packet,
|
||||
processedPacket,
|
||||
myPublicKey: myKey,
|
||||
decryptedText: text,
|
||||
fromSync: syncBatchInProgress
|
||||
@@ -574,7 +752,9 @@ final class SessionManager {
|
||||
// Desktop also skips system accounts and blocked users.
|
||||
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
|
||||
let isSystem = SystemAccounts.isSystemAccount(opponentKey)
|
||||
let shouldMarkRead = dialogIsActive && !isUserIdle && isAppInForeground && !isSystem
|
||||
let idle = isUserIdle
|
||||
let fg = isAppInForeground
|
||||
let shouldMarkRead = dialogIsActive && !idle && fg && !isSystem
|
||||
|
||||
if shouldMarkRead {
|
||||
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
|
||||
@@ -591,12 +771,9 @@ final class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
if syncBatchInProgress {
|
||||
latestSyncBatchMessageTimestamp = max(
|
||||
latestSyncBatchMessageTimestamp,
|
||||
normalizeSyncTimestamp(packet.timestamp)
|
||||
)
|
||||
} else {
|
||||
// Desktop parity (useUpdateSyncTime.ts): no-op during SYNCHRONIZATION.
|
||||
// Sync cursor is updated once at BATCH_END with the server's timestamp.
|
||||
if !syncBatchInProgress {
|
||||
saveLastSyncTimestamp(packet.timestamp)
|
||||
}
|
||||
}
|
||||
@@ -613,29 +790,18 @@ final class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForInboundQueueToDrain(timeoutMs: UInt64 = 5_000) async -> Bool {
|
||||
/// Desktop parity (dialogQueue.ts `whenFinish`): waits indefinitely for all
|
||||
/// enqueued message tasks to complete before advancing the sync cursor.
|
||||
private func waitForInboundQueueToDrain() async {
|
||||
// Fast path: already drained
|
||||
if !isProcessingIncomingMessages && pendingIncomingMessages.isEmpty {
|
||||
return true
|
||||
return
|
||||
}
|
||||
|
||||
// Event-based: wait for signal or timeout
|
||||
let drained = await withTaskGroup(of: Bool.self) { group in
|
||||
group.addTask { @MainActor in
|
||||
// Event-based: wait for signalQueueDrained()
|
||||
await withCheckedContinuation { continuation in
|
||||
self.drainContinuations.append(continuation)
|
||||
}
|
||||
return true
|
||||
}
|
||||
group.addTask {
|
||||
try? await Task.sleep(for: .milliseconds(timeoutMs))
|
||||
return false
|
||||
}
|
||||
let result = await group.next() ?? false
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
return drained
|
||||
}
|
||||
|
||||
private var syncCursorKey: String {
|
||||
@@ -705,49 +871,49 @@ final class SessionManager {
|
||||
raw < 1_000_000_000_000 ? raw * 1000 : raw
|
||||
}
|
||||
|
||||
/// Returns (decryptedText, rawKeyData) where rawKeyData can be used for attachment blob decryption.
|
||||
private static func decryptIncomingMessage(
|
||||
packet: PacketMessage,
|
||||
myPublicKey: String,
|
||||
privateKeyHex: String?
|
||||
) -> String? {
|
||||
guard let privateKeyHex,
|
||||
!packet.content.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
) -> (text: String, rawKeyData: Data?)? {
|
||||
let isOwnMessage = packet.fromPublicKey == myPublicKey
|
||||
|
||||
// Android parity for own sync packets: prefer aesChachaKey if present.
|
||||
if packet.fromPublicKey == myPublicKey,
|
||||
!packet.aesChachaKey.isEmpty,
|
||||
let decryptedPayload = try? CryptoManager.shared.decryptWithPassword(
|
||||
guard let privateKeyHex, !packet.content.isEmpty else { return nil }
|
||||
|
||||
// Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce).
|
||||
if isOwnMessage, !packet.aesChachaKey.isEmpty {
|
||||
do {
|
||||
let decryptedPayload = try CryptoManager.shared.decryptWithPassword(
|
||||
packet.aesChachaKey,
|
||||
password: privateKeyHex
|
||||
)
|
||||
{
|
||||
let keyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(decryptedPayload)
|
||||
if let text = try? MessageCrypto.decryptIncomingWithPlainKey(
|
||||
let text = try MessageCrypto.decryptIncomingWithPlainKey(
|
||||
ciphertext: packet.content,
|
||||
plainKeyAndNonce: keyAndNonce
|
||||
) {
|
||||
return text
|
||||
)
|
||||
return (text, keyAndNonce)
|
||||
} catch {
|
||||
// Fall through to ECDH path
|
||||
}
|
||||
Self.logger.debug("Own message fallback: aesChachaKey decoded but payload decryption failed for \(packet.messageId)")
|
||||
} else if packet.fromPublicKey == myPublicKey, !packet.aesChachaKey.isEmpty {
|
||||
Self.logger.debug("Own message fallback: failed to decode aesChachaKey for \(packet.messageId)")
|
||||
}
|
||||
|
||||
guard !packet.chachaKey.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
// ECDH path (works for opponent messages, may work for own if chachaKey targets us)
|
||||
guard !packet.chachaKey.isEmpty else { return nil }
|
||||
|
||||
do {
|
||||
return try MessageCrypto.decryptIncoming(
|
||||
let text = try MessageCrypto.decryptIncoming(
|
||||
ciphertext: packet.content,
|
||||
encryptedKey: packet.chachaKey,
|
||||
myPrivateKeyHex: privateKeyHex
|
||||
)
|
||||
let rawKeyData = try? MessageCrypto.extractDecryptedKeyData(
|
||||
encryptedKey: packet.chachaKey,
|
||||
myPrivateKeyHex: privateKeyHex
|
||||
)
|
||||
return (text, rawKeyData)
|
||||
} catch {
|
||||
Self.logger.error("Message decryption failed: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -861,13 +1027,36 @@ final class SessionManager {
|
||||
|
||||
/// Persistent handler for ALL search results — updates dialog names/usernames from server data.
|
||||
/// This runs independently of ChatListViewModel's search UI handler.
|
||||
/// Also detects own profile in search results and updates SessionManager + AccountManager.
|
||||
private func setupUserInfoSearchHandler() {
|
||||
userInfoSearchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
|
||||
Task { @MainActor [weak self] in
|
||||
guard self != nil else { return }
|
||||
guard let self else { return }
|
||||
let ownKey = self.currentPublicKey
|
||||
for user in packet.users {
|
||||
guard !user.publicKey.isEmpty else { continue }
|
||||
Self.logger.debug("🔍 Search result: \(user.publicKey.prefix(12))… title='\(user.title)' online=\(user.online) verified=\(user.verified)")
|
||||
|
||||
// Own profile from server — update local profile if we have empty data
|
||||
if user.publicKey == ownKey {
|
||||
var updated = false
|
||||
if !user.title.isEmpty && self.displayName.isEmpty {
|
||||
self.displayName = user.title
|
||||
AccountManager.shared.updateProfile(displayName: user.title, username: nil)
|
||||
Self.logger.info("Own profile restored from server: title='\(user.title)'")
|
||||
updated = true
|
||||
}
|
||||
if !user.username.isEmpty && self.username.isEmpty {
|
||||
self.username = user.username
|
||||
AccountManager.shared.updateProfile(displayName: nil, username: user.username)
|
||||
Self.logger.info("Own profile restored from server: username='\(user.username)'")
|
||||
updated = true
|
||||
}
|
||||
if updated {
|
||||
NotificationCenter.default.post(name: .profileDidUpdate, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Update user info + online status from search results
|
||||
DialogRepository.shared.updateUserInfo(
|
||||
publicKey: user.publicKey,
|
||||
@@ -898,7 +1087,7 @@ final class SessionManager {
|
||||
throw CryptoError.encryptionFailed
|
||||
}
|
||||
let aesChachaPayload = Data(latin1String.utf8)
|
||||
let aesChachaKey = try CryptoManager.shared.encryptWithPassword(
|
||||
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
aesChachaPayload,
|
||||
password: privateKeyHex
|
||||
)
|
||||
@@ -984,11 +1173,14 @@ final class SessionManager {
|
||||
|
||||
private func sendReadReceipt(toPublicKey: String, force: Bool) {
|
||||
let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let connState = ProtocolManager.shared.connectionState
|
||||
guard normalized != currentPublicKey,
|
||||
!normalized.isEmpty,
|
||||
let hash = privateKeyHash,
|
||||
ProtocolManager.shared.connectionState == .authenticated
|
||||
else { return }
|
||||
connState == .authenticated
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
if !force {
|
||||
|
||||
@@ -23,6 +23,8 @@ struct AvatarView: View {
|
||||
let size: CGFloat
|
||||
var isOnline: Bool = false
|
||||
var isSavedMessages: Bool = false
|
||||
/// Optional avatar photo. When non-nil, displayed as a circular image.
|
||||
var image: UIImage? = nil
|
||||
/// Override for the online-indicator border (matches row background).
|
||||
var onlineBorderColor: Color?
|
||||
|
||||
@@ -49,19 +51,24 @@ struct AvatarView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if isSavedMessages {
|
||||
if let image {
|
||||
// Avatar photo — circular crop
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
} else if isSavedMessages {
|
||||
Circle().fill(RosettaColors.primaryBlue)
|
||||
} else {
|
||||
// Mantine "light" variant: opaque base + semi-transparent tint
|
||||
Circle().fill(colorScheme == .dark ? Self.mantineDarkBody : .white)
|
||||
Circle().fill(avatarPair.tint.opacity(colorScheme == .dark ? 0.15 : 0.10))
|
||||
}
|
||||
|
||||
if isSavedMessages {
|
||||
Image(systemName: "bookmark.fill")
|
||||
.font(.system(size: fontSize, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
// Mantine "light" variant: opaque base + semi-transparent tint
|
||||
Circle().fill(colorScheme == .dark ? Self.mantineDarkBody : .white)
|
||||
Circle().fill(avatarPair.tint.opacity(colorScheme == .dark ? 0.15 : 0.10))
|
||||
|
||||
Text(initials)
|
||||
.font(.system(size: fontSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(textColor)
|
||||
|
||||
@@ -68,6 +68,53 @@ struct RosettaPrimaryButtonStyle: ButtonStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Row (Telegram-like instant press highlight)
|
||||
|
||||
enum SettingsRowPosition {
|
||||
case alone, top, middle, bottom
|
||||
}
|
||||
|
||||
/// Instant press highlight using DragGesture — bypasses ScrollView's touch delay.
|
||||
private struct SettingsHighlightModifier: ViewModifier {
|
||||
let position: SettingsRowPosition
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
@GestureState private var isPressed = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = shape(for: position)
|
||||
content
|
||||
.buttonStyle(.plain)
|
||||
.background(shape.fill(isPressed ? Color.white.opacity(0.08) : Color.clear))
|
||||
.clipShape(shape)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.updating($isPressed) { value, state, _ in
|
||||
state = abs(value.translation.height) < 10
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func shape(for position: SettingsRowPosition) -> UnevenRoundedRectangle {
|
||||
switch position {
|
||||
case .alone:
|
||||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius, bottomTrailingRadius: cornerRadius, topTrailingRadius: cornerRadius, style: .continuous)
|
||||
case .top:
|
||||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: 0, bottomTrailingRadius: 0, topTrailingRadius: cornerRadius, style: .continuous)
|
||||
case .middle:
|
||||
UnevenRoundedRectangle(topLeadingRadius: 0, bottomLeadingRadius: 0, bottomTrailingRadius: 0, topTrailingRadius: 0, style: .continuous)
|
||||
case .bottom:
|
||||
UnevenRoundedRectangle(topLeadingRadius: 0, bottomLeadingRadius: cornerRadius, bottomTrailingRadius: cornerRadius, topTrailingRadius: 0, style: .continuous)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func settingsHighlight(position: SettingsRowPosition = .alone, cornerRadius: CGFloat = 26) -> some View {
|
||||
modifier(SettingsHighlightModifier(position: position, cornerRadius: cornerRadius))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shine Overlay (Telegram-style color sweep)
|
||||
//
|
||||
// Instead of a white glare, a lighter/cyan tint of the button's own colour
|
||||
|
||||
@@ -11,7 +11,6 @@ final class KeyboardTrackingView: UIView {
|
||||
var onHeightChange: ((CGFloat) -> Void)?
|
||||
|
||||
private var observation: NSKeyValueObservation?
|
||||
private var superviewHeightObservation: NSKeyValueObservation?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: .init(x: 0, y: 0, width: frame.width, height: 0))
|
||||
@@ -29,30 +28,34 @@ final class KeyboardTrackingView: UIView {
|
||||
super.didMoveToSuperview()
|
||||
observation?.invalidate()
|
||||
observation = nil
|
||||
superviewHeightObservation?.invalidate()
|
||||
superviewHeightObservation = nil
|
||||
|
||||
guard let sv = superview else { return }
|
||||
|
||||
// Only observe .center — .bounds fires simultaneously for the same
|
||||
// position change, doubling KVO callbacks with no new information.
|
||||
observation = sv.observe(\.center, options: [.new]) { [weak self] view, _ in
|
||||
self?.reportHeight(from: view)
|
||||
}
|
||||
superviewHeightObservation = sv.observe(\.bounds, options: [.new]) { [weak self] view, _ in
|
||||
self?.reportHeight(from: view)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastReportedHeight: CGFloat = -1
|
||||
|
||||
private func reportHeight(from hostView: UIView) {
|
||||
guard let window = hostView.window else { return }
|
||||
let screenHeight = window.screen.bounds.height
|
||||
let hostFrame = hostView.convert(hostView.bounds, to: nil)
|
||||
let keyboardHeight = max(0, screenHeight - hostFrame.origin.y)
|
||||
onHeightChange?(keyboardHeight)
|
||||
// Throttle: only fire callback when rounded height actually changes.
|
||||
// Sub-point changes are invisible but still trigger full SwiftUI layout.
|
||||
// This halves the number of body evaluations during interactive dismiss.
|
||||
let rounded = round(keyboardHeight)
|
||||
guard abs(rounded - lastReportedHeight) > 1 else { return }
|
||||
lastReportedHeight = rounded
|
||||
onHeightChange?(rounded)
|
||||
}
|
||||
|
||||
deinit {
|
||||
observation?.invalidate()
|
||||
superviewHeightObservation?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Settings Card (flat #1C1C1E background, no border/blur)
|
||||
|
||||
struct SettingsCard<Content: View>: View {
|
||||
let content: () -> Content
|
||||
|
||||
init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.fill(Color(red: 28/255, green: 28/255, blue: 30/255))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Glass Card (material blur + border)
|
||||
|
||||
struct GlassCard<Content: View>: View {
|
||||
let cornerRadius: CGFloat
|
||||
let fillOpacity: Double
|
||||
|
||||
@@ -8,14 +8,17 @@ import UIKit
|
||||
/// - `keyboardPadding`: bottom padding to apply when keyboard is visible
|
||||
///
|
||||
/// Animation strategy:
|
||||
/// - Notification (show/hide): CADisplayLink interpolates `keyboardPadding` at 60fps
|
||||
/// with ease-out curve. Small incremental updates keep LazyVStack stable (no cell
|
||||
/// recycling, no gaps between bubbles).
|
||||
/// - KVO (interactive dismiss): raw assignment at 60fps — already smooth.
|
||||
/// - Notification (show/hide): A hidden UIView is animated with the keyboard's
|
||||
/// exact `UIViewAnimationCurve` (rawValue 7) inside the same Core Animation
|
||||
/// transaction. CADisplayLink samples the presentation layer at 60fps,
|
||||
/// giving pixel-perfect curve sync. Cubic bezier fallback if no window.
|
||||
/// - KVO (interactive dismiss): raw assignment at 30fps via coalescing.
|
||||
/// - NO `withAnimation` / `.animation()` — these cause LazyVStack cell recycling gaps.
|
||||
@MainActor
|
||||
final class KeyboardTracker: ObservableObject {
|
||||
|
||||
static let shared = KeyboardTracker()
|
||||
|
||||
/// Bottom padding — updated incrementally at display refresh rate.
|
||||
@Published private(set) var keyboardPadding: CGFloat = 0
|
||||
|
||||
@@ -25,17 +28,37 @@ final class KeyboardTracker: ObservableObject {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var lastNotificationPadding: CGFloat = 0
|
||||
|
||||
// CADisplayLink-based animation state
|
||||
// CADisplayLink-based animation state (notification-driven show/hide)
|
||||
private var displayLinkProxy: DisplayLinkProxy?
|
||||
private var animStartPadding: CGFloat = 0
|
||||
private var animTargetPadding: CGFloat = 0
|
||||
private var animStartTime: CFTimeInterval = 0
|
||||
private var animDuration: CFTimeInterval = 0.25
|
||||
private var animTickCount = 0
|
||||
private var animationNumber = 0
|
||||
private var lastTickTime: CFTimeInterval = 0
|
||||
|
||||
// Presentation-layer sync: a hidden UIView animated with the keyboard's
|
||||
// exact curve. Reading its presentation layer on each CADisplayLink tick
|
||||
// gives us the real easing value — no guessing control points.
|
||||
private var syncView: UIView?
|
||||
private var usingSyncAnimation = false
|
||||
|
||||
// Cubic bezier fallback (used when syncView is unavailable)
|
||||
private var bezierP1x: CGFloat = 0.25
|
||||
private var bezierP1y: CGFloat = 0.1
|
||||
private var bezierP2x: CGFloat = 0.25
|
||||
private var bezierP2y: CGFloat = 1.0
|
||||
|
||||
// KVO coalescing — buffers rapid KVO updates and applies at 30fps
|
||||
// instead of immediately on every callback (~60fps). Halves body evaluations.
|
||||
private var kvoDisplayLink: DisplayLinkProxy?
|
||||
private var pendingKVOPadding: CGFloat?
|
||||
|
||||
/// Spring kept for potential future use (e.g., composer-only animation).
|
||||
static let keyboardSpring = Animation.spring(duration: 0.25, bounce: 0)
|
||||
|
||||
init() {
|
||||
private init() {
|
||||
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = scene.keyWindow ?? scene.windows.first {
|
||||
let bottom = window.safeAreaInsets.bottom
|
||||
@@ -44,24 +67,35 @@ final class KeyboardTracker: ObservableObject {
|
||||
bottomInset = 34
|
||||
}
|
||||
|
||||
// iOS 26+ handles keyboard natively — no custom tracking needed.
|
||||
if #available(iOS 26, *) { return }
|
||||
|
||||
NotificationCenter.default
|
||||
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
|
||||
.sink { [weak self] in self?.handleNotification($0) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Called from KVO — pixel-perfect interactive dismiss at 60fps.
|
||||
/// Only applies DECREASING padding (swipe-to-dismiss).
|
||||
/// Called from KVO — pixel-perfect interactive dismiss.
|
||||
/// Buffers values and applies at 30fps via CADisplayLink coalescing
|
||||
/// to reduce ChatDetailView.body evaluations during swipe-to-dismiss.
|
||||
func updateFromKVO(keyboardHeight: CGFloat) {
|
||||
if #available(iOS 26, *) { return }
|
||||
guard !isAnimating else { return }
|
||||
|
||||
if keyboardHeight <= 0 {
|
||||
// Flush any pending KVO value and stop coalescing
|
||||
flushPendingKVO()
|
||||
stopKVOCoalescing()
|
||||
|
||||
if keyboardPadding != 0 {
|
||||
if pendingResetTask == nil {
|
||||
pendingResetTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
if self.keyboardPadding != 0 { self.keyboardPadding = 0 }
|
||||
if self.keyboardPadding != 0 {
|
||||
self.keyboardPadding = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,13 +105,51 @@ final class KeyboardTracker: ObservableObject {
|
||||
pendingResetTask?.cancel()
|
||||
pendingResetTask = nil
|
||||
|
||||
let newPadding = max(0, keyboardHeight - bottomInset)
|
||||
guard newPadding < keyboardPadding else { return }
|
||||
// Round to nearest 2pt — sub-point changes are invisible but still
|
||||
// trigger full ChatDetailView.body evaluations.
|
||||
let rawPadding = max(0, keyboardHeight - bottomInset)
|
||||
let newPadding = round(rawPadding / 2) * 2
|
||||
|
||||
if newPadding != keyboardPadding {
|
||||
keyboardPadding = newPadding
|
||||
// Only track decreasing padding (swipe-to-dismiss)
|
||||
let current = pendingKVOPadding ?? keyboardPadding
|
||||
guard newPadding < current else { return }
|
||||
|
||||
// Buffer the value — will be applied by kvoDisplayLink at 30fps
|
||||
pendingKVOPadding = newPadding
|
||||
|
||||
// Start coalescing display link if not running
|
||||
if kvoDisplayLink == nil {
|
||||
kvoDisplayLink = DisplayLinkProxy(preferredFPS: 30) { [weak self] in
|
||||
self?.applyPendingKVO()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by kvoDisplayLink at 30fps — applies buffered KVO value.
|
||||
private func applyPendingKVO() {
|
||||
guard let pending = pendingKVOPadding else {
|
||||
// No pending value — stop the display link
|
||||
stopKVOCoalescing()
|
||||
return
|
||||
}
|
||||
pendingKVOPadding = nil
|
||||
guard pending != keyboardPadding else { return }
|
||||
keyboardPadding = pending
|
||||
}
|
||||
|
||||
/// Immediately applies any buffered KVO value (used when KVO stops).
|
||||
private func flushPendingKVO() {
|
||||
guard let pending = pendingKVOPadding else { return }
|
||||
pendingKVOPadding = nil
|
||||
if pending != keyboardPadding {
|
||||
keyboardPadding = pending
|
||||
}
|
||||
}
|
||||
|
||||
private func stopKVOCoalescing() {
|
||||
kvoDisplayLink?.stop()
|
||||
kvoDisplayLink = nil
|
||||
}
|
||||
|
||||
private func handleNotification(_ notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
@@ -85,6 +157,9 @@ final class KeyboardTracker: ObservableObject {
|
||||
else { return }
|
||||
|
||||
isAnimating = true
|
||||
// Stop KVO coalescing — notification animation takes over
|
||||
flushPendingKVO()
|
||||
stopKVOCoalescing()
|
||||
|
||||
let screenHeight = UIScreen.main.bounds.height
|
||||
let keyboardTop = endFrame.origin.y
|
||||
@@ -96,59 +171,187 @@ final class KeyboardTracker: ObservableObject {
|
||||
|
||||
let targetPadding = isVisible ? max(0, endHeight - bottomInset) : 0
|
||||
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
|
||||
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0
|
||||
|
||||
let delta = targetPadding - lastNotificationPadding
|
||||
lastNotificationPadding = targetPadding
|
||||
|
||||
if abs(delta) > 1 {
|
||||
// Guard: skip animation when target equals current padding (e.g., after
|
||||
// interactive dismiss already brought padding to 0, the late notification
|
||||
// would start a wasted 0→0 animation with ~14 no-op CADisplayLink ticks).
|
||||
if abs(delta) > 1, targetPadding != keyboardPadding {
|
||||
// CADisplayLink interpolation: updates @Published at ~60fps.
|
||||
// Each frame is a small layout delta → LazyVStack handles it without
|
||||
// cell recycling → no gaps between message bubbles.
|
||||
startPaddingAnimation(to: targetPadding, duration: duration)
|
||||
startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw)
|
||||
} else {
|
||||
// Still snap to target in case of rounding differences
|
||||
if keyboardPadding != targetPadding {
|
||||
keyboardPadding = targetPadding
|
||||
}
|
||||
}
|
||||
|
||||
// Unblock KVO after animation + buffer.
|
||||
let unblockDelay = max(duration, 0.05) + 0.15
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(max(duration, 0.05) + 0.15))
|
||||
try? await Task.sleep(for: .seconds(unblockDelay))
|
||||
self?.isAnimating = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CADisplayLink animation
|
||||
|
||||
private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval) {
|
||||
displayLinkProxy?.stop()
|
||||
|
||||
private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval, curveRaw: Int) {
|
||||
animationNumber += 1
|
||||
animStartPadding = keyboardPadding
|
||||
animTargetPadding = target
|
||||
animStartTime = CACurrentMediaTime()
|
||||
animStartTime = 0
|
||||
animDuration = max(duration, 0.05)
|
||||
animTickCount = 0
|
||||
|
||||
// Primary: sync with the keyboard's exact curve via presentation layer.
|
||||
// UIView.animate called HERE lands in the same Core Animation transaction
|
||||
// as the keyboard notification — identical timing function and start time.
|
||||
usingSyncAnimation = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
|
||||
|
||||
// Fallback: cubic bezier approximation (CSS "ease").
|
||||
if !usingSyncAnimation {
|
||||
configureBezier(curveRaw: curveRaw)
|
||||
}
|
||||
|
||||
// Reuse existing display link to preserve vsync phase alignment.
|
||||
// Creating a new CADisplayLink on each animation resets the phase,
|
||||
// causing alternating frame intervals (15/18ms instead of steady 16.6ms).
|
||||
if let proxy = displayLinkProxy {
|
||||
proxy.isPaused = false
|
||||
} else {
|
||||
displayLinkProxy = DisplayLinkProxy { [weak self] in
|
||||
self?.animationTick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a hidden UIView animation matching the keyboard's exact curve.
|
||||
/// Returns true if sync animation was set up successfully.
|
||||
private func setupSyncAnimation(duration: CFTimeInterval, curveRaw: Int) -> Bool {
|
||||
guard let window = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene }).first?.keyWindow else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove previous sync view completely — a fresh view
|
||||
// guarantees clean presentation layer with no animation history.
|
||||
// Reusing the same view causes Core Animation to coalesce/skip
|
||||
// repeated 0→1 opacity animations on subsequent calls.
|
||||
syncView?.layer.removeAllAnimations()
|
||||
syncView?.removeFromSuperview()
|
||||
|
||||
let view = UIView(frame: CGRect(x: -10, y: -10, width: 1, height: 1))
|
||||
view.alpha = 0
|
||||
window.addSubview(view)
|
||||
syncView = view
|
||||
|
||||
// UIView.AnimationOptions encodes the curve in bits 16-19.
|
||||
// For rawValue 7 (private keyboard curve), this passes through to
|
||||
// Core Animation with Apple's exact timing function.
|
||||
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay: 0,
|
||||
options: [options]
|
||||
) {
|
||||
view.alpha = 1
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func animationTick() {
|
||||
let elapsed = CACurrentMediaTime() - animStartTime
|
||||
let t = min(elapsed / animDuration, 1.0)
|
||||
animTickCount += 1
|
||||
|
||||
// Ease-out cubic — closely matches iOS keyboard animation feel.
|
||||
let eased = 1 - pow(1 - t, 3)
|
||||
let now = CACurrentMediaTime()
|
||||
lastTickTime = now
|
||||
|
||||
// Get eased fraction — either from presentation layer (exact) or bezier (fallback).
|
||||
let eased: CGFloat
|
||||
var isComplete = false
|
||||
|
||||
if usingSyncAnimation, let presentation = syncView?.layer.presentation() {
|
||||
let fraction = CGFloat(presentation.opacity)
|
||||
eased = fraction
|
||||
isComplete = fraction >= 0.999
|
||||
} else {
|
||||
// Bezier fallback
|
||||
if animStartTime == 0 { animStartTime = now }
|
||||
let elapsed = now - animStartTime
|
||||
let t = min(elapsed / animDuration, 1.0)
|
||||
eased = cubicBezierEase(t)
|
||||
isComplete = t >= 1.0
|
||||
}
|
||||
|
||||
// Round to nearest 1pt — sub-point changes are invisible but still
|
||||
// trigger full SwiftUI layout passes. Skipping them reduces render cost.
|
||||
let raw = animStartPadding + (animTargetPadding - animStartPadding) * eased
|
||||
let rounded = round(raw)
|
||||
|
||||
if t >= 1.0 {
|
||||
if isComplete || animTickCount > 30 {
|
||||
keyboardPadding = animTargetPadding
|
||||
displayLinkProxy?.stop()
|
||||
displayLinkProxy = nil
|
||||
// Pause instead of invalidate — preserves vsync phase for next animation.
|
||||
displayLinkProxy?.isPaused = true
|
||||
lastTickTime = 0
|
||||
} else if rounded != keyboardPadding {
|
||||
keyboardPadding = rounded
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cubic bezier fallback
|
||||
|
||||
/// Maps `UIViewAnimationCurve` rawValue to cubic bezier control points.
|
||||
private func configureBezier(curveRaw: Int) {
|
||||
switch UIView.AnimationCurve(rawValue: curveRaw) {
|
||||
case .easeIn:
|
||||
bezierP1x = 0.42; bezierP1y = 0
|
||||
bezierP2x = 1.0; bezierP2y = 1.0
|
||||
case .easeOut:
|
||||
bezierP1x = 0; bezierP1y = 0
|
||||
bezierP2x = 0.58; bezierP2y = 1.0
|
||||
case .linear:
|
||||
bezierP1x = 0; bezierP1y = 0
|
||||
bezierP2x = 1.0; bezierP2y = 1.0
|
||||
default:
|
||||
// CSS "ease" — closest known approximation of curve 7.
|
||||
bezierP1x = 0.25; bezierP1y = 0.1
|
||||
bezierP2x = 0.25; bezierP2y = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates the configured cubic bezier at linear time `x` (0…1).
|
||||
/// Uses Newton–Raphson for fast convergence (~4 iterations).
|
||||
private func cubicBezierEase(_ x: CGFloat) -> CGFloat {
|
||||
guard x > 0 else { return 0 }
|
||||
guard x < 1 else { return 1 }
|
||||
var t = x
|
||||
for _ in 0..<8 {
|
||||
let bx = bezierValue(t, p1: bezierP1x, p2: bezierP2x)
|
||||
let dx = bezierDerivative(t, p1: bezierP1x, p2: bezierP2x)
|
||||
guard abs(dx) > 1e-6 else { break }
|
||||
t -= (bx - x) / dx
|
||||
t = min(max(t, 0), 1)
|
||||
}
|
||||
return bezierValue(t, p1: bezierP1y, p2: bezierP2y)
|
||||
}
|
||||
|
||||
/// Single axis of cubic bezier: B(t) = 3(1−t)²t·p1 + 3(1−t)t²·p2 + t³
|
||||
private func bezierValue(_ t: CGFloat, p1: CGFloat, p2: CGFloat) -> CGFloat {
|
||||
let mt = 1 - t
|
||||
return 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t
|
||||
}
|
||||
|
||||
/// Derivative of single bezier axis: B'(t)
|
||||
private func bezierDerivative(_ t: CGFloat, p1: CGFloat, p2: CGFloat) -> CGFloat {
|
||||
let mt = 1 - t
|
||||
return 3 * mt * mt * p1 + 6 * mt * t * (p2 - p1) + 3 * t * t * (1 - p2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CADisplayLink wrapper (avoids @objc requirement on @MainActor class)
|
||||
@@ -157,13 +360,14 @@ private class DisplayLinkProxy {
|
||||
private var callback: (() -> Void)?
|
||||
private var displayLink: CADisplayLink?
|
||||
|
||||
init(callback: @escaping () -> Void) {
|
||||
/// - Parameter preferredFPS: Target frame rate. 60 for notification animation,
|
||||
/// 30 for KVO coalescing (halves body evaluations during interactive dismiss).
|
||||
init(preferredFPS: Int = 60, callback: @escaping () -> Void) {
|
||||
self.callback = callback
|
||||
self.displayLink = CADisplayLink(target: self, selector: #selector(tick))
|
||||
// Cap at 60fps — keyboard animation doesn't need 120Hz, and each tick
|
||||
// triggers a full ChatDetailView body evaluation. 60fps halves the cost.
|
||||
let fps = Float(preferredFPS)
|
||||
self.displayLink?.preferredFrameRateRange = CAFrameRateRange(
|
||||
minimum: 30, maximum: 60, preferred: 60
|
||||
minimum: fps / 2, maximum: fps, preferred: fps
|
||||
)
|
||||
self.displayLink?.add(to: .main, forMode: .common)
|
||||
}
|
||||
@@ -172,6 +376,11 @@ private class DisplayLinkProxy {
|
||||
callback?()
|
||||
}
|
||||
|
||||
var isPaused: Bool {
|
||||
get { displayLink?.isPaused ?? true }
|
||||
set { displayLink?.isPaused = newValue }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
|
||||
60
Rosetta/DesignSystem/Components/RosettaLogoShape.swift
Normal file
60
Rosetta/DesignSystem/Components/RosettaLogoShape.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Stylized "R" logo shape from the desktop SVG.
|
||||
/// Renders at any size as a vector — no bitmap artifacts.
|
||||
struct RosettaLogoShape: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
// Original SVG viewBox: 0 0 384 384
|
||||
let w = rect.width
|
||||
let h = rect.height
|
||||
let sx = w / 384.0
|
||||
let sy = h / 384.0
|
||||
|
||||
var path = Path()
|
||||
|
||||
// Bottom-right piece (the "leg" + diagonal)
|
||||
path.move(to: CGPoint(x: 254.16 * sx, y: 284.45 * sy))
|
||||
path.addLine(to: CGPoint(x: 288.41 * sx, y: 275.19 * sy))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: 337.41 * sx, y: 240.77 * sy),
|
||||
control1: CGPoint(x: 310.0 * sx, y: 265.0 * sy),
|
||||
control2: CGPoint(x: 326.0 * sx, y: 254.0 * sy)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: 369.26 * sx, y: 163.69 * sy),
|
||||
control1: CGPoint(x: 358.63 * sx, y: 220.92 * sy),
|
||||
control2: CGPoint(x: 369.26 * sx, y: 195.24 * sy)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: 369.26 * sx, y: 110.0 * sy))
|
||||
path.addLine(to: CGPoint(x: 249.55 * sx, y: 110.22 * sy))
|
||||
path.addLine(to: CGPoint(x: 249.55 * sx, y: 168.15 * sy))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: 226.15 * sx, y: 208.22 * sy),
|
||||
control1: CGPoint(x: 249.55 * sx, y: 184.85 * sy),
|
||||
control2: CGPoint(x: 241.75 * sx, y: 198.20 * sy)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: 159.01 * sx, y: 223.23 * sy),
|
||||
control1: CGPoint(x: 210.55 * sx, y: 218.23 * sy),
|
||||
control2: CGPoint(x: 188.18 * sx, y: 223.23 * sy)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: 134.07 * sx, y: 223.23 * sy))
|
||||
path.addLine(to: CGPoint(x: 134.07 * sx, y: 301.0 * sy))
|
||||
path.addLine(to: CGPoint(x: 206.65 * sx, y: 381.43 * sy))
|
||||
path.addLine(to: CGPoint(x: 344.77 * sx, y: 381.43 * sy))
|
||||
path.addLine(to: CGPoint(x: 254.16 * sx, y: 284.45 * sy))
|
||||
path.closeSubpath()
|
||||
|
||||
// Top-left piece (the square block)
|
||||
path.move(to: CGPoint(x: 248.42 * sx, y: 109.26 * sy))
|
||||
path.addLine(to: CGPoint(x: 248.42 * sx, y: 2.61 * sy))
|
||||
path.addLine(to: CGPoint(x: 14.77 * sx, y: 2.61 * sy))
|
||||
path.addLine(to: CGPoint(x: 14.77 * sx, y: 221.52 * sy))
|
||||
path.addLine(to: CGPoint(x: 132.94 * sx, y: 221.52 * sy))
|
||||
path.addLine(to: CGPoint(x: 132.94 * sx, y: 109.26 * sy))
|
||||
path.addLine(to: CGPoint(x: 248.42 * sx, y: 109.26 * sy))
|
||||
path.closeSubpath()
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import UIKit
|
||||
|
||||
// MARK: - Tab
|
||||
|
||||
enum RosettaTab: CaseIterable, Sendable {
|
||||
enum RosettaTab: String, CaseIterable, Sendable {
|
||||
case chats
|
||||
case calls
|
||||
case settings
|
||||
|
||||
@@ -21,7 +21,7 @@ struct VerifiedBadge: View {
|
||||
|
||||
var body: some View {
|
||||
if verified > 0 {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: size))
|
||||
.foregroundStyle(resolvedColor)
|
||||
.onTapGesture { showExplanation = true }
|
||||
@@ -36,8 +36,28 @@ struct VerifiedBadge: View {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Desktop parity: different icon per verification level.
|
||||
/// Level 1 = rosette (public figure), Level 2 = shield (Rosetta admin),
|
||||
/// Level 3+ = arrow badge (group admin).
|
||||
private var iconName: String {
|
||||
switch verified {
|
||||
case 2:
|
||||
return "checkmark.shield.fill"
|
||||
case 3...:
|
||||
return "arrow.down.app.fill"
|
||||
default:
|
||||
return "checkmark.seal.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop parity: level 2 (Rosetta admin) uses green, others use brand blue.
|
||||
private var resolvedColor: Color {
|
||||
if let badgeTint { return badgeTint }
|
||||
if verified == 2 {
|
||||
return colorScheme == .dark
|
||||
? RosettaColors.success // green
|
||||
: RosettaColors.success
|
||||
}
|
||||
return colorScheme == .dark
|
||||
? RosettaColors.primaryBlue // #248AE6
|
||||
: Color(hex: 0xACD2F9) // soft blue (light theme)
|
||||
|
||||
@@ -2,12 +2,14 @@ import SwiftUI
|
||||
|
||||
// MARK: - Auth Screen Enum
|
||||
|
||||
enum AuthScreen: Equatable {
|
||||
enum AuthScreen: Equatable, Identifiable {
|
||||
case welcome
|
||||
case seedPhrase
|
||||
case confirmSeed
|
||||
case importSeed
|
||||
case setPassword
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
// MARK: - Auth Coordinator
|
||||
@@ -15,16 +17,31 @@ enum AuthScreen: Equatable {
|
||||
struct AuthCoordinator: View {
|
||||
let onAuthComplete: () -> Void
|
||||
var onBackToUnlock: (() -> Void)?
|
||||
var initialScreen: AuthScreen = .welcome
|
||||
|
||||
@State private var currentScreen: AuthScreen = .welcome
|
||||
@State private var currentScreen: AuthScreen
|
||||
@State private var seedPhrase: [String] = []
|
||||
@State private var isImportMode = false
|
||||
@State private var isImportMode: Bool
|
||||
@State private var navigationDirection: NavigationDirection = .forward
|
||||
@State private var swipeOffset: CGFloat = 0
|
||||
@State private var fadeOverlay: Bool = false
|
||||
/// Tracks whether the current drag was identified as a valid horizontal swipe.
|
||||
@State private var isSwipeActive = false
|
||||
|
||||
init(
|
||||
onAuthComplete: @escaping () -> Void,
|
||||
onBackToUnlock: (() -> Void)? = nil,
|
||||
initialScreen: AuthScreen = .welcome
|
||||
) {
|
||||
self.onAuthComplete = onAuthComplete
|
||||
self.onBackToUnlock = onBackToUnlock
|
||||
self.initialScreen = initialScreen
|
||||
_currentScreen = State(initialValue: initialScreen)
|
||||
_isImportMode = State(initialValue: initialScreen == .importSeed)
|
||||
}
|
||||
|
||||
private var canSwipeBack: Bool {
|
||||
if currentScreen == .welcome {
|
||||
if currentScreen == initialScreen {
|
||||
return onBackToUnlock != nil
|
||||
}
|
||||
return true
|
||||
@@ -70,15 +87,7 @@ struct AuthCoordinator: View {
|
||||
.allowsHitTesting(fadeOverlay)
|
||||
.animation(.easeInOut(duration: 0.035), value: fadeOverlay)
|
||||
}
|
||||
.overlay(alignment: .leading) {
|
||||
if canSwipeBack {
|
||||
Color.clear
|
||||
.frame(width: 20)
|
||||
.contentShape(Rectangle())
|
||||
.padding(.top, 60)
|
||||
.gesture(swipeBackGesture(screenWidth: screenWidth))
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(swipeBackGesture(screenWidth: screenWidth))
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
@@ -104,7 +113,13 @@ private extension AuthCoordinator {
|
||||
SeedPhraseView(
|
||||
seedPhrase: $seedPhrase,
|
||||
onContinue: { navigateTo(.confirmSeed) },
|
||||
onBack: { navigateBack(to: .welcome) }
|
||||
onBack: {
|
||||
if initialScreen == .seedPhrase {
|
||||
onBackToUnlock?()
|
||||
} else {
|
||||
navigateBack(to: .welcome)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
case .confirmSeed:
|
||||
@@ -124,7 +139,13 @@ private extension AuthCoordinator {
|
||||
isImportMode = true
|
||||
navigateTo(.setPassword)
|
||||
},
|
||||
onBack: { navigateBack(to: .welcome) }
|
||||
onBack: {
|
||||
if initialScreen == .importSeed {
|
||||
onBackToUnlock?()
|
||||
} else {
|
||||
navigateBack(to: .welcome)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
case .setPassword:
|
||||
@@ -149,11 +170,19 @@ private extension AuthCoordinator {
|
||||
case .welcome:
|
||||
EmptyView()
|
||||
case .seedPhrase:
|
||||
if initialScreen == .seedPhrase {
|
||||
EmptyView()
|
||||
} else {
|
||||
WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock)
|
||||
}
|
||||
case .confirmSeed:
|
||||
SeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
|
||||
case .importSeed:
|
||||
if initialScreen == .importSeed {
|
||||
EmptyView()
|
||||
} else {
|
||||
WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock)
|
||||
}
|
||||
case .setPassword:
|
||||
if isImportMode {
|
||||
ImportSeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
|
||||
@@ -193,11 +222,28 @@ private extension AuthCoordinator {
|
||||
|
||||
private extension AuthCoordinator {
|
||||
func swipeBackGesture(screenWidth: CGFloat) -> some Gesture {
|
||||
DragGesture(minimumDistance: 10)
|
||||
DragGesture(minimumDistance: 15)
|
||||
.onChanged { value in
|
||||
guard canSwipeBack else { return }
|
||||
|
||||
// On first significant movement, determine if this is a horizontal rightward swipe.
|
||||
// Ignore vertical drags (let ScrollView handle those).
|
||||
if !isSwipeActive && swipeOffset == 0 {
|
||||
let dx = abs(value.translation.width)
|
||||
let dy = abs(value.translation.height)
|
||||
guard dx > dy, value.translation.width > 0 else { return }
|
||||
isSwipeActive = true
|
||||
}
|
||||
guard isSwipeActive else { return }
|
||||
swipeOffset = max(value.translation.width, 0)
|
||||
}
|
||||
.onEnded { value in
|
||||
defer { isSwipeActive = false }
|
||||
guard canSwipeBack, swipeOffset > 0 else {
|
||||
swipeOffset = 0
|
||||
return
|
||||
}
|
||||
|
||||
let shouldGoBack = value.translation.width > 100
|
||||
|| value.predictedEndTranslation.width > 200
|
||||
|
||||
@@ -206,9 +252,9 @@ private extension AuthCoordinator {
|
||||
swipeOffset = screenWidth
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
if currentScreen == .welcome {
|
||||
if currentScreen == initialScreen {
|
||||
// Don't reset swipeOffset — keep screen offscreen
|
||||
// while parent performs its own transition to unlock.
|
||||
// while parent performs its own transition.
|
||||
onBackToUnlock?()
|
||||
} else {
|
||||
swipeOffset = 0
|
||||
|
||||
@@ -18,7 +18,9 @@ struct ImportSeedPhraseView: View {
|
||||
VStack(spacing: 0) {
|
||||
AuthNavigationBar(onBack: onBack)
|
||||
|
||||
GeometryReader { geometry in
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 24) {
|
||||
headerSection
|
||||
pasteButton
|
||||
@@ -27,15 +29,19 @@ struct ImportSeedPhraseView: View {
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 100)
|
||||
|
||||
Spacer(minLength: 24)
|
||||
|
||||
continueButton
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.frame(minHeight: geometry.size.height)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onTapGesture(count: 1) { focusedWordIndex = nil }
|
||||
.simultaneousGesture(TapGesture().onEnded {})
|
||||
|
||||
continueButton
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ private struct SecureToggleField: UIViewRepresentable {
|
||||
)
|
||||
|
||||
// Eye toggle button — entirely UIKit, no SwiftUI state involved
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 16)
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin)
|
||||
let eyeButton = UIButton(type: .system)
|
||||
eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal)
|
||||
eyeButton.tintColor = UIColor.white.withAlphaComponent(0.5)
|
||||
@@ -378,7 +378,7 @@ private struct SecureToggleField: UIViewRepresentable {
|
||||
|
||||
// Update eye icon
|
||||
let imageName = tf.isSecureTextEntry ? "eye" : "eye.slash"
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 16)
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin)
|
||||
let button = tf.rightView as? UIButton
|
||||
button?.setImage(
|
||||
UIImage(systemName: imageName, withConfiguration: config),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LocalAuthentication
|
||||
import SwiftUI
|
||||
|
||||
/// Password unlock screen matching rosetta-android design.
|
||||
@@ -8,7 +9,12 @@ struct UnlockView: View {
|
||||
@State private var password = ""
|
||||
@State private var isUnlocking = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showPassword = false
|
||||
// Biometric unlock
|
||||
@State private var biometricTriggered = false
|
||||
// Avatar
|
||||
@State private var avatarImage: UIImage?
|
||||
// Multi-account picker (desktop parity: DiceDropdown)
|
||||
@State private var showAccountPicker = false
|
||||
|
||||
// Staggered fade-in animation
|
||||
@State private var showAvatar = false
|
||||
@@ -46,6 +52,11 @@ struct UnlockView: View {
|
||||
return shortPublicKey
|
||||
}
|
||||
|
||||
/// Whether biometric unlock is available and enabled for this account.
|
||||
private var canUseBiometric: Bool {
|
||||
BiometricAuthManager.shared.canUseBiometric(forAccount: publicKey)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RosettaColors.authBackground
|
||||
@@ -60,17 +71,32 @@ struct UnlockView: View {
|
||||
initials: avatarText,
|
||||
colorIndex: avatarColorIndex,
|
||||
size: 100,
|
||||
isSavedMessages: false
|
||||
isSavedMessages: false,
|
||||
image: avatarImage
|
||||
)
|
||||
.opacity(showAvatar ? 1 : 0)
|
||||
.scaleEffect(showAvatar ? 1 : 0.8)
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
// Display name (or short public key fallback)
|
||||
// Display name (or short public key fallback) + account switcher
|
||||
HStack(spacing: 8) {
|
||||
Text(displayTitle)
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
// Desktop parity: DiceDropdown arrow icon for multi-account
|
||||
if AccountManager.shared.hasMultipleAccounts {
|
||||
Button {
|
||||
showAccountPicker = true
|
||||
} label: {
|
||||
Image(systemName: "arrow.left.arrow.right")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(Color.white.opacity(0.6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.opacity(showTitle ? 1 : 0)
|
||||
.offset(y: showTitle ? 0 : 8)
|
||||
|
||||
@@ -99,6 +125,14 @@ struct UnlockView: View {
|
||||
.opacity(showButton ? 1 : 0)
|
||||
.offset(y: showButton ? 0 : 12)
|
||||
|
||||
// Biometric button — shown when Face ID/Touch ID is available
|
||||
if canUseBiometric {
|
||||
biometricButton
|
||||
.padding(.top, 16)
|
||||
.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."
|
||||
@@ -110,7 +144,23 @@ struct UnlockView: View {
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
.onAppear { startAnimations() }
|
||||
.onAppear {
|
||||
avatarImage = AvatarRepository.shared.loadAvatar(publicKey: publicKey)
|
||||
startAnimations()
|
||||
}
|
||||
.task { await autoTriggerBiometric() }
|
||||
.confirmationDialog("Switch Account", isPresented: $showAccountPicker, titleVisibility: .visible) {
|
||||
ForEach(AccountManager.shared.allAccounts, id: \.publicKey) { account in
|
||||
let isActive = account.publicKey == publicKey
|
||||
Button(accountLabel(for: account)) {
|
||||
if !isActive {
|
||||
switchAccount(to: account.publicKey)
|
||||
}
|
||||
}
|
||||
.disabled(isActive)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,28 +170,7 @@ private extension UnlockView {
|
||||
var passwordField: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
GlassCard(cornerRadius: 14, fillOpacity: 0.08) {
|
||||
HStack(spacing: 12) {
|
||||
Group {
|
||||
if showPassword {
|
||||
TextField("Password", text: $password)
|
||||
} else {
|
||||
SecureField("Password", text: $password)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.white)
|
||||
.textContentType(.password)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { unlock() }
|
||||
|
||||
Image(systemName: showPassword ? "eye.slash" : "eye")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(Color.white.opacity(0.5))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { showPassword.toggle() }
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
UnlockPasswordField(text: $password, onSubmit: { unlock() })
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
@@ -167,14 +196,12 @@ private extension UnlockView {
|
||||
private extension UnlockView {
|
||||
var unlockButton: some View {
|
||||
Button(action: unlock) {
|
||||
HStack(spacing: 10) {
|
||||
Group {
|
||||
if isUnlocking {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.9)
|
||||
} else {
|
||||
Image(systemName: "lock.open.fill")
|
||||
.font(.system(size: 16))
|
||||
Text("Enter")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
@@ -183,8 +210,31 @@ private extension UnlockView {
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
}
|
||||
.buttonStyle(RosettaPrimaryButtonStyle(isEnabled: !password.isEmpty && !isUnlocking))
|
||||
.disabled(password.isEmpty || isUnlocking)
|
||||
.buttonStyle(RosettaPrimaryButtonStyle())
|
||||
.shine()
|
||||
.disabled(isUnlocking)
|
||||
}
|
||||
|
||||
/// Desktop parity: fingerprint/biometric icon instead of lock.
|
||||
private var biometricIconName: String {
|
||||
BiometricAuthManager.shared.biometricIconName
|
||||
}
|
||||
|
||||
/// Face ID / Touch ID button — allows user to re-trigger biometric unlock.
|
||||
var biometricButton: some View {
|
||||
Button {
|
||||
triggerBiometricUnlock()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: biometricIconName)
|
||||
.font(.system(size: 20))
|
||||
Text("Unlock with \(BiometricAuthManager.shared.biometricName)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(Color.white.opacity(0.8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isUnlocking)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,13 +285,30 @@ private extension UnlockView {
|
||||
|
||||
private extension UnlockView {
|
||||
func unlock() {
|
||||
guard !password.isEmpty, !isUnlocking else { return }
|
||||
guard !isUnlocking else { return }
|
||||
guard !password.isEmpty else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
errorMessage = "Please enter your password."
|
||||
}
|
||||
return
|
||||
}
|
||||
isUnlocking = true
|
||||
errorMessage = nil
|
||||
|
||||
let enteredPassword = password
|
||||
let accountKey = publicKey
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await SessionManager.shared.startSession(password: password)
|
||||
try await SessionManager.shared.startSession(password: enteredPassword)
|
||||
|
||||
// If biometric is enabled, update the stored password
|
||||
// (in case user changed password and re-entered it manually)
|
||||
let biometric = BiometricAuthManager.shared
|
||||
if biometric.isBiometricEnabled(forAccount: accountKey) {
|
||||
try? biometric.savePassword(enteredPassword, forAccount: accountKey)
|
||||
}
|
||||
|
||||
onUnlocked()
|
||||
} catch {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
@@ -252,6 +319,34 @@ private extension UnlockView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Label for account in the picker dialog.
|
||||
func accountLabel(for account: Account) -> String {
|
||||
let name = account.displayName ?? ""
|
||||
if !name.isEmpty { return name }
|
||||
guard account.publicKey.count >= 7 else { return account.publicKey }
|
||||
return String(account.publicKey.prefix(7))
|
||||
}
|
||||
|
||||
/// Switches active account — resets password/error/biometric state, reloads avatar.
|
||||
/// Desktop parity: `selectAccountToLoginDice()` in `Lockscreen.tsx`.
|
||||
func switchAccount(to newPublicKey: String) {
|
||||
AccountManager.shared.setActiveAccount(publicKey: newPublicKey)
|
||||
|
||||
// Reset input state
|
||||
password = ""
|
||||
errorMessage = nil
|
||||
isUnlocking = false
|
||||
biometricTriggered = false
|
||||
|
||||
// Reload avatar for the new account
|
||||
avatarImage = AvatarRepository.shared.loadAvatar(publicKey: newPublicKey)
|
||||
|
||||
// Auto-trigger biometric for the new account if available
|
||||
Task {
|
||||
await autoTriggerBiometric()
|
||||
}
|
||||
}
|
||||
|
||||
func startAnimations() {
|
||||
withAnimation(.easeOut(duration: 0.3)) { showAvatar = true }
|
||||
withAnimation(.easeOut(duration: 0.3).delay(0.08)) { showTitle = true }
|
||||
@@ -260,4 +355,183 @@ private extension UnlockView {
|
||||
withAnimation(.easeOut(duration: 0.3).delay(0.20)) { showButton = true }
|
||||
withAnimation(.easeOut(duration: 0.3).delay(0.24)) { showFooter = true }
|
||||
}
|
||||
|
||||
/// Auto-triggers biometric unlock after animations complete.
|
||||
func autoTriggerBiometric() async {
|
||||
guard canUseBiometric, !biometricTriggered else { return }
|
||||
biometricTriggered = true
|
||||
|
||||
// Wait for staggered animations to finish before showing Face ID prompt
|
||||
try? await Task.sleep(nanoseconds: 600_000_000)
|
||||
|
||||
guard !isUnlocking else { return }
|
||||
await performBiometricUnlock()
|
||||
}
|
||||
|
||||
/// Triggered by user tapping the biometric button.
|
||||
func triggerBiometricUnlock() {
|
||||
guard !isUnlocking else { return }
|
||||
Task {
|
||||
await performBiometricUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs the biometric unlock flow: authenticate → load password → start session.
|
||||
func performBiometricUnlock() async {
|
||||
guard !isUnlocking else { return }
|
||||
isUnlocking = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let storedPassword = try await BiometricAuthManager.shared.unlockWithBiometric(
|
||||
forAccount: publicKey
|
||||
)
|
||||
try await SessionManager.shared.startSession(password: storedPassword)
|
||||
onUnlocked()
|
||||
} catch let error as BiometricError {
|
||||
isUnlocking = false
|
||||
// User cancelled — silently return to password input
|
||||
if case .cancelled = error { return }
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
// SessionManager.startSession failed — stored password might be wrong
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
errorMessage = "Wrong password. Please try again."
|
||||
}
|
||||
isUnlocking = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIKit Password Field
|
||||
|
||||
/// UITextField subclass with locked intrinsicContentSize.
|
||||
/// Prevents layout propagation to SwiftUI when isSecureTextEntry toggles.
|
||||
private final class UnlockTextField: UITextField {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: 50)
|
||||
}
|
||||
|
||||
override func textRect(forBounds bounds: CGRect) -> CGRect {
|
||||
bounds.inset(by: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 46))
|
||||
}
|
||||
|
||||
override func editingRect(forBounds bounds: CGRect) -> CGRect {
|
||||
textRect(forBounds: bounds)
|
||||
}
|
||||
|
||||
override func rightViewRect(forBounds bounds: CGRect) -> CGRect {
|
||||
CGRect(x: bounds.width - 16 - 30, y: (bounds.height - 30) / 2, width: 30, height: 30)
|
||||
}
|
||||
|
||||
override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
|
||||
textRect(forBounds: bounds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps UIKit UITextField with built-in eye toggle as rightView.
|
||||
/// Toggle happens entirely in UIKit — no SwiftUI state, no body re-evaluation.
|
||||
private struct UnlockPasswordField: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
var onSubmit: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIView(context: Context) -> UnlockTextField {
|
||||
let tf = UnlockTextField()
|
||||
context.coordinator.textField = tf
|
||||
|
||||
tf.isSecureTextEntry = true
|
||||
tf.font = .systemFont(ofSize: 16)
|
||||
tf.textColor = .white
|
||||
tf.tintColor = UIColor(RosettaColors.primaryBlue)
|
||||
tf.autocapitalizationType = .none
|
||||
tf.autocorrectionType = .no
|
||||
tf.spellCheckingType = .no
|
||||
tf.textContentType = .password
|
||||
tf.returnKeyType = .done
|
||||
tf.backgroundColor = .clear
|
||||
tf.setContentHuggingPriority(.required, for: .vertical)
|
||||
tf.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
|
||||
tf.delegate = context.coordinator
|
||||
tf.addTarget(
|
||||
context.coordinator,
|
||||
action: #selector(Coordinator.textChanged(_:)),
|
||||
for: .editingChanged
|
||||
)
|
||||
tf.attributedPlaceholder = NSAttributedString(
|
||||
string: "Password",
|
||||
attributes: [.foregroundColor: UIColor.white.withAlphaComponent(0.3)]
|
||||
)
|
||||
|
||||
// Eye toggle button — entirely UIKit, light weight for clean Mantine-like look
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin)
|
||||
let eyeButton = UIButton(type: .system)
|
||||
eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal)
|
||||
eyeButton.tintColor = UIColor.white.withAlphaComponent(0.4)
|
||||
eyeButton.addTarget(
|
||||
context.coordinator,
|
||||
action: #selector(Coordinator.toggleSecure),
|
||||
for: .touchUpInside
|
||||
)
|
||||
eyeButton.frame = CGRect(x: 0, y: 0, width: 30, height: 30)
|
||||
eyeButton.accessibilityLabel = "Show password"
|
||||
tf.rightView = eyeButton
|
||||
tf.rightViewMode = .always
|
||||
|
||||
return tf
|
||||
}
|
||||
|
||||
func updateUIView(_ tf: UnlockTextField, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
if tf.text != text { tf.text = text }
|
||||
}
|
||||
|
||||
func sizeThatFits(
|
||||
_ proposal: ProposedViewSize,
|
||||
uiView: UnlockTextField,
|
||||
context: Context
|
||||
) -> CGSize? {
|
||||
CGSize(width: proposal.width ?? 200, height: 50)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UITextFieldDelegate {
|
||||
var parent: UnlockPasswordField
|
||||
weak var textField: UnlockTextField?
|
||||
|
||||
init(parent: UnlockPasswordField) { self.parent = parent }
|
||||
|
||||
@objc func textChanged(_ tf: UITextField) {
|
||||
parent.text = tf.text ?? ""
|
||||
}
|
||||
|
||||
@objc func toggleSecure() {
|
||||
guard let tf = textField else { return }
|
||||
UIView.performWithoutAnimation {
|
||||
let existingText = tf.text
|
||||
tf.isSecureTextEntry.toggle()
|
||||
tf.text = ""
|
||||
tf.text = existingText
|
||||
|
||||
let imageName = tf.isSecureTextEntry ? "eye" : "eye.slash"
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin)
|
||||
let button = tf.rightView as? UIButton
|
||||
button?.setImage(
|
||||
UIImage(systemName: imageName, withConfiguration: config),
|
||||
for: .normal
|
||||
)
|
||||
button?.accessibilityLabel = tf.isSecureTextEntry
|
||||
? "Show password"
|
||||
: "Hide password"
|
||||
}
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
parent.onSubmit()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,34 @@ private struct ComposerHeightKey: PreferenceKey {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads keyboardPadding in its own observation scope —
|
||||
/// parent body is NOT re-evaluated on padding changes.
|
||||
private struct KeyboardSpacer: View {
|
||||
@ObservedObject private var keyboard = KeyboardTracker.shared
|
||||
let composerHeight: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Color.clear.frame(height: composerHeight + keyboard.keyboardPadding + 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies keyboard bottom padding in an isolated observation scope.
|
||||
/// Parent view is NOT marked dirty when keyboardPadding changes.
|
||||
private struct KeyboardPaddedView<Content: View>: View {
|
||||
@ObservedObject private var keyboard = KeyboardTracker.shared
|
||||
let extraPadding: CGFloat
|
||||
let content: Content
|
||||
|
||||
init(extraPadding: CGFloat = 0, @ViewBuilder content: () -> Content) {
|
||||
self.extraPadding = extraPadding
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content.offset(y: -(keyboard.keyboardPadding + extraPadding))
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatDetailView: View {
|
||||
let route: ChatRoute
|
||||
var onPresentedChange: ((Bool) -> Void)? = nil
|
||||
@@ -30,8 +58,10 @@ struct ChatDetailView: View {
|
||||
@State private var isInputFocused = false
|
||||
@State private var isAtBottom = true
|
||||
@State private var composerHeight: CGFloat = 56
|
||||
@StateObject private var keyboard = KeyboardTracker()
|
||||
@State private var shouldScrollOnNextMessage = false
|
||||
/// Captured on chat open — ID of the first unread incoming message (for separator).
|
||||
@State private var firstUnreadMessageId: String?
|
||||
@State private var isSendingAvatar = false
|
||||
|
||||
private var currentPublicKey: String {
|
||||
SessionManager.shared.currentPublicKey
|
||||
@@ -66,6 +96,8 @@ struct ChatDetailView: View {
|
||||
|
||||
private var subtitleText: String {
|
||||
if route.isSavedMessages { return "" }
|
||||
// Desktop parity: system accounts show "official account" instead of online/offline
|
||||
if route.isSystemAccount { return "official account" }
|
||||
if isTyping { return "typing..." }
|
||||
if let dialog, dialog.isOnline { return "online" }
|
||||
return "offline"
|
||||
@@ -115,20 +147,38 @@ struct ChatDetailView: View {
|
||||
}
|
||||
.overlay { chatEdgeGradients }
|
||||
.overlay(alignment: .bottom) {
|
||||
if !route.isSystemAccount {
|
||||
KeyboardPaddedView {
|
||||
composer
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear.preference(key: ComposerHeightKey.self, value: geo.size.height)
|
||||
}
|
||||
)
|
||||
.padding(.bottom, keyboard.keyboardPadding)
|
||||
}
|
||||
.onPreferenceChange(ComposerHeightKey.self) { composerHeight = $0 }
|
||||
.ignoresSafeArea(.keyboard)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(ComposerHeightKey.self) { newHeight in
|
||||
composerHeight = newHeight
|
||||
}
|
||||
.modifier(IgnoreKeyboardSafeAreaLegacy())
|
||||
.background {
|
||||
ZStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
RosettaColors.Adaptive.background
|
||||
tiledChatBackground
|
||||
// Telegram-style: dark gradient at screen bottom (home indicator area).
|
||||
// In background (not overlay) so it never moves with keyboard.
|
||||
if #unavailable(iOS 26) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.0),
|
||||
Color.black.opacity(0.55)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 34)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
@@ -140,6 +190,12 @@ struct ChatDetailView: View {
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
.task {
|
||||
isViewActive = true
|
||||
// Capture first unread incoming message BEFORE marking as read.
|
||||
if firstUnreadMessageId == nil {
|
||||
firstUnreadMessageId = messages.first(where: {
|
||||
!$0.isRead && $0.fromPublicKey != currentPublicKey
|
||||
})?.id
|
||||
}
|
||||
// Desktop parity: restore draft text from DraftManager.
|
||||
let draft = DraftManager.shared.getDraft(for: route.publicKey)
|
||||
if !draft.isEmpty {
|
||||
@@ -161,6 +217,8 @@ struct ChatDetailView: View {
|
||||
guard isViewActive else { return }
|
||||
activateDialog()
|
||||
markDialogAsRead()
|
||||
// Desktop parity: skip online subscription and user info fetch for system accounts
|
||||
if !route.isSystemAccount {
|
||||
// Subscribe to opponent's online status (Android parity) — only after settled
|
||||
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
|
||||
// Desktop parity: force-refresh user info (incl. online status) on chat open.
|
||||
@@ -169,15 +227,19 @@ struct ChatDetailView: View {
|
||||
SessionManager.shared.forceRefreshUserInfo(publicKey: route.publicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
isViewActive = false
|
||||
firstUnreadMessageId = nil
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
|
||||
// Desktop parity: save draft text on chat close.
|
||||
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View { content }
|
||||
var body: some View {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatDetailView {
|
||||
@@ -244,7 +306,8 @@ private extension ChatDetailView {
|
||||
colorIndex: avatarColorIndex,
|
||||
size: 35,
|
||||
isOnline: false,
|
||||
isSavedMessages: route.isSavedMessages
|
||||
isSavedMessages: route.isSavedMessages,
|
||||
image: opponentAvatar
|
||||
)
|
||||
.frame(width: 36, height: 36)
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
@@ -300,7 +363,8 @@ private extension ChatDetailView {
|
||||
colorIndex: avatarColorIndex,
|
||||
size: 38,
|
||||
isOnline: false,
|
||||
isSavedMessages: route.isSavedMessages
|
||||
isSavedMessages: route.isSavedMessages,
|
||||
image: opponentAvatar
|
||||
)
|
||||
.frame(width: 44, height: 44)
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
@@ -335,6 +399,11 @@ private extension ChatDetailView {
|
||||
RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
|
||||
}
|
||||
|
||||
/// Avatar image for the opponent. System accounts return a bundled static image.
|
||||
var opponentAvatar: UIImage? {
|
||||
AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
|
||||
}
|
||||
|
||||
var incomingBubbleFill: Color {
|
||||
RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E))
|
||||
}
|
||||
@@ -415,7 +484,8 @@ private extension ChatDetailView {
|
||||
colorIndex: avatarColorIndex,
|
||||
size: 80,
|
||||
isOnline: dialog?.isOnline ?? false,
|
||||
isSavedMessages: route.isSavedMessages
|
||||
isSavedMessages: route.isSavedMessages,
|
||||
image: opponentAvatar
|
||||
)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
@@ -456,8 +526,8 @@ private extension ChatDetailView {
|
||||
// Spacer for composer + keyboard — OUTSIDE LazyVStack so padding
|
||||
// changes only shift the LazyVStack as a whole block (cheap),
|
||||
// instead of re-laying out every cell inside it (expensive).
|
||||
Color.clear
|
||||
.frame(height: composerHeight + keyboard.keyboardPadding + 4)
|
||||
// Isolated in KeyboardSpacer to avoid marking parent dirty.
|
||||
KeyboardSpacer(composerHeight: composerHeight)
|
||||
|
||||
// LazyVStack: only visible cells are loaded. Internal layout
|
||||
// is unaffected by the spacer above changing height.
|
||||
@@ -479,6 +549,13 @@ private extension ChatDetailView {
|
||||
)
|
||||
.scaleEffect(x: 1, y: -1) // flip each row back to normal
|
||||
.id(message.id)
|
||||
|
||||
// Unread Messages separator (Telegram style).
|
||||
// In inverted scroll, "above" visually = after in code.
|
||||
if message.id == firstUnreadMessageId {
|
||||
unreadSeparator
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,10 +589,12 @@ private extension ChatDetailView {
|
||||
scroll
|
||||
.scrollIndicators(.hidden)
|
||||
.overlay(alignment: .bottom) {
|
||||
KeyboardPaddedView(extraPadding: composerHeight + 4) {
|
||||
scrollToBottomButton(proxy: proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View {
|
||||
@@ -542,7 +621,6 @@ private extension ChatDetailView {
|
||||
.transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.padding(.bottom, composerHeight + keyboard.keyboardPadding + 4)
|
||||
.padding(.trailing, composerTrailingPadding)
|
||||
.allowsHitTesting(!isAtBottom)
|
||||
}
|
||||
@@ -555,7 +633,7 @@ private extension ChatDetailView {
|
||||
|
||||
// Telegram-style compact bubble: inline time+status at bottom-trailing.
|
||||
// Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming).
|
||||
Text(messageText)
|
||||
Text(parsedMarkdown(messageText))
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
@@ -600,6 +678,34 @@ private extension ChatDetailView {
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
|
||||
// MARK: - Markdown Parsing
|
||||
|
||||
/// Parses inline markdown (`**bold**`) from runtime strings.
|
||||
/// Falls back to plain `AttributedString` if parsing fails.
|
||||
private func parsedMarkdown(_ text: String) -> AttributedString {
|
||||
if let parsed = try? AttributedString(
|
||||
markdown: text,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
) {
|
||||
return parsed
|
||||
}
|
||||
return AttributedString(text)
|
||||
}
|
||||
|
||||
// MARK: - Unread Separator
|
||||
|
||||
private var unreadSeparator: some View {
|
||||
Text("Unread Messages")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.08))
|
||||
.padding(.horizontal, -10) // compensate scroll content padding
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
|
||||
// MARK: - Composer
|
||||
|
||||
var composer: some View {
|
||||
@@ -613,8 +719,15 @@ private extension ChatDetailView {
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
// Desktop parity: paperclip opens attachment menu with camera option.
|
||||
// Camera sends current user's avatar to this chat.
|
||||
Menu {
|
||||
Button {
|
||||
// Placeholder for attachment picker
|
||||
sendAvatarToChat()
|
||||
} label: {
|
||||
Label("Send Avatar", systemImage: "camera.fill")
|
||||
}
|
||||
.disabled(isSendingAvatar)
|
||||
} label: {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.paperclip,
|
||||
@@ -633,7 +746,7 @@ private extension ChatDetailView {
|
||||
text: $messageText,
|
||||
isFocused: $isInputFocused,
|
||||
onKeyboardHeightChange: { height in
|
||||
keyboard.updateFromKVO(keyboardHeight: height)
|
||||
KeyboardTracker.shared.updateFromKVO(keyboardHeight: height)
|
||||
},
|
||||
onUserTextInsertion: handleComposerUserTyping,
|
||||
textColor: UIColor(RosettaColors.Adaptive.text),
|
||||
@@ -724,26 +837,6 @@ private extension ChatDetailView {
|
||||
.animation(composerAnimation, value: canSend)
|
||||
.animation(composerAnimation, value: shouldShowSendButton)
|
||||
}
|
||||
.background {
|
||||
if #available(iOS 26, *) {
|
||||
Color.clear
|
||||
} else {
|
||||
// Telegram-style: dark gradient below composer → home indicator
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.0),
|
||||
Color.black.opacity(0.55)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 34)
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
|
||||
@@ -957,8 +1050,11 @@ private extension ChatDetailView {
|
||||
func markDialogAsRead() {
|
||||
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
|
||||
MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
|
||||
// Desktop parity: don't send read receipts for system accounts
|
||||
if !route.isSystemAccount {
|
||||
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove all delivered push notifications from this specific sender.
|
||||
func clearDeliveredNotifications(for senderKey: String) {
|
||||
@@ -1001,6 +1097,26 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop parity: onClickCamera() — sends current user's avatar to this chat.
|
||||
func sendAvatarToChat() {
|
||||
guard !isSendingAvatar else { return }
|
||||
isSendingAvatar = true
|
||||
sendError = nil
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await SessionManager.shared.sendAvatar(
|
||||
toPublicKey: route.publicKey,
|
||||
opponentTitle: route.title,
|
||||
opponentUsername: route.username
|
||||
)
|
||||
} catch {
|
||||
sendError = "Failed to send avatar"
|
||||
}
|
||||
isSendingAvatar = false
|
||||
}
|
||||
}
|
||||
|
||||
func handleComposerUserTyping() {
|
||||
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
||||
}
|
||||
@@ -1286,6 +1402,18 @@ private enum TelegramIconPath {
|
||||
static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"#
|
||||
}
|
||||
|
||||
/// iOS < 26: ignore keyboard safe area (manual KeyboardTracker handles offset).
|
||||
/// iOS 26+: let SwiftUI handle keyboard natively — no manual tracking.
|
||||
private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
} else {
|
||||
content.ignoresSafeArea(.keyboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
|
||||
@@ -182,6 +182,10 @@ private extension ChatListSearchContent {
|
||||
name: user.title, publicKey: user.publicKey
|
||||
)
|
||||
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
|
||||
let effectiveVerified = Self.effectiveVerifiedLevel(
|
||||
verified: user.verified, title: user.title,
|
||||
username: user.username, publicKey: user.publicKey
|
||||
)
|
||||
|
||||
return Button {
|
||||
onOpenDialog(ChatRoute(recent: user))
|
||||
@@ -193,6 +197,7 @@ private extension ChatListSearchContent {
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Text(isSelf ? "Saved Messages" : (
|
||||
user.title.isEmpty
|
||||
? String(user.publicKey.prefix(16)) + "..."
|
||||
@@ -201,8 +206,19 @@ private extension ChatListSearchContent {
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
if !user.lastSeenText.isEmpty {
|
||||
Text(user.lastSeenText)
|
||||
if !isSelf && effectiveVerified > 0 {
|
||||
VerifiedBadge(
|
||||
verified: effectiveVerified,
|
||||
size: 14
|
||||
)
|
||||
}
|
||||
}
|
||||
// Desktop parity: search subtitle shows @username, not online/offline.
|
||||
if !isSelf {
|
||||
Text(user.username.isEmpty
|
||||
? "@\(String(user.publicKey.prefix(10)))..."
|
||||
: "@\(user.username)"
|
||||
)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
@@ -216,6 +232,17 @@ private extension ChatListSearchContent {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// Desktop parity: compute effective verified level — server value + client heuristic.
|
||||
private static func effectiveVerifiedLevel(
|
||||
verified: Int, title: String, username: String, publicKey: String
|
||||
) -> Int {
|
||||
if verified > 0 { return verified }
|
||||
if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 }
|
||||
if username.caseInsensitiveCompare("rosetta") == .orderedSame { return 1 }
|
||||
if SystemAccounts.isSystemAccount(publicKey) { return 1 }
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server User Row
|
||||
|
||||
@@ -355,12 +355,17 @@ private struct ChatListToolbarBackgroundModifier: ViewModifier {
|
||||
|
||||
// MARK: - Toolbar Title (observation-isolated)
|
||||
|
||||
/// Reads `ProtocolManager.shared.connectionState` in its own observation scope.
|
||||
/// Connection state changes during handshake (4+ rapid transitions) are absorbed here,
|
||||
/// Reads `ProtocolManager.shared.connectionState` and `SessionManager.shared.syncBatchInProgress`
|
||||
/// in its own observation scope. State changes are absorbed here,
|
||||
/// not cascaded to the parent ChatListView / NavigationStack.
|
||||
private struct ToolbarTitleView: View {
|
||||
var body: some View {
|
||||
let state = ProtocolManager.shared.connectionState
|
||||
let isSyncing = SessionManager.shared.syncBatchInProgress
|
||||
|
||||
if state == .authenticated && isSyncing {
|
||||
UpdatingDotsView()
|
||||
} else {
|
||||
let title: String = switch state {
|
||||
case .authenticated: "Chats"
|
||||
default: "Connecting..."
|
||||
@@ -371,6 +376,38 @@ private struct ToolbarTitleView: View {
|
||||
.contentTransition(.numericText())
|
||||
.animation(.easeInOut(duration: 0.25), value: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop parity: "Updating..." with bouncing dots animation during sync.
|
||||
private struct UpdatingDotsView: View {
|
||||
@State private var activeDot = 0
|
||||
private let dotCount = 3
|
||||
private let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 1) {
|
||||
Text("Updating")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<dotCount, id: \.self) { index in
|
||||
Text(".")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.offset(y: activeDot == index ? -3 : 0)
|
||||
.animation(
|
||||
.easeInOut(duration: 0.3),
|
||||
value: activeDot
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
activeDot = (activeDot + 1) % dotCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar Stories Avatar (observation-isolated)
|
||||
@@ -385,7 +422,34 @@ private struct ToolbarStoriesAvatar: View {
|
||||
name: SessionManager.shared.displayName, publicKey: pk
|
||||
)
|
||||
let colorIdx = RosettaColors.avatarColorIndex(for: SessionManager.shared.displayName, publicKey: pk)
|
||||
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
|
||||
// Reading avatarVersion triggers observation — re-renders when any avatar is saved/removed.
|
||||
let _ = AvatarRepository.shared.avatarVersion
|
||||
let avatar = AvatarRepository.shared.loadAvatar(publicKey: pk)
|
||||
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28, image: avatar) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sync-Aware Empty State (observation-isolated)
|
||||
|
||||
/// Shows "Syncing..." indicator when sync is in progress, otherwise shows empty state.
|
||||
/// Reads `SessionManager.syncBatchInProgress` in its own observation scope.
|
||||
private struct SyncAwareEmptyState: View {
|
||||
var body: some View {
|
||||
let isSyncing = SessionManager.shared.syncBatchInProgress
|
||||
if isSyncing {
|
||||
VStack(spacing: 16) {
|
||||
Spacer().frame(height: 120)
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
Text("Syncing conversations…")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color.white.opacity(0.5))
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
ChatEmptyStateView(searchText: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +491,7 @@ private struct ChatListDialogContent: View {
|
||||
var body: some View {
|
||||
let hasPinned = !viewModel.pinnedDialogs.isEmpty
|
||||
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
|
||||
ChatEmptyStateView(searchText: "")
|
||||
SyncAwareEmptyState()
|
||||
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
|
||||
.onAppear { onPinnedStateChange(hasPinned) }
|
||||
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
|
||||
|
||||
@@ -61,7 +61,8 @@ private extension ChatRowView {
|
||||
colorIndex: dialog.avatarColorIndex,
|
||||
size: 62,
|
||||
isOnline: dialog.isOnline,
|
||||
isSavedMessages: dialog.isSavedMessages
|
||||
isSavedMessages: dialog.isSavedMessages,
|
||||
image: AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -144,7 +145,8 @@ private extension ChatRowView {
|
||||
if dialog.lastMessage.isEmpty {
|
||||
return "No messages yet"
|
||||
}
|
||||
return dialog.lastMessage
|
||||
// Strip inline markdown markers for clean chat list preview
|
||||
return dialog.lastMessage.replacingOccurrences(of: "**", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ struct ChatRoute: Hashable {
|
||||
publicKey: recent.publicKey,
|
||||
title: recent.title,
|
||||
username: recent.username,
|
||||
verified: 0
|
||||
verified: recent.verified
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ struct ChatRoute: Hashable {
|
||||
publicKey == SessionManager.shared.currentPublicKey
|
||||
}
|
||||
|
||||
var isSystemAccount: Bool {
|
||||
SystemAccounts.isSystemAccount(publicKey)
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
if isSavedMessages {
|
||||
return "Saved Messages"
|
||||
|
||||
@@ -256,6 +256,10 @@ private struct RecentSection: View {
|
||||
let isSelf = user.publicKey == currentPK
|
||||
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
|
||||
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
|
||||
let effectiveVerified = Self.effectiveVerifiedLevel(
|
||||
verified: user.verified, title: user.title,
|
||||
username: user.username, publicKey: user.publicKey
|
||||
)
|
||||
|
||||
return Button {
|
||||
navigationPath.append(ChatRoute(recent: user))
|
||||
@@ -269,13 +273,24 @@ private struct RecentSection: View {
|
||||
)
|
||||
|
||||
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.lastSeenText.isEmpty {
|
||||
Text(user.lastSeenText)
|
||||
if !isSelf && effectiveVerified > 0 {
|
||||
VerifiedBadge(
|
||||
verified: effectiveVerified,
|
||||
size: 14
|
||||
)
|
||||
}
|
||||
}
|
||||
// Desktop parity: search subtitle shows @username, not online/offline.
|
||||
if !isSelf {
|
||||
Text(user.username.isEmpty
|
||||
? "@\(String(user.publicKey.prefix(10)))..."
|
||||
: "@\(user.username)"
|
||||
)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
@@ -290,6 +305,17 @@ private struct RecentSection: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// Desktop parity: compute effective verified level — server value + client heuristic.
|
||||
private static func effectiveVerifiedLevel(
|
||||
verified: Int, title: String, username: String, publicKey: String
|
||||
) -> Int {
|
||||
if verified > 0 { return verified }
|
||||
if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 }
|
||||
if username.caseInsensitiveCompare("rosetta") == .orderedSame { return 1 }
|
||||
if SystemAccounts.isSystemAccount(publicKey) { return 1 }
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Results Content
|
||||
|
||||
@@ -3,10 +3,19 @@ import SwiftUI
|
||||
/// Main container view with tab-based navigation.
|
||||
struct MainTabView: View {
|
||||
var onLogout: (() -> Void)?
|
||||
/// Always start on Chats tab after login / account switch.
|
||||
/// Using @State (not @SceneStorage) ensures the tab resets to .chats
|
||||
/// when MainTabView is recreated after an account switch or unlock.
|
||||
@State private var selectedTab: RosettaTab = .chats
|
||||
@State private var isChatSearchActive = false
|
||||
@State private var isChatListDetailPresented = false
|
||||
@State private var isSettingsEditPresented = false
|
||||
@State private var isSettingsDetailPresented = false
|
||||
|
||||
// Add Account — presented as fullScreenCover so Settings stays alive.
|
||||
// Using optional AuthScreen as the item ensures the correct screen is
|
||||
// passed directly to the content closure (no stale capture).
|
||||
@State private var addAccountScreen: AuthScreen?
|
||||
/// All tabs are pre-activated so that switching only changes the offset,
|
||||
/// not the view structure. Creating a NavigationStack mid-animation causes
|
||||
/// "Update NavigationRequestObserver tried to update multiple times per frame" → freeze.
|
||||
@@ -14,6 +23,13 @@ struct MainTabView: View {
|
||||
/// When non-nil, the tab bar is being dragged and the pager follows interactively.
|
||||
@State private var dragFractionalIndex: CGFloat?
|
||||
|
||||
/// Local handler for Add Account — triggers fullScreenCover instead of app-level navigation.
|
||||
private var handleAddAccount: (AuthScreen) -> Void {
|
||||
{ screen in
|
||||
addAccountScreen = screen
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if #available(iOS 26.0, *) {
|
||||
@@ -22,6 +38,20 @@ struct MainTabView: View {
|
||||
legacyTabView
|
||||
}
|
||||
}
|
||||
.fullScreenCover(item: $addAccountScreen) { screen in
|
||||
AuthCoordinator(
|
||||
onAuthComplete: {
|
||||
addAccountScreen = nil
|
||||
// New account created — end session and go to unlock for the new account
|
||||
SessionManager.shared.endSession()
|
||||
onLogout?()
|
||||
},
|
||||
onBackToUnlock: {
|
||||
addAccountScreen = nil
|
||||
},
|
||||
initialScreen: screen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS 26+ (native TabView with liquid glass tab bar)
|
||||
@@ -45,7 +75,7 @@ struct MainTabView: View {
|
||||
.tag(RosettaTab.chats)
|
||||
.badge(chatUnreadCount)
|
||||
|
||||
SettingsView(onLogout: onLogout, isEditingProfile: $isSettingsEditPresented)
|
||||
SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented)
|
||||
.tabItem {
|
||||
Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon)
|
||||
}
|
||||
@@ -66,7 +96,7 @@ struct MainTabView: View {
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented {
|
||||
if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented && !isSettingsDetailPresented {
|
||||
RosettaTabBar(
|
||||
selectedTab: selectedTab,
|
||||
onTabSelected: { tab in
|
||||
@@ -93,6 +123,7 @@ struct MainTabView: View {
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.onChange(of: isChatSearchActive) { _, isActive in
|
||||
if isActive {
|
||||
dragFractionalIndex = nil
|
||||
@@ -143,7 +174,7 @@ struct MainTabView: View {
|
||||
case .calls:
|
||||
CallsView()
|
||||
case .settings:
|
||||
SettingsView(onLogout: onLogout, isEditingProfile: $isSettingsEditPresented)
|
||||
SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented)
|
||||
}
|
||||
} else {
|
||||
RosettaColors.Adaptive.background
|
||||
|
||||
333
Rosetta/Features/Settings/BackupView.swift
Normal file
333
Rosetta/Features/Settings/BackupView.swift
Normal file
@@ -0,0 +1,333 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Backup screen — desktop/Android parity.
|
||||
/// User enters password → decrypts seed phrase → displays 12-word grid.
|
||||
struct BackupView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var password = ""
|
||||
@State private var seedWords: [String] = []
|
||||
@State private var isVerifying = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var isPasswordVisible = false
|
||||
@State private var copied = false
|
||||
@FocusState private var isPasswordFocused: Bool
|
||||
|
||||
private var isUnlocked: Bool { !seedWords.isEmpty }
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
if isUnlocked {
|
||||
seedPhraseContent
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
passwordContent
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: isUnlocked)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
.background(RosettaColors.Adaptive.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.enableSwipeBack()
|
||||
.toolbar { toolbarContent }
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassCircle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Content
|
||||
|
||||
private var passwordContent: some View {
|
||||
VStack(spacing: 24) {
|
||||
LottieView(
|
||||
animationName: "lock",
|
||||
animationSpeed: 1.0
|
||||
)
|
||||
.frame(width: 120, height: 120)
|
||||
.padding(.top, 20)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Enter your password")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Text("To view your seed phrase, please enter your account password.")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
passwordField
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
verifyButton
|
||||
}
|
||||
}
|
||||
|
||||
private var passwordField: some View {
|
||||
SettingsCard {
|
||||
HStack(spacing: 12) {
|
||||
Group {
|
||||
if isPasswordVisible {
|
||||
TextField("Password", text: $password)
|
||||
} else {
|
||||
SecureField("Password", text: $password)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.focused($isPasswordFocused)
|
||||
.textContentType(.password)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { verifyPassword() }
|
||||
|
||||
Button {
|
||||
isPasswordVisible.toggle()
|
||||
} label: {
|
||||
Image(systemName: isPasswordVisible ? "eye.slash" : "eye")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 52)
|
||||
}
|
||||
.task {
|
||||
// Delay keyboard until push animation finishes
|
||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||
isPasswordFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
private var verifyButton: some View {
|
||||
Button {
|
||||
verifyPassword()
|
||||
} label: {
|
||||
Group {
|
||||
if isVerifying {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Verify")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(RosettaPrimaryButtonStyle())
|
||||
.disabled(password.isEmpty || isVerifying)
|
||||
.opacity(password.isEmpty ? 0.5 : 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Seed Phrase Content
|
||||
|
||||
private var seedPhraseContent: some View {
|
||||
VStack(spacing: 20) {
|
||||
warningBanner
|
||||
|
||||
seedPhraseGrid
|
||||
|
||||
copyButton
|
||||
|
||||
Text("Never share your seed phrase with anyone. Anyone with your seed phrase can access your account.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var warningBanner: some View {
|
||||
SettingsCard {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Keep it secret")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Text("Do not share your seed phrase with anyone.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
private var seedPhraseGrid: some View {
|
||||
let leftColumn = Array(seedWords.prefix(6))
|
||||
let rightColumn = Array(seedWords.dropFirst(6))
|
||||
|
||||
return HStack(alignment: .top, spacing: 12) {
|
||||
seedColumn(words: leftColumn, startIndex: 1)
|
||||
seedColumn(words: rightColumn, startIndex: 7)
|
||||
}
|
||||
}
|
||||
|
||||
private func seedColumn(words: [String], startIndex: Int) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(Array(words.enumerated()), id: \.offset) { offset, word in
|
||||
let globalIndex = startIndex + offset - 1
|
||||
seedWordCard(
|
||||
number: startIndex + offset,
|
||||
word: word,
|
||||
colorIndex: globalIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func seedWordCard(number: Int, word: String, colorIndex: Int) -> some View {
|
||||
let color = RosettaColors.seedWordColors[colorIndex % RosettaColors.seedWordColors.count]
|
||||
|
||||
return HStack(spacing: 8) {
|
||||
Text("\(number).")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(width: 28, alignment: .trailing)
|
||||
|
||||
Text(word)
|
||||
.font(.system(size: 17, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(color)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.modifier(BackupSeedCardStyle(color: color))
|
||||
}
|
||||
|
||||
private var copyButton: some View {
|
||||
Button {
|
||||
UIPasteboard.general.string = seedWords.joined(separator: " ")
|
||||
copied = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
copied = false
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: copied ? "checkmark" : "doc.on.doc")
|
||||
.font(.system(size: 15))
|
||||
Text(copied ? "Copied" : "Copy Seed Phrase")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(RosettaPrimaryButtonStyle())
|
||||
}
|
||||
|
||||
// MARK: - Password Verification
|
||||
|
||||
private func verifyPassword() {
|
||||
guard !password.isEmpty, !isVerifying else { return }
|
||||
isVerifying = true
|
||||
errorMessage = nil
|
||||
|
||||
// Capture for sendable detached task
|
||||
let enteredPassword = password
|
||||
let account = AccountManager.shared.currentAccount
|
||||
let crypto = CryptoManager.shared
|
||||
|
||||
Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
guard let account else {
|
||||
await MainActor.run {
|
||||
errorMessage = "No account found"
|
||||
isVerifying = false
|
||||
}
|
||||
return
|
||||
}
|
||||
let decrypted = try crypto.decryptWithPassword(
|
||||
account.seedPhraseEncrypted,
|
||||
password: enteredPassword
|
||||
)
|
||||
guard let phrase = String(data: decrypted, encoding: .utf8) else {
|
||||
throw CryptoError.decryptionFailed
|
||||
}
|
||||
let words = phrase.components(separatedBy: " ").filter { !$0.isEmpty }
|
||||
guard words.count == 12 else {
|
||||
throw CryptoError.decryptionFailed
|
||||
}
|
||||
await MainActor.run {
|
||||
seedWords = words
|
||||
isVerifying = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = "Wrong password"
|
||||
isVerifying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Seed Card Style
|
||||
|
||||
private struct BackupSeedCardStyle: ViewModifier {
|
||||
let color: Color
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(color.opacity(0.12))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(color.opacity(0.18), lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,19 @@ import SwiftUI
|
||||
/// Embedded profile editing content (no NavigationStack — lives inside SettingsView's).
|
||||
/// Avatar + photo picker, name fields with validation.
|
||||
struct ProfileEditView: View {
|
||||
var onAddAccount: ((AuthScreen) -> Void)?
|
||||
@Binding var displayName: String
|
||||
@Binding var username: String
|
||||
let publicKey: String
|
||||
|
||||
@Binding var displayNameError: String?
|
||||
@Binding var usernameError: String?
|
||||
/// Photo selected but not yet saved — only committed when Done is pressed.
|
||||
@Binding var pendingPhoto: UIImage?
|
||||
|
||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||
@State private var selectedPhoto: UIImage?
|
||||
@State private var showAddAccountSheet = false
|
||||
|
||||
private var initials: String {
|
||||
RosettaColors.initials(name: displayName, publicKey: publicKey)
|
||||
@@ -36,8 +40,13 @@ struct ProfileEditView: View {
|
||||
addAccountSection
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 24)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 100)
|
||||
.task {
|
||||
if selectedPhoto == nil {
|
||||
selectedPhoto = AvatarRepository.shared.loadAvatar(publicKey: publicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,20 +55,13 @@ struct ProfileEditView: View {
|
||||
private extension ProfileEditView {
|
||||
var avatarSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
if let selectedPhoto {
|
||||
Image(uiImage: selectedPhoto)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
AvatarView(
|
||||
initials: initials,
|
||||
colorIndex: avatarColorIndex,
|
||||
size: 80,
|
||||
isSavedMessages: false
|
||||
isSavedMessages: false,
|
||||
image: selectedPhoto
|
||||
)
|
||||
}
|
||||
|
||||
PhotosPicker(selection: $selectedPhotoItem, matching: .images) {
|
||||
Text("Set New Photo")
|
||||
@@ -72,6 +74,8 @@ private extension ProfileEditView {
|
||||
if let data = try? await item?.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
selectedPhoto = image
|
||||
// Preview only — actual save happens when Done is pressed
|
||||
pendingPhoto = image
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,7 +175,9 @@ private extension ProfileEditView {
|
||||
private extension ProfileEditView {
|
||||
var addAccountSection: some View {
|
||||
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
||||
Button {} label: {
|
||||
Button {
|
||||
showAddAccountSheet = true
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Add Another Account")
|
||||
@@ -180,9 +186,31 @@ private extension ProfileEditView {
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 52)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Create account",
|
||||
isPresented: $showAddAccountSheet,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Create New Account") {
|
||||
// Let dialog dismiss animation complete before heavy state changes
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
onAddAccount?(.seedPhrase)
|
||||
}
|
||||
}
|
||||
Button("Import Existing Account") {
|
||||
// Let dialog dismiss animation complete before heavy state changes
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
onAddAccount?(.importSeed)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("You may create a new account or import an existing one.")
|
||||
}
|
||||
}
|
||||
|
||||
func helperText(_ text: String) -> some View {
|
||||
@@ -204,7 +232,8 @@ private extension ProfileEditView {
|
||||
username: .constant("GaidarTheDev"),
|
||||
publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec",
|
||||
displayNameError: .constant(nil),
|
||||
usernameError: .constant(nil)
|
||||
usernameError: .constant(nil),
|
||||
pendingPhoto: .constant(nil)
|
||||
)
|
||||
}
|
||||
.background(RosettaColors.Adaptive.background)
|
||||
|
||||
247
Rosetta/Features/Settings/SafetyView.swift
Normal file
247
Rosetta/Features/Settings/SafetyView.swift
Normal file
@@ -0,0 +1,247 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Safety screen — Android parity.
|
||||
/// Shows public/private keys with copy, backup navigation, and delete account.
|
||||
struct SafetyView: View {
|
||||
var onLogout: (() -> Void)?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var copiedPublicKey = false
|
||||
@State private var copiedPrivateKey = false
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
private var publicKey: String {
|
||||
SessionManager.shared.currentPublicKey
|
||||
}
|
||||
|
||||
private var privateKeyHash: String {
|
||||
SessionManager.shared.privateKeyHash ?? ""
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
keysSection
|
||||
actionsSection
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
.background(RosettaColors.Adaptive.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.enableSwipeBack()
|
||||
.toolbar { toolbarContent }
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.alert("Delete Account", isPresented: $showDeleteConfirmation) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Delete Account", role: .destructive) {
|
||||
let publicKey = SessionManager.shared.currentPublicKey
|
||||
BiometricAuthManager.shared.clearAll(forAccount: publicKey)
|
||||
AvatarRepository.shared.removeAvatar(publicKey: publicKey)
|
||||
// Clear persisted chat files before session ends
|
||||
DialogRepository.shared.reset(clearPersisted: true)
|
||||
MessageRepository.shared.reset(clearPersisted: true)
|
||||
// Clear per-account UserDefaults entries
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)")
|
||||
defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)")
|
||||
defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)")
|
||||
SessionManager.shared.endSession()
|
||||
try? AccountManager.shared.deleteAccount()
|
||||
onLogout?()
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassCircle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keys Section
|
||||
|
||||
private var keysSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("KEYS")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
SettingsCard {
|
||||
VStack(spacing: 0) {
|
||||
copyRow(
|
||||
label: "Public Key",
|
||||
value: publicKey,
|
||||
isCopied: copiedPublicKey,
|
||||
position: .top
|
||||
) {
|
||||
UIPasteboard.general.string = publicKey
|
||||
copiedPublicKey = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
copiedPublicKey = false
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
.background(RosettaColors.Adaptive.divider)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
copyRow(
|
||||
label: "Private Key",
|
||||
value: privateKeyHash,
|
||||
isCopied: copiedPrivateKey,
|
||||
position: .bottom
|
||||
) {
|
||||
UIPasteboard.general.string = privateKeyHash
|
||||
copiedPrivateKey = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
copiedPrivateKey = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Your private key is encrypted. Never share it with anyone.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions Section
|
||||
|
||||
private var actionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SettingsCard {
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(value: SettingsDestination.backup) {
|
||||
HStack {
|
||||
Text("Backup")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.tertiaryText)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 48)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.settingsHighlight(position: .top)
|
||||
|
||||
Divider()
|
||||
.background(RosettaColors.Adaptive.divider)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
actionRow(
|
||||
label: "Delete Account",
|
||||
color: RosettaColors.error,
|
||||
showChevron: false,
|
||||
position: .bottom
|
||||
) {
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Deleting your account will permanently remove all data from this device and the server.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func copyRow(
|
||||
label: String,
|
||||
value: String,
|
||||
isCopied: Bool,
|
||||
position: SettingsRowPosition = .alone,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isCopied {
|
||||
Text("Copied")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.success)
|
||||
} else {
|
||||
Text(truncateKey(value))
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 48)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.settingsHighlight(position: position)
|
||||
}
|
||||
|
||||
private func actionRow(
|
||||
label: String,
|
||||
color: Color,
|
||||
showChevron: Bool = true,
|
||||
position: SettingsRowPosition = .alone,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(color)
|
||||
|
||||
Spacer()
|
||||
|
||||
if showChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.tertiaryText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 48)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.settingsHighlight(position: position)
|
||||
}
|
||||
|
||||
private func truncateKey(_ key: String) -> String {
|
||||
guard key.count > 20 else { return key }
|
||||
return String(key.prefix(20)) + "..."
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Settings Navigation
|
||||
|
||||
enum SettingsDestination: Hashable {
|
||||
case updates
|
||||
case safety
|
||||
case backup
|
||||
}
|
||||
|
||||
/// Settings screen with in-place profile editing transition.
|
||||
/// Avatar stays in place, content fades between settings and edit modes,
|
||||
/// tab bar slides down when editing.
|
||||
struct SettingsView: View {
|
||||
var onLogout: (() -> Void)?
|
||||
var onAddAccount: ((AuthScreen) -> Void)?
|
||||
@Binding var isEditingProfile: Bool
|
||||
@Binding var isDetailPresented: Bool
|
||||
|
||||
@StateObject private var viewModel = SettingsViewModel()
|
||||
@State private var navigationPath = NavigationPath()
|
||||
@State private var showDeleteAccountConfirmation = false
|
||||
@State private var showAddAccountSheet = false
|
||||
|
||||
// Biometric
|
||||
@State private var isBiometricEnabled = false
|
||||
@State private var showBiometricPasswordPrompt = false
|
||||
@State private var biometricPassword = ""
|
||||
@State private var biometricError: String?
|
||||
|
||||
// Avatar
|
||||
@State private var avatarImage: UIImage?
|
||||
/// Photo selected in ProfileEditView but not yet committed — saved on Done press.
|
||||
@State private var pendingAvatarPhoto: UIImage?
|
||||
|
||||
// Edit mode field state — initialized when entering edit mode
|
||||
@State private var editDisplayName = ""
|
||||
@@ -18,15 +41,17 @@ struct SettingsView: View {
|
||||
@State private var isSaving = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
if isEditingProfile {
|
||||
ProfileEditView(
|
||||
onAddAccount: onAddAccount,
|
||||
displayName: $editDisplayName,
|
||||
username: $editUsername,
|
||||
publicKey: viewModel.publicKey,
|
||||
displayNameError: $displayNameError,
|
||||
usernameError: $usernameError
|
||||
usernameError: $usernameError,
|
||||
pendingPhoto: $pendingAvatarPhoto
|
||||
)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
@@ -39,10 +64,33 @@ struct SettingsView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbarContent }
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.task { viewModel.refresh() }
|
||||
.navigationDestination(for: SettingsDestination.self) { destination in
|
||||
switch destination {
|
||||
case .updates:
|
||||
UpdatesView()
|
||||
case .safety:
|
||||
SafetyView(onLogout: onLogout)
|
||||
case .backup:
|
||||
BackupView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
viewModel.refresh()
|
||||
refreshBiometricState()
|
||||
avatarImage = AvatarRepository.shared.loadAvatar(publicKey: viewModel.publicKey)
|
||||
}
|
||||
.alert("Delete Account", isPresented: $showDeleteAccountConfirmation) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Delete Account", role: .destructive) {
|
||||
let publicKey = SessionManager.shared.currentPublicKey
|
||||
BiometricAuthManager.shared.clearAll(forAccount: publicKey)
|
||||
AvatarRepository.shared.removeAvatar(publicKey: publicKey)
|
||||
DialogRepository.shared.reset(clearPersisted: true)
|
||||
MessageRepository.shared.reset(clearPersisted: true)
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)")
|
||||
defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)")
|
||||
defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)")
|
||||
SessionManager.shared.endSession()
|
||||
try? AccountManager.shared.deleteAccount()
|
||||
onLogout?()
|
||||
@@ -51,11 +99,57 @@ struct SettingsView: View {
|
||||
Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.")
|
||||
}
|
||||
.onChange(of: isEditingProfile) { _, isEditing in
|
||||
if !isEditing { viewModel.refresh() }
|
||||
if !isEditing {
|
||||
viewModel.refresh()
|
||||
refreshBiometricState()
|
||||
avatarImage = AvatarRepository.shared.loadAvatar(publicKey: viewModel.publicKey)
|
||||
}
|
||||
}
|
||||
.onAppear { refreshBiometricState() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .profileDidUpdate)) { _ in
|
||||
viewModel.refresh()
|
||||
}
|
||||
.alert(
|
||||
"Enable \(BiometricAuthManager.shared.biometricName)",
|
||||
isPresented: $showBiometricPasswordPrompt
|
||||
) {
|
||||
SecureField("Password", text: $biometricPassword)
|
||||
Button("Cancel", role: .cancel) {
|
||||
biometricPassword = ""
|
||||
biometricError = nil
|
||||
isBiometricEnabled = false
|
||||
}
|
||||
Button("Enable") { enableBiometric() }
|
||||
} message: {
|
||||
if let biometricError {
|
||||
Text(biometricError)
|
||||
} else {
|
||||
Text("Enter your password to securely save it for \(BiometricAuthManager.shared.biometricName) unlock.")
|
||||
}
|
||||
}
|
||||
.onChange(of: navigationPath.count) { _, newCount in
|
||||
isDetailPresented = newCount > 0
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Create account",
|
||||
isPresented: $showAddAccountSheet,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Create New Account") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
onAddAccount?(.seedPhrase)
|
||||
}
|
||||
}
|
||||
Button("Import Existing Account") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
onAddAccount?(.importSeed)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("You may create a new account or import an existing one.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
@@ -65,6 +159,7 @@ struct SettingsView: View {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
if isEditingProfile {
|
||||
Button {
|
||||
pendingAvatarPhoto = nil
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = false
|
||||
}
|
||||
@@ -113,6 +208,7 @@ struct SettingsView: View {
|
||||
editUsername = viewModel.username
|
||||
displayNameError = nil
|
||||
usernameError = nil
|
||||
pendingAvatarPhoto = nil
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = true
|
||||
}
|
||||
@@ -132,7 +228,9 @@ struct SettingsView: View {
|
||||
// MARK: - Profile Save
|
||||
|
||||
private var hasProfileChanges: Bool {
|
||||
editDisplayName != viewModel.displayName || editUsername != viewModel.username
|
||||
editDisplayName != viewModel.displayName
|
||||
|| editUsername != viewModel.username
|
||||
|| pendingAvatarPhoto != nil
|
||||
}
|
||||
|
||||
private func saveProfile() {
|
||||
@@ -158,6 +256,18 @@ struct SettingsView: View {
|
||||
return
|
||||
}
|
||||
|
||||
let hasTextChanges = trimmedName != viewModel.displayName
|
||||
|| trimmedUsername != viewModel.username
|
||||
|
||||
// Avatar-only change — save locally, no server round-trip needed
|
||||
if !hasTextChanges {
|
||||
commitPendingAvatar()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
|
||||
@@ -168,8 +278,9 @@ struct SettingsView: View {
|
||||
isSaving = false
|
||||
|
||||
if let code = ResultCode(rawValue: result.resultCode), code == .success {
|
||||
// Server confirmed — update local profile
|
||||
// Server confirmed — update local profile + avatar
|
||||
updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
|
||||
commitPendingAvatar()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = false
|
||||
}
|
||||
@@ -201,12 +312,22 @@ struct SettingsView: View {
|
||||
ProtocolManager.shared.removeResultHandler(handlerId)
|
||||
isSaving = false
|
||||
updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
|
||||
commitPendingAvatar()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves pending avatar photo to disk and updates the displayed avatar.
|
||||
private func commitPendingAvatar() {
|
||||
if let photo = pendingAvatarPhoto {
|
||||
AvatarRepository.shared.saveAvatar(publicKey: viewModel.publicKey, image: photo)
|
||||
avatarImage = photo
|
||||
pendingAvatarPhoto = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func updateLocalProfile(displayName: String, username: String) {
|
||||
AccountManager.shared.updateProfile(
|
||||
displayName: displayName,
|
||||
@@ -221,16 +342,41 @@ struct SettingsView: View {
|
||||
// MARK: - Settings Content
|
||||
|
||||
private var settingsContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 0) {
|
||||
profileHeader
|
||||
accountSection
|
||||
settingsSection
|
||||
|
||||
accountSwitcherCard
|
||||
|
||||
// Desktop parity: separate cards with subtitle descriptions.
|
||||
updatesCard
|
||||
if BiometricAuthManager.shared.isBiometricAvailable {
|
||||
biometricCard
|
||||
}
|
||||
themeCard
|
||||
safetyCard
|
||||
|
||||
rosettaPowerFooter
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.top, 0)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
|
||||
/// Desktop parity: "rosetta — powering freedom" footer with small R icon.
|
||||
private var rosettaPowerFooter: some View {
|
||||
HStack(spacing: 6) {
|
||||
RosettaLogoShape()
|
||||
.fill(RosettaColors.Adaptive.textTertiary)
|
||||
.frame(width: 11, height: 11)
|
||||
|
||||
Text("rosetta – powering freedom")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 32)
|
||||
}
|
||||
|
||||
// MARK: - Profile Header
|
||||
|
||||
private var profileHeader: some View {
|
||||
@@ -239,7 +385,8 @@ struct SettingsView: View {
|
||||
initials: viewModel.initials,
|
||||
colorIndex: viewModel.avatarColorIndex,
|
||||
size: 80,
|
||||
isSavedMessages: false
|
||||
isSavedMessages: false,
|
||||
image: avatarImage
|
||||
)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
@@ -266,43 +413,319 @@ struct SettingsView: View {
|
||||
)
|
||||
.frame(height: 16)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Account Section
|
||||
// MARK: - Account Switcher Card
|
||||
|
||||
private var accountSection: some View {
|
||||
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
||||
private var accountSwitcherCard: some View {
|
||||
let currentKey = AccountManager.shared.currentAccount?.publicKey
|
||||
let otherAccounts = AccountManager.shared.allAccounts.filter { $0.publicKey != currentKey }
|
||||
|
||||
return SettingsCard {
|
||||
VStack(spacing: 0) {
|
||||
settingsRow(icon: "person.fill", title: "My Profile", color: .red) {}
|
||||
sectionDivider
|
||||
settingsRow(icon: "bookmark.fill", title: "Saved Messages", color: RosettaColors.primaryBlue) {}
|
||||
sectionDivider
|
||||
settingsRow(icon: "desktopcomputer", title: "Devices", color: .orange) {}
|
||||
ForEach(Array(otherAccounts.enumerated()), id: \.element.publicKey) { index, account in
|
||||
let position: SettingsRowPosition = index == 0 && otherAccounts.count == 1
|
||||
? .top
|
||||
: index == 0 ? .top : .middle
|
||||
|
||||
accountRow(account, position: position)
|
||||
|
||||
Divider()
|
||||
.background(RosettaColors.Adaptive.divider)
|
||||
.padding(.leading, 52)
|
||||
}
|
||||
|
||||
addAccountRow(position: otherAccounts.isEmpty ? .alone : .bottom)
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
private func accountRow(_ account: Account, position: SettingsRowPosition) -> some View {
|
||||
let name = account.displayName ?? String(account.publicKey.prefix(7))
|
||||
let initials = RosettaColors.initials(name: name, publicKey: account.publicKey)
|
||||
let colorIndex = RosettaColors.avatarColorIndex(for: name, publicKey: account.publicKey)
|
||||
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: account.publicKey)
|
||||
let unread = totalUnreadCount(for: account.publicKey)
|
||||
|
||||
return Button {
|
||||
// Show fade overlay FIRST — covers the screen immediately.
|
||||
// Delay setActiveAccount + endSession until overlay is fully opaque (35ms fade-in),
|
||||
// so the user never sees the account list re-render.
|
||||
onLogout?()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
|
||||
AccountManager.shared.setActiveAccount(publicKey: account.publicKey)
|
||||
SessionManager.shared.endSession()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(
|
||||
initials: initials,
|
||||
colorIndex: colorIndex,
|
||||
size: 30,
|
||||
isSavedMessages: false,
|
||||
image: avatarImage
|
||||
)
|
||||
|
||||
Text(name)
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if unread > 0 {
|
||||
Text(formattedUnreadCount(unread))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(RosettaColors.primaryBlue)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 48)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.settingsHighlight(position: position)
|
||||
}
|
||||
|
||||
private func addAccountRow(position: SettingsRowPosition) -> some View {
|
||||
Button {
|
||||
showAddAccountSheet = true
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
Text("Add Account")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 48)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.settingsHighlight(position: position)
|
||||
}
|
||||
|
||||
/// Calculates total unread count for the given account's dialogs.
|
||||
/// Only returns meaningful data for the currently active session.
|
||||
private func totalUnreadCount(for accountPublicKey: String) -> Int {
|
||||
guard accountPublicKey == SessionManager.shared.currentPublicKey else { return 0 }
|
||||
return DialogRepository.shared.dialogs.values.reduce(0) { $0 + $1.unreadCount }
|
||||
}
|
||||
|
||||
/// Formats unread count: "42" for < 1000, "1,2K" for >= 1000.
|
||||
private func formattedUnreadCount(_ count: Int) -> String {
|
||||
if count < 1000 {
|
||||
return "\(count)"
|
||||
}
|
||||
let thousands = Double(count) / 1000.0
|
||||
let formatted = String(format: "%.1f", thousands)
|
||||
.replacingOccurrences(of: ".", with: ",")
|
||||
return "\(formatted)K"
|
||||
}
|
||||
|
||||
// MARK: - Desktop Parity Cards
|
||||
|
||||
private var updatesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SettingsCard {
|
||||
NavigationLink(value: SettingsDestination.updates) {
|
||||
settingsRowLabel(
|
||||
icon: "arrow.triangle.2.circlepath",
|
||||
title: "Updates",
|
||||
color: .green
|
||||
)
|
||||
}
|
||||
.settingsHighlight()
|
||||
}
|
||||
Text("You can check for new versions of the app here. Updates may include security improvements and new features.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
private var themeCard: some View {
|
||||
settingsCardWithSubtitle(
|
||||
icon: "paintbrush.fill",
|
||||
title: "Theme",
|
||||
color: .indigo,
|
||||
subtitle: "You can change the theme."
|
||||
) {}
|
||||
}
|
||||
|
||||
private var safetyCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SettingsCard {
|
||||
NavigationLink(value: SettingsDestination.safety) {
|
||||
settingsRowLabel(
|
||||
icon: "shield.lefthalf.filled",
|
||||
title: "Safety",
|
||||
color: .purple
|
||||
)
|
||||
}
|
||||
.settingsHighlight()
|
||||
}
|
||||
(
|
||||
Text("You can learn more about your safety on the safety page, please make sure you are viewing the screen alone ")
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
+ Text("before proceeding to the safety page")
|
||||
.bold()
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
+ Text(".")
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
)
|
||||
.font(.system(size: 13))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
private var logoutCard: some View {
|
||||
settingsCardWithSubtitle(
|
||||
icon: "rectangle.portrait.and.arrow.right",
|
||||
title: "Logout",
|
||||
color: RosettaColors.error,
|
||||
titleColor: RosettaColors.error,
|
||||
showChevron: false,
|
||||
subtitle: "Logging out of your account. After logging out, you will be redirected to the password entry page."
|
||||
) {
|
||||
SessionManager.shared.endSession()
|
||||
onLogout?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Biometric Card
|
||||
|
||||
private var biometricCard: some View {
|
||||
let biometric = BiometricAuthManager.shared
|
||||
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
SettingsCard {
|
||||
settingsToggle(
|
||||
icon: biometric.biometricIconName,
|
||||
title: biometric.biometricName,
|
||||
color: .blue,
|
||||
isOn: isBiometricEnabled
|
||||
) { newValue in
|
||||
if newValue {
|
||||
// Show password prompt to enable
|
||||
biometricPassword = ""
|
||||
biometricError = nil
|
||||
showBiometricPasswordPrompt = true
|
||||
} else {
|
||||
disableBiometric()
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Use \(biometric.biometricName) to unlock Rosetta instead of entering your password.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
/// Toggle row for settings (icon + title + Toggle).
|
||||
private func settingsToggle(
|
||||
icon: String,
|
||||
title: String,
|
||||
color: Color,
|
||||
isOn: Bool,
|
||||
action: @escaping (Bool) -> Void
|
||||
) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.padding(.trailing, 16)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isOn },
|
||||
set: { action($0) }
|
||||
))
|
||||
.labelsHidden()
|
||||
.tint(RosettaColors.primaryBlue)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 52)
|
||||
}
|
||||
|
||||
private func refreshBiometricState() {
|
||||
let publicKey = viewModel.publicKey
|
||||
guard !publicKey.isEmpty else { return }
|
||||
isBiometricEnabled = BiometricAuthManager.shared.isBiometricEnabled(forAccount: publicKey)
|
||||
}
|
||||
|
||||
private func enableBiometric() {
|
||||
let enteredPassword = biometricPassword
|
||||
biometricPassword = ""
|
||||
|
||||
guard !enteredPassword.isEmpty else {
|
||||
biometricError = "Password cannot be empty"
|
||||
isBiometricEnabled = false
|
||||
return
|
||||
}
|
||||
|
||||
let publicKey = viewModel.publicKey
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Verify password is correct by attempting unlock
|
||||
_ = try await AccountManager.shared.unlock(password: enteredPassword)
|
||||
|
||||
// Password correct — save for biometric
|
||||
let biometric = BiometricAuthManager.shared
|
||||
try biometric.savePassword(enteredPassword, forAccount: publicKey)
|
||||
biometric.setBiometricEnabled(true, forAccount: publicKey)
|
||||
isBiometricEnabled = true
|
||||
biometricError = nil
|
||||
} catch {
|
||||
isBiometricEnabled = false
|
||||
biometricError = "Wrong password"
|
||||
showBiometricPasswordPrompt = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Section
|
||||
|
||||
private var settingsSection: some View {
|
||||
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
||||
VStack(spacing: 0) {
|
||||
settingsRow(icon: "paintbrush.fill", title: "Appearance", color: RosettaColors.primaryBlue) {}
|
||||
sectionDivider
|
||||
settingsRow(icon: "lock.fill", title: "Privacy and Security", color: .gray) {}
|
||||
sectionDivider
|
||||
settingsRow(icon: "bell.fill", title: "Notifications", color: .red) {}
|
||||
sectionDivider
|
||||
settingsRow(icon: "ladybug.fill", title: "Crash Logs", color: .orange) {}
|
||||
}
|
||||
}
|
||||
private func disableBiometric() {
|
||||
let publicKey = viewModel.publicKey
|
||||
let biometric = BiometricAuthManager.shared
|
||||
biometric.deletePassword(forAccount: publicKey)
|
||||
biometric.setBiometricEnabled(false, forAccount: publicKey)
|
||||
isBiometricEnabled = false
|
||||
}
|
||||
|
||||
// MARK: - Danger Section
|
||||
|
||||
private var dangerSection: some View {
|
||||
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
||||
SettingsCard {
|
||||
Button {
|
||||
showDeleteAccountConfirmation = true
|
||||
} label: {
|
||||
@@ -314,7 +737,9 @@ struct SettingsView: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 52)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.settingsHighlight()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,24 +750,43 @@ struct SettingsView: View {
|
||||
icon: String,
|
||||
title: String,
|
||||
color: Color,
|
||||
titleColor: Color = RosettaColors.Adaptive.text,
|
||||
detail: String? = nil,
|
||||
showChevron: Bool = true,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
settingsRowLabel(
|
||||
icon: icon, title: title, color: color,
|
||||
titleColor: titleColor, detail: detail,
|
||||
showChevron: showChevron
|
||||
)
|
||||
}
|
||||
.settingsHighlight()
|
||||
}
|
||||
|
||||
/// Non-interactive row label for use inside NavigationLink.
|
||||
private func settingsRowLabel(
|
||||
icon: String,
|
||||
title: String,
|
||||
color: Color,
|
||||
titleColor: Color = RosettaColors.Adaptive.text,
|
||||
detail: String? = nil,
|
||||
showChevron: Bool = true
|
||||
) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 21))
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.padding(.trailing, 16)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.foregroundStyle(titleColor)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
@@ -364,7 +808,34 @@ struct SettingsView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 52)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
/// Desktop parity: card with a single settings row + subtitle text below.
|
||||
private func settingsCardWithSubtitle(
|
||||
icon: String,
|
||||
title: String,
|
||||
color: Color,
|
||||
titleColor: Color = RosettaColors.Adaptive.text,
|
||||
showChevron: Bool = true,
|
||||
subtitle: String,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SettingsCard {
|
||||
settingsRow(
|
||||
icon: icon, title: title, color: color,
|
||||
titleColor: titleColor, showChevron: showChevron,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
private var sectionDivider: some View {
|
||||
|
||||
140
Rosetta/Features/Settings/UpdatesView.swift
Normal file
140
Rosetta/Features/Settings/UpdatesView.swift
Normal file
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Updates screen — Android parity.
|
||||
/// Shows app version info, up-to-date status, and a "Check for Updates" button.
|
||||
struct UpdatesView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||
}
|
||||
|
||||
private var buildNumber: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
statusCard
|
||||
versionCard
|
||||
helpText
|
||||
checkButton
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
.background(RosettaColors.Adaptive.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.enableSwipeBack()
|
||||
.toolbar { toolbarContent }
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassCircle()
|
||||
}
|
||||
|
||||
// No title — per user request
|
||||
}
|
||||
|
||||
// MARK: - Status Card
|
||||
|
||||
private var statusCard: some View {
|
||||
SettingsCard {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(RosettaColors.success)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("App is up to date")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.success)
|
||||
|
||||
Text("You're using the latest version")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Version Card
|
||||
|
||||
private var versionCard: some View {
|
||||
SettingsCard {
|
||||
VStack(spacing: 0) {
|
||||
versionRow(title: "Application Version", value: appVersion)
|
||||
|
||||
Divider()
|
||||
.background(RosettaColors.Adaptive.divider)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
versionRow(title: "Build Number", value: buildNumber)
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
private func versionRow(title: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 48)
|
||||
}
|
||||
|
||||
// MARK: - Help Text
|
||||
|
||||
private var helpText: some View {
|
||||
Text("We recommend always keeping the app up to date to improve visual effects and have the latest features.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// MARK: - Check Button
|
||||
|
||||
private var checkButton: some View {
|
||||
Button {} label: {
|
||||
Text("Check for Updates")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(RosettaPrimaryButtonStyle())
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,8 @@ struct RosettaApp: App {
|
||||
// If this is the first launch after install, clear any stale Keychain data.
|
||||
if !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") {
|
||||
try? AccountManager.shared.deleteAccount()
|
||||
try? KeychainManager.shared.delete(forKey: Account.KeychainKey.allAccounts)
|
||||
UserDefaults.standard.removeObject(forKey: Account.KeychainKey.activeAccountKey)
|
||||
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
|
||||
}
|
||||
|
||||
@@ -163,13 +165,8 @@ struct RosettaApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor static var _bodyCount = 0
|
||||
@ViewBuilder
|
||||
private func rootView(for state: AppState) -> some View {
|
||||
#if DEBUG
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(state)")
|
||||
#endif
|
||||
switch state {
|
||||
case .onboarding:
|
||||
OnboardingView {
|
||||
@@ -202,11 +199,19 @@ struct RosettaApp: App {
|
||||
)
|
||||
|
||||
case .main:
|
||||
MainTabView(onLogout: {
|
||||
MainTabView(
|
||||
onLogout: {
|
||||
isLoggedIn = false
|
||||
// Desktop parity: if other accounts remain after deletion, go to unlock.
|
||||
// Only go to onboarding if no accounts left.
|
||||
if AccountManager.shared.hasAccount {
|
||||
fadeTransition(to: .unlock)
|
||||
} else {
|
||||
hasCompletedOnboarding = false
|
||||
fadeTransition(to: .onboarding)
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,4 +243,6 @@ struct RosettaApp: App {
|
||||
extension Notification.Name {
|
||||
/// Posted when user taps a push notification — carries a `ChatRoute` as `object`.
|
||||
static let openChatFromNotification = Notification.Name("openChatFromNotification")
|
||||
/// Posted when own profile (displayName/username) is updated from the server.
|
||||
static let profileDidUpdate = Notification.Name("profileDidUpdate")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user