2856 lines
131 KiB
Swift
2856 lines
131 KiB
Swift
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)
|
||
|
||
// 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 selectionCheckmarkView = UIImageView()
|
||
|
||
// 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?
|
||
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)
|
||
|
||
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
|
||
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
|
||
|
||
let checkPath = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 28, height: 28))
|
||
selectionCheckBorder.path = checkPath.cgPath
|
||
selectionCheckBorder.fillColor = UIColor.clear.cgColor
|
||
selectionCheckBorder.strokeColor = UIColor { traits in
|
||
traits.userInterfaceStyle == .dark
|
||
? UIColor.white.withAlphaComponent(0.5)
|
||
: UIColor.black.withAlphaComponent(0.3)
|
||
}.cgColor
|
||
selectionCheckBorder.lineWidth = 1.5 // Telegram: 1.0 + UIScreenPixel
|
||
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 = checkPath.cgPath
|
||
selectionCheckFill.fillColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1).cgColor // #248AE6
|
||
selectionCheckFill.isHidden = true
|
||
selectionCheckContainer.layer.addSublayer(selectionCheckFill)
|
||
|
||
let checkConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
|
||
selectionCheckmarkView.image = UIImage(systemName: "checkmark", withConfiguration: checkConfig)
|
||
selectionCheckmarkView.tintColor = .white
|
||
selectionCheckmarkView.contentMode = .center
|
||
selectionCheckmarkView.frame = CGRect(x: 0, y: 0, width: 28, height: 28)
|
||
selectionCheckmarkView.isHidden = true
|
||
selectionCheckContainer.addSubview(selectionCheckmarkView)
|
||
|
||
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 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 {
|
||
// 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).
|
||
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)
|
||
guard let url = textLabel.textLayout?.linkAt(point: pointInText) else { return }
|
||
var finalURL = url
|
||
if finalURL.scheme == nil || finalURL.scheme?.isEmpty == true {
|
||
finalURL = URL(string: "https://\(url.absoluteString)") ?? url
|
||
}
|
||
UIApplication.shared.open(finalURL)
|
||
}
|
||
|
||
// 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: .utility) {
|
||
UIImage.fromBlurHash(hash, width: 48, height: 48)
|
||
}.value
|
||
guard !Task.isCancelled else { return }
|
||
await MainActor.run {
|
||
guard let self else { return }
|
||
self.photoBlurHashTasks.removeValue(forKey: attachmentId)
|
||
guard let decoded,
|
||
let tileIndex = self.tileIndex(for: attachmentId),
|
||
tileIndex < self.photoTileImageViews.count else {
|
||
return
|
||
}
|
||
Self.blurHashCache.setObject(decoded, forKey: hash as NSString)
|
||
// Do not override already loaded real image.
|
||
guard self.photoTileImageViews[tileIndex].image == nil else { return }
|
||
self.setPhotoTileImage(decoded, at: tileIndex, animated: false)
|
||
self.photoTilePlaceholderViews[tileIndex].isHidden = true
|
||
}
|
||
}
|
||
}
|
||
|
||
private func startPhotoLoadTask(attachment: MessageAttachment) {
|
||
if photoLoadTasks[attachment.id] != nil { return }
|
||
let attachmentId = attachment.id
|
||
photoLoadTasks[attachmentId] = Task { [weak self] in
|
||
await ImageLoadLimiter.shared.acquire()
|
||
let loaded = await Task.detached(priority: .userInitiated) {
|
||
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||
}.value
|
||
await ImageLoadLimiter.shared.release()
|
||
guard !Task.isCancelled else { return }
|
||
await MainActor.run {
|
||
guard let self else { return }
|
||
self.photoLoadTasks.removeValue(forKey: attachmentId)
|
||
guard let tileIndex = self.tileIndex(for: attachmentId),
|
||
tileIndex < self.photoTileImageViews.count,
|
||
let loaded else {
|
||
return
|
||
}
|
||
self.failedAttachmentIds.remove(attachmentId)
|
||
self.setPhotoTileImage(loaded, at: tileIndex, animated: true)
|
||
self.photoTilePlaceholderViews[tileIndex].isHidden = true
|
||
self.photoTileActivityIndicators[tileIndex].stopAnimating()
|
||
self.photoTileActivityIndicators[tileIndex].isHidden = true
|
||
self.photoTileErrorViews[tileIndex].isHidden = true
|
||
self.photoTileDownloadArrows[tileIndex].isHidden = true
|
||
}
|
||
}
|
||
}
|
||
|
||
private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) {
|
||
if photoDownloadTasks[attachment.id] != nil { return }
|
||
let tag = 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
|
||
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
|
||
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
|
||
selectionCheckmarkView.isHidden = true
|
||
}
|
||
|
||
// MARK: - Multi-Select
|
||
|
||
func setSelectionMode(_ enabled: Bool, animated: Bool) {
|
||
guard isInSelectionMode != enabled else { return }
|
||
isInSelectionMode = enabled
|
||
let newOffset: CGFloat = enabled ? 42 : 0
|
||
let duration: TimeInterval = enabled ? 0.3 : 0.4
|
||
let damping: CGFloat = enabled ? 0.8 : 0.85
|
||
|
||
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
|
||
|
||
// Checkbox fade + slide
|
||
let alphaAnim = CABasicAnimation(keyPath: "opacity")
|
||
alphaAnim.fromValue = fromAlpha
|
||
alphaAnim.toValue = toAlpha
|
||
alphaAnim.duration = duration
|
||
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 = duration
|
||
posAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||
selectionCheckContainer.layer.add(posAnim, forKey: "selectionSlide")
|
||
|
||
selectionCheckContainer.layer.opacity = toAlpha
|
||
|
||
// Content shift (spring animation, Telegram parity)
|
||
selectionOffset = newOffset
|
||
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: []) {
|
||
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
|
||
selectionCheckmarkView.isHidden = !selected
|
||
selectionCheckBorder.isHidden = selected
|
||
|
||
if animated && selected {
|
||
// Telegram CheckNode: 3-stage scale 1→0.9→1.1→1 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")
|
||
selectionCheckmarkView.layer.add(anim, forKey: "checkBounce")
|
||
} else if animated && !selected {
|
||
// Telegram CheckNode: 2-stage scale 1→0.9→1 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")
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
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
|
||
}
|
||
}
|