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

3151 lines
144 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
import SwiftUI
/// 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 {
// MARK: - Constants
private static let outgoingColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) // #3390EC
private static let incomingColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E
: UIColor(red: 0.95, green: 0.95, blue: 0.97, alpha: 1) // #F2F2F7
}
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: 14, weight: .regular)
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
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(.alwaysTemplate)
}()
/// Gold admin badge (Desktop parity: IconArrowBadgeDownFilled, gold #FFD700).
private static let goldAdminBadgeImage: UIImage = {
let viewBox = CGSize(width: 24, height: 24)
let canvasSize = CGSize(width: 16, height: 16)
let scale = UIScreen.main.scale
UIGraphicsBeginImageContextWithOptions(canvasSize, false, scale)
guard let ctx = UIGraphicsGetCurrentContext() else { return UIImage() }
var parser = SVGPathParser(pathData: TablerIconPath.arrowBadgeDownFilled)
let cgPath = parser.parse()
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(red: 1.0, green: 0.843, blue: 0.0, alpha: 1.0).cgColor)
ctx.fillPath()
let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
UIGraphicsEndImageContext()
return image
}()
// Telegram-exact stretchable bubble images (raster, not vector only way to get exact tail).
// `var` so they can be regenerated on theme switch (colors baked into raster at generation time).
private static var bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
incomingColor: incomingColor
)
private static var bubbleImagesStyle: UIUserInterfaceStyle = .unspecified
/// Regenerate cached bubble images after theme change.
/// Must be called on main thread. `performAsCurrent` ensures dynamic
/// `incomingColor` resolves with the correct light/dark traits.
static func regenerateBubbleImages(with traitCollection: UITraitCollection) {
traitCollection.performAsCurrent {
bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
incomingColor: incomingColor
)
}
bubbleImagesStyle = normalizedInterfaceStyle(from: traitCollection)
}
/// Ensure bubble image cache matches the current interface style.
/// Covers cases where the theme was changed while chat screen was not mounted.
static func ensureBubbleImages(for traitCollection: UITraitCollection) {
let style = normalizedInterfaceStyle(from: traitCollection)
guard bubbleImagesStyle != style else { return }
regenerateBubbleImages(with: traitCollection)
}
private static func normalizedInterfaceStyle(from traitCollection: UITraitCollection) -> UIUserInterfaceStyle {
switch traitCollection.userInterfaceStyle {
case .dark, .light:
return traitCollection.userInterfaceStyle
default:
let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "dark"
return themeMode == "light" ? .light : .dark
}
}
private static let blurHashCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 200
return cache
}()
// MARK: - Subviews (always present, hidden when unused)
// Date separator header (Telegram-style centered pill)
private let dateHeaderContainer = UIView()
private let dateHeaderLabel = UILabel()
// 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()
private var replyMessageId: String?
// 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)
// Voice message
private let voiceView = MessageVoiceView()
private var voiceBlobView: VoiceBlobView?
// Avatar-specific
private let avatarImageView = UIImageView()
// Forward header
private let forwardLabel = UILabel()
private let forwardAvatarView = UIView()
private let forwardAvatarInitialLabel = UILabel()
private let forwardAvatarImageView = UIImageView()
private let forwardNameLabel = UILabel()
// Group sender info (Telegram parity)
private let senderNameLabel = UILabel()
private let senderAdminIconView = UIImageView()
private let senderAvatarContainer = UIView()
private let senderAvatarImageView = UIImageView()
private let senderAvatarInitialLabel = UILabel()
// Group Invite Card
private let groupInviteContainer = UIView()
private let groupInviteIconBg = UIView()
private let groupInviteIcon = UIImageView()
private let groupInviteTitleLabel = UILabel()
private let groupInviteStatusLabel = UILabel()
private let groupInviteButton = UIButton(type: .custom)
private var groupInviteString: String?
private var currentInviteStatus: InviteCardStatus = .notJoined
private var inviteStatusTask: Task<Void, Never>?
enum InviteCardStatus {
case notJoined, joined, invalid, banned
}
// Highlight overlay (scroll-to-message flash)
private let highlightOverlay = UIView()
// Multi-select (Telegram parity: 28×28 checkbox, 42pt content shift)
private let selectionCheckContainer = UIView()
private let selectionCheckBorder = CAShapeLayer()
private let selectionCheckFill = CAShapeLayer()
private let selectionCheckmarkLayer = CAShapeLayer()
// 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(set) var currentLayout: MessageCellLayout?
/// Exposed for voice playback live updates (NativeMessageList matches against VoiceMessagePlayer.currentMessageId).
var currentMessageId: String? { message?.id }
var isSavedMessages = false
var isSystemAccount = false
/// When true, the inline date header pill is hidden (floating sticky one covers it).
var isInlineDateHeaderHidden = false
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> = []
// Multi-select state
private(set) var isInSelectionMode = false
private(set) var isMessageSelected = false
private var selectionOffset: CGFloat = 0 // 0 or 42 (Telegram: 42pt shift)
// 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
// Date header pill (Telegram-style centered date separator with glass)
dateHeaderContainer.clipsToBounds = false
dateHeaderContainer.isHidden = true
let inlineGlass = TelegramGlassUIView(frame: .zero)
inlineGlass.isUserInteractionEnabled = false
inlineGlass.autoresizingMask = [.flexibleWidth, .flexibleHeight]
dateHeaderContainer.addSubview(inlineGlass)
dateHeaderLabel.font = UIFont.systemFont(ofSize: 13, weight: .medium)
dateHeaderLabel.textColor = .label
dateHeaderLabel.textAlignment = .center
dateHeaderContainer.addSubview(dateHeaderLabel)
contentView.addSubview(dateHeaderContainer)
// 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
bubbleLayer.shadowRadius = 0
bubbleLayer.shadowOffset = .zero
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 Telegram parity: accent-tinted bg + 4pt radius bar
replyContainer.layer.cornerRadius = 4.0
replyContainer.clipsToBounds = true
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)
let replyTap = UITapGestureRecognizer(target: self, action: #selector(replyQuoteTapped))
replyContainer.addGestureRecognizer(replyTap)
replyContainer.isUserInteractionEnabled = true
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 = .label
fileContainer.addSubview(fileNameLabel)
fileSizeLabel.font = Self.fileSizeFont
fileSizeLabel.textColor = .secondaryLabel
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)
voiceView.isHidden = true
fileContainer.addSubview(voiceView)
bubbleView.addSubview(fileContainer)
// Group Invite Card
groupInviteIconBg.layer.cornerRadius = 22
groupInviteIconBg.clipsToBounds = true
groupInviteIcon.image = UIImage(
systemName: "person.2.fill",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)
)
groupInviteIcon.tintColor = .white
groupInviteIcon.contentMode = .scaleAspectFit
groupInviteIconBg.addSubview(groupInviteIcon)
groupInviteContainer.addSubview(groupInviteIconBg)
groupInviteTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
groupInviteTitleLabel.lineBreakMode = .byTruncatingTail
groupInviteContainer.addSubview(groupInviteTitleLabel)
groupInviteStatusLabel.font = .systemFont(ofSize: 12, weight: .regular)
groupInviteContainer.addSubview(groupInviteStatusLabel)
groupInviteButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
groupInviteButton.layer.cornerRadius = 14
groupInviteButton.clipsToBounds = true
groupInviteButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 14, bottom: 5, right: 14)
groupInviteButton.addTarget(self, action: #selector(groupInviteCardTapped), for: .touchUpInside)
groupInviteContainer.addSubview(groupInviteButton)
groupInviteContainer.isHidden = true
bubbleView.addSubview(groupInviteContainer)
// 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 from"
bubbleView.addSubview(forwardLabel)
forwardAvatarView.layer.cornerRadius = 8
forwardAvatarView.clipsToBounds = true
bubbleView.addSubview(forwardAvatarView)
forwardAvatarInitialLabel.font = .systemFont(ofSize: 8, weight: .medium)
forwardAvatarInitialLabel.textColor = .white
forwardAvatarInitialLabel.textAlignment = .center
forwardAvatarView.addSubview(forwardAvatarInitialLabel)
forwardAvatarImageView.contentMode = .scaleAspectFill
forwardAvatarImageView.clipsToBounds = true
forwardAvatarView.addSubview(forwardAvatarImageView)
forwardNameLabel.font = Self.forwardNameFont
forwardNameLabel.textColor = .white // default, overridden in configure()
bubbleView.addSubview(forwardNameLabel)
// Group sender info (Telegram parity: name INSIDE bubble as first line, avatar left)
senderNameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
senderNameLabel.isHidden = true
bubbleView.addSubview(senderNameLabel) // INSIDE bubble (Telegram: name is first line in bubble)
senderAdminIconView.contentMode = .scaleAspectFit
senderAdminIconView.isHidden = true
bubbleView.addSubview(senderAdminIconView)
senderAvatarContainer.layer.cornerRadius = 18 // 36pt circle
senderAvatarContainer.clipsToBounds = true
senderAvatarContainer.isHidden = true
senderAvatarContainer.isUserInteractionEnabled = true
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(handleAvatarTap))
senderAvatarContainer.addGestureRecognizer(avatarTap)
contentView.addSubview(senderAvatarContainer)
// Match AvatarView: size * 0.38, bold, rounded design
let avatarFontSize: CGFloat = 36 * 0.38
let descriptor = UIFont.systemFont(ofSize: avatarFontSize, weight: .bold)
.fontDescriptor.withDesign(.rounded) ?? UIFont.systemFont(ofSize: avatarFontSize, weight: .bold).fontDescriptor
senderAvatarInitialLabel.font = UIFont(descriptor: descriptor, size: avatarFontSize)
senderAvatarInitialLabel.textColor = .white
senderAvatarInitialLabel.textAlignment = .center
senderAvatarContainer.addSubview(senderAvatarInitialLabel)
senderAvatarImageView.contentMode = .scaleAspectFill
senderAvatarImageView.clipsToBounds = true
senderAvatarContainer.addSubview(senderAvatarImageView)
// Highlight overlay on top of all bubble content
highlightOverlay.backgroundColor = UIColor.white.withAlphaComponent(0.12)
highlightOverlay.isUserInteractionEnabled = false
highlightOverlay.alpha = 0
highlightOverlay.layer.cornerCurve = .continuous
bubbleView.addSubview(highlightOverlay)
// Swipe reply icon circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier)
replyCircleView.backgroundColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor.white.withAlphaComponent(0.12)
: UIColor.black.withAlphaComponent(0.12)
}
replyCircleView.layer.cornerRadius = 17 // 34pt / 2
replyCircleView.alpha = 0
contentView.addSubview(replyCircleView)
replyIconView.image = Self.telegramReplyArrowImage
replyIconView.contentMode = .scaleAspectFit
replyIconView.tintColor = UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
}
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)
// Multi-select checkbox (Telegram: 28×28pt circle, position at x:6)
selectionCheckContainer.frame = CGRect(x: 0, y: 0, width: 28, height: 28)
selectionCheckContainer.isHidden = true
selectionCheckContainer.isUserInteractionEnabled = false
// Telegram CheckNode: overlay style white border, shadow, CG checkmark
let inset: CGFloat = 2.0 - (1.0 / UIScreen.main.scale) // Telegram: 2.0 - UIScreenPixel
let borderWidth: CGFloat = 1.0 + (1.0 / UIScreen.main.scale) // Telegram: 1.0 + UIScreenPixel
let borderRect = CGRect(x: 0, y: 0, width: 28, height: 28).insetBy(dx: inset, dy: inset)
let checkCirclePath = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 28, height: 28))
selectionCheckBorder.path = UIBezierPath(ovalIn: borderRect).cgPath
selectionCheckBorder.fillColor = UIColor.clear.cgColor
selectionCheckBorder.strokeColor = UIColor.white.cgColor // Telegram: pure white for overlay
selectionCheckBorder.lineWidth = borderWidth
selectionCheckContainer.layer.addSublayer(selectionCheckBorder)
// Telegram CheckNode overlay shadow
selectionCheckContainer.layer.shadowColor = UIColor.black.cgColor
selectionCheckContainer.layer.shadowOpacity = 0.22
selectionCheckContainer.layer.shadowRadius = 2.5
selectionCheckContainer.layer.shadowOffset = .zero
selectionCheckFill.path = checkCirclePath.cgPath
selectionCheckFill.fillColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1).cgColor // #248AE6 Rosetta primary blue
selectionCheckFill.isHidden = true
selectionCheckContainer.layer.addSublayer(selectionCheckFill)
// Telegram CheckNode: CG-drawn checkmark (1.5pt, round cap/join)
let s = (28.0 - inset * 2) / 18.0 // Telegram scale factor
let cx = 14.0, cy = 14.0
let startX = cx - (4.0 - 0.3333) * s
let startY = cy + 0.5 * s
let checkmarkPath = UIBezierPath()
checkmarkPath.move(to: CGPoint(x: startX, y: startY))
checkmarkPath.addLine(to: CGPoint(x: startX + 2.5 * s, y: startY + 3.0 * s))
checkmarkPath.addLine(to: CGPoint(x: startX + 2.5 * s + 4.6667 * s, y: startY + 3.0 * s - 6.0 * s))
selectionCheckmarkLayer.path = checkmarkPath.cgPath
selectionCheckmarkLayer.strokeColor = UIColor.white.cgColor
selectionCheckmarkLayer.fillColor = UIColor.clear.cgColor
selectionCheckmarkLayer.lineWidth = 1.5
selectionCheckmarkLayer.lineCap = .round
selectionCheckmarkLayer.lineJoin = .round
selectionCheckmarkLayer.frame = CGRect(x: 0, y: 0, width: 28, height: 28)
selectionCheckmarkLayer.isHidden = true
selectionCheckContainer.layer.addSublayer(selectionCheckmarkLayer)
contentView.addSubview(selectionCheckContainer)
// Long-press Telegram context menu
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
longPress.minimumPressDuration = 0.35
bubbleView.addGestureRecognizer(longPress)
// Single tap open link if tapped on a URL
let linkTap = UITapGestureRecognizer(target: self, action: #selector(handleLinkTap(_:)))
bubbleView.addGestureRecognizer(linkTap)
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,
replyMessageId: String? = nil,
forwardSenderName: String? = nil,
forwardSenderKey: String? = nil
) {
self.message = message
self.actions = actions
self.replyMessageId = replyMessageId
let isOutgoing = currentLayout?.isOutgoing ?? false
let isMediaStatus: Bool = {
guard let type = currentLayout?.messageType else { return false }
return type == .photo || type == .emojiOnly
}()
// Text use cached CoreTextTextLayout from measurement phase.
// Same CTTypesetter pipeline identical line breaks, zero recomputation.
textLabel.textLayout = textLayout
// Timestamp dynamic UIColor for incoming so theme changes resolve instantly
timestampLabel.text = timestamp
if isMediaStatus {
timestampLabel.textColor = .white
} else if isOutgoing {
timestampLabel.textColor = UIColor.white.withAlphaComponent(0.55)
} else {
timestampLabel.textColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor.white.withAlphaComponent(0.6)
: UIColor.black.withAlphaComponent(0.45)
}
}
// 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 Telegram parity colors
if let replyName {
replyContainer.isHidden = false
replyContainer.backgroundColor = isOutgoing
? UIColor.white.withAlphaComponent(0.12)
: Self.outgoingColor.withAlphaComponent(0.12)
replyBar.backgroundColor = isOutgoing ? .white : Self.outgoingColor
replyNameLabel.text = replyName
replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
replyTextLabel.text = replyText ?? ""
replyTextLabel.textColor = isOutgoing
? .white
: UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .darkGray
}
} else {
replyContainer.isHidden = true
}
// Forward
if let forwardSenderName {
forwardLabel.isHidden = false
forwardAvatarView.isHidden = false
forwardNameLabel.isHidden = false
forwardNameLabel.text = forwardSenderName
// Telegram: same accentTextColor for both title and name
let accent: UIColor = isOutgoing ? .white : Self.outgoingColor
forwardLabel.textColor = accent
forwardNameLabel.textColor = accent
// Avatar: real photo if available, otherwise initial + color
if let key = forwardSenderKey, let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: key) {
forwardAvatarImageView.image = avatarImage
forwardAvatarImageView.isHidden = false
forwardAvatarInitialLabel.isHidden = true
forwardAvatarView.backgroundColor = .clear
} else {
forwardAvatarImageView.image = nil
forwardAvatarImageView.isHidden = true
forwardAvatarInitialLabel.isHidden = false
let initial = String(forwardSenderName.prefix(1)).uppercased()
forwardAvatarInitialLabel.text = initial
let colorIndex = RosettaColors.avatarColorIndex(for: forwardSenderName, publicKey: forwardSenderKey ?? "")
let hexes: [UInt32] = [0x228be6, 0x15aabf, 0xbe4bdb, 0x40c057, 0x4c6ef5, 0x82c91e, 0xfd7e14, 0xe64980, 0xfa5252, 0x12b886, 0x7950f2]
let hex = hexes[colorIndex % hexes.count]
forwardAvatarView.backgroundColor = UIColor(
red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255, alpha: 1
)
}
} 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)
fileNameLabel.textColor = isOutgoing ? .white : .label
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 = isOutgoing ? UIColor.white.withAlphaComponent(0.6) : .secondaryLabel
}
// 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 voiceAtt = message.attachments.first(where: { $0.type == .voice }) {
// Voice message: play button + waveform + duration
// Preview format: "tag::duration::waveform_base64" or "duration::waveform_base64"
let previewParts = Self.parseVoicePreview(voiceAtt.preview)
voiceView.isHidden = false
voiceView.frame = CGRect(x: 0, y: 0, width: fileContainer.bounds.width, height: 38)
voiceView.configure(
messageId: message.id,
attachmentId: voiceAtt.id,
preview: previewParts.waveform,
duration: previewParts.duration,
isOutgoing: layout.isOutgoing
)
let isCurrentVoice = VoiceMessagePlayer.shared.currentMessageId == message.id
voiceView.updatePlaybackState(
isPlaying: isCurrentVoice && VoiceMessagePlayer.shared.isPlaying,
progress: isCurrentVoice ? CGFloat(VoiceMessagePlayer.shared.progress) : 0
)
let voiceAttachment = voiceAtt
let storedPassword = message.attachmentPassword
let playbackDuration = previewParts.duration
let playbackMessageId = message.id
voiceView.onPlayTapped = { [weak self] in
guard let self else { return }
Task.detached(priority: .userInitiated) {
guard let playableURL = await Self.resolvePlayableVoiceURL(
attachment: voiceAttachment,
duration: playbackDuration,
storedPassword: storedPassword
) else {
return
}
await MainActor.run {
guard self.message?.id == playbackMessageId else { return }
VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: playableURL)
}
}
}
fileIconView.isHidden = true
fileNameLabel.isHidden = true
fileSizeLabel.isHidden = true
callArrowView.isHidden = true
callBackButton.isHidden = true
avatarImageView.isHidden = true
} else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
let isFileOutgoing = layout.isOutgoing
avatarImageView.isHidden = true
fileIconView.isHidden = false
// Telegram parity: solid accent circle (incoming) or semi-transparent (outgoing)
fileIconView.backgroundColor = isFileOutgoing
? UIColor.white.withAlphaComponent(0.2)
: UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
let isCached = AttachmentCache.shared.fileURL(
forAttachmentId: fileAtt.id, fileName: parsed.fileName
) != nil
let iconName = isCached ? Self.fileIcon(for: parsed.fileName) : "arrow.down"
fileIconSymbolView.image = UIImage(systemName: iconName)
fileIconSymbolView.tintColor = .white
fileNameLabel.font = Self.fileNameFont
fileNameLabel.text = parsed.fileName
// Telegram parity: accent blue filename (incoming) or white (outgoing)
fileNameLabel.textColor = isFileOutgoing ? .white : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize)
fileSizeLabel.textColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
callArrowView.isHidden = true
callBackButton.isHidden = true
} else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) {
let isOutgoing = currentLayout?.isOutgoing ?? false
fileNameLabel.font = Self.fileNameFont
fileNameLabel.text = "Avatar"
fileNameLabel.textColor = isOutgoing ? .white : .label
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 = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
} else {
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 = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
// 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 {
let isOutgoing = currentLayout?.isOutgoing ?? false
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"
fileNameLabel.textColor = isOutgoing ? .white : .label
fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
callArrowView.isHidden = true
callBackButton.isHidden = true
}
} else {
fileContainer.isHidden = true
}
// Group Invite Card
if let layout = currentLayout, layout.hasGroupInvite {
groupInviteContainer.isHidden = false
textLabel.isHidden = true
groupInviteString = message.text
groupInviteTitleLabel.text = layout.groupInviteTitle.isEmpty ? "Group" : layout.groupInviteTitle
// Local membership check (fast, MainActor)
let isJoined = GroupRepository.shared.hasGroup(for: layout.groupInviteGroupId)
applyGroupInviteStatus(isJoined ? .joined : .notJoined, isOutgoing: layout.isOutgoing)
// Async server check for authoritative status
let msgId = message.id, groupId = layout.groupInviteGroupId
inviteStatusTask?.cancel()
inviteStatusTask = Task { @MainActor [weak self] in
guard !Task.isCancelled else { return }
guard let self, self.message?.id == msgId else { return }
if let (_, status) = try? await GroupService.shared.checkInviteStatus(groupId: groupId),
!Task.isCancelled,
self.message?.id == msgId {
let cardStatus: InviteCardStatus = switch status {
case .joined: .joined
case .notJoined: .notJoined
case .invalid: .invalid
case .banned: .banned
}
self.applyGroupInviteStatus(cardStatus, isOutgoing: layout.isOutgoing)
}
}
} else {
groupInviteContainer.isHidden = true
}
}
private func applyGroupInviteStatus(_ status: InviteCardStatus, isOutgoing: Bool) {
currentInviteStatus = status
let color: UIColor
switch status {
case .notJoined: color = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6
case .joined: color = UIColor(red: 0.20, green: 0.78, blue: 0.35, alpha: 1) // #34C759
case .invalid, .banned: color = UIColor(red: 0.98, green: 0.23, blue: 0.19, alpha: 1)
}
groupInviteIconBg.backgroundColor = color
groupInviteTitleLabel.textColor = isOutgoing ? .white : color
let statusText: String
switch status {
case .notJoined: statusText = "Invite to join this group"
case .joined: statusText = "You are a member"
case .invalid: statusText = "This invite is invalid"
case .banned: statusText = "You are banned"
}
groupInviteStatusLabel.text = statusText
groupInviteStatusLabel.textColor = isOutgoing
? UIColor.white.withAlphaComponent(0.7)
: .secondaryLabel
let buttonTitle: String
switch status {
case .notJoined: buttonTitle = "Join Group"
case .joined: buttonTitle = "Open Group"
case .invalid: buttonTitle = "Invalid"
case .banned: buttonTitle = "Banned"
}
groupInviteButton.setTitle(buttonTitle, for: .normal)
groupInviteButton.setTitleColor(.white, for: .normal)
groupInviteButton.backgroundColor = color
groupInviteButton.isEnabled = (status == .notJoined || status == .joined)
groupInviteButton.alpha = groupInviteButton.isEnabled ? 1.0 : 0.5
}
@objc private func groupInviteCardTapped() {
guard let inviteStr = groupInviteString, let actions else { return }
if currentInviteStatus == .joined {
if let parsed = GroupRepository.parseInviteStringPure(inviteStr) {
let dialogKey = "#group:\(parsed.groupId)"
actions.onGroupInviteOpen(dialogKey)
}
} else if currentInviteStatus == .notJoined {
actions.onGroupInviteTap(inviteStr)
}
}
/// 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 }
Self.ensureBubbleImages(for: traitCollection)
let cellW = contentView.bounds.width
let tailProtrusion = Self.bubbleMetrics.tailProtrusion
// Inline date pills are always hidden floating overlay pills in
// NativeMessageListController handle all date display + push mechanics.
// Cell still reserves dateHeaderHeight for visual spacing between sections.
dateHeaderContainer.isHidden = true
// Rule 2: Tail reserve (6pt) + margin (2pt) strict vertical body alignment
// Group incoming: offset right by 40pt for avatar lane (Telegram parity).
let isGroupIncoming = !layout.isOutgoing && layout.senderKey.count > 0
let groupAvatarLane: CGFloat = isGroupIncoming ? 38 : 0
let baseBubbleX: CGFloat
if layout.isOutgoing {
baseBubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset
} else if isGroupIncoming {
// Group: avatar lane replaces tail space. Avatar at 4pt, bubble after lane.
baseBubbleX = 4 + groupAvatarLane
} else {
baseBubbleX = tailProtrusion + 2
}
// Telegram parity: only incoming messages shift right in selection mode
let bubbleX: CGFloat
if layout.isOutgoing {
bubbleX = baseBubbleX
} else {
bubbleX = baseBubbleX + selectionOffset
}
bubbleView.frame = CGRect(
x: bubbleX, y: layout.bubbleFrame.minY,
width: layout.bubbleSize.width, height: layout.bubbleSize.height
)
// Selection checkbox (Telegram: 28×28, x:6, vertically centered with bubble)
if isInSelectionMode {
let checkY = layout.bubbleFrame.minY + (layout.bubbleSize.height - 28) / 2
selectionCheckContainer.frame = CGRect(x: 6, y: checkY, width: 28, height: 28)
}
// 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
)
// Highlight overlay matches bubble bounds with inner corner radius
highlightOverlay.frame = bubbleView.bounds
highlightOverlay.layer.cornerRadius = 16
// 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 {
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
} else if layout.hasTail {
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
} else {
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
}
// Emoji-only: hide bubble visuals (no background, just floating emoji)
let isEmojiOnly = layout.messageType == .emojiOnly
bubbleImageView.isHidden = isEmojiOnly
bubbleLayer.isHidden = isEmojiOnly
bubbleOutlineLayer.isHidden = isEmojiOnly
// 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 if !voiceView.isHidden {
// Voice layout: position in upper area with padding (Telegram: bubble insets top=15, left=9)
let contentH: CGFloat = 38
let voiceLeftPad: CGFloat = 6 // left padding inside fileContainer
let voiceTopPad: CGFloat = 8 // top padding positions content in upper portion
voiceView.frame = CGRect(x: voiceLeftPad, y: voiceTopPad,
width: fileW - voiceLeftPad * 2, height: contentH)
voiceView.layoutIfNeeded() // ensure playButton.frame is set before blob positioning
fileIconView.isHidden = true
fileNameLabel.isHidden = true
fileSizeLabel.isHidden = true
avatarImageView.isHidden = true
// Reposition blob if it exists (keeps in sync on relayout)
if let blob = voiceBlobView {
let playCenter = voiceView.convert(voiceView.playButtonCenter, to: bubbleView)
let blobSize: CGFloat = 56
blob.frame = CGRect(x: playCenter.x - blobSize / 2,
y: playCenter.y - blobSize / 2,
width: blobSize, height: blobSize)
}
} 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
}
}
// Group Invite Card
groupInviteContainer.isHidden = !layout.hasGroupInvite
if layout.hasGroupInvite {
groupInviteContainer.frame = CGRect(x: 0, y: 0, width: layout.bubbleSize.width, height: layout.bubbleSize.height)
let cW = layout.bubbleSize.width
let topY: CGFloat = 10
groupInviteIconBg.frame = CGRect(x: 10, y: topY, width: 44, height: 44)
groupInviteIcon.frame = CGRect(x: 12, y: 12, width: 20, height: 20)
let textX: CGFloat = 64
let textW = cW - textX - 10
groupInviteTitleLabel.frame = CGRect(x: textX, y: topY + 2, width: textW, height: 19)
groupInviteStatusLabel.frame = CGRect(x: textX, y: topY + 22, width: textW, height: 16)
let btnSize = groupInviteButton.sizeThatFits(CGSize(width: 200, height: 28))
let btnW = min(btnSize.width + 28, textW)
groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28)
}
// Forward
if layout.isForward {
forwardLabel.frame = layout.forwardHeaderFrame
forwardAvatarView.frame = layout.forwardAvatarFrame
let avatarBounds = forwardAvatarView.bounds
forwardAvatarInitialLabel.frame = avatarBounds
forwardAvatarImageView.frame = avatarBounds
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 position is set AFTER sender name expansion (see below).
// Group sender name INSIDE bubble as first line (Telegram parity).
// When shown, shift all bubble content (text, reply, etc.) down by senderNameShift.
let senderNameShift: CGFloat = layout.showsSenderName ? 20 : 0
if layout.showsSenderName {
senderNameLabel.isHidden = false
senderNameLabel.text = layout.senderName
let nameColorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey)
senderNameLabel.textColor = RosettaColors.avatarTextColor(for: nameColorIdx)
senderNameLabel.sizeToFit()
// Reserve space for admin badge icon if sender is group owner
let adminIconSpace: CGFloat = layout.isGroupAdmin ? 20 : 0
// Position inside bubble: top-left, with standard bubble padding
senderNameLabel.frame = CGRect(
x: 10,
y: 6,
width: min(senderNameLabel.bounds.width, bubbleView.bounds.width - 24 - adminIconSpace),
height: 16
)
// Desktop parity: gold admin badge next to sender name
if layout.isGroupAdmin {
senderAdminIconView.isHidden = false
senderAdminIconView.image = Self.goldAdminBadgeImage
senderAdminIconView.frame = CGRect(
x: senderNameLabel.frame.maxX + 3,
y: 5,
width: 16,
height: 16
)
} else {
senderAdminIconView.isHidden = true
}
// Expand bubble to fit the name
bubbleView.frame.size.height += senderNameShift
// Shift text and other content down to make room for the name
textLabel.frame.origin.y += senderNameShift
timestampLabel.frame.origin.y += senderNameShift
checkSentView.frame.origin.y += senderNameShift
checkReadView.frame.origin.y += senderNameShift
clockFrameView.frame.origin.y += senderNameShift
clockMinView.frame.origin.y += senderNameShift
statusBackgroundView.frame.origin.y += senderNameShift
if layout.hasReplyQuote {
replyContainer.frame.origin.y += senderNameShift
}
if layout.hasPhoto {
photoContainer.frame.origin.y += senderNameShift
}
if layout.hasFile {
fileContainer.frame.origin.y += senderNameShift
}
if layout.hasGroupInvite {
groupInviteContainer.frame.origin.y += senderNameShift
}
if layout.isForward {
forwardLabel.frame.origin.y += senderNameShift
forwardAvatarView.frame.origin.y += senderNameShift
forwardNameLabel.frame.origin.y += senderNameShift
}
// Re-apply bubble image with tail protrusion after height expansion
let expandedImageFrame: CGRect
if layout.isOutgoing {
expandedImageFrame = CGRect(x: 0, y: 0,
width: bubbleView.bounds.width + tailProtrusion,
height: bubbleView.bounds.height)
} else {
expandedImageFrame = CGRect(x: -tailProtrusion, y: 0,
width: bubbleView.bounds.width + tailProtrusion,
height: bubbleView.bounds.height)
}
bubbleImageView.frame = expandedImageFrame.insetBy(dx: -1, dy: -1)
highlightOverlay.frame = bubbleView.bounds
// Update shadow/outline layers for expanded height
bubbleLayer.frame = bubbleView.bounds
bubbleLayer.path = BubblePathCache.shared.path(
size: expandedImageFrame.size, origin: expandedImageFrame.origin,
mergeType: layout.mergeType,
isOutgoing: layout.isOutgoing,
metrics: Self.bubbleMetrics
)
bubbleLayer.shadowPath = bubbleLayer.path
bubbleOutlineLayer.frame = bubbleView.bounds
bubbleOutlineLayer.path = bubbleLayer.path
} else {
senderNameLabel.isHidden = true
senderAdminIconView.isHidden = true
}
// Group sender avatar (left of bubble, last in run, Telegram parity)
// Size: 36pt (Android 42dp, Desktop 40px average ~36pt on iOS)
if layout.showsSenderAvatar {
let avatarSize: CGFloat = 36
senderAvatarContainer.isHidden = false
senderAvatarContainer.frame = CGRect(
x: 4 + selectionOffset,
y: bubbleView.frame.maxY - avatarSize,
width: avatarSize,
height: avatarSize
)
senderAvatarInitialLabel.frame = senderAvatarContainer.bounds
senderAvatarImageView.frame = senderAvatarContainer.bounds
senderAvatarImageView.layer.cornerRadius = avatarSize / 2
let colorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey)
// Mantine "light" variant: base + tint overlay (matches AvatarView SwiftUI rendering).
// Dark: #1A1B1E base + tint at 15%. Light: white base + tint at 10%.
let isDark = traitCollection.userInterfaceStyle == .dark
senderAvatarContainer.backgroundColor = isDark
? UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1)
: .white
let tintAlpha: CGFloat = isDark ? 0.15 : 0.10
let tintColor = RosettaColors.avatarColor(for: colorIdx).withAlphaComponent(tintAlpha)
senderAvatarInitialLabel.backgroundColor = tintColor
senderAvatarInitialLabel.text = RosettaColors.initials(name: layout.senderName, publicKey: layout.senderKey)
// Dark: shade-3 text. Light: shade-6 (tint) text.
senderAvatarInitialLabel.textColor = isDark
? RosettaColors.avatarTextColor(for: colorIdx)
: RosettaColors.avatarColor(for: colorIdx)
if let image = AvatarRepository.shared.loadAvatar(publicKey: layout.senderKey) {
senderAvatarImageView.image = image
senderAvatarImageView.isHidden = false
} else {
senderAvatarImageView.image = nil
senderAvatarImageView.isHidden = true
}
} else {
senderAvatarContainer.isHidden = true
}
// Reply icon (for swipe gesture) positioned AFTER all bubble size adjustments
// (sender name shift, etc.) so it's vertically centered on the final bubble.
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)
}
/// Telegram parity: file-type-specific icon name (same mapping as MessageFileView.swift).
/// Parse voice preview: "tag::duration::waveform" or "duration::waveform"
private static func parseVoicePreview(_ preview: String) -> (duration: TimeInterval, waveform: String) {
let parts = preview.components(separatedBy: "::")
// Format: "tag::duration::waveform" or "duration::waveform"
if parts.count >= 3, let dur = Int(parts[1]) {
return (TimeInterval(dur), parts[2])
} else if parts.count >= 2, let dur = Int(parts[0]) {
return (TimeInterval(dur), parts[1])
} else if let dur = Int(parts[0]) {
return (TimeInterval(dur), "")
}
return (0, preview)
}
private static func resolvePlayableVoiceURL(
attachment: MessageAttachment,
duration: TimeInterval,
storedPassword: String?
) async -> URL? {
let fileName = "voice_\(Int(duration))s.m4a"
if let cached = playableVoiceURLFromCache(attachmentId: attachment.id, fileName: fileName) {
return cached
}
guard let downloaded = await downloadVoiceData(attachment: attachment, storedPassword: storedPassword) else {
return nil
}
_ = AttachmentCache.shared.saveFile(downloaded, forAttachmentId: attachment.id, fileName: fileName)
return writePlayableVoiceTempFile(
data: downloaded,
attachmentId: attachment.id,
fileName: fileName
)
}
private static func playableVoiceURLFromCache(attachmentId: String, fileName: String) -> URL? {
guard let decrypted = AttachmentCache.shared.loadFileData(
forAttachmentId: attachmentId,
fileName: fileName
) else {
return nil
}
return writePlayableVoiceTempFile(data: decrypted, attachmentId: attachmentId, fileName: fileName)
}
private static func writePlayableVoiceTempFile(data: Data, attachmentId: String, fileName: String) -> URL? {
let safeFileName = fileName.replacingOccurrences(of: "/", with: "_")
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("voice_play_\(attachmentId)_\(safeFileName)")
try? FileManager.default.removeItem(at: tempURL)
do {
try data.write(to: tempURL, options: .atomic)
return tempURL
} catch {
return nil
}
}
private static func downloadVoiceData(attachment: MessageAttachment, storedPassword: String?) async -> Data? {
let tag = attachment.effectiveDownloadTag
guard !tag.isEmpty else { return nil }
guard let storedPassword, !storedPassword.isEmpty else { return nil }
do {
let encryptedData = try await TransportManager.shared.downloadFile(
tag: tag,
server: attachment.transportServer
)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
guard let decrypted = decryptAttachmentData(encryptedString: encryptedString, passwords: passwords) else {
return nil
}
return parseAttachmentFileData(decrypted)
} catch {
return nil
}
}
private static func decryptAttachmentData(encryptedString: String, passwords: [String]) -> Data? {
let crypto = CryptoManager.shared
for password in passwords {
if let data = try? crypto.decryptWithPassword(
encryptedString,
password: password,
requireCompression: true
) {
return data
}
}
for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password) {
return data
}
}
return nil
}
private static func parseAttachmentFileData(_ data: Data) -> Data {
if let string = String(data: data, encoding: .utf8),
string.hasPrefix("data:"),
let comma = string.firstIndex(of: ",") {
let payload = String(string[string.index(after: comma)...])
return Data(base64Encoded: payload) ?? data
}
return data
}
private static func fileIcon(for fileName: String) -> String {
let ext = (fileName as NSString).pathExtension.lowercased()
switch ext {
case "pdf": return "doc.fill"
case "zip", "rar", "7z": return "doc.zipper"
case "jpg", "jpeg", "png", "gif": return "photo.fill"
case "mp4", "mov", "avi": return "film.fill"
case "mp3", "wav", "aac": return "waveform"
default: return "doc.fill"
}
}
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,
server: String = ""
) async -> UIImage? {
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
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
#if DEBUG
print("[AVATAR-DBG] decryptAvatarImage blob=\(encryptedString.count) chars, \(passwords.count) candidates")
#endif
for password in passwords {
guard let data = try? crypto.decryptWithPassword(
encryptedString, password: password, requireCompression: true
) else {
#if DEBUG
print("[AVATAR-DBG] pwd=\(password.prefix(8))… requireCompression=true → nil")
#endif
continue
}
#if DEBUG
let hex = data.prefix(30).map { String(format: "%02x", $0) }.joined()
let utf8 = String(data: data.prefix(60), encoding: .utf8) ?? "<not utf8>"
print("[AVATAR-DBG] pwd=\(password.prefix(8))… requireCompression=true → \(data.count) bytes, hex=\(hex), utf8=\(utf8.prefix(60))")
#endif
if let img = parseAvatarImageData(data) { return img }
#if DEBUG
print("[AVATAR-DBG] parseAvatarImageData returned nil for requireCompression=true data")
#endif
}
for password in passwords {
guard let data = try? crypto.decryptWithPassword(
encryptedString, password: password
) else {
#if DEBUG
print("[AVATAR-DBG] pwd=\(password.prefix(8))… noCompression → nil")
#endif
continue
}
#if DEBUG
let hex = data.prefix(30).map { String(format: "%02x", $0) }.joined()
print("[AVATAR-DBG] pwd=\(password.prefix(8))… noCompression → \(data.count) bytes, hex=\(hex)")
#endif
if let img = parseAvatarImageData(data) { return img }
#if DEBUG
print("[AVATAR-DBG] parseAvatarImageData returned nil for noCompression data")
#endif
}
#if DEBUG
print("[AVATAR-DBG] ❌ All candidates failed")
#endif
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: - Link Tap
@objc private func handleLinkTap(_ gesture: UITapGestureRecognizer) {
// In selection mode: any tap toggles selection
if isInSelectionMode {
guard let msgId = message?.id else { return }
actions?.onToggleSelection(msgId)
return
}
let pointInText = gesture.location(in: textLabel)
// Check links first
if let url = textLabel.textLayout?.linkAt(point: pointInText) {
var finalURL = url
if finalURL.scheme == nil || finalURL.scheme?.isEmpty == true {
finalURL = URL(string: "https://\(url.absoluteString)") ?? url
}
UIApplication.shared.open(finalURL)
return
}
// Then check @mentions
if let username = textLabel.textLayout?.mentionAt(point: pointInText) {
actions?.onMentionTap(username)
return
}
}
@objc private func handleAvatarTap() {
guard let key = message?.fromPublicKey, !key.isEmpty else { return }
actions?.onAvatarTap(key)
}
// MARK: - Context Menu (Telegram-style)
private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium)
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began else { return }
// In selection mode: tap toggles selection instead of context menu
if isInSelectionMode {
guard let msgId = message?.id else { return }
contextMenuHaptic.impactOccurred()
actions?.onToggleSelection(msgId)
return
}
contextMenuHaptic.impactOccurred()
presentContextMenu()
}
private func presentContextMenu() {
guard let message, let actions else { return }
guard let layout = currentLayout else { return }
// Capture snapshot from window (pixel-perfect, accounts for inverted scroll)
guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: bubbleView) else { return }
// Build bubble mask path
let bubblePath = BubbleGeometryEngine.makeBezierPath(
in: CGRect(origin: .zero, size: frame.size),
mergeType: layout.mergeType,
outgoing: layout.isOutgoing
)
// Build menu items
let items = TelegramContextMenuBuilder.menuItems(
for: message,
actions: actions,
isSavedMessages: isSavedMessages,
isSystemAccount: isSystemAccount
)
TelegramContextMenuController.present(
snapshot: snapshot,
sourceFrame: frame,
bubblePath: bubblePath,
items: items,
isOutgoing: layout.isOutgoing
)
}
// MARK: - Swipe to Reply
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
if isInSelectionMode { return } // Disable swipe-to-reply in selection mode
if isSavedMessages || isSystemAccount { return }
let isReplyBlocked = (message?.attachments.contains(where: { $0.type == .avatar }) ?? false)
|| (currentLayout?.messageType == .groupInvite)
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)
// Move sender avatar with bubble during swipe (group chats)
if !senderAvatarContainer.isHidden {
senderAvatarContainer.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.senderAvatarContainer.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 else { return }
// Avatar download
if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }),
avatarAtt.id == id {
if AttachmentCache.shared.cachedImage(forAttachmentId: id) != nil { return }
let tag = avatarAtt.effectiveDownloadTag
guard !tag.isEmpty else { return }
guard let password = message.attachmentPassword, !password.isEmpty else {
#if DEBUG
print("[AVATAR-DBG] ❌ No attachmentPassword for avatar id=\(id)")
#endif
return
}
#if DEBUG
print("[AVATAR-DBG] Starting download tag=\(tag.prefix(12))… pwd=\(password.prefix(8))… len=\(password.count)")
#endif
fileSizeLabel.text = "Downloading..."
let messageId = message.id
// Desktop parity: group avatar saves to group dialog key, not sender key
let avatarTargetKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
? message.toPublicKey : message.fromPublicKey
let server = avatarAtt.transportServer
Task.detached(priority: .userInitiated) {
let downloaded = await Self.downloadAndCacheAvatar(
tag: tag, attachmentId: id,
storedPassword: password, senderKey: avatarTargetKey,
server: server
)
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"
// Trigger refresh of sender avatar circles in visible cells
NotificationCenter.default.post(
name: Notification.Name("avatarDidUpdate"), object: nil
)
} else {
self.fileSizeLabel.text = "Tap to retry"
}
}
}
return
}
// File download (desktop parity: MessageFile.tsx download flow)
if let fileAtt = message.attachments.first(where: { $0.type == .file }),
fileAtt.id == id {
let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
let fileName = parsed.fileName.isEmpty ? "file" : parsed.fileName
// Already cached? share
if let url = AttachmentCache.shared.fileURL(forAttachmentId: id, fileName: fileName) {
shareFile(url)
return
}
let tag = fileAtt.effectiveDownloadTag
guard !tag.isEmpty else { return }
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
fileSizeLabel.text = "File expired"
return
}
fileSizeLabel.text = "Downloading..."
let messageId = message.id
let attId = fileAtt.id
let server = fileAtt.transportServer
Task.detached(priority: .userInitiated) {
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
// Try with compression first, then without (legacy)
var decrypted: Data?
for pw in passwords {
if let d = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: pw, requireCompression: true) {
decrypted = d; break
}
}
if decrypted == nil {
for pw in passwords {
if let d = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: pw) {
decrypted = d; break
}
}
}
guard let decrypted else { throw TransportError.invalidResponse }
// Parse data URI if present
let fileData: Data
if let str = String(data: decrypted, encoding: .utf8),
str.hasPrefix("data:"), let comma = str.firstIndex(of: ",") {
let b64 = String(str[str.index(after: comma)...])
fileData = Data(base64Encoded: b64) ?? decrypted
} else {
fileData = decrypted
}
let url = AttachmentCache.shared.saveFile(fileData, forAttachmentId: attId, fileName: fileName)
await MainActor.run { [weak self] in
guard let self, self.message?.id == messageId else { return }
self.fileSizeLabel.text = Self.formattedFileSize(bytes: fileData.count)
self.fileIconSymbolView.image = UIImage(systemName: "doc.fill")
self.shareFile(url)
}
} catch {
await MainActor.run { [weak self] in
guard let self, self.message?.id == messageId else { return }
self.fileSizeLabel.text = "File expired"
}
}
}
return
}
}
private func shareFile(_ cachedURL: URL) {
guard let message else { return }
// Files are stored encrypted (.enc) on disk. Decrypt to temp dir for sharing.
guard let fileAtt = message.attachments.first(where: { $0.type == .file }) else { return }
let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
let fileName = parsed.fileName.isEmpty ? "file" : parsed.fileName
guard let data = AttachmentCache.shared.loadFileData(forAttachmentId: fileAtt.id, fileName: fileName) else {
fileSizeLabel.text = "File expired"
return
}
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
try? data.write(to: tempURL, options: .atomic)
let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first,
let rootVC = window.rootViewController {
var topVC = rootVC
while let presented = topVC.presentedViewController { topVC = presented }
if let popover = activityVC.popoverPresentationController {
popover.sourceView = topVC.view
popover.sourceRect = CGRect(x: topVC.view.bounds.midX, y: topVC.view.bounds.midY, width: 0, height: 0)
}
topVC.present(activityVC, animated: true)
}
}
// MARK: - Highlight (scroll-to-message flash)
func showHighlight() {
highlightOverlay.alpha = 0
UIView.animate(withDuration: 0.2) {
self.highlightOverlay.alpha = 1
}
}
func hideHighlight() {
UIView.animate(withDuration: 0.4) {
self.highlightOverlay.alpha = 0
}
}
@objc private func replyQuoteTapped() {
guard let replyMessageId, let actions else { return }
actions.onScrollToMessage(replyMessageId)
}
@objc private func fileContainerTapped() {
if isInSelectionMode {
guard let msgId = message?.id else { return }
actions?.onToggleSelection(msgId)
return
}
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) {
// In selection mode: any tap toggles selection
if isInSelectionMode {
guard let msgId = message?.id else { return }
actions?.onToggleSelection(msgId)
return
}
guard sender.tag >= 0, sender.tag < photoAttachments.count,
let message,
let actions else {
return
}
let attachment = photoAttachments[sender.tag]
let imageView = photoTileImageViews[sender.tag]
let sourceFrame = imageView.convert(imageView.bounds, to: nil)
if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil {
actions.onImageTap(attachment.id, sourceFrame, imageView)
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, sourceFrame, imageView)
} 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: .background) {
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 = attachment.effectiveDownloadTag
#if DEBUG
print("[PHOTO-DBG] downloadPhoto tag=\(tag.prefix(12))… pwd=\(message.attachmentPassword?.prefix(8) ?? "nil") len=\(message.attachmentPassword?.count ?? 0)")
#endif
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
}
let server = attachment.transportServer
photoDownloadTasks[attachmentId] = Task { [weak self] in
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
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 && layout.messageType != .emojiOnly
}
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) {
// emojiOnly has no visible bubble status pill floats below emoji
guard layout.messageType != .emojiOnly else { return }
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()
layer.removeAnimation(forKey: "insertionSlide")
layer.removeAnimation(forKey: "insertionMove")
contentView.layer.removeAnimation(forKey: "insertionAlpha")
dateHeaderContainer.isHidden = true
dateHeaderLabel.text = nil
isInlineDateHeaderHidden = false
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
bubbleImageView.isHidden = false
bubbleLayer.isHidden = false
bubbleOutlineLayer.isHidden = false
resetPhotoTiles()
replyContainer.isHidden = true
replyMessageId = nil
highlightOverlay.alpha = 0
fileContainer.isHidden = true
voiceView.isHidden = true
cleanupVoiceBlob()
callArrowView.isHidden = true
callBackButton.isHidden = true
groupInviteContainer.isHidden = true
groupInviteString = nil
currentInviteStatus = .notJoined
inviteStatusTask?.cancel()
inviteStatusTask = nil
avatarImageView.image = nil
avatarImageView.isHidden = true
fileIconView.isHidden = false
fileNameLabel.isHidden = false
fileSizeLabel.isHidden = false
forwardLabel.isHidden = true
forwardAvatarView.isHidden = true
forwardNameLabel.isHidden = true
senderNameLabel.isHidden = true
senderAdminIconView.isHidden = true
senderAvatarContainer.isHidden = true
senderAvatarInitialLabel.backgroundColor = .clear
senderAvatarImageView.image = nil
photoContainer.isHidden = true
bubbleView.transform = .identity
senderAvatarContainer.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
// Selection: reset selected state on reuse, keep mode (same for all cells)
isMessageSelected = false
selectionCheckFill.isHidden = true
selectionCheckmarkLayer.isHidden = true
}
// MARK: - Multi-Select
func setSelectionMode(_ enabled: Bool, animated: Bool) {
guard isInSelectionMode != enabled else { return }
isInSelectionMode = enabled
let newOffset: CGFloat = enabled ? 42 : 0
if animated {
selectionCheckContainer.isHidden = false
let fromAlpha: Float = enabled ? 0 : 1
let toAlpha: Float = enabled ? 1 : 0
let slideFrom = enabled ? -42.0 : 0.0
let slideTo = enabled ? 0.0 : -42.0
// Telegram: 0.2s easeOut for checkbox fade + slide
let alphaAnim = CABasicAnimation(keyPath: "opacity")
alphaAnim.fromValue = fromAlpha
alphaAnim.toValue = toAlpha
alphaAnim.duration = 0.2
alphaAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
alphaAnim.fillMode = .forwards
alphaAnim.isRemovedOnCompletion = false
selectionCheckContainer.layer.add(alphaAnim, forKey: "selectionAlpha")
let posAnim = CABasicAnimation(keyPath: "position.x")
posAnim.fromValue = selectionCheckContainer.layer.position.x + slideFrom
posAnim.toValue = selectionCheckContainer.layer.position.x + slideTo
posAnim.duration = 0.2
posAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
selectionCheckContainer.layer.add(posAnim, forKey: "selectionSlide")
selectionCheckContainer.layer.opacity = toAlpha
// Telegram: 0.2s easeOut for content shift
selectionOffset = newOffset
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
self.setNeedsLayout()
self.layoutIfNeeded()
} completion: { _ in
if !enabled {
self.selectionCheckContainer.isHidden = true
self.selectionCheckContainer.layer.removeAnimation(forKey: "selectionAlpha")
self.selectionCheckContainer.layer.removeAnimation(forKey: "selectionSlide")
self.selectionCheckContainer.layer.opacity = 1
}
}
} else {
selectionOffset = newOffset
selectionCheckContainer.isHidden = !enabled
selectionCheckContainer.layer.opacity = enabled ? 1 : 0
setNeedsLayout()
}
}
func setMessageSelected(_ selected: Bool, animated: Bool) {
guard isMessageSelected != selected else { return }
isMessageSelected = selected
selectionCheckFill.isHidden = !selected
selectionCheckmarkLayer.isHidden = !selected
selectionCheckBorder.isHidden = selected
if animated && selected {
// Telegram CheckNode: 3-stage scale 10.91.11 over 0.21s
let anim = CAKeyframeAnimation(keyPath: "transform.scale")
anim.values = [1.0, 0.9, 1.1, 1.0]
anim.keyTimes = [0, 0.26, 0.62, 1.0]
anim.duration = 0.21
anim.timingFunction = CAMediaTimingFunction(name: .easeOut)
selectionCheckFill.add(anim, forKey: "checkBounce")
selectionCheckmarkLayer.add(anim, forKey: "checkBounce")
} else if animated && !selected {
// Telegram CheckNode: 2-stage scale 10.91 over 0.15s
let anim = CAKeyframeAnimation(keyPath: "transform.scale")
anim.values = [1.0, 0.9, 1.0]
anim.keyTimes = [0, 0.53, 1.0]
anim.duration = 0.15
anim.timingFunction = CAMediaTimingFunction(name: .easeIn)
selectionCheckContainer.layer.add(anim, forKey: "checkBounce")
}
}
/// Called by NativeMessageList on every VoiceMessagePlayer progress tick for the active cell.
func updateVoicePlayback(isPlaying: Bool, progress: CGFloat, currentTime: TimeInterval, duration: TimeInterval) {
guard !voiceView.isHidden else { return }
voiceView.updatePlaybackState(isPlaying: isPlaying, progress: progress)
voiceView.updateDurationDuringPlayback(currentTime: currentTime, totalDuration: duration, isPlaying: isPlaying)
updateVoiceBlobState(isPlaying: isPlaying)
}
// MARK: - Voice Blob (rendered in bubbleView, clipped to bubble bounds)
private func updateVoiceBlobState(isPlaying: Bool) {
if isPlaying {
if voiceBlobView == nil {
let blob = VoiceBlobView(
frame: .zero,
maxLevel: 0.3,
smallBlobRange: (min: 0, max: 0),
mediumBlobRange: (min: 0.7, max: 0.8),
bigBlobRange: (min: 0.8, max: 0.9)
)
let isDark = traitCollection.userInterfaceStyle == .dark
let isOut = currentLayout?.isOutgoing ?? false
let colors = RosettaColors.Voice.colors(isOutgoing: isOut, isDark: isDark)
blob.setColor(colors.playButtonBg)
// Even-odd mask to cut out the inner 44pt circle (ring only)
let blobSize: CGFloat = 56
let maskLayer = CAShapeLayer()
let fullRect = CGRect(origin: .zero, size: CGSize(width: blobSize, height: blobSize))
let path = UIBezierPath(rect: fullRect)
let innerDiameter: CGFloat = 44
let innerOrigin = CGPoint(x: (blobSize - innerDiameter) / 2,
y: (blobSize - innerDiameter) / 2)
path.append(UIBezierPath(ovalIn: CGRect(origin: innerOrigin,
size: CGSize(width: innerDiameter, height: innerDiameter))))
maskLayer.path = path.cgPath
maskLayer.fillRule = .evenOdd
blob.layer.mask = maskLayer
// Insert below fileContainer so it's behind the play button
bubbleView.insertSubview(blob, belowSubview: fileContainer)
voiceBlobView = blob
}
// Force voiceView layout so playButton.frame is up-to-date
voiceView.layoutIfNeeded()
// Position blob centered on play button in bubbleView coords
let playCenter = voiceView.convert(voiceView.playButtonCenter, to: bubbleView)
let blobSize: CGFloat = 56
voiceBlobView?.frame = CGRect(x: playCenter.x - blobSize / 2,
y: playCenter.y - blobSize / 2,
width: blobSize, height: blobSize)
voiceBlobView?.startAnimating()
voiceBlobView?.updateLevel(0.2)
} else {
voiceBlobView?.stopAnimating()
}
}
private func cleanupVoiceBlob() {
voiceBlobView?.stopAnimating()
voiceBlobView?.removeFromSuperview()
voiceBlobView = nil
}
func voiceTransitionTargetFrame(in window: UIWindow) -> CGRect? {
guard !voiceView.isHidden else { return nil }
return voiceView.convert(voiceView.bounds, to: window)
}
func bubbleFrameInWindow(_ window: UIWindow) -> CGRect {
bubbleView.convert(bubbleView.bounds, to: window)
}
}
// MARK: - UIGestureRecognizerDelegate
extension NativeMessageCell: UIGestureRecognizerDelegate {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
if isInSelectionMode { return false } // No swipe in selection mode
// If touch is on the waveform during playback, let the waveform's scrub gesture win
if !voiceView.isHidden, voiceView.isScrubbingEnabled {
let pointInVoice = pan.location(in: voiceView)
if voiceView.waveformFrame.contains(pointInVoice) {
return false
}
}
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
}
}