Реплай: исправлен отступ от бара до текста (6pt → 8pt, Telegram parity)
This commit is contained in:
@@ -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 = "";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
304
RosettaTests/ReadEligibilityTests.swift
Normal file
304
RosettaTests/ReadEligibilityTests.swift
Normal 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
34
tools/push-test/README.md
Normal 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
|
||||
7
tools/push-test/call.apns
Normal file
7
tools/push-test/call.apns
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"aps": {},
|
||||
"Simulator Target Bundle": "com.rosetta.dev",
|
||||
"type": "call",
|
||||
"dialog": "02test_caller_key_xyz789",
|
||||
"title": "Caller Name"
|
||||
}
|
||||
10
tools/push-test/message.apns
Normal file
10
tools/push-test/message.apns
Normal 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
10
tools/push-test/read.apns
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user