From acc3fb8e2f52a89bc36a9493dd36349bccb70ae2 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sat, 14 Mar 2026 01:56:48 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=B1?= =?UTF-8?q?=D1=8B=D1=81=D1=82=D1=80=D0=BE=D0=B3=D0=BE=20=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D0=BB=D0=B0=20=D0=B2=D0=BD=D0=B8=D0=B7,=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=81=D0=BA=D1=80=D0=BE=D0=BB=D0=BB=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20FPS=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=BA=D0=BB=D0=B0=D0=B2=D0=B8=D0=B0=D1=82=D1=83=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta.xcodeproj/project.pbxproj | 8 +- .../Data/Repositories/DialogRepository.swift | 39 ++- .../Data/Repositories/MessageRepository.swift | 5 + Rosetta/Core/Services/DraftManager.swift | 45 ++++ Rosetta/Core/Services/SessionManager.swift | 21 +- .../Components/KeyboardTracker.swift | 198 ++++++++------ .../Chats/ChatDetail/ChatDetailView.swift | 251 ++++++++++++------ .../Chats/ChatList/ChatListView.swift | 58 +++- .../Features/Chats/ChatList/ChatRowView.swift | 19 +- Rosetta/RosettaApp.swift | 22 +- 10 files changed, 451 insertions(+), 215 deletions(-) create mode 100644 Rosetta/Core/Services/DraftManager.swift diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 906df3f..3a72572 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 = 11; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -288,7 +288,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.3; 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 = 11; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -327,7 +327,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.3; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 483d445..5105ee8 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -89,7 +89,9 @@ final class DialogRepository { /// Creates or updates a dialog from an incoming message packet. /// - 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) { + /// - Parameter isNewMessage: When `false`, the message was already in MessageRepository + /// (dedup hit) — skip `unreadCount` increment to avoid double-counting. + func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String, fromSync: Bool = false, isNewMessage: Bool = true) { if currentAccount.isEmpty { currentAccount = myPublicKey } @@ -123,11 +125,13 @@ final class DialogRepository { if fromMe { dialog.iHaveSent = true } else { - // Only increment unread count when the user is NOT viewing this dialog. - // If the dialog is active (ChatDetailView is open), the user sees messages - // immediately — incrementing here would race with markAsRead() and cause - // the badge to flicker under rapid incoming messages. - if !MessageRepository.shared.isDialogActive(opponentKey) { + // Only increment unread count when: + // 1. The message is genuinely new (not a dedup hit from sync re-processing) + // 2. The user is NOT currently viewing this dialog + // Desktop parity: desktop computes unread from `SELECT COUNT(*) WHERE read = 0`, + // so duplicates never inflate the count. iOS uses an incremental counter, + // so we must guard against re-incrementing for known messages. + if isNewMessage && !MessageRepository.shared.isDialogActive(opponentKey) { dialog.unreadCount += 1 } } @@ -321,6 +325,29 @@ final class DialogRepository { schedulePersist() } + /// Desktop parity: reconcile `unreadCount` with the actual `isRead` state of messages + /// in MessageRepository. Called after sync completes to fix any divergence accumulated + /// during batch processing (e.g., sync re-processing already-read messages). + /// Desktop equivalent: `SELECT COUNT(*) FROM messages WHERE read = 0`. + func reconcileUnreadCounts() { + var changed = false + for (opponentKey, dialog) in dialogs { + let messages = MessageRepository.shared.messages(for: opponentKey) + let actualUnread = messages.filter { + $0.fromPublicKey == opponentKey && !$0.isRead + }.count + if dialog.unreadCount != actualUnread { + var updated = dialog + updated.unreadCount = actualUnread + dialogs[opponentKey] = updated + changed = true + } + } + if changed { + 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. diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 3348abc..243ddf2 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -88,6 +88,11 @@ final class MessageRepository: ObservableObject { messagesByDialog[dialogKey]?.last?.id == messageId } + /// Whether the user is currently viewing any chat. + var hasActiveDialog: Bool { + !activeDialogs.isEmpty + } + func isDialogActive(_ dialogKey: String) -> Bool { activeDialogs.contains(dialogKey) } diff --git a/Rosetta/Core/Services/DraftManager.swift b/Rosetta/Core/Services/DraftManager.swift new file mode 100644 index 0000000..8430e5f --- /dev/null +++ b/Rosetta/Core/Services/DraftManager.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Desktop parity: in-memory draft store per dialog. +/// Desktop uses `useMemory` hook to persist draft text per conversation; +/// drafts survive switching between chats but are lost on app restart. +@MainActor +final class DraftManager { + static let shared = DraftManager() + + /// Dialog public key → draft text. + private var drafts: [String: String] = [:] + + private init() {} + + /// Returns the stored draft for a dialog, or empty string if none. + func getDraft(for dialogKey: String) -> String { + drafts[dialogKey] ?? "" + } + + /// Saves a draft for a dialog. Empty/whitespace-only text deletes the draft. + func saveDraft(for dialogKey: String, text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + drafts.removeValue(forKey: dialogKey) + } else { + drafts[dialogKey] = text + } + } + + /// Deletes the draft for a dialog (called after sending a message). + func deleteDraft(for dialogKey: String) { + drafts.removeValue(forKey: dialogKey) + } + + /// Whether a draft exists for a given dialog. + func hasDraft(for dialogKey: String) -> Bool { + guard let text = drafts[dialogKey] else { return false } + return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Clears all drafts (called on logout). + func reset() { + drafts.removeAll() + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index f3cabb7..a5c6d5e 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -22,7 +22,8 @@ final class SessionManager { /// Hex-encoded raw private key, kept in memory for message decryption. private(set) var privateKeyHex: String? private var lastTypingSentAt: [String: Int64] = [:] - private var syncBatchInProgress = false + /// Desktop parity: exposed so chat list can suppress unread badges during sync. + private(set) var syncBatchInProgress = false private var syncRequestInFlight = false private var stalledSyncBatchCount = 0 private let maxStalledSyncBatches = 12 @@ -117,6 +118,13 @@ final class SessionManager { /// Sends an encrypted message to a recipient, matching Android's outgoing flow. func sendMessage(text: String, toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "") async throws { + // Desktop parity: validate message is not empty/whitespace-only before sending. + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + Self.logger.debug("📤 Ignoring empty message") + return + } + guard let privKey = privateKeyHex, let hash = privateKeyHash else { Self.logger.error("📤 Cannot send — missing keys") throw CryptoError.decryptionFailed @@ -242,6 +250,7 @@ final class SessionManager { DialogRepository.shared.reset() MessageRepository.shared.reset() RecentSearchesRepository.shared.clearSession() + DraftManager.shared.reset() } // MARK: - Protocol Callbacks @@ -393,9 +402,10 @@ 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. + // Safety net: reconcile dialog delivery indicators and unread counts + // with actual message statuses, fixing any desync from stale retry timers. DialogRepository.shared.reconcileDeliveryStatuses() + DialogRepository.shared.reconcileUnreadCounts() // Clear dedup sets on reconnect so subscriptions can be re-established lazily. self.requestedUserInfoKeys.removeAll() @@ -447,6 +457,7 @@ final class SessionManager { self.syncBatchInProgress = false self.flushPendingReadReceipts() DialogRepository.shared.reconcileDeliveryStatuses() + DialogRepository.shared.reconcileUnreadCounts() self.stalledSyncBatchCount = 0 // Refresh user info now that sync is done (desktop parity: lazy per-component). Task { @MainActor [weak self] in @@ -463,6 +474,7 @@ final class SessionManager { self.syncBatchInProgress = false self.flushPendingReadReceipts() DialogRepository.shared.reconcileDeliveryStatuses() + DialogRepository.shared.reconcileUnreadCounts() self.stalledSyncBatchCount = 0 Self.logger.debug("SYNC NOT_NEEDED") // Refresh user info now that sync is done. @@ -529,7 +541,8 @@ final class SessionManager { } DialogRepository.shared.updateFromMessage( - packet, myPublicKey: myKey, decryptedText: text, fromSync: syncBatchInProgress + packet, myPublicKey: myKey, decryptedText: text, + fromSync: syncBatchInProgress, isNewMessage: !wasKnownBefore ) MessageRepository.shared.upsertFromMessagePacket( packet, diff --git a/Rosetta/DesignSystem/Components/KeyboardTracker.swift b/Rosetta/DesignSystem/Components/KeyboardTracker.swift index a7c62cc..4c6c63b 100644 --- a/Rosetta/DesignSystem/Components/KeyboardTracker.swift +++ b/Rosetta/DesignSystem/Components/KeyboardTracker.swift @@ -4,24 +4,36 @@ import UIKit /// Drives keyboard-related positioning for the chat composer. /// -/// Published properties: -/// - `keyboardPadding`: bottom padding for the composer -/// - `interactiveOffset`: visual offset during interactive drag +/// Published property: +/// - `keyboardPadding`: bottom padding to apply when keyboard is visible /// -/// Data sources: -/// 1. `keyboardWillChangeFrameNotification` — animated show/hide with system timing -/// 2. KVO on `inputAccessoryView.superview.center` — pixel-perfect interactive drag +/// Animation strategy: +/// - Notification (show/hide): CADisplayLink interpolates `keyboardPadding` at 60fps +/// with ease-out curve. Small incremental updates keep LazyVStack stable (no cell +/// recycling, no gaps between bubbles). +/// - KVO (interactive dismiss): raw assignment at 60fps — already smooth. +/// - NO `withAnimation` / `.animation()` — these cause LazyVStack cell recycling gaps. @MainActor final class KeyboardTracker: ObservableObject { + /// Bottom padding — updated incrementally at display refresh rate. @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() + private var lastNotificationPadding: CGFloat = 0 + + // CADisplayLink-based animation state + private var displayLinkProxy: DisplayLinkProxy? + private var animStartPadding: CGFloat = 0 + private var animTargetPadding: CGFloat = 0 + private var animStartTime: CFTimeInterval = 0 + private var animDuration: CFTimeInterval = 0.25 + + /// Spring kept for potential future use (e.g., composer-only animation). + static let keyboardSpring = Animation.spring(duration: 0.25, bounce: 0) init() { if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, @@ -38,27 +50,20 @@ final class KeyboardTracker: ObservableObject { .store(in: &cancellables) } + /// Called from KVO — pixel-perfect interactive dismiss at 60fps. + /// Only applies DECREASING padding (swipe-to-dismiss). func updateFromKVO(keyboardHeight: CGFloat) { guard !isAnimating else { return } if keyboardHeight <= 0 { - baseKeyboardHeight = nil - - if interactiveOffset > 0 { + if keyboardPadding != 0 { if pendingResetTask == nil { pendingResetTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: .milliseconds(300)) + try? await Task.sleep(for: .milliseconds(150)) 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 - } - } + if self.keyboardPadding != 0 { self.keyboardPadding = 0 } } } - } else if interactiveOffset != 0 { - interactiveOffset = 0 } return } @@ -66,39 +71,11 @@ final class KeyboardTracker: ObservableObject { pendingResetTask?.cancel() pendingResetTask = nil - if baseKeyboardHeight == nil { - baseKeyboardHeight = keyboardHeight - } + let newPadding = max(0, keyboardHeight - bottomInset) + guard newPadding < keyboardPadding else { return } - 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 + if newPadding != keyboardPadding { + keyboardPadding = newPadding } } @@ -107,52 +84,101 @@ final class KeyboardTracker: ObservableObject { let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + isAnimating = true + 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) + let targetPadding = isVisible ? max(0, endHeight - bottomInset) : 0 + let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 - isAnimating = true - if duration > 0 { - withAnimation(.easeInOut(duration: duration)) { - keyboardPadding = targetPadding - interactiveOffset = 0 - } - } else { - keyboardPadding = targetPadding - interactiveOffset = 0 - } + let delta = targetPadding - lastNotificationPadding + lastNotificationPadding = targetPadding - Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(max(duration, 0.05) + 0.15)) - self?.isAnimating = false - } - } else { - baseKeyboardHeight = nil + if abs(delta) > 1 { + // CADisplayLink interpolation: updates @Published at ~60fps. + // Each frame is a small layout delta → LazyVStack handles it without + // cell recycling → no gaps between message bubbles. + startPaddingAnimation(to: targetPadding, duration: duration) + } - isAnimating = true - if duration > 0 { - withAnimation(.easeInOut(duration: duration)) { - keyboardPadding = 0 - interactiveOffset = 0 - } - } else { - keyboardPadding = 0 - interactiveOffset = 0 - } + // Unblock KVO after animation + buffer. + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(max(duration, 0.05) + 0.15)) + self?.isAnimating = false + } + } - Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(max(duration, 0.05) + 0.15)) - self?.isAnimating = false - } + // MARK: - CADisplayLink animation + + private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval) { + displayLinkProxy?.stop() + + animStartPadding = keyboardPadding + animTargetPadding = target + animStartTime = CACurrentMediaTime() + animDuration = max(duration, 0.05) + + displayLinkProxy = DisplayLinkProxy { [weak self] in + self?.animationTick() + } + } + + private func animationTick() { + let elapsed = CACurrentMediaTime() - animStartTime + let t = min(elapsed / animDuration, 1.0) + + // Ease-out cubic — closely matches iOS keyboard animation feel. + let eased = 1 - pow(1 - t, 3) + + // Round to nearest 1pt — sub-point changes are invisible but still + // trigger full SwiftUI layout passes. Skipping them reduces render cost. + let raw = animStartPadding + (animTargetPadding - animStartPadding) * eased + let rounded = round(raw) + + if t >= 1.0 { + keyboardPadding = animTargetPadding + displayLinkProxy?.stop() + displayLinkProxy = nil + } else if rounded != keyboardPadding { + keyboardPadding = rounded } } } + +// MARK: - CADisplayLink wrapper (avoids @objc requirement on @MainActor class) + +private class DisplayLinkProxy { + private var callback: (() -> Void)? + private var displayLink: CADisplayLink? + + init(callback: @escaping () -> Void) { + self.callback = callback + self.displayLink = CADisplayLink(target: self, selector: #selector(tick)) + // Cap at 60fps — keyboard animation doesn't need 120Hz, and each tick + // triggers a full ChatDetailView body evaluation. 60fps halves the cost. + self.displayLink?.preferredFrameRateRange = CAFrameRateRange( + minimum: 30, maximum: 60, preferred: 60 + ) + self.displayLink?.add(to: .main, forMode: .common) + } + + @objc private func tick() { + callback?() + } + + func stop() { + displayLink?.invalidate() + displayLink = nil + callback = nil + } + + deinit { + stop() + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index e1c52a4..0f024fd 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -1,6 +1,15 @@ import SwiftUI +import UIKit import UserNotifications +/// Measures the composer height so the inverted scroll can reserve bottom space. +private struct ComposerHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + struct ChatDetailView: View { let route: ChatRoute var onPresentedChange: ((Bool) -> Void)? = nil @@ -19,7 +28,10 @@ struct ChatDetailView: View { @State private var isViewActive = false // markReadTask removed — read receipts no longer sent from .onChange(of: messages.count) @State private var isInputFocused = false + @State private var isAtBottom = true + @State private var composerHeight: CGFloat = 56 @StateObject private var keyboard = KeyboardTracker() + @State private var shouldScrollOnNextMessage = false private var currentPublicKey: String { SessionManager.shared.currentPublicKey @@ -92,62 +104,77 @@ struct ChatDetailView: View { private static let scrollBottomAnchorId = "chat_detail_bottom_anchor" + private var maxBubbleWidth: CGFloat { + max(min(UIScreen.main.bounds.width * 0.72, 380), 140) + } + @ViewBuilder private var content: some View { - GeometryReader { geometry in + ZStack { + messagesList(maxBubbleWidth: maxBubbleWidth) + } + .overlay { chatEdgeGradients } + .overlay(alignment: .bottom) { + composer + .background( + GeometryReader { geo in + Color.clear.preference(key: ComposerHeightKey.self, value: geo.size.height) + } + ) + .padding(.bottom, keyboard.keyboardPadding) + } + .onPreferenceChange(ComposerHeightKey.self) { composerHeight = $0 } + .ignoresSafeArea(.keyboard) + .background { ZStack { - messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140)) + RosettaColors.Adaptive.background + tiledChatBackground } - .overlay { chatEdgeGradients } - .safeAreaInset(edge: .bottom, spacing: 0) { - composer - .offset(y: keyboard.interactiveOffset) - .animation(.spring(.smooth(duration: 0.32)), value: keyboard.interactiveOffset) + .ignoresSafeArea() + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .enableSwipeBack() + .modifier(ChatDetailNavBarStyleModifier()) + .toolbar { chatDetailToolbar } + .toolbar(.hidden, for: .tabBar) + .task { + isViewActive = true + // Desktop parity: restore draft text from DraftManager. + let draft = DraftManager.shared.getDraft(for: route.publicKey) + if !draft.isEmpty { + messageText = draft } - .background { - ZStack { - RosettaColors.Adaptive.background - tiledChatBackground - } - .ignoresSafeArea() - } - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) - .enableSwipeBack() - .modifier(ChatDetailNavBarStyleModifier()) - .toolbar { chatDetailToolbar } - .toolbar(.hidden, for: .tabBar) - .task { - isViewActive = true - // Suppress notifications & clear badge immediately (no 600ms delay). - // setDialogActive only touches MessageRepository.activeDialogs (Set), - // does NOT mutate DialogRepository, so ForEach won't rebuild. - MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) - clearDeliveredNotifications(for: route.publicKey) - // Reset idle timer — user is actively viewing a chat. - SessionManager.shared.recordUserInteraction() - // Request user info (non-mutating, won't trigger list rebuild) - requestUserInfoIfNeeded() - // Delay DialogRepository mutations to let navigation transition complete. - // Without this, DialogRepository update rebuilds ChatListView's ForEach - // mid-navigation, recreating the NavigationLink and canceling the push. - try? await Task.sleep(for: .milliseconds(600)) - guard isViewActive else { return } - activateDialog() - markDialogAsRead() - // 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 - MessageRepository.shared.setDialogActive(route.publicKey, isActive: false) + // Suppress notifications & clear badge immediately (no 600ms delay). + // setDialogActive only touches MessageRepository.activeDialogs (Set), + // does NOT mutate DialogRepository, so ForEach won't rebuild. + MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) + clearDeliveredNotifications(for: route.publicKey) + // Reset idle timer — user is actively viewing a chat. + SessionManager.shared.recordUserInteraction() + // Request user info (non-mutating, won't trigger list rebuild) + requestUserInfoIfNeeded() + // Delay DialogRepository mutations to let navigation transition complete. + // Without this, DialogRepository update rebuilds ChatListView's ForEach + // mid-navigation, recreating the NavigationLink and canceling the push. + try? await Task.sleep(for: .milliseconds(600)) + guard isViewActive else { return } + activateDialog() + markDialogAsRead() + // 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 + MessageRepository.shared.setDialogActive(route.publicKey, isActive: false) + // Desktop parity: save draft text on chat close. + DraftManager.shared.saveDraft(for: route.publicKey, text: messageText) + } } var body: some View { content } @@ -419,57 +446,107 @@ private extension ChatDetailView { private func messagesScrollView(maxBubbleWidth: CGFloat) -> some View { ScrollViewReader { proxy in let scroll = ScrollView(.vertical, showsIndicators: false) { - LazyVStack(spacing: 0) { - ForEach(messages.indices, id: \.self) { index in - let message = messages[index] - messageRow( - message, - maxBubbleWidth: maxBubbleWidth, - position: bubblePosition(for: index) - ) - .id(message.id) - } - + VStack(spacing: 0) { + // Anchor at VStack start → after flip = visual BOTTOM (newest edge). + // scrollTo(anchor, .top) places this at viewport top = visual bottom. Color.clear - .frame(height: 1) + .frame(height: 4) .id(Self.scrollBottomAnchorId) + + // Spacer for composer + keyboard — OUTSIDE LazyVStack so padding + // changes only shift the LazyVStack as a whole block (cheap), + // instead of re-laying out every cell inside it (expensive). + Color.clear + .frame(height: composerHeight + keyboard.keyboardPadding + 4) + + // LazyVStack: only visible cells are loaded. Internal layout + // is unaffected by the spacer above changing height. + LazyVStack(spacing: 0) { + // Sentinel for viewport-based scroll tracking. + // Must be inside LazyVStack — regular VStack doesn't + // fire onAppear/onDisappear on scroll. + Color.clear + .frame(height: 1) + .onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } } + .onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } } + + ForEach(messages.indices.reversed(), id: \.self) { index in + let message = messages[index] + messageRow( + message, + maxBubbleWidth: maxBubbleWidth, + position: bubblePosition(for: index) + ) + .scaleEffect(x: 1, y: -1) // flip each row back to normal + .id(message.id) + } + } } .padding(.horizontal, 10) - .padding(.top, messagesTopInset) - .padding(.bottom, 10) + .padding(.bottom, messagesTopInset) // visual top (near nav bar) } + .scaleEffect(x: 1, y: -1) // INVERTED SCROLL — bottom-anchored by nature + // Parent .ignoresSafeArea(.keyboard) handles keyboard — no scroll-level ignore needed. + // Composer is overlay (not safeAreaInset), so no .container ignore needed either. .scrollDismissesKeyboard(.interactively) .onTapGesture { isInputFocused = false } .onAppear { + // In inverted scroll, offset 0 IS the visual bottom — no scroll needed. + // Safety scroll for edge cases (e.g., view recycling). DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) } - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(120)) - scrollToBottom(proxy: proxy, animated: false) - } - // markDialogAsRead() removed — already handled in .task with 600ms delay. - // Calling it here immediately mutates DialogRepository, triggering - // ChatListView ForEach rebuild mid-navigation and cancelling the push. } - .onChange(of: messages.count) { _, _ in - scrollToBottom(proxy: proxy, animated: true) - // 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: messages.last?.id) { _, _ in + let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true + if shouldScrollOnNextMessage || lastIsOutgoing || isAtBottom { + DispatchQueue.main.async { + scrollToBottom(proxy: proxy, animated: true) + } + shouldScrollOnNextMessage = false + } } .onChange(of: isInputFocused) { _, focused in guard focused else { return } - // User tapped the input — reset idle timer. SessionManager.shared.recordUserInteraction() - scrollToBottom(proxy: proxy, animated: false) } + // No keyboard scroll handlers needed — inverted scroll keeps bottom anchored. scroll - .defaultScrollAnchor(.bottom) .scrollIndicators(.hidden) + .overlay(alignment: .bottom) { + scrollToBottomButton(proxy: proxy) + } } } + @ViewBuilder + private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { + // Positioning container — always present, no transition on it. + // Only the button itself animates in/out. + HStack { + Spacer() + if !isAtBottom { + Button { + scrollToBottom(proxy: proxy, animated: true) + } label: { + TelegramVectorIcon( + pathData: TelegramIconPath.chevronDown, + viewBox: CGSize(width: 22, height: 12), + color: .white + ) + .frame(width: 14, height: 8) + .frame(width: 42, height: 42) + .background { + glass(shape: .circle, strokeOpacity: 0.18) + } + } + .buttonStyle(ChatDetailGlassPressButtonStyle()) + .transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity)) + } + } + .padding(.bottom, composerHeight + keyboard.keyboardPadding + 4) + .padding(.trailing, composerTrailingPadding) + .allowsHitTesting(!isAtBottom) + } + @ViewBuilder func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View { let outgoing = message.isFromMe(myPublicKey: currentPublicKey) @@ -555,7 +632,9 @@ private extension ChatDetailView { ChatTextInput( text: $messageText, isFocused: $isInputFocused, - onKeyboardHeightChange: { keyboard.updateFromKVO(keyboardHeight: $0) }, + onKeyboardHeightChange: { height in + keyboard.updateFromKVO(keyboardHeight: height) + }, onUserTextInsertion: handleComposerUserTyping, textColor: UIColor(RosettaColors.Adaptive.text), placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5)) @@ -843,12 +922,13 @@ private extension ChatDetailView { } func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { + // Inverted scroll: .top anchor in scroll coordinates = visual bottom on screen. if animated { withAnimation(.easeOut(duration: 0.2)) { - proxy.scrollTo(Self.scrollBottomAnchorId, anchor: .bottom) + proxy.scrollTo(Self.scrollBottomAnchorId, anchor: .top) } } else { - proxy.scrollTo(Self.scrollBottomAnchorId, anchor: .bottom) + proxy.scrollTo(Self.scrollBottomAnchorId, anchor: .top) } } @@ -898,8 +978,11 @@ private extension ChatDetailView { guard !message.isEmpty else { return } // User is sending a message — reset idle timer. SessionManager.shared.recordUserInteraction() + shouldScrollOnNextMessage = true messageText = "" sendError = nil + // Desktop parity: delete draft after sending. + DraftManager.shared.deleteDraft(for: route.publicKey) Task { @MainActor in do { @@ -1198,6 +1281,8 @@ private enum TelegramIconPath { static let sendPlane = #"M1.47656 7.84766C4.42969 6.57161 6.89062 5.50521 8.85938 4.64844C10.8281 3.8099 12.3047 3.18099 13.2891 2.76172C15.1849 1.97786 16.6159 1.39453 17.582 1.01172C18.5664 0.628906 19.3047 0.364583 19.7969 0.21875C20.2344 0.0729167 20.5807 0 20.8359 0H20.8633C20.9727 0 21.0911 0.0182292 21.2188 0.0546875C21.3828 0.0911458 21.5195 0.154948 21.6289 0.246094C21.7201 0.31901 21.793 0.410156 21.8477 0.519531C21.8659 0.592448 21.8932 0.683594 21.9297 0.792969C21.9297 0.865885 21.9388 0.947917 21.957 1.03906C21.957 1.14844 21.957 1.2487 21.957 1.33984C21.957 1.39453 21.957 1.4401 21.957 1.47656C21.957 1.51302 21.957 1.54948 21.957 1.58594C21.8112 3.02604 21.474 5.40495 20.9453 8.72266C20.4896 11.4753 20.0612 13.9544 19.6602 16.1602C19.5326 16.8529 19.332 17.3815 19.0586 17.7461C18.8398 18.0378 18.5755 18.2018 18.2656 18.2383C18.2292 18.2383 18.2018 18.2383 18.1836 18.2383C18.1654 18.2383 18.138 18.2383 18.1016 18.2383C17.8464 18.2383 17.5911 18.1927 17.3359 18.1016C17.099 18.0104 16.8529 17.8919 16.5977 17.7461C16.4154 17.6367 16.1693 17.4727 15.8594 17.2539C15.4948 16.9805 15.2396 16.7982 15.0938 16.707C14.474 16.306 13.7083 15.7956 12.7969 15.1758C11.8672 14.5378 11.1198 14.0365 10.5547 13.6719C10.1536 13.3984 9.87109 13.1341 9.70703 12.8789C9.5612 12.6602 9.50651 12.4414 9.54297 12.2227C9.57943 12.0221 9.69792 11.8034 9.89844 11.5664C10.0078 11.4206 10.2174 11.2018 10.5273 10.9102C10.6367 10.819 10.7188 10.7461 10.7734 10.6914C10.8646 10.6003 10.9375 10.5182 10.9922 10.4453C11.0286 10.4089 11.5482 9.92578 12.5508 8.99609C13.681 7.95703 14.5469 7.13672 15.1484 6.53516C16.0781 5.6237 16.5612 5.10417 16.5977 4.97656C16.5977 4.9401 16.5977 4.89453 16.5977 4.83984C16.5794 4.7487 16.543 4.67578 16.4883 4.62109C16.4336 4.58464 16.3698 4.56641 16.2969 4.56641C16.2422 4.56641 16.1693 4.57552 16.0781 4.59375C15.987 4.61198 15.2305 5.09505 13.8086 6.04297C12.4049 6.95443 10.3086 8.34896 7.51953 10.2266C7.11849 10.5182 6.73568 10.7279 6.37109 10.8555C6.00651 10.9831 5.66016 11.0469 5.33203 11.0469C5.00391 11.0469 4.52083 10.9648 3.88281 10.8008C3.3724 10.6732 2.80729 10.5091 2.1875 10.3086C2.27865 10.3451 2.04167 10.2721 1.47656 10.0898C1.22135 9.9987 1.02083 9.92578 0.875 9.87109C0.692708 9.79818 0.53776 9.72526 0.410156 9.65234C0.264323 9.57943 0.164062 9.48828 0.109375 9.37891C0.0364583 9.28776 0 9.17839 0 9.05078C0 9.03255 0 9.02344 0 9.02344C0 9.00521 0 8.98698 0 8.96875C0.0182292 8.78646 0.154948 8.60417 0.410156 8.42188C0.665365 8.23958 1.02083 8.04818 1.47656 7.84766Z"# + static let chevronDown = #"M11.8854 11.6408C11.3964 12.1197 10.6036 12.1197 10.1145 11.6408L0.366765 2.09366C-0.122255 1.61471 -0.122255 0.838169 0.366765 0.359215C0.855786 -0.119739 1.64864 -0.119739 2.13767 0.359215L11 9.03912L19.8623 0.359215C20.3514 -0.119739 21.1442 -0.119739 21.6332 0.359215C22.1223 0.838169 22.1223 1.61471 21.6332 2.09366L11.8854 11.6408Z"# + static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"# } diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 212f96c..d2fa8e9 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -420,16 +420,22 @@ private struct ChatListDialogContent: View { @ObservedObject var viewModel: ChatListViewModel @ObservedObject var navigationState: ChatListNavigationState var onPinnedStateChange: (Bool) -> Void = { _ in } + + /// Desktop parity: track typing dialogs from MessageRepository (@Published). + @State private var typingDialogs: Set = [] + var body: some View { let hasPinned = !viewModel.pinnedDialogs.isEmpty if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading { ChatEmptyStateView(searchText: "") .onChange(of: hasPinned) { _, val in onPinnedStateChange(val) } .onAppear { onPinnedStateChange(hasPinned) } + .onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 } } else { dialogList .onChange(of: hasPinned) { _, val in onPinnedStateChange(val) } .onAppear { onPinnedStateChange(hasPinned) } + .onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 } } } @@ -466,10 +472,42 @@ private struct ChatListDialogContent: View { } private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View { - Button { - navigationState.path.append(ChatRoute(dialog: dialog)) - } label: { - ChatRowView(dialog: dialog) + /// Desktop parity: wrap in SyncAwareChatRow to isolate @Observable read + /// of SessionManager.syncBatchInProgress from this view's observation scope. + SyncAwareChatRow( + dialog: dialog, + isTyping: typingDialogs.contains(dialog.opponentKey), + isFirst: isFirst, + onTap: { navigationState.path.append(ChatRoute(dialog: dialog)) }, + onDelete: { withAnimation { viewModel.deleteDialog(dialog) } }, + onToggleMute: { viewModel.toggleMute(dialog) }, + onTogglePin: { viewModel.togglePin(dialog) } + ) + } +} + +// MARK: - Sync-Aware Chat Row (observation-isolated) + +/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own +/// observation scope. Without this wrapper, every sync state change would +/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows. +private struct SyncAwareChatRow: View { + let dialog: Dialog + let isTyping: Bool + let isFirst: Bool + let onTap: () -> Void + let onDelete: () -> Void + let onToggleMute: () -> Void + let onTogglePin: () -> Void + + var body: some View { + let isSyncing = SessionManager.shared.syncBatchInProgress + Button(action: onTap) { + ChatRowView( + dialog: dialog, + isSyncing: isSyncing, + isTyping: isTyping + ) } .buttonStyle(.plain) .listRowInsets(EdgeInsets()) @@ -478,16 +516,12 @@ private struct ChatListDialogContent: View { .listRowSeparatorTint(RosettaColors.Adaptive.divider) .alignmentGuide(.listRowSeparatorLeading) { _ in 82 } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - withAnimation { viewModel.deleteDialog(dialog) } - } label: { + Button(role: .destructive, action: onDelete) { Label("Delete", systemImage: "trash") } if !dialog.isSavedMessages { - Button { - viewModel.toggleMute(dialog) - } label: { + Button(action: onToggleMute) { Label( dialog.isMuted ? "Unmute" : "Mute", systemImage: dialog.isMuted ? "bell" : "bell.slash" @@ -497,9 +531,7 @@ private struct ChatListDialogContent: View { } } .swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - viewModel.togglePin(dialog) - } label: { + Button(action: onTogglePin) { Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin") } .tint(.orange) diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index a7330c9..f6786b5 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -20,6 +20,10 @@ import Combine /// SF Pro Regular 15/20, black, tracking -0.23 struct ChatRowView: View { let dialog: Dialog + /// Desktop parity: suppress unread badge during sync. + var isSyncing: Bool = false + /// Desktop parity: show "typing..." instead of last message. + var isTyping: Bool = false /// Desktop parity: recheck delivery timeout every 40s so clock → error /// transitions happen automatically without user scrolling. @@ -123,12 +127,20 @@ private extension ChatRowView { Text(messageText) .font(.system(size: 15)) .tracking(-0.23) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .foregroundStyle( + isTyping && !dialog.isSavedMessages + ? RosettaColors.figmaBlue + : RosettaColors.Adaptive.textSecondary + ) .lineLimit(2) .frame(height: 41, alignment: .topLeading) } var messageText: String { + // Desktop parity: show "typing..." in chat list row when opponent is typing. + if isTyping && !dialog.isSavedMessages { + return "typing..." + } if dialog.lastMessage.isEmpty { return "No messages yet" } @@ -174,7 +186,9 @@ private extension ChatRowView { // Desktop parity: delivery icon and unread badge are // mutually exclusive — badge hidden when lastMessageFromMe. - if dialog.unreadCount > 0 && !dialog.lastMessageFromMe { + // Also hidden during sync (desktop hides badges while + // protocolState == SYNCHRONIZATION). + if dialog.unreadCount > 0 && !dialog.lastMessageFromMe && !isSyncing { unreadBadge } } @@ -299,6 +313,7 @@ private extension ChatRowView { VStack(spacing: 0) { ChatRowView(dialog: sampleDialog) + ChatRowView(dialog: sampleDialog, isTyping: true) } .background(RosettaColors.Adaptive.background) } diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index b85e223..a211a4f 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -52,29 +52,17 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent // MARK: - UNUserNotificationCenterDelegate - /// Handle foreground notifications — suppress only when the specific chat is open. + /// Handle foreground notifications — suppress ALL when app is in foreground. + /// Android parity: `isAppInForeground` check suppresses everything. + /// Messages arrive in real-time via WebSocket, push is only for background. func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - let userInfo = notification.request.content.userInfo - let type = userInfo["type"] as? String - - if type == "new_message" { - let senderKey = userInfo["sender_public_key"] as? String ?? "" - // Only suppress if this specific chat is currently open - if !senderKey.isEmpty, - MessageRepository.shared.isDialogActive(senderKey) - { - completionHandler([]) - } else { - completionHandler([.banner, .badge, .sound]) - } - } else { - completionHandler([.banner, .badge, .sound]) - } + // App is in foreground — suppress all notifications (Android parity). + completionHandler([]) } /// Handle notification tap — navigate to the sender's chat.