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