Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift

1951 lines
88 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
/// Universal pure UIKit message cell handles ALL message types.
/// Rosetta equivalent of Telegram's ChatMessageBubbleItemNode.
///
/// Architecture (Telegram pattern):
/// 1. `MessageCellLayout.calculate()` runs on ANY thread (background-safe)
/// 2. `NativeMessageCell.apply(layout:)` runs on main thread, just sets frames
/// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing
///
/// Subviews are always present but hidden when not needed (no alloc/dealloc overhead).
final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
// MARK: - Constants
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: floor(textFont.pointSize * 11.0 / 17.0), weight: .regular)
private static let replyNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
private static let replyTextFont = UIFont.systemFont(ofSize: 14, 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: 16, weight: .regular)
private static let fileSizeFont = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
private static let bubbleMetrics = BubbleMetrics.telegram()
private static let statusBubbleInsets = bubbleMetrics.mediaStatusInsets
private static let sendingClockAnimationKey = "clockFrameAnimation"
// 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 = StatusIconRenderer.makeCheckImage(partial: false, color: outgoingCheckColor)
private static let partialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: outgoingCheckColor)
private static let clockFrameImage = StatusIconRenderer.makeClockFrameImage(color: outgoingClockColor)
private static let clockMinImage = StatusIconRenderer.makeClockMinImage(color: outgoingClockColor)
private static let mediaFullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: mediaMetaColor)
private static let mediaPartialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: mediaMetaColor)
private static let mediaClockFrameImage = StatusIconRenderer.makeClockFrameImage(color: mediaMetaColor)
private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor)
private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed)
private static let maxVisiblePhotoTiles = 5
// Telegram-exact reply arrow rendered from SVG path (matches SwiftUI TelegramIconPath.replyArrow).
// Rendered at DISPLAY size (20×20) so UIImageView never upscales the raster.
private static let telegramReplyArrowImage: UIImage = {
let viewBox = CGSize(width: 16, height: 13)
let canvasSize = CGSize(width: 20, height: 20) // match replyIconView frame
let scale = UIScreen.main.scale
UIGraphicsBeginImageContextWithOptions(canvasSize, false, scale)
guard let ctx = UIGraphicsGetCurrentContext() else { return UIImage() }
var parser = SVGPathParser(pathData: TelegramIconPath.replyArrow)
let cgPath = parser.parse()
// Aspect-fit path into canvas, centered
let fitScale = min(canvasSize.width / viewBox.width, canvasSize.height / viewBox.height)
let scaledW = viewBox.width * fitScale
let scaledH = viewBox.height * fitScale
ctx.translateBy(x: (canvasSize.width - scaledW) / 2, y: (canvasSize.height - scaledH) / 2)
ctx.scaleBy(x: fitScale, y: fitScale)
ctx.addPath(cgPath)
ctx.setFillColor(UIColor.white.cgColor)
ctx.fillPath()
let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
UIGraphicsEndImageContext()
return image.withRenderingMode(.alwaysOriginal)
}()
// Telegram-exact stretchable bubble images (raster, not vector only way to get exact tail)
private static let bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
incomingColor: incomingColor
)
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 uses Telegram-exact stretchable image for the fill (raster tail)
// + CAShapeLayer for shadow path (approximate, vector)
private let bubbleView = UIView()
private let bubbleImageView = UIImageView()
private let bubbleLayer = CAShapeLayer() // shadow only, fillColor = clear
private let bubbleOutlineLayer = CAShapeLayer()
// 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 checkSentView = UIImageView()
private let checkReadView = UIImageView()
private let clockFrameView = UIImageView()
private let clockMinView = UIImageView()
// Reply quote
private let replyContainer = UIView()
private let replyBar = UIView()
private let replyNameLabel = UILabel()
private let replyTextLabel = UILabel()
// Photo collage (up to 5 tiles)
private let photoContainer = UIView()
private var photoTileImageViews: [UIImageView] = []
private var photoTilePlaceholderViews: [UIView] = []
private var photoTileActivityIndicators: [UIActivityIndicatorView] = []
private var photoTileErrorViews: [UIImageView] = []
private var photoTileDownloadArrows: [UIView] = []
private var photoTileButtons: [UIButton] = []
private let photoUploadingOverlayView = UIView()
private let photoUploadingIndicator = UIActivityIndicatorView(style: .medium)
private let photoOverflowOverlayView = UIView()
private let photoOverflowLabel = UILabel()
// File / Call / Avatar (shared container)
private let fileContainer = UIView()
private let fileIconView = UIView()
private let fileIconSymbolView = UIImageView()
private let fileNameLabel = UILabel()
private let fileSizeLabel = UILabel()
// Call-specific
private let callArrowView = UIImageView()
private let callBackButton = UIButton(type: .custom)
// Avatar-specific
private let avatarImageView = UIImageView()
// Forward header
private let forwardLabel = UILabel()
private let forwardAvatarView = UIView()
private let forwardNameLabel = UILabel()
// Swipe-to-reply
private let replyCircleView = UIView()
private let replyIconView = UIImageView()
private var hasTriggeredSwipeHaptic = false
private let swipeHaptic = UIImpactFeedbackGenerator(style: .heavy)
/// Global X of the first touch reject if near left screen edge (back gesture zone).
private var swipeStartX: CGFloat?
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 photoAttachments: [MessageAttachment] = []
private var totalPhotoAttachmentCount = 0
private var photoLoadTasks: [String: Task<Void, Never>] = [:]
private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
private var photoBlurHashTasks: [String: Task<Void, Never>] = [:]
private var downloadingAttachmentIds: Set<String> = []
private var failedAttachmentIds: Set<String> = []
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Setup
private func setupViews() {
contentView.backgroundColor = .clear
backgroundColor = .clear
// Allow reply swipe icon to extend beyond cell bounds
contentView.clipsToBounds = false
clipsToBounds = false
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip
// Bubble CAShapeLayer for shadow (index 0), then outline, then raster image on top
bubbleLayer.fillColor = UIColor.clear.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)
// Raster bubble image (Telegram-exact tail) added last so it renders above outline
bubbleImageView.contentMode = .scaleToFill
bubbleView.addSubview(bubbleImageView)
contentView.addSubview(bubbleView)
// Text (CoreTextLabel no font/color/lines config; all baked into CoreTextTextLayout)
bubbleView.addSubview(textLabel)
// Timestamp
// Telegram: solid pill UIColor(white:0, alpha:0.3), diameter 18 radius 9
statusBackgroundView.backgroundColor = UIColor(white: 0.0, alpha: 0.3)
statusBackgroundView.layer.cornerRadius = 9
statusBackgroundView.layer.cornerCurve = .continuous
statusBackgroundView.clipsToBounds = true
statusBackgroundView.isHidden = true
bubbleView.addSubview(statusBackgroundView)
timestampLabel.font = Self.timestampFont
bubbleView.addSubview(timestampLabel)
// 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 = 4.0
replyContainer.addSubview(replyBar)
replyNameLabel.font = Self.replyNameFont
replyContainer.addSubview(replyNameLabel)
replyTextLabel.font = Self.replyTextFont
replyTextLabel.lineBreakMode = .byTruncatingTail
replyContainer.addSubview(replyTextLabel)
bubbleView.addSubview(replyContainer)
// Photo collage
photoContainer.backgroundColor = .clear
photoContainer.clipsToBounds = true
bubbleView.addSubview(photoContainer)
for index in 0..<Self.maxVisiblePhotoTiles {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.isHidden = true
photoContainer.addSubview(imageView)
let placeholderView = UIView()
placeholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
placeholderView.isHidden = true
photoContainer.addSubview(placeholderView)
let indicator = UIActivityIndicatorView(style: .medium)
indicator.color = .white
indicator.hidesWhenStopped = true
indicator.isHidden = true
photoContainer.addSubview(indicator)
let errorView = UIImageView(image: Self.errorIcon)
errorView.contentMode = .center
errorView.isHidden = true
photoContainer.addSubview(errorView)
// Download arrow overlay shown when photo not yet downloaded
let downloadArrow = UIView()
downloadArrow.isHidden = true
downloadArrow.isUserInteractionEnabled = false
let arrowCircle = UIView(frame: CGRect(x: 0, y: 0, width: 48, height: 48))
arrowCircle.backgroundColor = UIColor.black.withAlphaComponent(0.5)
arrowCircle.layer.cornerRadius = 24
arrowCircle.tag = 1001
downloadArrow.addSubview(arrowCircle)
let arrowImage = UIImageView(
image: UIImage(systemName: "arrow.down",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
)
arrowImage.tintColor = .white
arrowImage.contentMode = .center
arrowImage.frame = arrowCircle.bounds
arrowCircle.addSubview(arrowImage)
photoContainer.addSubview(downloadArrow)
let button = UIButton(type: .custom)
button.tag = index
button.isHidden = true
button.addTarget(self, action: #selector(handlePhotoTileTap(_:)), for: .touchUpInside)
photoContainer.addSubview(button)
photoTileImageViews.append(imageView)
photoTilePlaceholderViews.append(placeholderView)
photoTileActivityIndicators.append(indicator)
photoTileErrorViews.append(errorView)
photoTileDownloadArrows.append(downloadArrow)
photoTileButtons.append(button)
}
photoUploadingOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.22)
photoUploadingOverlayView.isHidden = true
photoUploadingOverlayView.isUserInteractionEnabled = false
photoContainer.addSubview(photoUploadingOverlayView)
photoUploadingIndicator.color = .white
photoUploadingIndicator.hidesWhenStopped = true
photoUploadingIndicator.isHidden = true
photoContainer.addSubview(photoUploadingIndicator)
photoOverflowOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.45)
photoOverflowOverlayView.layer.cornerCurve = .continuous
photoOverflowOverlayView.layer.cornerRadius = 0
photoOverflowOverlayView.isHidden = true
photoOverflowOverlayView.isUserInteractionEnabled = false
photoContainer.addSubview(photoOverflowOverlayView)
photoOverflowLabel.font = UIFont.systemFont(ofSize: 26, weight: .semibold)
photoOverflowLabel.textColor = .white
photoOverflowLabel.textAlignment = .center
photoOverflowLabel.isHidden = true
photoOverflowOverlayView.addSubview(photoOverflowLabel)
// File
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconView.layer.cornerRadius = 22
fileIconSymbolView.tintColor = .white
fileIconSymbolView.contentMode = .scaleAspectFit
fileIconView.addSubview(fileIconSymbolView)
fileContainer.addSubview(fileIconView)
fileNameLabel.font = Self.fileNameFont
fileNameLabel.textColor = .white
fileContainer.addSubview(fileNameLabel)
fileSizeLabel.font = Self.fileSizeFont
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
fileContainer.addSubview(fileSizeLabel)
// Call arrow (small directional arrow left of duration)
callArrowView.contentMode = .scaleAspectFit
callArrowView.isHidden = true
fileContainer.addSubview(callArrowView)
// Call-back button Telegram: just a phone icon, NO circle background
let callPhoneIcon = UIImageView(
image: UIImage(systemName: "phone.fill",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))
)
callPhoneIcon.tintColor = .white
callPhoneIcon.contentMode = .center
callPhoneIcon.tag = 2002
callBackButton.addSubview(callPhoneIcon)
callBackButton.addTarget(self, action: #selector(callBackTapped), for: .touchUpInside)
callBackButton.isHidden = true
fileContainer.addSubview(callBackButton)
// Tap on entire file container triggers call-back for call bubbles
let fileTap = UITapGestureRecognizer(target: self, action: #selector(fileContainerTapped))
fileContainer.addGestureRecognizer(fileTap)
fileContainer.isUserInteractionEnabled = true
// Avatar image (circular, replaces icon for avatar type)
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true
avatarImageView.layer.cornerRadius = 22
avatarImageView.isHidden = true
fileContainer.addSubview(avatarImageView)
bubbleView.addSubview(fileContainer)
// Listen for avatar download trigger (tap-to-download, Android parity)
NotificationCenter.default.addObserver(
self, selector: #selector(handleAttachmentDownload(_:)),
name: .triggerAttachmentDownload, object: nil
)
// Forward header
forwardLabel.font = Self.forwardLabelFont
forwardLabel.text = "Forwarded message"
forwardLabel.textColor = UIColor.white.withAlphaComponent(0.6)
bubbleView.addSubview(forwardLabel)
forwardAvatarView.backgroundColor = UIColor.white.withAlphaComponent(0.3)
forwardAvatarView.layer.cornerRadius = 10
bubbleView.addSubview(forwardAvatarView)
forwardNameLabel.font = Self.forwardNameFont
forwardNameLabel.textColor = .white
bubbleView.addSubview(forwardNameLabel)
// Swipe reply icon circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier)
replyCircleView.backgroundColor = UIColor.white.withAlphaComponent(0.12)
replyCircleView.layer.cornerRadius = 17 // 34pt / 2
replyCircleView.alpha = 0
contentView.addSubview(replyCircleView)
replyIconView.image = Self.telegramReplyArrowImage
replyIconView.contentMode = .scaleAspectFit
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)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
pan.delegate = self
contentView.addGestureRecognizer(pan)
}
// 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,
forwardSenderName: String? = nil
) {
self.message = message
self.actions = actions
let isOutgoing = currentLayout?.isOutgoing ?? false
let isMediaStatus: Bool = {
guard let type = currentLayout?.messageType else { return false }
return type == .photo || type == .photoWithCaption
}()
// Text use cached CoreTextTextLayout from measurement phase.
// Same CTTypesetter pipeline identical line breaks, zero recomputation.
textLabel.textLayout = textLayout
// Timestamp
timestampLabel.text = timestamp
if isMediaStatus {
timestampLabel.textColor = .white
} else {
timestampLabel.textColor = isOutgoing
? UIColor.white.withAlphaComponent(0.55)
: UIColor.white.withAlphaComponent(0.6)
}
// Delivery 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 {
switch message.deliveryStatus {
case .delivered:
shouldShowSentCheck = true
checkSentView.image = isMediaStatus ? Self.mediaFullCheckImage : Self.fullCheckImage
if message.isRead {
checkReadView.image = isMediaStatus ? Self.mediaPartialCheckImage : Self.partialCheckImage
shouldShowReadCheck = true
}
case .waiting:
shouldShowClock = true
clockFrameView.image = isMediaStatus ? Self.mediaClockFrameImage : Self.clockFrameImage
clockMinView.image = isMediaStatus ? Self.mediaClockMinImage : Self.clockMinImage
startSendingClockAnimation()
case .error:
break
}
}
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 is shadow-only; fill comes from bubbleImageView)
photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor
// Reply quote
if let replyName {
replyContainer.isHidden = false
replyBar.backgroundColor = isOutgoing ? .white : Self.outgoingColor
replyNameLabel.text = replyName
replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
replyTextLabel.text = replyText ?? ""
replyTextLabel.textColor = isOutgoing
? UIColor.white.withAlphaComponent(0.8)
: UIColor.white.withAlphaComponent(0.6)
} else {
replyContainer.isHidden = true
}
// Forward
if let forwardSenderName {
forwardLabel.isHidden = false
forwardAvatarView.isHidden = false
forwardNameLabel.isHidden = false
forwardNameLabel.text = forwardSenderName
} else {
forwardLabel.isHidden = true
forwardAvatarView.isHidden = true
forwardNameLabel.isHidden = true
}
// Photo
configurePhoto(for: message)
// File
if let layout = currentLayout, layout.hasFile {
fileContainer.isHidden = false
if let callAtt = message.attachments.first(where: { $0.type == .call }) {
let durationSec = AttachmentPreviewCodec.parseCallDurationSeconds(callAtt.preview)
let isOutgoing = currentLayout?.isOutgoing ?? false
let isMissed = durationSec == 0
let isIncoming = !isOutgoing
// Telegram: call bubbles have NO icon circle on the left
avatarImageView.isHidden = true
fileIconView.isHidden = true
// Title (16pt medium Telegram parity)
fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium)
if isMissed {
fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call"
} else {
fileNameLabel.text = isIncoming ? "Incoming Call" : "Outgoing Call"
}
// Duration with arrow
if isMissed {
fileSizeLabel.text = "Declined"
fileSizeLabel.textColor = UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 0.95)
} else {
fileSizeLabel.text = Self.formattedDuration(seconds: durationSec)
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
}
// Directional arrow (green/red)
let arrowName = isIncoming ? "arrow.down.left" : "arrow.up.right"
callArrowView.image = UIImage(
systemName: arrowName,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 10, weight: .bold)
)
callArrowView.tintColor = isMissed
? UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 1)
: UIColor(red: 0.21, green: 0.75, blue: 0.20, alpha: 1) // #36C033
callArrowView.isHidden = false
callBackButton.isHidden = false
// Call button color: outgoing = white, incoming = blue (Telegram accentControlColor)
let callPhoneView = callBackButton.viewWithTag(2002) as? UIImageView
if isOutgoing {
callPhoneView?.tintColor = .white
} else {
callPhoneView?.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1)
}
} else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
avatarImageView.isHidden = true
fileIconView.isHidden = false
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "doc.fill")
fileNameLabel.font = Self.fileNameFont
fileNameLabel.text = parsed.fileName
fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize)
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
callArrowView.isHidden = true
callBackButton.isHidden = true
} else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) {
fileNameLabel.font = Self.fileNameFont
fileNameLabel.text = "Avatar"
callArrowView.isHidden = true
callBackButton.isHidden = true
// Android parity: show cached image OR blurhash placeholder.
// NO auto-download user must tap to download (via .triggerAttachmentDownload).
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: avatarAtt.id) {
avatarImageView.image = cached
avatarImageView.isHidden = false
fileIconView.isHidden = true
fileSizeLabel.text = "Shared profile photo"
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
} else {
let isOutgoing = currentLayout?.isOutgoing ?? false
if isOutgoing {
// Own avatar already uploaded, just loading from disk
fileSizeLabel.text = "Shared profile photo"
} else {
// Incoming avatar needs download on tap (Android parity)
fileSizeLabel.text = "Tap to download"
}
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
// Show blurhash placeholder (decode async if not cached)
let hash = AttachmentPreviewCodec.blurHash(from: avatarAtt.preview)
if !hash.isEmpty, let blurImg = Self.blurHashCache.object(forKey: hash as NSString) {
avatarImageView.image = blurImg
avatarImageView.isHidden = false
fileIconView.isHidden = true
} else {
avatarImageView.isHidden = true
fileIconView.isHidden = false
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill")
}
// Async: decode blurhash + try disk cache (NO CDN download tap required)
let messageId = message.id
let attId = avatarAtt.id
Task.detached(priority: .userInitiated) {
// 1. Decode blurhash immediately (~2ms)
if !hash.isEmpty {
if let decoded = UIImage.fromBlurHash(hash, width: 32, height: 32) {
await MainActor.run { [weak self] in
guard let self, self.message?.id == messageId else { return }
Self.blurHashCache.setObject(decoded, forKey: hash as NSString)
self.avatarImageView.image = decoded
self.avatarImageView.isHidden = false
self.fileIconView.isHidden = true
}
}
}
// 2. Try disk cache only (previously downloaded)
if let diskImage = AttachmentCache.shared.loadImage(forAttachmentId: attId) {
await MainActor.run { [weak self] in
guard let self, self.message?.id == messageId else { return }
self.avatarImageView.image = diskImage
self.avatarImageView.isHidden = false
self.fileIconView.isHidden = true
self.fileSizeLabel.text = "Shared profile photo"
}
}
// CDN download is triggered by user tap via .triggerAttachmentDownload
}
}
} else {
avatarImageView.isHidden = true
fileIconView.isHidden = false
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "doc.fill")
fileNameLabel.font = Self.fileNameFont
fileNameLabel.text = "File"
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
callArrowView.isHidden = true
callBackButton.isHidden = true
}
} else {
fileContainer.isHidden = true
}
}
/// Apply pre-calculated layout (main thread only just sets frames).
/// This is the "apply" part of Telegram's asyncLayout pattern.
/// NOTE: Bubble X-position is recalculated in layoutSubviews() based on actual cell width.
func apply(layout: MessageCellLayout) {
currentLayout = layout
setNeedsLayout() // trigger layoutSubviews for correct X positioning
}
override func layoutSubviews() {
super.layoutSubviews()
guard let layout = currentLayout else { return }
let cellW = contentView.bounds.width
let tailProtrusion = Self.bubbleMetrics.tailProtrusion
// Rule 2: Tail reserve (6pt) + margin (2pt) strict vertical body alignment
let bubbleX: CGFloat
if layout.isOutgoing {
bubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset
} else {
bubbleX = tailProtrusion + 2
}
bubbleView.frame = CGRect(
x: bubbleX, y: layout.groupGap,
width: layout.bubbleSize.width, height: layout.bubbleSize.height
)
// Raster bubble image (Telegram-exact tail via stretchable image)
// Telegram includes tail space (6pt) in backgroundFrame for ALL bubbles,
// not just tailed ones. This keeps right edges aligned in a group.
// For non-tailed, the tail area is transparent in the stretchable image.
let imageFrame: CGRect
if layout.isOutgoing {
imageFrame = CGRect(x: 0, y: 0,
width: layout.bubbleSize.width + tailProtrusion,
height: layout.bubbleSize.height)
} else {
imageFrame = CGRect(x: -tailProtrusion, y: 0,
width: layout.bubbleSize.width + tailProtrusion,
height: layout.bubbleSize.height)
}
// Telegram extends bubble image by 1pt on each side (ChatMessageBackground.swift line 115:
// `let imageFrame = CGRect(...).insetBy(dx: -1.0, dy: -1.0)`).
// This makes adjacent bubbles overlap by 2pt vertically, reducing perceived gap.
bubbleImageView.frame = imageFrame.insetBy(dx: -1, dy: -1)
bubbleImageView.image = Self.bubbleImages.image(
outgoing: layout.isOutgoing, mergeType: layout.mergeType
)
// Vector shadow path (approximate shape, used only for shadow)
bubbleLayer.frame = bubbleView.bounds
let shapeRect = imageFrame
bubbleLayer.path = BubblePathCache.shared.path(
size: shapeRect.size, origin: shapeRect.origin,
mergeType: layout.mergeType,
isOutgoing: layout.isOutgoing,
metrics: Self.bubbleMetrics
)
bubbleLayer.shadowPath = bubbleLayer.path
bubbleOutlineLayer.frame = bubbleView.bounds
bubbleOutlineLayer.path = bubbleLayer.path
let hasPhotoContent = layout.hasPhoto
if hasPhotoContent {
bubbleLayer.shadowOpacity = 0.04
bubbleLayer.shadowRadius = 0.4
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.2)
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
} else if layout.hasTail {
bubbleLayer.shadowOpacity = 0.12
bubbleLayer.shadowRadius = 0.6
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
} else {
bubbleLayer.shadowOpacity = 0.12
bubbleLayer.shadowRadius = 0.6
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
bubbleOutlineLayer.strokeColor = UIColor.black.withAlphaComponent(
layout.isOutgoing ? 0.16 : 0.22
).cgColor
}
// Text
textLabel.isHidden = layout.textSize == .zero
textLabel.frame = layout.textFrame
// Timestamp + checkmarks (two-node overlay)
timestampLabel.frame = layout.timestampFrame
checkSentView.frame = layout.checkSentFrame
checkReadView.frame = layout.checkReadFrame
clockFrameView.frame = layout.clockFrame
clockMinView.frame = layout.clockFrame
#if DEBUG
assertStatusLaneFramesValid(layout: layout)
#endif
// Telegram-style date/status pill on media-only bubbles.
updateStatusBackgroundVisibility()
updateStatusBackgroundFrame()
// Reply
replyContainer.isHidden = !layout.hasReplyQuote
if layout.hasReplyQuote {
replyContainer.frame = layout.replyContainerFrame
replyBar.frame = layout.replyBarFrame
replyNameLabel.frame = layout.replyNameFrame
replyTextLabel.frame = layout.replyTextFrame
}
// Photo
photoContainer.isHidden = !layout.hasPhoto
if layout.hasPhoto {
photoContainer.frame = layout.photoFrame
layoutPhotoTiles()
}
bringStatusOverlayToFront()
// File / Call / Avatar
fileContainer.isHidden = !layout.hasFile
if layout.hasFile {
fileContainer.frame = layout.fileFrame
let isCallType = message?.attachments.contains(where: { $0.type == .call }) ?? false
let fileW = layout.fileFrame.width
let isAvatarType = message?.attachments.contains(where: { $0.type == .avatar }) ?? false
let fileContainerH = layout.fileFrame.height
// For file-only messages, fileContainer spans the ENTIRE bubble (fileH = bubbleH).
// Centering in fileContainerH gives visually perfect centering within the bubble.
// The timestamp is a separate view positioned at the bottom no collision risk
// because content is left-aligned and timestamp is right-aligned.
let centerableH = fileContainerH
if isCallType {
// Telegram-exact call layout: NO icon circle, text at left edge
// Source: ChatMessageCallBubbleContentNode.swift
fileIconView.isHidden = true
let callBtnSize: CGFloat = 36
let callBtnRight: CGFloat = 10
let textRight = callBtnRight + callBtnSize + 8
// Vertically center content above timestamp
let contentH: CGFloat = 36 // title(20) + gap(2) + subtitle(14)
let topY = max(0, (centerableH - contentH) / 2)
fileNameLabel.frame = CGRect(x: 11, y: topY, width: fileW - 11 - textRight, height: 20)
fileSizeLabel.frame = CGRect(x: 25, y: topY + 22, width: fileW - 25 - textRight, height: 14)
callArrowView.frame = CGRect(x: 12, y: topY + 25, width: 10, height: 10)
// Call button: vertically centered in same area
let btnY = max(0, (centerableH - callBtnSize) / 2)
callBackButton.frame = CGRect(x: fileW - callBtnSize - callBtnRight, y: btnY, width: callBtnSize, height: callBtnSize)
callBackButton.viewWithTag(2002)?.frame = CGRect(x: 0, y: 0, width: callBtnSize, height: callBtnSize)
avatarImageView.isHidden = true
} else if isAvatarType {
// Avatar layout: vertically centered icon (44pt) + title + description
let contentH: CGFloat = 44 // icon height dominates
let topY = max(0, (centerableH - contentH) / 2)
fileIconView.frame = CGRect(x: 9, y: topY, width: 44, height: 44)
fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22)
avatarImageView.frame = CGRect(x: 9, y: topY, width: 44, height: 44)
let textTopY = topY + 4
fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19)
fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16)
} else {
// File layout: vertically centered icon + title + size
let contentH: CGFloat = 44 // icon height dominates
let topY = max(0, (centerableH - contentH) / 2)
fileIconView.frame = CGRect(x: 9, y: topY, width: 44, height: 44)
fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22)
let textTopY = topY + 4
fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19)
fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16)
avatarImageView.isHidden = true
}
}
// Forward
if layout.isForward {
forwardLabel.frame = layout.forwardHeaderFrame
forwardAvatarView.frame = layout.forwardAvatarFrame
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) positioned behind bubble's trailing edge.
// Starts hidden (alpha=0, scale=0). As bubble slides left via transform,
// the icon is revealed in the gap between shifted bubble and original position.
let replyIconDiameter: CGFloat = 34
let replyIconX = bubbleView.frame.maxX - replyIconDiameter
let replyIconY = bubbleView.frame.midY - replyIconDiameter / 2
replyCircleView.frame = CGRect(x: replyIconX, y: replyIconY, width: replyIconDiameter, height: replyIconDiameter)
replyIconView.frame = CGRect(x: replyIconX + 7, y: replyIconY + 7, width: 20, height: 20)
}
private static func formattedDuration(seconds: Int) -> String {
let safe = max(seconds, 0)
let minutes = safe / 60
let secs = safe % 60
return String(format: "%d:%02d", minutes, secs)
}
private static func formattedFileSize(bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
if bytes < 1024 * 1024 * 1024 { return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) }
return String(format: "%.1f GB", Double(bytes) / (1024 * 1024 * 1024))
}
/// Downloads avatar from CDN, decrypts, caches to disk, and returns the image.
/// Shared logic with `MessageAvatarView.downloadAvatar()`.
private static func downloadAndCacheAvatar(
tag: String, attachmentId: String, storedPassword: String, senderKey: String
) async -> UIImage? {
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
guard let image = decryptAvatarImage(encryptedString: encryptedString, passwords: passwords)
else { return nil }
AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
// Android parity: save avatar to sender's profile after download
if let jpegData = image.jpegData(compressionQuality: 0.85) {
let base64 = jpegData.base64EncodedString()
await MainActor.run {
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey)
}
}
return image
} catch {
return nil
}
}
/// Tries each password candidate to decrypt avatar image data.
private static func decryptAvatarImage(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 img = parseAvatarImageData(data) { return img }
}
for password in passwords {
guard let data = try? crypto.decryptWithPassword(
encryptedString, password: password
) else { continue }
if let img = parseAvatarImageData(data) { return img }
}
return nil
}
/// Parses avatar image data (data URI or raw base64 or raw bytes).
private static func parseAvatarImageData(_ 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 img = AttachmentCache.downsampledImage(from: imageData) {
return img
}
} else if let imageData = Data(base64Encoded: str),
let img = AttachmentCache.downsampledImage(from: imageData) {
return img
}
}
return AttachmentCache.downsampledImage(from: data)
}
// MARK: - Self-sizing (from pre-calculated layout)
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
// Always return concrete height never fall to super (expensive self-sizing)
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = currentLayout?.totalHeight ?? 50
return attrs
}
// MARK: - Context Menu
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
guard let message, let actions else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
var items: [UIAction] = []
if !message.text.isEmpty {
items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
actions.onCopy(message.text)
})
}
// Avatars, calls, and forwarded messages cannot be replied to or forwarded
let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages })
if !isAvatarOrForwarded {
items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
actions.onReply(message)
})
items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
actions.onForward(message)
})
}
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
actions.onDelete(message)
})
return UIMenu(children: items)
}
}
// MARK: - Swipe to Reply
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
// Block swipe on avatar, call, and forwarded-message attachments
let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }) ?? false
if isReplyBlocked { return }
let translation = gesture.translation(in: contentView)
let threshold: CGFloat = 55
let elasticCap: CGFloat = 85 // match SwiftUI SwipeToReplyModifier
let backGestureEdge: CGFloat = 40
switch gesture.state {
case .began:
// Record start position reject if near left screen edge (iOS back gesture zone)
let startPoint = gesture.location(in: contentView.window)
swipeStartX = startPoint.x
// Pre-warm haptic engine for instant response at threshold
swipeHaptic.prepare()
case .changed:
// Reject gestures from back gesture zone (left 40pt)
if let startX = swipeStartX, startX < backGestureEdge { return }
// Telegram: ALL messages swipe LEFT
let raw = min(translation.x, 0)
guard raw < 0 else { return }
// Elastic resistance past cap (Telegram rubber-band)
let absRaw = abs(raw)
let clamped: CGFloat
if absRaw > elasticCap {
clamped = -(elasticCap + (absRaw - elasticCap) * 0.15)
} else {
clamped = raw
}
bubbleView.transform = CGAffineTransform(translationX: clamped, y: 0)
// Icon progress: fade in from 4pt to threshold
let absClamped = abs(clamped)
let progress: CGFloat = absClamped > 4 ? min((absClamped - 4) / (threshold - 4), 1) : 0
replyCircleView.alpha = progress
replyCircleView.transform = CGAffineTransform(scaleX: progress, y: progress)
replyIconView.alpha = progress
replyIconView.transform = CGAffineTransform(scaleX: progress, y: progress)
// Haptic at threshold crossing (once per gesture, pre-prepared)
if absClamped >= threshold, !hasTriggeredSwipeHaptic {
hasTriggeredSwipeHaptic = true
swipeHaptic.impactOccurred()
}
case .ended, .cancelled:
let shouldReply = abs(translation.x) >= threshold
if shouldReply, let message, let actions {
actions.onReply(message)
}
hasTriggeredSwipeHaptic = false
swipeStartX = nil
// Velocity-aware spring (Telegram passes swipe velocity for natural spring-back)
let velocity = gesture.velocity(in: contentView)
let currentOffset = bubbleView.transform.tx
let relativeVx: CGFloat = currentOffset != 0 ? velocity.x / abs(currentOffset) : 0
let initialVelocity = CGVector(dx: relativeVx, dy: 0)
let timing = UISpringTimingParameters(mass: 1, stiffness: 386, damping: 33.4, initialVelocity: initialVelocity)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timing)
animator.addAnimations {
self.bubbleView.transform = .identity
self.replyCircleView.alpha = 0
self.replyCircleView.transform = .identity
self.replyIconView.alpha = 0
self.replyIconView.transform = .identity
}
animator.startAnimation()
default:
break
}
}
@objc private func handleDeliveryFailedTap() {
guard let message, let actions else { return }
actions.onRetry(message)
}
@objc private func callBackTapped() {
guard let message, let actions else { return }
let isOutgoing = currentLayout?.isOutgoing ?? false
let peerKey = isOutgoing ? message.toPublicKey : message.fromPublicKey
actions.onCall(peerKey)
}
@objc private func handleAttachmentDownload(_ notif: Notification) {
guard let id = notif.object as? String,
let message,
let avatarAtt = message.attachments.first(where: { $0.type == .avatar }),
avatarAtt.id == id else { return }
// Already downloaded?
if AttachmentCache.shared.cachedImage(forAttachmentId: id) != nil { return }
// Download from CDN
let tag = AttachmentPreviewCodec.downloadTag(from: avatarAtt.preview)
guard !tag.isEmpty else { return }
guard let password = message.attachmentPassword, !password.isEmpty else { return }
// Show loading state
fileSizeLabel.text = "Downloading..."
let messageId = message.id
let senderKey = message.fromPublicKey
Task.detached(priority: .userInitiated) {
let downloaded = await Self.downloadAndCacheAvatar(
tag: tag, attachmentId: id,
storedPassword: password, senderKey: senderKey
)
await MainActor.run { [weak self] in
guard let self, self.message?.id == messageId else { return }
if let downloaded {
self.avatarImageView.image = downloaded
self.avatarImageView.isHidden = false
self.fileIconView.isHidden = true
self.fileSizeLabel.text = "Shared profile photo"
} else {
self.fileSizeLabel.text = "Tap to retry"
}
}
}
}
@objc private func fileContainerTapped() {
guard let message, let actions else { return }
let isCallType = message.attachments.contains { $0.type == .call }
if isCallType {
// Tap anywhere on call bubble call back
let isOutgoing = currentLayout?.isOutgoing ?? false
let peerKey = isOutgoing ? message.toPublicKey : message.fromPublicKey
actions.onCall(peerKey)
} else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) {
// Tap on avatar bubble trigger download (Android parity)
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: avatarAtt.id)
} else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
// Tap on file bubble trigger download/share
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: fileAtt.id)
}
}
@objc private func handlePhotoTileTap(_ sender: UIButton) {
guard sender.tag >= 0, sender.tag < photoAttachments.count,
let message,
let actions else {
return
}
let attachment = photoAttachments[sender.tag]
if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil {
actions.onImageTap(attachment.id)
return
}
Task { [weak self] in
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .userInitiated) {
AttachmentCache.shared.loadImage(forAttachmentId: attachment.id)
}.value
await ImageLoadLimiter.shared.release()
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self,
self.message?.id == message.id else {
return
}
if loaded != nil {
actions.onImageTap(attachment.id)
} else {
self.downloadPhotoAttachment(attachment: attachment, message: message)
}
}
}
}
private func configurePhoto(for message: ChatMessage) {
guard let layout = currentLayout, layout.hasPhoto else {
resetPhotoTiles()
return
}
let allPhotoAttachments = message.attachments.filter { $0.type == .image }
totalPhotoAttachmentCount = allPhotoAttachments.count
photoAttachments = Array(allPhotoAttachments.prefix(Self.maxVisiblePhotoTiles))
guard !photoAttachments.isEmpty else {
resetPhotoTiles()
return
}
let activeIds = Set(photoAttachments.map(\.id))
for (attachmentId, task) in photoLoadTasks where !activeIds.contains(attachmentId) {
task.cancel()
photoLoadTasks.removeValue(forKey: attachmentId)
}
for (attachmentId, task) in photoDownloadTasks where !activeIds.contains(attachmentId) {
task.cancel()
photoDownloadTasks.removeValue(forKey: attachmentId)
downloadingAttachmentIds.remove(attachmentId)
failedAttachmentIds.remove(attachmentId)
}
for (attachmentId, task) in photoBlurHashTasks where !activeIds.contains(attachmentId) {
task.cancel()
photoBlurHashTasks.removeValue(forKey: attachmentId)
}
for index in 0..<photoTileImageViews.count {
let isActiveTile = index < photoAttachments.count
let imageView = photoTileImageViews[index]
let placeholderView = photoTilePlaceholderViews[index]
let indicator = photoTileActivityIndicators[index]
let errorView = photoTileErrorViews[index]
let downloadArrow = photoTileDownloadArrows[index]
let button = photoTileButtons[index]
button.isHidden = !isActiveTile
guard isActiveTile else {
imageView.image = nil
imageView.isHidden = true
placeholderView.isHidden = true
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = true
downloadArrow.isHidden = true
continue
}
let attachment = photoAttachments[index]
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
failedAttachmentIds.remove(attachment.id)
setPhotoTileImage(cached, at: index, animated: false)
placeholderView.isHidden = true
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = true
downloadArrow.isHidden = true
} else {
if let blur = Self.cachedBlurHashImage(from: attachment.preview) {
setPhotoTileImage(blur, at: index, animated: false)
} else {
setPhotoTileImage(nil, at: index, animated: false)
startPhotoBlurHashTask(attachment: attachment)
}
placeholderView.isHidden = imageView.image != nil
let hasFailed = failedAttachmentIds.contains(attachment.id)
if hasFailed {
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = false
downloadArrow.isHidden = true
} else if downloadingAttachmentIds.contains(attachment.id) {
indicator.startAnimating()
indicator.isHidden = false
errorView.isHidden = true
downloadArrow.isHidden = true
} else {
// Not downloaded, not downloading show download arrow
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = true
downloadArrow.isHidden = false
}
startPhotoLoadTask(attachment: attachment)
}
}
layoutPhotoOverflowOverlay()
updatePhotoUploadingOverlay(
isVisible: layout.isOutgoing && message.deliveryStatus == .waiting
)
}
private func layoutPhotoTiles() {
guard let layout = currentLayout, !photoAttachments.isEmpty else { return }
updatePhotoContainerMask(layout: layout)
let frames = Self.photoTileFrames(count: photoAttachments.count, in: photoContainer.bounds)
for (index, frame) in frames.enumerated() where index < photoTileImageViews.count {
photoTileImageViews[index].frame = frame
photoTilePlaceholderViews[index].frame = frame
photoTileButtons[index].frame = frame
photoTileActivityIndicators[index].center = CGPoint(x: frame.midX, y: frame.midY)
photoTileErrorViews[index].frame = CGRect(
x: frame.midX - 10, y: frame.midY - 10,
width: 20, height: 20
)
// Download arrow: full tile frame, circle centered
let arrow = photoTileDownloadArrows[index]
arrow.frame = frame
if let circle = arrow.viewWithTag(1001) {
circle.center = CGPoint(x: frame.width / 2, y: frame.height / 2)
}
}
photoUploadingOverlayView.frame = photoContainer.bounds
photoUploadingIndicator.center = CGPoint(
x: photoContainer.bounds.midX,
y: photoContainer.bounds.midY
)
photoContainer.bringSubviewToFront(photoUploadingOverlayView)
photoContainer.bringSubviewToFront(photoUploadingIndicator)
photoContainer.bringSubviewToFront(photoOverflowOverlayView)
layoutPhotoOverflowOverlay(frames: frames)
applyPhotoLastTileMask(frames: frames, layout: layout)
}
private func updatePhotoContainerMask(layout: MessageCellLayout? = nil) {
guard let layout = layout ?? currentLayout else {
photoContainer.layer.mask = nil
return
}
photoContainer.layer.mask = MediaBubbleCornerMaskFactory.containerMask(
bounds: photoContainer.bounds,
mergeType: layout.mergeType,
outgoing: layout.isOutgoing
)
}
private func applyPhotoLastTileMask(frames: [CGRect], layout: MessageCellLayout) {
guard !frames.isEmpty else { return }
// Reset per-tile masks first.
for index in 0..<photoTileImageViews.count {
photoTileImageViews[index].layer.mask = nil
photoTilePlaceholderViews[index].layer.mask = nil
photoTileButtons[index].layer.mask = nil
}
photoOverflowOverlayView.layer.mask = nil
let lastVisibleIndex = photoAttachments.count - 1
guard lastVisibleIndex >= 0, lastVisibleIndex < frames.count else { return }
let tileFrame = frames[lastVisibleIndex]
guard let prototypeMask = MediaBubbleCornerMaskFactory.tileMask(
tileFrame: tileFrame,
containerBounds: photoContainer.bounds,
mergeType: layout.mergeType,
outgoing: layout.isOutgoing
) else {
return
}
applyMaskPrototype(prototypeMask, to: photoTileImageViews[lastVisibleIndex])
applyMaskPrototype(prototypeMask, to: photoTilePlaceholderViews[lastVisibleIndex])
applyMaskPrototype(prototypeMask, to: photoTileButtons[lastVisibleIndex])
// Keep overflow badge clipping aligned with the same rounded corner.
applyMaskPrototype(prototypeMask, to: photoOverflowOverlayView)
}
private func applyMaskPrototype(_ prototype: CAShapeLayer, to view: UIView) {
guard let path = prototype.path else {
view.layer.mask = nil
return
}
let mask = CAShapeLayer()
mask.frame = CGRect(origin: .zero, size: view.bounds.size)
mask.path = path
view.layer.mask = mask
}
private static func photoTileFrames(count: Int, in bounds: CGRect) -> [CGRect] {
let spacing: CGFloat = 2
let width = bounds.width
let height = bounds.height
guard count > 0, width > 0, height > 0 else { return [] }
switch count {
case 1:
return [bounds]
case 2:
let cellWidth = (width - spacing) / 2
return [
CGRect(x: 0, y: 0, width: cellWidth, height: height),
CGRect(x: cellWidth + spacing, y: 0, width: cellWidth, height: height)
]
case 3:
let rightWidth = width * 0.34
let leftWidth = width - spacing - rightWidth
let rightTopHeight = (height - spacing) / 2
let rightBottomHeight = height - rightTopHeight - spacing
return [
CGRect(x: 0, y: 0, width: leftWidth, height: height),
CGRect(x: leftWidth + spacing, y: 0, width: rightWidth, height: rightTopHeight),
CGRect(x: leftWidth + spacing, y: rightTopHeight + spacing, width: rightWidth, height: rightBottomHeight)
]
case 4:
let cellWidth = (width - spacing) / 2
let topHeight = (height - spacing) / 2
let bottomHeight = height - topHeight - spacing
return [
CGRect(x: 0, y: 0, width: cellWidth, height: topHeight),
CGRect(x: cellWidth + spacing, y: 0, width: cellWidth, height: topHeight),
CGRect(x: 0, y: topHeight + spacing, width: cellWidth, height: bottomHeight),
CGRect(x: cellWidth + spacing, y: topHeight + spacing, width: cellWidth, height: bottomHeight)
]
default:
let topCellWidth = (width - spacing) / 2
let bottomCellWidth = (width - spacing * 2) / 3
var topHeight = min(topCellWidth * 0.85, 176)
var bottomHeight = min(bottomCellWidth * 0.85, 144)
let expectedHeight = topHeight + spacing + bottomHeight
if expectedHeight > 0 {
let scale = height / expectedHeight
topHeight *= scale
bottomHeight *= scale
}
return [
CGRect(x: 0, y: 0, width: topCellWidth, height: topHeight),
CGRect(x: topCellWidth + spacing, y: 0, width: topCellWidth, height: topHeight),
CGRect(x: 0, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight),
CGRect(x: bottomCellWidth + spacing, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight),
CGRect(x: (bottomCellWidth + spacing) * 2, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight)
]
}
}
private func setPhotoTileImage(_ image: UIImage?, at index: Int, animated: Bool) {
guard index >= 0, index < photoTileImageViews.count else { return }
let imageView = photoTileImageViews[index]
let update = {
imageView.image = image
}
if animated, image != nil, imageView.image != nil {
UIView.transition(
with: imageView,
duration: 0.18,
options: [.transitionCrossDissolve, .beginFromCurrentState, .allowUserInteraction],
animations: update
)
} else {
update()
}
imageView.isHidden = image == nil
}
private func layoutPhotoOverflowOverlay(frames: [CGRect]? = nil) {
let overflowCount = totalPhotoAttachmentCount - photoAttachments.count
guard overflowCount > 0,
!photoAttachments.isEmpty else {
photoOverflowOverlayView.isHidden = true
photoOverflowLabel.isHidden = true
photoOverflowLabel.text = nil
return
}
let lastVisibleIndex = photoAttachments.count - 1
guard lastVisibleIndex >= 0, lastVisibleIndex < photoTileImageViews.count else {
photoOverflowOverlayView.isHidden = true
photoOverflowLabel.isHidden = true
photoOverflowLabel.text = nil
return
}
let frame: CGRect
if let frames, lastVisibleIndex < frames.count {
frame = frames[lastVisibleIndex]
} else {
frame = photoTileImageViews[lastVisibleIndex].frame
}
guard frame.width > 0, frame.height > 0 else {
photoOverflowOverlayView.isHidden = true
photoOverflowLabel.isHidden = true
photoOverflowLabel.text = nil
return
}
photoOverflowOverlayView.frame = frame
photoOverflowOverlayView.isHidden = false
photoOverflowLabel.isHidden = false
photoOverflowLabel.text = "+\(overflowCount)"
photoOverflowLabel.font = UIFont.systemFont(
ofSize: max(18, min(frame.height * 0.34, 34)),
weight: .semibold
)
photoOverflowLabel.frame = photoOverflowOverlayView.bounds
}
private func updatePhotoUploadingOverlay(isVisible: Bool) {
photoUploadingOverlayView.isHidden = !isVisible
if isVisible {
photoUploadingIndicator.isHidden = false
photoUploadingIndicator.startAnimating()
} else {
photoUploadingIndicator.stopAnimating()
photoUploadingIndicator.isHidden = true
}
}
private func tileIndex(for attachmentId: String) -> Int? {
photoAttachments.firstIndex(where: { $0.id == attachmentId })
}
private func startPhotoBlurHashTask(attachment: MessageAttachment) {
let attachmentId = attachment.id
guard photoBlurHashTasks[attachmentId] == nil else { return }
let hash = Self.extractBlurHash(from: attachment.preview)
guard !hash.isEmpty else { return }
if let cached = Self.blurHashCache.object(forKey: hash as NSString) {
if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileImageViews.count {
setPhotoTileImage(cached, at: tileIndex, animated: false)
photoTilePlaceholderViews[tileIndex].isHidden = true
}
return
}
photoBlurHashTasks[attachmentId] = Task { [weak self] in
let decoded = await Task.detached(priority: .utility) {
UIImage.fromBlurHash(hash, width: 48, height: 48)
}.value
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.photoBlurHashTasks.removeValue(forKey: attachmentId)
guard let decoded,
let tileIndex = self.tileIndex(for: attachmentId),
tileIndex < self.photoTileImageViews.count else {
return
}
Self.blurHashCache.setObject(decoded, forKey: hash as NSString)
// Do not override already loaded real image.
guard self.photoTileImageViews[tileIndex].image == nil else { return }
self.setPhotoTileImage(decoded, at: tileIndex, animated: false)
self.photoTilePlaceholderViews[tileIndex].isHidden = true
}
}
}
private func startPhotoLoadTask(attachment: MessageAttachment) {
if photoLoadTasks[attachment.id] != nil { return }
let attachmentId = attachment.id
photoLoadTasks[attachmentId] = Task { [weak self] in
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .userInitiated) {
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
}.value
await ImageLoadLimiter.shared.release()
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.photoLoadTasks.removeValue(forKey: attachmentId)
guard let tileIndex = self.tileIndex(for: attachmentId),
tileIndex < self.photoTileImageViews.count,
let loaded else {
return
}
self.failedAttachmentIds.remove(attachmentId)
self.setPhotoTileImage(loaded, at: tileIndex, animated: true)
self.photoTilePlaceholderViews[tileIndex].isHidden = true
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = true
self.photoTileDownloadArrows[tileIndex].isHidden = true
}
}
}
private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) {
if photoDownloadTasks[attachment.id] != nil { return }
let tag = Self.extractTag(from: attachment.preview)
guard !tag.isEmpty,
let storedPassword = message.attachmentPassword,
!storedPassword.isEmpty else {
failedAttachmentIds.insert(attachment.id)
if let tileIndex = tileIndex(for: attachment.id), tileIndex < photoTileErrorViews.count {
photoTileActivityIndicators[tileIndex].stopAnimating()
photoTileActivityIndicators[tileIndex].isHidden = true
photoTileErrorViews[tileIndex].isHidden = false
photoTileDownloadArrows[tileIndex].isHidden = true
}
return
}
let attachmentId = attachment.id
failedAttachmentIds.remove(attachmentId)
downloadingAttachmentIds.insert(attachmentId)
if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileActivityIndicators.count {
photoTileActivityIndicators[tileIndex].startAnimating()
photoTileActivityIndicators[tileIndex].isHidden = false
photoTileErrorViews[tileIndex].isHidden = true
photoTileDownloadArrows[tileIndex].isHidden = true
}
photoDownloadTasks[attachmentId] = 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 else { return }
self.photoDownloadTasks.removeValue(forKey: attachmentId)
self.downloadingAttachmentIds.remove(attachmentId)
guard let tileIndex = self.tileIndex(for: attachmentId),
tileIndex < self.photoTileImageViews.count else {
return
}
if let image {
self.failedAttachmentIds.remove(attachmentId)
AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
self.setPhotoTileImage(image, at: tileIndex, animated: true)
self.photoTilePlaceholderViews[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = true
} else {
self.failedAttachmentIds.insert(attachmentId)
self.photoTileErrorViews[tileIndex].isHidden = false
}
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
self.photoTileDownloadArrows[tileIndex].isHidden = true
}
} catch {
await MainActor.run {
guard let self else { return }
self.photoDownloadTasks.removeValue(forKey: attachmentId)
self.downloadingAttachmentIds.remove(attachmentId)
self.failedAttachmentIds.insert(attachmentId)
guard let tileIndex = self.tileIndex(for: attachmentId),
tileIndex < self.photoTileActivityIndicators.count else {
return
}
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = false
self.photoTileDownloadArrows[tileIndex].isHidden = true
}
}
}
}
private func resetPhotoTiles() {
photoAttachments.removeAll()
totalPhotoAttachmentCount = 0
for task in photoLoadTasks.values {
task.cancel()
}
photoLoadTasks.removeAll()
for task in photoDownloadTasks.values {
task.cancel()
}
photoDownloadTasks.removeAll()
for task in photoBlurHashTasks.values {
task.cancel()
}
photoBlurHashTasks.removeAll()
downloadingAttachmentIds.removeAll()
failedAttachmentIds.removeAll()
photoContainer.layer.mask = nil
updatePhotoUploadingOverlay(isVisible: false)
photoOverflowOverlayView.isHidden = true
photoOverflowLabel.isHidden = true
photoOverflowLabel.text = nil
for index in 0..<photoTileImageViews.count {
photoTileImageViews[index].image = nil
photoTileImageViews[index].isHidden = true
photoTileImageViews[index].layer.mask = nil
photoTilePlaceholderViews[index].isHidden = true
photoTilePlaceholderViews[index].layer.mask = nil
photoTileActivityIndicators[index].stopAnimating()
photoTileActivityIndicators[index].isHidden = true
photoTileErrorViews[index].isHidden = true
photoTileDownloadArrows[index].isHidden = true
photoTileButtons[index].isHidden = true
photoTileButtons[index].layer.mask = nil
}
photoOverflowOverlayView.layer.mask = nil
}
private static func extractTag(from preview: String) -> String {
AttachmentPreviewCodec.downloadTag(from: preview)
}
private static func extractBlurHash(from preview: String) -> String {
AttachmentPreviewCodec.blurHash(from: preview)
}
private static func cachedBlurHashImage(from preview: String) -> UIImage? {
let hash = extractBlurHash(from: preview)
guard !hash.isEmpty else { return nil }
return blurHashCache.object(forKey: hash as NSString)
}
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 {
checkSentView.alpha = 1
checkSentView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
UIView.animate(
withDuration: 0.1,
delay: 0,
options: [.curveEaseOut, .beginFromCurrentState]
) {
self.checkSentView.transform = .identity
}
} else if !isSentVisible {
checkSentView.alpha = 1
checkSentView.transform = .identity
}
if isReadVisible && !wasReadCheckVisible {
checkReadView.alpha = 1
checkReadView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
UIView.animate(
withDuration: 0.1,
delay: 0,
options: [.curveEaseOut, .beginFromCurrentState]
) {
self.checkReadView.transform = .identity
}
} else if !isReadVisible {
checkReadView.alpha = 1
checkReadView.transform = .identity
}
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
)
}
private func bringStatusOverlayToFront() {
bubbleView.bringSubviewToFront(statusBackgroundView)
bubbleView.bringSubviewToFront(timestampLabel)
bubbleView.bringSubviewToFront(checkSentView)
bubbleView.bringSubviewToFront(checkReadView)
bubbleView.bringSubviewToFront(clockFrameView)
bubbleView.bringSubviewToFront(clockMinView)
}
#if DEBUG
private func assertStatusLaneFramesValid(layout: MessageCellLayout) {
let bubbleBounds = CGRect(origin: .zero, size: layout.bubbleSize)
let frames = [
("timestamp", layout.timestampFrame),
("checkSent", layout.checkSentFrame),
("checkRead", layout.checkReadFrame),
("clock", layout.clockFrame)
]
for (name, frame) in frames {
assert(frame.origin.x.isFinite && frame.origin.y.isFinite
&& frame.size.width.isFinite && frame.size.height.isFinite,
"Status frame \(name) has non-finite values: \(frame)")
assert(frame.width >= 0 && frame.height >= 0,
"Status frame \(name) has negative size: \(frame)")
guard !frame.isEmpty else { continue }
let insetBounds = bubbleBounds.insetBy(dx: -1.0, dy: -1.0)
assert(insetBounds.contains(frame),
"Status frame \(name) is outside bubble bounds. frame=\(frame), bubble=\(bubbleBounds)")
}
}
#endif
// MARK: - Reuse
override func prepareForReuse() {
super.prepareForReuse()
message = nil
actions = nil
currentLayout = nil
stopSendingClockAnimation()
textLabel.textLayout = nil
timestampLabel.text = nil
checkSentView.image = nil
checkSentView.isHidden = true
checkSentView.alpha = 1
checkSentView.transform = .identity
checkReadView.image = nil
checkReadView.isHidden = true
checkReadView.alpha = 1
checkReadView.transform = .identity
clockFrameView.image = nil
clockFrameView.isHidden = true
clockMinView.image = nil
clockMinView.isHidden = true
wasSentCheckVisible = false
wasReadCheckVisible = false
statusBackgroundView.isHidden = true
resetPhotoTiles()
replyContainer.isHidden = true
fileContainer.isHidden = true
callArrowView.isHidden = true
callBackButton.isHidden = true
avatarImageView.image = nil
avatarImageView.isHidden = true
fileIconView.isHidden = false
forwardLabel.isHidden = true
forwardAvatarView.isHidden = true
forwardNameLabel.isHidden = true
photoContainer.isHidden = true
bubbleView.transform = .identity
replyCircleView.alpha = 0
replyCircleView.transform = .identity
replyIconView.alpha = 0
replyIconView.transform = .identity
hasTriggeredSwipeHaptic = false
swipeStartX = nil
deliveryFailedButton.isHidden = true
deliveryFailedButton.alpha = 0
isDeliveryFailedVisible = false
}
}
// MARK: - UIGestureRecognizerDelegate
extension NativeMessageCell: UIGestureRecognizerDelegate {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: contentView)
// Telegram: only left swipe (negative velocity.x), clear horizontal dominance
return velocity.x < 0 && abs(velocity.x) > abs(velocity.y) * 2.0
}
}
// MARK: - Bubble Path Cache
/// Caches CGPath objects for bubble shapes to avoid recalculating Bezier paths every frame.
/// Telegram equivalent: PrincipalThemeEssentialGraphics caches bubble images.
final class BubblePathCache {
static let shared = BubblePathCache()
private let pathVersion = 9
private var cache: [String: CGPath] = [:]
func path(
size: CGSize, origin: CGPoint,
mergeType: BubbleMergeType,
isOutgoing: Bool,
metrics: BubbleMetrics
) -> CGPath {
let key = [
"v\(pathVersion)",
"\(Int(size.width))x\(Int(size.height))",
"ox\(Int(origin.x))",
"oy\(Int(origin.y))",
"\(mergeType)",
"\(isOutgoing)",
"r\(Int(metrics.mainRadius))",
"m\(Int(metrics.auxiliaryRadius))",
"t\(Int(metrics.tailProtrusion))",
].joined(separator: "_")
if let cached = cache[key] { return cached }
let rect = CGRect(origin: origin, size: size)
let path = BubbleGeometryEngine.makeCGPath(
in: rect,
mergeType: mergeType,
outgoing: isOutgoing,
metrics: metrics
)
cache[key] = path
// Evict if cache grows too large
if cache.count > 200 {
cache.removeAll()
}
return path
}
}