From e03e3685e7c3f44f9388ff8be4840a120637a60b Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sat, 28 Mar 2026 00:14:34 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20=D0=B2=D0=BE=D1=81?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B1=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BF=D1=83=D0=B7=D1=8B=D1=80=D1=8C=D0=BA=D0=B0=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D0=B8=D0=B7=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=20=D1=85=D0=B2=D0=BE=D1=81=D1=82=20/?= =?UTF-8?q?=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D0=B2=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repositories/MessageRepository.swift | 113 +++- Rosetta/Core/Layout/MessageCellLayout.swift | 460 ++++++++----- .../Network/Protocol/ProtocolManager.swift | 39 +- .../Network/Protocol/WebSocketClient.swift | 51 +- Rosetta/Core/Services/SessionManager.swift | 13 +- .../Chats/ChatDetail/BubbleTailShape.swift | 4 +- .../Chats/ChatDetail/ChatDetailView.swift | 111 +++- .../ChatDetail/ChatDetailViewModel.swift | 32 + .../Chats/ChatDetail/CoreTextLabel.swift | 273 ++++++++ .../Chats/ChatDetail/NativeMessageCell.swift | 624 ++++++++++++++++-- .../Chats/ChatDetail/NativeMessageList.swift | 50 +- .../ChatDetail/NativeTextBubbleCell.swift | 10 +- 12 files changed, 1490 insertions(+), 290 deletions(-) create mode 100644 Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index cc13844..253f683 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -13,6 +13,9 @@ final class MessageRepository: ObservableObject { @Published private(set) var typingDialogs: Set = [] private var activeDialogs: Set = [] + /// Dialogs that are currently eligible for interactive read: + /// screen is visible and list is at the bottom (Telegram-like behavior). + private var readEligibleDialogs: Set = [] private var typingResetTasks: [String: Task] = [:] private var currentAccount: String = "" @@ -77,15 +80,40 @@ final class MessageRepository: ObservableObject { } /// Load older messages for pagination (scroll-to-load-more). - func loadOlderMessages(for dialogKey: String, beforeTimestamp: Int64, limit: Int = 50) -> [ChatMessage] { + /// Uses a composite cursor `(timestamp, messageId)` to avoid gaps when multiple + /// messages share the same timestamp. + func loadOlderMessages( + for dialogKey: String, + beforeTimestamp: Int64, + beforeMessageId: String, + limit: Int = 50 + ) -> [ChatMessage] { let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey) do { let records = try db.read { db in - try MessageRecord + if beforeMessageId.isEmpty { + return try MessageRecord + .filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.dialogKey == dbDialogKey && + MessageRecord.Columns.timestamp < beforeTimestamp + ) + .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) + .limit(limit) + .fetchAll(db) + } + + return try MessageRecord .filter( MessageRecord.Columns.account == currentAccount && MessageRecord.Columns.dialogKey == dbDialogKey && - MessageRecord.Columns.timestamp < beforeTimestamp + ( + MessageRecord.Columns.timestamp < beforeTimestamp || + ( + MessageRecord.Columns.timestamp == beforeTimestamp && + MessageRecord.Columns.messageId < beforeMessageId + ) + ) ) .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) .limit(limit) @@ -140,6 +168,52 @@ final class MessageRepository: ObservableObject { } catch { return nil } } + /// Ensures a specific message exists in the in-memory cache for a dialog. + /// Returns `true` if the message was found in SQLite and is now available in cache. + @discardableResult + func ensureMessageLoaded(for dialogKey: String, messageId: String) -> Bool { + guard !currentAccount.isEmpty, !messageId.isEmpty else { return false } + if messagesByDialog[dialogKey]?.contains(where: { $0.id == messageId }) == true { + return true + } + + let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey) + do { + guard let record = try db.read({ db in + try MessageRecord + .filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.dialogKey == dbDialogKey && + MessageRecord.Columns.messageId == messageId + ) + .fetchOne(db) + }) else { + return false + } + + let hydrated = decryptRecord(record) + var cached = messagesByDialog[dialogKey] ?? loadMessagesFromDB(dialogKey: dialogKey, limit: Self.pageSize) + if !cached.contains(where: { $0.id == messageId }) { + cached.append(hydrated) + cached.sort { + if $0.timestamp == $1.timestamp { + return $0.id < $1.id + } + return $0.timestamp < $1.timestamp + } + if cached.count > Self.maxCacheSize { + cached = Array(cached.suffix(Self.maxCacheSize)) + } + messagesByDialog[dialogKey] = cached + } + + return true + } catch { + print("[DB] ensureMessageLoaded error: \(error)") + return false + } + } + func isLatestMessage(_ messageId: String, in dialogKey: String) -> Bool { messages(for: dialogKey).last?.id == messageId } @@ -175,12 +249,30 @@ final class MessageRepository: ObservableObject { activeDialogs.insert(dialogKey) } else { activeDialogs.remove(dialogKey) + readEligibleDialogs.remove(dialogKey) typingDialogs.remove(dialogKey) typingResetTasks[dialogKey]?.cancel() typingResetTasks[dialogKey] = nil } } + /// Sets whether a dialog may perform interactive read actions + /// (mark incoming as read + send read receipt). + func setDialogReadEligible(_ dialogKey: String, isEligible: Bool) { + guard !dialogKey.isEmpty else { return } + if isEligible { + // Eligibility only makes sense for active dialogs. + guard activeDialogs.contains(dialogKey) else { return } + readEligibleDialogs.insert(dialogKey) + } else { + readEligibleDialogs.remove(dialogKey) + } + } + + func isDialogReadEligible(_ dialogKey: String) -> Bool { + readEligibleDialogs.contains(dialogKey) + } + // MARK: - Message Updates func upsertFromMessagePacket( @@ -196,7 +288,9 @@ final class MessageRepository: ObservableObject { let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId let timestamp = normalizeTimestamp(packet.timestamp) let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey) - let incomingRead = !fromMe && activeDialogs.contains(opponentKey) + // Telegram-like read policy: incoming messages become read only when + // dialog is explicitly eligible (visible + scrolled to bottom). + let incomingRead = !fromMe && readEligibleDialogs.contains(opponentKey) let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered) // Add to LRU dedup cache @@ -205,6 +299,9 @@ 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) { @@ -212,6 +309,12 @@ 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 @@ -428,6 +531,7 @@ final class MessageRepository: ObservableObject { } messagesByDialog.removeValue(forKey: dialogKey) activeDialogs.remove(dialogKey) + readEligibleDialogs.remove(dialogKey) typingDialogs.remove(dialogKey) typingResetTasks[dialogKey]?.cancel() typingResetTasks[dialogKey] = nil @@ -624,6 +728,7 @@ final class MessageRepository: ObservableObject { messagesByDialog.removeAll() typingDialogs.removeAll() activeDialogs.removeAll() + readEligibleDialogs.removeAll() processedMessageIds.removeAll() pendingCacheRefresh.removeAll() cacheRefreshTask?.cancel() diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 73ebe30..0d3b291 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -14,6 +14,7 @@ struct MessageCellLayout: Sendable { // MARK: - Cell let totalHeight: CGFloat + let groupGap: CGFloat let isOutgoing: Bool let position: BubblePosition let messageType: MessageType @@ -33,7 +34,11 @@ struct MessageCellLayout: Sendable { // MARK: - Timestamp let timestampFrame: CGRect // Timestamp label frame in bubble coords - let checkmarkFrame: CGRect // Checkmark icon frame in bubble coords + let checkSentFrame: CGRect // Sent-check (✓) frame in bubble coords + let checkReadFrame: CGRect // Read-check (/) frame in bubble coords (overlaps sent for ✓✓) + let clockFrame: CGRect // Sending clock frame in bubble coords + let showsDeliveryFailedIndicator: Bool + let deliveryFailedInset: CGFloat // MARK: - Reply Quote (optional) @@ -81,6 +86,7 @@ extension MessageCellLayout { let maxBubbleWidth: CGFloat let isOutgoing: Bool let position: BubblePosition + let deliveryStatus: DeliveryStatus let text: String let hasReplyQuote: Bool let replyName: String? @@ -96,20 +102,26 @@ extension MessageCellLayout { /// Calculate complete cell layout on ANY thread. /// Uses CoreText for text measurement (thread-safe). - /// Returns layout with all frame rects ready for main-thread application. + /// Returns layout with all frame rects + cached CoreTextTextLayout for rendering. /// /// Telegram-style tight bubbles: timestamp goes inline with last text line /// when there's space, or on a new line when there isn't. - static func calculate(config: Config) -> MessageCellLayout { + static func calculate(config: Config) -> (layout: MessageCellLayout, textLayout: CoreTextTextLayout?) { let font = UIFont.systemFont(ofSize: 17, weight: .regular) - let tsFont = UIFont.systemFont(ofSize: 11, weight: .regular) + let tsFont = UIFont.systemFont(ofSize: floor(font.pointSize * 11.0 / 17.0), weight: .regular) + let screenScale = max(UIScreen.main.scale, 1) + let screenPixel = 1.0 / screenScale let hasTail = (config.position == .single || config.position == .bottom) let isTopOrSingle = (config.position == .single || config.position == .top) - let topPad: CGFloat = isTopOrSingle ? 6 : 2 - let tailW: CGFloat = hasTail ? 6 : 0 + // Keep a visible separator between grouped bubbles in native UIKit mode. + // A single-screen-pixel gap was too tight and visually merged into one blob. + let groupGap: CGFloat = isTopOrSingle ? (2 + screenPixel) : (1 + screenPixel) + let isOutgoingFailed = config.isOutgoing && config.deliveryStatus == .error + let deliveryFailedInset: CGFloat = isOutgoingFailed ? 24 : 0 + let effectiveMaxBubbleWidth = max(40, config.maxBubbleWidth - deliveryFailedInset) - // Determine message type + // Classify message type let messageType: MessageType if config.isForward { messageType = .forward @@ -124,163 +136,213 @@ extension MessageCellLayout { } else { messageType = .text } + let isTextMessage = (messageType == .text || messageType == .textWithReply) - // Status (timestamp + checkmark) measurement - let tsSize = measureText("00:00", maxWidth: 60, font: tsFont) - let checkW: CGFloat = config.isOutgoing ? 14 : 0 - let statusGap: CGFloat = 8 // minimum gap between trailing text and status - let statusWidth = tsSize.width + checkW + statusGap - - // Side padding inside bubble + // ── STEP 1: Asymmetric paddings + base text measurement (full width) ── + let topPad: CGFloat = 6 + screenPixel + let bottomPad: CGFloat = 6 - screenPixel let leftPad: CGFloat = 11 let rightPad: CGFloat = 11 - // Text measurement at FULL width (no timestamp reservation — Telegram pattern) - let fullTextMaxW = config.maxBubbleWidth - leftPad - rightPad - tailW - 4 - let isTextMessage = (messageType == .text || messageType == .textWithReply) + // maxTextWidth = effectiveMaxBubbleWidth - (leftPad + rightPad) + // Text is measured at the WIDEST possible constraint. + let maxTextWidth = effectiveMaxBubbleWidth - leftPad - rightPad + let textMeasurement: TextMeasurement + var cachedTextLayout: CoreTextTextLayout? if !config.text.isEmpty && isTextMessage { - textMeasurement = measureTextDetailed(config.text, maxWidth: max(fullTextMaxW, 50), font: font) + // CoreText (CTTypesetter) — returns per-line widths including lastLineWidth. + // Also captures CoreTextTextLayout for cell rendering (avoids double computation). + let (measurement, layout) = measureTextDetailedWithLayout(config.text, maxWidth: max(maxTextWidth, 50), font: font) + textMeasurement = measurement + cachedTextLayout = layout } else if !config.text.isEmpty { - // Photo captions, forwards, files — use old fixed-trailing approach - let tsTrailing: CGFloat = config.isOutgoing ? 53 : 37 - let textMaxW = config.maxBubbleWidth - leftPad - tsTrailing - tailW - 8 - let size = measureText(config.text, maxWidth: max(textMaxW, 50), font: font) + // Captions, forwards, files + let size = measureText(config.text, maxWidth: max(maxTextWidth, 50), font: font) textMeasurement = TextMeasurement(size: size, trailingLineWidth: size.width) } else { textMeasurement = TextMeasurement(size: .zero, trailingLineWidth: 0) } - // Determine if timestamp fits inline with last text line (Telegram algorithm) + // ── STEP 2: Meta-info dimensions ── + let tsSize = measureText("00:00", maxWidth: 60, font: tsFont) + let hasStatusIcon = config.isOutgoing && !isOutgoingFailed + let statusWidth: CGFloat = hasStatusIcon + ? floor(floor(font.pointSize * 13.0 / 17.0)) + : 0 + let checkW: CGFloat = statusWidth + // Telegram date/status lane keeps a wider visual gap before checks. + let timeGap: CGFloat = hasStatusIcon ? 5 : 0 + let statusGap: CGFloat = 2 + let metadataWidth = tsSize.width + timeGap + checkW + + // ── STEP 3: Inline vs Wrapped determination ── let timestampInline: Bool - let extraStatusH: CGFloat if isTextMessage && !config.text.isEmpty { - if textMeasurement.trailingLineWidth + statusWidth <= fullTextMaxW { - timestampInline = true - extraStatusH = 0 + let trailingWidthForStatus: CGFloat + if let cachedTextLayout { + if cachedTextLayout.lastLineHasRTL { + trailingWidthForStatus = 10_000 + } else if cachedTextLayout.lastLineHasBlockQuote { + trailingWidthForStatus = textMeasurement.size.width + } else { + trailingWidthForStatus = textMeasurement.trailingLineWidth + } } else { - timestampInline = false - extraStatusH = tsSize.height + 2 + trailingWidthForStatus = textMeasurement.trailingLineWidth } + timestampInline = trailingWidthForStatus + statusGap + metadataWidth <= maxTextWidth } else { - timestampInline = true // non-text messages: status overlays - extraStatusH = 0 + timestampInline = true } - // Reply quote - let replyH: CGFloat = config.hasReplyQuote ? 46 : 0 + // ── STEP 4: Bubble dimensions (unified width + height) ── - // Photo collage + // Content blocks above the text area + let replyH: CGFloat = config.hasReplyQuote ? 46 : 0 var photoH: CGFloat = 0 if config.imageCount > 0 { - photoH = Self.collageHeight(count: config.imageCount, width: config.maxBubbleWidth - 8) + photoH = Self.collageHeight(count: config.imageCount, width: effectiveMaxBubbleWidth - 8) } - - // Forward let forwardHeaderH: CGFloat = config.isForward ? 40 : 0 - - // File let fileH: CGFloat = CGFloat(config.fileCount) * 56 - // Bubble width — tight for text messages (Telegram pattern) - let minW: CGFloat = config.isOutgoing ? 86 : 66 + // Tiny floor just to prevent zero-width collapse. + // Telegram does NOT force a large minW — short messages get tight bubbles. + let minW: CGFloat = 40 + var bubbleW: CGFloat + var bubbleH: CGFloat = replyH + forwardHeaderH + photoH + fileH if config.imageCount > 0 { - // Photos: full width - bubbleW = config.maxBubbleWidth - tailW - 4 + // Photo: full width + bubbleW = effectiveMaxBubbleWidth + if !config.text.isEmpty { + bubbleH += topPad + textMeasurement.size.height + bottomPad + if photoH > 0 { bubbleH += 6 } + } } else if isTextMessage && !config.text.isEmpty { - // Tight bubble: just fits content + inline/new-line status - let contentW: CGFloat + // ── EXACT TELEGRAM MATH — no other modifiers ── + let actualTextW = textMeasurement.size.width + let lastLineW = textMeasurement.trailingLineWidth + + let finalContentW: CGFloat if timestampInline { - contentW = max(textMeasurement.size.width, - textMeasurement.trailingLineWidth + statusWidth) + // INLINE: width = max(widest line, last line + gap + status) + finalContentW = max(actualTextW, lastLineW + statusGap + metadataWidth) + bubbleH += topPad + textMeasurement.size.height + bottomPad } else { - contentW = max(textMeasurement.size.width, statusWidth) + // WRAPPED: status drops to new line below text + finalContentW = max(actualTextW, metadataWidth) + bubbleH += topPad + textMeasurement.size.height + 15 + bottomPad } - bubbleW = min(contentW + leftPad + rightPad, config.maxBubbleWidth - tailW - 4) - // Reply quote needs minimum width - if config.hasReplyQuote { - bubbleW = max(bubbleW, 180) - } - } else { - // Fallback for non-text: old approach - let tsTrailing: CGFloat = config.isOutgoing ? 53 : 37 - let bubbleContentW = leftPad + textMeasurement.size.width + tsTrailing - bubbleW = min(bubbleContentW, config.maxBubbleWidth - tailW - 4) - } - bubbleW = max(bubbleW, minW) - // Bubble height - var bubbleH: CGFloat = 0 - bubbleH += replyH - bubbleH += forwardHeaderH - bubbleH += photoH - bubbleH += fileH - if !config.text.isEmpty { - bubbleH += textMeasurement.size.height + 10 // 5pt top + 5pt bottom - bubbleH += extraStatusH // 0 if inline, ~15pt if new line - if photoH > 0 { bubbleH += 6 } // caption padding + // Set bubble width TIGHTLY: leftPad + content + rightPad + bubbleW = leftPad + finalContentW + rightPad + bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth)) + if config.hasReplyQuote { bubbleW = max(bubbleW, 180) } + } else if !config.text.isEmpty { + // Non-text with caption (file, forward) + let finalContentW = max(textMeasurement.size.width, metadataWidth) + bubbleW = leftPad + finalContentW + rightPad + bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth)) + bubbleH += topPad + textMeasurement.size.height + bottomPad + } else { + // No text (forward header only, empty) + bubbleW = leftPad + metadataWidth + rightPad + bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth)) } + if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward { - bubbleH = max(bubbleH, 36) // minimum + bubbleH = max(bubbleH, 35) } - // Total height - let totalH = topPad + bubbleH + (hasTail ? 6 : 0) + let totalH = groupGap + bubbleH - // Bubble frame (X computed from cell width in layoutSubviews, this is approximate) - let bubbleX: CGFloat - if config.isOutgoing { - bubbleX = config.maxBubbleWidth - bubbleW - tailW + 10 - 2 - } else { - bubbleX = tailW + 10 + 2 - } - let bubbleFrame = CGRect(x: bubbleX, y: topPad, width: bubbleW, height: bubbleH) + // Bubble X (approximate — overridden in layoutSubviews with actual cellWidth) + let bubbleX: CGFloat = config.isOutgoing ? effectiveMaxBubbleWidth - bubbleW : 8 + let bubbleFrame = CGRect(x: bubbleX, y: groupGap, width: bubbleW, height: bubbleH) - // Text frame (in bubble coords) - var textY: CGFloat = 5 - if config.hasReplyQuote { textY = replyH } - if forwardHeaderH > 0 { textY = forwardHeaderH } + // ── STEP 5: Geometry assignment ── + + // Text frame — MUST fill bubbleW - leftPad - rightPad (the content area), + // NOT textMeasurement.size.width. Using the measured width causes UILabel to + // re-wrap at a narrower constraint than CoreText measured, producing different + // line breaks ("jagged first line"). The content area is always ≥ measured width. + var textY: CGFloat = topPad + if config.hasReplyQuote { textY = replyH + topPad } + if forwardHeaderH > 0 { textY = forwardHeaderH + topPad } if photoH > 0 { - textY = photoH + 6 - if config.hasReplyQuote { textY = replyH + photoH + 6 } + textY = photoH + 6 + topPad + if config.hasReplyQuote { textY = replyH + photoH + 6 + topPad } } - if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) } + if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) + topPad } + let textFrame = CGRect(x: leftPad, y: textY, - width: textMeasurement.size.width, height: textMeasurement.size.height) + width: bubbleW - leftPad - rightPad, + height: textMeasurement.size.height) - // Timestamp + checkmark frames (always bottom-right of bubble) - let tsFrame = CGRect( - x: bubbleW - tsSize.width - checkW - rightPad, - y: bubbleH - tsSize.height - 5, - width: tsSize.width, height: tsSize.height - ) - let checkFrame = CGRect( - x: bubbleW - rightPad - 10, - y: bubbleH - tsSize.height - 4, - width: 10, height: 10 - ) + // Metadata frames: + // checkFrame.maxX = bubbleW - rightPad (inset from bubble edge, NOT glued) + // tsFrame.maxX = checkFrame.minX - timeGap + // checkFrame.minX = bubbleW - rightPad - checkW + let statusEndX = bubbleW - rightPad + let statusEndY = bubbleH - bottomPad - // Reply frames + let tsFrame: CGRect + if config.isOutgoing { + // [timestamp][timeGap][checkW] anchored right at statusEndX + tsFrame = CGRect( + x: statusEndX - checkW - timeGap - tsSize.width, + y: statusEndY - tsSize.height, + width: tsSize.width, height: tsSize.height + ) + } else { + // Incoming: [timestamp] anchored right at statusEndX + tsFrame = CGRect( + x: statusEndX - tsSize.width, + y: statusEndY - tsSize.height, + width: tsSize.width, height: tsSize.height + ) + } + + let checkSentFrame: CGRect + let checkReadFrame: CGRect + let clockFrame: CGRect + if hasStatusIcon { + let checkImgW: CGFloat = floor(floor(font.pointSize * 11.0 / 17.0)) + let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0) + let checkOffset: CGFloat = floor(font.pointSize * 6.0 / 17.0) + let checkReadX = statusEndX - checkImgW + let checkSentX = checkReadX - checkOffset + let checkY = tsFrame.minY + (3 - screenPixel) + checkSentFrame = CGRect(x: checkSentX, y: checkY, width: checkImgW, height: checkImgH) + checkReadFrame = CGRect(x: checkReadX, y: checkY, width: checkImgW, height: checkImgH) + // Telegram DateAndStatusNode: + // clock origin X = dateFrame.maxX + 3.0, center Y aligned with checks. + clockFrame = CGRect(x: tsFrame.maxX + 3.0, y: checkY - 1.0, width: 11, height: 11) + } else { + checkSentFrame = .zero + checkReadFrame = .zero + clockFrame = .zero + } + + // 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) - // Photo frame let photoFrame = CGRect(x: 2, y: config.hasReplyQuote ? replyH : 0, width: bubbleW - 4, height: photoH) - - // File frame let fileFrame = CGRect(x: 0, y: config.hasReplyQuote ? replyH : 0, width: bubbleW, height: fileH) - // Forward frames let fwdHeaderFrame = CGRect(x: 10, y: 6, width: bubbleW - 20, height: 14) let fwdAvatarFrame = CGRect(x: 10, y: 23, width: 20, height: 20) let fwdNameFrame = CGRect(x: 34, y: 24, width: bubbleW - 44, height: 17) - return MessageCellLayout( + let layout = MessageCellLayout( totalHeight: totalH, + groupGap: groupGap, isOutgoing: config.isOutgoing, position: config.position, messageType: messageType, @@ -291,7 +353,11 @@ extension MessageCellLayout { textSize: textMeasurement.size, timestampInline: timestampInline, timestampFrame: tsFrame, - checkmarkFrame: checkFrame, + checkSentFrame: checkSentFrame, + checkReadFrame: checkReadFrame, + clockFrame: clockFrame, + showsDeliveryFailedIndicator: isOutgoingFailed, + deliveryFailedInset: deliveryFailedInset, hasReplyQuote: config.hasReplyQuote, replyContainerFrame: replyContainerFrame, replyBarFrame: replyBarFrame, @@ -307,6 +373,7 @@ extension MessageCellLayout { forwardAvatarFrame: fwdAvatarFrame, forwardNameFrame: fwdNameFrame ) + return (layout, cachedTextLayout) } // MARK: - Collage Height (Thread-Safe) @@ -355,56 +422,20 @@ extension MessageCellLayout { let trailingLineWidth: CGFloat // Width of the LAST line only } - /// CoreText detailed text measurement — returns both overall size and trailing line width. - /// Uses CTFramesetter + CTFrame (thread-safe) for per-line width analysis. - /// This enables Telegram-style inline timestamp positioning. - private static func measureTextDetailed( + /// Telegram-exact text measurement using CTTypesetter + manual line breaking. + /// Returns BOTH measurement AND the full CoreTextTextLayout for cell rendering cache. + /// This eliminates the double CoreText computation (measure + render). + private static func measureTextDetailedWithLayout( _ text: String, maxWidth: CGFloat, font: UIFont - ) -> TextMeasurement { - guard !text.isEmpty else { - return TextMeasurement(size: .zero, trailingLineWidth: 0) - } - - let attrs: [NSAttributedString.Key: Any] = [.font: font] - let attrStr = CFAttributedStringCreate( - nil, text as CFString, - attrs as CFDictionary - )! - let framesetter = CTFramesetterCreateWithAttributedString(attrStr) - - // Create frame for text layout - let path = CGPath( - rect: CGRect(x: 0, y: 0, width: maxWidth, height: CGFloat.greatestFiniteMagnitude), - transform: nil + ) -> (TextMeasurement, CoreTextTextLayout) { + let layout = CoreTextTextLayout.calculate( + text: text, maxWidth: maxWidth, font: font, textColor: .white ) - let frame = CTFramesetterCreateFrame( - framesetter, CFRange(location: 0, length: 0), path, nil - ) - - let lines = CTFrameGetLines(frame) as! [CTLine] - guard !lines.isEmpty else { - return TextMeasurement(size: .zero, trailingLineWidth: 0) - } - - // Get max line width and last line width - var maxLineWidth: CGFloat = 0 - for line in lines { - let lineWidth = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) - maxLineWidth = max(maxLineWidth, lineWidth) - } - - let lastLineWidth = CGFloat(CTLineGetTypographicBounds(lines.last!, nil, nil, nil)) - - // Use framesetter for accurate total height - let suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints( - framesetter, CFRange(location: 0, length: 0), nil, - CGSize(width: maxWidth, height: .greatestFiniteMagnitude), nil - ) - - return TextMeasurement( - size: CGSize(width: ceil(maxLineWidth), height: ceil(suggestedSize.height)), - trailingLineWidth: ceil(lastLineWidth) + let measurement = TextMeasurement( + size: layout.size, + trailingLineWidth: layout.lastLineWidth ) + return (measurement, layout) } // MARK: - Garbage Text Detection (Thread-Safe) @@ -435,6 +466,80 @@ extension MessageCellLayout { return false } + + // MARK: - Bubble Grouping (Telegram-like) + + private enum BubbleGroupingKind { + case text + case media + case file + case forward + } + + /// Conservative grouping window to keep groups visually close to Telegram behavior. + /// Messages far apart in time should split into separate bubble groups. + private static let mergeTimeWindowMs: Int64 = 10 * 60 * 1000 + + private static func groupingKind(for message: ChatMessage, displayText: String) -> BubbleGroupingKind { + let hasImage = message.attachments.contains { $0.type == .image } + if hasImage { + return .media + } + let hasFileLike = message.attachments.contains { $0.type == .file || $0.type == .avatar } + if hasFileLike { + return .file + } + let hasReplyAttachment = message.attachments.contains { $0.type == .messages } + if hasReplyAttachment && displayText.isEmpty { + return .forward + } + return .text + } + + private static func isFromMe(_ message: ChatMessage, currentPublicKey: String) -> Bool { + message.fromPublicKey == currentPublicKey + } + + private static func timestampDeltaMs(_ lhs: Int64, _ rhs: Int64) -> Int64 { + lhs >= rhs ? (lhs - rhs) : (rhs - lhs) + } + + private static func shouldMerge( + current message: ChatMessage, + currentDisplayText: String, + with neighbor: ChatMessage, + neighborDisplayText: String, + currentPublicKey: String + ) -> Bool { + // Telegram-like: only same direction (incoming with incoming / outgoing with outgoing) + guard isFromMe(message, currentPublicKey: currentPublicKey) == isFromMe(neighbor, currentPublicKey: currentPublicKey) else { + return false + } + + // Keep failed messages visually isolated (external failed indicator behavior). + if message.deliveryStatus == .error || neighbor.deliveryStatus == .error { + return false + } + + // Long gaps should split groups. + if timestampDeltaMs(message.timestamp, neighbor.timestamp) >= mergeTimeWindowMs { + return false + } + + let currentKind = groupingKind(for: message, displayText: currentDisplayText) + let neighborKind = groupingKind(for: neighbor, displayText: neighborDisplayText) + guard currentKind == neighborKind else { + return false + } + + // Telegram-like grouping by semantic kind (except forwarded-empty blocks). + switch currentKind { + case .text, .media, .file: + return true + case .forward: + return false + } + } } // MARK: - Batch Calculation (Background Thread) @@ -442,6 +547,7 @@ extension MessageCellLayout { extension MessageCellLayout { /// Pre-calculate layouts for all messages on background queue. + /// Returns both frame layouts AND cached CoreTextTextLayouts for cell rendering. /// Telegram equivalent: ListView calls asyncLayout() on background. static func batchCalculate( messages: [ChatMessage], @@ -449,18 +555,42 @@ extension MessageCellLayout { currentPublicKey: String, opponentPublicKey: String, opponentTitle: String - ) -> [String: MessageCellLayout] { + ) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) { var result: [String: MessageCellLayout] = [:] + var textResult: [String: CoreTextTextLayout] = [:] for (index, message) in messages.enumerated() { let isOutgoing = message.fromPublicKey == currentPublicKey - // Calculate position + // Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView) + let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text + + // Calculate position (Telegram-like grouping rules) let position: BubblePosition = { - let hasPrev = index > 0 && - (messages[index - 1].fromPublicKey == currentPublicKey) == isOutgoing - let hasNext = index + 1 < messages.count && - (messages[index + 1].fromPublicKey == currentPublicKey) == isOutgoing + let hasPrev: Bool = { + guard index > 0 else { return false } + let prev = messages[index - 1] + let prevDisplayText = isGarbageOrEncrypted(prev.text) ? "" : prev.text + return shouldMerge( + current: message, + currentDisplayText: displayText, + with: prev, + neighborDisplayText: prevDisplayText, + currentPublicKey: currentPublicKey + ) + }() + let hasNext: Bool = { + guard index + 1 < messages.count else { return false } + let next = messages[index + 1] + let nextDisplayText = isGarbageOrEncrypted(next.text) ? "" : next.text + return shouldMerge( + current: message, + currentDisplayText: displayText, + with: next, + neighborDisplayText: nextDisplayText, + currentPublicKey: currentPublicKey + ) + }() switch (hasPrev, hasNext) { case (false, false): return .single case (false, true): return .top @@ -469,9 +599,6 @@ extension MessageCellLayout { } }() - // Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView) - let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text - // Classify let images = message.attachments.filter { $0.type == .image } let files = message.attachments.filter { $0.type == .file } @@ -483,6 +610,7 @@ extension MessageCellLayout { maxBubbleWidth: maxBubbleWidth, isOutgoing: isOutgoing, position: position, + deliveryStatus: message.deliveryStatus, text: displayText, hasReplyQuote: hasReply && !displayText.isEmpty, replyName: nil, @@ -496,9 +624,11 @@ extension MessageCellLayout { forwardCaption: nil ) - result[message.id] = calculate(config: config) + let (layout, textLayout) = calculate(config: config) + result[message.id] = layout + if let textLayout { textResult[message.id] = textLayout } } - return result + return (result, textResult) } } diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 1a14834..e549915 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -95,12 +95,31 @@ final class ProtocolManager: @unchecked Sendable { /// Connect to server and perform handshake. func connect(publicKey: String, privateKeyHash: String) { + let switchingAccount = savedPublicKey != nil && savedPublicKey != publicKey + if switchingAccount { + Self.logger.info("Account switch detected — resetting protocol session before reconnect") + disconnect() + } + savedPublicKey = publicKey savedPrivateHash = privateKeyHash - if connectionState == .authenticated || connectionState == .handshaking { + switch connectionState { + case .authenticated, .handshaking, .deviceVerificationRequired: Self.logger.info("Already connected/handshaking, skipping") return + case .connected: + if client.isConnected { + Self.logger.info("Socket already connected, skipping duplicate connect()") + return + } + case .connecting: + if client.isConnecting { + Self.logger.info("Connect already in progress, skipping duplicate connect()") + return + } + case .disconnected: + break } connectionState = .connecting @@ -110,11 +129,20 @@ final class ProtocolManager: @unchecked Sendable { func disconnect() { Self.logger.info("Disconnecting") heartbeatTask?.cancel() + heartbeatTask = nil handshakeTimeoutTask?.cancel() + handshakeTimeoutTask = nil pingTimeoutTask?.cancel() pingTimeoutTask = nil pingVerificationInProgress = false handshakeComplete = false + clearPacketQueue() + clearResultHandlers() + syncBatchLock.lock() + _syncBatchActive = false + syncBatchLock.unlock() + pendingDeviceVerification = nil + devices = [] client.disconnect() connectionState = .disconnected savedPublicKey = nil @@ -305,6 +333,9 @@ final class ProtocolManager: @unchecked Sendable { Self.logger.error("Disconnected: \(error.localizedDescription)") } heartbeatTask?.cancel() + heartbeatTask = nil + handshakeTimeoutTask?.cancel() + handshakeTimeoutTask = nil handshakeComplete = false pingVerificationInProgress = false pingTimeoutTask?.cancel() @@ -650,6 +681,12 @@ final class ProtocolManager: @unchecked Sendable { packetQueueLock.unlock() } + private func clearResultHandlers() { + resultHandlersLock.lock() + resultHandlers.removeAll() + resultHandlersLock.unlock() + } + // MARK: - Device Verification private func handleDeviceList(_ packet: PacketDeviceList) { diff --git a/Rosetta/Core/Network/Protocol/WebSocketClient.swift b/Rosetta/Core/Network/Protocol/WebSocketClient.swift index 3d172eb..b299588 100644 --- a/Rosetta/Core/Network/Protocol/WebSocketClient.swift +++ b/Rosetta/Core/Network/Protocol/WebSocketClient.swift @@ -62,6 +62,17 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD // MARK: - Connection + /// Stops the active "connecting" phase and cancels its safety timeout. + private func interruptConnecting() { + isConnecting = false + connectTimeoutTask?.cancel() + connectTimeoutTask = nil + } + + private func closeReasonData(_ reason: String) -> Data { + Data(reason.utf8) + } + func connect() { // Android parity: prevent duplicate connect() calls (Protocol.kt lines 237-256). guard webSocketTask == nil else { return } @@ -92,8 +103,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD try? await Task.sleep(nanoseconds: 15_000_000_000) guard let self, !Task.isCancelled, self.isConnecting else { return } Self.logger.warning("Connection establishment timeout (15s)") - self.isConnecting = false - self.webSocketTask?.cancel(with: .goingAway, reason: nil) + self.interruptConnecting() + self.webSocketTask?.cancel( + with: .normalClosure, + reason: self.closeReasonData("Reconnecting") + ) self.webSocketTask = nil self.isConnected = false self.handleDisconnect(error: NSError( @@ -106,12 +120,13 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD func disconnect() { Self.logger.info("Manual disconnect") isManuallyClosed = true - isConnecting = false + interruptConnecting() reconnectTask?.cancel() reconnectTask = nil - connectTimeoutTask?.cancel() - connectTimeoutTask = nil - webSocketTask?.cancel(with: .goingAway, reason: nil) + webSocketTask?.cancel( + with: .normalClosure, + reason: closeReasonData("User disconnected") + ) webSocketTask = nil isConnected = false } @@ -122,13 +137,14 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD guard !isManuallyClosed else { return } reconnectTask?.cancel() reconnectTask = nil - connectTimeoutTask?.cancel() - connectTimeoutTask = nil + interruptConnecting() // Always tear down and reconnect — connection may be zombie after background - webSocketTask?.cancel(with: .goingAway, reason: nil) + webSocketTask?.cancel( + with: .normalClosure, + reason: closeReasonData("Reconnecting") + ) webSocketTask = nil isConnected = false - isConnecting = false disconnectHandledForCurrentSocket = false // Android parity: reset backoff so next failure starts from 1s, not stale 8s/16s. reconnectAttempts = 0 @@ -217,7 +233,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD Self.logger.info("didClose ignored: stale socket (not current task)") return } - isConnecting = false + interruptConnecting() isConnected = false handleDisconnect(error: nil) } @@ -229,7 +245,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD // Ignore callbacks from old (cancelled) sockets after forceReconnect. guard task === self.webSocketTask else { return } Self.logger.warning("URLSession task failed: \(error.localizedDescription)") - isConnecting = false + interruptConnecting() isConnected = false handleDisconnect(error: error) } @@ -261,7 +277,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD // Android parity (onFailure): clear isConnecting before handleDisconnect. // Without this, if connection fails before didOpenWithProtocol (DNS/TLS error), // isConnecting stays true → handleDisconnect returns early → no reconnect ever scheduled. - self.isConnecting = false + self.interruptConnecting() self.handleDisconnect(error: error) } } @@ -270,19 +286,14 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD // MARK: - Reconnection private func handleDisconnect(error: Error?) { - // Android parity (Protocol.kt:562-566): if a new connection is already - // in progress, ignore stale disconnect from previous socket. - if isConnecting { - Self.logger.info("Disconnect ignored: connection already in progress") - return - } + // Ensure all disconnect paths break current "connecting" state. + interruptConnecting() if disconnectHandledForCurrentSocket { return } disconnectHandledForCurrentSocket = true webSocketTask = nil isConnected = false - isConnecting = false onDisconnected?(error) guard !isManuallyClosed else { return } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index f9479a8..1293f1f 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -110,6 +110,7 @@ final class SessionManager { let myKey = currentPublicKey for dialogKey in activeKeys { guard !SystemAccounts.isSystemAccount(dialogKey) else { continue } + guard MessageRepository.shared.isDialogReadEligible(dialogKey) else { continue } DialogRepository.shared.markAsRead(opponentKey: dialogKey) MessageRepository.shared.markIncomingAsRead( opponentKey: dialogKey, myPublicKey: myKey @@ -167,6 +168,15 @@ final class SessionManager { // account if the app version changed since the last notice. sendReleaseNotesIfNeeded(publicKey: account.publicKey) + // Pre-warm PBKDF2 cache for message storage encryption. + // First encryptWithPassword() call costs 50-100ms (PBKDF2 derivation). + // All subsequent calls use NSLock-protected cache (<1ms). + // Fire-and-forget on background thread — completes before first sync message arrives. + let pkForCache = privateKeyHex + Task.detached(priority: .utility) { + _ = CryptoManager.shared.cachedPBKDF2(password: pkForCache) + } + // Generate private key hash for handshake let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex) privateKeyHash = hash @@ -1427,9 +1437,10 @@ final class SessionManager { // Android parity: mark as read if dialog is active AND app is in foreground. // Android has NO idle detection — only isDialogActive flag (ON_RESUME/ON_PAUSE). let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey) + let dialogIsReadEligible = MessageRepository.shared.isDialogReadEligible(opponentKey) let isSystem = SystemAccounts.isSystemAccount(opponentKey) let fg = isAppInForeground - let shouldMarkRead = dialogIsActive && fg && !isSystem + let shouldMarkRead = dialogIsActive && dialogIsReadEligible && fg && !isSystem if shouldMarkRead { DialogRepository.shared.markAsRead(opponentKey: opponentKey) diff --git a/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift b/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift index 0df02c3..15600d1 100644 --- a/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift +++ b/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift @@ -65,8 +65,8 @@ struct MessageBubbleShape: Shape { // MARK: - Body (Rounded Rect with Per-Corner Radii) private func addBody(to p: inout Path, rect: CGRect) { - let r: CGFloat = 18 - let s: CGFloat = 8 + let r: CGFloat = 16 + let s: CGFloat = 5 let (tl, tr, bl, br) = cornerRadii(r: r, s: s) // Clamp to half the smallest dimension diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 3ec11dd..0fe5378 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -127,7 +127,8 @@ struct ChatDetailView: View { } private var maxBubbleWidth: CGFloat { - max(min(UIScreen.main.bounds.width * 0.72, 380), 140) + let w = UIScreen.main.bounds.width + return w <= 500 ? w - 36 : w * 0.85 } /// Visual chat content: messages list + gradient overlays + background. @@ -196,7 +197,12 @@ struct ChatDetailView: View { cellActions.onDelete = { [self] msg in messageToDelete = msg } cellActions.onCopy = { text in UIPasteboard.general.string = text } cellActions.onImageTap = { [self] attId in openImageViewer(attachmentId: attId) } - cellActions.onScrollToMessage = { [self] msgId in scrollToMessageId = msgId } + cellActions.onScrollToMessage = { [self] msgId in + Task { @MainActor in + guard await viewModel.ensureMessageLoaded(messageId: msgId) else { return } + scrollToMessageId = msgId + } + } cellActions.onRetry = { [self] msg in retryMessage(msg) } cellActions.onRemove = { [self] msg in removeMessage(msg) } // Capture first unread incoming message BEFORE marking as read. @@ -214,13 +220,11 @@ struct ChatDetailView: View { // setDialogActive only touches MessageRepository.activeDialogs (Set), // does NOT mutate DialogRepository, so ForEach won't rebuild. MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) + updateReadEligibility() clearDeliveredNotifications(for: route.publicKey) - // Android parity: mark messages as read in DB IMMEDIATELY (no delay). - // This prevents reconcileUnreadCounts() from re-inflating badge - // if it runs during the 600ms navigation delay. - MessageRepository.shared.markIncomingAsRead( - opponentKey: route.publicKey, myPublicKey: currentPublicKey - ) + // Telegram-like read policy: mark read only when dialog is truly readable + // (view active + list at bottom). + markDialogAsRead() // Request user info (non-mutating, won't trigger list rebuild) requestUserInfoIfNeeded() // Delay DialogRepository mutations to let navigation transition complete. @@ -229,6 +233,7 @@ struct ChatDetailView: View { try? await Task.sleep(for: .milliseconds(600)) guard isViewActive else { return } activateDialog() + updateReadEligibility() markDialogAsRead() // Desktop parity: skip online subscription and user info fetch for system accounts if !route.isSystemAccount { @@ -242,15 +247,11 @@ struct ChatDetailView: View { } } .onDisappear { - isViewActive = false firstUnreadMessageId = nil - // Android parity: mark all messages as read when leaving dialog. - // Android's unmount callback does SQL UPDATE messages SET read = 1. - // Don't re-send read receipt — it was already sent during the session. - DialogRepository.shared.markAsRead(opponentKey: route.publicKey) - MessageRepository.shared.markIncomingAsRead( - opponentKey: route.publicKey, myPublicKey: currentPublicKey - ) + // Flush final read only if dialog is still eligible at the moment of closing. + markDialogAsRead() + isViewActive = false + updateReadEligibility() MessageRepository.shared.setDialogActive(route.publicKey, isActive: false) // Desktop parity: save draft text on chat close. DraftManager.shared.saveDraft(for: route.publicKey, text: messageText) @@ -724,6 +725,10 @@ private extension ChatDetailView { scrollToBottomRequested: $scrollToBottomRequested, onAtBottomChange: { atBottom in isAtBottom = atBottom + updateReadEligibility() + if atBottom { + markDialogAsRead() + } }, onPaginate: { Task { await viewModel.loadMore() } @@ -736,6 +741,7 @@ private extension ChatDetailView { let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true if isViewActive && !lastIsOutgoing && !route.isSavedMessages && !route.isSystemAccount { + updateReadEligibility() markDialogAsRead() } }, @@ -1252,13 +1258,18 @@ private extension ChatDetailView { for att in replyData.attachments { if att.type == AttachmentType.image.rawValue { // ── Image re-upload ── - if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id), - let jpegData = image.jpegData(compressionQuality: 0.85) { - forwardedImages[att.id] = jpegData - #if DEBUG - print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)") - #endif - continue + if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id) { + // JPEG encoding (10-50ms) off main thread + let jpegData = await Task.detached(priority: .userInitiated) { + image.jpegData(compressionQuality: 0.85) + }.value + if let jpegData { + forwardedImages[att.id] = jpegData + #if DEBUG + print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)") + #endif + continue + } } // Not in cache — download from CDN, decrypt, then include. @@ -1285,8 +1296,22 @@ private extension ChatDetailView { let encryptedString = String(decoding: encryptedData, as: UTF8.self) let passwords = MessageCrypto.attachmentPasswordCandidates(from: password) - if let img = Self.decryptForwardImage(encryptedString: encryptedString, passwords: passwords), - let jpegData = img.jpegData(compressionQuality: 0.85) { + // Decrypt on background thread — PBKDF2 per candidate is 50-100ms. + #if DEBUG + let decryptStart = CFAbsoluteTimeGetCurrent() + print("⚡ PERF_DECRYPT | Image \(att.id.prefix(12)): starting background decrypt (\(passwords.count) candidates)") + #endif + let imgResult = await Task.detached(priority: .userInitiated) { + guard let img = Self.decryptForwardImage(encryptedString: encryptedString, passwords: passwords), + let jpegData = img.jpegData(compressionQuality: 0.85) else { return nil as (UIImage, Data)? } + return (img, jpegData) + }.value + #if DEBUG + let decryptMs = (CFAbsoluteTimeGetCurrent() - decryptStart) * 1000 + print("⚡ PERF_DECRYPT | Image \(att.id.prefix(12)): \(imgResult != nil ? "OK" : "FAIL") in \(String(format: "%.0f", decryptMs))ms (BACKGROUND)") + #endif + + if let (img, jpegData) = imgResult { forwardedImages[att.id] = jpegData AttachmentCache.shared.saveImage(img, forAttachmentId: att.id) #if DEBUG @@ -1341,7 +1366,20 @@ private extension ChatDetailView { let encryptedString = String(decoding: encryptedData, as: UTF8.self) let passwords = MessageCrypto.attachmentPasswordCandidates(from: password) - if let fileData = Self.decryptForwardFile(encryptedString: encryptedString, passwords: passwords) { + // Decrypt on background thread — PBKDF2 per candidate is 50-100ms. + #if DEBUG + let fileDecryptStart = CFAbsoluteTimeGetCurrent() + print("⚡ PERF_DECRYPT | File \(att.id.prefix(12)): starting background decrypt (\(passwords.count) candidates)") + #endif + let fileData = await Task.detached(priority: .userInitiated) { + Self.decryptForwardFile(encryptedString: encryptedString, passwords: passwords) + }.value + #if DEBUG + let fileDecryptMs = (CFAbsoluteTimeGetCurrent() - fileDecryptStart) * 1000 + print("⚡ PERF_DECRYPT | File \(att.id.prefix(12)): \(fileData != nil ? "OK" : "FAIL") in \(String(format: "%.0f", fileDecryptMs))ms (BACKGROUND)") + #endif + + if let fileData { forwardedFiles[att.id] = (data: fileData, fileName: fileName) #if DEBUG print("📤 File \(att.id.prefix(16)): CDN download+decrypt OK (\(fileData.count) bytes, name=\(fileName))") @@ -1399,7 +1437,8 @@ private extension ChatDetailView { } /// Decrypt a CDN-downloaded image blob with multiple password candidates. - private static func decryptForwardImage(encryptedString: String, passwords: [String]) -> UIImage? { + /// `nonisolated` — safe to call from background (no UI access, only CryptoManager). + nonisolated private static func decryptForwardImage(encryptedString: String, passwords: [String]) -> UIImage? { let crypto = CryptoManager.shared for password in passwords { if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true), @@ -1412,7 +1451,7 @@ private extension ChatDetailView { return nil } - private static func parseForwardImageData(_ data: Data) -> UIImage? { + nonisolated private static func parseForwardImageData(_ data: Data) -> UIImage? { if let str = String(data: data, encoding: .utf8), str.hasPrefix("data:"), let commaIndex = str.firstIndex(of: ",") { @@ -1425,8 +1464,8 @@ private extension ChatDetailView { } /// Decrypt a CDN-downloaded file blob with multiple password candidates. - /// Returns raw file data (extracted from data URI). - private static func decryptForwardFile(encryptedString: String, passwords: [String]) -> Data? { + /// `nonisolated` — safe to call from background (no UI access, only CryptoManager). + nonisolated private static func decryptForwardFile(encryptedString: String, passwords: [String]) -> Data? { let crypto = CryptoManager.shared for password in passwords { if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true), @@ -1440,7 +1479,7 @@ private extension ChatDetailView { } /// Extract raw file bytes from a data URI (format: "data:{mime};base64,{base64data}"). - private static func parseForwardFileData(_ data: Data) -> Data? { + nonisolated private static func parseForwardFileData(_ data: Data) -> Data? { if let str = String(data: data, encoding: .utf8), str.hasPrefix("data:"), let commaIndex = str.firstIndex(of: ",") { @@ -1497,6 +1536,14 @@ private extension ChatDetailView { SessionManager.shared.requestUserInfoIfNeeded(forKey: route.publicKey) } + /// Dialog is readable only when this screen is active and list is at bottom. + func updateReadEligibility() { + MessageRepository.shared.setDialogReadEligible( + route.publicKey, + isEligible: isViewActive && isAtBottom + ) + } + func activateDialog() { // Only update existing dialogs; don't create ghost entries from search. // New dialogs are created when messages are sent/received (SessionManager). @@ -1510,9 +1557,11 @@ private extension ChatDetailView { ) } MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) + updateReadEligibility() } func markDialogAsRead() { + guard MessageRepository.shared.isDialogReadEligible(route.publicKey) else { return } DialogRepository.shared.markAsRead(opponentKey: route.publicKey) MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey) // Desktop parity: don't send read receipts for system accounts diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift index bf3df5e..06621ea 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift @@ -94,6 +94,7 @@ final class ChatDetailViewModel: ObservableObject { let older = MessageRepository.shared.loadOlderMessages( for: dialogKey, beforeTimestamp: earliest.timestamp, + beforeMessageId: earliest.id, limit: MessageRepository.pageSize ) @@ -103,4 +104,35 @@ final class ChatDetailViewModel: ObservableObject { // messages will update via Combine pipeline (repo already prepends to cache). isLoadingMore = false } + + /// Ensures a target message is present in current dialog cache before scroll-to-message. + /// Returns true when the message is available to the UI list. + func ensureMessageLoaded(messageId: String) async -> Bool { + guard !messageId.isEmpty else { return false } + if messages.contains(where: { $0.id == messageId }) { + return true + } + + let repo = MessageRepository.shared + guard repo.ensureMessageLoaded(for: dialogKey, messageId: messageId) else { + return false + } + + // Wait briefly for Combine debounce (50ms) to propagate to this view model. + for _ in 0..<8 { + if messages.contains(where: { $0.id == messageId }) { + return true + } + try? await Task.sleep(for: .milliseconds(16)) + } + + // Fallback: force a direct snapshot refresh from repository. + let refreshed = repo.messages(for: dialogKey) + if refreshed.contains(where: { $0.id == messageId }) { + messages = refreshed + return true + } + + return false + } } diff --git a/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift b/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift new file mode 100644 index 0000000..d68257a --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift @@ -0,0 +1,273 @@ +import UIKit +import CoreText + +// MARK: - Telegram-Exact Text Layout (Pre-calculated) + +/// Pre-calculated text layout using Telegram's exact CoreText pipeline. +/// +/// Telegram uses CTTypesetter (NOT CTFramesetter) for manual line breaking, +/// custom inter-line spacing (12% of fontLineHeight), and CTRunDraw for rendering. +/// UILabel/TextKit produce different line breaks and density — this class +/// reproduces Telegram's exact algorithm from: +/// - InteractiveTextComponent.swift (lines 1480-1548) — line breaking +/// - TextNode.swift (lines 1723-1726) — font metrics & line spacing +/// - InteractiveTextComponent.swift (lines 2358-2573) — rendering +/// +/// Two-phase pattern (matches Telegram asyncLayout): +/// 1. `CoreTextTextLayout.calculate()` — runs on ANY thread (background-safe) +/// 2. `CoreTextLabel.draw()` — runs on main thread, renders pre-calculated lines +final class CoreTextTextLayout { + + // MARK: - Line + + /// A single laid-out line with position and metrics. + struct Line { + let ctLine: CTLine + let origin: CGPoint // Top-left corner in UIKit (top-down) coordinates + let width: CGFloat // Typographic advance width (CTLineGetTypographicBounds) + let ascent: CGFloat // Distance from baseline to top of tallest glyph + let descent: CGFloat // Distance from baseline to bottom of lowest glyph + } + + // MARK: - Properties + + let lines: [Line] + let size: CGSize // Bounding box (ceil'd max-line-width × total height) + let lastLineWidth: CGFloat // Width of the final line — for inline timestamp decisions + let lastLineHasRTL: Bool + let lastLineHasBlockQuote: Bool + let textColor: UIColor + + private init( + lines: [Line], + size: CGSize, + lastLineWidth: CGFloat, + lastLineHasRTL: Bool, + lastLineHasBlockQuote: Bool, + textColor: UIColor + ) { + self.lines = lines + self.size = size + self.lastLineWidth = lastLineWidth + self.lastLineHasRTL = lastLineHasRTL + self.lastLineHasBlockQuote = lastLineHasBlockQuote + self.textColor = textColor + } + + // MARK: - Telegram Line Spacing + + /// Telegram default: 12% of font line height. + /// Source: TextNode.swift line 277, InteractiveTextComponent.swift line 299. + static let telegramLineSpacingFactor: CGFloat = 0.12 + + // MARK: - Layout Calculation (Thread-Safe) + + /// Calculate text layout using Telegram's exact algorithm. + /// + /// Algorithm (InteractiveTextComponent.swift lines 1480-1548): + /// 1. `CTTypesetterCreateWithAttributedString` — create typesetter + /// 2. Loop: `CTTypesetterSuggestLineBreak` → `CTTypesetterCreateLine` + /// 3. `CTLineGetTypographicBounds` for per-line ascent/descent/width + /// 4. Accumulate height with `floor(fontLineHeight * 0.12)` inter-line spacing + /// + /// - Parameters: + /// - text: Raw message text + /// - maxWidth: Maximum line width constraint (bubbleW - leftPad - rightPad) + /// - font: Text font (default: system 17pt, matching Telegram) + /// - textColor: Foreground color baked into attributed string + /// - lineSpacingFactor: Inter-line spacing as fraction of font line height (default: 0.12) + /// - Returns: Pre-calculated layout with lines, size, and lastLineWidth + static func calculate( + text: String, + maxWidth: CGFloat, + font: UIFont = .systemFont(ofSize: 17), + textColor: UIColor = .white, + lineSpacingFactor: CGFloat = telegramLineSpacingFactor + ) -> CoreTextTextLayout { + // Guard: empty text, non-positive width, or NaN → return zero layout + let safeMaxWidth = maxWidth.isNaN ? 100 : max(maxWidth, 10) + guard !text.isEmpty, safeMaxWidth >= 10 else { + return CoreTextTextLayout( + lines: [], + size: .zero, + lastLineWidth: 0, + lastLineHasRTL: false, + lastLineHasBlockQuote: false, + textColor: textColor + ) + } + + // ── Attributed string (Telegram: StringWithAppliedEntities.swift) ── + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: textColor + ] + let attrString = NSAttributedString(string: text, attributes: attributes) + let stringLength = attrString.length + + // ── Typesetter (Telegram: InteractiveTextComponent line 1481) ── + let typesetter = CTTypesetterCreateWithAttributedString(attrString as CFAttributedString) + + // ── Font metrics (Telegram: TextNode.swift lines 1723-1726) ── + let ctFont = font as CTFont + let fontAscent = CTFontGetAscent(ctFont) + let fontDescent = CTFontGetDescent(ctFont) + let fontLineHeight = floor(fontAscent + fontDescent) + let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor) + + var resultLines: [Line] = [] + var currentIndex: CFIndex = 0 + var currentY: CGFloat = 0 + var maxLineWidth: CGFloat = 0 + var lastLineWidth: CGFloat = 0 + var lastLineRange = NSRange(location: 0, length: 0) + + // ── Line breaking loop (Telegram: InteractiveTextComponent lines 1490-1548) ── + // Safety: cap iterations to stringLength to prevent infinite loop if + // CTTypesetterSuggestLineBreak returns non-advancing counts. + var iterations = 0 + while currentIndex < stringLength { + iterations += 1 + if iterations > stringLength + 1 { break } // infinite loop guard + + // Suggest line break (word boundary) + let lineCharCount = CTTypesetterSuggestLineBreak( + typesetter, currentIndex, Double(safeMaxWidth) + ) + guard lineCharCount > 0 else { break } + + // Create line from typesetter + let ctLine = CTTypesetterCreateLine( + typesetter, CFRange(location: currentIndex, length: lineCharCount) + ) + + // Measure line (Telegram: CTLineGetTypographicBounds) + var lineAscent: CGFloat = 0 + var lineDescent: CGFloat = 0 + let lineWidth = CGFloat(CTLineGetTypographicBounds( + ctLine, &lineAscent, &lineDescent, nil + )) + + // Guard against NaN from CoreText (observed with certain Cyrillic strings) + guard !lineWidth.isNaN, !lineAscent.isNaN, !lineDescent.isNaN else { break } + + let clampedWidth = min(lineWidth, safeMaxWidth) + + // Inter-line spacing (applied BETWEEN lines, not before first) + if !resultLines.isEmpty { + currentY += fontLineSpacing + } + + resultLines.append(Line( + ctLine: ctLine, + origin: CGPoint(x: 0, y: currentY), + width: clampedWidth, + ascent: lineAscent, + descent: lineDescent + )) + + // Advance by font line height (Telegram uses font-level, not per-line) + currentY += fontLineHeight + + maxLineWidth = max(maxLineWidth, clampedWidth) + lastLineWidth = clampedWidth + lastLineRange = NSRange(location: currentIndex, length: lineCharCount) + currentIndex += lineCharCount + } + + let nsText = text as NSString + let safeLastRange = NSIntersectionRange(lastLineRange, NSRange(location: 0, length: nsText.length)) + let lastLineText = safeLastRange.length > 0 ? nsText.substring(with: safeLastRange) : "" + let lastLineHasRTL = containsRTLCharacters(in: lastLineText) + let lastLineHasBlockQuote = lastLineText + .trimmingCharacters(in: .whitespacesAndNewlines) + .hasPrefix(">") + + return CoreTextTextLayout( + lines: resultLines, + size: CGSize(width: ceil(maxLineWidth), height: ceil(currentY)), + lastLineWidth: ceil(lastLineWidth), + lastLineHasRTL: lastLineHasRTL, + lastLineHasBlockQuote: lastLineHasBlockQuote, + textColor: textColor + ) + } + + private static func containsRTLCharacters(in text: String) -> Bool { + for scalar in text.unicodeScalars { + switch scalar.value { + case 0x0590...0x08FF, 0xFB1D...0xFDFD, 0xFE70...0xFEFC: + return true + default: + continue + } + } + return false + } +} + +// MARK: - CoreText Label (Custom Rendering View) + +/// Custom UIView that renders `CoreTextTextLayout` via CoreText. +/// Drop-in replacement for UILabel in message body text rendering. +/// +/// Rendering matches Telegram (InteractiveTextComponent lines 2358-2573): +/// - Flips text matrix `CGAffineTransform(scaleX: 1.0, y: -1.0)` for UIKit coords +/// - Positions each line at its baseline via `context.textPosition` +/// - Draws each CTRun individually via `CTRunDraw` +final class CoreTextLabel: UIView { + + /// Pre-calculated layout to render. Setting triggers redraw. + var textLayout: CoreTextTextLayout? { + didSet { setNeedsDisplay() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + isOpaque = false + backgroundColor = .clear + contentMode = .redraw // Redraw on bounds change + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Rendering + + override func draw(_ rect: CGRect) { + guard let layout = textLayout, + let context = UIGraphicsGetCurrentContext() + else { return } + + // Save context state + let savedTextMatrix = context.textMatrix + let savedTextPosition = context.textPosition + + // Flip text matrix for UIKit coordinates (Telegram: scaleX: 1.0, y: -1.0). + // UIKit context has origin top-left (Y down). CoreText expects bottom-left (Y up). + // Flipping the text matrix makes glyphs render right-side-up in UIKit. + context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0) + + for line in layout.lines { + // Baseline position in UIKit coordinates: + // line.origin.y = top of line frame + // + line.ascent = baseline (distance from top to baseline) + // Telegram: context.textPosition = CGPoint(x: minX, y: maxY - descent) + // which equals origin.y + ascent (since maxY = origin.y + ascent + descent) + context.textPosition = CGPoint( + x: line.origin.x, + y: line.origin.y + line.ascent + ) + + // Draw each glyph run (Telegram: CTRunDraw per run) + let glyphRuns = CTLineGetGlyphRuns(line.ctLine) as! [CTRun] + for run in glyphRuns { + CTRunDraw(run, context, CFRangeMake(0, 0)) // 0,0 = all glyphs + } + } + + // Restore context state + context.textMatrix = savedTextMatrix + context.textPosition = savedTextPosition + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 1beac0a..e649d95 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -13,29 +13,143 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // MARK: - Constants - private static let outgoingColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1, alpha: 1) - private static let incomingColor = UIColor(red: 0x2C/255.0, green: 0x2C/255.0, blue: 0x2E/255.0, alpha: 1) + private static let outgoingColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) // #3390EC + private static let incomingColor = UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular) - private static let timestampFont = UIFont.systemFont(ofSize: 11, weight: .regular) + private static let timestampFont = UIFont.systemFont(ofSize: floor(textFont.pointSize * 11.0 / 17.0), weight: .regular) private static let replyNameFont = UIFont.systemFont(ofSize: 13, weight: .semibold) private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular) private static let forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular) private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold) private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium) private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular) + private static let statusBubbleInsets = UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7) + private static let sendingClockAnimationKey = "clockFrameAnimation" + + // MARK: - Telegram Check Images (CGContext — ported from PresentationThemeEssentialGraphics.swift) + + /// Telegram-exact checkmark image via CGContext stroke. + /// `partial: true` → single arm (/), `partial: false` → full V (✓). + /// Canvas: 11-unit coordinate space scaled to `width` pt. + private static func generateTelegramCheck(partial: Bool, color: UIColor, width: CGFloat = 11) -> UIImage? { + let height = floor(width * 9.0 / 11.0) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) + return renderer.image { ctx in + let gc = ctx.cgContext + // Keep UIKit default Y-down coordinates; Telegram check path points + // are already authored for this orientation in our renderer. + gc.clear(CGRect(x: 0, y: 0, width: width, height: height)) + gc.scaleBy(x: width / 11.0, y: width / 11.0) + gc.translateBy(x: 1.0, y: 1.0) + gc.setStrokeColor(color.cgColor) + gc.setLineWidth(0.99) + gc.setLineCap(.round) + gc.setLineJoin(.round) + if partial { + // Single arm: bottom-left → top-right diagonal + gc.move(to: CGPoint(x: 0.5, y: 7)) + gc.addLine(to: CGPoint(x: 7, y: 0)) + } else { + // Full V: left → bottom-center (rounded tip) → top-right + gc.move(to: CGPoint(x: 0, y: 4)) + gc.addLine(to: CGPoint(x: 2.95157047, y: 6.95157047)) + gc.addCurve(to: CGPoint(x: 3.04490857, y: 6.95157047), + control1: CGPoint(x: 2.97734507, y: 6.97734507), + control2: CGPoint(x: 3.01913396, y: 6.97734507)) + gc.addCurve(to: CGPoint(x: 3.04660389, y: 6.9498112), + control1: CGPoint(x: 3.04548448, y: 6.95099456), + control2: CGPoint(x: 3.04604969, y: 6.95040803)) + gc.addLine(to: CGPoint(x: 9.5, y: 0)) + } + gc.strokePath() + } + } + + /// Telegram-exact clock frame image. + private static func generateTelegramClockFrame(color: UIColor) -> UIImage? { + let size = CGSize(width: 11, height: 11) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + let gc = ctx.cgContext + // Telegram uses `generateImage(contextGenerator:)` (non-rotated context). + // Flip UIKit context to the same Y-up coordinate space. + gc.translateBy(x: 0, y: size.height) + gc.scaleBy(x: 1, y: -1) + gc.clear(CGRect(origin: .zero, size: size)) + gc.setStrokeColor(color.cgColor) + gc.setFillColor(color.cgColor) + gc.setLineWidth(1.0) + gc.strokeEllipse(in: CGRect(x: 0.5, y: 0.5, width: 10, height: 10)) + gc.fill(CGRect(x: 5.0, y: 3.0, width: 1.0, height: 2.5)) + } + } + + /// Telegram-exact clock minute/hour image. + private static func generateTelegramClockMin(color: UIColor) -> UIImage? { + let size = CGSize(width: 11, height: 11) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + let gc = ctx.cgContext + // Match Telegram's non-rotated drawing context coordinates. + gc.translateBy(x: 0, y: size.height) + gc.scaleBy(x: 1, y: -1) + gc.clear(CGRect(origin: .zero, size: size)) + gc.setFillColor(color.cgColor) + gc.fill(CGRect(x: 5.0, y: 5.0, width: 4.5, height: 1.0)) + } + } + + /// Error indicator (circle with exclamation mark). + private static func generateErrorIcon(color: UIColor, width: CGFloat = 20) -> UIImage? { + let size = CGSize(width: width, height: width) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + let gc = ctx.cgContext + gc.scaleBy(x: width / 11.0, y: width / 11.0) + gc.setFillColor(color.cgColor) + gc.fillEllipse(in: CGRect(x: 0.0, y: 0.0, width: 11.0, height: 11.0)) + gc.setFillColor(UIColor.white.cgColor) + gc.fill(CGRect(x: 5.0, y: 2.5, width: 1.0, height: 4.25)) + gc.fillEllipse(in: CGRect(x: 4.75, y: 7.8, width: 1.5, height: 1.5)) + } + } + + // Pre-rendered images (cached at class load — Telegram caches in PrincipalThemeEssentialGraphics) + private static let outgoingCheckColor = UIColor.white + private static let outgoingClockColor = UIColor.white.withAlphaComponent(0.5) + private static let mediaMetaColor = UIColor.white + private static let fullCheckImage = generateTelegramCheck(partial: false, color: outgoingCheckColor) + private static let partialCheckImage = generateTelegramCheck(partial: true, color: outgoingCheckColor) + private static let clockFrameImage = generateTelegramClockFrame(color: outgoingClockColor) + private static let clockMinImage = generateTelegramClockMin(color: outgoingClockColor) + private static let mediaFullCheckImage = generateTelegramCheck(partial: false, color: mediaMetaColor) + private static let mediaPartialCheckImage = generateTelegramCheck(partial: true, color: mediaMetaColor) + private static let mediaClockFrameImage = generateTelegramClockFrame(color: mediaMetaColor) + private static let mediaClockMinImage = generateTelegramClockMin(color: mediaMetaColor) + private static let errorIcon = generateErrorIcon(color: .systemRed) + private static let blurHashCache: NSCache = { + let cache = NSCache() + cache.countLimit = 200 + return cache + }() // MARK: - Subviews (always present, hidden when unused) // Bubble private let bubbleView = UIView() private let bubbleLayer = CAShapeLayer() + private let bubbleOutlineLayer = CAShapeLayer() - // Text - private let textLabel = UILabel() + // Text (CoreText rendering — matches Telegram's CTTypesetter + CTRunDraw pipeline) + private let textLabel = CoreTextLabel() // Timestamp + delivery + private let statusBackgroundView = UIView() private let timestampLabel = UILabel() - private let checkmarkView = UIImageView() + private let checkSentView = UIImageView() + private let checkReadView = UIImageView() + private let clockFrameView = UIImageView() + private let clockMinView = UIImageView() // Reply quote private let replyContainer = UIView() @@ -46,6 +160,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // Photo private let photoView = UIImageView() private let photoPlaceholderView = UIView() + private let photoActivityIndicator = UIActivityIndicatorView(style: .medium) // File private let fileContainer = UIView() @@ -60,12 +175,20 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // Swipe-to-reply private let replyIconView = UIImageView() + private let deliveryFailedButton = UIButton(type: .custom) // MARK: - State private var message: ChatMessage? private var actions: MessageCellActions? private var currentLayout: MessageCellLayout? + private var isDeliveryFailedVisible = false + private var wasSentCheckVisible = false + private var wasReadCheckVisible = false + private var photoAttachmentId: String? + private var photoLoadTask: Task? + private var photoDownloadTask: Task? + private var isPhotoDownloading = false // MARK: - Init @@ -86,23 +209,38 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // Bubble bubbleLayer.fillColor = Self.outgoingColor.cgColor + bubbleLayer.fillRule = .nonZero + bubbleLayer.shadowColor = UIColor.black.cgColor + bubbleLayer.shadowOpacity = 0.12 + bubbleLayer.shadowRadius = 0.6 + bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4) bubbleView.layer.insertSublayer(bubbleLayer, at: 0) + bubbleOutlineLayer.fillColor = UIColor.clear.cgColor + bubbleOutlineLayer.lineWidth = 1.0 / max(UIScreen.main.scale, 1) + bubbleView.layer.insertSublayer(bubbleOutlineLayer, above: bubbleLayer) contentView.addSubview(bubbleView) - // Text - textLabel.font = Self.textFont - textLabel.textColor = .white - textLabel.numberOfLines = 0 - textLabel.lineBreakMode = .byWordWrapping + // Text (CoreTextLabel — no font/color/lines config; all baked into CoreTextTextLayout) bubbleView.addSubview(textLabel) // Timestamp + statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.32) + statusBackgroundView.layer.cornerRadius = 6 + statusBackgroundView.isHidden = true + bubbleView.addSubview(statusBackgroundView) + timestampLabel.font = Self.timestampFont bubbleView.addSubview(timestampLabel) - // Checkmark - checkmarkView.contentMode = .scaleAspectFit - bubbleView.addSubview(checkmarkView) + // Checkmarks (Telegram two-node overlay: sent ✓ + read /) + checkSentView.contentMode = .scaleAspectFit + bubbleView.addSubview(checkSentView) + checkReadView.contentMode = .scaleAspectFit + bubbleView.addSubview(checkReadView) + clockFrameView.contentMode = .scaleAspectFit + clockMinView.contentMode = .scaleAspectFit + bubbleView.addSubview(clockFrameView) + bubbleView.addSubview(clockMinView) // Reply quote replyBar.layer.cornerRadius = 1.5 @@ -117,11 +255,19 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // Photo photoView.contentMode = .scaleAspectFill photoView.clipsToBounds = true + photoView.isUserInteractionEnabled = true bubbleView.addSubview(photoView) photoPlaceholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1) bubbleView.addSubview(photoPlaceholderView) + photoActivityIndicator.color = .white + photoActivityIndicator.hidesWhenStopped = true + bubbleView.addSubview(photoActivityIndicator) + + let photoTap = UITapGestureRecognizer(target: self, action: #selector(handlePhotoTap)) + photoView.addGestureRecognizer(photoTap) + // File fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) fileIconView.layer.cornerRadius = 20 @@ -155,6 +301,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel replyIconView.alpha = 0 contentView.addSubview(replyIconView) + // Delivery failed node (Telegram-style external badge) + deliveryFailedButton.setImage(Self.errorIcon, for: .normal) + deliveryFailedButton.imageView?.contentMode = .scaleAspectFit + deliveryFailedButton.isHidden = true + deliveryFailedButton.accessibilityLabel = "Retry sending" + deliveryFailedButton.addTarget(self, action: #selector(handleDeliveryFailedTap), for: .touchUpInside) + contentView.addSubview(deliveryFailedButton) + // Interactions let contextMenu = UIContextMenuInteraction(delegate: self) bubbleView.addInteraction(contextMenu) @@ -167,9 +321,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // MARK: - Configure + Apply Layout /// Configure cell data (content). Does NOT trigger layout. + /// `textLayout` is pre-computed during `calculateLayouts()` — no double CoreText work. func configure( message: ChatMessage, timestamp: String, + textLayout: CoreTextTextLayout? = nil, actions: MessageCellActions, replyName: String? = nil, replyText: String? = nil, @@ -179,33 +335,56 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel self.actions = actions let isOutgoing = currentLayout?.isOutgoing ?? false + let isMediaStatus = currentLayout?.messageType == .photo - // Text (filter garbage/encrypted — UIKit path parity with SwiftUI) - textLabel.text = MessageCellLayout.isGarbageOrEncrypted(message.text) ? "" : message.text + // Text — use cached CoreTextTextLayout from measurement phase. + // Same CTTypesetter pipeline → identical line breaks, zero recomputation. + textLabel.textLayout = textLayout // Timestamp timestampLabel.text = timestamp - timestampLabel.textColor = isOutgoing - ? UIColor.white.withAlphaComponent(0.55) - : UIColor.white.withAlphaComponent(0.6) + if isMediaStatus { + timestampLabel.textColor = .white + } else { + timestampLabel.textColor = isOutgoing + ? UIColor.white.withAlphaComponent(0.55) + : UIColor.white.withAlphaComponent(0.6) + } - // Delivery + // Delivery checkmarks (Telegram two-node pattern: checkSent + checkRead) + stopSendingClockAnimation() + var shouldShowSentCheck = false + var shouldShowReadCheck = false + var shouldShowClock = false + checkSentView.image = nil + checkReadView.image = nil + clockFrameView.image = nil + clockMinView.image = nil if isOutgoing { - checkmarkView.isHidden = false switch message.deliveryStatus { case .delivered: - checkmarkView.image = UIImage(systemName: "checkmark")?.withRenderingMode(.alwaysTemplate) - checkmarkView.tintColor = message.isRead ? .white : UIColor.white.withAlphaComponent(0.55) + shouldShowSentCheck = true + checkSentView.image = isMediaStatus ? Self.mediaFullCheckImage : Self.fullCheckImage + if message.isRead { + checkReadView.image = isMediaStatus ? Self.mediaPartialCheckImage : Self.partialCheckImage + shouldShowReadCheck = true + } case .waiting: - checkmarkView.image = UIImage(systemName: "clock")?.withRenderingMode(.alwaysTemplate) - checkmarkView.tintColor = UIColor.white.withAlphaComponent(0.55) + shouldShowClock = true + clockFrameView.image = isMediaStatus ? Self.mediaClockFrameImage : Self.clockFrameImage + clockMinView.image = isMediaStatus ? Self.mediaClockMinImage : Self.clockMinImage + startSendingClockAnimation() case .error: - checkmarkView.image = UIImage(systemName: "exclamationmark.circle")?.withRenderingMode(.alwaysTemplate) - checkmarkView.tintColor = .red + break } - } else { - checkmarkView.isHidden = true } + checkSentView.isHidden = !shouldShowSentCheck + checkReadView.isHidden = !shouldShowReadCheck + clockFrameView.isHidden = !shouldShowClock + clockMinView.isHidden = !shouldShowClock + animateCheckAppearanceIfNeeded(isSentVisible: shouldShowSentCheck, isReadVisible: shouldShowReadCheck) + deliveryFailedButton.isHidden = !(isOutgoing && message.deliveryStatus == .error) + updateStatusBackgroundVisibility() // Bubble color bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor @@ -236,9 +415,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel forwardNameLabel.isHidden = true } - // Photo placeholder (actual image loading handled separately) - photoView.isHidden = !(currentLayout?.hasPhoto ?? false) - photoPlaceholderView.isHidden = !(currentLayout?.hasPhoto ?? false) + // Photo + configurePhoto(for: message) // File if let layout = currentLayout, layout.hasFile { @@ -265,20 +443,17 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel let cellW = contentView.bounds.width let tailW: CGFloat = layout.hasTail ? 6 : 0 - let isTopOrSingle = (layout.position == .single || layout.position == .top) - let topPad: CGFloat = isTopOrSingle ? 6 : 2 - // Bubble X: align to RIGHT for outgoing, LEFT for incoming - // This is computed from CELL WIDTH, not maxBubbleWidth + // Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment let bubbleX: CGFloat if layout.isOutgoing { - bubbleX = cellW - layout.bubbleSize.width - tailW - 2 + bubbleX = cellW - layout.bubbleSize.width - 6 - 2 - layout.deliveryFailedInset } else { - bubbleX = tailW + 2 + bubbleX = 6 + 2 } bubbleView.frame = CGRect( - x: bubbleX, y: topPad, + x: bubbleX, y: layout.groupGap, width: layout.bubbleSize.width, height: layout.bubbleSize.height ) bubbleLayer.frame = bubbleView.bounds @@ -299,14 +474,32 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel size: shapeRect.size, origin: shapeRect.origin, position: layout.position, isOutgoing: layout.isOutgoing, hasTail: layout.hasTail ) + bubbleLayer.shadowPath = bubbleLayer.path + bubbleOutlineLayer.frame = bubbleView.bounds + bubbleOutlineLayer.path = bubbleLayer.path + if layout.hasTail { + // Tail path is appended as a second subpath; stroking it produces + // a visible seam at the junction. Keep fill-only for tailed bubbles. + bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor + } else { + bubbleOutlineLayer.strokeColor = UIColor.black.withAlphaComponent( + layout.isOutgoing ? 0.16 : 0.22 + ).cgColor + } // Text textLabel.isHidden = layout.textSize == .zero textLabel.frame = layout.textFrame - // Timestamp + checkmark + // Timestamp + checkmarks (two-node overlay) timestampLabel.frame = layout.timestampFrame - checkmarkView.frame = layout.checkmarkFrame + checkSentView.frame = layout.checkSentFrame + checkReadView.frame = layout.checkReadFrame + clockFrameView.frame = layout.clockFrame + clockMinView.frame = layout.clockFrame + + // Telegram-style date/status pill on media-only bubbles. + updateStatusBackgroundFrame() // Reply replyContainer.isHidden = !layout.hasReplyQuote @@ -323,6 +516,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel if layout.hasPhoto { photoView.frame = layout.photoFrame photoPlaceholderView.frame = layout.photoFrame + photoActivityIndicator.center = CGPoint(x: layout.photoFrame.midX, y: layout.photoFrame.midY) } // File @@ -341,6 +535,43 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel forwardNameLabel.frame = layout.forwardNameFrame } + // Telegram-style failed delivery badge outside bubble (slide + fade). + let failedSize = CGSize(width: 20, height: 20) + let targetFailedFrame = CGRect( + x: bubbleView.frame.maxX + layout.deliveryFailedInset - failedSize.width, + y: bubbleView.frame.maxY - failedSize.height, + width: failedSize.width, + height: failedSize.height + ) + if layout.showsDeliveryFailedIndicator { + if !isDeliveryFailedVisible { + isDeliveryFailedVisible = true + deliveryFailedButton.isHidden = false + deliveryFailedButton.alpha = 0 + deliveryFailedButton.frame = targetFailedFrame.offsetBy(dx: layout.deliveryFailedInset, dy: 0) + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut, .beginFromCurrentState]) { + self.deliveryFailedButton.alpha = 1 + self.deliveryFailedButton.frame = targetFailedFrame + } + } else { + deliveryFailedButton.isHidden = false + deliveryFailedButton.alpha = 1 + deliveryFailedButton.frame = targetFailedFrame + } + } else if isDeliveryFailedVisible { + isDeliveryFailedVisible = false + let hideFrame = deliveryFailedButton.frame.offsetBy(dx: layout.deliveryFailedInset, dy: 0) + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn, .beginFromCurrentState]) { + self.deliveryFailedButton.alpha = 0 + self.deliveryFailedButton.frame = hideFrame + } completion: { _ in + self.deliveryFailedButton.isHidden = true + } + } else { + deliveryFailedButton.isHidden = true + deliveryFailedButton.alpha = 0 + } + // Reply icon (for swipe gesture) — use actual bubbleView frame replyIconView.frame = CGRect( x: layout.isOutgoing @@ -419,6 +650,275 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel } } + @objc private func handleDeliveryFailedTap() { + guard let message, let actions else { return } + actions.onRetry(message) + } + + @objc private func handlePhotoTap() { + guard let message, + let actions, + let layout = currentLayout, + layout.hasPhoto, + let attachment = message.attachments.first(where: { $0.type == .image }) else { + return + } + + if AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) != nil { + actions.onImageTap(attachment.id) + return + } + + downloadPhotoAttachment(attachment: attachment, message: message) + } + + private func configurePhoto(for message: ChatMessage) { + guard let layout = currentLayout, layout.hasPhoto else { + photoAttachmentId = nil + photoLoadTask?.cancel() + photoLoadTask = nil + photoDownloadTask?.cancel() + photoDownloadTask = nil + isPhotoDownloading = false + photoActivityIndicator.stopAnimating() + photoView.image = nil + photoView.isHidden = true + photoPlaceholderView.isHidden = true + return + } + + guard let attachment = message.attachments.first(where: { $0.type == .image }) else { + photoAttachmentId = nil + photoLoadTask?.cancel() + photoLoadTask = nil + photoDownloadTask?.cancel() + photoDownloadTask = nil + isPhotoDownloading = false + photoActivityIndicator.stopAnimating() + photoView.image = nil + photoView.isHidden = true + photoPlaceholderView.isHidden = false + return + } + + photoAttachmentId = attachment.id + + if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + photoView.image = cached + photoView.isHidden = false + photoPlaceholderView.isHidden = true + photoActivityIndicator.stopAnimating() + isPhotoDownloading = false + photoLoadTask?.cancel() + photoLoadTask = nil + return + } + + photoView.image = Self.blurHashImage(from: attachment.preview) + photoView.isHidden = false + photoPlaceholderView.isHidden = photoView.image != nil + if !isPhotoDownloading { + photoActivityIndicator.stopAnimating() + } + startPhotoLoadTask(attachmentId: attachment.id) + } + + private func startPhotoLoadTask(attachmentId: String) { + photoLoadTask?.cancel() + photoLoadTask = Task { [weak self] in + await ImageLoadLimiter.shared.acquire() + let loaded = await Task.detached(priority: .userInitiated) { + await AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + }.value + await ImageLoadLimiter.shared.release() + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self, self.photoAttachmentId == attachmentId, let loaded else { return } + self.photoView.image = loaded + self.photoView.isHidden = false + self.photoPlaceholderView.isHidden = true + self.photoActivityIndicator.stopAnimating() + self.isPhotoDownloading = false + } + } + } + + private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) { + guard !isPhotoDownloading else { return } + let tag = Self.extractTag(from: attachment.preview) + guard !tag.isEmpty, + let storedPassword = message.attachmentPassword, + !storedPassword.isEmpty else { + return + } + + isPhotoDownloading = true + photoActivityIndicator.startAnimating() + photoDownloadTask?.cancel() + let attachmentId = attachment.id + let preview = attachment.preview + + photoDownloadTask = Task { [weak self] in + do { + let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) + let encryptedString = String(decoding: encryptedData, as: UTF8.self) + let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) + let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords) + await MainActor.run { + guard let self, self.photoAttachmentId == attachmentId else { return } + if let image { + AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId) + self.photoView.image = image + self.photoView.isHidden = false + self.photoPlaceholderView.isHidden = true + } else { + self.photoView.image = Self.blurHashImage(from: preview) + self.photoView.isHidden = false + self.photoPlaceholderView.isHidden = self.photoView.image != nil + } + self.photoActivityIndicator.stopAnimating() + self.isPhotoDownloading = false + } + } catch { + await MainActor.run { + guard let self, self.photoAttachmentId == attachmentId else { return } + self.photoActivityIndicator.stopAnimating() + self.isPhotoDownloading = false + } + } + } + } + + private static func extractTag(from preview: String) -> String { + let parts = preview.components(separatedBy: "::") + return parts.first ?? preview + } + + private static func extractBlurHash(from preview: String) -> String { + let parts = preview.components(separatedBy: "::") + return parts.count > 1 ? parts[1] : "" + } + + private static func blurHashImage(from preview: String) -> UIImage? { + let hash = extractBlurHash(from: preview) + guard !hash.isEmpty else { return nil } + if let cached = blurHashCache.object(forKey: hash as NSString) { + return cached + } + guard let image = UIImage.fromBlurHash(hash, width: 48, height: 48) else { + return nil + } + blurHashCache.setObject(image, forKey: hash as NSString) + return image + } + + private static func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? { + let crypto = CryptoManager.shared + for password in passwords { + guard let data = try? crypto.decryptWithPassword( + encryptedString, password: password, requireCompression: true + ) else { continue } + if let image = parseImageData(data) { return image } + } + for password in passwords { + guard let data = try? crypto.decryptWithPassword(encryptedString, password: password) else { continue } + if let image = parseImageData(data) { return image } + } + return nil + } + + private static func parseImageData(_ data: Data) -> UIImage? { + if let str = String(data: data, encoding: .utf8) { + if str.hasPrefix("data:"), let commaIndex = str.firstIndex(of: ",") { + let base64Part = String(str[str.index(after: commaIndex)...]) + if let imageData = Data(base64Encoded: base64Part), + let image = AttachmentCache.downsampledImage(from: imageData) { + return image + } + } else if let imageData = Data(base64Encoded: str), + let image = AttachmentCache.downsampledImage(from: imageData) { + return image + } + } + return AttachmentCache.downsampledImage(from: data) + } + + private func startSendingClockAnimation() { + if clockFrameView.layer.animation(forKey: Self.sendingClockAnimationKey) == nil { + let frameRotation = CABasicAnimation(keyPath: "transform.rotation.z") + frameRotation.duration = 6.0 + frameRotation.fromValue = NSNumber(value: Float(0)) + frameRotation.toValue = NSNumber(value: Float(Double.pi * 2.0)) + frameRotation.repeatCount = .infinity + frameRotation.timingFunction = CAMediaTimingFunction(name: .linear) + frameRotation.beginTime = 1.0 + clockFrameView.layer.add(frameRotation, forKey: Self.sendingClockAnimationKey) + } + if clockMinView.layer.animation(forKey: Self.sendingClockAnimationKey) == nil { + let minRotation = CABasicAnimation(keyPath: "transform.rotation.z") + minRotation.duration = 1.0 + minRotation.fromValue = NSNumber(value: Float(0)) + minRotation.toValue = NSNumber(value: Float(Double.pi * 2.0)) + minRotation.repeatCount = .infinity + minRotation.timingFunction = CAMediaTimingFunction(name: .linear) + minRotation.beginTime = 1.0 + clockMinView.layer.add(minRotation, forKey: Self.sendingClockAnimationKey) + } + } + + private func stopSendingClockAnimation() { + clockFrameView.layer.removeAnimation(forKey: Self.sendingClockAnimationKey) + clockMinView.layer.removeAnimation(forKey: Self.sendingClockAnimationKey) + } + + private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) { + if isSentVisible && !wasSentCheckVisible { + let pop = CABasicAnimation(keyPath: "transform.scale") + pop.fromValue = NSNumber(value: Float(1.3)) + pop.toValue = NSNumber(value: Float(1.0)) + pop.duration = 0.1 + pop.timingFunction = CAMediaTimingFunction(name: .easeOut) + checkSentView.layer.add(pop, forKey: "checkPop") + } + + if isReadVisible && !wasReadCheckVisible { + let pop = CABasicAnimation(keyPath: "transform.scale") + pop.fromValue = NSNumber(value: Float(1.3)) + pop.toValue = NSNumber(value: Float(1.0)) + pop.duration = 0.1 + pop.timingFunction = CAMediaTimingFunction(name: .easeOut) + checkReadView.layer.add(pop, forKey: "checkPop") + } + + wasSentCheckVisible = isSentVisible + wasReadCheckVisible = isReadVisible + } + + private func updateStatusBackgroundVisibility() { + guard let layout = currentLayout else { + statusBackgroundView.isHidden = true + return + } + // Telegram uses a dedicated status background on media messages. + statusBackgroundView.isHidden = layout.messageType != .photo + } + + private func updateStatusBackgroundFrame() { + guard !statusBackgroundView.isHidden else { return } + var contentRect = timestampLabel.frame + let statusNodes = [checkSentView, checkReadView, clockFrameView, clockMinView] + for node in statusNodes where !node.isHidden { + contentRect = contentRect.union(node.frame) + } + let insets = Self.statusBubbleInsets + statusBackgroundView.frame = CGRect( + x: contentRect.minX - insets.left, + y: contentRect.minY - insets.top, + width: contentRect.width + insets.left + insets.right, + height: contentRect.height + insets.top + insets.bottom + ) + } + // MARK: - Reuse override func prepareForReuse() { @@ -426,9 +926,27 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel message = nil actions = nil currentLayout = nil - textLabel.text = nil + stopSendingClockAnimation() + textLabel.textLayout = nil timestampLabel.text = nil - checkmarkView.image = nil + checkSentView.image = nil + checkSentView.isHidden = true + checkReadView.image = nil + checkReadView.isHidden = true + clockFrameView.image = nil + clockFrameView.isHidden = true + clockMinView.image = nil + clockMinView.isHidden = true + wasSentCheckVisible = false + wasReadCheckVisible = false + statusBackgroundView.isHidden = true + photoAttachmentId = nil + photoLoadTask?.cancel() + photoLoadTask = nil + photoDownloadTask?.cancel() + photoDownloadTask = nil + isPhotoDownloading = false + photoActivityIndicator.stopAnimating() photoView.image = nil replyContainer.isHidden = true fileContainer.isHidden = true @@ -439,6 +957,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel photoPlaceholderView.isHidden = true bubbleView.transform = .identity replyIconView.alpha = 0 + deliveryFailedButton.isHidden = true + deliveryFailedButton.alpha = 0 + isDeliveryFailedVisible = false } } @@ -459,13 +980,14 @@ extension NativeMessageCell: UIGestureRecognizerDelegate { final class BubblePathCache { static let shared = BubblePathCache() + private let pathVersion = 7 private var cache: [String: CGPath] = [:] func path( size: CGSize, origin: CGPoint, position: BubblePosition, isOutgoing: Bool, hasTail: Bool ) -> CGPath { - let key = "\(Int(size.width))x\(Int(size.height))_\(Int(origin.x))_\(position)_\(isOutgoing)_\(hasTail)" + let key = "v\(pathVersion)_\(Int(size.width))x\(Int(size.height))_\(Int(origin.x))_\(position)_\(isOutgoing)_\(hasTail)" if let cached = cache[key] { return cached } let rect = CGRect(origin: origin, size: size) @@ -483,7 +1005,7 @@ final class BubblePathCache { private func makeBubblePath( in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool ) -> CGPath { - let r: CGFloat = 18, s: CGFloat = 8, tailW: CGFloat = 6 + let r: CGFloat = 16, s: CGFloat = 8, tailW: CGFloat = 6 // Body rect let bodyRect: CGRect @@ -527,7 +1049,7 @@ final class BubblePathCache { tangent2End: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY), radius: cTL) path.closeSubpath() - // Figma SVG tail + // Stable Figma tail (previous behavior) if hasTail { addFigmaTail(to: path, bodyRect: bodyRect, isOutgoing: isOutgoing) } @@ -535,19 +1057,21 @@ final class BubblePathCache { return path } + /// Figma SVG tail path (stable shape used before recent experiments). private func addFigmaTail(to path: CGMutablePath, bodyRect: CGRect, isOutgoing: Bool) { let svgStraightX: CGFloat = 5.59961 let svgMaxY: CGFloat = 33.2305 - let sc: CGFloat = 6 / svgStraightX - let tailH = svgMaxY * sc + let scale: CGFloat = 6.0 / svgStraightX + let tailH = svgMaxY * scale + let bodyEdge = isOutgoing ? bodyRect.maxX : bodyRect.minX let bottom = bodyRect.maxY let top = bottom - tailH let dir: CGFloat = isOutgoing ? 1 : -1 func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint { - let dx = (svgStraightX - svgX) * sc * dir - return CGPoint(x: bodyEdge + dx, y: top + svgY * sc) + let dx = (svgStraightX - svgX) * scale * dir + return CGPoint(x: bodyEdge + dx, y: top + svgY * scale) } if isOutgoing { diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 01051fd..5ec8a0f 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -82,6 +82,10 @@ final class NativeMessageListController: UIViewController { /// All frame rects computed once, applied on main thread (just sets frames). private var layoutCache: [String: MessageCellLayout] = [:] + /// Cache: messageId → pre-calculated CoreTextTextLayout for cell rendering. + /// Eliminates double CoreText computation (measure + render → measure once, render from cache). + private var textLayoutCache: [String: CoreTextTextLayout] = [:] + // MARK: - Init init(config: Config) { @@ -237,6 +241,7 @@ final class NativeMessageListController: UIViewController { cell.configure( message: message, timestamp: self.formatTimestamp(message.timestamp), + textLayout: self.textLayoutCache[message.id], actions: self.config.actions, replyName: replyName, replyText: replyText, @@ -399,35 +404,58 @@ final class NativeMessageListController: UIViewController { /// Called from SwiftUI when messages array changes. func update(messages: [ChatMessage], animated: Bool = false) { + let oldIds = Set(self.messages.map(\.id)) self.messages = messages - // Pre-calculate layouts (Telegram asyncLayout pattern). - // TODO: Move to background thread for full Telegram parity. - // Currently on main thread (still fast — C++ math + CoreText). + // Recalculate ALL layouts — BubblePosition depends on neighbors in the FULL + // array, so inserting one message changes the previous message's position/tail. + // CoreText measurement is ~0.1ms per message; 50 msgs ≈ 5ms — well under 16ms. calculateLayouts() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) - snapshot.appendItems(messages.reversed().map(\.id)) + let itemIds = messages.reversed().map(\.id) + snapshot.appendItems(itemIds) + + // Reconfigure existing cells whose BubblePosition/tail may have changed. + // Without this, DiffableDataSource reuses stale cells (wrong corners/tail). + let existingItems = itemIds.filter { oldIds.contains($0) } + if !existingItems.isEmpty { + snapshot.reconfigureItems(existingItems) + } + dataSource.apply(snapshot, animatingDifferences: animated) } // MARK: - Layout Calculation (Telegram asyncLayout pattern) - /// Pre-calculate layouts for NEW messages only (skip cached). + /// Recalculate layouts for ALL messages using the full array. + /// BubblePosition is computed from neighbors — partial recalculation produces + /// stale positions (wrong corners, missing tails on live insertion). private func calculateLayouts() { - let existingIds = Set(layoutCache.keys) - let newMessages = messages.filter { !existingIds.contains($0.id) } - guard !newMessages.isEmpty else { return } + guard !messages.isEmpty else { + layoutCache.removeAll() + textLayoutCache.removeAll() + return + } + #if DEBUG + let start = CFAbsoluteTimeGetCurrent() + #endif - let newLayouts = MessageCellLayout.batchCalculate( - messages: newMessages, + let (layouts, textLayouts) = MessageCellLayout.batchCalculate( + messages: messages, maxBubbleWidth: config.maxBubbleWidth, currentPublicKey: config.currentPublicKey, opponentPublicKey: config.opponentPublicKey, opponentTitle: config.opponentTitle ) - layoutCache.merge(newLayouts) { _, new in new } + 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 diff --git a/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift index 19a59f7..756680f 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift @@ -7,15 +7,15 @@ final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteraction // MARK: - Constants - private static let mainRadius: CGFloat = 18 - private static let smallRadius: CGFloat = 8 + private static let mainRadius: CGFloat = 16 + private static let smallRadius: CGFloat = 5 private static let tailProtrusion: CGFloat = 6 private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular) - private static let timestampFont = UIFont.systemFont(ofSize: 11, weight: .regular) + private static let timestampFont = UIFont.systemFont(ofSize: 9, weight: .regular) private static let replyNameFont = UIFont.systemFont(ofSize: 13, weight: .semibold) private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular) - private static let outgoingColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1, alpha: 1) - private static let incomingColor = UIColor(red: 0x2C/255.0, green: 0x2C/255.0, blue: 0x2E/255.0, alpha: 1) + private static let outgoingColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) // #3390EC + private static let incomingColor = UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E private static let replyQuoteHeight: CGFloat = 41 // MARK: - Subviews