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:
2026-03-15 03:50:56 +05:00
parent acc3fb8e2f
commit dd4642f251
48 changed files with 3865 additions and 517 deletions

View File

@@ -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>

View File

@@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View 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)"
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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"
}
}

View File

@@ -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]

View File

@@ -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
}
}

View 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
)
}
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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 {

View 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()
}
}

View File

@@ -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()
}
}

View File

@@ -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:

View 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
}
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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 00 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 01 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` (01).
/// Uses NewtonRaphson 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(1t)²t·p1 + 3(1t)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

View 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
}
}

View File

@@ -3,7 +3,7 @@ import UIKit
// MARK: - Tab
enum RosettaTab: CaseIterable, Sendable {
enum RosettaTab: String, CaseIterable, Sendable {
case chats
case calls
case settings

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}
}
}
}

View File

@@ -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),

View File

@@ -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
}
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 }

View File

@@ -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: "")
}
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View 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))
}
}
}

View File

@@ -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)

View 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)) + "..."
}
}

View File

@@ -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 {

View 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)
}
}

View File

@@ -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")
}