Реплай: исправлен отступ от бара до текста (6pt → 8pt, Telegram parity)

This commit is contained in:
2026-03-31 14:51:00 +05:00
parent 876e541006
commit e5179b11ea
23 changed files with 450 additions and 348 deletions

View File

@@ -613,7 +613,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 28; CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -629,7 +629,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.7; MARKETING_VERSION = 1.2.8;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -653,7 +653,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 28; CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -669,7 +669,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.7; MARKETING_VERSION = 1.2.8;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -169,12 +169,6 @@ final class DialogRepository {
} }
} else if textIsEmpty { } else if textIsEmpty {
lastMessageText = "" 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 { } else {
lastMessageText = lastMsg.text lastMessageText = lastMsg.text
} }

View File

@@ -273,6 +273,13 @@ final class MessageRepository: ObservableObject {
readEligibleDialogs.contains(dialogKey) 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 // MARK: - Message Updates
func upsertFromMessagePacket( func upsertFromMessagePacket(
@@ -306,9 +313,6 @@ final class MessageRepository: ObservableObject {
// Android parity: encrypt plaintext with private key for local storage. // Android parity: encrypt plaintext with private key for local storage.
// Android: `encryptWithPassword(plainText, privateKey)` `plain_message` column. // Android: `encryptWithPassword(plainText, privateKey)` `plain_message` column.
// If encryption fails, store plaintext as fallback. // If encryption fails, store plaintext as fallback.
#if DEBUG
let encStart = CFAbsoluteTimeGetCurrent()
#endif
let storedText: String let storedText: String
if !privateKey.isEmpty, if !privateKey.isEmpty,
let enc = try? CryptoManager.shared.encryptWithPassword(Data(decryptedText.utf8), password: privateKey) { let enc = try? CryptoManager.shared.encryptWithPassword(Data(decryptedText.utf8), password: privateKey) {
@@ -316,12 +320,6 @@ final class MessageRepository: ObservableObject {
} else { } else {
storedText = decryptedText 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 encoder = JSONEncoder()
let attachmentsJSON: String let attachmentsJSON: String
@@ -356,11 +354,6 @@ final class MessageRepository: ObservableObject {
let finalAttachments = shouldPreserveAttachments ? existing.attachments : attachmentsJSON 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) // Update existing message (store encrypted text)
try db.execute( try db.execute(
@@ -876,13 +869,6 @@ final class MessageRepository: ObservableObject {
plainText = decrypted plainText = decrypted
} else { } else {
let fallback = Self.safePlainMessageFallback(record.text) 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 plainText = fallback
} }
} else { } else {

View File

@@ -287,7 +287,11 @@ extension MessageCellLayout {
// Content blocks above the text area // Content blocks above the text area
let mediaDimensions = Self.mediaDimensions(for: UIScreen.main.bounds.width) 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 var photoH: CGFloat = 0
let forwardHeaderH: CGFloat = config.isForward ? 40 : 0 let forwardHeaderH: CGFloat = config.isForward ? 40 : 0
var fileH: CGFloat = CGFloat(config.fileCount) * 52 var fileH: CGFloat = CGFloat(config.fileCount) * 52
@@ -530,10 +534,10 @@ extension MessageCellLayout {
) )
// Accessory frames (reply, photo, file, forward) // Accessory frames (reply, photo, file, forward)
let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: 41) let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: replyContainerH)
let replyBarFrame = CGRect(x: 0, y: 0, width: 3, height: 41) let replyBarFrame = CGRect(x: 0, y: 0, width: 3, height: replyContainerH)
let replyNameFrame = CGRect(x: 9, y: 2, width: bubbleW - 24, height: 17) let replyNameFrame = CGRect(x: 11, y: 3, width: bubbleW - 27, height: 17)
let replyTextFrame = CGRect(x: 9, y: 20, width: bubbleW - 24, 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 // Telegram: 2pt inset on all four sides between photo and bubble edge
let photoY: CGFloat = (config.hasReplyQuote ? replyH : 0) + 2 let photoY: CGFloat = (config.hasReplyQuote ? replyH : 0) + 2

View File

@@ -69,6 +69,7 @@ final class SessionManager {
// MARK: - Foreground Detection (Android parity) // MARK: - Foreground Detection (Android parity)
private var foregroundObserverToken: NSObjectProtocol? private var foregroundObserverToken: NSObjectProtocol?
private var backgroundObserverToken: NSObjectProtocol?
/// Android parity: 5s debounce between foreground sync requests. /// Android parity: 5s debounce between foreground sync requests.
private var lastForegroundSyncTime: TimeInterval = 0 private var lastForegroundSyncTime: TimeInterval = 0
@@ -1533,11 +1534,6 @@ final class SessionManager {
fromSync: effectiveFromSync, fromSync: effectiveFromSync,
dialogIdentityOverride: opponentKey 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) // Android parity 1:1: dialogDao.updateDialogFromMessages(account, opponentKey)
// Full recalculation of lastMessage, unread, iHaveSent, delivery from DB. // Full recalculation of lastMessage, unread, iHaveSent, delivery from DB.
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
@@ -2474,6 +2470,8 @@ final class SessionManager {
// Android parity (onResume line 428): clear ALL delivered notifications // Android parity (onResume line 428): clear ALL delivered notifications
UNUserNotificationCenter.current().removeAllDeliveredNotifications() UNUserNotificationCenter.current().removeAllDeliveredNotifications()
// Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog. // 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() self?.markActiveDialogsAsRead()
// Android parity: on foreground resume, always force reconnect. // Android parity: on foreground resume, always force reconnect.
@@ -2491,6 +2489,18 @@ final class SessionManager {
self?.syncOnForeground() 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 /// Android parity: `syncOnForeground()` request sync on foreground resume

View File

@@ -11,17 +11,14 @@ enum ReleaseNotes {
Entry( Entry(
version: appVersion, version: appVersion,
body: """ body: """
**Чат — анимации и навигация**
Telegram-style анимация появления новых сообщений (spring slide-up + alpha fade). Разделители дат со sticky-поведением и push-переходом между секциями. Reply-to-reply с подсветкой сообщения при навигации по реплаю.
**Звонки — минимизированная панель** **Звонки — минимизированная панель**
Telegram-style call-бар при активном звонке: зелёный градиент в статус-баре, навигационная панель сдвигается вниз (UIKit additionalSafeAreaInsets). Тап по панели — возврат на полный экран звонка. Telegram-style call-бар при активном звонке: зелёный градиент, навигационная панель сдвигается вниз. Тап по панели — возврат на полный экран звонка.
**Звонки — полный экран** **Стабильность**
Анимированный градиентный фон из цвета аватара собеседника. Свайп вниз для сворачивания. E2E-шифрование бейдж. Фикс скролла реплай-сообщений под композер при отправке. Блокировка восстановления клавиатуры при свайп-бэк. Скип read receipt для системных аккаунтов. Фикс race condition свайп-минимизации call bar. Пустой чат: glass-подложка и composer на iOS < 26.
**Контекстное меню**
Рефакторинг Telegram-style контекстного меню сообщений. Карточка действий с блюром и анимацией.
**Производительность**
Ячейки сообщений — Equatable-компонент, 120 FPS скролл. Пагинация по 50 сообщений. Клавиатура синхронизирована с контентом.
""" """
) )
] ]

View File

@@ -285,16 +285,8 @@ struct ChatTextInput: UIViewRepresentable {
// calculates the needed height for the text, independent of the current frame. // 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 fittingHeight = tv.sizeThatFits(CGSize(width: tv.bounds.width, height: .greatestFiniteMagnitude)).height
let isMultiline = fittingHeight > threshold 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 { if isMultiline != wasMultiline {
wasMultiline = isMultiline wasMultiline = isMultiline
#if DEBUG
print("📐 MULTILINE CHANGED → \(isMultiline)")
#endif
parent.onMultilineChange(isMultiline) parent.onMultilineChange(isMultiline)
} }
} }

View File

@@ -91,9 +91,6 @@ final class ComposerHostView: UIView {
/// When called inside UIView.animate creates CA animation in same transaction. /// When called inside UIView.animate creates CA animation in same transaction.
/// When called directly (KVO) immediate constraint update. /// When called directly (KVO) immediate constraint update.
func setKeyboardOffset(_ offset: CGFloat) { func setKeyboardOffset(_ offset: CGFloat) {
#if DEBUG
print("🎹 UIKit setKeyboardOffset(\(Int(offset))) — constraint=\(-Int(offset))")
#endif
bottomConstraint.constant = -offset bottomConstraint.constant = -offset
layoutIfNeeded() layoutIfNeeded()
} }

View File

@@ -132,12 +132,6 @@ final class KeyboardTracker: ObservableObject {
if #available(iOS 26, *) { return } if #available(iOS 26, *) { return }
PerformanceLogger.shared.track("keyboard.kvo") PerformanceLogger.shared.track("keyboard.kvo")
guard !isAnimating else { return } 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 { if keyboardHeight <= 0 {
// Flush any pending KVO value and stop coalescing // Flush any pending KVO value and stop coalescing
@@ -259,18 +253,12 @@ final class KeyboardTracker: ObservableObject {
let initialEased = cubicBezierEase(initialT) let initialEased = cubicBezierEase(initialT)
let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased
spacerPadding = max(keyboardPadding, round(initialValue)) spacerPadding = max(keyboardPadding, round(initialValue))
#if DEBUG
print("⌨️ 🎬 SHOW headstart: spacer=\(Int(spacerPadding)) target=\(Int(targetPadding))")
#endif
} else { } else {
// spacerPadding handled by animationTick via curve prediction // spacerPadding handled by animationTick via curve prediction
// Keep keyboardPadding headstart for iOS 26+ consumers // Keep keyboardPadding headstart for iOS 26+ consumers
let initialT = min(0.016 / max(duration, 0.05), 1.0) let initialT = min(0.016 / max(duration, 0.05), 1.0)
let initialEased = cubicBezierEase(initialT) let initialEased = cubicBezierEase(initialT)
let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased
#if DEBUG
print("⌨️ 🎬 HIDE headstart: kbPad \(Int(keyboardPadding))\(Int(round(initialValue)))")
#endif
keyboardPadding = round(initialValue) keyboardPadding = round(initialValue)
} }
@@ -278,16 +266,9 @@ final class KeyboardTracker: ObservableObject {
lastNotificationPadding = targetPadding lastNotificationPadding = targetPadding
PerformanceLogger.shared.track("keyboard.notification") 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 // Filter spurious notifications (delta=0, duration=0) keyboard frame
// didn't actually change. Happens on some iOS versions during text input. // didn't actually change. Happens on some iOS versions during text input.
guard abs(delta) > 1 || targetPadding != keyboardPadding else { guard abs(delta) > 1 || targetPadding != keyboardPadding else {
#if DEBUG
if delta != 0 { print("⌨️ ⏭️ SKIP spurious | delta=\(Int(delta))") }
#endif
return return
} }
@@ -377,10 +358,7 @@ final class KeyboardTracker: ObservableObject {
configureBezier(curveRaw: curveRaw) configureBezier(curveRaw: curveRaw)
// Primary: sync view matches keyboard's exact curve (same CA transaction). // Primary: sync view matches keyboard's exact curve (same CA transaction).
let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw) let _ = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
#if DEBUG
print("⌨️ 🎬 START | from=\(Int(keyboardPadding)) to=\(Int(target)) syncOK=\(syncOK) dur=\(String(format: "%.3f", duration))s")
#endif
// Reuse existing display link to preserve vsync phase alignment. // Reuse existing display link to preserve vsync phase alignment.
if let proxy = displayLinkProxy { if let proxy = displayLinkProxy {
@@ -437,11 +415,6 @@ final class KeyboardTracker: ObservableObject {
// non-monotonic values in the first 2 ticks while Core Animation commits // non-monotonic values in the first 2 ticks while Core Animation commits
// the animation to the render server. Without this guard, padding oscillates // the animation to the render server. Without this guard, padding oscillates
// (e.g. 312 306 310 302) causing a visible "jerk". // (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 { if eased < lastEased {
eased = lastEased eased = lastEased
} }
@@ -489,29 +462,14 @@ final class KeyboardTracker: ObservableObject {
let hardDeadline = elapsed >= animDuration + 0.25 let hardDeadline = elapsed >= animDuration + 0.25
let closeEnough = abs(animTargetPadding - rounded) <= 1 let closeEnough = abs(animTargetPadding - rounded) <= 1
if hardDeadline || animTickCount > 40 || (pastDuration && closeEnough) { if hardDeadline || animTickCount > 40 || (pastDuration && closeEnough) {
let prevPadding = keyboardPadding
keyboardPadding = max(0, animTargetPadding) keyboardPadding = max(0, animTargetPadding)
spacerPadding = max(0, animTargetPadding) spacerPadding = max(0, animTargetPadding)
// Pause instead of invalidate preserves vsync phase for next animation. // Pause instead of invalidate preserves vsync phase for next animation.
displayLinkProxy?.isPaused = true displayLinkProxy?.isPaused = true
lastTickTime = 0 lastTickTime = 0
isAnimatingKeyboard = false 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 { } 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 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 // Curve-predicted spacerPadding: predicts where the animation will be

View File

@@ -68,11 +68,6 @@ struct TelegramGlassRoundedRect: UIViewRepresentable {
} }
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) { 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.isFrozen = !context.environment.telegramGlassActive
uiView.fixedCornerRadius = cornerRadius uiView.fixedCornerRadius = cornerRadius
uiView.applyCornerRadius() uiView.applyCornerRadius()

View File

@@ -278,6 +278,20 @@ struct ChatDetailView: View {
// Desktop parity: save draft text on chat close. // Desktop parity: save draft text on chat close.
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText) 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 { var body: some View {
@@ -931,9 +945,6 @@ private extension ChatDetailView {
}, },
onUserTextInsertion: handleComposerUserTyping, onUserTextInsertion: handleComposerUserTyping,
onMultilineChange: { multiline in onMultilineChange: { multiline in
#if DEBUG
print("📐 onMultilineChange callback: \(multiline) (was \(isMultilineInput)) → radius will be \(multiline ? 16 : 21)")
#endif
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
isMultilineInput = multiline isMultilineInput = multiline
} }
@@ -1276,18 +1287,6 @@ private extension ChatDetailView {
// MARK: - Forward // MARK: - Forward
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) { 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. // Android parity: unwrap nested forwards.
// If the message being forwarded is itself a forward, extract the inner // If the message being forwarded is itself a forward, extract the inner
// forwarded messages and re-forward them directly (flatten). // 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 replyAttachment = message.attachments.first(where: { $0.type == .messages })
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty 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, if isForward,
let att = replyAttachment, let att = replyAttachment,
let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)), let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)),
!innerMessages.isEmpty { !innerMessages.isEmpty {
// Unwrap: forward the original messages, not the wrapper // Unwrap: forward the original messages, not the wrapper
forwardDataList = innerMessages 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 { } else {
// Regular message forward as-is // Regular message forward as-is
forwardDataList = [buildReplyData(from: message)] 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. // Desktop commit aaa4b42: no re-upload needed.
@@ -1343,14 +1314,6 @@ private extension ChatDetailView {
let targetUsername = targetRoute.username let targetUsername = targetRoute.username
Task { @MainActor in 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 { do {
try await SessionManager.shared.sendMessageWithReply( try await SessionManager.shared.sendMessageWithReply(
text: "", text: "",
@@ -1359,15 +1322,7 @@ private extension ChatDetailView {
opponentTitle: targetTitle, opponentTitle: targetTitle,
opponentUsername: targetUsername opponentUsername: targetUsername
) )
#if DEBUG
print("📤 ✅ FORWARD SENT OK")
print("═══════════════════════════════════════════════")
#endif
} catch { } catch {
#if DEBUG
print("📤 ❌ FORWARD FAILED: \(error)")
print("═══════════════════════════════════════════════")
#endif
sendError = "Failed to forward message" sendError = "Failed to forward message"
} }
} }
@@ -1400,9 +1355,6 @@ private extension ChatDetailView {
let msgAtt = message.attachments.first(where: { $0.type == .messages }), let msgAtt = message.attachments.first(where: { $0.type == .messages }),
let innerMessages = parseReplyBlob(msgAtt.blob), let innerMessages = parseReplyBlob(msgAtt.blob),
let firstInner = innerMessages.first { let firstInner = innerMessages.first {
#if DEBUG
print("📤 buildReplyData: extracted inner message with \(firstInner.attachments.count) attachments")
#endif
return firstInner return firstInner
} }

View File

@@ -15,7 +15,6 @@ struct ForwardChatPickerView: View {
} }
var body: some View { var body: some View {
let _ = print("[ForwardPicker] body — dialogs count: \(dialogs.count)")
NavigationStack { NavigationStack {
List(dialogs) { dialog in List(dialogs) { dialog in
Button { Button {

View File

@@ -235,17 +235,11 @@ struct MessageAvatarView: View {
let tag = attachment.effectiveDownloadTag let tag = attachment.effectiveDownloadTag
guard !tag.isEmpty else { 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 downloadError = true
return return
} }
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { 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 downloadError = true
return return
} }
@@ -254,23 +248,12 @@ struct MessageAvatarView: View {
downloadError = false downloadError = false
let server = attachment.transportServer let server = attachment.transportServer
#if DEBUG
print("🖼️ AVATAR START: tag=\(tag) server=\(server) storedPwd=\(storedPassword.prefix(30))… attId=\(attachment.id)")
#endif
Task { Task {
do { do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server) let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
let encryptedString = String(decoding: encryptedData, as: UTF8.self) 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) 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( let downloadedImage = decryptAndParseImage(
encryptedString: encryptedString, passwords: passwords encryptedString: encryptedString, passwords: passwords
) )
@@ -285,21 +268,12 @@ struct MessageAvatarView: View {
let base64 = jpegData.base64EncodedString() let base64 = jpegData.base64EncodedString()
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey) AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey)
} }
#if DEBUG
print("🖼️ AVATAR DECRYPT OK: attId=\(attachment.id)")
#endif
} else { } else {
#if DEBUG
print("🖼️ AVATAR DECRYPT FAIL: all \(passwords.count) candidates failed for attId=\(attachment.id)")
#endif
downloadError = true downloadError = true
} }
isDownloading = false isDownloading = false
} }
} catch { } catch {
#if DEBUG
print("🖼️ AVATAR ERROR: \(error) for tag=\(tag)")
#endif
await MainActor.run { await MainActor.run {
downloadError = true downloadError = true
isDownloading = false isDownloading = false
@@ -311,41 +285,25 @@ struct MessageAvatarView: View {
/// Tries each password candidate and validates the decrypted content is a real image. /// Tries each password candidate and validates the decrypted content is a real image.
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? { private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared let crypto = CryptoManager.shared
for (i, password) in passwords.enumerated() { for password in passwords {
do { do {
let data = try crypto.decryptWithPassword( let data = try crypto.decryptWithPassword(
encryptedString, password: password, requireCompression: true encryptedString, password: password, requireCompression: true
) )
#if DEBUG
print("🖼️ PASS1 candidate[\(i)] decrypted \(data.count) bytes")
#endif
if let img = parseImageData(data) { return img } if let img = parseImageData(data) { return img }
#if DEBUG
print("🖼️ PASS1 candidate[\(i)] parseImageData FAILED")
#endif
} catch { } catch {
#if DEBUG continue
print("🖼️ PASS1 candidate[\(i)] FAILED: \(error)")
#endif
} }
} }
// Fallback: try without requireCompression (legacy uncompressed payloads) // Fallback: try without requireCompression (legacy uncompressed payloads)
for (i, password) in passwords.enumerated() { for password in passwords {
do { do {
let data = try crypto.decryptWithPassword( let data = try crypto.decryptWithPassword(
encryptedString, password: password 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 let img = parseImageData(data) { return img }
#if DEBUG
print("🖼️ PASS2 candidate[\(i)] parseImageData FAILED")
#endif
} catch { } catch {
#if DEBUG continue
print("🖼️ PASS2 candidate[\(i)] FAILED: \(error)")
#endif
} }
} }
return nil return nil

View File

@@ -125,7 +125,7 @@ struct MessageCellView: View, Equatable {
items: contextMenuItems(for: message), items: contextMenuItems(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing), previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
isOutgoing: outgoing, isOutgoing: outgoing,
replyQuoteHeight: replyData != nil ? 46 : 0, replyQuoteHeight: replyData != nil ? 49 : 0,
onReplyQuoteTap: replyData.map { reply in onReplyQuoteTap: replyData.map { reply in
{ [reply] in actions.onScrollToMessage(reply.message_id) } { [reply] in actions.onScrollToMessage(reply.message_id) }
} }
@@ -154,21 +154,6 @@ struct MessageCellView: View, Equatable {
let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty
&& !Self.isGarbageText(reply.message) && !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 = { let fallbackText: String = {
if hasCaption { return reply.message } if hasCaption { return reply.message }
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" } 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" } if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" }
return "Attachment" 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 imageAttachment = reply.attachments.first(where: { $0.type == 0 })
let blurHash: String? = { let blurHash: String? = {
guard let att = imageAttachment, !att.preview.isEmpty else { return nil } guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
@@ -570,40 +555,37 @@ struct MessageCellView: View, Equatable {
}() }()
HStack(spacing: 0) { HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5) RoundedRectangle(cornerRadius: 4)
.fill(accentColor) .fill(accentColor)
.frame(width: 3) .frame(width: 3)
.padding(.vertical, 4)
if let att = imageAttachment { if let att = imageAttachment {
ReplyQuoteThumbnail(attachment: att, blurHash: blurHash) ReplyQuoteThumbnail(attachment: att, blurHash: blurHash)
.padding(.leading, 6) .padding(.leading, 6)
} }
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 2) {
Text(senderName) Text(senderName)
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.tracking(-0.23) .foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
.foregroundStyle(outgoing ? Color.white.opacity(0.85) : RosettaColors.figmaBlue)
.lineLimit(1) .lineLimit(1)
Text(previewText) Text(previewText)
.font(.system(size: 15, weight: .regular)) .font(.system(size: 14, weight: .regular))
.tracking(-0.23) .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
.lineLimit(1) .lineLimit(1)
} }
.padding(.leading, 6) .padding(.leading, 8)
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.frame(height: 41) .padding(.vertical, 3)
.background( .background(
RoundedRectangle(cornerRadius: 4) 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(.horizontal, 5)
.padding(.top, 5) .padding(.top, 5)
.padding(.bottom, 0) .padding(.bottom, 2)
} }
// MARK: - Forwarded File Preview // MARK: - Forwarded File Preview

View File

@@ -253,17 +253,11 @@ struct MessageImageView: View {
let tag = attachment.effectiveDownloadTag let tag = attachment.effectiveDownloadTag
guard !tag.isEmpty else { 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 downloadError = true
return return
} }
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { 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 downloadError = true
return return
} }
@@ -272,24 +266,12 @@ struct MessageImageView: View {
downloadError = false downloadError = false
let server = attachment.transportServer let server = attachment.transportServer
#if DEBUG
print("📸 DOWNLOAD START: tag=\(tag) server=\(server) storedPwd=\(storedPassword.prefix(30))… attId=\(attachment.id)")
#endif
Task { Task {
do { do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server) let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
let encryptedString = String(decoding: encryptedData, as: UTF8.self) 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) let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
#if DEBUG
print("📸 CANDIDATES (\(passwords.count)): \(passwords.map { "\($0.prefix(20))…(\($0.count)ch)" })")
#endif
let downloadedImage = decryptAndParseImage( let downloadedImage = decryptAndParseImage(
encryptedString: encryptedString, passwords: passwords encryptedString: encryptedString, passwords: passwords
) )
@@ -298,21 +280,12 @@ struct MessageImageView: View {
if let downloadedImage { if let downloadedImage {
image = downloadedImage image = downloadedImage
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id) AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
#if DEBUG
print("📸 DECRYPT OK: attId=\(attachment.id) imageSize=\(downloadedImage.size)")
#endif
} else { } else {
#if DEBUG
print("📸 DECRYPT FAIL: all \(passwords.count) candidates failed for attId=\(attachment.id)")
#endif
downloadError = true downloadError = true
} }
isDownloading = false isDownloading = false
} }
} catch { } catch {
#if DEBUG
print("📸 DOWNLOAD ERROR: \(error) for tag=\(tag)")
#endif
await MainActor.run { await MainActor.run {
downloadError = true downloadError = true
isDownloading = false isDownloading = false
@@ -323,40 +296,24 @@ struct MessageImageView: View {
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? { private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared let crypto = CryptoManager.shared
for (i, password) in passwords.enumerated() { for password in passwords {
do { do {
let data = try crypto.decryptWithPassword( let data = try crypto.decryptWithPassword(
encryptedString, password: password, requireCompression: true encryptedString, password: password, requireCompression: true
) )
#if DEBUG
print("📸 PASS1 candidate[\(i)] decrypted \(data.count) bytes")
#endif
if let img = parseImageData(data) { return img } 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 { } catch {
#if DEBUG continue
print("📸 PASS1 candidate[\(i)] decrypt FAILED: \(error)")
#endif
} }
} }
for (i, password) in passwords.enumerated() { for password in passwords {
do { do {
let data = try crypto.decryptWithPassword( let data = try crypto.decryptWithPassword(
encryptedString, password: password 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 let img = parseImageData(data) { return img }
#if DEBUG
print("📸 PASS2 candidate[\(i)] parseImageData FAILED")
#endif
} catch { } catch {
#if DEBUG continue
print("📸 PASS2 candidate[\(i)] decrypt FAILED: \(error)")
#endif
} }
} }
return nil return nil

View File

@@ -244,7 +244,9 @@ final class NativeMessageCell: UICollectionViewCell {
bubbleView.addSubview(clockFrameView) bubbleView.addSubview(clockFrameView)
bubbleView.addSubview(clockMinView) 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 replyBar.layer.cornerRadius = 4.0
replyContainer.addSubview(replyBar) replyContainer.addSubview(replyBar)
replyNameLabel.font = Self.replyNameFont replyNameLabel.font = Self.replyNameFont
@@ -519,16 +521,17 @@ final class NativeMessageCell: UICollectionViewCell {
// Bubble color (bubbleLayer is shadow-only; fill comes from bubbleImageView) // Bubble color (bubbleLayer is shadow-only; fill comes from bubbleImageView)
photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor
// Reply quote // Reply quote Telegram parity colors
if let replyName { if let replyName {
replyContainer.isHidden = false replyContainer.isHidden = false
replyContainer.backgroundColor = isOutgoing
? UIColor.white.withAlphaComponent(0.12)
: Self.outgoingColor.withAlphaComponent(0.12)
replyBar.backgroundColor = isOutgoing ? .white : Self.outgoingColor replyBar.backgroundColor = isOutgoing ? .white : Self.outgoingColor
replyNameLabel.text = replyName replyNameLabel.text = replyName
replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
replyTextLabel.text = replyText ?? "" replyTextLabel.text = replyText ?? ""
replyTextLabel.textColor = isOutgoing replyTextLabel.textColor = .white
? UIColor.white.withAlphaComponent(0.8)
: UIColor.white.withAlphaComponent(0.6)
} else { } else {
replyContainer.isHidden = true replyContainer.isHidden = true
} }

View File

@@ -614,12 +614,9 @@ final class NativeMessageListController: UIViewController {
// Build date for each visible cell, collect section ranges. // Build date for each visible cell, collect section ranges.
var sectionMap: [String: (topY: CGFloat, bottomY: CGFloat)] = [:] var sectionMap: [String: (topY: CGFloat, bottomY: CGFloat)] = [:]
let calendar = Calendar.current
let now = Date()
for cell in collectionView.visibleCells { for cell in collectionView.visibleCells {
guard let nativeCell = cell as? NativeMessageCell, guard let nativeCell = cell as? NativeMessageCell else { continue }
let layout = nativeCell.currentLayout else { continue }
// Determine this cell's date text // Determine this cell's date text
// Use the layout's dateHeaderText if available, else compute from message // Use the layout's dateHeaderText if available, else compute from message
let cellFrame = collectionView.convert(cell.frame, to: view) let cellFrame = collectionView.convert(cell.frame, to: view)
@@ -696,16 +693,6 @@ final class NativeMessageListController: UIViewController {
datePillPool[i].container.isHidden = true 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. // 3. Show/hide with timer.
if usedPillCount > 0 { if usedPillCount > 0 {
showDatePills() showDatePills()
@@ -937,10 +924,6 @@ final class NativeMessageListController: UIViewController {
textLayoutCache.removeAll() textLayoutCache.removeAll()
return return
} }
#if DEBUG
let start = CFAbsoluteTimeGetCurrent()
#endif
let (layouts, textLayouts) = MessageCellLayout.batchCalculate( let (layouts, textLayouts) = MessageCellLayout.batchCalculate(
messages: messages, messages: messages,
maxBubbleWidth: config.maxBubbleWidth, maxBubbleWidth: config.maxBubbleWidth,
@@ -950,11 +933,6 @@ final class NativeMessageListController: UIViewController {
) )
layoutCache = layouts layoutCache = layouts
textLayoutCache = textLayouts 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 // MARK: - Inset Management
@@ -1095,12 +1073,6 @@ final class NativeMessageListController: UIViewController {
let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return } 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 screenHeight = view.window?.screen.bounds.height ?? UIScreen.main.bounds.height
let keyboardHeight = max(0, screenHeight - endFrame.minY) let keyboardHeight = max(0, screenHeight - endFrame.minY)
let safeBottom = view.safeAreaInsets.bottom let safeBottom = view.safeAreaInsets.bottom
@@ -1173,9 +1145,6 @@ final class NativeMessageListController: UIViewController {
} }
@objc private func keyboardDidHide() { @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 currentKeyboardHeight = 0
isKeyboardAnimating = false isKeyboardAnimating = false
onKeyboardDidHide?() onKeyboardDidHide?()

View File

@@ -8,12 +8,7 @@ struct SearchView: View {
@State private var searchText = "" @State private var searchText = ""
@State private var navigationPath: [ChatRoute] = [] @State private var navigationPath: [ChatRoute] = []
@MainActor static var _bodyCount = 0
var body: some View { var body: some View {
#if DEBUG
let _ = Self._bodyCount += 1
let _ = print("🔵 SearchView.body #\(Self._bodyCount)")
#endif
NavigationStack(path: $navigationPath) { NavigationStack(path: $navigationPath) {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background RosettaColors.Adaptive.background
@@ -142,13 +137,8 @@ private extension SearchView {
/// does NOT propagate to `SearchView`'s NavigationStack. /// does NOT propagate to `SearchView`'s NavigationStack.
private struct FavoriteContactsRow: View { private struct FavoriteContactsRow: View {
@Binding var navigationPath: [ChatRoute] @Binding var navigationPath: [ChatRoute]
@MainActor static var _bodyCount = 0
var body: some View { var body: some View {
#if DEBUG
let _ = Self._bodyCount += 1
let _ = print("🟠 FavoriteContactsRow.body #\(Self._bodyCount)")
#endif
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10) let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
if !dialogs.isEmpty { if !dialogs.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
@@ -192,13 +182,7 @@ private struct FavoriteContactsRow: View {
private struct RecentSection: View { private struct RecentSection: View {
@ObservedObject var viewModel: SearchViewModel @ObservedObject var viewModel: SearchViewModel
@Binding var navigationPath: [ChatRoute] @Binding var navigationPath: [ChatRoute]
@MainActor static var _bodyCount = 0
var body: some View { var body: some View {
#if DEBUG
let _ = Self._bodyCount += 1
let _ = print("🟤 RecentSection.body #\(Self._bodyCount)")
#endif
if viewModel.recentSearches.isEmpty { if viewModel.recentSearches.isEmpty {
emptyState emptyState
} else { } else {

View File

@@ -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))
}
}

34
tools/push-test/README.md Normal file
View File

@@ -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

View File

@@ -0,0 +1,7 @@
{
"aps": {},
"Simulator Target Bundle": "com.rosetta.dev",
"type": "call",
"dialog": "02test_caller_key_xyz789",
"title": "Caller Name"
}

View File

@@ -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"
}

10
tools/push-test/read.apns Normal file
View File

@@ -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"
}