diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index b8173e8..b7e60d4 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -613,7 +613,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -629,7 +629,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.7; + MARKETING_VERSION = 1.2.8; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -653,7 +653,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -669,7 +669,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.7; + MARKETING_VERSION = 1.2.8; 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 6d92659..dedaa8b 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -169,12 +169,6 @@ final class DialogRepository { } } else if textIsEmpty { lastMessageText = "" - #if DEBUG - if !lastMsg.text.isEmpty { - print("[Dialog] ⚠️ Last message has garbled text but no attachments — opponentKey=\(opponentKey.prefix(12))… msgId=\(lastMsg.id.prefix(12))…") - print("[Dialog] text prefix: \(lastMsg.text.prefix(40))…") - } - #endif } else { lastMessageText = lastMsg.text } diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 68551f4..e077f2c 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -273,6 +273,13 @@ final class MessageRepository: ObservableObject { readEligibleDialogs.contains(dialogKey) } + /// Clears all read eligibility flags. Called when app enters background + /// to prevent stale eligibility from marking messages as read during + /// background sync or premature foreground resume. + func clearAllReadEligibility() { + readEligibleDialogs.removeAll() + } + // MARK: - Message Updates func upsertFromMessagePacket( @@ -306,9 +313,6 @@ final class MessageRepository: ObservableObject { // Android parity: encrypt plaintext with private key for local storage. // Android: `encryptWithPassword(plainText, privateKey)` → `plain_message` column. // If encryption fails, store plaintext as fallback. - #if DEBUG - let encStart = CFAbsoluteTimeGetCurrent() - #endif let storedText: String if !privateKey.isEmpty, let enc = try? CryptoManager.shared.encryptWithPassword(Data(decryptedText.utf8), password: privateKey) { @@ -316,12 +320,6 @@ final class MessageRepository: ObservableObject { } else { storedText = decryptedText } - #if DEBUG - let encElapsed = (CFAbsoluteTimeGetCurrent() - encStart) * 1000 - if encElapsed > 5 { - print("⚡ PERF_ENCRYPT | upsert | \(String(format: "%.1f", encElapsed))ms (PBKDF2 CACHE MISS?)") - } - #endif let encoder = JSONEncoder() let attachmentsJSON: String @@ -356,11 +354,6 @@ final class MessageRepository: ObservableObject { let finalAttachments = shouldPreserveAttachments ? existing.attachments : attachmentsJSON - #if DEBUG - if shouldPreserveAttachments { - print("🛡️ [DB] Preserved decrypted .messages blob for messageId=\(messageId.prefix(16)) (sync would have corrupted it)") - } - #endif // Update existing message (store encrypted text) try db.execute( @@ -876,13 +869,6 @@ final class MessageRepository: ObservableObject { plainText = decrypted } else { let fallback = Self.safePlainMessageFallback(record.text) - #if DEBUG - if !fallback.isEmpty { - print("[MSG] ⚠️ decryptRecord fallback returned RAW text for msgId=\(record.messageId.prefix(12))") - print("[MSG] text prefix: \(record.text.prefix(40))…") - print("[MSG] attachments: \(record.attachments.prefix(60))…") - } - #endif plainText = fallback } } else { diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 21f9557..a0e43c7 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -287,7 +287,11 @@ extension MessageCellLayout { // Content blocks above the text area let mediaDimensions = Self.mediaDimensions(for: UIScreen.main.bounds.width) - let replyH: CGFloat = config.hasReplyQuote ? 46 : 0 + // Telegram reply: 3pt top pad + 17pt name + 2pt spacing + 17pt text + 3pt bottom = 42pt container + let replyContainerH: CGFloat = 42 + let replyTopInset: CGFloat = 5 + let replyBottomGap: CGFloat = 7 // Telegram: 7pt from reply container bottom to message text + let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 0 var photoH: CGFloat = 0 let forwardHeaderH: CGFloat = config.isForward ? 40 : 0 var fileH: CGFloat = CGFloat(config.fileCount) * 52 @@ -530,10 +534,10 @@ extension MessageCellLayout { ) // Accessory frames (reply, photo, file, forward) - let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: 41) - let replyBarFrame = CGRect(x: 0, y: 0, width: 3, height: 41) - let replyNameFrame = CGRect(x: 9, y: 2, width: bubbleW - 24, height: 17) - let replyTextFrame = CGRect(x: 9, y: 20, width: bubbleW - 24, height: 17) + let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: replyContainerH) + let replyBarFrame = CGRect(x: 0, y: 0, width: 3, height: replyContainerH) + let replyNameFrame = CGRect(x: 11, y: 3, width: bubbleW - 27, height: 17) + let replyTextFrame = CGRect(x: 11, y: 22, width: bubbleW - 27, height: 17) // Telegram: 2pt inset on all four sides between photo and bubble edge let photoY: CGFloat = (config.hasReplyQuote ? replyH : 0) + 2 diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 358e910..5a4fa4e 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -69,6 +69,7 @@ final class SessionManager { // MARK: - Foreground Detection (Android parity) private var foregroundObserverToken: NSObjectProtocol? + private var backgroundObserverToken: NSObjectProtocol? /// Android parity: 5s debounce between foreground sync requests. private var lastForegroundSyncTime: TimeInterval = 0 @@ -1533,11 +1534,6 @@ final class SessionManager { fromSync: effectiveFromSync, dialogIdentityOverride: opponentKey ) - #if DEBUG - if processedPacket.attachments.contains(where: { $0.type == .call }) { - print("[CallAtt] Stored call attachment msgId=\(processedPacket.messageId.prefix(12))… text='\(text.prefix(20))' attCount=\(processedPacket.attachments.count)") - } - #endif // Android parity 1:1: dialogDao.updateDialogFromMessages(account, opponentKey) // Full recalculation of lastMessage, unread, iHaveSent, delivery from DB. DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) @@ -2474,6 +2470,8 @@ final class SessionManager { // Android parity (onResume line 428): clear ALL delivered notifications UNUserNotificationCenter.current().removeAllDeliveredNotifications() // Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog. + // Safe after background clear: readEligibleDialogs is empty on resume, + // so this is a no-op until ChatDetailView re-evaluates eligibility. self?.markActiveDialogsAsRead() // Android parity: on foreground resume, always force reconnect. @@ -2491,6 +2489,18 @@ final class SessionManager { self?.syncOnForeground() } } + + // Clear read eligibility when app enters background. + // Prevents stale readEligibleDialogs from marking messages as read + // during background sync or on premature foreground resume. + // ChatDetailView re-evaluates eligibility via didBecomeActiveNotification. + backgroundObserverToken = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { _ in + MessageRepository.shared.clearAllReadEligibility() + } } /// Android parity: `syncOnForeground()` — request sync on foreground resume diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index e7913d8..c4aeac8 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -11,17 +11,14 @@ enum ReleaseNotes { Entry( version: appVersion, body: """ + **Чат — анимации и навигация** + Telegram-style анимация появления новых сообщений (spring slide-up + alpha fade). Разделители дат со sticky-поведением и push-переходом между секциями. Reply-to-reply с подсветкой сообщения при навигации по реплаю. + **Звонки — минимизированная панель** - Telegram-style call-бар при активном звонке: зелёный градиент в статус-баре, навигационная панель сдвигается вниз (UIKit additionalSafeAreaInsets). Тап по панели — возврат на полный экран звонка. + Telegram-style call-бар при активном звонке: зелёный градиент, навигационная панель сдвигается вниз. Тап по панели — возврат на полный экран звонка. - **Звонки — полный экран** - Анимированный градиентный фон из цвета аватара собеседника. Свайп вниз для сворачивания. E2E-шифрование бейдж. - - **Контекстное меню** - Рефакторинг Telegram-style контекстного меню сообщений. Карточка действий с блюром и анимацией. - - **Производительность** - Ячейки сообщений — Equatable-компонент, 120 FPS скролл. Пагинация по 50 сообщений. Клавиатура синхронизирована с контентом. + **Стабильность** + Фикс скролла реплай-сообщений под композер при отправке. Блокировка восстановления клавиатуры при свайп-бэк. Скип read receipt для системных аккаунтов. Фикс race condition свайп-минимизации call bar. Пустой чат: glass-подложка и composer на iOS < 26. """ ) ] diff --git a/Rosetta/DesignSystem/Components/ChatTextInput.swift b/Rosetta/DesignSystem/Components/ChatTextInput.swift index 4136bd1..785d64a 100644 --- a/Rosetta/DesignSystem/Components/ChatTextInput.swift +++ b/Rosetta/DesignSystem/Components/ChatTextInput.swift @@ -285,16 +285,8 @@ struct ChatTextInput: UIViewRepresentable { // calculates the needed height for the text, independent of the current frame. let fittingHeight = tv.sizeThatFits(CGSize(width: tv.bounds.width, height: .greatestFiniteMagnitude)).height let isMultiline = fittingHeight > threshold - #if DEBUG - let textLen = tv.text?.count ?? 0 - let textRepr = tv.text?.replacingOccurrences(of: "\n", with: "\\n").prefix(20) ?? "" - print("📐 checkMultiline | fitH=\(String(format: "%.1f", fittingHeight)) contentH=\(String(format: "%.1f", tv.contentSize.height)) singleH=\(String(format: "%.1f", singleLineHeight)) threshold=\(String(format: "%.1f", threshold)) | isMulti=\(isMultiline) was=\(wasMultiline) | len=\(textLen) text=\"\(textRepr)\"") - #endif if isMultiline != wasMultiline { wasMultiline = isMultiline - #if DEBUG - print("📐 MULTILINE CHANGED → \(isMultiline)") - #endif parent.onMultilineChange(isMultiline) } } diff --git a/Rosetta/DesignSystem/Components/ComposerContainerController.swift b/Rosetta/DesignSystem/Components/ComposerContainerController.swift index 9471572..f435124 100644 --- a/Rosetta/DesignSystem/Components/ComposerContainerController.swift +++ b/Rosetta/DesignSystem/Components/ComposerContainerController.swift @@ -91,9 +91,6 @@ final class ComposerHostView: UIView { /// When called inside UIView.animate — creates CA animation in same transaction. /// When called directly (KVO) — immediate constraint update. func setKeyboardOffset(_ offset: CGFloat) { - #if DEBUG - print("🎹 UIKit setKeyboardOffset(\(Int(offset))) — constraint=\(-Int(offset))") - #endif bottomConstraint.constant = -offset layoutIfNeeded() } diff --git a/Rosetta/DesignSystem/Components/KeyboardTracker.swift b/Rosetta/DesignSystem/Components/KeyboardTracker.swift index 8a46006..3f66907 100644 --- a/Rosetta/DesignSystem/Components/KeyboardTracker.swift +++ b/Rosetta/DesignSystem/Components/KeyboardTracker.swift @@ -132,12 +132,6 @@ final class KeyboardTracker: ObservableObject { if #available(iOS 26, *) { return } PerformanceLogger.shared.track("keyboard.kvo") guard !isAnimating else { return } - #if DEBUG - let rawPad = max(0, keyboardHeight - bottomInset) - if abs(rawPad - keyboardPadding) > 4 { - print("⌨️ 👆 KVO | height=\(Int(keyboardHeight)) → pad=\(Int(rawPad)) | current=\(Int(keyboardPadding))") - } - #endif if keyboardHeight <= 0 { // Flush any pending KVO value and stop coalescing @@ -259,18 +253,12 @@ final class KeyboardTracker: ObservableObject { let initialEased = cubicBezierEase(initialT) let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased spacerPadding = max(keyboardPadding, round(initialValue)) - #if DEBUG - print("⌨️ 🎬 SHOW headstart: spacer=\(Int(spacerPadding)) target=\(Int(targetPadding))") - #endif } else { // spacerPadding handled by animationTick via curve prediction // Keep keyboardPadding headstart for iOS 26+ consumers let initialT = min(0.016 / max(duration, 0.05), 1.0) let initialEased = cubicBezierEase(initialT) let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased - #if DEBUG - print("⌨️ 🎬 HIDE headstart: kbPad \(Int(keyboardPadding))→\(Int(round(initialValue)))") - #endif keyboardPadding = round(initialValue) } @@ -278,16 +266,9 @@ final class KeyboardTracker: ObservableObject { lastNotificationPadding = targetPadding PerformanceLogger.shared.track("keyboard.notification") - #if DEBUG - let direction = targetPadding > keyboardPadding ? "⬆️ SHOW" : "⬇️ HIDE" - print("⌨️ \(direction) | screenH=\(Int(screenHeight)) kbTop=\(Int(keyboardTop)) endH=\(Int(endHeight)) bottomInset=\(Int(bottomInset)) | target=\(Int(targetPadding)) current=\(Int(keyboardPadding)) delta=\(Int(delta)) | dur=\(String(format: "%.3f", duration))s curve=\(curveRaw)") - #endif // Filter spurious notifications (delta=0, duration=0) — keyboard frame // didn't actually change. Happens on some iOS versions during text input. guard abs(delta) > 1 || targetPadding != keyboardPadding else { - #if DEBUG - if delta != 0 { print("⌨️ ⏭️ SKIP spurious | delta=\(Int(delta))") } - #endif return } @@ -377,10 +358,7 @@ final class KeyboardTracker: ObservableObject { configureBezier(curveRaw: curveRaw) // Primary: sync view matches keyboard's exact curve (same CA transaction). - let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw) - #if DEBUG - print("⌨️ 🎬 START | from=\(Int(keyboardPadding)) to=\(Int(target)) syncOK=\(syncOK) dur=\(String(format: "%.3f", duration))s") - #endif + let _ = setupSyncAnimation(duration: duration, curveRaw: curveRaw) // Reuse existing display link to preserve vsync phase alignment. if let proxy = displayLinkProxy { @@ -437,11 +415,6 @@ final class KeyboardTracker: ObservableObject { // non-monotonic values in the first 2 ticks while Core Animation commits // the animation to the render server. Without this guard, padding oscillates // (e.g. 312 → 306 → 310 → 302) causing a visible "jerk". - #if DEBUG - if eased < lastEased { - print("⌨️ ⚠️ MONOTONIC | raw=\(String(format: "%.4f", eased)) clamped=\(String(format: "%.4f", lastEased))") - } - #endif if eased < lastEased { eased = lastEased } @@ -489,29 +462,14 @@ final class KeyboardTracker: ObservableObject { let hardDeadline = elapsed >= animDuration + 0.25 let closeEnough = abs(animTargetPadding - rounded) <= 1 if hardDeadline || animTickCount > 40 || (pastDuration && closeEnough) { - let prevPadding = keyboardPadding keyboardPadding = max(0, animTargetPadding) spacerPadding = max(0, animTargetPadding) // Pause instead of invalidate — preserves vsync phase for next animation. displayLinkProxy?.isPaused = true lastTickTime = 0 isAnimatingKeyboard = false - #if DEBUG - let syncFinalOpacity = syncView?.layer.presentation().map { CGFloat($0.opacity) } - let elapsedMs = elapsed * 1000 - let snapDelta = Int(animTargetPadding - prevPadding) - print("⌨️ ✅ DONE | ticks=\(animTickCount) snap=\(snapDelta)pt | syncOp=\(syncFinalOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | glide=\(pastDuration) elapsed=\(String(format: "%.0f", elapsedMs))ms dur=\(String(format: "%.0f", animDuration * 1000))ms") - #endif } else if rounded != keyboardPadding { - #if DEBUG - let syncOpacity = syncView?.layer.presentation().map { CGFloat($0.opacity) } - let prevPad = keyboardPadding - let gliding = pastDuration && remaining > 1 - #endif keyboardPadding = rounded - #if DEBUG - print("⌨️ T\(animTickCount) | syncOp=\(syncOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | pad \(Int(prevPad))→\(Int(rounded)) Δ\(Int(rounded - prevPad))pt spacer=\(Int(spacerPadding)) max=\(Int(max(rounded, spacerPadding))) | \(String(format: "%.1f", elapsed * 1000))ms\(gliding ? " 🛬" : "")") - #endif } // Curve-predicted spacerPadding: predicts where the animation will be diff --git a/Rosetta/DesignSystem/Components/TelegramGlassView.swift b/Rosetta/DesignSystem/Components/TelegramGlassView.swift index 999c3c5..f75a23c 100644 --- a/Rosetta/DesignSystem/Components/TelegramGlassView.swift +++ b/Rosetta/DesignSystem/Components/TelegramGlassView.swift @@ -68,11 +68,6 @@ struct TelegramGlassRoundedRect: UIViewRepresentable { } func updateUIView(_ uiView: TelegramGlassUIView, context: Context) { - #if DEBUG - let oldRadius = uiView.fixedCornerRadius ?? -1 - let boundsH = uiView.bounds.height - print("🔲 GlassRoundedRect.updateUIView | radius \(Int(oldRadius))→\(Int(cornerRadius)) boundsH=\(Int(boundsH))") - #endif uiView.isFrozen = !context.environment.telegramGlassActive uiView.fixedCornerRadius = cornerRadius uiView.applyCornerRadius() diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 9bafcc9..35716f5 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -278,6 +278,20 @@ struct ChatDetailView: View { // Desktop parity: save draft text on chat close. DraftManager.shared.saveDraft(for: route.publicKey, text: messageText) } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + // Re-evaluate read eligibility after app returns from background. + // readEligibleDialogs is cleared on didEnterBackground (SessionManager); + // this restores eligibility for the currently-visible chat. + // 600ms delay lets notification-tap navigation settle — if user tapped + // a notification for a DIFFERENT chat, isViewActive becomes false. + guard isViewActive else { return } + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(600)) + guard isViewActive else { return } + updateReadEligibility() + markDialogAsRead() + } + } } var body: some View { @@ -931,9 +945,6 @@ private extension ChatDetailView { }, onUserTextInsertion: handleComposerUserTyping, onMultilineChange: { multiline in - #if DEBUG - print("📐 onMultilineChange callback: \(multiline) (was \(isMultilineInput)) → radius will be \(multiline ? 16 : 21)") - #endif withAnimation(.easeInOut(duration: 0.2)) { isMultilineInput = multiline } @@ -1276,18 +1287,6 @@ private extension ChatDetailView { // MARK: - Forward func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) { - #if DEBUG - print("═══════════════════════════════════════════════") - print("📤 FORWARD START") - print("📤 Original message: id=\(message.id.prefix(16)), text='\(message.text.prefix(30))'") - print("📤 Original attachments (\(message.attachments.count)):") - for att in message.attachments { - print("📤 - type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))' blob=\(att.blob.isEmpty ? "(empty)" : "(\(att.blob.count) chars, starts: \(att.blob.prefix(30)))")") - } - print("📤 Attachment password: \(message.attachmentPassword?.prefix(20) ?? "nil")") - print("📤 Target: \(targetRoute.publicKey.prefix(16))") - #endif - // Android parity: unwrap nested forwards. // If the message being forwarded is itself a forward, extract the inner // forwarded messages and re-forward them directly (flatten). @@ -1296,43 +1295,15 @@ private extension ChatDetailView { let replyAttachment = message.attachments.first(where: { $0.type == .messages }) let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty - #if DEBUG - if let att = replyAttachment { - let blobParsed = parseReplyBlob(att.blob) - let previewParsed = parseReplyBlob(att.preview) - print("📤 Unwrap check: isForward=\(isForward)") - print("📤 blob parse: \(blobParsed == nil ? "FAILED" : "OK (\(blobParsed!.count) msgs, atts: \(blobParsed!.map { $0.attachments.count }))")") - print("📤 preview parse: \(previewParsed == nil ? "FAILED (preview='\(att.preview.prefix(20))')" : "OK (\(previewParsed!.count) msgs)")") - } - #endif - if isForward, let att = replyAttachment, let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)), !innerMessages.isEmpty { // Unwrap: forward the original messages, not the wrapper forwardDataList = innerMessages - #if DEBUG - print("📤 ✅ UNWRAP path: \(innerMessages.count) inner message(s)") - for (i, msg) in innerMessages.enumerated() { - print("📤 msg[\(i)]: publicKey=\(msg.publicKey.prefix(12)), text='\(msg.message.prefix(30))', attachments=\(msg.attachments.count)") - for att in msg.attachments { - print("📤 att: type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))'") - } - } - #endif } else { // Regular message — forward as-is forwardDataList = [buildReplyData(from: message)] - #if DEBUG - print("📤 ⚠️ BUILD_REPLY_DATA path (unwrap failed or not a forward)") - if let first = forwardDataList.first { - print("📤 result: publicKey=\(first.publicKey.prefix(12)), text='\(first.message.prefix(30))', attachments=\(first.attachments.count)") - for att in first.attachments { - print("📤 att: type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))'") - } - } - #endif } // Desktop commit aaa4b42: no re-upload needed. @@ -1343,14 +1314,6 @@ private extension ChatDetailView { let targetUsername = targetRoute.username Task { @MainActor in - #if DEBUG - print("📤 ── SEND SUMMARY ──") - print("📤 forwardDataList: \(forwardDataList.count) message(s)") - for (i, msg) in forwardDataList.enumerated() { - print("📤 msg[\(i)]: text='\(msg.message.prefix(20))' attachments=\(msg.attachments.count) chacha_key_plain=\(msg.chacha_key_plain.prefix(16))…") - } - #endif - do { try await SessionManager.shared.sendMessageWithReply( text: "", @@ -1359,15 +1322,7 @@ private extension ChatDetailView { opponentTitle: targetTitle, opponentUsername: targetUsername ) - #if DEBUG - print("📤 ✅ FORWARD SENT OK") - print("═══════════════════════════════════════════════") - #endif } catch { - #if DEBUG - print("📤 ❌ FORWARD FAILED: \(error)") - print("═══════════════════════════════════════════════") - #endif sendError = "Failed to forward message" } } @@ -1400,9 +1355,6 @@ private extension ChatDetailView { let msgAtt = message.attachments.first(where: { $0.type == .messages }), let innerMessages = parseReplyBlob(msgAtt.blob), let firstInner = innerMessages.first { - #if DEBUG - print("📤 buildReplyData: extracted inner message with \(firstInner.attachments.count) attachments") - #endif return firstInner } diff --git a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift index 8cc6082..56c1eb8 100644 --- a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift @@ -15,7 +15,6 @@ struct ForwardChatPickerView: View { } var body: some View { - let _ = print("[ForwardPicker] body — dialogs count: \(dialogs.count)") NavigationStack { List(dialogs) { dialog in Button { diff --git a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift index 0879365..87f2758 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift @@ -235,17 +235,11 @@ struct MessageAvatarView: View { let tag = attachment.effectiveDownloadTag guard !tag.isEmpty else { - #if DEBUG - print("🖼️ AVATAR FAIL: empty tag for attachment \(attachment.id), preview=\(attachment.preview.prefix(60)), transportTag=\(attachment.transportTag)") - #endif downloadError = true return } guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { - #if DEBUG - print("🖼️ AVATAR FAIL: nil/empty attachmentPassword for msgId=\(message.id.prefix(8))… attId=\(attachment.id)") - #endif downloadError = true return } @@ -254,23 +248,12 @@ struct MessageAvatarView: View { downloadError = false let server = attachment.transportServer - #if DEBUG - print("🖼️ AVATAR START: tag=\(tag) server=\(server) storedPwd=\(storedPassword.prefix(30))… attId=\(attachment.id)") - #endif Task { do { let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server) let encryptedString = String(decoding: encryptedData, as: UTF8.self) - #if DEBUG - let hasColon = encryptedString.contains(":") - print("🖼️ AVATAR DOWNLOADED: \(encryptedData.count) bytes, hasColon=\(hasColon), first50=\(encryptedString.prefix(50))") - #endif - let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) - #if DEBUG - print("🖼️ AVATAR CANDIDATES (\(passwords.count)): \(passwords.map { "\($0.prefix(20))…(\($0.count)ch)" })") - #endif let downloadedImage = decryptAndParseImage( encryptedString: encryptedString, passwords: passwords ) @@ -285,21 +268,12 @@ struct MessageAvatarView: View { let base64 = jpegData.base64EncodedString() AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey) } - #if DEBUG - print("🖼️ AVATAR DECRYPT OK: attId=\(attachment.id)") - #endif } else { - #if DEBUG - print("🖼️ AVATAR DECRYPT FAIL: all \(passwords.count) candidates failed for attId=\(attachment.id)") - #endif downloadError = true } isDownloading = false } } catch { - #if DEBUG - print("🖼️ AVATAR ERROR: \(error) for tag=\(tag)") - #endif await MainActor.run { downloadError = true isDownloading = false @@ -311,41 +285,25 @@ struct MessageAvatarView: View { /// Tries each password candidate and validates the decrypted content is a real image. private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? { let crypto = CryptoManager.shared - for (i, password) in passwords.enumerated() { + for password in passwords { do { let data = try crypto.decryptWithPassword( encryptedString, password: password, requireCompression: true ) - #if DEBUG - print("🖼️ PASS1 candidate[\(i)] decrypted \(data.count) bytes") - #endif if let img = parseImageData(data) { return img } - #if DEBUG - print("🖼️ PASS1 candidate[\(i)] parseImageData FAILED") - #endif } catch { - #if DEBUG - print("🖼️ PASS1 candidate[\(i)] FAILED: \(error)") - #endif + continue } } // Fallback: try without requireCompression (legacy uncompressed payloads) - for (i, password) in passwords.enumerated() { + for password in passwords { do { let data = try crypto.decryptWithPassword( encryptedString, password: password ) - #if DEBUG - print("🖼️ PASS2 candidate[\(i)] decrypted \(data.count) bytes, prefix: \(data.prefix(20).map { String(format: "%02x", $0) }.joined())") - #endif if let img = parseImageData(data) { return img } - #if DEBUG - print("🖼️ PASS2 candidate[\(i)] parseImageData FAILED") - #endif } catch { - #if DEBUG - print("🖼️ PASS2 candidate[\(i)] FAILED: \(error)") - #endif + continue } } return nil diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index bf70284..12a9037 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -125,7 +125,7 @@ struct MessageCellView: View, Equatable { items: contextMenuItems(for: message), previewShape: MessageBubbleShape(position: position, outgoing: outgoing), isOutgoing: outgoing, - replyQuoteHeight: replyData != nil ? 46 : 0, + replyQuoteHeight: replyData != nil ? 49 : 0, onReplyQuoteTap: replyData.map { reply in { [reply] in actions.onScrollToMessage(reply.message_id) } } @@ -154,21 +154,6 @@ struct MessageCellView: View, Equatable { let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty && !Self.isGarbageText(reply.message) - #if DEBUG - let _ = { - if reply.attachments.isEmpty { - print("⚠️ Forward bubble: reply has NO attachments. message_id=\(reply.message_id), text='\(reply.message.prefix(50))', publicKey=\(reply.publicKey.prefix(12))") - if let att = message.attachments.first(where: { $0.type == .messages }) { - let blobPrefix = att.blob.prefix(60) - let isEncrypted = att.blob.contains(":") && !att.blob.hasPrefix("[") - print("⚠️ raw .messages blob (\(att.blob.count) chars): '\(blobPrefix)...' encrypted=\(isEncrypted)") - } - } else { - print("📋 Forward bubble: message_id=\(reply.message_id.prefix(16)), \(reply.attachments.count) atts (images=\(imageAttachments.count), files=\(fileAttachments.count)), caption=\(hasCaption)") - } - }() - #endif - let fallbackText: String = { if hasCaption { return reply.message } if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" } @@ -561,7 +546,7 @@ struct MessageCellView: View, Equatable { if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" } return "Attachment" }() - let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue + let accentColor = outgoing ? Color.white : RosettaColors.figmaBlue let imageAttachment = reply.attachments.first(where: { $0.type == 0 }) let blurHash: String? = { guard let att = imageAttachment, !att.preview.isEmpty else { return nil } @@ -570,40 +555,37 @@ struct MessageCellView: View, Equatable { }() HStack(spacing: 0) { - RoundedRectangle(cornerRadius: 1.5) + RoundedRectangle(cornerRadius: 4) .fill(accentColor) .frame(width: 3) - .padding(.vertical, 4) if let att = imageAttachment { ReplyQuoteThumbnail(attachment: att, blurHash: blurHash) .padding(.leading, 6) } - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: 2) { Text(senderName) - .font(.system(size: 15, weight: .semibold)) - .tracking(-0.23) - .foregroundStyle(outgoing ? Color.white.opacity(0.85) : RosettaColors.figmaBlue) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue) .lineLimit(1) Text(previewText) - .font(.system(size: 15, weight: .regular)) - .tracking(-0.23) - .foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) .lineLimit(1) } - .padding(.leading, 6) + .padding(.leading, 8) Spacer(minLength: 0) } - .frame(height: 41) + .padding(.vertical, 3) .background( RoundedRectangle(cornerRadius: 4) - .fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06)) + .fill(outgoing ? Color.white.opacity(0.12) : RosettaColors.figmaBlue.opacity(0.12)) ) .padding(.horizontal, 5) .padding(.top, 5) - .padding(.bottom, 0) + .padding(.bottom, 2) } // MARK: - Forwarded File Preview diff --git a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift index 05dc360..d90e51b 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift @@ -253,17 +253,11 @@ struct MessageImageView: View { let tag = attachment.effectiveDownloadTag guard !tag.isEmpty else { - #if DEBUG - print("📸 DOWNLOAD FAIL: empty tag for attachment \(attachment.id), preview=\(attachment.preview.prefix(60)), transportTag=\(attachment.transportTag)") - #endif downloadError = true return } guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { - #if DEBUG - print("📸 DOWNLOAD FAIL: nil/empty attachmentPassword for msgId=\(message.id.prefix(8))… attId=\(attachment.id)") - #endif downloadError = true return } @@ -272,24 +266,12 @@ struct MessageImageView: View { downloadError = false let server = attachment.transportServer - #if DEBUG - print("📸 DOWNLOAD START: tag=\(tag) server=\(server) storedPwd=\(storedPassword.prefix(30))… attId=\(attachment.id)") - #endif Task { do { let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server) let encryptedString = String(decoding: encryptedData, as: UTF8.self) - #if DEBUG - let colonIdx = encryptedString.firstIndex(of: ":") - let hasColonSeparator = colonIdx != nil - print("📸 DOWNLOADED: \(encryptedData.count) bytes, hasColon=\(hasColonSeparator), first50=\(encryptedString.prefix(50))") - #endif - let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) - #if DEBUG - print("📸 CANDIDATES (\(passwords.count)): \(passwords.map { "\($0.prefix(20))…(\($0.count)ch)" })") - #endif let downloadedImage = decryptAndParseImage( encryptedString: encryptedString, passwords: passwords ) @@ -298,21 +280,12 @@ struct MessageImageView: View { if let downloadedImage { image = downloadedImage AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id) - #if DEBUG - print("📸 DECRYPT OK: attId=\(attachment.id) imageSize=\(downloadedImage.size)") - #endif } else { - #if DEBUG - print("📸 DECRYPT FAIL: all \(passwords.count) candidates failed for attId=\(attachment.id)") - #endif downloadError = true } isDownloading = false } } catch { - #if DEBUG - print("📸 DOWNLOAD ERROR: \(error) for tag=\(tag)") - #endif await MainActor.run { downloadError = true isDownloading = false @@ -323,40 +296,24 @@ struct MessageImageView: View { private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? { let crypto = CryptoManager.shared - for (i, password) in passwords.enumerated() { + for password in passwords { do { let data = try crypto.decryptWithPassword( encryptedString, password: password, requireCompression: true ) - #if DEBUG - print("📸 PASS1 candidate[\(i)] decrypted \(data.count) bytes") - #endif if let img = parseImageData(data) { return img } - #if DEBUG - print("📸 PASS1 candidate[\(i)] parseImageData FAILED (data prefix: \(data.prefix(30).map { String(format: "%02x", $0) }.joined()))") - #endif } catch { - #if DEBUG - print("📸 PASS1 candidate[\(i)] decrypt FAILED: \(error)") - #endif + continue } } - for (i, password) in passwords.enumerated() { + for password in passwords { do { let data = try crypto.decryptWithPassword( encryptedString, password: password ) - #if DEBUG - print("📸 PASS2 candidate[\(i)] decrypted \(data.count) bytes, prefix: \(data.prefix(20).map { String(format: "%02x", $0) }.joined())") - #endif if let img = parseImageData(data) { return img } - #if DEBUG - print("📸 PASS2 candidate[\(i)] parseImageData FAILED") - #endif } catch { - #if DEBUG - print("📸 PASS2 candidate[\(i)] decrypt FAILED: \(error)") - #endif + continue } } return nil diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index d8d9c91..9b25fb1 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -244,7 +244,9 @@ final class NativeMessageCell: UICollectionViewCell { bubbleView.addSubview(clockFrameView) bubbleView.addSubview(clockMinView) - // Reply quote + // Reply quote — Telegram parity: accent-tinted bg + 4pt radius bar + replyContainer.layer.cornerRadius = 4.0 + replyContainer.clipsToBounds = true replyBar.layer.cornerRadius = 4.0 replyContainer.addSubview(replyBar) replyNameLabel.font = Self.replyNameFont @@ -519,16 +521,17 @@ final class NativeMessageCell: UICollectionViewCell { // Bubble color (bubbleLayer is shadow-only; fill comes from bubbleImageView) photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor - // Reply quote + // Reply quote — Telegram parity colors if let replyName { replyContainer.isHidden = false + replyContainer.backgroundColor = isOutgoing + ? UIColor.white.withAlphaComponent(0.12) + : Self.outgoingColor.withAlphaComponent(0.12) replyBar.backgroundColor = isOutgoing ? .white : Self.outgoingColor replyNameLabel.text = replyName replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor replyTextLabel.text = replyText ?? "" - replyTextLabel.textColor = isOutgoing - ? UIColor.white.withAlphaComponent(0.8) - : UIColor.white.withAlphaComponent(0.6) + replyTextLabel.textColor = .white } else { replyContainer.isHidden = true } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index beeebfb..fe170d6 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -614,12 +614,9 @@ final class NativeMessageListController: UIViewController { // Build date for each visible cell, collect section ranges. var sectionMap: [String: (topY: CGFloat, bottomY: CGFloat)] = [:] - let calendar = Calendar.current - let now = Date() for cell in collectionView.visibleCells { - guard let nativeCell = cell as? NativeMessageCell, - let layout = nativeCell.currentLayout else { continue } + guard let nativeCell = cell as? NativeMessageCell else { continue } // Determine this cell's date text // Use the layout's dateHeaderText if available, else compute from message let cellFrame = collectionView.convert(cell.frame, to: view) @@ -696,16 +693,6 @@ final class NativeMessageListController: UIViewController { datePillPool[i].container.isHidden = true } - #if DEBUG - if !sections.isEmpty { - let desc = sections.enumerated().map { i, s in - let y = min(max(s.topY + 6, stickyY), s.bottomY - pillH) - return "\(s.text)@\(Int(y))[\(Int(s.topY))→\(Int(s.bottomY))]" - }.joined(separator: " | ") - print("📅 pills=\(usedPillCount) \(desc)") - } - #endif - // 3. Show/hide with timer. if usedPillCount > 0 { showDatePills() @@ -937,10 +924,6 @@ final class NativeMessageListController: UIViewController { textLayoutCache.removeAll() return } - #if DEBUG - let start = CFAbsoluteTimeGetCurrent() - #endif - let (layouts, textLayouts) = MessageCellLayout.batchCalculate( messages: messages, maxBubbleWidth: config.maxBubbleWidth, @@ -950,11 +933,6 @@ final class NativeMessageListController: UIViewController { ) layoutCache = layouts textLayoutCache = textLayouts - - #if DEBUG - let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000 - print("⚡ PERF_LAYOUT | \(messages.count) msgs | \(String(format: "%.1f", elapsed))ms | textLayouts cached: \(textLayouts.count)") - #endif } // MARK: - Inset Management @@ -1095,12 +1073,6 @@ final class NativeMessageListController: UIViewController { let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - #if DEBUG - let screenH = view.window?.screen.bounds.height ?? UIScreen.main.bounds.height - let kbH = max(0, screenH - endFrame.minY) - print("⌨️ keyboardWillChange | kbHeight=\(kbH) endFrame=\(endFrame) composerH=\(lastComposerHeight) currentInset=\(collectionView?.contentInset.top ?? 0)") - #endif - let screenHeight = view.window?.screen.bounds.height ?? UIScreen.main.bounds.height let keyboardHeight = max(0, screenHeight - endFrame.minY) let safeBottom = view.safeAreaInsets.bottom @@ -1173,9 +1145,6 @@ final class NativeMessageListController: UIViewController { } @objc private func keyboardDidHide() { - #if DEBUG - print("⌨️ didHide | cv.frame=\(collectionView?.frame ?? .zero) composer.frame=\(composerView?.frame ?? .zero) offset=\(collectionView?.contentOffset ?? .zero) composerConst=\(composerBottomConstraint?.constant ?? 0)") - #endif currentKeyboardHeight = 0 isKeyboardAnimating = false onKeyboardDidHide?() diff --git a/Rosetta/Features/Chats/Search/SearchView.swift b/Rosetta/Features/Chats/Search/SearchView.swift index a50f47b..7a402a7 100644 --- a/Rosetta/Features/Chats/Search/SearchView.swift +++ b/Rosetta/Features/Chats/Search/SearchView.swift @@ -8,12 +8,7 @@ struct SearchView: View { @State private var searchText = "" @State private var navigationPath: [ChatRoute] = [] - @MainActor static var _bodyCount = 0 var body: some View { - #if DEBUG - let _ = Self._bodyCount += 1 - let _ = print("🔵 SearchView.body #\(Self._bodyCount)") - #endif NavigationStack(path: $navigationPath) { ZStack(alignment: .bottom) { RosettaColors.Adaptive.background @@ -142,13 +137,8 @@ private extension SearchView { /// does NOT propagate to `SearchView`'s NavigationStack. private struct FavoriteContactsRow: View { @Binding var navigationPath: [ChatRoute] - @MainActor static var _bodyCount = 0 var body: some View { - #if DEBUG - let _ = Self._bodyCount += 1 - let _ = print("🟠 FavoriteContactsRow.body #\(Self._bodyCount)") - #endif let dialogs = DialogRepository.shared.sortedDialogs.prefix(10) if !dialogs.isEmpty { ScrollView(.horizontal, showsIndicators: false) { @@ -192,13 +182,7 @@ private struct FavoriteContactsRow: View { private struct RecentSection: View { @ObservedObject var viewModel: SearchViewModel @Binding var navigationPath: [ChatRoute] - @MainActor static var _bodyCount = 0 - var body: some View { - #if DEBUG - let _ = Self._bodyCount += 1 - let _ = print("🟤 RecentSection.body #\(Self._bodyCount)") - #endif if viewModel.recentSearches.isEmpty { emptyState } else { diff --git a/RosettaTests/ReadEligibilityTests.swift b/RosettaTests/ReadEligibilityTests.swift new file mode 100644 index 0000000..9215496 --- /dev/null +++ b/RosettaTests/ReadEligibilityTests.swift @@ -0,0 +1,304 @@ +import XCTest +@testable import Rosetta + +/// Tests for the read eligibility lifecycle fix: +/// readEligibleDialogs must be cleared on background entry to prevent +/// stale eligibility from marking messages as read during background sync +/// or premature foreground resume. +@MainActor +final class ReadEligibilityTests: XCTestCase { + private var ctx: DBTestContext! + private let peer = "02peer_read_test" + + override func setUpWithError() throws { + ctx = DBTestContext() + } + + override func tearDownWithError() throws { + // Clean up dialog state + MessageRepository.shared.setDialogActive(peer, isActive: false) + MessageRepository.shared.clearAllReadEligibility() + ctx.teardown() + ctx = nil + } + + // MARK: - clearAllReadEligibility + + func testClearAllReadEligibility_removesAllEntries() async throws { + try await ctx.bootstrap() + + // Set up two dialogs as active + read-eligible + let peer2 = "02peer_read_test_2" + MessageRepository.shared.setDialogActive(peer, isActive: true) + MessageRepository.shared.setDialogActive(peer2, isActive: true) + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + MessageRepository.shared.setDialogReadEligible(peer2, isEligible: true) + + XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer)) + XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer2)) + + // Clear all + MessageRepository.shared.clearAllReadEligibility() + + XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer)) + XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer2)) + + // Active state is preserved (only eligibility cleared) + XCTAssertTrue(MessageRepository.shared.isDialogActive(peer)) + XCTAssertTrue(MessageRepository.shared.isDialogActive(peer2)) + + // Cleanup + MessageRepository.shared.setDialogActive(peer2, isActive: false) + } + + // MARK: - Stale eligibility → messages inserted as read (the bug) + + func testStaleEligibility_marksIncomingAsReadOnInsert() async throws { + try await ctx.bootstrap() + + // Simulate: user opens chat, scrolls to bottom → read-eligible + MessageRepository.shared.setDialogActive(peer, isActive: true) + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + + // Simulate: message arrives while eligibility is stale (e.g., background sync) + try await ctx.runScenario(FixtureScenario(name: "stale read", events: [ + .incoming(opponent: peer, messageId: "stale-1", timestamp: 1000, text: "hello"), + ])) + + let snapshot = try ctx.normalizedSnapshot() + // BUG: message is marked read because readEligibleDialogs had stale entry + XCTAssertEqual(snapshot.messages.first?.read, true, + "With stale eligibility, incoming message is incorrectly marked as read") + XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 0, + "With stale eligibility, unread count is 0 (should be 1)") + } + + func testClearedEligibility_keepsIncomingAsUnread() async throws { + try await ctx.bootstrap() + + // Simulate: user opens chat, scrolls to bottom → read-eligible + MessageRepository.shared.setDialogActive(peer, isActive: true) + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + + // Simulate: app enters background → eligibility cleared (THE FIX) + MessageRepository.shared.clearAllReadEligibility() + + // Simulate: message arrives during background sync + try await ctx.runScenario(FixtureScenario(name: "cleared read", events: [ + .incoming(opponent: peer, messageId: "cleared-1", timestamp: 2000, text: "world"), + ])) + + let snapshot = try ctx.normalizedSnapshot() + // FIXED: message stays unread because eligibility was cleared + XCTAssertEqual(snapshot.messages.first?.read, false, + "After clearing eligibility, incoming message must stay unread") + XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 1, + "After clearing eligibility, unread count must be 1") + } + + // MARK: - Re-enabling eligibility after foreground resume + + func testReEnableEligibility_afterClear_marksAsRead() async throws { + try await ctx.bootstrap() + + // Simulate: user opens chat → active + eligible + MessageRepository.shared.setDialogActive(peer, isActive: true) + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + + // Background → clear + MessageRepository.shared.clearAllReadEligibility() + + // Message arrives while cleared → stays unread + try await ctx.runScenario(FixtureScenario(name: "pre-reenable", events: [ + .incoming(opponent: peer, messageId: "re-1", timestamp: 3000, text: "hey"), + ])) + + let snapshot1 = try ctx.normalizedSnapshot() + XCTAssertEqual(snapshot1.messages.first?.read, false) + XCTAssertEqual(snapshot1.dialogs.first?.unreadCount, 1) + + // Foreground → ChatDetailView re-enables eligibility + marks as read + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + MessageRepository.shared.markIncomingAsRead(opponentKey: peer, myPublicKey: ctx.account) + DialogRepository.shared.markAsRead(opponentKey: peer) + + let snapshot2 = try ctx.normalizedSnapshot() + XCTAssertEqual(snapshot2.messages.first?.read, true, + "After re-enabling eligibility and marking, message must be read") + XCTAssertEqual(snapshot2.dialogs.first?.unreadCount, 0) + } + + // MARK: - Notification tap scenario (Chat A open, tap notification for Chat B) + + func testNotificationTap_differentChat_doesNotMarkOriginalAsRead() async throws { + try await ctx.bootstrap() + + let chatA = peer + let chatB = "02peer_chat_b" + + // User is in Chat A, scrolled to bottom + MessageRepository.shared.setDialogActive(chatA, isActive: true) + MessageRepository.shared.setDialogReadEligible(chatA, isEligible: true) + + // App goes to background → eligibility cleared + MessageRepository.shared.clearAllReadEligibility() + + // Messages arrive for Chat A during background + try await ctx.runScenario(FixtureScenario(name: "chatA bg", events: [ + .incoming(opponent: chatA, messageId: "a-1", timestamp: 4000, text: "from A"), + ])) + + // Messages arrive for Chat B during background + try await ctx.runScenario(FixtureScenario(name: "chatB bg", events: [ + .incoming(opponent: chatB, messageId: "b-1", timestamp: 4001, text: "from B"), + ])) + + let snapshot = try ctx.normalizedSnapshot() + + // Chat A messages must be unread (user didn't view them) + let chatAMsg = snapshot.messages.first(where: { $0.messageId == "a-1" }) + XCTAssertEqual(chatAMsg?.read, false, "Chat A message must be unread after background") + + let chatADialog = snapshot.dialogs.first(where: { $0.opponentKey == chatA }) + XCTAssertEqual(chatADialog?.unreadCount, 1, "Chat A must have 1 unread") + + // Chat B messages must also be unread + let chatBMsg = snapshot.messages.first(where: { $0.messageId == "b-1" }) + XCTAssertEqual(chatBMsg?.read, false, "Chat B message must be unread") + + let chatBDialog = snapshot.dialogs.first(where: { $0.opponentKey == chatB }) + XCTAssertEqual(chatBDialog?.unreadCount, 1, "Chat B must have 1 unread") + + // Now simulate: user taps notification for Chat B → Chat B becomes active + eligible + MessageRepository.shared.setDialogActive(chatA, isActive: false) + MessageRepository.shared.setDialogActive(chatB, isActive: true) + MessageRepository.shared.setDialogReadEligible(chatB, isEligible: true) + MessageRepository.shared.markIncomingAsRead(opponentKey: chatB, myPublicKey: ctx.account) + DialogRepository.shared.markAsRead(opponentKey: chatB) + + let snapshot2 = try ctx.normalizedSnapshot() + + // Chat A still unread + let chatAMsg2 = snapshot2.messages.first(where: { $0.messageId == "a-1" }) + XCTAssertEqual(chatAMsg2?.read, false, "Chat A must STILL be unread after tapping Chat B notification") + + let chatADialog2 = snapshot2.dialogs.first(where: { $0.opponentKey == chatA }) + XCTAssertEqual(chatADialog2?.unreadCount, 1, "Chat A must STILL have 1 unread") + + // Chat B is now read + let chatBMsg2 = snapshot2.messages.first(where: { $0.messageId == "b-1" }) + XCTAssertEqual(chatBMsg2?.read, true, "Chat B message must be read after opening") + + let chatBDialog2 = snapshot2.dialogs.first(where: { $0.opponentKey == chatB }) + XCTAssertEqual(chatBDialog2?.unreadCount, 0, "Chat B must have 0 unread after opening") + + // Cleanup + MessageRepository.shared.setDialogActive(chatB, isActive: false) + } + + // MARK: - setDialogActive(false) also clears eligibility + + func testSetDialogActiveOff_clearsEligibility() async throws { + try await ctx.bootstrap() + + MessageRepository.shared.setDialogActive(peer, isActive: true) + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer)) + + MessageRepository.shared.setDialogActive(peer, isActive: false) + XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer)) + XCTAssertFalse(MessageRepository.shared.isDialogActive(peer)) + } + + // MARK: - Eligibility requires active dialog + + func testSetDialogReadEligible_requiresActiveDialog() async throws { + try await ctx.bootstrap() + + // Try to set eligible without active → should be ignored + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer), + "Eligibility must not be set for inactive dialogs") + + // Now activate and set eligible + MessageRepository.shared.setDialogActive(peer, isActive: true) + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer)) + } + + // MARK: - Multiple messages during background + + func testMultipleMessagesWhileBackgrounded_allStayUnread() async throws { + try await ctx.bootstrap() + + // User in chat, at bottom + MessageRepository.shared.setDialogActive(peer, isActive: true) + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + + // Background → clear + MessageRepository.shared.clearAllReadEligibility() + + // Multiple messages arrive during background + try await ctx.runScenario(FixtureScenario(name: "batch bg", events: [ + .incoming(opponent: peer, messageId: "batch-1", timestamp: 5000, text: "msg 1"), + .incoming(opponent: peer, messageId: "batch-2", timestamp: 5001, text: "msg 2"), + .incoming(opponent: peer, messageId: "batch-3", timestamp: 5002, text: "msg 3"), + ])) + + let snapshot = try ctx.normalizedSnapshot() + let messages = snapshot.messages.filter { $0.messageId.hasPrefix("batch-") } + + XCTAssertEqual(messages.count, 3) + for msg in messages { + XCTAssertEqual(msg.read, false, "Message \(msg.messageId) must be unread") + } + + let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer }) + XCTAssertEqual(dialog?.unreadCount, 3, "All 3 messages must be unread") + } + + // MARK: - Return to same chat after background + + func testReturnToSameChat_afterReEnable_marksAllRead() async throws { + try await ctx.bootstrap() + + MessageRepository.shared.setDialogActive(peer, isActive: true) + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + + // Background → clear + MessageRepository.shared.clearAllReadEligibility() + + // Messages during background + try await ctx.runScenario(FixtureScenario(name: "return same", events: [ + .incoming(opponent: peer, messageId: "ret-1", timestamp: 6000, text: "a"), + .incoming(opponent: peer, messageId: "ret-2", timestamp: 6001, text: "b"), + ])) + + // Foreground → user returns to same chat → re-enable eligibility + mark + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + MessageRepository.shared.markIncomingAsRead(opponentKey: peer, myPublicKey: ctx.account) + DialogRepository.shared.markAsRead(opponentKey: peer) + + let snapshot = try ctx.normalizedSnapshot() + let messages = snapshot.messages.filter { $0.messageId.hasPrefix("ret-") } + + for msg in messages { + XCTAssertEqual(msg.read, true, "Message \(msg.messageId) must be read after re-enable") + } + + let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer }) + XCTAssertEqual(dialog?.unreadCount, 0) + } + + // MARK: - clearAllReadEligibility is idempotent + + func testClearAllReadEligibility_idempotent() async throws { + try await ctx.bootstrap() + + // Call on empty set — no crash + MessageRepository.shared.clearAllReadEligibility() + MessageRepository.shared.clearAllReadEligibility() + + XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer)) + } +} diff --git a/tools/push-test/README.md b/tools/push-test/README.md new file mode 100644 index 0000000..b2aa73b --- /dev/null +++ b/tools/push-test/README.md @@ -0,0 +1,34 @@ +# Push Notification Test Payloads + +Simulate push notifications on iOS Simulator without a server. + +## Usage + +```bash +# Find booted simulator ID +xcrun simctl list devices booted + +# Send message push (triggers NSE — mutable-content) +xcrun simctl push booted com.rosetta.dev tools/push-test/message.apns + +# Send read push (triggers AppDelegate — content-available) +xcrun simctl push booted com.rosetta.dev tools/push-test/read.apns + +# Send call push +xcrun simctl push booted com.rosetta.dev tools/push-test/call.apns +``` + +## Testing the read eligibility fix + +1. Open a chat, scroll to bottom +2. Background the app (Cmd+Shift+H) +3. Send a message push: `xcrun simctl push booted com.rosetta.dev tools/push-test/message.apns` +4. Return to app +5. Verify: message should NOT be auto-marked as read + +## Notes + +- `mutable-content: 1` — NSE processes (badge, sound, mute filter) +- `content-available: 1` — AppDelegate `didReceiveRemoteNotification` fires +- NSE does NOT run on Simulator — only AppDelegate path is testable +- For full NSE testing, use a physical device with TestFlight diff --git a/tools/push-test/call.apns b/tools/push-test/call.apns new file mode 100644 index 0000000..efad3a4 --- /dev/null +++ b/tools/push-test/call.apns @@ -0,0 +1,7 @@ +{ + "aps": {}, + "Simulator Target Bundle": "com.rosetta.dev", + "type": "call", + "dialog": "02test_caller_key_xyz789", + "title": "Caller Name" +} diff --git a/tools/push-test/message.apns b/tools/push-test/message.apns new file mode 100644 index 0000000..f0c9571 --- /dev/null +++ b/tools/push-test/message.apns @@ -0,0 +1,10 @@ +{ + "aps": { + "sound": "default", + "mutable-content": 1 + }, + "Simulator Target Bundle": "com.rosetta.dev", + "type": "personal_message", + "dialog": "02test_sender_key_abc123", + "title": "Test User" +} diff --git a/tools/push-test/read.apns b/tools/push-test/read.apns new file mode 100644 index 0000000..6757c20 --- /dev/null +++ b/tools/push-test/read.apns @@ -0,0 +1,10 @@ +{ + "aps": { + "content-available": 1, + "sound": "default" + }, + "Simulator Target Bundle": "com.rosetta.dev", + "type": "read", + "dialog": "02test_sender_key_abc123", + "title": "Test User" +}