diff --git a/.gitignore b/.gitignore index 58ae931..a723ed0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ sprints/ CLAUDE.md .claude.local.md desktop +AGENTS.md # Xcode build/ diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index e6328f8..db40619 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -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 = ""; diff --git a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme index f1f4a59..373a139 100644 --- a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme +++ b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> (_ type: T.Type, fileName: String) -> T? { + func load(_ 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(_ value: T, fileName: String) { + func save(_ 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) { diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 6eef8d1..483d445 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -12,6 +12,7 @@ final class DialogRepository { didSet { _sortedDialogsCache = nil } } private var currentAccount: String = "" + private var storagePassword: String = "" private var persistTask: Task? 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 + ) } } diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index fd149ef..3348abc 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -16,21 +16,25 @@ final class MessageRepository: ObservableObject { private var typingResetTasks: [String: Task] = [:] private var persistTask: Task? 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 + ) } } diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift b/Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift index 4744c2a..5834bb2 100644 --- a/Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift +++ b/Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift @@ -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) diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 4f889be..c64d9d5 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -57,8 +57,10 @@ final class ProtocolManager: @unchecked Sendable { private var heartbeatTask: Task? private var handshakeTimeoutTask: Task? 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 diff --git a/Rosetta/Core/Network/Protocol/WebSocketClient.swift b/Rosetta/Core/Network/Protocol/WebSocketClient.swift index d0ed0c1..d65056a 100644 --- a/Rosetta/Core/Network/Protocol/WebSocketClient.swift +++ b/Rosetta/Core/Network/Protocol/WebSocketClient.swift @@ -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 diff --git a/Rosetta/Core/Services/DeviceIdentityManager.swift b/Rosetta/Core/Services/DeviceIdentityManager.swift new file mode 100644 index 0000000..42b4275 --- /dev/null +++ b/Rosetta/Core/Services/DeviceIdentityManager.swift @@ -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] }) + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 11a093c..f3cabb7 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -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() + } } } } diff --git a/Rosetta/Core/Utils/ProfileValidator.swift b/Rosetta/Core/Utils/ProfileValidator.swift new file mode 100644 index 0000000..c071c52 --- /dev/null +++ b/Rosetta/Core/Utils/ProfileValidator.swift @@ -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 = [ + "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 + } +} diff --git a/Rosetta/Core/Utils/ProtocolConstants.swift b/Rosetta/Core/Utils/ProtocolConstants.swift index 7e17b3a..ca9c8f0 100644 --- a/Rosetta/Core/Utils/ProtocolConstants.swift +++ b/Rosetta/Core/Utils/ProtocolConstants.swift @@ -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 diff --git a/Rosetta/DesignSystem/Components/ChatTextInput.swift b/Rosetta/DesignSystem/Components/ChatTextInput.swift new file mode 100644 index 0000000..054998c --- /dev/null +++ b/Rosetta/DesignSystem/Components/ChatTextInput.swift @@ -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) + } + } +} diff --git a/Rosetta/DesignSystem/Components/CopyableText.swift b/Rosetta/DesignSystem/Components/CopyableText.swift new file mode 100644 index 0000000..c1a937b --- /dev/null +++ b/Rosetta/DesignSystem/Components/CopyableText.swift @@ -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]) + } +} diff --git a/Rosetta/DesignSystem/Components/KeyboardTracker.swift b/Rosetta/DesignSystem/Components/KeyboardTracker.swift new file mode 100644 index 0000000..a7c62cc --- /dev/null +++ b/Rosetta/DesignSystem/Components/KeyboardTracker.swift @@ -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? + private var cancellables = Set() + + 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 + } + } + } +} diff --git a/Rosetta/Features/Auth/AuthCoordinator.swift b/Rosetta/Features/Auth/AuthCoordinator.swift index 9dba785..b8477d2 100644 --- a/Rosetta/Features/Auth/AuthCoordinator.swift +++ b/Rosetta/Features/Auth/AuthCoordinator.swift @@ -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 } } diff --git a/Rosetta/Features/Auth/SetPasswordView.swift b/Rosetta/Features/Auth/SetPasswordView.swift index 2e54874..193bc69 100644 --- a/Rosetta/Features/Auth/SetPasswordView.swift +++ b/Rosetta/Features/Auth/SetPasswordView.swift @@ -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 diff --git a/Rosetta/Features/Auth/UnlockView.swift b/Rosetta/Features/Auth/UnlockView.swift index 7aec1a6..794ad39 100644 --- a/Rosetta/Features/Auth/UnlockView.swift +++ b/Rosetta/Features/Auth/UnlockView.swift @@ -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) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 50a6d74..9eda262 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -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,14 +190,17 @@ private extension ChatDetailView { Text(subtitleText) .font(.system(size: 12, weight: .medium)) .foregroundStyle( - isTyping || (dialog?.isOnline == true) - ? RosettaColors.online - : RosettaColors.Adaptive.textSecondary + isTyping + ? RosettaColors.primaryBlue + : (dialog?.isOnline == true) + ? RosettaColors.online + : RosettaColors.Adaptive.textSecondary ) .lineLimit(1) } } .padding(.horizontal, 12) + .frame(minWidth: 120) .frame(height: 44) .background { glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white) @@ -229,14 +245,17 @@ private extension ChatDetailView { Text(subtitleText) .font(.system(size: 12, weight: .medium)) .foregroundStyle( - isTyping || (dialog?.isOnline == true) - ? RosettaColors.online - : RosettaColors.Adaptive.textSecondary + isTyping + ? RosettaColors.primaryBlue + : (dialog?.isOnline == true) + ? RosettaColors.online + : RosettaColors.Adaptive.textSecondary ) .lineLimit(1) } } .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,17 +549,16 @@ 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() - .padding(.leading, 6) - .padding(.top, 6) - .padding(.bottom, 8) - .frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading) + 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) + .frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading) HStack(alignment: .center, spacing: 0) { Button { } label: { @@ -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") @@ -1139,7 +1181,7 @@ private struct SVGPathParser { while index < tokens.count { if case .command = tokens[index] { return } index += 1 - } + } } } diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index 929b6d7..a7f287e 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 8cde306..212f96c 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index 0b8c3f4..a7330c9 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -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 } } diff --git a/Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift b/Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift index 1c0908c..8decebe 100644 --- a/Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift +++ b/Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift @@ -4,35 +4,34 @@ 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 { - ScrollView { - VStack(spacing: 0) { - ForEach(0..<7, id: \.self) { index in - skeletonRow(index: index) - if index < 6 { - Divider() - .foregroundStyle(RosettaColors.Adaptive.divider) - .padding(.leading, 82) + TimelineView(.animation) { timeline in + let phase = shimmerPhase(from: timeline.date) + + ScrollView { + VStack(spacing: 0) { + ForEach(0..<7, id: \.self) { index in + skeletonRow(index: index, phase: phase) + if index < 6 { + Divider() + .foregroundStyle(RosettaColors.Adaptive.divider) + .padding(.leading, 82) + } } } } - } - .scrollDisabled(true) - .task { - withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { - phase = 1 - } + .scrollDisabled(true) } } - 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,44 +77,56 @@ 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 { - HStack(spacing: 12) { - Circle() - .fill(shimmerGradient) - .frame(width: 48, height: 48) + TimelineView(.animation) { timeline in + let phase = shimmerPhase(from: timeline.date) - VStack(alignment: .leading, spacing: 6) { - RoundedRectangle(cornerRadius: 4) - .fill(shimmerGradient) - .frame(width: 120, height: 14) + HStack(spacing: 12) { + Circle() + .fill(shimmerGradient(phase: phase)) + .frame(width: 48, height: 48) - RoundedRectangle(cornerRadius: 4) - .fill(shimmerGradient) - .frame(width: 90, height: 12) + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient(phase: phase)) + .frame(width: 120, height: 14) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient(phase: phase)) + .frame(width: 90, height: 12) + } + + Spacer() } - - Spacer() + .padding(.horizontal, 16) + .padding(.vertical, 12) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .task { - withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { - phase = 1 - } - } - } - - 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: - 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: position - 0.3, y: 0), + endPoint: UnitPoint(x: position + 0.3, y: 0) + ) +} diff --git a/Rosetta/Features/Chats/Search/SearchView.swift b/Rosetta/Features/Chats/Search/SearchView.swift index 7cb88a8..71c2ccb 100644 --- a/Rosetta/Features/Chats/Search/SearchView.swift +++ b/Rosetta/Features/Chats/Search/SearchView.swift @@ -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 { diff --git a/Rosetta/Features/Settings/ProfileEditView.swift b/Rosetta/Features/Settings/ProfileEditView.swift index 6be2c64..6a71b0c 100644 --- a/Rosetta/Features/Settings/ProfileEditView.swift +++ b/Rosetta/Features/Settings/ProfileEditView.swift @@ -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,55 +83,90 @@ private extension ProfileEditView { private extension ProfileEditView { var nameSection: some View { - GlassCard(cornerRadius: 26, fillOpacity: 0.08) { - VStack(spacing: 0) { - HStack { - TextField("First Name", text: $displayName) - .font(.system(size: 17)) - .foregroundStyle(RosettaColors.Adaptive.text) - .autocorrectionDisabled() - .textInputAutocapitalization(.words) + 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: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 18)) - .foregroundStyle(RosettaColors.tertiaryText) + if !displayName.isEmpty { + Button { displayName = "" } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(RosettaColors.tertiaryText) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } - } - .padding(.horizontal, 16) - .frame(height: 52) + .padding(.horizontal, 16) + .frame(height: 52) - Divider() - .background(RosettaColors.Adaptive.divider) - .padding(.leading, 16) + Divider() + .background(RosettaColors.Adaptive.divider) + .padding(.leading, 16) - HStack { - TextField("Username", text: $username) - .font(.system(size: 17)) - .foregroundStyle(RosettaColors.Adaptive.text) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + // 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 { - Button { username = "" } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 18)) + 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)) + .foregroundStyle(RosettaColors.tertiaryText) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } + .padding(.horizontal, 16) + .frame(height: 52) } - .padding(.horizontal, 16) - .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) diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index 16a6eed..42e6700 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -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,29 +137,85 @@ 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 - ) - if let hash = SessionManager.shared.privateKeyHash { - var packet = PacketUserInfo() - packet.username = trimmedUsername - packet.title = trimmedName - packet.privateKey = hash - ProtocolManager.shared.sendPacket(packet) + // 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" + } + } } } - withAnimation(.easeInOut(duration: 0.3)) { - isEditingProfile = false + // Send PacketUserInfo to server + if let hash = SessionManager.shared.privateKeyHash { + var packet = PacketUserInfo() + packet.username = trimmedUsername + packet.title = trimmedName + packet.privateKey = hash + ProtocolManager.shared.sendPacket(packet) } + + // 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) - } } diff --git a/Rosetta/Features/Settings/SettingsViewModel.swift b/Rosetta/Features/Settings/SettingsViewModel.swift index 96ef066..740769e 100644 --- a/Rosetta/Features/Settings/SettingsViewModel.swift +++ b/Rosetta/Features/Settings/SettingsViewModel.swift @@ -79,7 +79,4 @@ final class SettingsViewModel: ObservableObject { } } - func copyPublicKey() { - UIPasteboard.general.string = publicKey - } } diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index c4db6ae..fcb7a31 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -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" { - completionHandler([]) + 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") +}