From 70deaaf7f7b9f1cafd334c03d6b45ff9f330d7a3 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Thu, 12 Mar 2026 04:01:21 +0500 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D0=BE=D0=BB=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8?= =?UTF-8?q?:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20toggle=20?= =?UTF-8?q?=D0=B2=20UIKit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repositories/DialogRepository.swift | 59 ++++- .../Data/Repositories/MessageRepository.swift | 42 ++- .../Packets/PacketOnlineSubscribe.swift | 15 +- .../Network/Protocol/WebSocketClient.swift | 10 +- Rosetta/Core/Services/SessionManager.swift | 179 ++++++++++--- .../Components/RosettaTabBar.swift | 5 +- .../Components/SwipeBackModifier.swift | 53 +++- Rosetta/Features/Auth/AuthCoordinator.swift | 17 +- Rosetta/Features/Auth/SetPasswordView.swift | 246 +++++++++++++----- .../Chats/ChatDetail/ChatDetailView.swift | 162 ++++++------ .../ChatDetail/ChatDetailViewModel.swift | 65 +++++ .../ChatList/ChatListSearchContent.swift | 9 +- .../Chats/ChatList/ChatListView.swift | 21 +- .../Chats/ChatList/ChatListViewModel.swift | 6 +- .../Features/Chats/ChatList/ChatRowView.swift | 31 ++- .../Chats/Search/SearchResultsSection.swift | 1 + .../Features/Chats/Search/SearchView.swift | 1 + .../Chats/Search/SearchViewModel.swift | 2 +- Rosetta/Features/MainTabView.swift | 39 +-- .../Features/Onboarding/OnboardingPager.swift | 2 + .../Features/Settings/ProfileEditView.swift | 7 +- Rosetta/Features/Settings/SettingsView.swift | 16 +- Rosetta/RosettaApp.swift | 3 +- 23 files changed, 706 insertions(+), 285 deletions(-) create mode 100644 Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index c9ac606..6eef8d1 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -8,15 +8,21 @@ final class DialogRepository { static let shared = DialogRepository() - private(set) var dialogs: [String: Dialog] = [:] + private(set) var dialogs: [String: Dialog] = [:] { + didSet { _sortedDialogsCache = nil } + } private var currentAccount: String = "" private var persistTask: Task? + private var _sortedDialogsCache: [Dialog]? var sortedDialogs: [Dialog] { - Array(dialogs.values).sorted { + if let cached = _sortedDialogsCache { return cached } + let sorted = Array(dialogs.values).sorted { if $0.isPinned != $1.isPinned { return $0.isPinned } return $0.lastMessageTimestamp > $1.lastMessageTimestamp } + _sortedDialogsCache = sorted + return sorted } private init() {} @@ -72,7 +78,9 @@ final class DialogRepository { } /// Creates or updates a dialog from an incoming message packet. - func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) { + /// - Parameter fromSync: When `true`, outgoing messages are marked as `.delivered` + /// because the server already processed them — delivery ACKs will never arrive again. + func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String, fromSync: Bool = false) { if currentAccount.isEmpty { currentAccount = myPublicKey } @@ -101,7 +109,7 @@ final class DialogRepository { dialog.lastMessage = decryptedText dialog.lastMessageTimestamp = normalizeTimestamp(packet.timestamp) dialog.lastMessageFromMe = fromMe - dialog.lastMessageDelivered = fromMe ? .waiting : .delivered + dialog.lastMessageDelivered = fromMe ? (fromSync ? .delivered : .waiting) : .delivered if fromMe { dialog.iHaveSent = true @@ -229,6 +237,26 @@ final class DialogRepository { schedulePersist() } + /// Update dialog metadata after a single message was deleted. + /// If no messages remain, the dialog is removed entirely. + func reconcileAfterMessageDelete(opponentKey: String) { + let messages = MessageRepository.shared.messages(for: opponentKey) + guard var dialog = dialogs[opponentKey] else { return } + + guard let lastMsg = messages.last else { + dialogs.removeValue(forKey: opponentKey) + schedulePersist() + return + } + + dialog.lastMessage = lastMsg.text + dialog.lastMessageTimestamp = lastMsg.timestamp + dialog.lastMessageFromMe = lastMsg.fromPublicKey == currentAccount + dialog.lastMessageDelivered = lastMsg.deliveryStatus + dialogs[opponentKey] = dialog + schedulePersist() + } + /// Desktop parity: check last N messages to determine if dialog should be a request. /// If none of the last `dialogDropToRequestsMessageCount` messages are from me, /// and the dialog is not a system account, mark as request (`iHaveSent = false`). @@ -280,6 +308,29 @@ final class DialogRepository { schedulePersist() } + /// Desktop parity: reconcile dialog-level `lastMessageDelivered` with the actual + /// delivery status of the last message in MessageRepository. + /// Called after sync completes to fix any divergence accumulated during batch processing. + func reconcileDeliveryStatuses() { + var changed = false + for (opponentKey, dialog) in dialogs { + guard dialog.lastMessageFromMe else { continue } + let messages = MessageRepository.shared.messages(for: opponentKey) + guard let lastMessage = messages.last, + lastMessage.fromPublicKey == currentAccount else { continue } + let realStatus = lastMessage.deliveryStatus + if dialog.lastMessageDelivered != realStatus { + var updated = dialog + updated.lastMessageDelivered = realStatus + dialogs[opponentKey] = updated + changed = true + } + } + if changed { + schedulePersist() + } + } + func toggleMute(opponentKey: String) { guard var dialog = dialogs[opponentKey] else { return } dialog.isMuted.toggle() diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index c3e6b5a..fd149ef 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -8,8 +8,8 @@ final class MessageRepository: ObservableObject { // Desktop parity: MESSAGE_MAX_LOADED = 40 per dialog. private let maxMessagesPerDialog = ProtocolConstants.messageMaxCached - @Published private var messagesByDialog: [String: [ChatMessage]] = [:] - @Published private var typingDialogs: Set = [] + @Published private(set) var messagesByDialog: [String: [ChatMessage]] = [:] + @Published private(set) var typingDialogs: Set = [] private var activeDialogs: Set = [] private var messageToDialog: [String: String] = [:] @@ -97,12 +97,17 @@ final class MessageRepository: ObservableObject { // MARK: - Message Updates - func upsertFromMessagePacket(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) { + /// - Parameter fromSync: When `true`, outgoing messages are created as `.delivered` + /// because the server already processed them during sync — ACKs will never arrive again. + func upsertFromMessagePacket(_ packet: PacketMessage, myPublicKey: String, decryptedText: String, fromSync: Bool = false) { let fromMe = packet.fromPublicKey == myPublicKey let dialogKey = fromMe ? packet.toPublicKey : packet.fromPublicKey let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId let timestamp = normalizeTimestamp(packet.timestamp) let incomingRead = !fromMe && activeDialogs.contains(dialogKey) + // Sync-originated outgoing messages: server already delivered them, + // no ACK will arrive → treat as .delivered immediately. + let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered) messageToDialog[messageId] = dialogKey @@ -112,7 +117,7 @@ final class MessageRepository: ObservableObject { messages[existingIndex].timestamp = timestamp messages[existingIndex].attachments = packet.attachments if fromMe, messages[existingIndex].deliveryStatus == .error { - messages[existingIndex].deliveryStatus = .waiting + messages[existingIndex].deliveryStatus = fromSync ? .delivered : .waiting } if incomingRead { messages[existingIndex].isRead = true @@ -127,7 +132,7 @@ final class MessageRepository: ObservableObject { toPublicKey: packet.toPublicKey, text: decryptedText, timestamp: timestamp, - deliveryStatus: fromMe ? .waiting : .delivered, + deliveryStatus: outgoingStatus, isRead: incomingRead || fromMe, attachments: packet.attachments ) @@ -199,12 +204,15 @@ final class MessageRepository: ObservableObject { } func deleteDialog(_ dialogKey: String) { - guard messagesByDialog.removeValue(forKey: dialogKey) != nil else { return } + guard let removedMessages = messagesByDialog.removeValue(forKey: dialogKey) else { return } activeDialogs.remove(dialogKey) typingDialogs.remove(dialogKey) typingResetTasks[dialogKey]?.cancel() typingResetTasks[dialogKey] = nil - messageToDialog = messageToDialog.filter { $0.value != dialogKey } + // O(k) where k = messages in this dialog (max 40), instead of O(n) for all messages + for message in removedMessages { + messageToDialog.removeValue(forKey: message.id) + } schedulePersist() } @@ -251,6 +259,14 @@ final class MessageRepository: ObservableObject { return (retryable: retryable, expired: expired) } + /// Delete a single message by ID and persist the change. + func deleteMessage(id: String) { + guard let dialogKey = messageToDialog.removeValue(forKey: id) else { return } + updateMessages(for: dialogKey) { messages in + messages.removeAll { $0.id == id } + } + } + func reset(clearPersisted: Bool = false) { persistTask?.cancel() persistTask = nil @@ -278,12 +294,16 @@ final class MessageRepository: ObservableObject { private func updateMessages(for dialogKey: String, mutate: (inout [ChatMessage]) -> Void) { var messages = messagesByDialog[dialogKey] ?? [] + let countBefore = messages.count mutate(&messages) - messages.sort { - if $0.timestamp != $1.timestamp { - return $0.timestamp < $1.timestamp + // Only sort when messages were added/removed; skip for in-place updates (delivery status, read) + if messages.count != countBefore { + messages.sort { + if $0.timestamp != $1.timestamp { + return $0.timestamp < $1.timestamp + } + return $0.id < $1.id } - return $0.id < $1.id } if messages.count > maxMessagesPerDialog { let overflow = messages.count - maxMessagesPerDialog diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketOnlineSubscribe.swift b/Rosetta/Core/Network/Protocol/Packets/PacketOnlineSubscribe.swift index af66adc..13fbffb 100644 --- a/Rosetta/Core/Network/Protocol/Packets/PacketOnlineSubscribe.swift +++ b/Rosetta/Core/Network/Protocol/Packets/PacketOnlineSubscribe.swift @@ -1,17 +1,24 @@ import Foundation /// OnlineSubscribe packet (0x04) — subscribe to a user's online status updates. -/// Client sends this for each dialog opponent to receive PacketOnlineState (0x05) updates. +/// Desktop parity: privateKey + int16(count) + publicKey[]. struct PacketOnlineSubscribe: Packet { static let packetId = 0x04 - var publicKey: String = "" + var privateKey: String = "" + var publicKeys: [String] = [] func write(to stream: Stream) { - stream.writeString(publicKey) + stream.writeString(privateKey) + stream.writeInt16(publicKeys.count) + for key in publicKeys { + stream.writeString(key) + } } mutating func read(from stream: Stream) { - publicKey = stream.readString() + privateKey = stream.readString() + let count = stream.readInt16() + publicKeys = (0.. Void)? var onDisconnected: ((Error?) -> Void)? @@ -88,6 +89,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD hasNotifiedConnected = true isConnected = true disconnectHandledForCurrentSocket = false + reconnectAttempt = 0 reconnectTask?.cancel() reconnectTask = nil onConnected?() @@ -140,9 +142,13 @@ 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) reconnectTask = Task { [weak self] in - Self.logger.info("Reconnecting in 5 seconds...") - try? await Task.sleep(nanoseconds: 5_000_000_000) + Self.logger.info("Reconnecting in \(String(format: "%.1f", delaySeconds))s (attempt \(attempt + 1))...") + try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000)) guard let self, !isManuallyClosed, !Task.isCancelled else { return } self.reconnectTask = nil self.connect() diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 533752d..11a093c 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -142,16 +142,29 @@ final class SessionManager { myPublicKey: currentPublicKey ) - // Optimistic UI update — show message immediately as "waiting" + // Desktop parity: if not connected, set initial status to ERROR (not WAITING). + // The packet is still queued and will be sent on reconnect; + // delivery ACK will update status to DELIVERED. + let isConnected = connState == .authenticated + let offlineAsSend = !isConnected + + // Optimistic UI update DialogRepository.shared.updateFromMessage( - packet, myPublicKey: currentPublicKey, decryptedText: text + packet, myPublicKey: currentPublicKey, decryptedText: text, fromSync: offlineAsSend ) MessageRepository.shared.upsertFromMessagePacket( packet, myPublicKey: currentPublicKey, - decryptedText: text + decryptedText: text, + fromSync: offlineAsSend ) + // Desktop parity: mark as ERROR when offline (fromSync sets .delivered, override to .error) + if offlineAsSend { + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) + DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error) + } + // Saved Messages: local-only, no server send if toPublicKey == currentPublicKey { MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) @@ -159,7 +172,7 @@ final class SessionManager { return } - // Send via WebSocket + // Send via WebSocket (queued if offline, sent directly if online) ProtocolManager.shared.sendPacket(packet) registerOutgoingRetry(for: packet) } @@ -364,6 +377,13 @@ final class SessionManager { self.pendingIncomingMessages.removeAll() self.isProcessingIncomingMessages = false + // Cancel stale retry timers from previous connection — + // they would fire and duplicate-send messages that are about to be retried fresh. + self.pendingOutgoingRetryTasks.values.forEach { $0.cancel() } + self.pendingOutgoingRetryTasks.removeAll() + self.pendingOutgoingPackets.removeAll() + self.pendingOutgoingAttempts.removeAll() + // Desktop parity: request message synchronization after authentication. self.requestSynchronize() self.retryWaitingOutgoingMessagesAfterReconnect() @@ -375,14 +395,9 @@ final class SessionManager { // Send push token to server for push notifications (Android parity). self.sendPushTokenToServer() - // Desktop parity: proactively fetch user info (names, online status) - // for all dialogs. Desktop does this per-component via useUserInformation; - // we do it in bulk after handshake with staggered sends. - Task { @MainActor [weak self] in - // Small delay so sync packets go first - try? await Task.sleep(for: .milliseconds(500)) - await self?.refreshOnlineStatusForAllDialogs() - } + // Desktop parity: user info refresh is deferred until sync completes. + // Desktop fetches lazily per-component (useUserInformation); we do it + // in bulk after sync ends to avoid flooding the server during sync streaming. } } @@ -422,7 +437,13 @@ final class SessionManager { Self.logger.debug("SYNC stopped after stalled batches") self.syncBatchInProgress = false self.flushPendingReadReceipts() + DialogRepository.shared.reconcileDeliveryStatuses() self.stalledSyncBatchCount = 0 + // Refresh user info now that sync is done (desktop parity: lazy per-component). + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + await self?.refreshOnlineStatusForAllDialogs() + } return } @@ -432,8 +453,14 @@ final class SessionManager { case .notNeeded: self.syncBatchInProgress = false self.flushPendingReadReceipts() + DialogRepository.shared.reconcileDeliveryStatuses() self.stalledSyncBatchCount = 0 Self.logger.debug("SYNC NOT_NEEDED") + // Refresh user info now that sync is done. + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + await self?.refreshOnlineStatusForAllDialogs() + } } } } @@ -460,6 +487,7 @@ final class SessionManager { } } isProcessingIncomingMessages = false + signalQueueDrained() } private func processIncomingMessage(_ packet: PacketMessage) async { @@ -492,12 +520,13 @@ final class SessionManager { } DialogRepository.shared.updateFromMessage( - packet, myPublicKey: myKey, decryptedText: text + packet, myPublicKey: myKey, decryptedText: text, fromSync: syncBatchInProgress ) MessageRepository.shared.upsertFromMessagePacket( packet, myPublicKey: myKey, - decryptedText: text + decryptedText: text, + fromSync: syncBatchInProgress ) let dialog = DialogRepository.shared.dialogs[opponentKey] @@ -513,8 +542,10 @@ final class SessionManager { // that triggered server RST disconnects. // Desktop parity: only mark as read if user is NOT idle AND app is in foreground. + // Desktop also skips system accounts and blocked users. let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey) - let shouldMarkRead = dialogIsActive && !isUserIdle && isAppInForeground + let isSystem = SystemAccounts.isSystemAccount(opponentKey) + let shouldMarkRead = dialogIsActive && !isUserIdle && isAppInForeground && !isSystem if shouldMarkRead { DialogRepository.shared.markAsRead(opponentKey: opponentKey) @@ -541,18 +572,41 @@ final class SessionManager { } } - private func waitForInboundQueueToDrain(timeoutMs: UInt64 = 5_000) async -> Bool { - let started = DispatchTime.now().uptimeNanoseconds - let timeoutNs = timeoutMs * 1_000_000 + /// Continuations waiting for the inbound queue to drain. + private var drainContinuations: [CheckedContinuation] = [] - while isProcessingIncomingMessages || !pendingIncomingMessages.isEmpty { - if DispatchTime.now().uptimeNanoseconds - started >= timeoutNs { - return false - } - try? await Task.sleep(for: .milliseconds(20)) + /// Signal all waiting continuations that the queue has drained. + private func signalQueueDrained() { + let waiting = drainContinuations + drainContinuations.removeAll() + for continuation in waiting { + continuation.resume() + } + } + + private func waitForInboundQueueToDrain(timeoutMs: UInt64 = 5_000) async -> Bool { + // Fast path: already drained + if !isProcessingIncomingMessages && pendingIncomingMessages.isEmpty { + return true } - return true + // Event-based: wait for signal or timeout + let drained = await withTaskGroup(of: Bool.self) { group in + group.addTask { @MainActor in + await withCheckedContinuation { continuation in + self.drainContinuations.append(continuation) + } + return true + } + group.addTask { + try? await Task.sleep(for: .milliseconds(timeoutMs)) + return false + } + let result = await group.next() ?? false + group.cancelAll() + return result + } + return drained } private var syncCursorKey: String { @@ -566,11 +620,13 @@ final class SessionManager { func subscribeToOnlineStatus(publicKey: String) { guard !publicKey.isEmpty, ProtocolManager.shared.connectionState == .authenticated, - !onlineSubscribedKeys.contains(publicKey) + !onlineSubscribedKeys.contains(publicKey), + let hash = privateKeyHash else { return } onlineSubscribedKeys.insert(publicKey) var packet = PacketOnlineSubscribe() - packet.publicKey = publicKey + packet.privateKey = hash + packet.publicKeys = [publicKey] ProtocolManager.shared.sendPacket(packet) } @@ -719,10 +775,9 @@ final class SessionManager { ProtocolManager.shared.sendPacket(searchPacket) } - /// After handshake, request user info for all existing dialog opponents. - /// Desktop parity: useUserInformation sends PacketSearch(publicKey) for every user - /// not in cache. We do the same in bulk — empty-title dialogs first (names missing), - /// then the rest (online status refresh). + /// Request user info for all existing dialog opponents after sync completes. + /// Desktop parity: useUserInformation sends PacketSearch(publicKey) lazily per-component. + /// We do it in bulk after sync — with generous staggering to avoid server rate-limiting. private func refreshOnlineStatusForAllDialogs() async { let dialogs = DialogRepository.shared.dialogs let ownKey = currentPublicKey @@ -739,24 +794,24 @@ final class SessionManager { } } - // Priority: fetch missing names first + // Priority: fetch missing names first (generous 200ms stagger) var count = 0 for key in missingName { guard ProtocolManager.shared.connectionState == .authenticated else { break } requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash) count += 1 if count > 1 { - try? await Task.sleep(for: .milliseconds(50)) + try? await Task.sleep(for: .milliseconds(200)) } } - // Then refresh online status for dialogs that already have names + // Then refresh online status for dialogs that already have names (300ms stagger) for key in hasName { guard ProtocolManager.shared.connectionState == .authenticated else { break } requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash) count += 1 if count > 1 { - try? await Task.sleep(for: .milliseconds(100)) + try? await Task.sleep(for: .milliseconds(300)) } } Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(hasName.count) online status = \(count) total") @@ -832,13 +887,13 @@ final class SessionManager { ) for expired in result.expired { - if MessageRepository.shared.isLatestMessage(expired.messageId, in: expired.dialogKey) { - DialogRepository.shared.updateDeliveryStatus( - messageId: expired.messageId, - opponentKey: expired.dialogKey, - status: .error - ) - } + // Update dialog status to error — downgrade guards in + // DialogRepository.updateDeliveryStatus prevent regressions. + DialogRepository.shared.updateDeliveryStatus( + messageId: expired.messageId, + opponentKey: expired.dialogKey, + status: .error + ) resolveOutgoingRetry(messageId: expired.messageId) } @@ -863,8 +918,17 @@ final class SessionManager { registerOutgoingRetry(for: packet) } catch { Self.logger.error("Failed to retry waiting message \(message.id): \(error.localizedDescription)") + // Mark message as error so it doesn't stay stuck at .waiting forever. + MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .error) + let opponentKey = message.toPublicKey + DialogRepository.shared.updateDeliveryStatus( + messageId: message.id, + opponentKey: opponentKey, + status: .error + ) } } + } private func flushPendingReadReceipts() { @@ -916,13 +980,29 @@ final class SessionManager { guard let packet = self.pendingOutgoingPackets[messageId] else { return } let attempts = self.pendingOutgoingAttempts[messageId] ?? 0 + + // Check if message exceeded delivery timeout (80s) — mark as error. + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + let ageMs = nowMs - packet.timestamp + if ageMs >= self.maxOutgoingWaitingLifetimeMs { + Self.logger.warning("Message \(messageId) expired after \(ageMs)ms — marking as error") + self.markOutgoingAsError(messageId: messageId, packet: packet) + return + } + guard attempts < self.maxOutgoingRetryAttempts else { - self.resolveOutgoingRetry(messageId: messageId) + // Max retries exhausted for this connection session — mark as error. + // The user sees the error icon immediately instead of a stuck clock. + Self.logger.warning("Message \(messageId) exhausted \(attempts) retries — marking as error") + self.markOutgoingAsError(messageId: messageId, packet: packet) return } guard ProtocolManager.shared.connectionState == .authenticated else { - self.scheduleOutgoingRetry(messageId: messageId) + // Not authenticated — don't endlessly loop. The message will be + // retried via retryWaitingOutgoingMessagesAfterReconnect() on next handshake. + Self.logger.debug("Message \(messageId) retry deferred — not authenticated") + self.resolveOutgoingRetry(messageId: messageId) return } @@ -934,6 +1014,19 @@ final class SessionManager { } } + /// Mark an outgoing message as error in both repositories and clean up retry state. + private func markOutgoingAsError(messageId: String, packet: PacketMessage) { + let fromMe = packet.fromPublicKey == currentPublicKey + let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) + DialogRepository.shared.updateDeliveryStatus( + messageId: messageId, + opponentKey: opponentKey, + status: .error + ) + resolveOutgoingRetry(messageId: messageId) + } + private func resolveOutgoingRetry(messageId: String) { pendingOutgoingRetryTasks[messageId]?.cancel() pendingOutgoingRetryTasks.removeValue(forKey: messageId) diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index d6bc233..8f44e25 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -8,7 +8,7 @@ enum RosettaTab: CaseIterable, Sendable { case calls case settings - static let interactionOrder: [RosettaTab] = [.chats, .calls, .settings] + static let interactionOrder: [RosettaTab] = [.calls, .chats, .settings] var label: String { switch self { @@ -189,10 +189,11 @@ struct RosettaTabBar: View { .offset(x: xOffset) } else { // iOS < 26 — thin frosted glass + // +2 centers the narrowed (width-4) pill within the tab Capsule().fill(.thinMaterial) .frame(width: width - 4) .padding(.vertical, 4) - .offset(x: xOffset) + .offset(x: xOffset + 2) } } .animation( diff --git a/Rosetta/DesignSystem/Components/SwipeBackModifier.swift b/Rosetta/DesignSystem/Components/SwipeBackModifier.swift index 000a4f6..3265a9c 100644 --- a/Rosetta/DesignSystem/Components/SwipeBackModifier.swift +++ b/Rosetta/DesignSystem/Components/SwipeBackModifier.swift @@ -1,7 +1,12 @@ import SwiftUI +import UIKit -/// Re-enables the iOS interactive swipe-back gesture when -/// `.navigationBarBackButtonHidden(true)` is used in SwiftUI. +/// Enables full-screen interactive swipe-back gesture (not just the edge) +/// when `.navigationBarBackButtonHidden(true)` is used in SwiftUI. +/// +/// How it works: finds the UINavigationController's `interactivePopGestureRecognizer`, +/// extracts its internal target/action, and adds a new full-width UIPanGestureRecognizer +/// with the same target/action to the navigation controller's view. struct SwipeBackGestureEnabler: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { SwipeBackController() @@ -9,19 +14,51 @@ struct SwipeBackGestureEnabler: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} - private final class SwipeBackController: UIViewController { + private final class SwipeBackController: UIViewController, UIGestureRecognizerDelegate { + private var addedGesture = false + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if let nav = navigationController { - nav.interactivePopGestureRecognizer?.isEnabled = true - nav.interactivePopGestureRecognizer?.delegate = nil - } + guard !addedGesture else { return } + addedGesture = true + + guard let nav = navigationController, + let edgeGesture = nav.interactivePopGestureRecognizer, + let targets = edgeGesture.value(forKey: "targets") as? NSArray, + targets.count > 0 + else { return } + + // Re-enable system gesture (in case it was disabled) + edgeGesture.isEnabled = true + + // Create a full-width pan gesture with the same internal target + let fullWidthGesture = UIPanGestureRecognizer() + fullWidthGesture.setValue(targets, forKey: "targets") + fullWidthGesture.delegate = self + nav.view.addGestureRecognizer(fullWidthGesture) + } + + // MARK: - UIGestureRecognizerDelegate + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false } + let velocity = pan.velocity(in: pan.view) + // Only allow right-swipe (positive X) and primarily horizontal + return velocity.x > 0 && abs(velocity.x) > abs(velocity.y) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + false } } } extension View { - /// Restores the swipe-back gesture after hiding the default back button. + /// Restores the swipe-back gesture from anywhere on screen + /// after hiding the default back button. func enableSwipeBack() -> some View { background(SwipeBackGestureEnabler()) } diff --git a/Rosetta/Features/Auth/AuthCoordinator.swift b/Rosetta/Features/Auth/AuthCoordinator.swift index bdb1f15..9dba785 100644 --- a/Rosetta/Features/Auth/AuthCoordinator.swift +++ b/Rosetta/Features/Auth/AuthCoordinator.swift @@ -24,7 +24,10 @@ struct AuthCoordinator: View { @State private var fadeOverlay: Bool = false private var canSwipeBack: Bool { - currentScreen != .welcome + if currentScreen == .welcome { + return onBackToUnlock != nil + } + return true } var body: some View { @@ -203,9 +206,15 @@ private extension AuthCoordinator { swipeOffset = screenWidth } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - swipeOffset = 0 - navigationDirection = .backward - currentScreen = backDestination + if currentScreen == .welcome { + // Don't reset swipeOffset — keep screen offscreen + // while parent performs its own transition to unlock. + onBackToUnlock?() + } else { + swipeOffset = 0 + navigationDirection = .backward + currentScreen = backDestination + } } } else { withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { diff --git a/Rosetta/Features/Auth/SetPasswordView.swift b/Rosetta/Features/Auth/SetPasswordView.swift index 8074df1..2e54874 100644 --- a/Rosetta/Features/Auth/SetPasswordView.swift +++ b/Rosetta/Features/Auth/SetPasswordView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit struct SetPasswordView: View { let seedPhrase: [String] @@ -8,11 +9,9 @@ struct SetPasswordView: View { @State private var password = "" @State private var confirmPassword = "" - @State private var showPassword = false - @State private var showConfirmPassword = false @State private var isCreating = false @State private var errorMessage: String? - @FocusState private var focusedField: Field? + @State private var focusedField: Field? fileprivate enum Field { case password, confirm @@ -115,93 +114,39 @@ private extension SetPasswordView { private extension SetPasswordView { var passwordField: some View { - secureInputField( - placeholder: "Password", - text: $password, - isSecure: !showPassword, - toggleAction: { showPassword.toggle() }, - isRevealed: showPassword, - field: .password - ) - .accessibilityLabel("Password input") + secureInputField(placeholder: "Password", text: $password, field: .password) + .accessibilityLabel("Password input") } var confirmField: some View { - secureInputField( - placeholder: "Confirm Password", - text: $confirmPassword, - isSecure: !showConfirmPassword, - toggleAction: { showConfirmPassword.toggle() }, - isRevealed: showConfirmPassword, - field: .confirm - ) - .accessibilityLabel("Confirm password input") + secureInputField(placeholder: "Confirm Password", text: $confirmPassword, field: .confirm) + .accessibilityLabel("Confirm password input") } func secureInputField( placeholder: String, text: Binding, - isSecure: Bool, - toggleAction: @escaping () -> Void, - isRevealed: Bool, field: Field ) -> some View { - HStack(spacing: 12) { - ZStack(alignment: .leading) { - // Placeholder (shown when text is empty) - if text.wrappedValue.isEmpty { - Text(placeholder) - .font(.system(size: 16)) - .foregroundStyle(RosettaColors.tertiaryText) - } - // Actual input — always the same type trick: use overlay to keep focus - if isSecure { - SecureField("", text: text) - .font(.system(size: 16)) - .foregroundStyle(.white) - .focused($focusedField, equals: field) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } else { - TextField("", text: text) - .font(.system(size: 16)) - .foregroundStyle(.white) - .focused($focusedField, equals: field) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } - } - .frame(height: 22) - - Image(systemName: isRevealed ? "eye.slash" : "eye") - .font(.system(size: 16)) - .foregroundStyle(Color.white.opacity(0.5)) - .frame(width: 30, height: 30) - .contentShape(Rectangle()) - .onTapGesture { - // Save and restore focus to prevent drop - let currentFocus = focusedField - toggleAction() - DispatchQueue.main.async { - focusedField = currentFocus - } - } - .accessibilityLabel(isRevealed ? "Hide password" : "Show password") - } - .padding(.horizontal, 16) - .padding(.vertical, 10) + SecureToggleField( + text: text, + placeholder: placeholder, + field: field, + focusedField: $focusedField + ) + .frame(height: 50) .background { - let isFocused = focusedField == field RoundedRectangle(cornerRadius: 12) .fill(RosettaColors.cardFill) .overlay { RoundedRectangle(cornerRadius: 12) .stroke( - isFocused ? RosettaColors.primaryBlue : RosettaColors.subtleBorder, - lineWidth: isFocused ? 2 : 1 + focusedField == field + ? RosettaColors.primaryBlue + : RosettaColors.subtleBorder, + lineWidth: focusedField == field ? 2 : 1 ) } - .animation(.easeInOut(duration: 0.2), value: isFocused) } } } @@ -276,6 +221,163 @@ private extension SetPasswordView { } } +// MARK: - Fixed Height Text Field + +/// UITextField subclass with locked intrinsicContentSize and custom rect overrides. +/// Handles internal padding (16pt left, 46pt right for eye button) and rightView positioning. +/// Prevents any layout propagation to SwiftUI when isSecureTextEntry toggles. +private final class FixedHeightTextField: UITextField { + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: 50) + } + + override func textRect(forBounds bounds: CGRect) -> CGRect { + bounds.inset(by: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 46)) + } + + override func editingRect(forBounds bounds: CGRect) -> CGRect { + textRect(forBounds: bounds) + } + + override func rightViewRect(forBounds bounds: CGRect) -> CGRect { + CGRect(x: bounds.width - 16 - 30, y: (bounds.height - 30) / 2, width: 30, height: 30) + } + + override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + textRect(forBounds: bounds) + } +} + +// MARK: - Secure Toggle Field (UIKit) + +/// Wraps UIKit's UITextField with a built-in eye toggle button as rightView. +/// The toggle happens entirely in UIKit — no SwiftUI @State changes, no body re-evaluation, +/// no ScrollView layout pass. This eliminates the "input expanding" bug. +private struct SecureToggleField: UIViewRepresentable { + @Binding var text: String + var placeholder: String + var field: SetPasswordView.Field + @Binding var focusedField: SetPasswordView.Field? + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + func makeUIView(context: Context) -> FixedHeightTextField { + let tf = FixedHeightTextField() + context.coordinator.textField = tf + + tf.isSecureTextEntry = true + tf.font = .systemFont(ofSize: 16) + tf.textColor = .white + tf.tintColor = UIColor(RosettaColors.primaryBlue) + tf.autocapitalizationType = .none + tf.autocorrectionType = .no + tf.spellCheckingType = .no + tf.textContentType = .init(rawValue: "") + tf.inputAccessoryView = UIView(frame: .zero) + tf.backgroundColor = .clear + tf.setContentHuggingPriority(.required, for: .vertical) + tf.setContentCompressionResistancePriority(.required, for: .vertical) + + tf.delegate = context.coordinator + tf.addTarget( + context.coordinator, + action: #selector(Coordinator.textChanged(_:)), + for: .editingChanged + ) + tf.attributedPlaceholder = NSAttributedString( + string: placeholder, + attributes: [.foregroundColor: UIColor(RosettaColors.tertiaryText)] + ) + + // Eye toggle button — entirely UIKit, no SwiftUI state involved + let config = UIImage.SymbolConfiguration(pointSize: 16) + let eyeButton = UIButton(type: .system) + eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal) + eyeButton.tintColor = UIColor.white.withAlphaComponent(0.5) + eyeButton.addTarget( + context.coordinator, + action: #selector(Coordinator.toggleSecure), + for: .touchUpInside + ) + eyeButton.frame = CGRect(x: 0, y: 0, width: 30, height: 30) + eyeButton.accessibilityLabel = "Show password" + tf.rightView = eyeButton + tf.rightViewMode = .always + + return tf + } + + func updateUIView(_ tf: FixedHeightTextField, context: Context) { + context.coordinator.parent = self + + // Sync text (only if different to avoid cursor jump) + if tf.text != text { tf.text = text } + + // Sync focus (deferred to avoid re-entrant layout) + let wantsFocus = focusedField == field + if wantsFocus && !tf.isFirstResponder { + DispatchQueue.main.async { tf.becomeFirstResponder() } + } else if !wantsFocus && tf.isFirstResponder { + DispatchQueue.main.async { tf.resignFirstResponder() } + } + } + + /// Lock the size SwiftUI sees — prevents intrinsicContentSize changes + /// from propagating as layout invalidations. + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: FixedHeightTextField, + context: Context + ) -> CGSize? { + CGSize(width: proposal.width ?? 200, height: 50) + } + + final class Coordinator: NSObject, UITextFieldDelegate { + var parent: SecureToggleField + weak var textField: FixedHeightTextField? + + init(parent: SecureToggleField) { self.parent = parent } + + @objc func textChanged(_ tf: UITextField) { + parent.text = tf.text ?? "" + } + + /// Toggle isSecureTextEntry entirely in UIKit — no SwiftUI @State change. + @objc func toggleSecure() { + guard let tf = textField else { return } + UIView.performWithoutAnimation { + let existingText = tf.text + tf.isSecureTextEntry.toggle() + // iOS clears text when toggling isSecureTextEntry — restore it + tf.text = "" + tf.text = existingText + + // Update eye icon + let imageName = tf.isSecureTextEntry ? "eye" : "eye.slash" + let config = UIImage.SymbolConfiguration(pointSize: 16) + let button = tf.rightView as? UIButton + button?.setImage( + UIImage(systemName: imageName, withConfiguration: config), + for: .normal + ) + button?.accessibilityLabel = tf.isSecureTextEntry + ? "Show password" + : "Hide password" + } + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + parent.focusedField = parent.field + } + + func textFieldDidEndEditing(_ textField: UITextField) { + if parent.focusedField == parent.field { + parent.focusedField = nil + } + } + } +} + #Preview { SetPasswordView( seedPhrase: ["abandon", "ability", "able", "about", "above", "absent", diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index eae878e..50a6d74 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -5,11 +5,18 @@ struct ChatDetailView: View { var onPresentedChange: ((Bool) -> Void)? = nil @Environment(\.dismiss) private var dismiss - @ObservedObject private var messageRepository = MessageRepository.shared + @StateObject private var viewModel: ChatDetailViewModel + + init(route: ChatRoute, onPresentedChange: ((Bool) -> Void)? = nil) { + self.route = route + self.onPresentedChange = onPresentedChange + _viewModel = StateObject(wrappedValue: ChatDetailViewModel(dialogKey: route.publicKey)) + } @State private var messageText = "" @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 private var currentPublicKey: String { @@ -21,11 +28,11 @@ struct ChatDetailView: View { } private var messages: [ChatMessage] { - messageRepository.messages(for: route.publicKey) + viewModel.messages } private var isTyping: Bool { - messageRepository.isTyping(dialogKey: route.publicKey) + viewModel.isTyping } private var titleText: String { @@ -45,7 +52,6 @@ struct ChatDetailView: View { private var subtitleText: String { if route.isSavedMessages { return "" } - if ProtocolManager.shared.connectionState != .authenticated { return "connecting..." } if isTyping { return "typing..." } if let dialog, dialog.isOnline { return "online" } return "offline" @@ -74,9 +80,7 @@ struct ChatDetailView: View { private var sendButtonWidth: CGFloat { 38 } private var sendButtonHeight: CGFloat { 36 } - private var composerTrailingPadding: CGFloat { - isInputFocused ? 16 : 28 - } + private var composerTrailingPadding: CGFloat { 16 } private var composerAnimation: Animation { .spring(response: 0.28, dampingFraction: 0.9) @@ -125,7 +129,7 @@ struct ChatDetailView: View { } .onDisappear { isViewActive = false - messageRepository.setDialogActive(route.publicKey, isActive: false) + MessageRepository.shared.setDialogActive(route.publicKey, isActive: false) } } } @@ -319,20 +323,25 @@ private extension ChatDetailView { } } + /// Cached tiled pattern color — computed once, reused across renders + private static let cachedTiledColor: Color? = { + guard let uiImage = UIImage(named: "ChatBackground"), + let cgImage = uiImage.cgImage else { return nil } + let tileWidth: CGFloat = 200 + let scaleFactor = uiImage.size.width / tileWidth + let scaledImage = UIImage( + cgImage: cgImage, + scale: uiImage.scale * scaleFactor, + orientation: .up + ) + return Color(uiColor: UIColor(patternImage: scaledImage)) + }() + /// Tiled chat background with properly scaled tiles (200pt wide) private var tiledChatBackground: some View { Group { - if let uiImage = UIImage(named: "ChatBackground"), - let cgImage = uiImage.cgImage { - let tileWidth: CGFloat = 200 - let scaleFactor = uiImage.size.width / tileWidth - let scaledImage = UIImage( - cgImage: cgImage, - scale: uiImage.scale * scaleFactor, - orientation: .up - ) - Color(uiColor: UIColor(patternImage: scaledImage)) - .opacity(0.18) + if let color = Self.cachedTiledColor { + color.opacity(0.18) } else { Color.clear } @@ -389,7 +398,8 @@ private extension ChatDetailView { ScrollViewReader { proxy in let scroll = ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: 0) { - ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in + ForEach(messages.indices, id: \.self) { index in + let message = messages[index] messageRow( message, maxBubbleWidth: maxBubbleWidth, @@ -406,7 +416,7 @@ private extension ChatDetailView { .padding(.top, messagesTopInset) .padding(.bottom, 10) } - .scrollDismissesKeyboard(.interactively) + .scrollDismissesKeyboard(.immediately) .onTapGesture { isInputFocused = false } .onAppear { DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) } @@ -420,16 +430,19 @@ private extension ChatDetailView { } .onChange(of: messages.count) { _, _ in scrollToBottom(proxy: proxy, animated: true) - if isViewActive { - markDialogAsRead() - } + // Read receipts are NOT sent here — SessionManager already sends + // 0x07 for each incoming message when dialog is active (shouldMarkRead). + // Sending again from .onChange caused duplicate packets (2-3× more than + // desktop), which may contribute to server RST disconnects. + // The initial read is handled in .task with 600ms delay. } .onChange(of: isInputFocused) { _, focused in 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: 80_000_000) + try? await Task.sleep(nanoseconds: 300_000_000) scrollToBottom(proxy: proxy, animated: true) } } @@ -469,7 +482,13 @@ private extension ChatDetailView { : RosettaColors.Adaptive.textSecondary.opacity(0.6) ) - if outgoing { deliveryIndicator(message.deliveryStatus) } + if outgoing { + if message.deliveryStatus == .error { + errorMenu(for: message) + } else { + deliveryIndicator(message.deliveryStatus) + } + } } .padding(.trailing, 11) .padding(.bottom, 5) @@ -545,50 +564,11 @@ private extension ChatDetailView { .frame(height: 36, alignment: .center) .overlay(alignment: .trailing) { Button(action: sendCurrentMessage) { - ZStack { - TelegramVectorIcon( - pathData: TelegramIconPath.sendPlane, - viewBox: CGSize(width: 22, height: 19), - color: .white - ) - .blendMode(.difference) - - TelegramVectorIcon( - pathData: TelegramIconPath.sendPlane, - viewBox: CGSize(width: 22, height: 19), - color: .white - ) - .blendMode(.saturation) - - TelegramVectorIcon( - pathData: TelegramIconPath.sendPlane, - viewBox: CGSize(width: 22, height: 19), - color: .white - ) - .blendMode(.overlay) - - TelegramVectorIcon( - pathData: TelegramIconPath.sendPlane, - viewBox: CGSize(width: 22, height: 19), - color: .black - ) - .blendMode(.overlay) - - TelegramVectorIcon( - pathData: TelegramIconPath.sendPlane, - viewBox: CGSize(width: 22, height: 19), - color: .white - ) - .blendMode(.overlay) - - TelegramVectorIcon( - pathData: TelegramIconPath.sendPlane, - viewBox: CGSize(width: 22, height: 19), - color: .black - ) - .blendMode(.overlay) - } - .compositingGroup() + TelegramVectorIcon( + pathData: TelegramIconPath.sendPlane, + viewBox: CGSize(width: 22, height: 19), + color: .white + ) .opacity(0.42 + (0.58 * sendButtonProgress)) .scaleEffect(0.72 + (0.28 * sendButtonProgress)) .frame(width: 22, height: 19) @@ -644,10 +624,9 @@ private extension ChatDetailView { .padding(.leading, 16) .padding(.trailing, composerTrailingPadding) .padding(.top, 4) - .padding(.bottom, isInputFocused ? 8 : 0) + .padding(.bottom, 4) .animation(composerAnimation, value: canSend) .animation(composerAnimation, value: shouldShowSendButton) - .animation(composerAnimation, value: isInputFocused) } .background { if #available(iOS 26, *) { @@ -796,6 +775,41 @@ private extension ChatDetailView { } } + @ViewBuilder + func errorMenu(for message: ChatMessage) -> some View { + Menu { + Button { + retryMessage(message) + } label: { + Label("Retry", systemImage: "arrow.clockwise") + } + Button(role: .destructive) { + removeMessage(message) + } label: { + Label("Remove", systemImage: "trash") + } + } label: { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(RosettaColors.error) + } + } + + func retryMessage(_ message: ChatMessage) { + let text = message.text + let toKey = message.toPublicKey + MessageRepository.shared.deleteMessage(id: message.id) + DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: toKey) + Task { + try? await SessionManager.shared.sendMessage(text: text, toPublicKey: toKey) + } + } + + func removeMessage(_ message: ChatMessage) { + MessageRepository.shared.deleteMessage(id: message.id) + DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey) + } + func messageTime(_ timestamp: Int64) -> String { Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000)) } @@ -829,12 +843,12 @@ private extension ChatDetailView { myPublicKey: currentPublicKey ) } - messageRepository.setDialogActive(route.publicKey, isActive: true) + MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) } func markDialogAsRead() { DialogRepository.shared.markAsRead(opponentKey: route.publicKey) - messageRepository.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey) + MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey) SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey) } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift new file mode 100644 index 0000000..63af19a --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift @@ -0,0 +1,65 @@ +import Foundation +import Combine + +/// Per-dialog observation isolation for ChatDetailView. +/// +/// Instead of `@ObservedObject messageRepository` (which re-renders on ANY dialog change), +/// this ViewModel subscribes only to the specific dialog's messages via Combine pipeline +/// with `removeDuplicates()`. The view re-renders ONLY when its own dialog's data changes. +@MainActor +final class ChatDetailViewModel: ObservableObject { + let dialogKey: String + + @Published private(set) var messages: [ChatMessage] = [] + @Published private(set) var isTyping: Bool = false + + private var cancellables = Set() + + init(dialogKey: String) { + self.dialogKey = dialogKey + + let repo = MessageRepository.shared + + // Seed with current values + messages = repo.messages(for: dialogKey) + isTyping = repo.isTyping(dialogKey: dialogKey) + + // Subscribe to messagesByDialog changes, filtered to our dialog only. + // Broken into steps to help the Swift type-checker. + let key = dialogKey + let messagesPublisher = repo.$messagesByDialog + .map { (dict: [String: [ChatMessage]]) -> [ChatMessage] in + dict[key] ?? [] + } + .removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in + guard lhs.count == rhs.count else { return false } + for i in lhs.indices { + if lhs[i].id != rhs[i].id || lhs[i].deliveryStatus != rhs[i].deliveryStatus { + return false + } + } + return true + } + .receive(on: DispatchQueue.main) + + messagesPublisher + .sink { [weak self] newMessages in + self?.messages = newMessages + } + .store(in: &cancellables) + + // Subscribe to typing state changes, filtered to our dialog + let typingPublisher = repo.$typingDialogs + .map { (dialogs: Set) -> Bool in + dialogs.contains(key) + } + .removeDuplicates() + .receive(on: DispatchQueue.main) + + typingPublisher + .sink { [weak self] typing in + self?.isTyping = typing + } + .store(in: &cancellables) + } +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index bcd79cb..929b6d7 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -140,6 +140,11 @@ private extension ChatListSearchContent { ForEach(viewModel.recentSearches, id: \.publicKey) { recent in recentRow(recent) + if recent.publicKey != viewModel.recentSearches.last?.publicKey { + Divider() + .padding(.leading, 68) + .foregroundStyle(RosettaColors.Adaptive.divider) + } } } @@ -179,7 +184,7 @@ private extension ChatListSearchContent { let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey) return Button { - onSelectRecent(user.username.isEmpty ? user.publicKey : user.username) + onOpenDialog(ChatRoute(recent: user)) } label: { HStack(spacing: 10) { AvatarView( @@ -207,6 +212,7 @@ private extension ChatListSearchContent { } .padding(.horizontal, 16) .padding(.vertical, 5) + .contentShape(Rectangle()) } .buttonStyle(.plain) } @@ -263,6 +269,7 @@ private extension ChatListSearchContent { } .padding(.horizontal, 16) .padding(.vertical, 12) + .contentShape(Rectangle()) } .buttonStyle(.plain) } diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 255f87c..8cde306 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -32,16 +32,13 @@ struct ChatListView: View { @State private var hasPinnedChats = false @FocusState private var isSearchFocused: Bool - @MainActor static var _bodyCount = 0 var body: some View { - let _ = Self._bodyCount += 1 - let _ = print("🟡 ChatListView.body #\(Self._bodyCount)") NavigationStack(path: $navigationState.path) { VStack(spacing: 0) { // Custom search bar customSearchBar .padding(.horizontal, 16) - .padding(.top, 6) + .padding(.top, 12) .padding(.bottom, 8) .background( (hasPinnedChats && !isSearchActive @@ -290,7 +287,7 @@ private extension ChatListView { Text("Edit") .font(.system(size: 17, weight: .medium)) .foregroundStyle(RosettaColors.Adaptive.text) - .frame(height: 44) + .frame(height: 40) .padding(.horizontal, 10) } .buttonStyle(.plain) @@ -312,7 +309,7 @@ private extension ChatListView { .resizable() .scaledToFit() .frame(width: 22, height: 22) - .frame(width: 44, height: 44) + .frame(width: 40, height: 40) } .buttonStyle(.plain) .accessibilityLabel("Add chat") @@ -323,7 +320,7 @@ private extension ChatListView { .resizable() .scaledToFit() .frame(width: 20, height: 20) - .frame(width: 44, height: 44) + .frame(width: 40, height: 40) } .buttonStyle(.plain) .accessibilityLabel("New chat") @@ -377,10 +374,7 @@ private struct ToolbarTitleView: View { /// Changes to these `@Observable` singletons only re-render this small view, /// not the parent ChatListView / NavigationStack. private struct ToolbarStoriesAvatar: View { - @MainActor static var _bodyCount = 0 var body: some View { - let _ = Self._bodyCount += 1 - let _ = print("🟣 ToolbarStoriesAvatar.body #\(Self._bodyCount)") let pk = AccountManager.shared.currentAccount?.publicKey ?? "" let initials = RosettaColors.initials( name: SessionManager.shared.displayName, publicKey: pk @@ -396,10 +390,7 @@ private struct ToolbarStoriesAvatar: View { /// During handshake, `connectionState` changes 4+ times rapidly — this view /// absorbs those re-renders instead of cascading them to the NavigationStack. private struct DeviceVerificationBannersContainer: View { - @MainActor static var _bodyCount = 0 var body: some View { - let _ = Self._bodyCount += 1 - let _ = print("⚪ DeviceVerificationBanners.body #\(Self._bodyCount)") let proto = ProtocolManager.shared if proto.connectionState == .deviceVerificationRequired { @@ -424,11 +415,7 @@ private struct ChatListDialogContent: View { @ObservedObject var viewModel: ChatListViewModel @ObservedObject var navigationState: ChatListNavigationState var onPinnedStateChange: (Bool) -> Void = { _ in } - @MainActor static var _bodyCount = 0 - var body: some View { - let _ = Self._bodyCount += 1 - let _ = print("🔶 ChatListDialogContent.body #\(Self._bodyCount)") let hasPinned = !viewModel.pinnedDialogs.isEmpty if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading { ChatEmptyStateView(searchText: "") diff --git a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift index df46b7e..6c2afe0 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -46,8 +46,8 @@ final class ChatListViewModel: ObservableObject { var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } } var totalUnreadCount: Int { - DialogRepository.shared.sortedDialogs - .filter { !$0.isMuted } + DialogRepository.shared.dialogs.values + .lazy.filter { !$0.isMuted } .reduce(0) { $0 + $1.unreadCount } } @@ -172,7 +172,7 @@ final class ChatListViewModel: ObservableObject { var packet = PacketSearch() packet.privateKey = hash - packet.search = query + packet.search = query.lowercased() Self.logger.debug("📤 Sending search packet for '\(query)'") ProtocolManager.shared.sendPacket(packet) } diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index e73cbc7..0b8c3f4 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Combine // MARK: - ChatRowView @@ -20,6 +21,11 @@ import SwiftUI struct ChatRowView: View { let dialog: Dialog + /// Desktop parity: recheck delivery timeout every 40s so clock → error + /// transitions happen automatically without user scrolling. + @State private var now = Date() + private let recheckTimer = Timer.publish(every: 40, on: .main, in: .common).autoconnect() + var displayTitle: String { if dialog.isSavedMessages { return "Saved Messages" } if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle } @@ -38,6 +44,7 @@ struct ChatRowView: View { .padding(.trailing, 16) .frame(height: 78) .contentShape(Rectangle()) + .onReceive(recheckTimer) { now = $0 } } } @@ -215,7 +222,7 @@ private extension ChatRowView { private var isWithinWaitingWindow: Bool { guard dialog.lastMessageTimestamp > 0 else { return true } let sentDate = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000) - return Date().timeIntervalSince(sentDate) < Self.maxWaitingSeconds + return now.timeIntervalSince(sentDate) < Self.maxWaitingSeconds } var unreadBadge: some View { @@ -244,6 +251,16 @@ private extension ChatRowView { // MARK: - Time Formatting private extension ChatRowView { + private static let timeFormatter: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "h:mm a"; return f + }() + private static let dayFormatter: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "EEE"; return f + }() + private static let dateFormatter: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f + }() + var formattedTime: String { guard dialog.lastMessageTimestamp > 0 else { return "" } @@ -252,19 +269,13 @@ private extension ChatRowView { let calendar = Calendar.current if calendar.isDateInToday(date) { - let f = DateFormatter() - f.dateFormat = "h:mm a" - return f.string(from: date) + return Self.timeFormatter.string(from: date) } else if calendar.isDateInYesterday(date) { return "Yesterday" } else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 { - let f = DateFormatter() - f.dateFormat = "EEE" - return f.string(from: date) + return Self.dayFormatter.string(from: date) } else { - let f = DateFormatter() - f.dateFormat = "dd.MM.yy" - return f.string(from: date) + return Self.dateFormatter.string(from: date) } } } diff --git a/Rosetta/Features/Chats/Search/SearchResultsSection.swift b/Rosetta/Features/Chats/Search/SearchResultsSection.swift index f93d9ea..d796a7e 100644 --- a/Rosetta/Features/Chats/Search/SearchResultsSection.swift +++ b/Rosetta/Features/Chats/Search/SearchResultsSection.swift @@ -108,6 +108,7 @@ private extension SearchResultsSection { } .padding(.horizontal, 16) .padding(.vertical, 5) + .contentShape(Rectangle()) } .buttonStyle(.plain) } diff --git a/Rosetta/Features/Chats/Search/SearchView.swift b/Rosetta/Features/Chats/Search/SearchView.swift index 86cce7e..7cb88a8 100644 --- a/Rosetta/Features/Chats/Search/SearchView.swift +++ b/Rosetta/Features/Chats/Search/SearchView.swift @@ -280,6 +280,7 @@ private struct RecentSection: View { } .padding(.horizontal, 16) .padding(.vertical, 5) + .contentShape(Rectangle()) } .buttonStyle(.plain) } diff --git a/Rosetta/Features/Chats/Search/SearchViewModel.swift b/Rosetta/Features/Chats/Search/SearchViewModel.swift index 7285ee5..12637f3 100644 --- a/Rosetta/Features/Chats/Search/SearchViewModel.swift +++ b/Rosetta/Features/Chats/Search/SearchViewModel.swift @@ -88,7 +88,7 @@ final class SearchViewModel: ObservableObject { var packet = PacketSearch() packet.privateKey = hash - packet.search = currentQuery + packet.search = currentQuery.lowercased() ProtocolManager.shared.sendPacket(packet) } diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 0a79046..ea9274f 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -29,6 +29,12 @@ struct MainTabView: View { @available(iOS 26.0, *) private var systemTabView: some View { TabView(selection: $selectedTab) { + CallsView() + .tabItem { + Label(RosettaTab.calls.label, systemImage: RosettaTab.calls.icon) + } + .tag(RosettaTab.calls) + ChatListView( isSearchActive: $isChatSearchActive, isDetailPresented: $isChatListDetailPresented @@ -39,12 +45,6 @@ struct MainTabView: View { .tag(RosettaTab.chats) .badge(chatUnreadCount) - CallsView() - .tabItem { - Label(RosettaTab.calls.label, systemImage: RosettaTab.calls.icon) - } - .tag(RosettaTab.calls) - SettingsView(onLogout: onLogout, isEditingProfile: $isSettingsEditPresented) .tabItem { Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon) @@ -72,7 +72,7 @@ struct MainTabView: View { onTabSelected: { tab in activatedTabs.insert(tab) for t in RosettaTab.interactionOrder { activatedTabs.insert(t) } - withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) { + withAnimation(.easeInOut(duration: 0.15)) { selectedTab = tab } }, @@ -83,7 +83,7 @@ struct MainTabView: View { } dragFractionalIndex = state.fractionalIndex } else { - withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) { + withAnimation(.easeInOut(duration: 0.15)) { dragFractionalIndex = nil } } @@ -107,23 +107,30 @@ struct MainTabView: View { @ViewBuilder private func tabPager(availableSize: CGSize) -> some View { let width = max(1, availableSize.width) - let totalWidth = width * CGFloat(RosettaTab.interactionOrder.count) - HStack(spacing: 0) { + ZStack { ForEach(RosettaTab.interactionOrder, id: \.self) { tab in tabView(for: tab) .frame(width: width, height: availableSize.height) + .opacity(tabOpacity(for: tab)) + .allowsHitTesting(tab == selectedTab && dragFractionalIndex == nil) } } - .frame(width: totalWidth, alignment: .leading) - .modifier(PagerOffsetModifier( - effectiveIndex: dragFractionalIndex ?? currentPageIndex, - pageWidth: width, - isDragging: dragFractionalIndex != nil - )) .clipped() } + private func tabOpacity(for tab: RosettaTab) -> Double { + if let frac = dragFractionalIndex { + // During drag: crossfade between adjacent tabs + let tabIndex = CGFloat(tab.interactionIndex) + let distance = abs(frac - tabIndex) + if distance >= 1 { return 0 } + return Double(1 - distance) + } else { + return tab == selectedTab ? 1 : 0 + } + } + @ViewBuilder private func tabView(for tab: RosettaTab) -> some View { if activatedTabs.contains(tab) { diff --git a/Rosetta/Features/Onboarding/OnboardingPager.swift b/Rosetta/Features/Onboarding/OnboardingPager.swift index 484c603..9a093cd 100644 --- a/Rosetta/Features/Onboarding/OnboardingPager.swift +++ b/Rosetta/Features/Onboarding/OnboardingPager.swift @@ -61,6 +61,8 @@ struct OnboardingPager: UIViewControllerRepresentable { } } + deinit {} + // MARK: DataSource func pageViewController( diff --git a/Rosetta/Features/Settings/ProfileEditView.swift b/Rosetta/Features/Settings/ProfileEditView.swift index 61c15eb..6be2c64 100644 --- a/Rosetta/Features/Settings/ProfileEditView.swift +++ b/Rosetta/Features/Settings/ProfileEditView.swift @@ -8,7 +8,7 @@ struct ProfileEditView: View { @Binding var displayName: String @Binding var username: String let publicKey: String - var onLogout: () -> Void + var onLogout: () -> Void = {} @State private var selectedPhotoItem: PhotosPickerItem? @State private var selectedPhoto: UIImage? @@ -160,7 +160,7 @@ private extension ProfileEditView { Button(action: onLogout) { HStack { Spacer() - Text("Log Out") + Text("Delete Account") .font(.system(size: 17)) .foregroundStyle(RosettaColors.error) Spacer() @@ -187,8 +187,7 @@ private extension ProfileEditView { ProfileEditView( displayName: .constant("Gaidar"), username: .constant("GaidarTheDev"), - publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec", - onLogout: {} + publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec" ) } .background(RosettaColors.Adaptive.background) diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index ced3f98..16a6eed 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -9,7 +9,7 @@ struct SettingsView: View { @StateObject private var viewModel = SettingsViewModel() @State private var showCopiedToast = false - @State private var showLogoutConfirmation = false + @State private var showDeleteAccountConfirmation = false // Edit mode field state — initialized when entering edit mode @State private var editDisplayName = "" @@ -23,7 +23,7 @@ struct SettingsView: View { displayName: $editDisplayName, username: $editUsername, publicKey: viewModel.publicKey, - onLogout: { showLogoutConfirmation = true } + onLogout: { showDeleteAccountConfirmation = true } ) .transition(.opacity) } else { @@ -37,14 +37,15 @@ struct SettingsView: View { .toolbar { toolbarContent } .toolbarBackground(.hidden, for: .navigationBar) .task { viewModel.refresh() } - .alert("Log Out", isPresented: $showLogoutConfirmation) { + .alert("Delete Account", isPresented: $showDeleteAccountConfirmation) { Button("Cancel", role: .cancel) {} - Button("Log Out", role: .destructive) { + Button("Delete Account", role: .destructive) { SessionManager.shared.endSession() + try? AccountManager.shared.deleteAccount() onLogout?() } } message: { - Text("Are you sure you want to log out?") + Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.") } .onChange(of: isEditingProfile) { _, isEditing in if !isEditing { viewModel.refresh() } @@ -166,7 +167,6 @@ struct SettingsView: View { profileHeader accountSection settingsSection - dangerSection } .padding(.horizontal, 16) .padding(.top, 8) @@ -256,11 +256,11 @@ struct SettingsView: View { private var dangerSection: some View { GlassCard(cornerRadius: 26, fillOpacity: 0.08) { Button { - showLogoutConfirmation = true + showDeleteAccountConfirmation = true } label: { HStack { Spacer() - Text("Log Out") + Text("Delete Account") .font(.system(size: 17)) .foregroundStyle(RosettaColors.error) Spacer() diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 60fef56..c4db6ae 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -181,7 +181,8 @@ struct RosettaApp: App { case .main: MainTabView(onLogout: { isLoggedIn = false - fadeTransition(to: .unlock) + hasCompletedOnboarding = false + fadeTransition(to: .onboarding) }) } }