Фикс: восстановлена загрузка собственного пузырька изображения и стабилизирован хвост / интервал
This commit is contained in:
@@ -65,8 +65,8 @@ struct MessageBubbleShape: Shape {
|
||||
// MARK: - Body (Rounded Rect with Per-Corner Radii)
|
||||
|
||||
private func addBody(to p: inout Path, rect: CGRect) {
|
||||
let r: CGFloat = 18
|
||||
let s: CGFloat = 8
|
||||
let r: CGFloat = 16
|
||||
let s: CGFloat = 5
|
||||
let (tl, tr, bl, br) = cornerRadii(r: r, s: s)
|
||||
|
||||
// Clamp to half the smallest dimension
|
||||
|
||||
@@ -127,7 +127,8 @@ struct ChatDetailView: View {
|
||||
}
|
||||
|
||||
private var maxBubbleWidth: CGFloat {
|
||||
max(min(UIScreen.main.bounds.width * 0.72, 380), 140)
|
||||
let w = UIScreen.main.bounds.width
|
||||
return w <= 500 ? w - 36 : w * 0.85
|
||||
}
|
||||
|
||||
/// Visual chat content: messages list + gradient overlays + background.
|
||||
@@ -196,7 +197,12 @@ struct ChatDetailView: View {
|
||||
cellActions.onDelete = { [self] msg in messageToDelete = msg }
|
||||
cellActions.onCopy = { text in UIPasteboard.general.string = text }
|
||||
cellActions.onImageTap = { [self] attId in openImageViewer(attachmentId: attId) }
|
||||
cellActions.onScrollToMessage = { [self] msgId in scrollToMessageId = msgId }
|
||||
cellActions.onScrollToMessage = { [self] msgId in
|
||||
Task { @MainActor in
|
||||
guard await viewModel.ensureMessageLoaded(messageId: msgId) else { return }
|
||||
scrollToMessageId = msgId
|
||||
}
|
||||
}
|
||||
cellActions.onRetry = { [self] msg in retryMessage(msg) }
|
||||
cellActions.onRemove = { [self] msg in removeMessage(msg) }
|
||||
// Capture first unread incoming message BEFORE marking as read.
|
||||
@@ -214,13 +220,11 @@ struct ChatDetailView: View {
|
||||
// setDialogActive only touches MessageRepository.activeDialogs (Set),
|
||||
// does NOT mutate DialogRepository, so ForEach won't rebuild.
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
||||
updateReadEligibility()
|
||||
clearDeliveredNotifications(for: route.publicKey)
|
||||
// Android parity: mark messages as read in DB IMMEDIATELY (no delay).
|
||||
// This prevents reconcileUnreadCounts() from re-inflating badge
|
||||
// if it runs during the 600ms navigation delay.
|
||||
MessageRepository.shared.markIncomingAsRead(
|
||||
opponentKey: route.publicKey, myPublicKey: currentPublicKey
|
||||
)
|
||||
// Telegram-like read policy: mark read only when dialog is truly readable
|
||||
// (view active + list at bottom).
|
||||
markDialogAsRead()
|
||||
// Request user info (non-mutating, won't trigger list rebuild)
|
||||
requestUserInfoIfNeeded()
|
||||
// Delay DialogRepository mutations to let navigation transition complete.
|
||||
@@ -229,6 +233,7 @@ struct ChatDetailView: View {
|
||||
try? await Task.sleep(for: .milliseconds(600))
|
||||
guard isViewActive else { return }
|
||||
activateDialog()
|
||||
updateReadEligibility()
|
||||
markDialogAsRead()
|
||||
// Desktop parity: skip online subscription and user info fetch for system accounts
|
||||
if !route.isSystemAccount {
|
||||
@@ -242,15 +247,11 @@ struct ChatDetailView: View {
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
isViewActive = false
|
||||
firstUnreadMessageId = nil
|
||||
// Android parity: mark all messages as read when leaving dialog.
|
||||
// Android's unmount callback does SQL UPDATE messages SET read = 1.
|
||||
// Don't re-send read receipt — it was already sent during the session.
|
||||
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
|
||||
MessageRepository.shared.markIncomingAsRead(
|
||||
opponentKey: route.publicKey, myPublicKey: currentPublicKey
|
||||
)
|
||||
// Flush final read only if dialog is still eligible at the moment of closing.
|
||||
markDialogAsRead()
|
||||
isViewActive = false
|
||||
updateReadEligibility()
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
|
||||
// Desktop parity: save draft text on chat close.
|
||||
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
|
||||
@@ -724,6 +725,10 @@ private extension ChatDetailView {
|
||||
scrollToBottomRequested: $scrollToBottomRequested,
|
||||
onAtBottomChange: { atBottom in
|
||||
isAtBottom = atBottom
|
||||
updateReadEligibility()
|
||||
if atBottom {
|
||||
markDialogAsRead()
|
||||
}
|
||||
},
|
||||
onPaginate: {
|
||||
Task { await viewModel.loadMore() }
|
||||
@@ -736,6 +741,7 @@ private extension ChatDetailView {
|
||||
let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true
|
||||
if isViewActive && !lastIsOutgoing
|
||||
&& !route.isSavedMessages && !route.isSystemAccount {
|
||||
updateReadEligibility()
|
||||
markDialogAsRead()
|
||||
}
|
||||
},
|
||||
@@ -1252,13 +1258,18 @@ private extension ChatDetailView {
|
||||
for att in replyData.attachments {
|
||||
if att.type == AttachmentType.image.rawValue {
|
||||
// ── Image re-upload ──
|
||||
if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id),
|
||||
let jpegData = image.jpegData(compressionQuality: 0.85) {
|
||||
forwardedImages[att.id] = jpegData
|
||||
#if DEBUG
|
||||
print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)")
|
||||
#endif
|
||||
continue
|
||||
if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id) {
|
||||
// JPEG encoding (10-50ms) off main thread
|
||||
let jpegData = await Task.detached(priority: .userInitiated) {
|
||||
image.jpegData(compressionQuality: 0.85)
|
||||
}.value
|
||||
if let jpegData {
|
||||
forwardedImages[att.id] = jpegData
|
||||
#if DEBUG
|
||||
print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Not in cache — download from CDN, decrypt, then include.
|
||||
@@ -1285,8 +1296,22 @@ private extension ChatDetailView {
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: password)
|
||||
|
||||
if let img = Self.decryptForwardImage(encryptedString: encryptedString, passwords: passwords),
|
||||
let jpegData = img.jpegData(compressionQuality: 0.85) {
|
||||
// Decrypt on background thread — PBKDF2 per candidate is 50-100ms.
|
||||
#if DEBUG
|
||||
let decryptStart = CFAbsoluteTimeGetCurrent()
|
||||
print("⚡ PERF_DECRYPT | Image \(att.id.prefix(12)): starting background decrypt (\(passwords.count) candidates)")
|
||||
#endif
|
||||
let imgResult = await Task.detached(priority: .userInitiated) {
|
||||
guard let img = Self.decryptForwardImage(encryptedString: encryptedString, passwords: passwords),
|
||||
let jpegData = img.jpegData(compressionQuality: 0.85) else { return nil as (UIImage, Data)? }
|
||||
return (img, jpegData)
|
||||
}.value
|
||||
#if DEBUG
|
||||
let decryptMs = (CFAbsoluteTimeGetCurrent() - decryptStart) * 1000
|
||||
print("⚡ PERF_DECRYPT | Image \(att.id.prefix(12)): \(imgResult != nil ? "OK" : "FAIL") in \(String(format: "%.0f", decryptMs))ms (BACKGROUND)")
|
||||
#endif
|
||||
|
||||
if let (img, jpegData) = imgResult {
|
||||
forwardedImages[att.id] = jpegData
|
||||
AttachmentCache.shared.saveImage(img, forAttachmentId: att.id)
|
||||
#if DEBUG
|
||||
@@ -1341,7 +1366,20 @@ private extension ChatDetailView {
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: password)
|
||||
|
||||
if let fileData = Self.decryptForwardFile(encryptedString: encryptedString, passwords: passwords) {
|
||||
// Decrypt on background thread — PBKDF2 per candidate is 50-100ms.
|
||||
#if DEBUG
|
||||
let fileDecryptStart = CFAbsoluteTimeGetCurrent()
|
||||
print("⚡ PERF_DECRYPT | File \(att.id.prefix(12)): starting background decrypt (\(passwords.count) candidates)")
|
||||
#endif
|
||||
let fileData = await Task.detached(priority: .userInitiated) {
|
||||
Self.decryptForwardFile(encryptedString: encryptedString, passwords: passwords)
|
||||
}.value
|
||||
#if DEBUG
|
||||
let fileDecryptMs = (CFAbsoluteTimeGetCurrent() - fileDecryptStart) * 1000
|
||||
print("⚡ PERF_DECRYPT | File \(att.id.prefix(12)): \(fileData != nil ? "OK" : "FAIL") in \(String(format: "%.0f", fileDecryptMs))ms (BACKGROUND)")
|
||||
#endif
|
||||
|
||||
if let fileData {
|
||||
forwardedFiles[att.id] = (data: fileData, fileName: fileName)
|
||||
#if DEBUG
|
||||
print("📤 File \(att.id.prefix(16)): CDN download+decrypt OK (\(fileData.count) bytes, name=\(fileName))")
|
||||
@@ -1399,7 +1437,8 @@ private extension ChatDetailView {
|
||||
}
|
||||
|
||||
/// Decrypt a CDN-downloaded image blob with multiple password candidates.
|
||||
private static func decryptForwardImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
||||
/// `nonisolated` — safe to call from background (no UI access, only CryptoManager).
|
||||
nonisolated private static func decryptForwardImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
||||
let crypto = CryptoManager.shared
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true),
|
||||
@@ -1412,7 +1451,7 @@ private extension ChatDetailView {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseForwardImageData(_ data: Data) -> UIImage? {
|
||||
nonisolated private static func parseForwardImageData(_ data: Data) -> UIImage? {
|
||||
if let str = String(data: data, encoding: .utf8),
|
||||
str.hasPrefix("data:"),
|
||||
let commaIndex = str.firstIndex(of: ",") {
|
||||
@@ -1425,8 +1464,8 @@ private extension ChatDetailView {
|
||||
}
|
||||
|
||||
/// Decrypt a CDN-downloaded file blob with multiple password candidates.
|
||||
/// Returns raw file data (extracted from data URI).
|
||||
private static func decryptForwardFile(encryptedString: String, passwords: [String]) -> Data? {
|
||||
/// `nonisolated` — safe to call from background (no UI access, only CryptoManager).
|
||||
nonisolated private static func decryptForwardFile(encryptedString: String, passwords: [String]) -> Data? {
|
||||
let crypto = CryptoManager.shared
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true),
|
||||
@@ -1440,7 +1479,7 @@ private extension ChatDetailView {
|
||||
}
|
||||
|
||||
/// Extract raw file bytes from a data URI (format: "data:{mime};base64,{base64data}").
|
||||
private static func parseForwardFileData(_ data: Data) -> Data? {
|
||||
nonisolated private static func parseForwardFileData(_ data: Data) -> Data? {
|
||||
if let str = String(data: data, encoding: .utf8),
|
||||
str.hasPrefix("data:"),
|
||||
let commaIndex = str.firstIndex(of: ",") {
|
||||
@@ -1497,6 +1536,14 @@ private extension ChatDetailView {
|
||||
SessionManager.shared.requestUserInfoIfNeeded(forKey: route.publicKey)
|
||||
}
|
||||
|
||||
/// Dialog is readable only when this screen is active and list is at bottom.
|
||||
func updateReadEligibility() {
|
||||
MessageRepository.shared.setDialogReadEligible(
|
||||
route.publicKey,
|
||||
isEligible: isViewActive && isAtBottom
|
||||
)
|
||||
}
|
||||
|
||||
func activateDialog() {
|
||||
// Only update existing dialogs; don't create ghost entries from search.
|
||||
// New dialogs are created when messages are sent/received (SessionManager).
|
||||
@@ -1510,9 +1557,11 @@ private extension ChatDetailView {
|
||||
)
|
||||
}
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
||||
updateReadEligibility()
|
||||
}
|
||||
|
||||
func markDialogAsRead() {
|
||||
guard MessageRepository.shared.isDialogReadEligible(route.publicKey) else { return }
|
||||
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
|
||||
MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
|
||||
// Desktop parity: don't send read receipts for system accounts
|
||||
|
||||
@@ -94,6 +94,7 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
let older = MessageRepository.shared.loadOlderMessages(
|
||||
for: dialogKey,
|
||||
beforeTimestamp: earliest.timestamp,
|
||||
beforeMessageId: earliest.id,
|
||||
limit: MessageRepository.pageSize
|
||||
)
|
||||
|
||||
@@ -103,4 +104,35 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
// messages will update via Combine pipeline (repo already prepends to cache).
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
/// Ensures a target message is present in current dialog cache before scroll-to-message.
|
||||
/// Returns true when the message is available to the UI list.
|
||||
func ensureMessageLoaded(messageId: String) async -> Bool {
|
||||
guard !messageId.isEmpty else { return false }
|
||||
if messages.contains(where: { $0.id == messageId }) {
|
||||
return true
|
||||
}
|
||||
|
||||
let repo = MessageRepository.shared
|
||||
guard repo.ensureMessageLoaded(for: dialogKey, messageId: messageId) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Wait briefly for Combine debounce (50ms) to propagate to this view model.
|
||||
for _ in 0..<8 {
|
||||
if messages.contains(where: { $0.id == messageId }) {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(for: .milliseconds(16))
|
||||
}
|
||||
|
||||
// Fallback: force a direct snapshot refresh from repository.
|
||||
let refreshed = repo.messages(for: dialogKey)
|
||||
if refreshed.contains(where: { $0.id == messageId }) {
|
||||
messages = refreshed
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
273
Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift
Normal file
273
Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift
Normal file
@@ -0,0 +1,273 @@
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
// MARK: - Telegram-Exact Text Layout (Pre-calculated)
|
||||
|
||||
/// Pre-calculated text layout using Telegram's exact CoreText pipeline.
|
||||
///
|
||||
/// Telegram uses CTTypesetter (NOT CTFramesetter) for manual line breaking,
|
||||
/// custom inter-line spacing (12% of fontLineHeight), and CTRunDraw for rendering.
|
||||
/// UILabel/TextKit produce different line breaks and density — this class
|
||||
/// reproduces Telegram's exact algorithm from:
|
||||
/// - InteractiveTextComponent.swift (lines 1480-1548) — line breaking
|
||||
/// - TextNode.swift (lines 1723-1726) — font metrics & line spacing
|
||||
/// - InteractiveTextComponent.swift (lines 2358-2573) — rendering
|
||||
///
|
||||
/// Two-phase pattern (matches Telegram asyncLayout):
|
||||
/// 1. `CoreTextTextLayout.calculate()` — runs on ANY thread (background-safe)
|
||||
/// 2. `CoreTextLabel.draw()` — runs on main thread, renders pre-calculated lines
|
||||
final class CoreTextTextLayout {
|
||||
|
||||
// MARK: - Line
|
||||
|
||||
/// A single laid-out line with position and metrics.
|
||||
struct Line {
|
||||
let ctLine: CTLine
|
||||
let origin: CGPoint // Top-left corner in UIKit (top-down) coordinates
|
||||
let width: CGFloat // Typographic advance width (CTLineGetTypographicBounds)
|
||||
let ascent: CGFloat // Distance from baseline to top of tallest glyph
|
||||
let descent: CGFloat // Distance from baseline to bottom of lowest glyph
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let lines: [Line]
|
||||
let size: CGSize // Bounding box (ceil'd max-line-width × total height)
|
||||
let lastLineWidth: CGFloat // Width of the final line — for inline timestamp decisions
|
||||
let lastLineHasRTL: Bool
|
||||
let lastLineHasBlockQuote: Bool
|
||||
let textColor: UIColor
|
||||
|
||||
private init(
|
||||
lines: [Line],
|
||||
size: CGSize,
|
||||
lastLineWidth: CGFloat,
|
||||
lastLineHasRTL: Bool,
|
||||
lastLineHasBlockQuote: Bool,
|
||||
textColor: UIColor
|
||||
) {
|
||||
self.lines = lines
|
||||
self.size = size
|
||||
self.lastLineWidth = lastLineWidth
|
||||
self.lastLineHasRTL = lastLineHasRTL
|
||||
self.lastLineHasBlockQuote = lastLineHasBlockQuote
|
||||
self.textColor = textColor
|
||||
}
|
||||
|
||||
// MARK: - Telegram Line Spacing
|
||||
|
||||
/// Telegram default: 12% of font line height.
|
||||
/// Source: TextNode.swift line 277, InteractiveTextComponent.swift line 299.
|
||||
static let telegramLineSpacingFactor: CGFloat = 0.12
|
||||
|
||||
// MARK: - Layout Calculation (Thread-Safe)
|
||||
|
||||
/// Calculate text layout using Telegram's exact algorithm.
|
||||
///
|
||||
/// Algorithm (InteractiveTextComponent.swift lines 1480-1548):
|
||||
/// 1. `CTTypesetterCreateWithAttributedString` — create typesetter
|
||||
/// 2. Loop: `CTTypesetterSuggestLineBreak` → `CTTypesetterCreateLine`
|
||||
/// 3. `CTLineGetTypographicBounds` for per-line ascent/descent/width
|
||||
/// 4. Accumulate height with `floor(fontLineHeight * 0.12)` inter-line spacing
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - text: Raw message text
|
||||
/// - maxWidth: Maximum line width constraint (bubbleW - leftPad - rightPad)
|
||||
/// - font: Text font (default: system 17pt, matching Telegram)
|
||||
/// - textColor: Foreground color baked into attributed string
|
||||
/// - lineSpacingFactor: Inter-line spacing as fraction of font line height (default: 0.12)
|
||||
/// - Returns: Pre-calculated layout with lines, size, and lastLineWidth
|
||||
static func calculate(
|
||||
text: String,
|
||||
maxWidth: CGFloat,
|
||||
font: UIFont = .systemFont(ofSize: 17),
|
||||
textColor: UIColor = .white,
|
||||
lineSpacingFactor: CGFloat = telegramLineSpacingFactor
|
||||
) -> CoreTextTextLayout {
|
||||
// Guard: empty text, non-positive width, or NaN → return zero layout
|
||||
let safeMaxWidth = maxWidth.isNaN ? 100 : max(maxWidth, 10)
|
||||
guard !text.isEmpty, safeMaxWidth >= 10 else {
|
||||
return CoreTextTextLayout(
|
||||
lines: [],
|
||||
size: .zero,
|
||||
lastLineWidth: 0,
|
||||
lastLineHasRTL: false,
|
||||
lastLineHasBlockQuote: false,
|
||||
textColor: textColor
|
||||
)
|
||||
}
|
||||
|
||||
// ── Attributed string (Telegram: StringWithAppliedEntities.swift) ──
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: textColor
|
||||
]
|
||||
let attrString = NSAttributedString(string: text, attributes: attributes)
|
||||
let stringLength = attrString.length
|
||||
|
||||
// ── Typesetter (Telegram: InteractiveTextComponent line 1481) ──
|
||||
let typesetter = CTTypesetterCreateWithAttributedString(attrString as CFAttributedString)
|
||||
|
||||
// ── Font metrics (Telegram: TextNode.swift lines 1723-1726) ──
|
||||
let ctFont = font as CTFont
|
||||
let fontAscent = CTFontGetAscent(ctFont)
|
||||
let fontDescent = CTFontGetDescent(ctFont)
|
||||
let fontLineHeight = floor(fontAscent + fontDescent)
|
||||
let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor)
|
||||
|
||||
var resultLines: [Line] = []
|
||||
var currentIndex: CFIndex = 0
|
||||
var currentY: CGFloat = 0
|
||||
var maxLineWidth: CGFloat = 0
|
||||
var lastLineWidth: CGFloat = 0
|
||||
var lastLineRange = NSRange(location: 0, length: 0)
|
||||
|
||||
// ── Line breaking loop (Telegram: InteractiveTextComponent lines 1490-1548) ──
|
||||
// Safety: cap iterations to stringLength to prevent infinite loop if
|
||||
// CTTypesetterSuggestLineBreak returns non-advancing counts.
|
||||
var iterations = 0
|
||||
while currentIndex < stringLength {
|
||||
iterations += 1
|
||||
if iterations > stringLength + 1 { break } // infinite loop guard
|
||||
|
||||
// Suggest line break (word boundary)
|
||||
let lineCharCount = CTTypesetterSuggestLineBreak(
|
||||
typesetter, currentIndex, Double(safeMaxWidth)
|
||||
)
|
||||
guard lineCharCount > 0 else { break }
|
||||
|
||||
// Create line from typesetter
|
||||
let ctLine = CTTypesetterCreateLine(
|
||||
typesetter, CFRange(location: currentIndex, length: lineCharCount)
|
||||
)
|
||||
|
||||
// Measure line (Telegram: CTLineGetTypographicBounds)
|
||||
var lineAscent: CGFloat = 0
|
||||
var lineDescent: CGFloat = 0
|
||||
let lineWidth = CGFloat(CTLineGetTypographicBounds(
|
||||
ctLine, &lineAscent, &lineDescent, nil
|
||||
))
|
||||
|
||||
// Guard against NaN from CoreText (observed with certain Cyrillic strings)
|
||||
guard !lineWidth.isNaN, !lineAscent.isNaN, !lineDescent.isNaN else { break }
|
||||
|
||||
let clampedWidth = min(lineWidth, safeMaxWidth)
|
||||
|
||||
// Inter-line spacing (applied BETWEEN lines, not before first)
|
||||
if !resultLines.isEmpty {
|
||||
currentY += fontLineSpacing
|
||||
}
|
||||
|
||||
resultLines.append(Line(
|
||||
ctLine: ctLine,
|
||||
origin: CGPoint(x: 0, y: currentY),
|
||||
width: clampedWidth,
|
||||
ascent: lineAscent,
|
||||
descent: lineDescent
|
||||
))
|
||||
|
||||
// Advance by font line height (Telegram uses font-level, not per-line)
|
||||
currentY += fontLineHeight
|
||||
|
||||
maxLineWidth = max(maxLineWidth, clampedWidth)
|
||||
lastLineWidth = clampedWidth
|
||||
lastLineRange = NSRange(location: currentIndex, length: lineCharCount)
|
||||
currentIndex += lineCharCount
|
||||
}
|
||||
|
||||
let nsText = text as NSString
|
||||
let safeLastRange = NSIntersectionRange(lastLineRange, NSRange(location: 0, length: nsText.length))
|
||||
let lastLineText = safeLastRange.length > 0 ? nsText.substring(with: safeLastRange) : ""
|
||||
let lastLineHasRTL = containsRTLCharacters(in: lastLineText)
|
||||
let lastLineHasBlockQuote = lastLineText
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.hasPrefix(">")
|
||||
|
||||
return CoreTextTextLayout(
|
||||
lines: resultLines,
|
||||
size: CGSize(width: ceil(maxLineWidth), height: ceil(currentY)),
|
||||
lastLineWidth: ceil(lastLineWidth),
|
||||
lastLineHasRTL: lastLineHasRTL,
|
||||
lastLineHasBlockQuote: lastLineHasBlockQuote,
|
||||
textColor: textColor
|
||||
)
|
||||
}
|
||||
|
||||
private static func containsRTLCharacters(in text: String) -> Bool {
|
||||
for scalar in text.unicodeScalars {
|
||||
switch scalar.value {
|
||||
case 0x0590...0x08FF, 0xFB1D...0xFDFD, 0xFE70...0xFEFC:
|
||||
return true
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreText Label (Custom Rendering View)
|
||||
|
||||
/// Custom UIView that renders `CoreTextTextLayout` via CoreText.
|
||||
/// Drop-in replacement for UILabel in message body text rendering.
|
||||
///
|
||||
/// Rendering matches Telegram (InteractiveTextComponent lines 2358-2573):
|
||||
/// - Flips text matrix `CGAffineTransform(scaleX: 1.0, y: -1.0)` for UIKit coords
|
||||
/// - Positions each line at its baseline via `context.textPosition`
|
||||
/// - Draws each CTRun individually via `CTRunDraw`
|
||||
final class CoreTextLabel: UIView {
|
||||
|
||||
/// Pre-calculated layout to render. Setting triggers redraw.
|
||||
var textLayout: CoreTextTextLayout? {
|
||||
didSet { setNeedsDisplay() }
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
isOpaque = false
|
||||
backgroundColor = .clear
|
||||
contentMode = .redraw // Redraw on bounds change
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let layout = textLayout,
|
||||
let context = UIGraphicsGetCurrentContext()
|
||||
else { return }
|
||||
|
||||
// Save context state
|
||||
let savedTextMatrix = context.textMatrix
|
||||
let savedTextPosition = context.textPosition
|
||||
|
||||
// Flip text matrix for UIKit coordinates (Telegram: scaleX: 1.0, y: -1.0).
|
||||
// UIKit context has origin top-left (Y down). CoreText expects bottom-left (Y up).
|
||||
// Flipping the text matrix makes glyphs render right-side-up in UIKit.
|
||||
context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
|
||||
|
||||
for line in layout.lines {
|
||||
// Baseline position in UIKit coordinates:
|
||||
// line.origin.y = top of line frame
|
||||
// + line.ascent = baseline (distance from top to baseline)
|
||||
// Telegram: context.textPosition = CGPoint(x: minX, y: maxY - descent)
|
||||
// which equals origin.y + ascent (since maxY = origin.y + ascent + descent)
|
||||
context.textPosition = CGPoint(
|
||||
x: line.origin.x,
|
||||
y: line.origin.y + line.ascent
|
||||
)
|
||||
|
||||
// Draw each glyph run (Telegram: CTRunDraw per run)
|
||||
let glyphRuns = CTLineGetGlyphRuns(line.ctLine) as! [CTRun]
|
||||
for run in glyphRuns {
|
||||
CTRunDraw(run, context, CFRangeMake(0, 0)) // 0,0 = all glyphs
|
||||
}
|
||||
}
|
||||
|
||||
// Restore context state
|
||||
context.textMatrix = savedTextMatrix
|
||||
context.textPosition = savedTextPosition
|
||||
}
|
||||
}
|
||||
@@ -13,29 +13,143 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private static let outgoingColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1, alpha: 1)
|
||||
private static let incomingColor = UIColor(red: 0x2C/255.0, green: 0x2C/255.0, blue: 0x2E/255.0, alpha: 1)
|
||||
private static let outgoingColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) // #3390EC
|
||||
private static let incomingColor = UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E
|
||||
private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular)
|
||||
private static let timestampFont = UIFont.systemFont(ofSize: 11, weight: .regular)
|
||||
private static let timestampFont = UIFont.systemFont(ofSize: floor(textFont.pointSize * 11.0 / 17.0), weight: .regular)
|
||||
private static let replyNameFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
|
||||
private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular)
|
||||
private static let forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular)
|
||||
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||
private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular)
|
||||
private static let statusBubbleInsets = UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7)
|
||||
private static let sendingClockAnimationKey = "clockFrameAnimation"
|
||||
|
||||
// MARK: - Telegram Check Images (CGContext — ported from PresentationThemeEssentialGraphics.swift)
|
||||
|
||||
/// Telegram-exact checkmark image via CGContext stroke.
|
||||
/// `partial: true` → single arm (/), `partial: false` → full V (✓).
|
||||
/// Canvas: 11-unit coordinate space scaled to `width` pt.
|
||||
private static func generateTelegramCheck(partial: Bool, color: UIColor, width: CGFloat = 11) -> UIImage? {
|
||||
let height = floor(width * 9.0 / 11.0)
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height))
|
||||
return renderer.image { ctx in
|
||||
let gc = ctx.cgContext
|
||||
// Keep UIKit default Y-down coordinates; Telegram check path points
|
||||
// are already authored for this orientation in our renderer.
|
||||
gc.clear(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
gc.scaleBy(x: width / 11.0, y: width / 11.0)
|
||||
gc.translateBy(x: 1.0, y: 1.0)
|
||||
gc.setStrokeColor(color.cgColor)
|
||||
gc.setLineWidth(0.99)
|
||||
gc.setLineCap(.round)
|
||||
gc.setLineJoin(.round)
|
||||
if partial {
|
||||
// Single arm: bottom-left → top-right diagonal
|
||||
gc.move(to: CGPoint(x: 0.5, y: 7))
|
||||
gc.addLine(to: CGPoint(x: 7, y: 0))
|
||||
} else {
|
||||
// Full V: left → bottom-center (rounded tip) → top-right
|
||||
gc.move(to: CGPoint(x: 0, y: 4))
|
||||
gc.addLine(to: CGPoint(x: 2.95157047, y: 6.95157047))
|
||||
gc.addCurve(to: CGPoint(x: 3.04490857, y: 6.95157047),
|
||||
control1: CGPoint(x: 2.97734507, y: 6.97734507),
|
||||
control2: CGPoint(x: 3.01913396, y: 6.97734507))
|
||||
gc.addCurve(to: CGPoint(x: 3.04660389, y: 6.9498112),
|
||||
control1: CGPoint(x: 3.04548448, y: 6.95099456),
|
||||
control2: CGPoint(x: 3.04604969, y: 6.95040803))
|
||||
gc.addLine(to: CGPoint(x: 9.5, y: 0))
|
||||
}
|
||||
gc.strokePath()
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram-exact clock frame image.
|
||||
private static func generateTelegramClockFrame(color: UIColor) -> UIImage? {
|
||||
let size = CGSize(width: 11, height: 11)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
return renderer.image { ctx in
|
||||
let gc = ctx.cgContext
|
||||
// Telegram uses `generateImage(contextGenerator:)` (non-rotated context).
|
||||
// Flip UIKit context to the same Y-up coordinate space.
|
||||
gc.translateBy(x: 0, y: size.height)
|
||||
gc.scaleBy(x: 1, y: -1)
|
||||
gc.clear(CGRect(origin: .zero, size: size))
|
||||
gc.setStrokeColor(color.cgColor)
|
||||
gc.setFillColor(color.cgColor)
|
||||
gc.setLineWidth(1.0)
|
||||
gc.strokeEllipse(in: CGRect(x: 0.5, y: 0.5, width: 10, height: 10))
|
||||
gc.fill(CGRect(x: 5.0, y: 3.0, width: 1.0, height: 2.5))
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram-exact clock minute/hour image.
|
||||
private static func generateTelegramClockMin(color: UIColor) -> UIImage? {
|
||||
let size = CGSize(width: 11, height: 11)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
return renderer.image { ctx in
|
||||
let gc = ctx.cgContext
|
||||
// Match Telegram's non-rotated drawing context coordinates.
|
||||
gc.translateBy(x: 0, y: size.height)
|
||||
gc.scaleBy(x: 1, y: -1)
|
||||
gc.clear(CGRect(origin: .zero, size: size))
|
||||
gc.setFillColor(color.cgColor)
|
||||
gc.fill(CGRect(x: 5.0, y: 5.0, width: 4.5, height: 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Error indicator (circle with exclamation mark).
|
||||
private static func generateErrorIcon(color: UIColor, width: CGFloat = 20) -> UIImage? {
|
||||
let size = CGSize(width: width, height: width)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
return renderer.image { ctx in
|
||||
let gc = ctx.cgContext
|
||||
gc.scaleBy(x: width / 11.0, y: width / 11.0)
|
||||
gc.setFillColor(color.cgColor)
|
||||
gc.fillEllipse(in: CGRect(x: 0.0, y: 0.0, width: 11.0, height: 11.0))
|
||||
gc.setFillColor(UIColor.white.cgColor)
|
||||
gc.fill(CGRect(x: 5.0, y: 2.5, width: 1.0, height: 4.25))
|
||||
gc.fillEllipse(in: CGRect(x: 4.75, y: 7.8, width: 1.5, height: 1.5))
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-rendered images (cached at class load — Telegram caches in PrincipalThemeEssentialGraphics)
|
||||
private static let outgoingCheckColor = UIColor.white
|
||||
private static let outgoingClockColor = UIColor.white.withAlphaComponent(0.5)
|
||||
private static let mediaMetaColor = UIColor.white
|
||||
private static let fullCheckImage = generateTelegramCheck(partial: false, color: outgoingCheckColor)
|
||||
private static let partialCheckImage = generateTelegramCheck(partial: true, color: outgoingCheckColor)
|
||||
private static let clockFrameImage = generateTelegramClockFrame(color: outgoingClockColor)
|
||||
private static let clockMinImage = generateTelegramClockMin(color: outgoingClockColor)
|
||||
private static let mediaFullCheckImage = generateTelegramCheck(partial: false, color: mediaMetaColor)
|
||||
private static let mediaPartialCheckImage = generateTelegramCheck(partial: true, color: mediaMetaColor)
|
||||
private static let mediaClockFrameImage = generateTelegramClockFrame(color: mediaMetaColor)
|
||||
private static let mediaClockMinImage = generateTelegramClockMin(color: mediaMetaColor)
|
||||
private static let errorIcon = generateErrorIcon(color: .systemRed)
|
||||
private static let blurHashCache: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
cache.countLimit = 200
|
||||
return cache
|
||||
}()
|
||||
|
||||
// MARK: - Subviews (always present, hidden when unused)
|
||||
|
||||
// Bubble
|
||||
private let bubbleView = UIView()
|
||||
private let bubbleLayer = CAShapeLayer()
|
||||
private let bubbleOutlineLayer = CAShapeLayer()
|
||||
|
||||
// Text
|
||||
private let textLabel = UILabel()
|
||||
// Text (CoreText rendering — matches Telegram's CTTypesetter + CTRunDraw pipeline)
|
||||
private let textLabel = CoreTextLabel()
|
||||
|
||||
// Timestamp + delivery
|
||||
private let statusBackgroundView = UIView()
|
||||
private let timestampLabel = UILabel()
|
||||
private let checkmarkView = UIImageView()
|
||||
private let checkSentView = UIImageView()
|
||||
private let checkReadView = UIImageView()
|
||||
private let clockFrameView = UIImageView()
|
||||
private let clockMinView = UIImageView()
|
||||
|
||||
// Reply quote
|
||||
private let replyContainer = UIView()
|
||||
@@ -46,6 +160,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
// Photo
|
||||
private let photoView = UIImageView()
|
||||
private let photoPlaceholderView = UIView()
|
||||
private let photoActivityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
// File
|
||||
private let fileContainer = UIView()
|
||||
@@ -60,12 +175,20 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
|
||||
// Swipe-to-reply
|
||||
private let replyIconView = UIImageView()
|
||||
private let deliveryFailedButton = UIButton(type: .custom)
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private var message: ChatMessage?
|
||||
private var actions: MessageCellActions?
|
||||
private var currentLayout: MessageCellLayout?
|
||||
private var isDeliveryFailedVisible = false
|
||||
private var wasSentCheckVisible = false
|
||||
private var wasReadCheckVisible = false
|
||||
private var photoAttachmentId: String?
|
||||
private var photoLoadTask: Task<Void, Never>?
|
||||
private var photoDownloadTask: Task<Void, Never>?
|
||||
private var isPhotoDownloading = false
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
@@ -86,23 +209,38 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
|
||||
// Bubble
|
||||
bubbleLayer.fillColor = Self.outgoingColor.cgColor
|
||||
bubbleLayer.fillRule = .nonZero
|
||||
bubbleLayer.shadowColor = UIColor.black.cgColor
|
||||
bubbleLayer.shadowOpacity = 0.12
|
||||
bubbleLayer.shadowRadius = 0.6
|
||||
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
|
||||
bubbleView.layer.insertSublayer(bubbleLayer, at: 0)
|
||||
bubbleOutlineLayer.fillColor = UIColor.clear.cgColor
|
||||
bubbleOutlineLayer.lineWidth = 1.0 / max(UIScreen.main.scale, 1)
|
||||
bubbleView.layer.insertSublayer(bubbleOutlineLayer, above: bubbleLayer)
|
||||
contentView.addSubview(bubbleView)
|
||||
|
||||
// Text
|
||||
textLabel.font = Self.textFont
|
||||
textLabel.textColor = .white
|
||||
textLabel.numberOfLines = 0
|
||||
textLabel.lineBreakMode = .byWordWrapping
|
||||
// Text (CoreTextLabel — no font/color/lines config; all baked into CoreTextTextLayout)
|
||||
bubbleView.addSubview(textLabel)
|
||||
|
||||
// Timestamp
|
||||
statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
||||
statusBackgroundView.layer.cornerRadius = 6
|
||||
statusBackgroundView.isHidden = true
|
||||
bubbleView.addSubview(statusBackgroundView)
|
||||
|
||||
timestampLabel.font = Self.timestampFont
|
||||
bubbleView.addSubview(timestampLabel)
|
||||
|
||||
// Checkmark
|
||||
checkmarkView.contentMode = .scaleAspectFit
|
||||
bubbleView.addSubview(checkmarkView)
|
||||
// Checkmarks (Telegram two-node overlay: sent ✓ + read /)
|
||||
checkSentView.contentMode = .scaleAspectFit
|
||||
bubbleView.addSubview(checkSentView)
|
||||
checkReadView.contentMode = .scaleAspectFit
|
||||
bubbleView.addSubview(checkReadView)
|
||||
clockFrameView.contentMode = .scaleAspectFit
|
||||
clockMinView.contentMode = .scaleAspectFit
|
||||
bubbleView.addSubview(clockFrameView)
|
||||
bubbleView.addSubview(clockMinView)
|
||||
|
||||
// Reply quote
|
||||
replyBar.layer.cornerRadius = 1.5
|
||||
@@ -117,11 +255,19 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
// Photo
|
||||
photoView.contentMode = .scaleAspectFill
|
||||
photoView.clipsToBounds = true
|
||||
photoView.isUserInteractionEnabled = true
|
||||
bubbleView.addSubview(photoView)
|
||||
|
||||
photoPlaceholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
|
||||
bubbleView.addSubview(photoPlaceholderView)
|
||||
|
||||
photoActivityIndicator.color = .white
|
||||
photoActivityIndicator.hidesWhenStopped = true
|
||||
bubbleView.addSubview(photoActivityIndicator)
|
||||
|
||||
let photoTap = UITapGestureRecognizer(target: self, action: #selector(handlePhotoTap))
|
||||
photoView.addGestureRecognizer(photoTap)
|
||||
|
||||
// File
|
||||
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
||||
fileIconView.layer.cornerRadius = 20
|
||||
@@ -155,6 +301,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
replyIconView.alpha = 0
|
||||
contentView.addSubview(replyIconView)
|
||||
|
||||
// Delivery failed node (Telegram-style external badge)
|
||||
deliveryFailedButton.setImage(Self.errorIcon, for: .normal)
|
||||
deliveryFailedButton.imageView?.contentMode = .scaleAspectFit
|
||||
deliveryFailedButton.isHidden = true
|
||||
deliveryFailedButton.accessibilityLabel = "Retry sending"
|
||||
deliveryFailedButton.addTarget(self, action: #selector(handleDeliveryFailedTap), for: .touchUpInside)
|
||||
contentView.addSubview(deliveryFailedButton)
|
||||
|
||||
// Interactions
|
||||
let contextMenu = UIContextMenuInteraction(delegate: self)
|
||||
bubbleView.addInteraction(contextMenu)
|
||||
@@ -167,9 +321,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
// MARK: - Configure + Apply Layout
|
||||
|
||||
/// Configure cell data (content). Does NOT trigger layout.
|
||||
/// `textLayout` is pre-computed during `calculateLayouts()` — no double CoreText work.
|
||||
func configure(
|
||||
message: ChatMessage,
|
||||
timestamp: String,
|
||||
textLayout: CoreTextTextLayout? = nil,
|
||||
actions: MessageCellActions,
|
||||
replyName: String? = nil,
|
||||
replyText: String? = nil,
|
||||
@@ -179,33 +335,56 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
self.actions = actions
|
||||
|
||||
let isOutgoing = currentLayout?.isOutgoing ?? false
|
||||
let isMediaStatus = currentLayout?.messageType == .photo
|
||||
|
||||
// Text (filter garbage/encrypted — UIKit path parity with SwiftUI)
|
||||
textLabel.text = MessageCellLayout.isGarbageOrEncrypted(message.text) ? "" : message.text
|
||||
// Text — use cached CoreTextTextLayout from measurement phase.
|
||||
// Same CTTypesetter pipeline → identical line breaks, zero recomputation.
|
||||
textLabel.textLayout = textLayout
|
||||
|
||||
// Timestamp
|
||||
timestampLabel.text = timestamp
|
||||
timestampLabel.textColor = isOutgoing
|
||||
? UIColor.white.withAlphaComponent(0.55)
|
||||
: UIColor.white.withAlphaComponent(0.6)
|
||||
if isMediaStatus {
|
||||
timestampLabel.textColor = .white
|
||||
} else {
|
||||
timestampLabel.textColor = isOutgoing
|
||||
? UIColor.white.withAlphaComponent(0.55)
|
||||
: UIColor.white.withAlphaComponent(0.6)
|
||||
}
|
||||
|
||||
// Delivery
|
||||
// Delivery checkmarks (Telegram two-node pattern: checkSent + checkRead)
|
||||
stopSendingClockAnimation()
|
||||
var shouldShowSentCheck = false
|
||||
var shouldShowReadCheck = false
|
||||
var shouldShowClock = false
|
||||
checkSentView.image = nil
|
||||
checkReadView.image = nil
|
||||
clockFrameView.image = nil
|
||||
clockMinView.image = nil
|
||||
if isOutgoing {
|
||||
checkmarkView.isHidden = false
|
||||
switch message.deliveryStatus {
|
||||
case .delivered:
|
||||
checkmarkView.image = UIImage(systemName: "checkmark")?.withRenderingMode(.alwaysTemplate)
|
||||
checkmarkView.tintColor = message.isRead ? .white : UIColor.white.withAlphaComponent(0.55)
|
||||
shouldShowSentCheck = true
|
||||
checkSentView.image = isMediaStatus ? Self.mediaFullCheckImage : Self.fullCheckImage
|
||||
if message.isRead {
|
||||
checkReadView.image = isMediaStatus ? Self.mediaPartialCheckImage : Self.partialCheckImage
|
||||
shouldShowReadCheck = true
|
||||
}
|
||||
case .waiting:
|
||||
checkmarkView.image = UIImage(systemName: "clock")?.withRenderingMode(.alwaysTemplate)
|
||||
checkmarkView.tintColor = UIColor.white.withAlphaComponent(0.55)
|
||||
shouldShowClock = true
|
||||
clockFrameView.image = isMediaStatus ? Self.mediaClockFrameImage : Self.clockFrameImage
|
||||
clockMinView.image = isMediaStatus ? Self.mediaClockMinImage : Self.clockMinImage
|
||||
startSendingClockAnimation()
|
||||
case .error:
|
||||
checkmarkView.image = UIImage(systemName: "exclamationmark.circle")?.withRenderingMode(.alwaysTemplate)
|
||||
checkmarkView.tintColor = .red
|
||||
break
|
||||
}
|
||||
} else {
|
||||
checkmarkView.isHidden = true
|
||||
}
|
||||
checkSentView.isHidden = !shouldShowSentCheck
|
||||
checkReadView.isHidden = !shouldShowReadCheck
|
||||
clockFrameView.isHidden = !shouldShowClock
|
||||
clockMinView.isHidden = !shouldShowClock
|
||||
animateCheckAppearanceIfNeeded(isSentVisible: shouldShowSentCheck, isReadVisible: shouldShowReadCheck)
|
||||
deliveryFailedButton.isHidden = !(isOutgoing && message.deliveryStatus == .error)
|
||||
updateStatusBackgroundVisibility()
|
||||
|
||||
// Bubble color
|
||||
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
|
||||
@@ -236,9 +415,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
forwardNameLabel.isHidden = true
|
||||
}
|
||||
|
||||
// Photo placeholder (actual image loading handled separately)
|
||||
photoView.isHidden = !(currentLayout?.hasPhoto ?? false)
|
||||
photoPlaceholderView.isHidden = !(currentLayout?.hasPhoto ?? false)
|
||||
// Photo
|
||||
configurePhoto(for: message)
|
||||
|
||||
// File
|
||||
if let layout = currentLayout, layout.hasFile {
|
||||
@@ -265,20 +443,17 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
|
||||
let cellW = contentView.bounds.width
|
||||
let tailW: CGFloat = layout.hasTail ? 6 : 0
|
||||
let isTopOrSingle = (layout.position == .single || layout.position == .top)
|
||||
let topPad: CGFloat = isTopOrSingle ? 6 : 2
|
||||
|
||||
// Bubble X: align to RIGHT for outgoing, LEFT for incoming
|
||||
// This is computed from CELL WIDTH, not maxBubbleWidth
|
||||
// Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment
|
||||
let bubbleX: CGFloat
|
||||
if layout.isOutgoing {
|
||||
bubbleX = cellW - layout.bubbleSize.width - tailW - 2
|
||||
bubbleX = cellW - layout.bubbleSize.width - 6 - 2 - layout.deliveryFailedInset
|
||||
} else {
|
||||
bubbleX = tailW + 2
|
||||
bubbleX = 6 + 2
|
||||
}
|
||||
|
||||
bubbleView.frame = CGRect(
|
||||
x: bubbleX, y: topPad,
|
||||
x: bubbleX, y: layout.groupGap,
|
||||
width: layout.bubbleSize.width, height: layout.bubbleSize.height
|
||||
)
|
||||
bubbleLayer.frame = bubbleView.bounds
|
||||
@@ -299,14 +474,32 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
size: shapeRect.size, origin: shapeRect.origin,
|
||||
position: layout.position, isOutgoing: layout.isOutgoing, hasTail: layout.hasTail
|
||||
)
|
||||
bubbleLayer.shadowPath = bubbleLayer.path
|
||||
bubbleOutlineLayer.frame = bubbleView.bounds
|
||||
bubbleOutlineLayer.path = bubbleLayer.path
|
||||
if layout.hasTail {
|
||||
// Tail path is appended as a second subpath; stroking it produces
|
||||
// a visible seam at the junction. Keep fill-only for tailed bubbles.
|
||||
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
|
||||
} else {
|
||||
bubbleOutlineLayer.strokeColor = UIColor.black.withAlphaComponent(
|
||||
layout.isOutgoing ? 0.16 : 0.22
|
||||
).cgColor
|
||||
}
|
||||
|
||||
// Text
|
||||
textLabel.isHidden = layout.textSize == .zero
|
||||
textLabel.frame = layout.textFrame
|
||||
|
||||
// Timestamp + checkmark
|
||||
// Timestamp + checkmarks (two-node overlay)
|
||||
timestampLabel.frame = layout.timestampFrame
|
||||
checkmarkView.frame = layout.checkmarkFrame
|
||||
checkSentView.frame = layout.checkSentFrame
|
||||
checkReadView.frame = layout.checkReadFrame
|
||||
clockFrameView.frame = layout.clockFrame
|
||||
clockMinView.frame = layout.clockFrame
|
||||
|
||||
// Telegram-style date/status pill on media-only bubbles.
|
||||
updateStatusBackgroundFrame()
|
||||
|
||||
// Reply
|
||||
replyContainer.isHidden = !layout.hasReplyQuote
|
||||
@@ -323,6 +516,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
if layout.hasPhoto {
|
||||
photoView.frame = layout.photoFrame
|
||||
photoPlaceholderView.frame = layout.photoFrame
|
||||
photoActivityIndicator.center = CGPoint(x: layout.photoFrame.midX, y: layout.photoFrame.midY)
|
||||
}
|
||||
|
||||
// File
|
||||
@@ -341,6 +535,43 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
forwardNameLabel.frame = layout.forwardNameFrame
|
||||
}
|
||||
|
||||
// Telegram-style failed delivery badge outside bubble (slide + fade).
|
||||
let failedSize = CGSize(width: 20, height: 20)
|
||||
let targetFailedFrame = CGRect(
|
||||
x: bubbleView.frame.maxX + layout.deliveryFailedInset - failedSize.width,
|
||||
y: bubbleView.frame.maxY - failedSize.height,
|
||||
width: failedSize.width,
|
||||
height: failedSize.height
|
||||
)
|
||||
if layout.showsDeliveryFailedIndicator {
|
||||
if !isDeliveryFailedVisible {
|
||||
isDeliveryFailedVisible = true
|
||||
deliveryFailedButton.isHidden = false
|
||||
deliveryFailedButton.alpha = 0
|
||||
deliveryFailedButton.frame = targetFailedFrame.offsetBy(dx: layout.deliveryFailedInset, dy: 0)
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut, .beginFromCurrentState]) {
|
||||
self.deliveryFailedButton.alpha = 1
|
||||
self.deliveryFailedButton.frame = targetFailedFrame
|
||||
}
|
||||
} else {
|
||||
deliveryFailedButton.isHidden = false
|
||||
deliveryFailedButton.alpha = 1
|
||||
deliveryFailedButton.frame = targetFailedFrame
|
||||
}
|
||||
} else if isDeliveryFailedVisible {
|
||||
isDeliveryFailedVisible = false
|
||||
let hideFrame = deliveryFailedButton.frame.offsetBy(dx: layout.deliveryFailedInset, dy: 0)
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn, .beginFromCurrentState]) {
|
||||
self.deliveryFailedButton.alpha = 0
|
||||
self.deliveryFailedButton.frame = hideFrame
|
||||
} completion: { _ in
|
||||
self.deliveryFailedButton.isHidden = true
|
||||
}
|
||||
} else {
|
||||
deliveryFailedButton.isHidden = true
|
||||
deliveryFailedButton.alpha = 0
|
||||
}
|
||||
|
||||
// Reply icon (for swipe gesture) — use actual bubbleView frame
|
||||
replyIconView.frame = CGRect(
|
||||
x: layout.isOutgoing
|
||||
@@ -419,6 +650,275 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleDeliveryFailedTap() {
|
||||
guard let message, let actions else { return }
|
||||
actions.onRetry(message)
|
||||
}
|
||||
|
||||
@objc private func handlePhotoTap() {
|
||||
guard let message,
|
||||
let actions,
|
||||
let layout = currentLayout,
|
||||
layout.hasPhoto,
|
||||
let attachment = message.attachments.first(where: { $0.type == .image }) else {
|
||||
return
|
||||
}
|
||||
|
||||
if AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) != nil {
|
||||
actions.onImageTap(attachment.id)
|
||||
return
|
||||
}
|
||||
|
||||
downloadPhotoAttachment(attachment: attachment, message: message)
|
||||
}
|
||||
|
||||
private func configurePhoto(for message: ChatMessage) {
|
||||
guard let layout = currentLayout, layout.hasPhoto else {
|
||||
photoAttachmentId = nil
|
||||
photoLoadTask?.cancel()
|
||||
photoLoadTask = nil
|
||||
photoDownloadTask?.cancel()
|
||||
photoDownloadTask = nil
|
||||
isPhotoDownloading = false
|
||||
photoActivityIndicator.stopAnimating()
|
||||
photoView.image = nil
|
||||
photoView.isHidden = true
|
||||
photoPlaceholderView.isHidden = true
|
||||
return
|
||||
}
|
||||
|
||||
guard let attachment = message.attachments.first(where: { $0.type == .image }) else {
|
||||
photoAttachmentId = nil
|
||||
photoLoadTask?.cancel()
|
||||
photoLoadTask = nil
|
||||
photoDownloadTask?.cancel()
|
||||
photoDownloadTask = nil
|
||||
isPhotoDownloading = false
|
||||
photoActivityIndicator.stopAnimating()
|
||||
photoView.image = nil
|
||||
photoView.isHidden = true
|
||||
photoPlaceholderView.isHidden = false
|
||||
return
|
||||
}
|
||||
|
||||
photoAttachmentId = attachment.id
|
||||
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
photoView.image = cached
|
||||
photoView.isHidden = false
|
||||
photoPlaceholderView.isHidden = true
|
||||
photoActivityIndicator.stopAnimating()
|
||||
isPhotoDownloading = false
|
||||
photoLoadTask?.cancel()
|
||||
photoLoadTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
photoView.image = Self.blurHashImage(from: attachment.preview)
|
||||
photoView.isHidden = false
|
||||
photoPlaceholderView.isHidden = photoView.image != nil
|
||||
if !isPhotoDownloading {
|
||||
photoActivityIndicator.stopAnimating()
|
||||
}
|
||||
startPhotoLoadTask(attachmentId: attachment.id)
|
||||
}
|
||||
|
||||
private func startPhotoLoadTask(attachmentId: String) {
|
||||
photoLoadTask?.cancel()
|
||||
photoLoadTask = Task { [weak self] in
|
||||
await ImageLoadLimiter.shared.acquire()
|
||||
let loaded = await Task.detached(priority: .userInitiated) {
|
||||
await AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||
}.value
|
||||
await ImageLoadLimiter.shared.release()
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let self, self.photoAttachmentId == attachmentId, let loaded else { return }
|
||||
self.photoView.image = loaded
|
||||
self.photoView.isHidden = false
|
||||
self.photoPlaceholderView.isHidden = true
|
||||
self.photoActivityIndicator.stopAnimating()
|
||||
self.isPhotoDownloading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) {
|
||||
guard !isPhotoDownloading else { return }
|
||||
let tag = Self.extractTag(from: attachment.preview)
|
||||
guard !tag.isEmpty,
|
||||
let storedPassword = message.attachmentPassword,
|
||||
!storedPassword.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
isPhotoDownloading = true
|
||||
photoActivityIndicator.startAnimating()
|
||||
photoDownloadTask?.cancel()
|
||||
let attachmentId = attachment.id
|
||||
let preview = attachment.preview
|
||||
|
||||
photoDownloadTask = Task { [weak self] in
|
||||
do {
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
|
||||
let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords)
|
||||
await MainActor.run {
|
||||
guard let self, self.photoAttachmentId == attachmentId else { return }
|
||||
if let image {
|
||||
AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
|
||||
self.photoView.image = image
|
||||
self.photoView.isHidden = false
|
||||
self.photoPlaceholderView.isHidden = true
|
||||
} else {
|
||||
self.photoView.image = Self.blurHashImage(from: preview)
|
||||
self.photoView.isHidden = false
|
||||
self.photoPlaceholderView.isHidden = self.photoView.image != nil
|
||||
}
|
||||
self.photoActivityIndicator.stopAnimating()
|
||||
self.isPhotoDownloading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
guard let self, self.photoAttachmentId == attachmentId else { return }
|
||||
self.photoActivityIndicator.stopAnimating()
|
||||
self.isPhotoDownloading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractTag(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.first ?? preview
|
||||
}
|
||||
|
||||
private static func extractBlurHash(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.count > 1 ? parts[1] : ""
|
||||
}
|
||||
|
||||
private static func blurHashImage(from preview: String) -> UIImage? {
|
||||
let hash = extractBlurHash(from: preview)
|
||||
guard !hash.isEmpty else { return nil }
|
||||
if let cached = blurHashCache.object(forKey: hash as NSString) {
|
||||
return cached
|
||||
}
|
||||
guard let image = UIImage.fromBlurHash(hash, width: 48, height: 48) else {
|
||||
return nil
|
||||
}
|
||||
blurHashCache.setObject(image, forKey: hash as NSString)
|
||||
return image
|
||||
}
|
||||
|
||||
private static func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
||||
let crypto = CryptoManager.shared
|
||||
for password in passwords {
|
||||
guard let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password, requireCompression: true
|
||||
) else { continue }
|
||||
if let image = parseImageData(data) { return image }
|
||||
}
|
||||
for password in passwords {
|
||||
guard let data = try? crypto.decryptWithPassword(encryptedString, password: password) else { continue }
|
||||
if let image = parseImageData(data) { return image }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseImageData(_ data: Data) -> UIImage? {
|
||||
if let str = String(data: data, encoding: .utf8) {
|
||||
if str.hasPrefix("data:"), let commaIndex = str.firstIndex(of: ",") {
|
||||
let base64Part = String(str[str.index(after: commaIndex)...])
|
||||
if let imageData = Data(base64Encoded: base64Part),
|
||||
let image = AttachmentCache.downsampledImage(from: imageData) {
|
||||
return image
|
||||
}
|
||||
} else if let imageData = Data(base64Encoded: str),
|
||||
let image = AttachmentCache.downsampledImage(from: imageData) {
|
||||
return image
|
||||
}
|
||||
}
|
||||
return AttachmentCache.downsampledImage(from: data)
|
||||
}
|
||||
|
||||
private func startSendingClockAnimation() {
|
||||
if clockFrameView.layer.animation(forKey: Self.sendingClockAnimationKey) == nil {
|
||||
let frameRotation = CABasicAnimation(keyPath: "transform.rotation.z")
|
||||
frameRotation.duration = 6.0
|
||||
frameRotation.fromValue = NSNumber(value: Float(0))
|
||||
frameRotation.toValue = NSNumber(value: Float(Double.pi * 2.0))
|
||||
frameRotation.repeatCount = .infinity
|
||||
frameRotation.timingFunction = CAMediaTimingFunction(name: .linear)
|
||||
frameRotation.beginTime = 1.0
|
||||
clockFrameView.layer.add(frameRotation, forKey: Self.sendingClockAnimationKey)
|
||||
}
|
||||
if clockMinView.layer.animation(forKey: Self.sendingClockAnimationKey) == nil {
|
||||
let minRotation = CABasicAnimation(keyPath: "transform.rotation.z")
|
||||
minRotation.duration = 1.0
|
||||
minRotation.fromValue = NSNumber(value: Float(0))
|
||||
minRotation.toValue = NSNumber(value: Float(Double.pi * 2.0))
|
||||
minRotation.repeatCount = .infinity
|
||||
minRotation.timingFunction = CAMediaTimingFunction(name: .linear)
|
||||
minRotation.beginTime = 1.0
|
||||
clockMinView.layer.add(minRotation, forKey: Self.sendingClockAnimationKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopSendingClockAnimation() {
|
||||
clockFrameView.layer.removeAnimation(forKey: Self.sendingClockAnimationKey)
|
||||
clockMinView.layer.removeAnimation(forKey: Self.sendingClockAnimationKey)
|
||||
}
|
||||
|
||||
private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) {
|
||||
if isSentVisible && !wasSentCheckVisible {
|
||||
let pop = CABasicAnimation(keyPath: "transform.scale")
|
||||
pop.fromValue = NSNumber(value: Float(1.3))
|
||||
pop.toValue = NSNumber(value: Float(1.0))
|
||||
pop.duration = 0.1
|
||||
pop.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
checkSentView.layer.add(pop, forKey: "checkPop")
|
||||
}
|
||||
|
||||
if isReadVisible && !wasReadCheckVisible {
|
||||
let pop = CABasicAnimation(keyPath: "transform.scale")
|
||||
pop.fromValue = NSNumber(value: Float(1.3))
|
||||
pop.toValue = NSNumber(value: Float(1.0))
|
||||
pop.duration = 0.1
|
||||
pop.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
checkReadView.layer.add(pop, forKey: "checkPop")
|
||||
}
|
||||
|
||||
wasSentCheckVisible = isSentVisible
|
||||
wasReadCheckVisible = isReadVisible
|
||||
}
|
||||
|
||||
private func updateStatusBackgroundVisibility() {
|
||||
guard let layout = currentLayout else {
|
||||
statusBackgroundView.isHidden = true
|
||||
return
|
||||
}
|
||||
// Telegram uses a dedicated status background on media messages.
|
||||
statusBackgroundView.isHidden = layout.messageType != .photo
|
||||
}
|
||||
|
||||
private func updateStatusBackgroundFrame() {
|
||||
guard !statusBackgroundView.isHidden else { return }
|
||||
var contentRect = timestampLabel.frame
|
||||
let statusNodes = [checkSentView, checkReadView, clockFrameView, clockMinView]
|
||||
for node in statusNodes where !node.isHidden {
|
||||
contentRect = contentRect.union(node.frame)
|
||||
}
|
||||
let insets = Self.statusBubbleInsets
|
||||
statusBackgroundView.frame = CGRect(
|
||||
x: contentRect.minX - insets.left,
|
||||
y: contentRect.minY - insets.top,
|
||||
width: contentRect.width + insets.left + insets.right,
|
||||
height: contentRect.height + insets.top + insets.bottom
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Reuse
|
||||
|
||||
override func prepareForReuse() {
|
||||
@@ -426,9 +926,27 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
message = nil
|
||||
actions = nil
|
||||
currentLayout = nil
|
||||
textLabel.text = nil
|
||||
stopSendingClockAnimation()
|
||||
textLabel.textLayout = nil
|
||||
timestampLabel.text = nil
|
||||
checkmarkView.image = nil
|
||||
checkSentView.image = nil
|
||||
checkSentView.isHidden = true
|
||||
checkReadView.image = nil
|
||||
checkReadView.isHidden = true
|
||||
clockFrameView.image = nil
|
||||
clockFrameView.isHidden = true
|
||||
clockMinView.image = nil
|
||||
clockMinView.isHidden = true
|
||||
wasSentCheckVisible = false
|
||||
wasReadCheckVisible = false
|
||||
statusBackgroundView.isHidden = true
|
||||
photoAttachmentId = nil
|
||||
photoLoadTask?.cancel()
|
||||
photoLoadTask = nil
|
||||
photoDownloadTask?.cancel()
|
||||
photoDownloadTask = nil
|
||||
isPhotoDownloading = false
|
||||
photoActivityIndicator.stopAnimating()
|
||||
photoView.image = nil
|
||||
replyContainer.isHidden = true
|
||||
fileContainer.isHidden = true
|
||||
@@ -439,6 +957,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
photoPlaceholderView.isHidden = true
|
||||
bubbleView.transform = .identity
|
||||
replyIconView.alpha = 0
|
||||
deliveryFailedButton.isHidden = true
|
||||
deliveryFailedButton.alpha = 0
|
||||
isDeliveryFailedVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,13 +980,14 @@ extension NativeMessageCell: UIGestureRecognizerDelegate {
|
||||
final class BubblePathCache {
|
||||
static let shared = BubblePathCache()
|
||||
|
||||
private let pathVersion = 7
|
||||
private var cache: [String: CGPath] = [:]
|
||||
|
||||
func path(
|
||||
size: CGSize, origin: CGPoint,
|
||||
position: BubblePosition, isOutgoing: Bool, hasTail: Bool
|
||||
) -> CGPath {
|
||||
let key = "\(Int(size.width))x\(Int(size.height))_\(Int(origin.x))_\(position)_\(isOutgoing)_\(hasTail)"
|
||||
let key = "v\(pathVersion)_\(Int(size.width))x\(Int(size.height))_\(Int(origin.x))_\(position)_\(isOutgoing)_\(hasTail)"
|
||||
if let cached = cache[key] { return cached }
|
||||
|
||||
let rect = CGRect(origin: origin, size: size)
|
||||
@@ -483,7 +1005,7 @@ final class BubblePathCache {
|
||||
private func makeBubblePath(
|
||||
in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool
|
||||
) -> CGPath {
|
||||
let r: CGFloat = 18, s: CGFloat = 8, tailW: CGFloat = 6
|
||||
let r: CGFloat = 16, s: CGFloat = 8, tailW: CGFloat = 6
|
||||
|
||||
// Body rect
|
||||
let bodyRect: CGRect
|
||||
@@ -527,7 +1049,7 @@ final class BubblePathCache {
|
||||
tangent2End: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY), radius: cTL)
|
||||
path.closeSubpath()
|
||||
|
||||
// Figma SVG tail
|
||||
// Stable Figma tail (previous behavior)
|
||||
if hasTail {
|
||||
addFigmaTail(to: path, bodyRect: bodyRect, isOutgoing: isOutgoing)
|
||||
}
|
||||
@@ -535,19 +1057,21 @@ final class BubblePathCache {
|
||||
return path
|
||||
}
|
||||
|
||||
/// Figma SVG tail path (stable shape used before recent experiments).
|
||||
private func addFigmaTail(to path: CGMutablePath, bodyRect: CGRect, isOutgoing: Bool) {
|
||||
let svgStraightX: CGFloat = 5.59961
|
||||
let svgMaxY: CGFloat = 33.2305
|
||||
let sc: CGFloat = 6 / svgStraightX
|
||||
let tailH = svgMaxY * sc
|
||||
let scale: CGFloat = 6.0 / svgStraightX
|
||||
let tailH = svgMaxY * scale
|
||||
|
||||
let bodyEdge = isOutgoing ? bodyRect.maxX : bodyRect.minX
|
||||
let bottom = bodyRect.maxY
|
||||
let top = bottom - tailH
|
||||
let dir: CGFloat = isOutgoing ? 1 : -1
|
||||
|
||||
func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint {
|
||||
let dx = (svgStraightX - svgX) * sc * dir
|
||||
return CGPoint(x: bodyEdge + dx, y: top + svgY * sc)
|
||||
let dx = (svgStraightX - svgX) * scale * dir
|
||||
return CGPoint(x: bodyEdge + dx, y: top + svgY * scale)
|
||||
}
|
||||
|
||||
if isOutgoing {
|
||||
|
||||
@@ -82,6 +82,10 @@ final class NativeMessageListController: UIViewController {
|
||||
/// All frame rects computed once, applied on main thread (just sets frames).
|
||||
private var layoutCache: [String: MessageCellLayout] = [:]
|
||||
|
||||
/// Cache: messageId → pre-calculated CoreTextTextLayout for cell rendering.
|
||||
/// Eliminates double CoreText computation (measure + render → measure once, render from cache).
|
||||
private var textLayoutCache: [String: CoreTextTextLayout] = [:]
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(config: Config) {
|
||||
@@ -237,6 +241,7 @@ final class NativeMessageListController: UIViewController {
|
||||
cell.configure(
|
||||
message: message,
|
||||
timestamp: self.formatTimestamp(message.timestamp),
|
||||
textLayout: self.textLayoutCache[message.id],
|
||||
actions: self.config.actions,
|
||||
replyName: replyName,
|
||||
replyText: replyText,
|
||||
@@ -399,35 +404,58 @@ final class NativeMessageListController: UIViewController {
|
||||
|
||||
/// Called from SwiftUI when messages array changes.
|
||||
func update(messages: [ChatMessage], animated: Bool = false) {
|
||||
let oldIds = Set(self.messages.map(\.id))
|
||||
self.messages = messages
|
||||
|
||||
// Pre-calculate layouts (Telegram asyncLayout pattern).
|
||||
// TODO: Move to background thread for full Telegram parity.
|
||||
// Currently on main thread (still fast — C++ math + CoreText).
|
||||
// Recalculate ALL layouts — BubblePosition depends on neighbors in the FULL
|
||||
// array, so inserting one message changes the previous message's position/tail.
|
||||
// CoreText measurement is ~0.1ms per message; 50 msgs ≈ 5ms — well under 16ms.
|
||||
calculateLayouts()
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(messages.reversed().map(\.id))
|
||||
let itemIds = messages.reversed().map(\.id)
|
||||
snapshot.appendItems(itemIds)
|
||||
|
||||
// Reconfigure existing cells whose BubblePosition/tail may have changed.
|
||||
// Without this, DiffableDataSource reuses stale cells (wrong corners/tail).
|
||||
let existingItems = itemIds.filter { oldIds.contains($0) }
|
||||
if !existingItems.isEmpty {
|
||||
snapshot.reconfigureItems(existingItems)
|
||||
}
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
// MARK: - Layout Calculation (Telegram asyncLayout pattern)
|
||||
|
||||
/// Pre-calculate layouts for NEW messages only (skip cached).
|
||||
/// Recalculate layouts for ALL messages using the full array.
|
||||
/// BubblePosition is computed from neighbors — partial recalculation produces
|
||||
/// stale positions (wrong corners, missing tails on live insertion).
|
||||
private func calculateLayouts() {
|
||||
let existingIds = Set(layoutCache.keys)
|
||||
let newMessages = messages.filter { !existingIds.contains($0.id) }
|
||||
guard !newMessages.isEmpty else { return }
|
||||
guard !messages.isEmpty else {
|
||||
layoutCache.removeAll()
|
||||
textLayoutCache.removeAll()
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
let start = CFAbsoluteTimeGetCurrent()
|
||||
#endif
|
||||
|
||||
let newLayouts = MessageCellLayout.batchCalculate(
|
||||
messages: newMessages,
|
||||
let (layouts, textLayouts) = MessageCellLayout.batchCalculate(
|
||||
messages: messages,
|
||||
maxBubbleWidth: config.maxBubbleWidth,
|
||||
currentPublicKey: config.currentPublicKey,
|
||||
opponentPublicKey: config.opponentPublicKey,
|
||||
opponentTitle: config.opponentTitle
|
||||
)
|
||||
layoutCache.merge(newLayouts) { _, new in new }
|
||||
layoutCache = layouts
|
||||
textLayoutCache = textLayouts
|
||||
|
||||
#if DEBUG
|
||||
let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000
|
||||
print("⚡ PERF_LAYOUT | \(messages.count) msgs | \(String(format: "%.1f", elapsed))ms | textLayouts cached: \(textLayouts.count)")
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Inset Management
|
||||
|
||||
@@ -7,15 +7,15 @@ final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteraction
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private static let mainRadius: CGFloat = 18
|
||||
private static let smallRadius: CGFloat = 8
|
||||
private static let mainRadius: CGFloat = 16
|
||||
private static let smallRadius: CGFloat = 5
|
||||
private static let tailProtrusion: CGFloat = 6
|
||||
private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular)
|
||||
private static let timestampFont = UIFont.systemFont(ofSize: 11, weight: .regular)
|
||||
private static let timestampFont = UIFont.systemFont(ofSize: 9, weight: .regular)
|
||||
private static let replyNameFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
|
||||
private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular)
|
||||
private static let outgoingColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1, alpha: 1)
|
||||
private static let incomingColor = UIColor(red: 0x2C/255.0, green: 0x2C/255.0, blue: 0x2E/255.0, alpha: 1)
|
||||
private static let outgoingColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) // #3390EC
|
||||
private static let incomingColor = UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E
|
||||
private static let replyQuoteHeight: CGFloat = 41
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
Reference in New Issue
Block a user