Реплай: исправлен отступ от бара до текста (6pt → 8pt, Telegram parity)
This commit is contained in:
@@ -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 = "";
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 сообщений. Клавиатура синхронизирована с контентом.
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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