Фикс: восстановлена загрузка собственного пузырька изображения и стабилизирован хвост / интервал

This commit is contained in:
2026-03-28 00:14:34 +05:00
parent 3a3489ac49
commit e03e3685e7
12 changed files with 1490 additions and 290 deletions

View File

@@ -13,6 +13,9 @@ final class MessageRepository: ObservableObject {
@Published private(set) var typingDialogs: Set<String> = [] @Published private(set) var typingDialogs: Set<String> = []
private var activeDialogs: Set<String> = [] private var activeDialogs: Set<String> = []
/// Dialogs that are currently eligible for interactive read:
/// screen is visible and list is at the bottom (Telegram-like behavior).
private var readEligibleDialogs: Set<String> = []
private var typingResetTasks: [String: Task<Void, Never>] = [:] private var typingResetTasks: [String: Task<Void, Never>] = [:]
private var currentAccount: String = "" private var currentAccount: String = ""
@@ -77,11 +80,19 @@ final class MessageRepository: ObservableObject {
} }
/// Load older messages for pagination (scroll-to-load-more). /// 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) let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey)
do { do {
let records = try db.read { db in let records = try db.read { db in
try MessageRecord if beforeMessageId.isEmpty {
return try MessageRecord
.filter( .filter(
MessageRecord.Columns.account == currentAccount && MessageRecord.Columns.account == currentAccount &&
MessageRecord.Columns.dialogKey == dbDialogKey && MessageRecord.Columns.dialogKey == dbDialogKey &&
@@ -91,6 +102,23 @@ final class MessageRepository: ObservableObject {
.limit(limit) .limit(limit)
.fetchAll(db) .fetchAll(db)
} }
return try MessageRecord
.filter(
MessageRecord.Columns.account == currentAccount &&
MessageRecord.Columns.dialogKey == dbDialogKey &&
(
MessageRecord.Columns.timestamp < beforeTimestamp ||
(
MessageRecord.Columns.timestamp == beforeTimestamp &&
MessageRecord.Columns.messageId < beforeMessageId
)
)
)
.order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc)
.limit(limit)
.fetchAll(db)
}
let older = records.reversed().map { decryptRecord($0) } let older = records.reversed().map { decryptRecord($0) }
// Prepend to cache // Prepend to cache
if var cached = messagesByDialog[dialogKey] { if var cached = messagesByDialog[dialogKey] {
@@ -140,6 +168,52 @@ final class MessageRepository: ObservableObject {
} catch { return nil } } 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 { func isLatestMessage(_ messageId: String, in dialogKey: String) -> Bool {
messages(for: dialogKey).last?.id == messageId messages(for: dialogKey).last?.id == messageId
} }
@@ -175,12 +249,30 @@ final class MessageRepository: ObservableObject {
activeDialogs.insert(dialogKey) activeDialogs.insert(dialogKey)
} else { } else {
activeDialogs.remove(dialogKey) activeDialogs.remove(dialogKey)
readEligibleDialogs.remove(dialogKey)
typingDialogs.remove(dialogKey) typingDialogs.remove(dialogKey)
typingResetTasks[dialogKey]?.cancel() typingResetTasks[dialogKey]?.cancel()
typingResetTasks[dialogKey] = nil 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 // MARK: - Message Updates
func upsertFromMessagePacket( func upsertFromMessagePacket(
@@ -196,7 +288,9 @@ final class MessageRepository: ObservableObject {
let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId
let timestamp = normalizeTimestamp(packet.timestamp) let timestamp = normalizeTimestamp(packet.timestamp)
let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey) 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) let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered)
// Add to LRU dedup cache // Add to LRU dedup cache
@@ -205,6 +299,9 @@ final class MessageRepository: ObservableObject {
// Android parity: encrypt plaintext with private key for local storage. // Android parity: encrypt plaintext with private key for local storage.
// Android: `encryptWithPassword(plainText, privateKey)` `plain_message` column. // Android: `encryptWithPassword(plainText, privateKey)` `plain_message` column.
// If encryption fails, store plaintext as fallback. // If encryption fails, store plaintext as fallback.
#if DEBUG
let encStart = CFAbsoluteTimeGetCurrent()
#endif
let storedText: String let storedText: String
if !privateKey.isEmpty, if !privateKey.isEmpty,
let enc = try? CryptoManager.shared.encryptWithPassword(Data(decryptedText.utf8), password: privateKey) { let enc = try? CryptoManager.shared.encryptWithPassword(Data(decryptedText.utf8), password: privateKey) {
@@ -212,6 +309,12 @@ final class MessageRepository: ObservableObject {
} else { } else {
storedText = decryptedText storedText = decryptedText
} }
#if DEBUG
let encElapsed = (CFAbsoluteTimeGetCurrent() - encStart) * 1000
if encElapsed > 5 {
print("⚡ PERF_ENCRYPT | upsert | \(String(format: "%.1f", encElapsed))ms (PBKDF2 CACHE MISS?)")
}
#endif
let encoder = JSONEncoder() let encoder = JSONEncoder()
let attachmentsJSON: String let attachmentsJSON: String
@@ -428,6 +531,7 @@ final class MessageRepository: ObservableObject {
} }
messagesByDialog.removeValue(forKey: dialogKey) messagesByDialog.removeValue(forKey: dialogKey)
activeDialogs.remove(dialogKey) activeDialogs.remove(dialogKey)
readEligibleDialogs.remove(dialogKey)
typingDialogs.remove(dialogKey) typingDialogs.remove(dialogKey)
typingResetTasks[dialogKey]?.cancel() typingResetTasks[dialogKey]?.cancel()
typingResetTasks[dialogKey] = nil typingResetTasks[dialogKey] = nil
@@ -624,6 +728,7 @@ final class MessageRepository: ObservableObject {
messagesByDialog.removeAll() messagesByDialog.removeAll()
typingDialogs.removeAll() typingDialogs.removeAll()
activeDialogs.removeAll() activeDialogs.removeAll()
readEligibleDialogs.removeAll()
processedMessageIds.removeAll() processedMessageIds.removeAll()
pendingCacheRefresh.removeAll() pendingCacheRefresh.removeAll()
cacheRefreshTask?.cancel() cacheRefreshTask?.cancel()

View File

@@ -14,6 +14,7 @@ struct MessageCellLayout: Sendable {
// MARK: - Cell // MARK: - Cell
let totalHeight: CGFloat let totalHeight: CGFloat
let groupGap: CGFloat
let isOutgoing: Bool let isOutgoing: Bool
let position: BubblePosition let position: BubblePosition
let messageType: MessageType let messageType: MessageType
@@ -33,7 +34,11 @@ struct MessageCellLayout: Sendable {
// MARK: - Timestamp // MARK: - Timestamp
let timestampFrame: CGRect // Timestamp label frame in bubble coords 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) // MARK: - Reply Quote (optional)
@@ -81,6 +86,7 @@ extension MessageCellLayout {
let maxBubbleWidth: CGFloat let maxBubbleWidth: CGFloat
let isOutgoing: Bool let isOutgoing: Bool
let position: BubblePosition let position: BubblePosition
let deliveryStatus: DeliveryStatus
let text: String let text: String
let hasReplyQuote: Bool let hasReplyQuote: Bool
let replyName: String? let replyName: String?
@@ -96,20 +102,26 @@ extension MessageCellLayout {
/// Calculate complete cell layout on ANY thread. /// Calculate complete cell layout on ANY thread.
/// Uses CoreText for text measurement (thread-safe). /// 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 /// Telegram-style tight bubbles: timestamp goes inline with last text line
/// when there's space, or on a new line when there isn't. /// 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 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 hasTail = (config.position == .single || config.position == .bottom)
let isTopOrSingle = (config.position == .single || config.position == .top) let isTopOrSingle = (config.position == .single || config.position == .top)
let topPad: CGFloat = isTopOrSingle ? 6 : 2 // Keep a visible separator between grouped bubbles in native UIKit mode.
let tailW: CGFloat = hasTail ? 6 : 0 // 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 let messageType: MessageType
if config.isForward { if config.isForward {
messageType = .forward messageType = .forward
@@ -124,163 +136,213 @@ extension MessageCellLayout {
} else { } else {
messageType = .text messageType = .text
} }
let isTextMessage = (messageType == .text || messageType == .textWithReply)
// Status (timestamp + checkmark) measurement // STEP 1: Asymmetric paddings + base text measurement (full width)
let tsSize = measureText("00:00", maxWidth: 60, font: tsFont) let topPad: CGFloat = 6 + screenPixel
let checkW: CGFloat = config.isOutgoing ? 14 : 0 let bottomPad: CGFloat = 6 - screenPixel
let statusGap: CGFloat = 8 // minimum gap between trailing text and status
let statusWidth = tsSize.width + checkW + statusGap
// Side padding inside bubble
let leftPad: CGFloat = 11 let leftPad: CGFloat = 11
let rightPad: CGFloat = 11 let rightPad: CGFloat = 11
// Text measurement at FULL width (no timestamp reservation Telegram pattern) // maxTextWidth = effectiveMaxBubbleWidth - (leftPad + rightPad)
let fullTextMaxW = config.maxBubbleWidth - leftPad - rightPad - tailW - 4 // Text is measured at the WIDEST possible constraint.
let isTextMessage = (messageType == .text || messageType == .textWithReply) let maxTextWidth = effectiveMaxBubbleWidth - leftPad - rightPad
let textMeasurement: TextMeasurement let textMeasurement: TextMeasurement
var cachedTextLayout: CoreTextTextLayout?
if !config.text.isEmpty && isTextMessage { 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 { } else if !config.text.isEmpty {
// Photo captions, forwards, files use old fixed-trailing approach // Captions, forwards, files
let tsTrailing: CGFloat = config.isOutgoing ? 53 : 37 let size = measureText(config.text, maxWidth: max(maxTextWidth, 50), font: font)
let textMaxW = config.maxBubbleWidth - leftPad - tsTrailing - tailW - 8
let size = measureText(config.text, maxWidth: max(textMaxW, 50), font: font)
textMeasurement = TextMeasurement(size: size, trailingLineWidth: size.width) textMeasurement = TextMeasurement(size: size, trailingLineWidth: size.width)
} else { } else {
textMeasurement = TextMeasurement(size: .zero, trailingLineWidth: 0) 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 timestampInline: Bool
let extraStatusH: CGFloat
if isTextMessage && !config.text.isEmpty { if isTextMessage && !config.text.isEmpty {
if textMeasurement.trailingLineWidth + statusWidth <= fullTextMaxW { 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 {
trailingWidthForStatus = textMeasurement.trailingLineWidth
}
timestampInline = trailingWidthForStatus + statusGap + metadataWidth <= maxTextWidth
} else {
timestampInline = true timestampInline = true
extraStatusH = 0
} else {
timestampInline = false
extraStatusH = tsSize.height + 2
}
} else {
timestampInline = true // non-text messages: status overlays
extraStatusH = 0
} }
// Reply quote // STEP 4: Bubble dimensions (unified width + height)
// Content blocks above the text area
let replyH: CGFloat = config.hasReplyQuote ? 46 : 0 let replyH: CGFloat = config.hasReplyQuote ? 46 : 0
// Photo collage
var photoH: CGFloat = 0 var photoH: CGFloat = 0
if config.imageCount > 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 let forwardHeaderH: CGFloat = config.isForward ? 40 : 0
// File
let fileH: CGFloat = CGFloat(config.fileCount) * 56 let fileH: CGFloat = CGFloat(config.fileCount) * 56
// Bubble width tight for text messages (Telegram pattern) // Tiny floor just to prevent zero-width collapse.
let minW: CGFloat = config.isOutgoing ? 86 : 66 // Telegram does NOT force a large minW short messages get tight bubbles.
let minW: CGFloat = 40
var bubbleW: CGFloat var bubbleW: CGFloat
var bubbleH: CGFloat = replyH + forwardHeaderH + photoH + fileH
if config.imageCount > 0 { if config.imageCount > 0 {
// Photos: full width // Photo: full width
bubbleW = config.maxBubbleWidth - tailW - 4 bubbleW = effectiveMaxBubbleWidth
} else if isTextMessage && !config.text.isEmpty {
// Tight bubble: just fits content + inline/new-line status
let contentW: CGFloat
if timestampInline {
contentW = max(textMeasurement.size.width,
textMeasurement.trailingLineWidth + statusWidth)
} else {
contentW = max(textMeasurement.size.width, statusWidth)
}
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 { if !config.text.isEmpty {
bubbleH += textMeasurement.size.height + 10 // 5pt top + 5pt bottom bubbleH += topPad + textMeasurement.size.height + bottomPad
bubbleH += extraStatusH // 0 if inline, ~15pt if new line if photoH > 0 { bubbleH += 6 }
if photoH > 0 { bubbleH += 6 } // caption padding
}
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward {
bubbleH = max(bubbleH, 36) // minimum
} }
} else if isTextMessage && !config.text.isEmpty {
// EXACT TELEGRAM MATH no other modifiers
let actualTextW = textMeasurement.size.width
let lastLineW = textMeasurement.trailingLineWidth
// Total height let finalContentW: CGFloat
let totalH = topPad + bubbleH + (hasTail ? 6 : 0) if timestampInline {
// INLINE: width = max(widest line, last line + gap + status)
// Bubble frame (X computed from cell width in layoutSubviews, this is approximate) finalContentW = max(actualTextW, lastLineW + statusGap + metadataWidth)
let bubbleX: CGFloat bubbleH += topPad + textMeasurement.size.height + bottomPad
if config.isOutgoing {
bubbleX = config.maxBubbleWidth - bubbleW - tailW + 10 - 2
} else { } else {
bubbleX = tailW + 10 + 2 // WRAPPED: status drops to new line below text
finalContentW = max(actualTextW, metadataWidth)
bubbleH += topPad + textMeasurement.size.height + 15 + bottomPad
} }
let bubbleFrame = CGRect(x: bubbleX, y: topPad, width: bubbleW, height: bubbleH)
// Text frame (in bubble coords) // Set bubble width TIGHTLY: leftPad + content + rightPad
var textY: CGFloat = 5 bubbleW = leftPad + finalContentW + rightPad
if config.hasReplyQuote { textY = replyH } bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
if forwardHeaderH > 0 { textY = forwardHeaderH } 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, 35)
}
let totalH = groupGap + 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)
// 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 { if photoH > 0 {
textY = photoH + 6 textY = photoH + 6 + topPad
if config.hasReplyQuote { textY = replyH + photoH + 6 } 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)
// Timestamp + checkmark frames (always bottom-right of bubble) let textFrame = CGRect(x: leftPad, y: textY,
let tsFrame = CGRect( width: bubbleW - leftPad - rightPad,
x: bubbleW - tsSize.width - checkW - rightPad, height: textMeasurement.size.height)
y: bubbleH - tsSize.height - 5,
// 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
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 width: tsSize.width, height: tsSize.height
) )
let checkFrame = CGRect( } else {
x: bubbleW - rightPad - 10, // Incoming: [timestamp] anchored right at statusEndX
y: bubbleH - tsSize.height - 4, tsFrame = CGRect(
width: 10, height: 10 x: statusEndX - tsSize.width,
y: statusEndY - tsSize.height,
width: tsSize.width, height: tsSize.height
) )
}
// Reply frames 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 replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: 41)
let replyBarFrame = CGRect(x: 0, y: 0, width: 3, 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 replyNameFrame = CGRect(x: 9, y: 2, width: bubbleW - 24, height: 17)
let replyTextFrame = CGRect(x: 9, y: 20, 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) 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) 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 fwdHeaderFrame = CGRect(x: 10, y: 6, width: bubbleW - 20, height: 14)
let fwdAvatarFrame = CGRect(x: 10, y: 23, width: 20, height: 20) let fwdAvatarFrame = CGRect(x: 10, y: 23, width: 20, height: 20)
let fwdNameFrame = CGRect(x: 34, y: 24, width: bubbleW - 44, height: 17) let fwdNameFrame = CGRect(x: 34, y: 24, width: bubbleW - 44, height: 17)
return MessageCellLayout( let layout = MessageCellLayout(
totalHeight: totalH, totalHeight: totalH,
groupGap: groupGap,
isOutgoing: config.isOutgoing, isOutgoing: config.isOutgoing,
position: config.position, position: config.position,
messageType: messageType, messageType: messageType,
@@ -291,7 +353,11 @@ extension MessageCellLayout {
textSize: textMeasurement.size, textSize: textMeasurement.size,
timestampInline: timestampInline, timestampInline: timestampInline,
timestampFrame: tsFrame, timestampFrame: tsFrame,
checkmarkFrame: checkFrame, checkSentFrame: checkSentFrame,
checkReadFrame: checkReadFrame,
clockFrame: clockFrame,
showsDeliveryFailedIndicator: isOutgoingFailed,
deliveryFailedInset: deliveryFailedInset,
hasReplyQuote: config.hasReplyQuote, hasReplyQuote: config.hasReplyQuote,
replyContainerFrame: replyContainerFrame, replyContainerFrame: replyContainerFrame,
replyBarFrame: replyBarFrame, replyBarFrame: replyBarFrame,
@@ -307,6 +373,7 @@ extension MessageCellLayout {
forwardAvatarFrame: fwdAvatarFrame, forwardAvatarFrame: fwdAvatarFrame,
forwardNameFrame: fwdNameFrame forwardNameFrame: fwdNameFrame
) )
return (layout, cachedTextLayout)
} }
// MARK: - Collage Height (Thread-Safe) // MARK: - Collage Height (Thread-Safe)
@@ -355,56 +422,20 @@ extension MessageCellLayout {
let trailingLineWidth: CGFloat // Width of the LAST line only let trailingLineWidth: CGFloat // Width of the LAST line only
} }
/// CoreText detailed text measurement returns both overall size and trailing line width. /// Telegram-exact text measurement using CTTypesetter + manual line breaking.
/// Uses CTFramesetter + CTFrame (thread-safe) for per-line width analysis. /// Returns BOTH measurement AND the full CoreTextTextLayout for cell rendering cache.
/// This enables Telegram-style inline timestamp positioning. /// This eliminates the double CoreText computation (measure + render).
private static func measureTextDetailed( private static func measureTextDetailedWithLayout(
_ text: String, maxWidth: CGFloat, font: UIFont _ text: String, maxWidth: CGFloat, font: UIFont
) -> TextMeasurement { ) -> (TextMeasurement, CoreTextTextLayout) {
guard !text.isEmpty else { let layout = CoreTextTextLayout.calculate(
return TextMeasurement(size: .zero, trailingLineWidth: 0) text: text, maxWidth: maxWidth, font: font, textColor: .white
}
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
) )
let frame = CTFramesetterCreateFrame( let measurement = TextMeasurement(
framesetter, CFRange(location: 0, length: 0), path, nil size: layout.size,
) trailingLineWidth: layout.lastLineWidth
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)
) )
return (measurement, layout)
} }
// MARK: - Garbage Text Detection (Thread-Safe) // MARK: - Garbage Text Detection (Thread-Safe)
@@ -435,6 +466,80 @@ extension MessageCellLayout {
return false 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) // MARK: - Batch Calculation (Background Thread)
@@ -442,6 +547,7 @@ extension MessageCellLayout {
extension MessageCellLayout { extension MessageCellLayout {
/// Pre-calculate layouts for all messages on background queue. /// 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. /// Telegram equivalent: ListView calls asyncLayout() on background.
static func batchCalculate( static func batchCalculate(
messages: [ChatMessage], messages: [ChatMessage],
@@ -449,18 +555,42 @@ extension MessageCellLayout {
currentPublicKey: String, currentPublicKey: String,
opponentPublicKey: String, opponentPublicKey: String,
opponentTitle: String opponentTitle: String
) -> [String: MessageCellLayout] { ) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) {
var result: [String: MessageCellLayout] = [:] var result: [String: MessageCellLayout] = [:]
var textResult: [String: CoreTextTextLayout] = [:]
for (index, message) in messages.enumerated() { for (index, message) in messages.enumerated() {
let isOutgoing = message.fromPublicKey == currentPublicKey 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 position: BubblePosition = {
let hasPrev = index > 0 && let hasPrev: Bool = {
(messages[index - 1].fromPublicKey == currentPublicKey) == isOutgoing guard index > 0 else { return false }
let hasNext = index + 1 < messages.count && let prev = messages[index - 1]
(messages[index + 1].fromPublicKey == currentPublicKey) == isOutgoing 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) { switch (hasPrev, hasNext) {
case (false, false): return .single case (false, false): return .single
case (false, true): return .top 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 // Classify
let images = message.attachments.filter { $0.type == .image } let images = message.attachments.filter { $0.type == .image }
let files = message.attachments.filter { $0.type == .file } let files = message.attachments.filter { $0.type == .file }
@@ -483,6 +610,7 @@ extension MessageCellLayout {
maxBubbleWidth: maxBubbleWidth, maxBubbleWidth: maxBubbleWidth,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
position: position, position: position,
deliveryStatus: message.deliveryStatus,
text: displayText, text: displayText,
hasReplyQuote: hasReply && !displayText.isEmpty, hasReplyQuote: hasReply && !displayText.isEmpty,
replyName: nil, replyName: nil,
@@ -496,9 +624,11 @@ extension MessageCellLayout {
forwardCaption: nil 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)
} }
} }

View File

@@ -95,12 +95,31 @@ final class ProtocolManager: @unchecked Sendable {
/// Connect to server and perform handshake. /// Connect to server and perform handshake.
func connect(publicKey: String, privateKeyHash: String) { 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 savedPublicKey = publicKey
savedPrivateHash = privateKeyHash savedPrivateHash = privateKeyHash
if connectionState == .authenticated || connectionState == .handshaking { switch connectionState {
case .authenticated, .handshaking, .deviceVerificationRequired:
Self.logger.info("Already connected/handshaking, skipping") Self.logger.info("Already connected/handshaking, skipping")
return 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 connectionState = .connecting
@@ -110,11 +129,20 @@ final class ProtocolManager: @unchecked Sendable {
func disconnect() { func disconnect() {
Self.logger.info("Disconnecting") Self.logger.info("Disconnecting")
heartbeatTask?.cancel() heartbeatTask?.cancel()
heartbeatTask = nil
handshakeTimeoutTask?.cancel() handshakeTimeoutTask?.cancel()
handshakeTimeoutTask = nil
pingTimeoutTask?.cancel() pingTimeoutTask?.cancel()
pingTimeoutTask = nil pingTimeoutTask = nil
pingVerificationInProgress = false pingVerificationInProgress = false
handshakeComplete = false handshakeComplete = false
clearPacketQueue()
clearResultHandlers()
syncBatchLock.lock()
_syncBatchActive = false
syncBatchLock.unlock()
pendingDeviceVerification = nil
devices = []
client.disconnect() client.disconnect()
connectionState = .disconnected connectionState = .disconnected
savedPublicKey = nil savedPublicKey = nil
@@ -305,6 +333,9 @@ final class ProtocolManager: @unchecked Sendable {
Self.logger.error("Disconnected: \(error.localizedDescription)") Self.logger.error("Disconnected: \(error.localizedDescription)")
} }
heartbeatTask?.cancel() heartbeatTask?.cancel()
heartbeatTask = nil
handshakeTimeoutTask?.cancel()
handshakeTimeoutTask = nil
handshakeComplete = false handshakeComplete = false
pingVerificationInProgress = false pingVerificationInProgress = false
pingTimeoutTask?.cancel() pingTimeoutTask?.cancel()
@@ -650,6 +681,12 @@ final class ProtocolManager: @unchecked Sendable {
packetQueueLock.unlock() packetQueueLock.unlock()
} }
private func clearResultHandlers() {
resultHandlersLock.lock()
resultHandlers.removeAll()
resultHandlersLock.unlock()
}
// MARK: - Device Verification // MARK: - Device Verification
private func handleDeviceList(_ packet: PacketDeviceList) { private func handleDeviceList(_ packet: PacketDeviceList) {

View File

@@ -62,6 +62,17 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
// MARK: - Connection // 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() { func connect() {
// Android parity: prevent duplicate connect() calls (Protocol.kt lines 237-256). // Android parity: prevent duplicate connect() calls (Protocol.kt lines 237-256).
guard webSocketTask == nil else { return } 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) try? await Task.sleep(nanoseconds: 15_000_000_000)
guard let self, !Task.isCancelled, self.isConnecting else { return } guard let self, !Task.isCancelled, self.isConnecting else { return }
Self.logger.warning("Connection establishment timeout (15s)") Self.logger.warning("Connection establishment timeout (15s)")
self.isConnecting = false self.interruptConnecting()
self.webSocketTask?.cancel(with: .goingAway, reason: nil) self.webSocketTask?.cancel(
with: .normalClosure,
reason: self.closeReasonData("Reconnecting")
)
self.webSocketTask = nil self.webSocketTask = nil
self.isConnected = false self.isConnected = false
self.handleDisconnect(error: NSError( self.handleDisconnect(error: NSError(
@@ -106,12 +120,13 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
func disconnect() { func disconnect() {
Self.logger.info("Manual disconnect") Self.logger.info("Manual disconnect")
isManuallyClosed = true isManuallyClosed = true
isConnecting = false interruptConnecting()
reconnectTask?.cancel() reconnectTask?.cancel()
reconnectTask = nil reconnectTask = nil
connectTimeoutTask?.cancel() webSocketTask?.cancel(
connectTimeoutTask = nil with: .normalClosure,
webSocketTask?.cancel(with: .goingAway, reason: nil) reason: closeReasonData("User disconnected")
)
webSocketTask = nil webSocketTask = nil
isConnected = false isConnected = false
} }
@@ -122,13 +137,14 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return } guard !isManuallyClosed else { return }
reconnectTask?.cancel() reconnectTask?.cancel()
reconnectTask = nil reconnectTask = nil
connectTimeoutTask?.cancel() interruptConnecting()
connectTimeoutTask = nil
// Always tear down and reconnect connection may be zombie after background // 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 webSocketTask = nil
isConnected = false isConnected = false
isConnecting = false
disconnectHandledForCurrentSocket = false disconnectHandledForCurrentSocket = false
// Android parity: reset backoff so next failure starts from 1s, not stale 8s/16s. // Android parity: reset backoff so next failure starts from 1s, not stale 8s/16s.
reconnectAttempts = 0 reconnectAttempts = 0
@@ -217,7 +233,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
Self.logger.info("didClose ignored: stale socket (not current task)") Self.logger.info("didClose ignored: stale socket (not current task)")
return return
} }
isConnecting = false interruptConnecting()
isConnected = false isConnected = false
handleDisconnect(error: nil) handleDisconnect(error: nil)
} }
@@ -229,7 +245,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
// Ignore callbacks from old (cancelled) sockets after forceReconnect. // Ignore callbacks from old (cancelled) sockets after forceReconnect.
guard task === self.webSocketTask else { return } guard task === self.webSocketTask else { return }
Self.logger.warning("URLSession task failed: \(error.localizedDescription)") Self.logger.warning("URLSession task failed: \(error.localizedDescription)")
isConnecting = false interruptConnecting()
isConnected = false isConnected = false
handleDisconnect(error: error) handleDisconnect(error: error)
} }
@@ -261,7 +277,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
// Android parity (onFailure): clear isConnecting before handleDisconnect. // Android parity (onFailure): clear isConnecting before handleDisconnect.
// Without this, if connection fails before didOpenWithProtocol (DNS/TLS error), // Without this, if connection fails before didOpenWithProtocol (DNS/TLS error),
// isConnecting stays true handleDisconnect returns early no reconnect ever scheduled. // isConnecting stays true handleDisconnect returns early no reconnect ever scheduled.
self.isConnecting = false self.interruptConnecting()
self.handleDisconnect(error: error) self.handleDisconnect(error: error)
} }
} }
@@ -270,19 +286,14 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
// MARK: - Reconnection // MARK: - Reconnection
private func handleDisconnect(error: Error?) { private func handleDisconnect(error: Error?) {
// Android parity (Protocol.kt:562-566): if a new connection is already // Ensure all disconnect paths break current "connecting" state.
// in progress, ignore stale disconnect from previous socket. interruptConnecting()
if isConnecting {
Self.logger.info("Disconnect ignored: connection already in progress")
return
}
if disconnectHandledForCurrentSocket { if disconnectHandledForCurrentSocket {
return return
} }
disconnectHandledForCurrentSocket = true disconnectHandledForCurrentSocket = true
webSocketTask = nil webSocketTask = nil
isConnected = false isConnected = false
isConnecting = false
onDisconnected?(error) onDisconnected?(error)
guard !isManuallyClosed else { return } guard !isManuallyClosed else { return }

View File

@@ -110,6 +110,7 @@ final class SessionManager {
let myKey = currentPublicKey let myKey = currentPublicKey
for dialogKey in activeKeys { for dialogKey in activeKeys {
guard !SystemAccounts.isSystemAccount(dialogKey) else { continue } guard !SystemAccounts.isSystemAccount(dialogKey) else { continue }
guard MessageRepository.shared.isDialogReadEligible(dialogKey) else { continue }
DialogRepository.shared.markAsRead(opponentKey: dialogKey) DialogRepository.shared.markAsRead(opponentKey: dialogKey)
MessageRepository.shared.markIncomingAsRead( MessageRepository.shared.markIncomingAsRead(
opponentKey: dialogKey, myPublicKey: myKey opponentKey: dialogKey, myPublicKey: myKey
@@ -167,6 +168,15 @@ final class SessionManager {
// account if the app version changed since the last notice. // account if the app version changed since the last notice.
sendReleaseNotesIfNeeded(publicKey: account.publicKey) 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 // Generate private key hash for handshake
let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex) let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex)
privateKeyHash = hash privateKeyHash = hash
@@ -1427,9 +1437,10 @@ final class SessionManager {
// Android parity: mark as read if dialog is active AND app is in foreground. // 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). // Android has NO idle detection only isDialogActive flag (ON_RESUME/ON_PAUSE).
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey) let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
let dialogIsReadEligible = MessageRepository.shared.isDialogReadEligible(opponentKey)
let isSystem = SystemAccounts.isSystemAccount(opponentKey) let isSystem = SystemAccounts.isSystemAccount(opponentKey)
let fg = isAppInForeground let fg = isAppInForeground
let shouldMarkRead = dialogIsActive && fg && !isSystem let shouldMarkRead = dialogIsActive && dialogIsReadEligible && fg && !isSystem
if shouldMarkRead { if shouldMarkRead {
DialogRepository.shared.markAsRead(opponentKey: opponentKey) DialogRepository.shared.markAsRead(opponentKey: opponentKey)

View File

@@ -65,8 +65,8 @@ struct MessageBubbleShape: Shape {
// MARK: - Body (Rounded Rect with Per-Corner Radii) // MARK: - Body (Rounded Rect with Per-Corner Radii)
private func addBody(to p: inout Path, rect: CGRect) { private func addBody(to p: inout Path, rect: CGRect) {
let r: CGFloat = 18 let r: CGFloat = 16
let s: CGFloat = 8 let s: CGFloat = 5
let (tl, tr, bl, br) = cornerRadii(r: r, s: s) let (tl, tr, bl, br) = cornerRadii(r: r, s: s)
// Clamp to half the smallest dimension // Clamp to half the smallest dimension

View File

@@ -127,7 +127,8 @@ struct ChatDetailView: View {
} }
private var maxBubbleWidth: CGFloat { 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. /// Visual chat content: messages list + gradient overlays + background.
@@ -196,7 +197,12 @@ struct ChatDetailView: View {
cellActions.onDelete = { [self] msg in messageToDelete = msg } cellActions.onDelete = { [self] msg in messageToDelete = msg }
cellActions.onCopy = { text in UIPasteboard.general.string = text } cellActions.onCopy = { text in UIPasteboard.general.string = text }
cellActions.onImageTap = { [self] attId in openImageViewer(attachmentId: attId) } 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.onRetry = { [self] msg in retryMessage(msg) }
cellActions.onRemove = { [self] msg in removeMessage(msg) } cellActions.onRemove = { [self] msg in removeMessage(msg) }
// Capture first unread incoming message BEFORE marking as read. // Capture first unread incoming message BEFORE marking as read.
@@ -214,13 +220,11 @@ struct ChatDetailView: View {
// setDialogActive only touches MessageRepository.activeDialogs (Set), // setDialogActive only touches MessageRepository.activeDialogs (Set),
// does NOT mutate DialogRepository, so ForEach won't rebuild. // does NOT mutate DialogRepository, so ForEach won't rebuild.
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
updateReadEligibility()
clearDeliveredNotifications(for: route.publicKey) clearDeliveredNotifications(for: route.publicKey)
// Android parity: mark messages as read in DB IMMEDIATELY (no delay). // Telegram-like read policy: mark read only when dialog is truly readable
// This prevents reconcileUnreadCounts() from re-inflating badge // (view active + list at bottom).
// if it runs during the 600ms navigation delay. markDialogAsRead()
MessageRepository.shared.markIncomingAsRead(
opponentKey: route.publicKey, myPublicKey: currentPublicKey
)
// Request user info (non-mutating, won't trigger list rebuild) // Request user info (non-mutating, won't trigger list rebuild)
requestUserInfoIfNeeded() requestUserInfoIfNeeded()
// Delay DialogRepository mutations to let navigation transition complete. // Delay DialogRepository mutations to let navigation transition complete.
@@ -229,6 +233,7 @@ struct ChatDetailView: View {
try? await Task.sleep(for: .milliseconds(600)) try? await Task.sleep(for: .milliseconds(600))
guard isViewActive else { return } guard isViewActive else { return }
activateDialog() activateDialog()
updateReadEligibility()
markDialogAsRead() markDialogAsRead()
// Desktop parity: skip online subscription and user info fetch for system accounts // Desktop parity: skip online subscription and user info fetch for system accounts
if !route.isSystemAccount { if !route.isSystemAccount {
@@ -242,15 +247,11 @@ struct ChatDetailView: View {
} }
} }
.onDisappear { .onDisappear {
isViewActive = false
firstUnreadMessageId = nil firstUnreadMessageId = nil
// Android parity: mark all messages as read when leaving dialog. // Flush final read only if dialog is still eligible at the moment of closing.
// Android's unmount callback does SQL UPDATE messages SET read = 1. markDialogAsRead()
// Don't re-send read receipt it was already sent during the session. isViewActive = false
DialogRepository.shared.markAsRead(opponentKey: route.publicKey) updateReadEligibility()
MessageRepository.shared.markIncomingAsRead(
opponentKey: route.publicKey, myPublicKey: currentPublicKey
)
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false) MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
// Desktop parity: save draft text on chat close. // Desktop parity: save draft text on chat close.
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText) DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
@@ -724,6 +725,10 @@ private extension ChatDetailView {
scrollToBottomRequested: $scrollToBottomRequested, scrollToBottomRequested: $scrollToBottomRequested,
onAtBottomChange: { atBottom in onAtBottomChange: { atBottom in
isAtBottom = atBottom isAtBottom = atBottom
updateReadEligibility()
if atBottom {
markDialogAsRead()
}
}, },
onPaginate: { onPaginate: {
Task { await viewModel.loadMore() } Task { await viewModel.loadMore() }
@@ -736,6 +741,7 @@ private extension ChatDetailView {
let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true
if isViewActive && !lastIsOutgoing if isViewActive && !lastIsOutgoing
&& !route.isSavedMessages && !route.isSystemAccount { && !route.isSavedMessages && !route.isSystemAccount {
updateReadEligibility()
markDialogAsRead() markDialogAsRead()
} }
}, },
@@ -1252,14 +1258,19 @@ private extension ChatDetailView {
for att in replyData.attachments { for att in replyData.attachments {
if att.type == AttachmentType.image.rawValue { if att.type == AttachmentType.image.rawValue {
// Image re-upload // Image re-upload
if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id), if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id) {
let jpegData = image.jpegData(compressionQuality: 0.85) { // 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 forwardedImages[att.id] = jpegData
#if DEBUG #if DEBUG
print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)") print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)")
#endif #endif
continue continue
} }
}
// Not in cache download from CDN, decrypt, then include. // Not in cache download from CDN, decrypt, then include.
let cdnTag = att.preview.components(separatedBy: "::").first ?? "" let cdnTag = att.preview.components(separatedBy: "::").first ?? ""
@@ -1285,8 +1296,22 @@ private extension ChatDetailView {
let encryptedString = String(decoding: encryptedData, as: UTF8.self) let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: password) let passwords = MessageCrypto.attachmentPasswordCandidates(from: password)
if let img = Self.decryptForwardImage(encryptedString: encryptedString, passwords: passwords), // Decrypt on background thread PBKDF2 per candidate is 50-100ms.
let jpegData = img.jpegData(compressionQuality: 0.85) { #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 forwardedImages[att.id] = jpegData
AttachmentCache.shared.saveImage(img, forAttachmentId: att.id) AttachmentCache.shared.saveImage(img, forAttachmentId: att.id)
#if DEBUG #if DEBUG
@@ -1341,7 +1366,20 @@ private extension ChatDetailView {
let encryptedString = String(decoding: encryptedData, as: UTF8.self) let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: password) 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) forwardedFiles[att.id] = (data: fileData, fileName: fileName)
#if DEBUG #if DEBUG
print("📤 File \(att.id.prefix(16)): CDN download+decrypt OK (\(fileData.count) bytes, name=\(fileName))") 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. /// 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 let crypto = CryptoManager.shared
for password in passwords { for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true), if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true),
@@ -1412,7 +1451,7 @@ private extension ChatDetailView {
return nil 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), if let str = String(data: data, encoding: .utf8),
str.hasPrefix("data:"), str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") { let commaIndex = str.firstIndex(of: ",") {
@@ -1425,8 +1464,8 @@ private extension ChatDetailView {
} }
/// Decrypt a CDN-downloaded file blob with multiple password candidates. /// Decrypt a CDN-downloaded file blob with multiple password candidates.
/// Returns raw file data (extracted from data URI). /// `nonisolated` safe to call from background (no UI access, only CryptoManager).
private static func decryptForwardFile(encryptedString: String, passwords: [String]) -> Data? { nonisolated private static func decryptForwardFile(encryptedString: String, passwords: [String]) -> Data? {
let crypto = CryptoManager.shared let crypto = CryptoManager.shared
for password in passwords { for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true), 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}"). /// 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), if let str = String(data: data, encoding: .utf8),
str.hasPrefix("data:"), str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") { let commaIndex = str.firstIndex(of: ",") {
@@ -1497,6 +1536,14 @@ private extension ChatDetailView {
SessionManager.shared.requestUserInfoIfNeeded(forKey: route.publicKey) 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() { func activateDialog() {
// Only update existing dialogs; don't create ghost entries from search. // Only update existing dialogs; don't create ghost entries from search.
// New dialogs are created when messages are sent/received (SessionManager). // New dialogs are created when messages are sent/received (SessionManager).
@@ -1510,9 +1557,11 @@ private extension ChatDetailView {
) )
} }
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
updateReadEligibility()
} }
func markDialogAsRead() { func markDialogAsRead() {
guard MessageRepository.shared.isDialogReadEligible(route.publicKey) else { return }
DialogRepository.shared.markAsRead(opponentKey: route.publicKey) DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey) MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
// Desktop parity: don't send read receipts for system accounts // Desktop parity: don't send read receipts for system accounts

View File

@@ -94,6 +94,7 @@ final class ChatDetailViewModel: ObservableObject {
let older = MessageRepository.shared.loadOlderMessages( let older = MessageRepository.shared.loadOlderMessages(
for: dialogKey, for: dialogKey,
beforeTimestamp: earliest.timestamp, beforeTimestamp: earliest.timestamp,
beforeMessageId: earliest.id,
limit: MessageRepository.pageSize limit: MessageRepository.pageSize
) )
@@ -103,4 +104,35 @@ final class ChatDetailViewModel: ObservableObject {
// messages will update via Combine pipeline (repo already prepends to cache). // messages will update via Combine pipeline (repo already prepends to cache).
isLoadingMore = false 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
}
} }

View File

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

View File

@@ -13,29 +13,143 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// MARK: - Constants // MARK: - Constants
private static let outgoingColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1, 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: 0x2C/255.0, green: 0x2C/255.0, blue: 0x2E/255.0, alpha: 1) 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 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 replyNameFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular) private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular)
private static let forwardLabelFont = 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 forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium) private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular) 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<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 200
return cache
}()
// MARK: - Subviews (always present, hidden when unused) // MARK: - Subviews (always present, hidden when unused)
// Bubble // Bubble
private let bubbleView = UIView() private let bubbleView = UIView()
private let bubbleLayer = CAShapeLayer() private let bubbleLayer = CAShapeLayer()
private let bubbleOutlineLayer = CAShapeLayer()
// Text // Text (CoreText rendering matches Telegram's CTTypesetter + CTRunDraw pipeline)
private let textLabel = UILabel() private let textLabel = CoreTextLabel()
// Timestamp + delivery // Timestamp + delivery
private let statusBackgroundView = UIView()
private let timestampLabel = UILabel() 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 // Reply quote
private let replyContainer = UIView() private let replyContainer = UIView()
@@ -46,6 +160,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// Photo // Photo
private let photoView = UIImageView() private let photoView = UIImageView()
private let photoPlaceholderView = UIView() private let photoPlaceholderView = UIView()
private let photoActivityIndicator = UIActivityIndicatorView(style: .medium)
// File // File
private let fileContainer = UIView() private let fileContainer = UIView()
@@ -60,12 +175,20 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// Swipe-to-reply // Swipe-to-reply
private let replyIconView = UIImageView() private let replyIconView = UIImageView()
private let deliveryFailedButton = UIButton(type: .custom)
// MARK: - State // MARK: - State
private var message: ChatMessage? private var message: ChatMessage?
private var actions: MessageCellActions? private var actions: MessageCellActions?
private var currentLayout: MessageCellLayout? private var currentLayout: MessageCellLayout?
private var isDeliveryFailedVisible = false
private var wasSentCheckVisible = false
private var wasReadCheckVisible = false
private var photoAttachmentId: String?
private var photoLoadTask: Task<Void, Never>?
private var photoDownloadTask: Task<Void, Never>?
private var isPhotoDownloading = false
// MARK: - Init // MARK: - Init
@@ -86,23 +209,38 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// Bubble // Bubble
bubbleLayer.fillColor = Self.outgoingColor.cgColor 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) 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) contentView.addSubview(bubbleView)
// Text // Text (CoreTextLabel no font/color/lines config; all baked into CoreTextTextLayout)
textLabel.font = Self.textFont
textLabel.textColor = .white
textLabel.numberOfLines = 0
textLabel.lineBreakMode = .byWordWrapping
bubbleView.addSubview(textLabel) bubbleView.addSubview(textLabel)
// Timestamp // Timestamp
statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.32)
statusBackgroundView.layer.cornerRadius = 6
statusBackgroundView.isHidden = true
bubbleView.addSubview(statusBackgroundView)
timestampLabel.font = Self.timestampFont timestampLabel.font = Self.timestampFont
bubbleView.addSubview(timestampLabel) bubbleView.addSubview(timestampLabel)
// Checkmark // Checkmarks (Telegram two-node overlay: sent + read /)
checkmarkView.contentMode = .scaleAspectFit checkSentView.contentMode = .scaleAspectFit
bubbleView.addSubview(checkmarkView) bubbleView.addSubview(checkSentView)
checkReadView.contentMode = .scaleAspectFit
bubbleView.addSubview(checkReadView)
clockFrameView.contentMode = .scaleAspectFit
clockMinView.contentMode = .scaleAspectFit
bubbleView.addSubview(clockFrameView)
bubbleView.addSubview(clockMinView)
// Reply quote // Reply quote
replyBar.layer.cornerRadius = 1.5 replyBar.layer.cornerRadius = 1.5
@@ -117,11 +255,19 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// Photo // Photo
photoView.contentMode = .scaleAspectFill photoView.contentMode = .scaleAspectFill
photoView.clipsToBounds = true photoView.clipsToBounds = true
photoView.isUserInteractionEnabled = true
bubbleView.addSubview(photoView) bubbleView.addSubview(photoView)
photoPlaceholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1) photoPlaceholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
bubbleView.addSubview(photoPlaceholderView) bubbleView.addSubview(photoPlaceholderView)
photoActivityIndicator.color = .white
photoActivityIndicator.hidesWhenStopped = true
bubbleView.addSubview(photoActivityIndicator)
let photoTap = UITapGestureRecognizer(target: self, action: #selector(handlePhotoTap))
photoView.addGestureRecognizer(photoTap)
// File // File
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconView.layer.cornerRadius = 20 fileIconView.layer.cornerRadius = 20
@@ -155,6 +301,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
replyIconView.alpha = 0 replyIconView.alpha = 0
contentView.addSubview(replyIconView) 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 // Interactions
let contextMenu = UIContextMenuInteraction(delegate: self) let contextMenu = UIContextMenuInteraction(delegate: self)
bubbleView.addInteraction(contextMenu) bubbleView.addInteraction(contextMenu)
@@ -167,9 +321,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// MARK: - Configure + Apply Layout // MARK: - Configure + Apply Layout
/// Configure cell data (content). Does NOT trigger layout. /// Configure cell data (content). Does NOT trigger layout.
/// `textLayout` is pre-computed during `calculateLayouts()` no double CoreText work.
func configure( func configure(
message: ChatMessage, message: ChatMessage,
timestamp: String, timestamp: String,
textLayout: CoreTextTextLayout? = nil,
actions: MessageCellActions, actions: MessageCellActions,
replyName: String? = nil, replyName: String? = nil,
replyText: String? = nil, replyText: String? = nil,
@@ -179,33 +335,56 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
self.actions = actions self.actions = actions
let isOutgoing = currentLayout?.isOutgoing ?? false let isOutgoing = currentLayout?.isOutgoing ?? false
let isMediaStatus = currentLayout?.messageType == .photo
// Text (filter garbage/encrypted UIKit path parity with SwiftUI) // Text use cached CoreTextTextLayout from measurement phase.
textLabel.text = MessageCellLayout.isGarbageOrEncrypted(message.text) ? "" : message.text // Same CTTypesetter pipeline identical line breaks, zero recomputation.
textLabel.textLayout = textLayout
// Timestamp // Timestamp
timestampLabel.text = timestamp timestampLabel.text = timestamp
if isMediaStatus {
timestampLabel.textColor = .white
} else {
timestampLabel.textColor = isOutgoing timestampLabel.textColor = isOutgoing
? UIColor.white.withAlphaComponent(0.55) ? UIColor.white.withAlphaComponent(0.55)
: UIColor.white.withAlphaComponent(0.6) : 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 { if isOutgoing {
checkmarkView.isHidden = false
switch message.deliveryStatus { switch message.deliveryStatus {
case .delivered: case .delivered:
checkmarkView.image = UIImage(systemName: "checkmark")?.withRenderingMode(.alwaysTemplate) shouldShowSentCheck = true
checkmarkView.tintColor = message.isRead ? .white : UIColor.white.withAlphaComponent(0.55) checkSentView.image = isMediaStatus ? Self.mediaFullCheckImage : Self.fullCheckImage
if message.isRead {
checkReadView.image = isMediaStatus ? Self.mediaPartialCheckImage : Self.partialCheckImage
shouldShowReadCheck = true
}
case .waiting: case .waiting:
checkmarkView.image = UIImage(systemName: "clock")?.withRenderingMode(.alwaysTemplate) shouldShowClock = true
checkmarkView.tintColor = UIColor.white.withAlphaComponent(0.55) clockFrameView.image = isMediaStatus ? Self.mediaClockFrameImage : Self.clockFrameImage
clockMinView.image = isMediaStatus ? Self.mediaClockMinImage : Self.clockMinImage
startSendingClockAnimation()
case .error: case .error:
checkmarkView.image = UIImage(systemName: "exclamationmark.circle")?.withRenderingMode(.alwaysTemplate) break
checkmarkView.tintColor = .red
} }
} 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 // Bubble color
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
@@ -236,9 +415,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
forwardNameLabel.isHidden = true forwardNameLabel.isHidden = true
} }
// Photo placeholder (actual image loading handled separately) // Photo
photoView.isHidden = !(currentLayout?.hasPhoto ?? false) configurePhoto(for: message)
photoPlaceholderView.isHidden = !(currentLayout?.hasPhoto ?? false)
// File // File
if let layout = currentLayout, layout.hasFile { if let layout = currentLayout, layout.hasFile {
@@ -265,20 +443,17 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let cellW = contentView.bounds.width let cellW = contentView.bounds.width
let tailW: CGFloat = layout.hasTail ? 6 : 0 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 // Rule 2: Tail reserve (6pt) + margin (2pt) strict vertical body alignment
// This is computed from CELL WIDTH, not maxBubbleWidth
let bubbleX: CGFloat let bubbleX: CGFloat
if layout.isOutgoing { if layout.isOutgoing {
bubbleX = cellW - layout.bubbleSize.width - tailW - 2 bubbleX = cellW - layout.bubbleSize.width - 6 - 2 - layout.deliveryFailedInset
} else { } else {
bubbleX = tailW + 2 bubbleX = 6 + 2
} }
bubbleView.frame = CGRect( bubbleView.frame = CGRect(
x: bubbleX, y: topPad, x: bubbleX, y: layout.groupGap,
width: layout.bubbleSize.width, height: layout.bubbleSize.height width: layout.bubbleSize.width, height: layout.bubbleSize.height
) )
bubbleLayer.frame = bubbleView.bounds bubbleLayer.frame = bubbleView.bounds
@@ -299,14 +474,32 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
size: shapeRect.size, origin: shapeRect.origin, size: shapeRect.size, origin: shapeRect.origin,
position: layout.position, isOutgoing: layout.isOutgoing, hasTail: layout.hasTail 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 // Text
textLabel.isHidden = layout.textSize == .zero textLabel.isHidden = layout.textSize == .zero
textLabel.frame = layout.textFrame textLabel.frame = layout.textFrame
// Timestamp + checkmark // Timestamp + checkmarks (two-node overlay)
timestampLabel.frame = layout.timestampFrame 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 // Reply
replyContainer.isHidden = !layout.hasReplyQuote replyContainer.isHidden = !layout.hasReplyQuote
@@ -323,6 +516,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
if layout.hasPhoto { if layout.hasPhoto {
photoView.frame = layout.photoFrame photoView.frame = layout.photoFrame
photoPlaceholderView.frame = layout.photoFrame photoPlaceholderView.frame = layout.photoFrame
photoActivityIndicator.center = CGPoint(x: layout.photoFrame.midX, y: layout.photoFrame.midY)
} }
// File // File
@@ -341,6 +535,43 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
forwardNameLabel.frame = layout.forwardNameFrame 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 // Reply icon (for swipe gesture) use actual bubbleView frame
replyIconView.frame = CGRect( replyIconView.frame = CGRect(
x: layout.isOutgoing 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 // MARK: - Reuse
override func prepareForReuse() { override func prepareForReuse() {
@@ -426,9 +926,27 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
message = nil message = nil
actions = nil actions = nil
currentLayout = nil currentLayout = nil
textLabel.text = nil stopSendingClockAnimation()
textLabel.textLayout = nil
timestampLabel.text = 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 photoView.image = nil
replyContainer.isHidden = true replyContainer.isHidden = true
fileContainer.isHidden = true fileContainer.isHidden = true
@@ -439,6 +957,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoPlaceholderView.isHidden = true photoPlaceholderView.isHidden = true
bubbleView.transform = .identity bubbleView.transform = .identity
replyIconView.alpha = 0 replyIconView.alpha = 0
deliveryFailedButton.isHidden = true
deliveryFailedButton.alpha = 0
isDeliveryFailedVisible = false
} }
} }
@@ -459,13 +980,14 @@ extension NativeMessageCell: UIGestureRecognizerDelegate {
final class BubblePathCache { final class BubblePathCache {
static let shared = BubblePathCache() static let shared = BubblePathCache()
private let pathVersion = 7
private var cache: [String: CGPath] = [:] private var cache: [String: CGPath] = [:]
func path( func path(
size: CGSize, origin: CGPoint, size: CGSize, origin: CGPoint,
position: BubblePosition, isOutgoing: Bool, hasTail: Bool position: BubblePosition, isOutgoing: Bool, hasTail: Bool
) -> CGPath { ) -> 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 } if let cached = cache[key] { return cached }
let rect = CGRect(origin: origin, size: size) let rect = CGRect(origin: origin, size: size)
@@ -483,7 +1005,7 @@ final class BubblePathCache {
private func makeBubblePath( private func makeBubblePath(
in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool
) -> CGPath { ) -> CGPath {
let r: CGFloat = 18, s: CGFloat = 8, tailW: CGFloat = 6 let r: CGFloat = 16, s: CGFloat = 8, tailW: CGFloat = 6
// Body rect // Body rect
let bodyRect: CGRect let bodyRect: CGRect
@@ -527,7 +1049,7 @@ final class BubblePathCache {
tangent2End: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY), radius: cTL) tangent2End: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY), radius: cTL)
path.closeSubpath() path.closeSubpath()
// Figma SVG tail // Stable Figma tail (previous behavior)
if hasTail { if hasTail {
addFigmaTail(to: path, bodyRect: bodyRect, isOutgoing: isOutgoing) addFigmaTail(to: path, bodyRect: bodyRect, isOutgoing: isOutgoing)
} }
@@ -535,19 +1057,21 @@ final class BubblePathCache {
return path return path
} }
/// Figma SVG tail path (stable shape used before recent experiments).
private func addFigmaTail(to path: CGMutablePath, bodyRect: CGRect, isOutgoing: Bool) { private func addFigmaTail(to path: CGMutablePath, bodyRect: CGRect, isOutgoing: Bool) {
let svgStraightX: CGFloat = 5.59961 let svgStraightX: CGFloat = 5.59961
let svgMaxY: CGFloat = 33.2305 let svgMaxY: CGFloat = 33.2305
let sc: CGFloat = 6 / svgStraightX let scale: CGFloat = 6.0 / svgStraightX
let tailH = svgMaxY * sc let tailH = svgMaxY * scale
let bodyEdge = isOutgoing ? bodyRect.maxX : bodyRect.minX let bodyEdge = isOutgoing ? bodyRect.maxX : bodyRect.minX
let bottom = bodyRect.maxY let bottom = bodyRect.maxY
let top = bottom - tailH let top = bottom - tailH
let dir: CGFloat = isOutgoing ? 1 : -1 let dir: CGFloat = isOutgoing ? 1 : -1
func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint { func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint {
let dx = (svgStraightX - svgX) * sc * dir let dx = (svgStraightX - svgX) * scale * dir
return CGPoint(x: bodyEdge + dx, y: top + svgY * sc) return CGPoint(x: bodyEdge + dx, y: top + svgY * scale)
} }
if isOutgoing { if isOutgoing {

View File

@@ -82,6 +82,10 @@ final class NativeMessageListController: UIViewController {
/// All frame rects computed once, applied on main thread (just sets frames). /// All frame rects computed once, applied on main thread (just sets frames).
private var layoutCache: [String: MessageCellLayout] = [:] 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 // MARK: - Init
init(config: Config) { init(config: Config) {
@@ -237,6 +241,7 @@ final class NativeMessageListController: UIViewController {
cell.configure( cell.configure(
message: message, message: message,
timestamp: self.formatTimestamp(message.timestamp), timestamp: self.formatTimestamp(message.timestamp),
textLayout: self.textLayoutCache[message.id],
actions: self.config.actions, actions: self.config.actions,
replyName: replyName, replyName: replyName,
replyText: replyText, replyText: replyText,
@@ -399,35 +404,58 @@ final class NativeMessageListController: UIViewController {
/// Called from SwiftUI when messages array changes. /// Called from SwiftUI when messages array changes.
func update(messages: [ChatMessage], animated: Bool = false) { func update(messages: [ChatMessage], animated: Bool = false) {
let oldIds = Set(self.messages.map(\.id))
self.messages = messages self.messages = messages
// Pre-calculate layouts (Telegram asyncLayout pattern). // Recalculate ALL layouts BubblePosition depends on neighbors in the FULL
// TODO: Move to background thread for full Telegram parity. // array, so inserting one message changes the previous message's position/tail.
// Currently on main thread (still fast C++ math + CoreText). // CoreText measurement is ~0.1ms per message; 50 msgs 5ms well under 16ms.
calculateLayouts() calculateLayouts()
var snapshot = NSDiffableDataSourceSnapshot<Int, String>() var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
snapshot.appendSections([0]) 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) dataSource.apply(snapshot, animatingDifferences: animated)
} }
// MARK: - Layout Calculation (Telegram asyncLayout pattern) // 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() { private func calculateLayouts() {
let existingIds = Set(layoutCache.keys) guard !messages.isEmpty else {
let newMessages = messages.filter { !existingIds.contains($0.id) } layoutCache.removeAll()
guard !newMessages.isEmpty else { return } textLayoutCache.removeAll()
return
}
#if DEBUG
let start = CFAbsoluteTimeGetCurrent()
#endif
let newLayouts = MessageCellLayout.batchCalculate( let (layouts, textLayouts) = MessageCellLayout.batchCalculate(
messages: newMessages, messages: messages,
maxBubbleWidth: config.maxBubbleWidth, maxBubbleWidth: config.maxBubbleWidth,
currentPublicKey: config.currentPublicKey, currentPublicKey: config.currentPublicKey,
opponentPublicKey: config.opponentPublicKey, opponentPublicKey: config.opponentPublicKey,
opponentTitle: config.opponentTitle 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 // MARK: - Inset Management

View File

@@ -7,15 +7,15 @@ final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteraction
// MARK: - Constants // MARK: - Constants
private static let mainRadius: CGFloat = 18 private static let mainRadius: CGFloat = 16
private static let smallRadius: CGFloat = 8 private static let smallRadius: CGFloat = 5
private static let tailProtrusion: CGFloat = 6 private static let tailProtrusion: CGFloat = 6
private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular) 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 replyNameFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular) 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 outgoingColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) // #3390EC
private static let incomingColor = UIColor(red: 0x2C/255.0, green: 0x2C/255.0, blue: 0x2E/255.0, alpha: 1) private static let incomingColor = UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E
private static let replyQuoteHeight: CGFloat = 41 private static let replyQuoteHeight: CGFloat = 41
// MARK: - Subviews // MARK: - Subviews