Исправление аватарки на экране разблокировки, плавная анимация инпута, онлайн-статус по входящим сообщениям, push-навигация в чат, оптимизация debug-логов
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ sprints/
|
||||
CLAUDE.md
|
||||
.claude.local.md
|
||||
desktop
|
||||
AGENTS.md
|
||||
|
||||
# Xcode
|
||||
build/
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -288,7 +288,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.9;
|
||||
MARKETING_VERSION = 1.0.10;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -311,7 +311,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -327,7 +327,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.9;
|
||||
MARKETING_VERSION = 1.0.10;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
@@ -81,7 +81,7 @@ final class CryptoManager: @unchecked Sendable {
|
||||
let compressed = try CryptoPrimitives.rawDeflate(data)
|
||||
let key = CryptoPrimitives.pbkdf2(
|
||||
password: password, salt: "rosetta", iterations: 1000,
|
||||
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
|
||||
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
|
||||
)
|
||||
let iv = try CryptoPrimitives.randomBytes(count: 16)
|
||||
let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv)
|
||||
|
||||
@@ -21,17 +21,34 @@ actor ChatPersistenceStore {
|
||||
rootDirectory = directory
|
||||
}
|
||||
|
||||
func load<T: Decodable>(_ type: T.Type, fileName: String) -> T? {
|
||||
func load<T: Decodable>(_ type: T.Type, fileName: String, password: String? = nil) -> T? {
|
||||
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),
|
||||
let decrypted = try? CryptoManager.shared.decryptWithPassword(encryptedSnapshot, password: password),
|
||||
let decoded = try? decoder.decode(type, from: decrypted) {
|
||||
return decoded
|
||||
}
|
||||
return try? decoder.decode(type, from: data)
|
||||
}
|
||||
|
||||
func save<T: Encodable>(_ value: T, fileName: String) {
|
||||
func save<T: Encodable>(_ value: T, fileName: String, password: String? = nil) {
|
||||
let fileURL = rootDirectory.appendingPathComponent(fileName, isDirectory: false)
|
||||
guard let data = try? encoder.encode(value) else { return }
|
||||
try? data.write(to: fileURL, options: [.atomic])
|
||||
let payload: Data
|
||||
if let password,
|
||||
let encryptedSnapshot = try? CryptoManager.shared.encryptWithPassword(data, password: password),
|
||||
let encryptedData = encryptedSnapshot.data(using: .utf8) {
|
||||
payload = encryptedData
|
||||
} else {
|
||||
payload = data
|
||||
}
|
||||
try? payload.write(
|
||||
to: fileURL,
|
||||
options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
|
||||
)
|
||||
}
|
||||
|
||||
func remove(fileName: String) {
|
||||
|
||||
@@ -12,6 +12,7 @@ final class DialogRepository {
|
||||
didSet { _sortedDialogsCache = nil }
|
||||
}
|
||||
private var currentAccount: String = ""
|
||||
private var storagePassword: String = ""
|
||||
private var persistTask: Task<Void, Never>?
|
||||
private var _sortedDialogsCache: [Dialog]?
|
||||
|
||||
@@ -27,23 +28,30 @@ final class DialogRepository {
|
||||
|
||||
private init() {}
|
||||
|
||||
func bootstrap(accountPublicKey: String) async {
|
||||
func bootstrap(accountPublicKey: String, storagePassword: String) async {
|
||||
let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !account.isEmpty else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if currentAccount == account, !dialogs.isEmpty {
|
||||
if currentAccount == account,
|
||||
self.storagePassword == storagePassword,
|
||||
!dialogs.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
currentAccount = account
|
||||
self.storagePassword = storagePassword
|
||||
persistTask?.cancel()
|
||||
persistTask = nil
|
||||
|
||||
let fileName = Self.dialogsFileName(for: account)
|
||||
let stored = await ChatPersistenceStore.shared.load([Dialog].self, fileName: fileName) ?? []
|
||||
let stored = await ChatPersistenceStore.shared.load(
|
||||
[Dialog].self,
|
||||
fileName: fileName,
|
||||
password: storagePassword
|
||||
) ?? []
|
||||
dialogs = Dictionary(
|
||||
uniqueKeysWithValues: stored
|
||||
.filter { $0.account == account }
|
||||
@@ -55,6 +63,7 @@ final class DialogRepository {
|
||||
persistTask?.cancel()
|
||||
persistTask = nil
|
||||
dialogs.removeAll()
|
||||
storagePassword = ""
|
||||
|
||||
guard !currentAccount.isEmpty else { return }
|
||||
let accountToReset = currentAccount
|
||||
@@ -186,15 +195,19 @@ final class DialogRepository {
|
||||
func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) {
|
||||
guard var dialog = dialogs[opponentKey] else { return }
|
||||
let current = dialog.lastMessageDelivered
|
||||
if current == .read, status == .delivered {
|
||||
return
|
||||
}
|
||||
if current == .read, status == .waiting {
|
||||
return
|
||||
}
|
||||
if current == .delivered, status == .waiting {
|
||||
if current == status { return }
|
||||
|
||||
// Desktop parity: desktop reads the actual last message from the DB at
|
||||
// render time (useDialogInfo → SELECT * FROM messages WHERE message_id = ?).
|
||||
// On iOS we cache lastMessageDelivered, so we must only accept updates
|
||||
// from the latest outgoing message to avoid stale ACKs / error timers
|
||||
// from older messages overwriting the indicator.
|
||||
let messages = MessageRepository.shared.messages(for: opponentKey)
|
||||
if let lastOutgoing = messages.last(where: { $0.fromPublicKey == dialog.account }),
|
||||
lastOutgoing.id != messageId {
|
||||
return
|
||||
}
|
||||
|
||||
dialog.lastMessageDelivered = status
|
||||
dialogs[opponentKey] = dialog
|
||||
schedulePersist()
|
||||
@@ -347,11 +360,16 @@ final class DialogRepository {
|
||||
|
||||
let snapshot = Array(dialogs.values)
|
||||
let fileName = Self.dialogsFileName(for: currentAccount)
|
||||
let storagePassword = self.storagePassword
|
||||
persistTask?.cancel()
|
||||
persistTask = Task(priority: .utility) {
|
||||
try? await Task.sleep(for: .milliseconds(180))
|
||||
guard !Task.isCancelled else { return }
|
||||
await ChatPersistenceStore.shared.save(snapshot, fileName: fileName)
|
||||
await ChatPersistenceStore.shared.save(
|
||||
snapshot,
|
||||
fileName: fileName,
|
||||
password: storagePassword.isEmpty ? nil : storagePassword
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,21 +16,25 @@ final class MessageRepository: ObservableObject {
|
||||
private var typingResetTasks: [String: Task<Void, Never>] = [:]
|
||||
private var persistTask: Task<Void, Never>?
|
||||
private var currentAccount: String = ""
|
||||
private var storagePassword: String = ""
|
||||
|
||||
private init() {}
|
||||
|
||||
func bootstrap(accountPublicKey: String) async {
|
||||
func bootstrap(accountPublicKey: String, storagePassword: String) async {
|
||||
let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !account.isEmpty else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if currentAccount == account, !messagesByDialog.isEmpty {
|
||||
if currentAccount == account,
|
||||
self.storagePassword == storagePassword,
|
||||
!messagesByDialog.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
currentAccount = account
|
||||
self.storagePassword = storagePassword
|
||||
persistTask?.cancel()
|
||||
persistTask = nil
|
||||
activeDialogs.removeAll()
|
||||
@@ -42,7 +46,11 @@ final class MessageRepository: ObservableObject {
|
||||
messageToDialog.removeAll()
|
||||
|
||||
let fileName = Self.messagesFileName(for: account)
|
||||
let stored = await ChatPersistenceStore.shared.load([String: [ChatMessage]].self, fileName: fileName) ?? [:]
|
||||
let stored = await ChatPersistenceStore.shared.load(
|
||||
[String: [ChatMessage]].self,
|
||||
fileName: fileName,
|
||||
password: storagePassword
|
||||
) ?? [:]
|
||||
var restored: [String: [ChatMessage]] = [:]
|
||||
for (dialogKey, list) in stored {
|
||||
var sorted = list.sorted {
|
||||
@@ -278,6 +286,7 @@ final class MessageRepository: ObservableObject {
|
||||
typingDialogs.removeAll()
|
||||
activeDialogs.removeAll()
|
||||
messageToDialog.removeAll()
|
||||
storagePassword = ""
|
||||
|
||||
guard !currentAccount.isEmpty else { return }
|
||||
let accountToReset = currentAccount
|
||||
@@ -327,11 +336,16 @@ final class MessageRepository: ObservableObject {
|
||||
|
||||
let snapshot = messagesByDialog
|
||||
let fileName = Self.messagesFileName(for: currentAccount)
|
||||
let storagePassword = self.storagePassword
|
||||
persistTask?.cancel()
|
||||
persistTask = Task(priority: .utility) {
|
||||
try? await Task.sleep(for: .milliseconds(220))
|
||||
guard !Task.isCancelled else { return }
|
||||
await ChatPersistenceStore.shared.save(snapshot, fileName: fileName)
|
||||
await ChatPersistenceStore.shared.save(
|
||||
snapshot,
|
||||
fileName: fileName,
|
||||
password: storagePassword.isEmpty ? nil : storagePassword
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ struct PacketHandshake: Packet {
|
||||
var protocolVersion: Int = 1
|
||||
var heartbeatInterval: Int = 15
|
||||
var device = HandshakeDevice()
|
||||
var handshakeState: HandshakeState = .completed
|
||||
var handshakeState: HandshakeState = .needDeviceVerification
|
||||
|
||||
func write(to stream: Stream) {
|
||||
stream.writeString(privateKey)
|
||||
|
||||
@@ -57,8 +57,10 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
private var heartbeatTask: Task<Void, Never>?
|
||||
private var handshakeTimeoutTask: Task<Void, Never>?
|
||||
private let searchHandlersLock = NSLock()
|
||||
private let resultHandlersLock = NSLock()
|
||||
private let packetQueueLock = NSLock()
|
||||
private var searchResultHandlers: [UUID: (PacketSearch) -> Void] = [:]
|
||||
private var resultHandlers: [UUID: (PacketResult) -> Void] = [:]
|
||||
|
||||
// Saved credentials for auto-reconnect
|
||||
private var savedPublicKey: String?
|
||||
@@ -98,6 +100,15 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
savedPrivateHash = nil
|
||||
}
|
||||
|
||||
/// Immediately reconnect after returning from background, bypassing backoff.
|
||||
func reconnectIfNeeded() {
|
||||
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
|
||||
if connectionState == .authenticated || connectionState == .handshaking { return }
|
||||
Self.logger.info("Force reconnect from foreground")
|
||||
connectionState = .connecting
|
||||
client.forceReconnect()
|
||||
}
|
||||
|
||||
// MARK: - Sending
|
||||
|
||||
func sendPacket(_ packet: any Packet) {
|
||||
@@ -128,6 +139,24 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
searchHandlersLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Result Handlers (Android parity: waitPacket(0x02))
|
||||
|
||||
/// Register a one-shot handler for PacketResult (0x02).
|
||||
@discardableResult
|
||||
func addResultHandler(_ handler: @escaping (PacketResult) -> Void) -> UUID {
|
||||
let id = UUID()
|
||||
resultHandlersLock.lock()
|
||||
resultHandlers[id] = handler
|
||||
resultHandlersLock.unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
func removeResultHandler(_ id: UUID) {
|
||||
resultHandlersLock.lock()
|
||||
resultHandlers.removeValue(forKey: id)
|
||||
resultHandlersLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Private Setup
|
||||
|
||||
private func setupClientCallbacks() {
|
||||
@@ -173,7 +202,7 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
let device = HandshakeDevice(
|
||||
deviceId: UIDevice.current.identifierForVendor?.uuidString ?? "unknown",
|
||||
deviceId: DeviceIdentityManager.shared.currentDeviceId(),
|
||||
deviceName: UIDevice.current.name,
|
||||
deviceOs: "iOS \(UIDevice.current.systemVersion)"
|
||||
)
|
||||
@@ -184,7 +213,7 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
protocolVersion: 1,
|
||||
heartbeatInterval: 15,
|
||||
device: device,
|
||||
handshakeState: .completed
|
||||
handshakeState: .needDeviceVerification
|
||||
)
|
||||
|
||||
sendPacketDirect(handshake)
|
||||
@@ -238,7 +267,8 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
}
|
||||
case 0x02:
|
||||
if let p = packet as? PacketResult {
|
||||
let _ = ResultCode(rawValue: p.resultCode)
|
||||
Self.logger.info("📥 PacketResult: code=\(p.resultCode)")
|
||||
notifyResultHandlers(p)
|
||||
}
|
||||
case 0x03:
|
||||
if let p = packet as? PacketSearch {
|
||||
@@ -293,6 +323,18 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyResultHandlers(_ packet: PacketResult) {
|
||||
resultHandlersLock.lock()
|
||||
let handlers = resultHandlers
|
||||
// One-shot: clear all handlers after dispatch (Android parity)
|
||||
resultHandlers.removeAll()
|
||||
resultHandlersLock.unlock()
|
||||
|
||||
for (_, handler) in handlers {
|
||||
handler(packet)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHandshakeResponse(_ packet: PacketHandshake) {
|
||||
handshakeTimeoutTask?.cancel()
|
||||
handshakeTimeoutTask = nil
|
||||
|
||||
@@ -14,7 +14,6 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
private var hasNotifiedConnected = false
|
||||
private(set) var isConnected = false
|
||||
private var disconnectHandledForCurrentSocket = false
|
||||
private var reconnectAttempt = 0
|
||||
|
||||
var onConnected: (() -> Void)?
|
||||
var onDisconnected: ((Error?) -> Void)?
|
||||
@@ -55,6 +54,20 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
/// Immediately reconnect, bypassing scheduled retry.
|
||||
/// Used when returning from background to establish connection ASAP.
|
||||
func forceReconnect() {
|
||||
guard !isManuallyClosed else { return }
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
guard !isConnected else { return }
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
webSocketTask = nil
|
||||
disconnectHandledForCurrentSocket = false
|
||||
Self.logger.info("Force reconnect triggered")
|
||||
connect()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func send(_ data: Data, onFailure: ((Error?) -> Void)? = nil) -> Bool {
|
||||
guard isConnected, let task = webSocketTask else {
|
||||
@@ -89,7 +102,6 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
hasNotifiedConnected = true
|
||||
isConnected = true
|
||||
disconnectHandledForCurrentSocket = false
|
||||
reconnectAttempt = 0
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
onConnected?()
|
||||
@@ -142,12 +154,10 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
guard !isManuallyClosed else { return }
|
||||
|
||||
guard reconnectTask == nil else { return }
|
||||
let attempt = reconnectAttempt
|
||||
reconnectAttempt += 1
|
||||
// Exponential backoff: 5s, 7.5s, 11.25s, ... capped at 30s
|
||||
let delaySeconds = min(5.0 * pow(1.5, Double(attempt)), 30.0)
|
||||
// Fixed 5-second reconnect interval (desktop parity)
|
||||
let delaySeconds: Double = 5.0
|
||||
reconnectTask = Task { [weak self] in
|
||||
Self.logger.info("Reconnecting in \(String(format: "%.1f", delaySeconds))s (attempt \(attempt + 1))...")
|
||||
Self.logger.info("Reconnecting in 5s...")
|
||||
try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000))
|
||||
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
|
||||
self.reconnectTask = nil
|
||||
|
||||
79
Rosetta/Core/Services/DeviceIdentityManager.swift
Normal file
79
Rosetta/Core/Services/DeviceIdentityManager.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Manages the random per-device identifier used in handshake/device approval flows.
|
||||
/// The identifier is stored encrypted in the app container, keyed by local device traits,
|
||||
/// mirroring the desktop model more closely than sending `identifierForVendor` directly.
|
||||
final class DeviceIdentityManager: @unchecked Sendable {
|
||||
|
||||
static let shared = DeviceIdentityManager()
|
||||
|
||||
private enum LegacyKeychainKey {
|
||||
static let randomDeviceId = "randomDeviceId"
|
||||
}
|
||||
|
||||
private let crypto = CryptoManager.shared
|
||||
private let keychain = KeychainManager.shared
|
||||
private let fileManager = FileManager.default
|
||||
private let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||
private let salt = "rosetta-device-salt"
|
||||
|
||||
private lazy var deviceFileURL: URL = {
|
||||
let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let directory = baseURL
|
||||
.appendingPathComponent("Rosetta", isDirectory: true)
|
||||
.appendingPathComponent("System", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory.appendingPathComponent("device_id", isDirectory: false)
|
||||
}()
|
||||
|
||||
private init() {}
|
||||
|
||||
func currentDeviceId() -> String {
|
||||
if let encrypted = try? String(contentsOf: deviceFileURL, encoding: .utf8),
|
||||
let decrypted = try? crypto.decryptWithPassword(encrypted, password: localSecret()),
|
||||
let deviceId = String(data: decrypted, encoding: .utf8),
|
||||
!deviceId.isEmpty {
|
||||
return deviceId
|
||||
}
|
||||
|
||||
if let legacy = try? keychain.loadString(forKey: LegacyKeychainKey.randomDeviceId),
|
||||
!legacy.isEmpty {
|
||||
persist(deviceId: legacy)
|
||||
try? keychain.delete(forKey: LegacyKeychainKey.randomDeviceId)
|
||||
return legacy
|
||||
}
|
||||
|
||||
let generated = generateRandomDeviceId(length: 128)
|
||||
persist(deviceId: generated)
|
||||
return generated
|
||||
}
|
||||
}
|
||||
|
||||
private extension DeviceIdentityManager {
|
||||
func persist(deviceId: String) {
|
||||
guard let raw = deviceId.data(using: .utf8),
|
||||
let encrypted = try? crypto.encryptWithPassword(raw, password: localSecret()),
|
||||
let data = encrypted.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
try? data.write(
|
||||
to: deviceFileURL,
|
||||
options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
|
||||
)
|
||||
}
|
||||
|
||||
func localSecret() -> String {
|
||||
let vendorId = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
|
||||
return vendorId + UIDevice.current.name + salt
|
||||
}
|
||||
|
||||
func generateRandomDeviceId(length: Int) -> String {
|
||||
guard let randomBytes = try? CryptoPrimitives.randomBytes(count: max(length, 1)) else {
|
||||
return UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
}
|
||||
return String(randomBytes.prefix(length).map { alphabet[Int($0) % alphabet.count] })
|
||||
}
|
||||
}
|
||||
@@ -91,8 +91,14 @@ final class SessionManager {
|
||||
username = account.username ?? ""
|
||||
|
||||
// Warm local state immediately, then let network sync reconcile updates.
|
||||
await DialogRepository.shared.bootstrap(accountPublicKey: account.publicKey)
|
||||
await MessageRepository.shared.bootstrap(accountPublicKey: account.publicKey)
|
||||
await DialogRepository.shared.bootstrap(
|
||||
accountPublicKey: account.publicKey,
|
||||
storagePassword: privateKeyHex
|
||||
)
|
||||
await MessageRepository.shared.bootstrap(
|
||||
accountPublicKey: account.publicKey,
|
||||
storagePassword: privateKeyHex
|
||||
)
|
||||
RecentSearchesRepository.shared.setAccount(account.publicKey)
|
||||
|
||||
// Generate private key hash for handshake
|
||||
@@ -177,7 +183,7 @@ final class SessionManager {
|
||||
registerOutgoingRetry(for: packet)
|
||||
}
|
||||
|
||||
/// Sends typing indicator with throttling (Android parity: max once per 2s per dialog).
|
||||
/// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog).
|
||||
func sendTypingIndicator(toPublicKey: String) {
|
||||
guard toPublicKey != currentPublicKey,
|
||||
let hash = privateKeyHash,
|
||||
@@ -186,7 +192,7 @@ final class SessionManager {
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let lastSent = lastTypingSentAt[toPublicKey] ?? 0
|
||||
if now - lastSent < 2_000 {
|
||||
if now - lastSent < ProtocolConstants.typingThrottleMs {
|
||||
return
|
||||
}
|
||||
lastTypingSentAt[toPublicKey] = now
|
||||
@@ -387,6 +393,9 @@ final class SessionManager {
|
||||
// Desktop parity: request message synchronization after authentication.
|
||||
self.requestSynchronize()
|
||||
self.retryWaitingOutgoingMessagesAfterReconnect()
|
||||
// Safety net: reconcile dialog delivery indicators with actual
|
||||
// message statuses, fixing any desync from stale retry timers.
|
||||
DialogRepository.shared.reconcileDeliveryStatuses()
|
||||
|
||||
// Clear dedup sets on reconnect so subscriptions can be re-established lazily.
|
||||
self.requestedUserInfoKeys.removeAll()
|
||||
@@ -529,6 +538,13 @@ final class SessionManager {
|
||||
fromSync: syncBatchInProgress
|
||||
)
|
||||
|
||||
// Desktop parity: if we received a message from the opponent (not our own),
|
||||
// they are clearly online — update their online status immediately.
|
||||
// This supplements PacketOnlineState (0x05) which may arrive with delay.
|
||||
if !fromMe && !syncBatchInProgress {
|
||||
DialogRepository.shared.updateOnlineState(publicKey: opponentKey, isOnline: true)
|
||||
}
|
||||
|
||||
let dialog = DialogRepository.shared.dialogs[opponentKey]
|
||||
|
||||
if dialog?.opponentTitle.isEmpty == true {
|
||||
@@ -630,6 +646,19 @@ final class SessionManager {
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
}
|
||||
|
||||
/// Force-refresh user info (including online status) by sending PacketSearch
|
||||
/// without dedup check. Desktop parity: `forceUpdateUserInformation()`.
|
||||
func forceRefreshUserInfo(publicKey: String) {
|
||||
guard !publicKey.isEmpty,
|
||||
let hash = privateKeyHash,
|
||||
ProtocolManager.shared.connectionState == .authenticated
|
||||
else { return }
|
||||
var searchPacket = PacketSearch()
|
||||
searchPacket.privateKey = hash
|
||||
searchPacket.search = publicKey
|
||||
ProtocolManager.shared.sendPacket(searchPacket)
|
||||
}
|
||||
|
||||
private func requestSynchronize(cursor: Int64? = nil) {
|
||||
guard ProtocolManager.shared.connectionState == .authenticated else { return }
|
||||
guard !syncRequestInFlight else { return }
|
||||
@@ -1064,7 +1093,7 @@ final class SessionManager {
|
||||
// MARK: - Idle Detection Setup
|
||||
|
||||
private func setupIdleDetection() {
|
||||
// Track app going to background/foreground to reset idle state.
|
||||
// Track app going to background/foreground to reset idle state + reconnect.
|
||||
idleObserverToken = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willEnterForegroundNotification,
|
||||
object: nil,
|
||||
@@ -1072,6 +1101,10 @@ final class SessionManager {
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.lastUserInteractionTime = Date()
|
||||
// Immediate reconnect when returning from background
|
||||
if ProtocolManager.shared.connectionState != .authenticated {
|
||||
ProtocolManager.shared.reconnectIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
Rosetta/Core/Utils/ProfileValidator.swift
Normal file
67
Rosetta/Core/Utils/ProfileValidator.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
/// Client-side validation for display name and username fields.
|
||||
/// Used in profile editing (SettingsView / ProfileEditView).
|
||||
enum ProfileValidator {
|
||||
|
||||
// MARK: - Display Name
|
||||
|
||||
enum DisplayNameError: LocalizedError {
|
||||
case empty
|
||||
case tooLong
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .empty: return "Name cannot be empty"
|
||||
case .tooLong: return "Name cannot exceed 64 characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func validateDisplayName(_ name: String) -> DisplayNameError? {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty { return .empty }
|
||||
if trimmed.count > 64 { return .tooLong }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Username
|
||||
|
||||
enum UsernameError: LocalizedError {
|
||||
case tooShort
|
||||
case tooLong
|
||||
case invalidCharacters
|
||||
case mustStartWithLetter
|
||||
case reserved
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .tooShort: return "Username must be at least 5 characters"
|
||||
case .tooLong: return "Username cannot exceed 32 characters"
|
||||
case .invalidCharacters: return "Only lowercase letters, numbers, and underscores"
|
||||
case .mustStartWithLetter: return "Must start with a letter"
|
||||
case .reserved: return "This username is not available"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let reservedNames: Set<String> = [
|
||||
"rosetta", "admin", "support", "help", "system",
|
||||
"moderator", "official", "staff", "bot", "service",
|
||||
]
|
||||
|
||||
/// Validate username. Returns `nil` if valid, or the first error found.
|
||||
/// Empty username is allowed (user may not have chosen one yet).
|
||||
static func validateUsername(_ username: String) -> UsernameError? {
|
||||
let lowered = username.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
guard !lowered.isEmpty else { return nil } // empty is OK
|
||||
if lowered.count < 5 { return .tooShort }
|
||||
if lowered.count > 32 { return .tooLong }
|
||||
guard let first = lowered.first, first.isLetter else { return .mustStartWithLetter }
|
||||
if lowered.range(of: "^[a-z][a-z0-9_]{4,31}$", options: .regularExpression) == nil {
|
||||
return .invalidCharacters
|
||||
}
|
||||
if reservedNames.contains(lowered) { return .reserved }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,8 @@ enum ProtocolConstants {
|
||||
/// Read receipt throttle interval in milliseconds.
|
||||
static let readReceiptThrottleMs: Int64 = 400
|
||||
|
||||
/// Typing indicator throttle interval in milliseconds.
|
||||
static let typingThrottleMs: Int64 = 2_000
|
||||
/// Typing indicator throttle interval in milliseconds (desktop parity).
|
||||
static let typingThrottleMs: Int64 = 3_000
|
||||
|
||||
/// Typing indicator display timeout in seconds.
|
||||
static let typingDisplayTimeoutS: TimeInterval = 3
|
||||
|
||||
270
Rosetta/DesignSystem/Components/ChatTextInput.swift
Normal file
270
Rosetta/DesignSystem/Components/ChatTextInput.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - KeyboardTrackingView
|
||||
|
||||
/// A zero-height UIView used as `inputAccessoryView`.
|
||||
/// KVO on `superview.center` gives pixel-perfect keyboard position
|
||||
/// during interactive dismiss — the most reliable path for composer sync.
|
||||
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))
|
||||
autoresizingMask = .flexibleWidth
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
.init(width: UIView.noIntrinsicMetric, height: 0)
|
||||
}
|
||||
|
||||
override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
observation?.invalidate()
|
||||
observation = nil
|
||||
superviewHeightObservation?.invalidate()
|
||||
superviewHeightObservation = nil
|
||||
|
||||
guard let sv = superview else { return }
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
deinit {
|
||||
observation?.invalidate()
|
||||
superviewHeightObservation?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ChatInputTextView
|
||||
|
||||
/// UITextView subclass with a placeholder label that stays visible while empty.
|
||||
final class ChatInputTextView: UITextView {
|
||||
|
||||
let trackingView = KeyboardTrackingView(
|
||||
frame: .init(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 0)
|
||||
)
|
||||
|
||||
/// Placeholder label — visible when text is empty, even while focused.
|
||||
let placeholderLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.isUserInteractionEnabled = false
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
||||
super.init(frame: frame, textContainer: textContainer)
|
||||
inputAccessoryView = trackingView
|
||||
inputAssistantItem.leadingBarButtonGroups = []
|
||||
inputAssistantItem.trailingBarButtonGroups = []
|
||||
addSubview(placeholderLabel)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
// Position placeholder to match text position exactly
|
||||
let insets = textContainerInset
|
||||
let padding = textContainer.lineFragmentPadding
|
||||
placeholderLabel.frame.origin = CGPoint(x: insets.left + padding, y: insets.top)
|
||||
placeholderLabel.frame.size = CGSize(
|
||||
width: bounds.width - insets.left - insets.right - padding * 2,
|
||||
height: placeholderLabel.intrinsicContentSize.height
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
}
|
||||
|
||||
// MARK: - ChatTextInput (UIViewRepresentable)
|
||||
|
||||
struct ChatTextInput: UIViewRepresentable {
|
||||
|
||||
@Binding var text: String
|
||||
@Binding var isFocused: Bool
|
||||
var onKeyboardHeightChange: (CGFloat) -> Void
|
||||
var onUserTextInsertion: () -> Void = {}
|
||||
var font: UIFont = .systemFont(ofSize: 17, weight: .regular)
|
||||
var textColor: UIColor = .white
|
||||
var placeholderColor: UIColor = UIColor.white.withAlphaComponent(0.35)
|
||||
var placeholder: String = "Message"
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(parent: self)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> ChatInputTextView {
|
||||
let tv = ChatInputTextView()
|
||||
tv.delegate = context.coordinator
|
||||
tv.font = font
|
||||
tv.textColor = textColor
|
||||
tv.backgroundColor = .clear
|
||||
tv.tintColor = UIColor(RosettaColors.primaryBlue)
|
||||
tv.isScrollEnabled = false
|
||||
tv.textContainerInset = UIEdgeInsets(top: 6, left: 2, bottom: 8, right: 0)
|
||||
tv.textContainer.lineFragmentPadding = 0
|
||||
tv.autocapitalizationType = .sentences
|
||||
tv.autocorrectionType = .default
|
||||
tv.keyboardAppearance = .dark
|
||||
tv.returnKeyType = .default
|
||||
|
||||
// Placeholder label (stays visible when text is empty, even if focused)
|
||||
tv.placeholderLabel.text = placeholder
|
||||
tv.placeholderLabel.font = font
|
||||
tv.placeholderLabel.textColor = placeholderColor
|
||||
tv.placeholderLabel.isHidden = !text.isEmpty
|
||||
|
||||
tv.trackingView.onHeightChange = { [weak coordinator = context.coordinator] height in
|
||||
coordinator?.handleKeyboardHeight(height)
|
||||
}
|
||||
|
||||
// Set initial text
|
||||
if !text.isEmpty {
|
||||
tv.text = text
|
||||
}
|
||||
|
||||
return tv
|
||||
}
|
||||
|
||||
func updateUIView(_ tv: ChatInputTextView, context: Context) {
|
||||
let coord = context.coordinator
|
||||
coord.parent = self
|
||||
|
||||
// Sync text from SwiftUI → UIKit (avoid loop)
|
||||
if !coord.isUpdatingText {
|
||||
if text != tv.text {
|
||||
tv.text = text
|
||||
coord.invalidateHeight(tv)
|
||||
}
|
||||
tv.placeholderLabel.isHidden = !text.isEmpty
|
||||
}
|
||||
|
||||
// Sync focus without replaying stale async requests during interactive dismiss.
|
||||
coord.syncFocus(for: tv)
|
||||
}
|
||||
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, uiView tv: ChatInputTextView, context: Context) -> CGSize? {
|
||||
let maxWidth = proposal.width ?? UIScreen.main.bounds.width
|
||||
let lineHeight = font.lineHeight
|
||||
let maxLines: CGFloat = 5
|
||||
let insets = tv.textContainerInset
|
||||
let maxTextHeight = lineHeight * maxLines
|
||||
let maxTotalHeight = maxTextHeight + insets.top + insets.bottom
|
||||
|
||||
let fittingSize = tv.sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
|
||||
let clampedHeight = min(fittingSize.height, maxTotalHeight)
|
||||
|
||||
// Enable scrolling when content exceeds max height
|
||||
let shouldScroll = fittingSize.height > maxTotalHeight
|
||||
if tv.isScrollEnabled != shouldScroll {
|
||||
tv.isScrollEnabled = shouldScroll
|
||||
}
|
||||
|
||||
return CGSize(width: maxWidth, height: max(36, clampedHeight))
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
final class Coordinator: NSObject, UITextViewDelegate {
|
||||
var parent: ChatTextInput
|
||||
var isUpdatingText = false
|
||||
private var pendingFocusSync: DispatchWorkItem?
|
||||
|
||||
init(parent: ChatTextInput) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func handleKeyboardHeight(_ height: CGFloat) {
|
||||
parent.onKeyboardHeightChange(height)
|
||||
}
|
||||
|
||||
// MARK: UITextViewDelegate
|
||||
|
||||
func textViewDidBeginEditing(_ tv: UITextView) {
|
||||
pendingFocusSync?.cancel()
|
||||
// Placeholder stays visible — only hidden when user types
|
||||
if !parent.isFocused {
|
||||
parent.isFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ tv: UITextView) {
|
||||
pendingFocusSync?.cancel()
|
||||
// Must be synchronous — async causes race condition with .ignoresSafeArea(.keyboard):
|
||||
// padding animation triggers updateUIView before isFocused is updated,
|
||||
// causing becomeFirstResponder() → keyboard reopens.
|
||||
if parent.isFocused {
|
||||
parent.isFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
func textView(
|
||||
_ tv: UITextView,
|
||||
shouldChangeTextIn range: NSRange,
|
||||
replacementText text: String
|
||||
) -> Bool {
|
||||
guard !text.isEmpty, text != "\n" else { return true }
|
||||
parent.onUserTextInsertion()
|
||||
return true
|
||||
}
|
||||
|
||||
func textViewDidChange(_ tv: UITextView) {
|
||||
isUpdatingText = true
|
||||
parent.text = tv.text ?? ""
|
||||
isUpdatingText = false
|
||||
// Toggle placeholder based on content
|
||||
if let chatTV = tv as? ChatInputTextView {
|
||||
chatTV.placeholderLabel.isHidden = !tv.text.isEmpty
|
||||
}
|
||||
invalidateHeight(tv)
|
||||
}
|
||||
|
||||
func invalidateHeight(_ tv: UITextView) {
|
||||
tv.invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
func syncFocus(for tv: UITextView) {
|
||||
pendingFocusSync?.cancel()
|
||||
|
||||
let wantsFocus = parent.isFocused
|
||||
guard wantsFocus != tv.isFirstResponder else { return }
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self, weak tv] in
|
||||
guard let self, let tv else { return }
|
||||
guard self.parent.isFocused == wantsFocus else { return }
|
||||
|
||||
if wantsFocus {
|
||||
guard !tv.isFirstResponder else { return }
|
||||
tv.becomeFirstResponder()
|
||||
} else {
|
||||
guard tv.isFirstResponder else { return }
|
||||
tv.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
pendingFocusSync = workItem
|
||||
DispatchQueue.main.async(execute: workItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Rosetta/DesignSystem/Components/CopyableText.swift
Normal file
94
Rosetta/DesignSystem/Components/CopyableText.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Displays text that shows a native iOS callout bubble ("Copy") on tap or long press.
|
||||
/// Uses `UIEditMenuInteraction` for the system callout style.
|
||||
struct CopyableText: UIViewRepresentable {
|
||||
let displayText: String
|
||||
let fullText: String
|
||||
let font: UIFont
|
||||
let textColor: UIColor
|
||||
|
||||
func makeUIView(context: Context) -> CopyableLabel {
|
||||
let label = CopyableLabel()
|
||||
label.text = displayText
|
||||
label.font = font
|
||||
label.textColor = textColor
|
||||
label.textToCopy = fullText
|
||||
label.textAlignment = .center
|
||||
label.setContentHuggingPriority(.required, for: .vertical)
|
||||
label.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
return label
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: CopyableLabel, context: Context) {
|
||||
uiView.text = displayText
|
||||
uiView.font = font
|
||||
uiView.textColor = textColor
|
||||
uiView.textToCopy = fullText
|
||||
}
|
||||
}
|
||||
|
||||
/// UILabel subclass with `UIEditMenuInteraction` for native callout copy menu.
|
||||
final class CopyableLabel: UILabel, UIEditMenuInteractionDelegate {
|
||||
|
||||
var textToCopy: String = ""
|
||||
|
||||
private var editMenuInteraction: UIEditMenuInteraction?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setup()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
isUserInteractionEnabled = true
|
||||
|
||||
let interaction = UIEditMenuInteraction(delegate: self)
|
||||
addInteraction(interaction)
|
||||
editMenuInteraction = interaction
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
addGestureRecognizer(tap)
|
||||
|
||||
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||
longPress.minimumPressDuration = 0.5
|
||||
addGestureRecognizer(longPress)
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
showMenu(at: gesture.location(in: self))
|
||||
}
|
||||
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
guard gesture.state == .began else { return }
|
||||
showMenu(at: gesture.location(in: self))
|
||||
}
|
||||
|
||||
private func showMenu(at point: CGPoint) {
|
||||
let config = UIEditMenuConfiguration(identifier: nil, sourcePoint: point)
|
||||
editMenuInteraction?.presentEditMenu(with: config)
|
||||
}
|
||||
|
||||
// MARK: - UIEditMenuInteractionDelegate
|
||||
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
menuFor configuration: UIEditMenuConfiguration,
|
||||
suggestedActions: [UIMenuElement]
|
||||
) -> UIMenu? {
|
||||
let textToCopy = textToCopy
|
||||
let copy = UIAction(
|
||||
title: String(localized: "Copy"),
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
UIPasteboard.general.string = textToCopy
|
||||
}
|
||||
return UIMenu(children: [copy])
|
||||
}
|
||||
}
|
||||
158
Rosetta/DesignSystem/Components/KeyboardTracker.swift
Normal file
158
Rosetta/DesignSystem/Components/KeyboardTracker.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
/// Drives keyboard-related positioning for the chat composer.
|
||||
///
|
||||
/// Published properties:
|
||||
/// - `keyboardPadding`: bottom padding for the composer
|
||||
/// - `interactiveOffset`: visual offset during interactive drag
|
||||
///
|
||||
/// Data sources:
|
||||
/// 1. `keyboardWillChangeFrameNotification` — animated show/hide with system timing
|
||||
/// 2. KVO on `inputAccessoryView.superview.center` — pixel-perfect interactive drag
|
||||
@MainActor
|
||||
final class KeyboardTracker: ObservableObject {
|
||||
|
||||
@Published private(set) var keyboardPadding: CGFloat = 0
|
||||
@Published private(set) var interactiveOffset: CGFloat = 0
|
||||
|
||||
private var baseKeyboardHeight: CGFloat?
|
||||
private var isAnimating = false
|
||||
private let bottomInset: CGFloat
|
||||
private var pendingResetTask: Task<Void, Never>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = scene.keyWindow ?? scene.windows.first {
|
||||
let bottom = window.safeAreaInsets.bottom
|
||||
bottomInset = bottom < 50 ? bottom : 34
|
||||
} else {
|
||||
bottomInset = 34
|
||||
}
|
||||
|
||||
NotificationCenter.default
|
||||
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
|
||||
.sink { [weak self] in self?.handleNotification($0) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func updateFromKVO(keyboardHeight: CGFloat) {
|
||||
guard !isAnimating else { return }
|
||||
|
||||
if keyboardHeight <= 0 {
|
||||
baseKeyboardHeight = nil
|
||||
|
||||
if interactiveOffset > 0 {
|
||||
if pendingResetTask == nil {
|
||||
pendingResetTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
if self.interactiveOffset != 0 || self.keyboardPadding != 0 {
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
self.interactiveOffset = 0
|
||||
self.keyboardPadding = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if interactiveOffset != 0 {
|
||||
interactiveOffset = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pendingResetTask?.cancel()
|
||||
pendingResetTask = nil
|
||||
|
||||
if baseKeyboardHeight == nil {
|
||||
baseKeyboardHeight = keyboardHeight
|
||||
}
|
||||
|
||||
guard let base = baseKeyboardHeight else { return }
|
||||
|
||||
if keyboardHeight >= base {
|
||||
if keyboardHeight > base {
|
||||
baseKeyboardHeight = keyboardHeight
|
||||
let newPadding = max(0, keyboardHeight - bottomInset)
|
||||
if newPadding != keyboardPadding {
|
||||
keyboardPadding = newPadding
|
||||
}
|
||||
}
|
||||
|
||||
if interactiveOffset > 0 {
|
||||
withAnimation(.interactiveSpring(duration: 0.35)) {
|
||||
interactiveOffset = 0
|
||||
}
|
||||
} else if interactiveOffset != 0 {
|
||||
interactiveOffset = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let newOffset = base - keyboardHeight
|
||||
// Ignore small fluctuations (<10pt) from animation settling — only respond
|
||||
// to significant drags. Without this threshold, KVO reports slightly-off
|
||||
// values after isAnimating expires, causing a brief downward offset (sink).
|
||||
if newOffset > 10 {
|
||||
if newOffset != interactiveOffset { interactiveOffset = newOffset }
|
||||
} else if interactiveOffset != 0 {
|
||||
interactiveOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNotification(_ notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
|
||||
else { return }
|
||||
|
||||
let screenHeight = UIScreen.main.bounds.height
|
||||
let keyboardTop = endFrame.origin.y
|
||||
let isVisible = keyboardTop < screenHeight
|
||||
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0
|
||||
let endHeight = isVisible ? (screenHeight - keyboardTop) : 0
|
||||
|
||||
pendingResetTask?.cancel()
|
||||
pendingResetTask = nil
|
||||
|
||||
if isVisible {
|
||||
baseKeyboardHeight = endHeight
|
||||
let targetPadding = max(0, endHeight - bottomInset)
|
||||
|
||||
isAnimating = true
|
||||
if duration > 0 {
|
||||
withAnimation(.easeInOut(duration: duration)) {
|
||||
keyboardPadding = targetPadding
|
||||
interactiveOffset = 0
|
||||
}
|
||||
} else {
|
||||
keyboardPadding = targetPadding
|
||||
interactiveOffset = 0
|
||||
}
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(max(duration, 0.05) + 0.15))
|
||||
self?.isAnimating = false
|
||||
}
|
||||
} else {
|
||||
baseKeyboardHeight = nil
|
||||
|
||||
isAnimating = true
|
||||
if duration > 0 {
|
||||
withAnimation(.easeInOut(duration: duration)) {
|
||||
keyboardPadding = 0
|
||||
interactiveOffset = 0
|
||||
}
|
||||
} else {
|
||||
keyboardPadding = 0
|
||||
interactiveOffset = 0
|
||||
}
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(max(duration, 0.05) + 0.15))
|
||||
self?.isAnimating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ struct AuthCoordinator: View {
|
||||
.ignoresSafeArea()
|
||||
.opacity(fadeOverlay ? 1 : 0)
|
||||
.allowsHitTesting(fadeOverlay)
|
||||
.animation(.easeInOut(duration: 0.12), value: fadeOverlay)
|
||||
.animation(.easeInOut(duration: 0.08), value: fadeOverlay)
|
||||
}
|
||||
.overlay(alignment: .leading) {
|
||||
if canSwipeBack {
|
||||
@@ -174,9 +174,9 @@ private extension AuthCoordinator {
|
||||
navigationDirection = .forward
|
||||
fadeOverlay = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 140_000_000)
|
||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||
currentScreen = screen
|
||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
fadeOverlay = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ struct SetPasswordView: View {
|
||||
@State private var isCreating = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var focusedField: Field?
|
||||
@State private var showWeakPasswordAlert = false
|
||||
|
||||
fileprivate enum Field {
|
||||
case password, confirm
|
||||
@@ -25,6 +26,12 @@ struct SetPasswordView: View {
|
||||
passwordsMatch && !isCreating
|
||||
}
|
||||
|
||||
private var matchesDesktopPasswordPolicy: Bool {
|
||||
password.range(of: "[A-Z]+", options: .regularExpression) != nil &&
|
||||
password.range(of: "[0-9]+", options: .regularExpression) != nil &&
|
||||
password.range(of: "[$@#&!]+", options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
AuthNavigationBar(onBack: onBack)
|
||||
@@ -72,6 +79,14 @@ struct SetPasswordView: View {
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.alert("Insecure password", isPresented: $showWeakPasswordAlert) {
|
||||
Button("I'll come up with a new one", role: .cancel) {}
|
||||
Button("Continue") {
|
||||
performAccountCreation()
|
||||
}
|
||||
} message: {
|
||||
Text("Your password is insecure. Such passwords are easy to guess. Choose a stronger one, or continue anyway.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +215,15 @@ private extension SetPasswordView {
|
||||
}
|
||||
|
||||
func createAccount() {
|
||||
guard canCreate else { return }
|
||||
guard matchesDesktopPasswordPolicy else {
|
||||
showWeakPasswordAlert = true
|
||||
return
|
||||
}
|
||||
performAccountCreation()
|
||||
}
|
||||
|
||||
func performAccountCreation() {
|
||||
guard canCreate else { return }
|
||||
isCreating = true
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ struct UnlockView: View {
|
||||
account?.publicKey ?? ""
|
||||
}
|
||||
|
||||
/// First 2 chars of public key — matching Android's avatar text.
|
||||
/// Initials from display name, falling back to first 2 chars of public key.
|
||||
private var avatarText: String {
|
||||
RosettaColors.avatarText(publicKey: publicKey)
|
||||
RosettaColors.initials(name: account?.displayName ?? "", publicKey: publicKey)
|
||||
}
|
||||
|
||||
private var avatarColorIndex: Int {
|
||||
RosettaColors.avatarColorIndex(for: "", publicKey: publicKey)
|
||||
RosettaColors.avatarColorIndex(for: account?.displayName ?? "", publicKey: publicKey)
|
||||
}
|
||||
|
||||
/// Short public key — 7 characters like Android (e.g. "0325a4d").
|
||||
@@ -67,8 +67,8 @@ struct UnlockView: View {
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
// Short public key (7 chars like Android)
|
||||
Text(shortPublicKey)
|
||||
// Display name (or short public key fallback)
|
||||
Text(displayTitle)
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.opacity(showTitle ? 1 : 0)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
struct ChatDetailView: View {
|
||||
let route: ChatRoute
|
||||
@@ -17,7 +18,8 @@ struct ChatDetailView: View {
|
||||
@State private var sendError: String?
|
||||
@State private var isViewActive = false
|
||||
// markReadTask removed — read receipts no longer sent from .onChange(of: messages.count)
|
||||
@FocusState private var isInputFocused: Bool
|
||||
@State private var isInputFocused = false
|
||||
@StateObject private var keyboard = KeyboardTracker()
|
||||
|
||||
private var currentPublicKey: String {
|
||||
SessionManager.shared.currentPublicKey
|
||||
@@ -97,7 +99,11 @@ struct ChatDetailView: View {
|
||||
messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140))
|
||||
}
|
||||
.overlay { chatEdgeGradients }
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { composer }
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
composer
|
||||
.offset(y: keyboard.interactiveOffset)
|
||||
.animation(.spring(.smooth(duration: 0.32)), value: keyboard.interactiveOffset)
|
||||
}
|
||||
.background {
|
||||
ZStack {
|
||||
RosettaColors.Adaptive.background
|
||||
@@ -124,8 +130,15 @@ struct ChatDetailView: View {
|
||||
guard isViewActive else { return }
|
||||
activateDialog()
|
||||
markDialogAsRead()
|
||||
// Clear delivered notifications from this sender
|
||||
clearDeliveredNotifications(for: route.publicKey)
|
||||
// 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.
|
||||
// PacketSearch (0x03) returns current online state, supplementing 0x05 subscription.
|
||||
if !route.isSavedMessages {
|
||||
SessionManager.shared.forceRefreshUserInfo(publicKey: route.publicKey)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
isViewActive = false
|
||||
@@ -177,7 +190,9 @@ private extension ChatDetailView {
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(
|
||||
isTyping || (dialog?.isOnline == true)
|
||||
isTyping
|
||||
? RosettaColors.primaryBlue
|
||||
: (dialog?.isOnline == true)
|
||||
? RosettaColors.online
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
@@ -185,6 +200,7 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 120)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
@@ -229,7 +245,9 @@ private extension ChatDetailView {
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(
|
||||
isTyping || (dialog?.isOnline == true)
|
||||
isTyping
|
||||
? RosettaColors.primaryBlue
|
||||
: (dialog?.isOnline == true)
|
||||
? RosettaColors.online
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
@@ -237,6 +255,7 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minWidth: 120)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
@@ -416,7 +435,7 @@ private extension ChatDetailView {
|
||||
.padding(.top, messagesTopInset)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onTapGesture { isInputFocused = false }
|
||||
.onAppear {
|
||||
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
|
||||
@@ -440,13 +459,8 @@ private extension ChatDetailView {
|
||||
guard focused else { return }
|
||||
// User tapped the input — reset idle timer.
|
||||
SessionManager.shared.recordUserInteraction()
|
||||
// Delay matches keyboard animation (~250ms) so scroll happens after layout settles.
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
scrollToBottom(proxy: proxy, animated: true)
|
||||
scrollToBottom(proxy: proxy, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
scroll
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.scrollIndicators(.hidden)
|
||||
@@ -535,16 +549,15 @@ private extension ChatDetailView {
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
TextField("Message", text: $messageText, axis: .vertical)
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1...5)
|
||||
.focused($isInputFocused)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled()
|
||||
ChatTextInput(
|
||||
text: $messageText,
|
||||
isFocused: $isInputFocused,
|
||||
onKeyboardHeightChange: { keyboard.updateFromKVO(keyboardHeight: $0) },
|
||||
onUserTextInsertion: handleComposerUserTyping,
|
||||
textColor: UIColor(RosettaColors.Adaptive.text),
|
||||
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
||||
)
|
||||
.padding(.leading, 6)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
||||
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
@@ -625,6 +638,7 @@ private extension ChatDetailView {
|
||||
.padding(.trailing, composerTrailingPadding)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 4)
|
||||
.simultaneousGesture(composerDismissGesture)
|
||||
.animation(composerAnimation, value: canSend)
|
||||
.animation(composerAnimation, value: shouldShowSendButton)
|
||||
}
|
||||
@@ -739,6 +753,17 @@ private extension ChatDetailView {
|
||||
else { isInputFocused = true }
|
||||
}
|
||||
|
||||
var composerDismissGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
guard isInputFocused else { return }
|
||||
let vertical = value.translation.height
|
||||
let horizontal = value.translation.width
|
||||
guard vertical > 12, abs(vertical) > abs(horizontal) else { return }
|
||||
isInputFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryTint(_ status: DeliveryStatus) -> Color {
|
||||
switch status {
|
||||
case .read: return Color(hex: 0xA4E2FF)
|
||||
@@ -852,6 +877,19 @@ private extension ChatDetailView {
|
||||
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
|
||||
}
|
||||
|
||||
/// Remove all delivered push notifications from this specific sender.
|
||||
func clearDeliveredNotifications(for senderKey: String) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getDeliveredNotifications { delivered in
|
||||
let idsToRemove = delivered
|
||||
.filter { $0.request.content.userInfo["sender_public_key"] as? String == senderKey }
|
||||
.map { $0.request.identifier }
|
||||
if !idsToRemove.isEmpty {
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendCurrentMessage() {
|
||||
let message = trimmedMessage
|
||||
guard !message.isEmpty else { return }
|
||||
@@ -877,6 +915,10 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
func handleComposerUserTyping() {
|
||||
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
||||
}
|
||||
|
||||
static let timeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
|
||||
@@ -135,7 +135,7 @@ private extension ChatListSearchContent {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
ForEach(viewModel.recentSearches, id: \.publicKey) { recent in
|
||||
|
||||
@@ -56,7 +56,7 @@ struct ChatListView: View {
|
||||
navigationState.path.append(route)
|
||||
// Delay search dismissal so NavigationStack processes
|
||||
// the push before the search overlay is removed.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
isSearchActive = false
|
||||
isSearchFocused = false
|
||||
searchText = ""
|
||||
@@ -92,6 +92,11 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
.tint(RosettaColors.figmaBlue)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
|
||||
guard let route = notification.object as? ChatRoute else { return }
|
||||
// Navigate to the chat from push notification tap
|
||||
navigationState.path = [route]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cancel Search
|
||||
|
||||
@@ -172,7 +172,9 @@ private extension ChatRowView {
|
||||
.rotationEffect(.degrees(45))
|
||||
}
|
||||
|
||||
if dialog.unreadCount > 0 {
|
||||
// Desktop parity: delivery icon and unread badge are
|
||||
// mutually exclusive — badge hidden when lastMessageFromMe.
|
||||
if dialog.unreadCount > 0 && !dialog.lastMessageFromMe {
|
||||
unreadBadge
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ import SwiftUI
|
||||
|
||||
/// Telegram-style skeleton loading for search results.
|
||||
/// Matches the Figma chat row layout: 62px avatar, two-line text, trailing time.
|
||||
/// Uses TimelineView so the shimmer never restarts on view rebuild.
|
||||
struct SearchSkeletonView: View {
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation) { timeline in
|
||||
let phase = shimmerPhase(from: timeline.date)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(0..<7, id: \.self) { index in
|
||||
skeletonRow(index: index)
|
||||
skeletonRow(index: index, phase: phase)
|
||||
if index < 6 {
|
||||
Divider()
|
||||
.foregroundStyle(RosettaColors.Adaptive.divider)
|
||||
@@ -21,18 +24,14 @@ struct SearchSkeletonView: View {
|
||||
}
|
||||
}
|
||||
.scrollDisabled(true)
|
||||
.task {
|
||||
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func skeletonRow(index: Int) -> some View {
|
||||
private func skeletonRow(index: Int, phase: CGFloat) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
// Avatar — 62pt circle matching Figma
|
||||
Circle()
|
||||
.fill(shimmerGradient)
|
||||
.fill(shimmerGradient(phase: phase))
|
||||
.frame(width: 62, height: 62)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 10)
|
||||
@@ -41,12 +40,12 @@ struct SearchSkeletonView: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Title line — name width varies per row
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.fill(shimmerGradient(phase: phase))
|
||||
.frame(width: titleWidth(for: index), height: 16)
|
||||
|
||||
// Subtitle line — message preview
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.fill(shimmerGradient(phase: phase))
|
||||
.frame(width: subtitleWidth(for: index), height: 14)
|
||||
}
|
||||
|
||||
@@ -54,7 +53,7 @@ struct SearchSkeletonView: View {
|
||||
|
||||
// Trailing time placeholder
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(shimmerGradient)
|
||||
.fill(shimmerGradient(phase: phase))
|
||||
.frame(width: 40, height: 12)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
@@ -71,18 +70,6 @@ struct SearchSkeletonView: View {
|
||||
let widths: [CGFloat] = [200, 170, 220, 150, 190, 180, 210]
|
||||
return widths[index % widths.count]
|
||||
}
|
||||
|
||||
private var shimmerGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.gray.opacity(0.08),
|
||||
Color.gray.opacity(0.15),
|
||||
Color.gray.opacity(0.08),
|
||||
],
|
||||
startPoint: UnitPoint(x: phase - 0.4, y: 0),
|
||||
endPoint: UnitPoint(x: phase + 0.4, y: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchSkeletonRow
|
||||
@@ -90,21 +77,23 @@ struct SearchSkeletonView: View {
|
||||
/// Single shimmer row matching `serverUserRow` layout (48px avatar, two text lines).
|
||||
/// Used inline below existing search results while server is still loading.
|
||||
struct SearchSkeletonRow: View {
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation) { timeline in
|
||||
let phase = shimmerPhase(from: timeline.date)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(shimmerGradient)
|
||||
.fill(shimmerGradient(phase: phase))
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.fill(shimmerGradient(phase: phase))
|
||||
.frame(width: 120, height: 14)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.fill(shimmerGradient(phase: phase))
|
||||
.frame(width: 90, height: 12)
|
||||
}
|
||||
|
||||
@@ -112,22 +101,32 @@ struct SearchSkeletonRow: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.task {
|
||||
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shimmerGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
// MARK: - Shared Shimmer Helpers
|
||||
|
||||
/// Derives a 0→1 phase from the system clock (1.5s cycle).
|
||||
/// Clock-based — never resets on view rebuild.
|
||||
private func shimmerPhase(from date: Date) -> CGFloat {
|
||||
let elapsed = date.timeIntervalSinceReferenceDate
|
||||
let cycle: Double = 1.5
|
||||
return CGFloat(elapsed.truncatingRemainder(dividingBy: cycle) / cycle)
|
||||
}
|
||||
|
||||
private func shimmerGradient(phase: CGFloat) -> LinearGradient {
|
||||
// Map phase 0→1 to position -0.5→1.5 so the highlight
|
||||
// enters from off-screen left and exits off-screen right.
|
||||
// When phase wraps 1→0, highlight is already invisible — no jump.
|
||||
let position = phase * 2.0 - 0.5
|
||||
return LinearGradient(
|
||||
colors: [
|
||||
Color.gray.opacity(0.08),
|
||||
Color.gray.opacity(0.15),
|
||||
Color.gray.opacity(0.08),
|
||||
],
|
||||
startPoint: UnitPoint(x: phase - 0.4, y: 0),
|
||||
endPoint: UnitPoint(x: phase + 0.4, y: 0)
|
||||
startPoint: UnitPoint(x: position - 0.3, y: 0),
|
||||
endPoint: UnitPoint(x: position + 0.3, y: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ struct SearchView: View {
|
||||
|
||||
@MainActor static var _bodyCount = 0
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🔵 SearchView.body #\(Self._bodyCount)")
|
||||
#endif
|
||||
NavigationStack(path: $navigationPath) {
|
||||
ZStack(alignment: .bottom) {
|
||||
RosettaColors.Adaptive.background
|
||||
@@ -143,8 +145,10 @@ private struct FavoriteContactsRow: View {
|
||||
@MainActor static var _bodyCount = 0
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟠 FavoriteContactsRow.body #\(Self._bodyCount)")
|
||||
#endif
|
||||
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
|
||||
if !dialogs.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
@@ -191,8 +195,10 @@ private struct RecentSection: View {
|
||||
@MainActor static var _bodyCount = 0
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟤 RecentSection.body #\(Self._bodyCount)")
|
||||
#endif
|
||||
if viewModel.recentSearches.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
|
||||
@@ -2,13 +2,14 @@ import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
/// Embedded profile editing content (no NavigationStack — lives inside SettingsView's).
|
||||
/// Matches Telegram's edit screen: avatar + photo picker, name fields,
|
||||
/// helper texts, "Add Another Account", and "Log Out".
|
||||
/// Avatar + photo picker, name fields with validation.
|
||||
struct ProfileEditView: View {
|
||||
@Binding var displayName: String
|
||||
@Binding var username: String
|
||||
let publicKey: String
|
||||
var onLogout: () -> Void = {}
|
||||
|
||||
@Binding var displayNameError: String?
|
||||
@Binding var usernameError: String?
|
||||
|
||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||
@State private var selectedPhoto: UIImage?
|
||||
@@ -33,12 +34,6 @@ struct ProfileEditView: View {
|
||||
.padding(.bottom, 24)
|
||||
|
||||
addAccountSection
|
||||
|
||||
helperText("You can connect multiple accounts with different phone numbers.")
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
logoutSection
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 24)
|
||||
@@ -88,14 +83,19 @@ private extension ProfileEditView {
|
||||
|
||||
private extension ProfileEditView {
|
||||
var nameSection: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
||||
VStack(spacing: 0) {
|
||||
// Display Name field
|
||||
HStack {
|
||||
TextField("First Name", text: $displayName)
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.words)
|
||||
.onChange(of: displayName) { _, newValue in
|
||||
displayNameError = ProfileValidator.validateDisplayName(newValue)?.errorDescription
|
||||
}
|
||||
|
||||
if !displayName.isEmpty {
|
||||
Button { displayName = "" } label: {
|
||||
@@ -113,14 +113,27 @@ private extension ProfileEditView {
|
||||
.background(RosettaColors.Adaptive.divider)
|
||||
.padding(.leading, 16)
|
||||
|
||||
// Username field
|
||||
HStack {
|
||||
TextField("Username", text: $username)
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: username) { _, newValue in
|
||||
let lowered = newValue.lowercased()
|
||||
if lowered != newValue {
|
||||
username = lowered
|
||||
}
|
||||
usernameError = ProfileValidator.validateUsername(lowered)?.errorDescription
|
||||
}
|
||||
|
||||
if !username.isEmpty {
|
||||
Text("\(username.count)/32")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.tertiaryText)
|
||||
.padding(.trailing, 4)
|
||||
|
||||
Button { username = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
@@ -133,10 +146,27 @@ private extension ProfileEditView {
|
||||
.frame(height: 52)
|
||||
}
|
||||
}
|
||||
|
||||
// Validation errors below the card
|
||||
if let displayNameError {
|
||||
Text(displayNameError)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
if let usernameError {
|
||||
Text(usernameError)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, displayNameError != nil ? 2 : 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add Account & Logout
|
||||
// MARK: - Add Account & Helpers
|
||||
|
||||
private extension ProfileEditView {
|
||||
var addAccountSection: some View {
|
||||
@@ -155,21 +185,6 @@ private extension ProfileEditView {
|
||||
}
|
||||
}
|
||||
|
||||
var logoutSection: some View {
|
||||
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
||||
Button(action: onLogout) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Delete Account")
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 52)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func helperText(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13))
|
||||
@@ -187,7 +202,9 @@ private extension ProfileEditView {
|
||||
ProfileEditView(
|
||||
displayName: .constant("Gaidar"),
|
||||
username: .constant("GaidarTheDev"),
|
||||
publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec"
|
||||
publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec",
|
||||
displayNameError: .constant(nil),
|
||||
usernameError: .constant(nil)
|
||||
)
|
||||
}
|
||||
.background(RosettaColors.Adaptive.background)
|
||||
|
||||
@@ -8,12 +8,14 @@ struct SettingsView: View {
|
||||
@Binding var isEditingProfile: Bool
|
||||
|
||||
@StateObject private var viewModel = SettingsViewModel()
|
||||
@State private var showCopiedToast = false
|
||||
@State private var showDeleteAccountConfirmation = false
|
||||
|
||||
// Edit mode field state — initialized when entering edit mode
|
||||
@State private var editDisplayName = ""
|
||||
@State private var editUsername = ""
|
||||
@State private var displayNameError: String?
|
||||
@State private var usernameError: String?
|
||||
@State private var isSaving = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -23,7 +25,8 @@ struct SettingsView: View {
|
||||
displayName: $editDisplayName,
|
||||
username: $editUsername,
|
||||
publicKey: viewModel.publicKey,
|
||||
onLogout: { showDeleteAccountConfirmation = true }
|
||||
displayNameError: $displayNameError,
|
||||
usernameError: $usernameError
|
||||
)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
@@ -51,12 +54,8 @@ struct SettingsView: View {
|
||||
if !isEditing { viewModel.refresh() }
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if showCopiedToast {
|
||||
copiedToast
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
@@ -66,7 +65,7 @@ struct SettingsView: View {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
if isEditingProfile {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = false
|
||||
}
|
||||
} label: {
|
||||
@@ -78,6 +77,7 @@ struct SettingsView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassCapsule()
|
||||
.disabled(isSaving)
|
||||
} else {
|
||||
Button {} label: {
|
||||
Image(systemName: "qrcode")
|
||||
@@ -111,7 +111,9 @@ struct SettingsView: View {
|
||||
Button {
|
||||
editDisplayName = viewModel.displayName
|
||||
editUsername = viewModel.username
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
displayNameError = nil
|
||||
usernameError = nil
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = true
|
||||
}
|
||||
} label: {
|
||||
@@ -135,17 +137,54 @@ struct SettingsView: View {
|
||||
|
||||
private func saveProfile() {
|
||||
let trimmedName = editDisplayName.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedUsername = editUsername.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedUsername = editUsername.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
|
||||
if hasProfileChanges {
|
||||
AccountManager.shared.updateProfile(
|
||||
displayName: trimmedName,
|
||||
username: trimmedUsername
|
||||
)
|
||||
SessionManager.shared.updateDisplayNameAndUsername(
|
||||
displayName: trimmedName,
|
||||
username: trimmedUsername
|
||||
)
|
||||
// Validate before saving
|
||||
if let error = ProfileValidator.validateDisplayName(trimmedName) {
|
||||
displayNameError = error.errorDescription
|
||||
return
|
||||
}
|
||||
if let error = ProfileValidator.validateUsername(trimmedUsername) {
|
||||
usernameError = error.errorDescription
|
||||
return
|
||||
}
|
||||
displayNameError = nil
|
||||
usernameError = nil
|
||||
|
||||
guard hasProfileChanges else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
|
||||
// Register one-shot result handler (Android parity: waitPacket(0x02))
|
||||
let handlerId = ProtocolManager.shared.addResultHandler { result in
|
||||
Task { @MainActor in
|
||||
guard isSaving else { return }
|
||||
isSaving = false
|
||||
|
||||
if let code = ResultCode(rawValue: result.resultCode), code == .success {
|
||||
// Server confirmed — update local profile
|
||||
updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = false
|
||||
}
|
||||
} else {
|
||||
// Server returned error
|
||||
if result.resultCode == ResultCode.usernameTaken.rawValue {
|
||||
usernameError = "This username is already taken"
|
||||
} else {
|
||||
usernameError = "Failed to save profile"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send PacketUserInfo to server
|
||||
if let hash = SessionManager.shared.privateKeyHash {
|
||||
var packet = PacketUserInfo()
|
||||
packet.username = trimmedUsername
|
||||
@@ -153,12 +192,31 @@ struct SettingsView: View {
|
||||
packet.privateKey = hash
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
// 10s timeout — fallback to local save (Android parity)
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
guard isSaving else { return }
|
||||
// Server didn't respond — save locally as fallback
|
||||
ProtocolManager.shared.removeResultHandler(handlerId)
|
||||
isSaving = false
|
||||
updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateLocalProfile(displayName: String, username: String) {
|
||||
AccountManager.shared.updateProfile(
|
||||
displayName: displayName,
|
||||
username: username
|
||||
)
|
||||
SessionManager.shared.updateDisplayNameAndUsername(
|
||||
displayName: displayName,
|
||||
username: username
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Settings Content
|
||||
|
||||
@@ -200,23 +258,13 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.copyPublicKey()
|
||||
withAnimation(.easeInOut(duration: 0.25)) { showCopiedToast = true }
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
withAnimation { showCopiedToast = false }
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(formatPublicKey(viewModel.publicKey))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(RosettaColors.tertiaryText)
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
}
|
||||
}
|
||||
CopyableText(
|
||||
displayText: formatPublicKey(viewModel.publicKey),
|
||||
fullText: viewModel.publicKey,
|
||||
font: .monospacedSystemFont(ofSize: 12, weight: .regular),
|
||||
textColor: UIColor(RosettaColors.tertiaryText)
|
||||
)
|
||||
.frame(height: 16)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
@@ -330,14 +378,4 @@ struct SettingsView: View {
|
||||
return String(key.prefix(8)) + "..." + String(key.suffix(6))
|
||||
}
|
||||
|
||||
private var copiedToast: some View {
|
||||
Text("Public key copied")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(RosettaColors.Adaptive.backgroundSecondary)
|
||||
.clipShape(Capsule())
|
||||
.padding(.top, 60)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,4 @@ final class SettingsViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func copyPublicKey() {
|
||||
UIPasteboard.general.string = publicKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
/// Handle foreground notifications — suppress when app is active (Android parity).
|
||||
/// Handle foreground notifications — suppress only when the specific chat is open.
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
@@ -62,21 +62,54 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
let userInfo = notification.request.content.userInfo
|
||||
let type = userInfo["type"] as? String
|
||||
|
||||
// Suppress foreground notifications (Android parity: isAppInForeground check)
|
||||
if type == "new_message" {
|
||||
let senderKey = userInfo["sender_public_key"] as? String ?? ""
|
||||
// Only suppress if this specific chat is currently open
|
||||
if !senderKey.isEmpty,
|
||||
MessageRepository.shared.isDialogActive(senderKey)
|
||||
{
|
||||
completionHandler([])
|
||||
} else {
|
||||
completionHandler([.banner, .badge, .sound])
|
||||
}
|
||||
} else {
|
||||
completionHandler([.banner, .badge, .sound])
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle notification tap — navigate to chat.
|
||||
/// Handle notification tap — navigate to the sender's chat.
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
// TODO: Navigate to specific chat using sender_public_key from payload
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
let senderKey = userInfo["sender_public_key"] as? String ?? ""
|
||||
|
||||
if !senderKey.isEmpty {
|
||||
let senderName = userInfo["sender_name"] as? String ?? ""
|
||||
let route = ChatRoute(
|
||||
publicKey: senderKey,
|
||||
title: senderName,
|
||||
username: "",
|
||||
verified: 0
|
||||
)
|
||||
// Post notification for ChatListView to handle navigation
|
||||
NotificationCenter.default.post(
|
||||
name: .openChatFromNotification,
|
||||
object: route
|
||||
)
|
||||
// Clear all delivered notifications from this sender
|
||||
center.getDeliveredNotifications { delivered in
|
||||
let idsToRemove = delivered
|
||||
.filter { $0.request.content.userInfo["sender_public_key"] as? String == senderKey }
|
||||
.map { $0.request.identifier }
|
||||
if !idsToRemove.isEmpty {
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
@@ -131,7 +164,7 @@ struct RosettaApp: App {
|
||||
.ignoresSafeArea()
|
||||
.opacity(transitionOverlay ? 1 : 0)
|
||||
.allowsHitTesting(transitionOverlay)
|
||||
.animation(.easeInOut(duration: 0.12), value: transitionOverlay)
|
||||
.animation(.easeInOut(duration: 0.08), value: transitionOverlay)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear {
|
||||
@@ -145,8 +178,10 @@ 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 {
|
||||
@@ -193,9 +228,9 @@ struct RosettaApp: App {
|
||||
guard !transitionOverlay else { return }
|
||||
transitionOverlay = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 140_000_000) // wait for overlay fade-in
|
||||
try? await Task.sleep(nanoseconds: 80_000_000) // wait for overlay fade-in
|
||||
appState = newState
|
||||
try? await Task.sleep(nanoseconds: 30_000_000) // brief settle
|
||||
try? await Task.sleep(nanoseconds: 20_000_000) // brief settle
|
||||
transitionOverlay = false
|
||||
}
|
||||
}
|
||||
@@ -209,3 +244,10 @@ struct RosettaApp: App {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Names
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted when user taps a push notification — carries a `ChatRoute` as `object`.
|
||||
static let openChatFromNotification = Notification.Name("openChatFromNotification")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user