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