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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,273 @@
import UIKit
import CoreText
// MARK: - Telegram-Exact Text Layout (Pre-calculated)
/// Pre-calculated text layout using Telegram's exact CoreText pipeline.
///
/// Telegram uses CTTypesetter (NOT CTFramesetter) for manual line breaking,
/// custom inter-line spacing (12% of fontLineHeight), and CTRunDraw for rendering.
/// UILabel/TextKit produce different line breaks and density this class
/// reproduces Telegram's exact algorithm from:
/// - InteractiveTextComponent.swift (lines 1480-1548) line breaking
/// - TextNode.swift (lines 1723-1726) font metrics & line spacing
/// - InteractiveTextComponent.swift (lines 2358-2573) rendering
///
/// Two-phase pattern (matches Telegram asyncLayout):
/// 1. `CoreTextTextLayout.calculate()` runs on ANY thread (background-safe)
/// 2. `CoreTextLabel.draw()` runs on main thread, renders pre-calculated lines
final class CoreTextTextLayout {
// MARK: - Line
/// A single laid-out line with position and metrics.
struct Line {
let ctLine: CTLine
let origin: CGPoint // Top-left corner in UIKit (top-down) coordinates
let width: CGFloat // Typographic advance width (CTLineGetTypographicBounds)
let ascent: CGFloat // Distance from baseline to top of tallest glyph
let descent: CGFloat // Distance from baseline to bottom of lowest glyph
}
// MARK: - Properties
let lines: [Line]
let size: CGSize // Bounding box (ceil'd max-line-width × total height)
let lastLineWidth: CGFloat // Width of the final line for inline timestamp decisions
let lastLineHasRTL: Bool
let lastLineHasBlockQuote: Bool
let textColor: UIColor
private init(
lines: [Line],
size: CGSize,
lastLineWidth: CGFloat,
lastLineHasRTL: Bool,
lastLineHasBlockQuote: Bool,
textColor: UIColor
) {
self.lines = lines
self.size = size
self.lastLineWidth = lastLineWidth
self.lastLineHasRTL = lastLineHasRTL
self.lastLineHasBlockQuote = lastLineHasBlockQuote
self.textColor = textColor
}
// MARK: - Telegram Line Spacing
/// Telegram default: 12% of font line height.
/// Source: TextNode.swift line 277, InteractiveTextComponent.swift line 299.
static let telegramLineSpacingFactor: CGFloat = 0.12
// MARK: - Layout Calculation (Thread-Safe)
/// Calculate text layout using Telegram's exact algorithm.
///
/// Algorithm (InteractiveTextComponent.swift lines 1480-1548):
/// 1. `CTTypesetterCreateWithAttributedString` create typesetter
/// 2. Loop: `CTTypesetterSuggestLineBreak` `CTTypesetterCreateLine`
/// 3. `CTLineGetTypographicBounds` for per-line ascent/descent/width
/// 4. Accumulate height with `floor(fontLineHeight * 0.12)` inter-line spacing
///
/// - Parameters:
/// - text: Raw message text
/// - maxWidth: Maximum line width constraint (bubbleW - leftPad - rightPad)
/// - font: Text font (default: system 17pt, matching Telegram)
/// - textColor: Foreground color baked into attributed string
/// - lineSpacingFactor: Inter-line spacing as fraction of font line height (default: 0.12)
/// - Returns: Pre-calculated layout with lines, size, and lastLineWidth
static func calculate(
text: String,
maxWidth: CGFloat,
font: UIFont = .systemFont(ofSize: 17),
textColor: UIColor = .white,
lineSpacingFactor: CGFloat = telegramLineSpacingFactor
) -> CoreTextTextLayout {
// Guard: empty text, non-positive width, or NaN return zero layout
let safeMaxWidth = maxWidth.isNaN ? 100 : max(maxWidth, 10)
guard !text.isEmpty, safeMaxWidth >= 10 else {
return CoreTextTextLayout(
lines: [],
size: .zero,
lastLineWidth: 0,
lastLineHasRTL: false,
lastLineHasBlockQuote: false,
textColor: textColor
)
}
// Attributed string (Telegram: StringWithAppliedEntities.swift)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: textColor
]
let attrString = NSAttributedString(string: text, attributes: attributes)
let stringLength = attrString.length
// Typesetter (Telegram: InteractiveTextComponent line 1481)
let typesetter = CTTypesetterCreateWithAttributedString(attrString as CFAttributedString)
// Font metrics (Telegram: TextNode.swift lines 1723-1726)
let ctFont = font as CTFont
let fontAscent = CTFontGetAscent(ctFont)
let fontDescent = CTFontGetDescent(ctFont)
let fontLineHeight = floor(fontAscent + fontDescent)
let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor)
var resultLines: [Line] = []
var currentIndex: CFIndex = 0
var currentY: CGFloat = 0
var maxLineWidth: CGFloat = 0
var lastLineWidth: CGFloat = 0
var lastLineRange = NSRange(location: 0, length: 0)
// Line breaking loop (Telegram: InteractiveTextComponent lines 1490-1548)
// Safety: cap iterations to stringLength to prevent infinite loop if
// CTTypesetterSuggestLineBreak returns non-advancing counts.
var iterations = 0
while currentIndex < stringLength {
iterations += 1
if iterations > stringLength + 1 { break } // infinite loop guard
// Suggest line break (word boundary)
let lineCharCount = CTTypesetterSuggestLineBreak(
typesetter, currentIndex, Double(safeMaxWidth)
)
guard lineCharCount > 0 else { break }
// Create line from typesetter
let ctLine = CTTypesetterCreateLine(
typesetter, CFRange(location: currentIndex, length: lineCharCount)
)
// Measure line (Telegram: CTLineGetTypographicBounds)
var lineAscent: CGFloat = 0
var lineDescent: CGFloat = 0
let lineWidth = CGFloat(CTLineGetTypographicBounds(
ctLine, &lineAscent, &lineDescent, nil
))
// Guard against NaN from CoreText (observed with certain Cyrillic strings)
guard !lineWidth.isNaN, !lineAscent.isNaN, !lineDescent.isNaN else { break }
let clampedWidth = min(lineWidth, safeMaxWidth)
// Inter-line spacing (applied BETWEEN lines, not before first)
if !resultLines.isEmpty {
currentY += fontLineSpacing
}
resultLines.append(Line(
ctLine: ctLine,
origin: CGPoint(x: 0, y: currentY),
width: clampedWidth,
ascent: lineAscent,
descent: lineDescent
))
// Advance by font line height (Telegram uses font-level, not per-line)
currentY += fontLineHeight
maxLineWidth = max(maxLineWidth, clampedWidth)
lastLineWidth = clampedWidth
lastLineRange = NSRange(location: currentIndex, length: lineCharCount)
currentIndex += lineCharCount
}
let nsText = text as NSString
let safeLastRange = NSIntersectionRange(lastLineRange, NSRange(location: 0, length: nsText.length))
let lastLineText = safeLastRange.length > 0 ? nsText.substring(with: safeLastRange) : ""
let lastLineHasRTL = containsRTLCharacters(in: lastLineText)
let lastLineHasBlockQuote = lastLineText
.trimmingCharacters(in: .whitespacesAndNewlines)
.hasPrefix(">")
return CoreTextTextLayout(
lines: resultLines,
size: CGSize(width: ceil(maxLineWidth), height: ceil(currentY)),
lastLineWidth: ceil(lastLineWidth),
lastLineHasRTL: lastLineHasRTL,
lastLineHasBlockQuote: lastLineHasBlockQuote,
textColor: textColor
)
}
private static func containsRTLCharacters(in text: String) -> Bool {
for scalar in text.unicodeScalars {
switch scalar.value {
case 0x0590...0x08FF, 0xFB1D...0xFDFD, 0xFE70...0xFEFC:
return true
default:
continue
}
}
return false
}
}
// MARK: - CoreText Label (Custom Rendering View)
/// Custom UIView that renders `CoreTextTextLayout` via CoreText.
/// Drop-in replacement for UILabel in message body text rendering.
///
/// Rendering matches Telegram (InteractiveTextComponent lines 2358-2573):
/// - Flips text matrix `CGAffineTransform(scaleX: 1.0, y: -1.0)` for UIKit coords
/// - Positions each line at its baseline via `context.textPosition`
/// - Draws each CTRun individually via `CTRunDraw`
final class CoreTextLabel: UIView {
/// Pre-calculated layout to render. Setting triggers redraw.
var textLayout: CoreTextTextLayout? {
didSet { setNeedsDisplay() }
}
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false
backgroundColor = .clear
contentMode = .redraw // Redraw on bounds change
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Rendering
override func draw(_ rect: CGRect) {
guard let layout = textLayout,
let context = UIGraphicsGetCurrentContext()
else { return }
// Save context state
let savedTextMatrix = context.textMatrix
let savedTextPosition = context.textPosition
// Flip text matrix for UIKit coordinates (Telegram: scaleX: 1.0, y: -1.0).
// UIKit context has origin top-left (Y down). CoreText expects bottom-left (Y up).
// Flipping the text matrix makes glyphs render right-side-up in UIKit.
context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
for line in layout.lines {
// Baseline position in UIKit coordinates:
// line.origin.y = top of line frame
// + line.ascent = baseline (distance from top to baseline)
// Telegram: context.textPosition = CGPoint(x: minX, y: maxY - descent)
// which equals origin.y + ascent (since maxY = origin.y + ascent + descent)
context.textPosition = CGPoint(
x: line.origin.x,
y: line.origin.y + line.ascent
)
// Draw each glyph run (Telegram: CTRunDraw per run)
let glyphRuns = CTLineGetGlyphRuns(line.ctLine) as! [CTRun]
for run in glyphRuns {
CTRunDraw(run, context, CFRangeMake(0, 0)) // 0,0 = all glyphs
}
}
// Restore context state
context.textMatrix = savedTextMatrix
context.textPosition = savedTextPosition
}
}

View File

@@ -13,29 +13,143 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// MARK: - Constants
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 {

View File

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

View File

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