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 = { let cache = NSCache() cache.countLimit = 200 return cache }() // MARK: - Subviews (always present, hidden when unused) // Date separator header (Telegram-style centered pill) private let dateHeaderContainer = UIView() private let dateHeaderLabel = UILabel() // Bubble — uses Telegram-exact stretchable image for the fill (raster tail) // + CAShapeLayer for shadow path (approximate, vector) private let bubbleView = UIView() private let bubbleImageView = UIImageView() private let bubbleLayer = CAShapeLayer() // shadow only, fillColor = clear private let bubbleOutlineLayer = CAShapeLayer() // Text (CoreText rendering — matches Telegram's CTTypesetter + CTRunDraw pipeline) private let textLabel = CoreTextLabel() // Timestamp + delivery private let statusBackgroundView = UIView() private let timestampLabel = UILabel() private let checkSentView = UIImageView() private let checkReadView = UIImageView() private let clockFrameView = UIImageView() private let clockMinView = UIImageView() // Reply quote private let replyContainer = UIView() private let replyBar = UIView() private let replyNameLabel = UILabel() private let replyTextLabel = UILabel() private var replyMessageId: String? // Photo collage (up to 5 tiles) private let photoContainer = UIView() private var photoTileImageViews: [UIImageView] = [] private var photoTilePlaceholderViews: [UIView] = [] private var photoTileActivityIndicators: [UIActivityIndicatorView] = [] private var photoTileErrorViews: [UIImageView] = [] private var photoTileDownloadArrows: [UIView] = [] private var photoTileButtons: [UIButton] = [] private let photoUploadingOverlayView = UIView() private let photoUploadingIndicator = UIActivityIndicatorView(style: .medium) private let photoOverflowOverlayView = UIView() private let photoOverflowLabel = UILabel() // File / Call / Avatar (shared container) private let fileContainer = UIView() private let fileIconView = UIView() private let fileIconSymbolView = UIImageView() private let fileNameLabel = UILabel() private let fileSizeLabel = UILabel() // Call-specific private let callArrowView = UIImageView() private let callBackButton = UIButton(type: .custom) // Voice message private let voiceView = MessageVoiceView() private var voiceBlobView: VoiceBlobView? // Avatar-specific private let avatarImageView = UIImageView() // Forward header private let forwardLabel = UILabel() private let forwardAvatarView = UIView() private let forwardAvatarInitialLabel = UILabel() private let forwardAvatarImageView = UIImageView() private let forwardNameLabel = UILabel() // Group sender info (Telegram parity) private let senderNameLabel = UILabel() private let senderAdminIconView = UIImageView() private let senderAvatarContainer = UIView() private let senderAvatarImageView = UIImageView() private let senderAvatarInitialLabel = UILabel() // Group Invite Card private let groupInviteContainer = UIView() private let groupInviteIconBg = UIView() private let groupInviteIcon = UIImageView() private let groupInviteTitleLabel = UILabel() private let groupInviteStatusLabel = UILabel() private let groupInviteButton = UIButton(type: .custom) private var groupInviteString: String? private var currentInviteStatus: InviteCardStatus = .notJoined private var inviteStatusTask: Task? enum InviteCardStatus { case notJoined, joined, invalid, banned } // Highlight overlay (scroll-to-message flash) private let highlightOverlay = UIView() // Multi-select (Telegram parity: 28×28 checkbox, 42pt content shift) private let selectionCheckContainer = UIView() private let selectionCheckBorder = CAShapeLayer() private let selectionCheckFill = CAShapeLayer() private let selectionCheckmarkLayer = CAShapeLayer() // Swipe-to-reply private let replyCircleView = UIView() private let replyIconView = UIImageView() private var hasTriggeredSwipeHaptic = false private let swipeHaptic = UIImpactFeedbackGenerator(style: .heavy) /// Global X of the first touch — reject if near left screen edge (back gesture zone). private var swipeStartX: CGFloat? private let deliveryFailedButton = UIButton(type: .custom) // MARK: - State private var message: ChatMessage? private var actions: MessageCellActions? private(set) var currentLayout: MessageCellLayout? /// Exposed for voice playback live updates (NativeMessageList matches against VoiceMessagePlayer.currentMessageId). var currentMessageId: String? { message?.id } var isSavedMessages = false var isSystemAccount = false /// When true, the inline date header pill is hidden (floating sticky one covers it). var isInlineDateHeaderHidden = false private var isDeliveryFailedVisible = false private var wasSentCheckVisible = false private var wasReadCheckVisible = false private var photoAttachments: [MessageAttachment] = [] private var totalPhotoAttachmentCount = 0 private var photoLoadTasks: [String: Task] = [:] private var photoDownloadTasks: [String: Task] = [:] private var photoBlurHashTasks: [String: Task] = [:] private var downloadingAttachmentIds: Set = [] private var failedAttachmentIds: Set = [] // 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..> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1 ) } } else { forwardLabel.isHidden = true forwardAvatarView.isHidden = true forwardNameLabel.isHidden = true } // Photo configurePhoto(for: message) // File if let layout = currentLayout, layout.hasFile { fileContainer.isHidden = false if let callAtt = message.attachments.first(where: { $0.type == .call }) { let durationSec = AttachmentPreviewCodec.parseCallDurationSeconds(callAtt.preview) let isOutgoing = currentLayout?.isOutgoing ?? false let isMissed = durationSec == 0 let isIncoming = !isOutgoing // Telegram: call bubbles have NO icon circle on the left avatarImageView.isHidden = true fileIconView.isHidden = true // Title (16pt medium — Telegram parity) fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium) fileNameLabel.textColor = isOutgoing ? .white : .label if isMissed { fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call" } else { fileNameLabel.text = isIncoming ? "Incoming Call" : "Outgoing Call" } // Duration with arrow if isMissed { fileSizeLabel.text = "Declined" fileSizeLabel.textColor = UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 0.95) } else { fileSizeLabel.text = Self.formattedDuration(seconds: durationSec) fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.6) : .secondaryLabel } // Directional arrow (green/red) let arrowName = isIncoming ? "arrow.down.left" : "arrow.up.right" callArrowView.image = UIImage( systemName: arrowName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 10, weight: .bold) ) callArrowView.tintColor = isMissed ? UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 1) : UIColor(red: 0.21, green: 0.75, blue: 0.20, alpha: 1) // #36C033 callArrowView.isHidden = false callBackButton.isHidden = false // Call button color: outgoing = white, incoming = blue (Telegram accentControlColor) let callPhoneView = callBackButton.viewWithTag(2002) as? UIImageView if isOutgoing { callPhoneView?.tintColor = .white } else { callPhoneView?.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) } } else if let voiceAtt = message.attachments.first(where: { $0.type == .voice }) { // Voice message: play button + waveform + duration // Preview format: "tag::duration::waveform_base64" or "duration::waveform_base64" let previewParts = Self.parseVoicePreview(voiceAtt.preview) voiceView.isHidden = false voiceView.frame = CGRect(x: 0, y: 0, width: fileContainer.bounds.width, height: 38) voiceView.configure( messageId: message.id, attachmentId: voiceAtt.id, preview: previewParts.waveform, duration: previewParts.duration, isOutgoing: layout.isOutgoing ) let isCurrentVoice = VoiceMessagePlayer.shared.currentMessageId == message.id voiceView.updatePlaybackState( isPlaying: isCurrentVoice && VoiceMessagePlayer.shared.isPlaying, progress: isCurrentVoice ? CGFloat(VoiceMessagePlayer.shared.progress) : 0 ) let voiceAttachment = voiceAtt let storedPassword = message.attachmentPassword let playbackDuration = previewParts.duration let playbackMessageId = message.id voiceView.onPlayTapped = { [weak self] in guard let self else { return } Task.detached(priority: .userInitiated) { guard let playableURL = await Self.resolvePlayableVoiceURL( attachment: voiceAttachment, duration: playbackDuration, storedPassword: storedPassword ) else { return } await MainActor.run { guard self.message?.id == playbackMessageId else { return } VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: playableURL) } } } fileIconView.isHidden = true fileNameLabel.isHidden = true fileSizeLabel.isHidden = true callArrowView.isHidden = true callBackButton.isHidden = true avatarImageView.isHidden = true } else if let fileAtt = message.attachments.first(where: { $0.type == .file }) { let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview) let isFileOutgoing = layout.isOutgoing avatarImageView.isHidden = true fileIconView.isHidden = false // Telegram parity: solid accent circle (incoming) or semi-transparent (outgoing) fileIconView.backgroundColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.2) : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1) let isCached = AttachmentCache.shared.fileURL( forAttachmentId: fileAtt.id, fileName: parsed.fileName ) != nil let iconName = isCached ? Self.fileIcon(for: parsed.fileName) : "arrow.down" fileIconSymbolView.image = UIImage(systemName: iconName) fileIconSymbolView.tintColor = .white fileNameLabel.font = Self.fileNameFont fileNameLabel.text = parsed.fileName // Telegram parity: accent blue filename (incoming) or white (outgoing) fileNameLabel.textColor = isFileOutgoing ? .white : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1) fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize) fileSizeLabel.textColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel callArrowView.isHidden = true callBackButton.isHidden = true } else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) { let isOutgoing = currentLayout?.isOutgoing ?? false fileNameLabel.font = Self.fileNameFont fileNameLabel.text = "Avatar" fileNameLabel.textColor = isOutgoing ? .white : .label callArrowView.isHidden = true callBackButton.isHidden = true // Android parity: show cached image OR blurhash placeholder. // NO auto-download — user must tap to download (via .triggerAttachmentDownload). if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: avatarAtt.id) { avatarImageView.image = cached avatarImageView.isHidden = false fileIconView.isHidden = true fileSizeLabel.text = "Shared profile photo" fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel } else { if isOutgoing { // Own avatar — already uploaded, just loading from disk fileSizeLabel.text = "Shared profile photo" } else { // Incoming avatar — needs download on tap (Android parity) fileSizeLabel.text = "Tap to download" } fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel // Show blurhash placeholder (decode async if not cached) let hash = AttachmentPreviewCodec.blurHash(from: avatarAtt.preview) if !hash.isEmpty, let blurImg = Self.blurHashCache.object(forKey: hash as NSString) { avatarImageView.image = blurImg avatarImageView.isHidden = false fileIconView.isHidden = true } else { avatarImageView.isHidden = true fileIconView.isHidden = false fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill") } // Async: decode blurhash + try disk cache (NO CDN download — tap required) let messageId = message.id let attId = avatarAtt.id Task.detached(priority: .userInitiated) { // 1. Decode blurhash immediately (~2ms) if !hash.isEmpty { if let decoded = UIImage.fromBlurHash(hash, width: 32, height: 32) { await MainActor.run { [weak self] in guard let self, self.message?.id == messageId else { return } Self.blurHashCache.setObject(decoded, forKey: hash as NSString) self.avatarImageView.image = decoded self.avatarImageView.isHidden = false self.fileIconView.isHidden = true } } } // 2. Try disk cache only (previously downloaded) if let diskImage = AttachmentCache.shared.loadImage(forAttachmentId: attId) { await MainActor.run { [weak self] in guard let self, self.message?.id == messageId else { return } self.avatarImageView.image = diskImage self.avatarImageView.isHidden = false self.fileIconView.isHidden = true self.fileSizeLabel.text = "Shared profile photo" } } // CDN download is triggered by user tap via .triggerAttachmentDownload } } } else { let isOutgoing = currentLayout?.isOutgoing ?? false avatarImageView.isHidden = true fileIconView.isHidden = false fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) fileIconSymbolView.image = UIImage(systemName: "doc.fill") fileNameLabel.font = Self.fileNameFont fileNameLabel.text = "File" fileNameLabel.textColor = isOutgoing ? .white : .label fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel callArrowView.isHidden = true callBackButton.isHidden = true } } else { fileContainer.isHidden = true } // Group Invite Card if let layout = currentLayout, layout.hasGroupInvite { groupInviteContainer.isHidden = false textLabel.isHidden = true groupInviteString = message.text groupInviteTitleLabel.text = layout.groupInviteTitle.isEmpty ? "Group" : layout.groupInviteTitle // Local membership check (fast, MainActor) let isJoined = GroupRepository.shared.hasGroup(for: layout.groupInviteGroupId) applyGroupInviteStatus(isJoined ? .joined : .notJoined, isOutgoing: layout.isOutgoing) // Async server check for authoritative status let msgId = message.id, groupId = layout.groupInviteGroupId inviteStatusTask?.cancel() inviteStatusTask = Task { @MainActor [weak self] in guard !Task.isCancelled else { return } guard let self, self.message?.id == msgId else { return } if let (_, status) = try? await GroupService.shared.checkInviteStatus(groupId: groupId), !Task.isCancelled, self.message?.id == msgId { let cardStatus: InviteCardStatus = switch status { case .joined: .joined case .notJoined: .notJoined case .invalid: .invalid case .banned: .banned } self.applyGroupInviteStatus(cardStatus, isOutgoing: layout.isOutgoing) } } } else { groupInviteContainer.isHidden = true } } private func applyGroupInviteStatus(_ status: InviteCardStatus, isOutgoing: Bool) { currentInviteStatus = status let color: UIColor switch status { case .notJoined: color = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6 case .joined: color = UIColor(red: 0.20, green: 0.78, blue: 0.35, alpha: 1) // #34C759 case .invalid, .banned: color = UIColor(red: 0.98, green: 0.23, blue: 0.19, alpha: 1) } groupInviteIconBg.backgroundColor = color groupInviteTitleLabel.textColor = isOutgoing ? .white : color let statusText: String switch status { case .notJoined: statusText = "Invite to join this group" case .joined: statusText = "You are a member" case .invalid: statusText = "This invite is invalid" case .banned: statusText = "You are banned" } groupInviteStatusLabel.text = statusText groupInviteStatusLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.7) : .secondaryLabel let buttonTitle: String switch status { case .notJoined: buttonTitle = "Join Group" case .joined: buttonTitle = "Open Group" case .invalid: buttonTitle = "Invalid" case .banned: buttonTitle = "Banned" } groupInviteButton.setTitle(buttonTitle, for: .normal) groupInviteButton.setTitleColor(.white, for: .normal) groupInviteButton.backgroundColor = color groupInviteButton.isEnabled = (status == .notJoined || status == .joined) groupInviteButton.alpha = groupInviteButton.isEnabled ? 1.0 : 0.5 } @objc private func groupInviteCardTapped() { guard let inviteStr = groupInviteString, let actions else { return } if currentInviteStatus == .joined { if let parsed = GroupRepository.parseInviteStringPure(inviteStr) { let dialogKey = "#group:\(parsed.groupId)" actions.onGroupInviteOpen(dialogKey) } } else if currentInviteStatus == .notJoined { actions.onGroupInviteTap(inviteStr) } } /// Apply pre-calculated layout (main thread only — just sets frames). /// This is the "apply" part of Telegram's asyncLayout pattern. /// NOTE: Bubble X-position is recalculated in layoutSubviews() based on actual cell width. func apply(layout: MessageCellLayout) { currentLayout = layout setNeedsLayout() // trigger layoutSubviews for correct X positioning } override func layoutSubviews() { super.layoutSubviews() guard let layout = currentLayout else { return } Self.ensureBubbleImages(for: traitCollection) let cellW = contentView.bounds.width let tailProtrusion = Self.bubbleMetrics.tailProtrusion // Inline date pills are always hidden — floating overlay pills in // NativeMessageListController handle all date display + push mechanics. // Cell still reserves dateHeaderHeight for visual spacing between sections. dateHeaderContainer.isHidden = true // Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment // Group incoming: offset right by 40pt for avatar lane (Telegram parity). let isGroupIncoming = !layout.isOutgoing && layout.senderKey.count > 0 let groupAvatarLane: CGFloat = isGroupIncoming ? 38 : 0 let baseBubbleX: CGFloat if layout.isOutgoing { baseBubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset } else if isGroupIncoming { // Group: avatar lane replaces tail space. Avatar at 4pt, bubble after lane. baseBubbleX = 4 + groupAvatarLane } else { baseBubbleX = tailProtrusion + 2 } // Telegram parity: only incoming messages shift right in selection mode let bubbleX: CGFloat if layout.isOutgoing { bubbleX = baseBubbleX } else { bubbleX = baseBubbleX + selectionOffset } bubbleView.frame = CGRect( x: bubbleX, y: layout.bubbleFrame.minY, width: layout.bubbleSize.width, height: layout.bubbleSize.height ) // ── Selection checkbox (Telegram: 28×28, x:6, vertically centered with bubble) ── if isInSelectionMode { let checkY = layout.bubbleFrame.minY + (layout.bubbleSize.height - 28) / 2 selectionCheckContainer.frame = CGRect(x: 6, y: checkY, width: 28, height: 28) } // ── Raster bubble image (Telegram-exact tail via stretchable image) ── // Telegram includes tail space (6pt) in backgroundFrame for ALL bubbles, // not just tailed ones. This keeps right edges aligned in a group. // For non-tailed, the tail area is transparent in the stretchable image. let imageFrame: CGRect if layout.isOutgoing { imageFrame = CGRect(x: 0, y: 0, width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height) } else { imageFrame = CGRect(x: -tailProtrusion, y: 0, width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height) } // Telegram extends bubble image by 1pt on each side (ChatMessageBackground.swift line 115: // `let imageFrame = CGRect(...).insetBy(dx: -1.0, dy: -1.0)`). // This makes adjacent bubbles overlap by 2pt vertically, reducing perceived gap. bubbleImageView.frame = imageFrame.insetBy(dx: -1, dy: -1) bubbleImageView.image = Self.bubbleImages.image( outgoing: layout.isOutgoing, mergeType: layout.mergeType ) // Highlight overlay — matches bubble bounds with inner corner radius highlightOverlay.frame = bubbleView.bounds highlightOverlay.layer.cornerRadius = 16 // ── Vector shadow path (approximate shape, used only for shadow) ── bubbleLayer.frame = bubbleView.bounds let shapeRect = imageFrame bubbleLayer.path = BubblePathCache.shared.path( size: shapeRect.size, origin: shapeRect.origin, mergeType: layout.mergeType, isOutgoing: layout.isOutgoing, metrics: Self.bubbleMetrics ) bubbleLayer.shadowPath = bubbleLayer.path bubbleOutlineLayer.frame = bubbleView.bounds bubbleOutlineLayer.path = bubbleLayer.path let hasPhotoContent = layout.hasPhoto if hasPhotoContent { bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor } else if layout.hasTail { bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor } else { bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor } // Emoji-only: hide bubble visuals (no background, just floating emoji) let isEmojiOnly = layout.messageType == .emojiOnly bubbleImageView.isHidden = isEmojiOnly bubbleLayer.isHidden = isEmojiOnly bubbleOutlineLayer.isHidden = isEmojiOnly // Text textLabel.isHidden = layout.textSize == .zero textLabel.frame = layout.textFrame // Timestamp + checkmarks (two-node overlay) timestampLabel.frame = layout.timestampFrame checkSentView.frame = layout.checkSentFrame checkReadView.frame = layout.checkReadFrame clockFrameView.frame = layout.clockFrame clockMinView.frame = layout.clockFrame #if DEBUG assertStatusLaneFramesValid(layout: layout) #endif // Telegram-style date/status pill on media-only bubbles. updateStatusBackgroundVisibility() updateStatusBackgroundFrame() // Reply replyContainer.isHidden = !layout.hasReplyQuote if layout.hasReplyQuote { replyContainer.frame = layout.replyContainerFrame replyBar.frame = layout.replyBarFrame replyNameLabel.frame = layout.replyNameFrame replyTextLabel.frame = layout.replyTextFrame } // Photo photoContainer.isHidden = !layout.hasPhoto if layout.hasPhoto { photoContainer.frame = layout.photoFrame layoutPhotoTiles() } bringStatusOverlayToFront() // File / Call / Avatar fileContainer.isHidden = !layout.hasFile if layout.hasFile { fileContainer.frame = layout.fileFrame let isCallType = message?.attachments.contains(where: { $0.type == .call }) ?? false let fileW = layout.fileFrame.width let isAvatarType = message?.attachments.contains(where: { $0.type == .avatar }) ?? false let fileContainerH = layout.fileFrame.height // For file-only messages, fileContainer spans the ENTIRE bubble (fileH = bubbleH). // Centering in fileContainerH gives visually perfect centering within the bubble. // The timestamp is a separate view positioned at the bottom — no collision risk // because content is left-aligned and timestamp is right-aligned. let centerableH = fileContainerH if isCallType { // Telegram-exact call layout: NO icon circle, text at left edge // Source: ChatMessageCallBubbleContentNode.swift fileIconView.isHidden = true let callBtnSize: CGFloat = 36 let callBtnRight: CGFloat = 10 let textRight = callBtnRight + callBtnSize + 8 // Vertically center content above timestamp let contentH: CGFloat = 36 // title(20) + gap(2) + subtitle(14) let topY = max(0, (centerableH - contentH) / 2) fileNameLabel.frame = CGRect(x: 11, y: topY, width: fileW - 11 - textRight, height: 20) fileSizeLabel.frame = CGRect(x: 25, y: topY + 22, width: fileW - 25 - textRight, height: 14) callArrowView.frame = CGRect(x: 12, y: topY + 25, width: 10, height: 10) // Call button: vertically centered in same area let btnY = max(0, (centerableH - callBtnSize) / 2) callBackButton.frame = CGRect(x: fileW - callBtnSize - callBtnRight, y: btnY, width: callBtnSize, height: callBtnSize) callBackButton.viewWithTag(2002)?.frame = CGRect(x: 0, y: 0, width: callBtnSize, height: callBtnSize) avatarImageView.isHidden = true } else if isAvatarType { // Avatar layout: vertically centered icon (44pt) + title + description let contentH: CGFloat = 44 // icon height dominates let topY = max(0, (centerableH - contentH) / 2) fileIconView.frame = CGRect(x: 9, y: topY, width: 44, height: 44) fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22) avatarImageView.frame = CGRect(x: 9, y: topY, width: 44, height: 44) let textTopY = topY + 4 fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19) fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16) } else if !voiceView.isHidden { // Voice layout: position in upper area with padding (Telegram: bubble insets top=15, left=9) let contentH: CGFloat = 38 let voiceLeftPad: CGFloat = 6 // left padding inside fileContainer let voiceTopPad: CGFloat = 8 // top padding — positions content in upper portion voiceView.frame = CGRect(x: voiceLeftPad, y: voiceTopPad, width: fileW - voiceLeftPad * 2, height: contentH) voiceView.layoutIfNeeded() // ensure playButton.frame is set before blob positioning fileIconView.isHidden = true fileNameLabel.isHidden = true fileSizeLabel.isHidden = true avatarImageView.isHidden = true // Reposition blob if it exists (keeps in sync on relayout) if let blob = voiceBlobView { let playCenter = voiceView.convert(voiceView.playButtonCenter, to: bubbleView) let blobSize: CGFloat = 56 blob.frame = CGRect(x: playCenter.x - blobSize / 2, y: playCenter.y - blobSize / 2, width: blobSize, height: blobSize) } } else { // File layout: vertically centered icon + title + size let contentH: CGFloat = 44 // icon height dominates let topY = max(0, (centerableH - contentH) / 2) fileIconView.frame = CGRect(x: 9, y: topY, width: 44, height: 44) fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22) let textTopY = topY + 4 fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19) fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16) avatarImageView.isHidden = true } } // Group Invite Card groupInviteContainer.isHidden = !layout.hasGroupInvite if layout.hasGroupInvite { groupInviteContainer.frame = CGRect(x: 0, y: 0, width: layout.bubbleSize.width, height: layout.bubbleSize.height) let cW = layout.bubbleSize.width let topY: CGFloat = 10 groupInviteIconBg.frame = CGRect(x: 10, y: topY, width: 44, height: 44) groupInviteIcon.frame = CGRect(x: 12, y: 12, width: 20, height: 20) let textX: CGFloat = 64 let textW = cW - textX - 10 groupInviteTitleLabel.frame = CGRect(x: textX, y: topY + 2, width: textW, height: 19) groupInviteStatusLabel.frame = CGRect(x: textX, y: topY + 22, width: textW, height: 16) let btnSize = groupInviteButton.sizeThatFits(CGSize(width: 200, height: 28)) let btnW = min(btnSize.width + 28, textW) groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28) } // Forward if layout.isForward { forwardLabel.frame = layout.forwardHeaderFrame forwardAvatarView.frame = layout.forwardAvatarFrame let avatarBounds = forwardAvatarView.bounds forwardAvatarInitialLabel.frame = avatarBounds forwardAvatarImageView.frame = avatarBounds forwardNameLabel.frame = layout.forwardNameFrame } // Telegram-style failed delivery badge outside bubble (slide + fade). let failedSize = CGSize(width: 20, height: 20) let targetFailedFrame = CGRect( x: bubbleView.frame.maxX + layout.deliveryFailedInset - failedSize.width, y: bubbleView.frame.maxY - failedSize.height, width: failedSize.width, height: failedSize.height ) if layout.showsDeliveryFailedIndicator { if !isDeliveryFailedVisible { isDeliveryFailedVisible = true deliveryFailedButton.isHidden = false deliveryFailedButton.alpha = 0 deliveryFailedButton.frame = targetFailedFrame.offsetBy(dx: layout.deliveryFailedInset, dy: 0) UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut, .beginFromCurrentState]) { self.deliveryFailedButton.alpha = 1 self.deliveryFailedButton.frame = targetFailedFrame } } else { deliveryFailedButton.isHidden = false deliveryFailedButton.alpha = 1 deliveryFailedButton.frame = targetFailedFrame } } else if isDeliveryFailedVisible { isDeliveryFailedVisible = false let hideFrame = deliveryFailedButton.frame.offsetBy(dx: layout.deliveryFailedInset, dy: 0) UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn, .beginFromCurrentState]) { self.deliveryFailedButton.alpha = 0 self.deliveryFailedButton.frame = hideFrame } completion: { _ in self.deliveryFailedButton.isHidden = true } } else { deliveryFailedButton.isHidden = true deliveryFailedButton.alpha = 0 } // Reply icon position is set AFTER sender name expansion (see below). // Group sender name — INSIDE bubble as first line (Telegram parity). // When shown, shift all bubble content (text, reply, etc.) down by senderNameShift. let senderNameShift: CGFloat = layout.showsSenderName ? 20 : 0 if layout.showsSenderName { senderNameLabel.isHidden = false senderNameLabel.text = layout.senderName let nameColorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey) senderNameLabel.textColor = RosettaColors.avatarTextColor(for: nameColorIdx) senderNameLabel.sizeToFit() // Reserve space for admin badge icon if sender is group owner let adminIconSpace: CGFloat = layout.isGroupAdmin ? 20 : 0 // Position inside bubble: top-left, with standard bubble padding senderNameLabel.frame = CGRect( x: 10, y: 6, width: min(senderNameLabel.bounds.width, bubbleView.bounds.width - 24 - adminIconSpace), height: 16 ) // Desktop parity: gold admin badge next to sender name if layout.isGroupAdmin { senderAdminIconView.isHidden = false senderAdminIconView.image = Self.goldAdminBadgeImage senderAdminIconView.frame = CGRect( x: senderNameLabel.frame.maxX + 3, y: 5, width: 16, height: 16 ) } else { senderAdminIconView.isHidden = true } // Expand bubble to fit the name bubbleView.frame.size.height += senderNameShift // Shift text and other content down to make room for the name textLabel.frame.origin.y += senderNameShift timestampLabel.frame.origin.y += senderNameShift checkSentView.frame.origin.y += senderNameShift checkReadView.frame.origin.y += senderNameShift clockFrameView.frame.origin.y += senderNameShift clockMinView.frame.origin.y += senderNameShift statusBackgroundView.frame.origin.y += senderNameShift if layout.hasReplyQuote { replyContainer.frame.origin.y += senderNameShift } if layout.hasPhoto { photoContainer.frame.origin.y += senderNameShift } if layout.hasFile { fileContainer.frame.origin.y += senderNameShift } if layout.hasGroupInvite { groupInviteContainer.frame.origin.y += senderNameShift } if layout.isForward { forwardLabel.frame.origin.y += senderNameShift forwardAvatarView.frame.origin.y += senderNameShift forwardNameLabel.frame.origin.y += senderNameShift } // Re-apply bubble image with tail protrusion after height expansion let expandedImageFrame: CGRect if layout.isOutgoing { expandedImageFrame = CGRect(x: 0, y: 0, width: bubbleView.bounds.width + tailProtrusion, height: bubbleView.bounds.height) } else { expandedImageFrame = CGRect(x: -tailProtrusion, y: 0, width: bubbleView.bounds.width + tailProtrusion, height: bubbleView.bounds.height) } bubbleImageView.frame = expandedImageFrame.insetBy(dx: -1, dy: -1) highlightOverlay.frame = bubbleView.bounds // Update shadow/outline layers for expanded height bubbleLayer.frame = bubbleView.bounds bubbleLayer.path = BubblePathCache.shared.path( size: expandedImageFrame.size, origin: expandedImageFrame.origin, mergeType: layout.mergeType, isOutgoing: layout.isOutgoing, metrics: Self.bubbleMetrics ) bubbleLayer.shadowPath = bubbleLayer.path bubbleOutlineLayer.frame = bubbleView.bounds bubbleOutlineLayer.path = bubbleLayer.path } else { senderNameLabel.isHidden = true senderAdminIconView.isHidden = true } // Group sender avatar (left of bubble, last in run, Telegram parity) // Size: 36pt (Android 42dp, Desktop 40px — average ~36pt on iOS) if layout.showsSenderAvatar { let avatarSize: CGFloat = 36 senderAvatarContainer.isHidden = false senderAvatarContainer.frame = CGRect( x: 4 + selectionOffset, y: bubbleView.frame.maxY - avatarSize, width: avatarSize, height: avatarSize ) senderAvatarInitialLabel.frame = senderAvatarContainer.bounds senderAvatarImageView.frame = senderAvatarContainer.bounds senderAvatarImageView.layer.cornerRadius = avatarSize / 2 let colorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey) // Mantine "light" variant: base + tint overlay (matches AvatarView SwiftUI rendering). // Dark: #1A1B1E base + tint at 15%. Light: white base + tint at 10%. let isDark = traitCollection.userInterfaceStyle == .dark senderAvatarContainer.backgroundColor = isDark ? UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1) : .white let tintAlpha: CGFloat = isDark ? 0.15 : 0.10 let tintColor = RosettaColors.avatarColor(for: colorIdx).withAlphaComponent(tintAlpha) senderAvatarInitialLabel.backgroundColor = tintColor senderAvatarInitialLabel.text = RosettaColors.initials(name: layout.senderName, publicKey: layout.senderKey) // Dark: shade-3 text. Light: shade-6 (tint) text. senderAvatarInitialLabel.textColor = isDark ? RosettaColors.avatarTextColor(for: colorIdx) : RosettaColors.avatarColor(for: colorIdx) if let image = AvatarRepository.shared.loadAvatar(publicKey: layout.senderKey) { senderAvatarImageView.image = image senderAvatarImageView.isHidden = false } else { senderAvatarImageView.image = nil senderAvatarImageView.isHidden = true } } else { senderAvatarContainer.isHidden = true } // Reply icon (for swipe gesture) — positioned AFTER all bubble size adjustments // (sender name shift, etc.) so it's vertically centered on the final bubble. let replyIconDiameter: CGFloat = 34 let replyIconX = bubbleView.frame.maxX - replyIconDiameter let replyIconY = bubbleView.frame.midY - replyIconDiameter / 2 replyCircleView.frame = CGRect(x: replyIconX, y: replyIconY, width: replyIconDiameter, height: replyIconDiameter) replyIconView.frame = CGRect(x: replyIconX + 7, y: replyIconY + 7, width: 20, height: 20) } private static func formattedDuration(seconds: Int) -> String { let safe = max(seconds, 0) let minutes = safe / 60 let secs = safe % 60 return String(format: "%d:%02d", minutes, secs) } /// Telegram parity: file-type-specific icon name (same mapping as MessageFileView.swift). /// Parse voice preview: "tag::duration::waveform" or "duration::waveform" private static func parseVoicePreview(_ preview: String) -> (duration: TimeInterval, waveform: String) { let parts = preview.components(separatedBy: "::") // Format: "tag::duration::waveform" or "duration::waveform" if parts.count >= 3, let dur = Int(parts[1]) { return (TimeInterval(dur), parts[2]) } else if parts.count >= 2, let dur = Int(parts[0]) { return (TimeInterval(dur), parts[1]) } else if let dur = Int(parts[0]) { return (TimeInterval(dur), "") } return (0, preview) } private static func resolvePlayableVoiceURL( attachment: MessageAttachment, duration: TimeInterval, storedPassword: String? ) async -> URL? { let fileName = "voice_\(Int(duration))s.m4a" if let cached = playableVoiceURLFromCache(attachmentId: attachment.id, fileName: fileName) { return cached } guard let downloaded = await downloadVoiceData(attachment: attachment, storedPassword: storedPassword) else { return nil } _ = AttachmentCache.shared.saveFile(downloaded, forAttachmentId: attachment.id, fileName: fileName) return writePlayableVoiceTempFile( data: downloaded, attachmentId: attachment.id, fileName: fileName ) } private static func playableVoiceURLFromCache(attachmentId: String, fileName: String) -> URL? { guard let decrypted = AttachmentCache.shared.loadFileData( forAttachmentId: attachmentId, fileName: fileName ) else { return nil } return writePlayableVoiceTempFile(data: decrypted, attachmentId: attachmentId, fileName: fileName) } private static func writePlayableVoiceTempFile(data: Data, attachmentId: String, fileName: String) -> URL? { let safeFileName = fileName.replacingOccurrences(of: "/", with: "_") let tempURL = FileManager.default.temporaryDirectory .appendingPathComponent("voice_play_\(attachmentId)_\(safeFileName)") try? FileManager.default.removeItem(at: tempURL) do { try data.write(to: tempURL, options: .atomic) return tempURL } catch { return nil } } private static func downloadVoiceData(attachment: MessageAttachment, storedPassword: String?) async -> Data? { let tag = attachment.effectiveDownloadTag guard !tag.isEmpty else { return nil } guard let storedPassword, !storedPassword.isEmpty else { return nil } do { let encryptedData = try await TransportManager.shared.downloadFile( tag: tag, server: attachment.transportServer ) let encryptedString = String(decoding: encryptedData, as: UTF8.self) let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) guard let decrypted = decryptAttachmentData(encryptedString: encryptedString, passwords: passwords) else { return nil } return parseAttachmentFileData(decrypted) } catch { return nil } } private static func decryptAttachmentData(encryptedString: String, passwords: [String]) -> Data? { let crypto = CryptoManager.shared for password in passwords { if let data = try? crypto.decryptWithPassword( encryptedString, password: password, requireCompression: true ) { return data } } for password in passwords { if let data = try? crypto.decryptWithPassword(encryptedString, password: password) { return data } } return nil } private static func parseAttachmentFileData(_ data: Data) -> Data { if let string = String(data: data, encoding: .utf8), string.hasPrefix("data:"), let comma = string.firstIndex(of: ",") { let payload = String(string[string.index(after: comma)...]) return Data(base64Encoded: payload) ?? data } return data } private static func fileIcon(for fileName: String) -> String { let ext = (fileName as NSString).pathExtension.lowercased() switch ext { case "pdf": return "doc.fill" case "zip", "rar", "7z": return "doc.zipper" case "jpg", "jpeg", "png", "gif": return "photo.fill" case "mp4", "mov", "avi": return "film.fill" case "mp3", "wav", "aac": return "waveform" default: return "doc.fill" } } private static func formattedFileSize(bytes: Int) -> String { if bytes < 1024 { return "\(bytes) B" } if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) } if bytes < 1024 * 1024 * 1024 { return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) } return String(format: "%.1f GB", Double(bytes) / (1024 * 1024 * 1024)) } /// Downloads avatar from CDN, decrypts, caches to disk, and returns the image. /// Shared logic with `MessageAvatarView.downloadAvatar()`. private static func downloadAndCacheAvatar( tag: String, attachmentId: String, storedPassword: String, senderKey: String, server: String = "" ) async -> UIImage? { do { let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server) let encryptedString = String(decoding: encryptedData, as: UTF8.self) let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) guard let image = decryptAvatarImage(encryptedString: encryptedString, passwords: passwords) else { return nil } AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId) // Android parity: save avatar to sender's profile after download if let jpegData = image.jpegData(compressionQuality: 0.85) { let base64 = jpegData.base64EncodedString() await MainActor.run { AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey) } } return image } catch { return nil } } /// Tries each password candidate to decrypt avatar image data. private static func decryptAvatarImage(encryptedString: String, passwords: [String]) -> UIImage? { let crypto = CryptoManager.shared #if DEBUG print("[AVATAR-DBG] decryptAvatarImage blob=\(encryptedString.count) chars, \(passwords.count) candidates") #endif for password in passwords { guard let data = try? crypto.decryptWithPassword( encryptedString, password: password, requireCompression: true ) else { #if DEBUG print("[AVATAR-DBG] pwd=\(password.prefix(8))… requireCompression=true → nil") #endif continue } #if DEBUG let hex = data.prefix(30).map { String(format: "%02x", $0) }.joined() let utf8 = String(data: data.prefix(60), encoding: .utf8) ?? "" print("[AVATAR-DBG] pwd=\(password.prefix(8))… requireCompression=true → \(data.count) bytes, hex=\(hex), utf8=\(utf8.prefix(60))") #endif if let img = parseAvatarImageData(data) { return img } #if DEBUG print("[AVATAR-DBG] parseAvatarImageData returned nil for requireCompression=true data") #endif } for password in passwords { guard let data = try? crypto.decryptWithPassword( encryptedString, password: password ) else { #if DEBUG print("[AVATAR-DBG] pwd=\(password.prefix(8))… noCompression → nil") #endif continue } #if DEBUG let hex = data.prefix(30).map { String(format: "%02x", $0) }.joined() print("[AVATAR-DBG] pwd=\(password.prefix(8))… noCompression → \(data.count) bytes, hex=\(hex)") #endif if let img = parseAvatarImageData(data) { return img } #if DEBUG print("[AVATAR-DBG] parseAvatarImageData returned nil for noCompression data") #endif } #if DEBUG print("[AVATAR-DBG] ❌ All candidates failed") #endif return nil } /// Parses avatar image data (data URI or raw base64 or raw bytes). private static func parseAvatarImageData(_ data: Data) -> UIImage? { if let str = String(data: data, encoding: .utf8) { if str.hasPrefix("data:"), let commaIndex = str.firstIndex(of: ",") { let base64Part = String(str[str.index(after: commaIndex)...]) if let imageData = Data(base64Encoded: base64Part), let img = AttachmentCache.downsampledImage(from: imageData) { return img } } else if let imageData = Data(base64Encoded: str), let img = AttachmentCache.downsampledImage(from: imageData) { return img } } return AttachmentCache.downsampledImage(from: data) } // MARK: - Self-sizing (from pre-calculated layout) override func preferredLayoutAttributesFitting( _ layoutAttributes: UICollectionViewLayoutAttributes ) -> UICollectionViewLayoutAttributes { // Always return concrete height — never fall to super (expensive self-sizing) let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes attrs.size.height = currentLayout?.totalHeight ?? 50 return attrs } // MARK: - Link Tap @objc private func handleLinkTap(_ gesture: UITapGestureRecognizer) { // In selection mode: any tap toggles selection if isInSelectionMode { guard let msgId = message?.id else { return } actions?.onToggleSelection(msgId) return } let pointInText = gesture.location(in: textLabel) // Check links first if let url = textLabel.textLayout?.linkAt(point: pointInText) { var finalURL = url if finalURL.scheme == nil || finalURL.scheme?.isEmpty == true { finalURL = URL(string: "https://\(url.absoluteString)") ?? url } UIApplication.shared.open(finalURL) return } // Then check @mentions if let username = textLabel.textLayout?.mentionAt(point: pointInText) { actions?.onMentionTap(username) return } } @objc private func handleAvatarTap() { guard let key = message?.fromPublicKey, !key.isEmpty else { return } actions?.onAvatarTap(key) } // MARK: - Context Menu (Telegram-style) private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium) @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { guard gesture.state == .began else { return } // In selection mode: tap toggles selection instead of context menu if isInSelectionMode { guard let msgId = message?.id else { return } contextMenuHaptic.impactOccurred() actions?.onToggleSelection(msgId) return } contextMenuHaptic.impactOccurred() presentContextMenu() } private func presentContextMenu() { guard let message, let actions else { return } guard let layout = currentLayout else { return } // Capture snapshot from window (pixel-perfect, accounts for inverted scroll) guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: bubbleView) else { return } // Build bubble mask path let bubblePath = BubbleGeometryEngine.makeBezierPath( in: CGRect(origin: .zero, size: frame.size), mergeType: layout.mergeType, outgoing: layout.isOutgoing ) // Build menu items let items = TelegramContextMenuBuilder.menuItems( for: message, actions: actions, isSavedMessages: isSavedMessages, isSystemAccount: isSystemAccount ) TelegramContextMenuController.present( snapshot: snapshot, sourceFrame: frame, bubblePath: bubblePath, items: items, isOutgoing: layout.isOutgoing ) } // MARK: - Swipe to Reply @objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) { if isInSelectionMode { return } // Disable swipe-to-reply in selection mode if isSavedMessages || isSystemAccount { return } let isReplyBlocked = (message?.attachments.contains(where: { $0.type == .avatar }) ?? false) || (currentLayout?.messageType == .groupInvite) if isReplyBlocked { return } let translation = gesture.translation(in: contentView) let threshold: CGFloat = 55 let elasticCap: CGFloat = 85 // match SwiftUI SwipeToReplyModifier let backGestureEdge: CGFloat = 40 switch gesture.state { case .began: // Record start position — reject if near left screen edge (iOS back gesture zone) let startPoint = gesture.location(in: contentView.window) swipeStartX = startPoint.x // Pre-warm haptic engine for instant response at threshold swipeHaptic.prepare() case .changed: // Reject gestures from back gesture zone (left 40pt) if let startX = swipeStartX, startX < backGestureEdge { return } // Telegram: ALL messages swipe LEFT let raw = min(translation.x, 0) guard raw < 0 else { return } // Elastic resistance past cap (Telegram rubber-band) let absRaw = abs(raw) let clamped: CGFloat if absRaw > elasticCap { clamped = -(elasticCap + (absRaw - elasticCap) * 0.15) } else { clamped = raw } bubbleView.transform = CGAffineTransform(translationX: clamped, y: 0) // Move sender avatar with bubble during swipe (group chats) if !senderAvatarContainer.isHidden { senderAvatarContainer.transform = CGAffineTransform(translationX: clamped, y: 0) } // Icon progress: fade in from 4pt to threshold let absClamped = abs(clamped) let progress: CGFloat = absClamped > 4 ? min((absClamped - 4) / (threshold - 4), 1) : 0 replyCircleView.alpha = progress replyCircleView.transform = CGAffineTransform(scaleX: progress, y: progress) replyIconView.alpha = progress replyIconView.transform = CGAffineTransform(scaleX: progress, y: progress) // Haptic at threshold crossing (once per gesture, pre-prepared) if absClamped >= threshold, !hasTriggeredSwipeHaptic { hasTriggeredSwipeHaptic = true swipeHaptic.impactOccurred() } case .ended, .cancelled: let shouldReply = abs(translation.x) >= threshold if shouldReply, let message, let actions { actions.onReply(message) } hasTriggeredSwipeHaptic = false swipeStartX = nil // Velocity-aware spring (Telegram passes swipe velocity for natural spring-back) let velocity = gesture.velocity(in: contentView) let currentOffset = bubbleView.transform.tx let relativeVx: CGFloat = currentOffset != 0 ? velocity.x / abs(currentOffset) : 0 let initialVelocity = CGVector(dx: relativeVx, dy: 0) let timing = UISpringTimingParameters(mass: 1, stiffness: 386, damping: 33.4, initialVelocity: initialVelocity) let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timing) animator.addAnimations { self.bubbleView.transform = .identity self.senderAvatarContainer.transform = .identity self.replyCircleView.alpha = 0 self.replyCircleView.transform = .identity self.replyIconView.alpha = 0 self.replyIconView.transform = .identity } animator.startAnimation() default: break } } @objc private func handleDeliveryFailedTap() { guard let message, let actions else { return } actions.onRetry(message) } @objc private func callBackTapped() { guard let message, let actions else { return } let isOutgoing = currentLayout?.isOutgoing ?? false let peerKey = isOutgoing ? message.toPublicKey : message.fromPublicKey actions.onCall(peerKey) } @objc private func handleAttachmentDownload(_ notif: Notification) { guard let id = notif.object as? String, let message else { return } // Avatar download if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }), avatarAtt.id == id { if AttachmentCache.shared.cachedImage(forAttachmentId: id) != nil { return } let tag = avatarAtt.effectiveDownloadTag guard !tag.isEmpty else { return } guard let password = message.attachmentPassword, !password.isEmpty else { #if DEBUG print("[AVATAR-DBG] ❌ No attachmentPassword for avatar id=\(id)") #endif return } #if DEBUG print("[AVATAR-DBG] Starting download tag=\(tag.prefix(12))… pwd=\(password.prefix(8))… len=\(password.count)") #endif fileSizeLabel.text = "Downloading..." let messageId = message.id // Desktop parity: group avatar saves to group dialog key, not sender key let avatarTargetKey = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? message.toPublicKey : message.fromPublicKey let server = avatarAtt.transportServer Task.detached(priority: .userInitiated) { let downloaded = await Self.downloadAndCacheAvatar( tag: tag, attachmentId: id, storedPassword: password, senderKey: avatarTargetKey, server: server ) await MainActor.run { [weak self] in guard let self, self.message?.id == messageId else { return } if let downloaded { self.avatarImageView.image = downloaded self.avatarImageView.isHidden = false self.fileIconView.isHidden = true self.fileSizeLabel.text = "Shared profile photo" // Trigger refresh of sender avatar circles in visible cells NotificationCenter.default.post( name: Notification.Name("avatarDidUpdate"), object: nil ) } else { self.fileSizeLabel.text = "Tap to retry" } } } return } // File download (desktop parity: MessageFile.tsx download flow) if let fileAtt = message.attachments.first(where: { $0.type == .file }), fileAtt.id == id { let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview) let fileName = parsed.fileName.isEmpty ? "file" : parsed.fileName // Already cached? → share if let url = AttachmentCache.shared.fileURL(forAttachmentId: id, fileName: fileName) { shareFile(url) return } let tag = fileAtt.effectiveDownloadTag guard !tag.isEmpty else { return } guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { fileSizeLabel.text = "File expired" return } fileSizeLabel.text = "Downloading..." let messageId = message.id let attId = fileAtt.id let server = fileAtt.transportServer Task.detached(priority: .userInitiated) { do { let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server) let encryptedString = String(decoding: encryptedData, as: UTF8.self) let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) // Try with compression first, then without (legacy) var decrypted: Data? for pw in passwords { if let d = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: pw, requireCompression: true) { decrypted = d; break } } if decrypted == nil { for pw in passwords { if let d = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: pw) { decrypted = d; break } } } guard let decrypted else { throw TransportError.invalidResponse } // Parse data URI if present let fileData: Data if let str = String(data: decrypted, encoding: .utf8), str.hasPrefix("data:"), let comma = str.firstIndex(of: ",") { let b64 = String(str[str.index(after: comma)...]) fileData = Data(base64Encoded: b64) ?? decrypted } else { fileData = decrypted } let url = AttachmentCache.shared.saveFile(fileData, forAttachmentId: attId, fileName: fileName) await MainActor.run { [weak self] in guard let self, self.message?.id == messageId else { return } self.fileSizeLabel.text = Self.formattedFileSize(bytes: fileData.count) self.fileIconSymbolView.image = UIImage(systemName: "doc.fill") self.shareFile(url) } } catch { await MainActor.run { [weak self] in guard let self, self.message?.id == messageId else { return } self.fileSizeLabel.text = "File expired" } } } return } } private func shareFile(_ cachedURL: URL) { guard let message else { return } // Files are stored encrypted (.enc) on disk. Decrypt to temp dir for sharing. guard let fileAtt = message.attachments.first(where: { $0.type == .file }) else { return } let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview) let fileName = parsed.fileName.isEmpty ? "file" : parsed.fileName guard let data = AttachmentCache.shared.loadFileData(forAttachmentId: fileAtt.id, fileName: fileName) else { fileSizeLabel.text = "File expired" return } let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) try? data.write(to: tempURL, options: .atomic) let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = scene.windows.first, let rootVC = window.rootViewController { var topVC = rootVC while let presented = topVC.presentedViewController { topVC = presented } if let popover = activityVC.popoverPresentationController { popover.sourceView = topVC.view popover.sourceRect = CGRect(x: topVC.view.bounds.midX, y: topVC.view.bounds.midY, width: 0, height: 0) } topVC.present(activityVC, animated: true) } } // MARK: - Highlight (scroll-to-message flash) func showHighlight() { highlightOverlay.alpha = 0 UIView.animate(withDuration: 0.2) { self.highlightOverlay.alpha = 1 } } func hideHighlight() { UIView.animate(withDuration: 0.4) { self.highlightOverlay.alpha = 0 } } @objc private func replyQuoteTapped() { guard let replyMessageId, let actions else { return } actions.onScrollToMessage(replyMessageId) } @objc private func fileContainerTapped() { if isInSelectionMode { guard let msgId = message?.id else { return } actions?.onToggleSelection(msgId) return } guard let message, let actions else { return } let isCallType = message.attachments.contains { $0.type == .call } if isCallType { // Tap anywhere on call bubble → call back let isOutgoing = currentLayout?.isOutgoing ?? false let peerKey = isOutgoing ? message.toPublicKey : message.fromPublicKey actions.onCall(peerKey) } else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) { // Tap on avatar bubble → trigger download (Android parity) NotificationCenter.default.post(name: .triggerAttachmentDownload, object: avatarAtt.id) } else if let fileAtt = message.attachments.first(where: { $0.type == .file }) { // Tap on file bubble → trigger download/share NotificationCenter.default.post(name: .triggerAttachmentDownload, object: fileAtt.id) } } @objc private func handlePhotoTileTap(_ sender: UIButton) { // In selection mode: any tap toggles selection if isInSelectionMode { guard let msgId = message?.id else { return } actions?.onToggleSelection(msgId) return } guard sender.tag >= 0, sender.tag < photoAttachments.count, let message, let actions else { return } let attachment = photoAttachments[sender.tag] let imageView = photoTileImageViews[sender.tag] let sourceFrame = imageView.convert(imageView.bounds, to: nil) if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil { actions.onImageTap(attachment.id, sourceFrame, imageView) return } Task { [weak self] in await ImageLoadLimiter.shared.acquire() let loaded = await Task.detached(priority: .userInitiated) { AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) }.value await ImageLoadLimiter.shared.release() guard !Task.isCancelled else { return } await MainActor.run { guard let self, self.message?.id == message.id else { return } if loaded != nil { actions.onImageTap(attachment.id, sourceFrame, imageView) } else { self.downloadPhotoAttachment(attachment: attachment, message: message) } } } } private func configurePhoto(for message: ChatMessage) { guard let layout = currentLayout, layout.hasPhoto else { resetPhotoTiles() return } let allPhotoAttachments = message.attachments.filter { $0.type == .image } totalPhotoAttachmentCount = allPhotoAttachments.count photoAttachments = Array(allPhotoAttachments.prefix(Self.maxVisiblePhotoTiles)) guard !photoAttachments.isEmpty else { resetPhotoTiles() return } let activeIds = Set(photoAttachments.map(\.id)) for (attachmentId, task) in photoLoadTasks where !activeIds.contains(attachmentId) { task.cancel() photoLoadTasks.removeValue(forKey: attachmentId) } for (attachmentId, task) in photoDownloadTasks where !activeIds.contains(attachmentId) { task.cancel() photoDownloadTasks.removeValue(forKey: attachmentId) downloadingAttachmentIds.remove(attachmentId) failedAttachmentIds.remove(attachmentId) } for (attachmentId, task) in photoBlurHashTasks where !activeIds.contains(attachmentId) { task.cancel() photoBlurHashTasks.removeValue(forKey: attachmentId) } for index in 0..= 0, lastVisibleIndex < frames.count else { return } let tileFrame = frames[lastVisibleIndex] guard let prototypeMask = MediaBubbleCornerMaskFactory.tileMask( tileFrame: tileFrame, containerBounds: photoContainer.bounds, mergeType: layout.mergeType, outgoing: layout.isOutgoing ) else { return } applyMaskPrototype(prototypeMask, to: photoTileImageViews[lastVisibleIndex]) applyMaskPrototype(prototypeMask, to: photoTilePlaceholderViews[lastVisibleIndex]) applyMaskPrototype(prototypeMask, to: photoTileButtons[lastVisibleIndex]) // Keep overflow badge clipping aligned with the same rounded corner. applyMaskPrototype(prototypeMask, to: photoOverflowOverlayView) } private func applyMaskPrototype(_ prototype: CAShapeLayer, to view: UIView) { guard let path = prototype.path else { view.layer.mask = nil return } let mask = CAShapeLayer() mask.frame = CGRect(origin: .zero, size: view.bounds.size) mask.path = path view.layer.mask = mask } private static func photoTileFrames(count: Int, in bounds: CGRect) -> [CGRect] { let spacing: CGFloat = 2 let width = bounds.width let height = bounds.height guard count > 0, width > 0, height > 0 else { return [] } switch count { case 1: return [bounds] case 2: let cellWidth = (width - spacing) / 2 return [ CGRect(x: 0, y: 0, width: cellWidth, height: height), CGRect(x: cellWidth + spacing, y: 0, width: cellWidth, height: height) ] case 3: let rightWidth = width * 0.34 let leftWidth = width - spacing - rightWidth let rightTopHeight = (height - spacing) / 2 let rightBottomHeight = height - rightTopHeight - spacing return [ CGRect(x: 0, y: 0, width: leftWidth, height: height), CGRect(x: leftWidth + spacing, y: 0, width: rightWidth, height: rightTopHeight), CGRect(x: leftWidth + spacing, y: rightTopHeight + spacing, width: rightWidth, height: rightBottomHeight) ] case 4: let cellWidth = (width - spacing) / 2 let topHeight = (height - spacing) / 2 let bottomHeight = height - topHeight - spacing return [ CGRect(x: 0, y: 0, width: cellWidth, height: topHeight), CGRect(x: cellWidth + spacing, y: 0, width: cellWidth, height: topHeight), CGRect(x: 0, y: topHeight + spacing, width: cellWidth, height: bottomHeight), CGRect(x: cellWidth + spacing, y: topHeight + spacing, width: cellWidth, height: bottomHeight) ] default: let topCellWidth = (width - spacing) / 2 let bottomCellWidth = (width - spacing * 2) / 3 var topHeight = min(topCellWidth * 0.85, 176) var bottomHeight = min(bottomCellWidth * 0.85, 144) let expectedHeight = topHeight + spacing + bottomHeight if expectedHeight > 0 { let scale = height / expectedHeight topHeight *= scale bottomHeight *= scale } return [ CGRect(x: 0, y: 0, width: topCellWidth, height: topHeight), CGRect(x: topCellWidth + spacing, y: 0, width: topCellWidth, height: topHeight), CGRect(x: 0, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight), CGRect(x: bottomCellWidth + spacing, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight), CGRect(x: (bottomCellWidth + spacing) * 2, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight) ] } } private func setPhotoTileImage(_ image: UIImage?, at index: Int, animated: Bool) { guard index >= 0, index < photoTileImageViews.count else { return } let imageView = photoTileImageViews[index] let update = { imageView.image = image } if animated, image != nil, imageView.image != nil { UIView.transition( with: imageView, duration: 0.18, options: [.transitionCrossDissolve, .beginFromCurrentState, .allowUserInteraction], animations: update ) } else { update() } imageView.isHidden = image == nil } private func layoutPhotoOverflowOverlay(frames: [CGRect]? = nil) { let overflowCount = totalPhotoAttachmentCount - photoAttachments.count guard overflowCount > 0, !photoAttachments.isEmpty else { photoOverflowOverlayView.isHidden = true photoOverflowLabel.isHidden = true photoOverflowLabel.text = nil return } let lastVisibleIndex = photoAttachments.count - 1 guard lastVisibleIndex >= 0, lastVisibleIndex < photoTileImageViews.count else { photoOverflowOverlayView.isHidden = true photoOverflowLabel.isHidden = true photoOverflowLabel.text = nil return } let frame: CGRect if let frames, lastVisibleIndex < frames.count { frame = frames[lastVisibleIndex] } else { frame = photoTileImageViews[lastVisibleIndex].frame } guard frame.width > 0, frame.height > 0 else { photoOverflowOverlayView.isHidden = true photoOverflowLabel.isHidden = true photoOverflowLabel.text = nil return } photoOverflowOverlayView.frame = frame photoOverflowOverlayView.isHidden = false photoOverflowLabel.isHidden = false photoOverflowLabel.text = "+\(overflowCount)" photoOverflowLabel.font = UIFont.systemFont( ofSize: max(18, min(frame.height * 0.34, 34)), weight: .semibold ) photoOverflowLabel.frame = photoOverflowOverlayView.bounds } private func updatePhotoUploadingOverlay(isVisible: Bool) { photoUploadingOverlayView.isHidden = !isVisible if isVisible { photoUploadingIndicator.isHidden = false photoUploadingIndicator.startAnimating() } else { photoUploadingIndicator.stopAnimating() photoUploadingIndicator.isHidden = true } } private func tileIndex(for attachmentId: String) -> Int? { photoAttachments.firstIndex(where: { $0.id == attachmentId }) } private func startPhotoBlurHashTask(attachment: MessageAttachment) { let attachmentId = attachment.id guard photoBlurHashTasks[attachmentId] == nil else { return } let hash = Self.extractBlurHash(from: attachment.preview) guard !hash.isEmpty else { return } if let cached = Self.blurHashCache.object(forKey: hash as NSString) { if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileImageViews.count { setPhotoTileImage(cached, at: tileIndex, animated: false) photoTilePlaceholderViews[tileIndex].isHidden = true } return } photoBlurHashTasks[attachmentId] = Task { [weak self] in let decoded = await Task.detached(priority: .background) { UIImage.fromBlurHash(hash, width: 48, height: 48) }.value guard !Task.isCancelled else { return } await MainActor.run { guard let self else { return } self.photoBlurHashTasks.removeValue(forKey: attachmentId) guard let decoded, let tileIndex = self.tileIndex(for: attachmentId), tileIndex < self.photoTileImageViews.count else { return } Self.blurHashCache.setObject(decoded, forKey: hash as NSString) // Do not override already loaded real image. guard self.photoTileImageViews[tileIndex].image == nil else { return } self.setPhotoTileImage(decoded, at: tileIndex, animated: false) self.photoTilePlaceholderViews[tileIndex].isHidden = true } } } private func startPhotoLoadTask(attachment: MessageAttachment) { if photoLoadTasks[attachment.id] != nil { return } let attachmentId = attachment.id photoLoadTasks[attachmentId] = Task { [weak self] in await ImageLoadLimiter.shared.acquire() let loaded = await Task.detached(priority: .userInitiated) { AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) }.value await ImageLoadLimiter.shared.release() guard !Task.isCancelled else { return } await MainActor.run { guard let self else { return } self.photoLoadTasks.removeValue(forKey: attachmentId) guard let tileIndex = self.tileIndex(for: attachmentId), tileIndex < self.photoTileImageViews.count, let loaded else { return } self.failedAttachmentIds.remove(attachmentId) self.setPhotoTileImage(loaded, at: tileIndex, animated: true) self.photoTilePlaceholderViews[tileIndex].isHidden = true self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].isHidden = true self.photoTileErrorViews[tileIndex].isHidden = true self.photoTileDownloadArrows[tileIndex].isHidden = true } } } private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) { if photoDownloadTasks[attachment.id] != nil { return } let tag = attachment.effectiveDownloadTag #if DEBUG print("[PHOTO-DBG] downloadPhoto tag=\(tag.prefix(12))… pwd=\(message.attachmentPassword?.prefix(8) ?? "nil") len=\(message.attachmentPassword?.count ?? 0)") #endif guard !tag.isEmpty, let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { failedAttachmentIds.insert(attachment.id) if let tileIndex = tileIndex(for: attachment.id), tileIndex < photoTileErrorViews.count { photoTileActivityIndicators[tileIndex].stopAnimating() photoTileActivityIndicators[tileIndex].isHidden = true photoTileErrorViews[tileIndex].isHidden = false photoTileDownloadArrows[tileIndex].isHidden = true } return } let attachmentId = attachment.id failedAttachmentIds.remove(attachmentId) downloadingAttachmentIds.insert(attachmentId) if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileActivityIndicators.count { photoTileActivityIndicators[tileIndex].startAnimating() photoTileActivityIndicators[tileIndex].isHidden = false photoTileErrorViews[tileIndex].isHidden = true photoTileDownloadArrows[tileIndex].isHidden = true } let server = attachment.transportServer photoDownloadTasks[attachmentId] = Task { [weak self] in do { let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server) let encryptedString = String(decoding: encryptedData, as: UTF8.self) let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords) await MainActor.run { guard let self else { return } self.photoDownloadTasks.removeValue(forKey: attachmentId) self.downloadingAttachmentIds.remove(attachmentId) guard let tileIndex = self.tileIndex(for: attachmentId), tileIndex < self.photoTileImageViews.count else { return } if let image { self.failedAttachmentIds.remove(attachmentId) AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId) self.setPhotoTileImage(image, at: tileIndex, animated: true) self.photoTilePlaceholderViews[tileIndex].isHidden = true self.photoTileErrorViews[tileIndex].isHidden = true } else { self.failedAttachmentIds.insert(attachmentId) self.photoTileErrorViews[tileIndex].isHidden = false } self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].isHidden = true self.photoTileDownloadArrows[tileIndex].isHidden = true } } catch { await MainActor.run { guard let self else { return } self.photoDownloadTasks.removeValue(forKey: attachmentId) self.downloadingAttachmentIds.remove(attachmentId) self.failedAttachmentIds.insert(attachmentId) guard let tileIndex = self.tileIndex(for: attachmentId), tileIndex < self.photoTileActivityIndicators.count else { return } self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].isHidden = true self.photoTileErrorViews[tileIndex].isHidden = false self.photoTileDownloadArrows[tileIndex].isHidden = true } } } } private func resetPhotoTiles() { photoAttachments.removeAll() totalPhotoAttachmentCount = 0 for task in photoLoadTasks.values { task.cancel() } photoLoadTasks.removeAll() for task in photoDownloadTasks.values { task.cancel() } photoDownloadTasks.removeAll() for task in photoBlurHashTasks.values { task.cancel() } photoBlurHashTasks.removeAll() downloadingAttachmentIds.removeAll() failedAttachmentIds.removeAll() photoContainer.layer.mask = nil updatePhotoUploadingOverlay(isVisible: false) photoOverflowOverlayView.isHidden = true photoOverflowLabel.isHidden = true photoOverflowLabel.text = nil for index in 0.. String { AttachmentPreviewCodec.downloadTag(from: preview) } private static func extractBlurHash(from preview: String) -> String { AttachmentPreviewCodec.blurHash(from: preview) } private static func cachedBlurHashImage(from preview: String) -> UIImage? { let hash = extractBlurHash(from: preview) guard !hash.isEmpty else { return nil } return blurHashCache.object(forKey: hash as NSString) } private static func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? { let crypto = CryptoManager.shared for password in passwords { guard let data = try? crypto.decryptWithPassword( encryptedString, password: password, requireCompression: true ) else { continue } if let image = parseImageData(data) { return image } } for password in passwords { guard let data = try? crypto.decryptWithPassword(encryptedString, password: password) else { continue } if let image = parseImageData(data) { return image } } return nil } private static func parseImageData(_ data: Data) -> UIImage? { if let str = String(data: data, encoding: .utf8) { if str.hasPrefix("data:"), let commaIndex = str.firstIndex(of: ",") { let base64Part = String(str[str.index(after: commaIndex)...]) if let imageData = Data(base64Encoded: base64Part), let image = AttachmentCache.downsampledImage(from: imageData) { return image } } else if let imageData = Data(base64Encoded: str), let image = AttachmentCache.downsampledImage(from: imageData) { return image } } return AttachmentCache.downsampledImage(from: data) } private func startSendingClockAnimation() { if clockFrameView.layer.animation(forKey: Self.sendingClockAnimationKey) == nil { let frameRotation = CABasicAnimation(keyPath: "transform.rotation.z") frameRotation.duration = 6.0 frameRotation.fromValue = NSNumber(value: Float(0)) frameRotation.toValue = NSNumber(value: Float(Double.pi * 2.0)) frameRotation.repeatCount = .infinity frameRotation.timingFunction = CAMediaTimingFunction(name: .linear) frameRotation.beginTime = 1.0 clockFrameView.layer.add(frameRotation, forKey: Self.sendingClockAnimationKey) } if clockMinView.layer.animation(forKey: Self.sendingClockAnimationKey) == nil { let minRotation = CABasicAnimation(keyPath: "transform.rotation.z") minRotation.duration = 1.0 minRotation.fromValue = NSNumber(value: Float(0)) minRotation.toValue = NSNumber(value: Float(Double.pi * 2.0)) minRotation.repeatCount = .infinity minRotation.timingFunction = CAMediaTimingFunction(name: .linear) minRotation.beginTime = 1.0 clockMinView.layer.add(minRotation, forKey: Self.sendingClockAnimationKey) } } private func stopSendingClockAnimation() { clockFrameView.layer.removeAnimation(forKey: Self.sendingClockAnimationKey) clockMinView.layer.removeAnimation(forKey: Self.sendingClockAnimationKey) } private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) { if isSentVisible && !wasSentCheckVisible { checkSentView.alpha = 1 checkSentView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3) UIView.animate( withDuration: 0.1, delay: 0, options: [.curveEaseOut, .beginFromCurrentState] ) { self.checkSentView.transform = .identity } } else if !isSentVisible { checkSentView.alpha = 1 checkSentView.transform = .identity } if isReadVisible && !wasReadCheckVisible { checkReadView.alpha = 1 checkReadView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3) UIView.animate( withDuration: 0.1, delay: 0, options: [.curveEaseOut, .beginFromCurrentState] ) { self.checkReadView.transform = .identity } } else if !isReadVisible { checkReadView.alpha = 1 checkReadView.transform = .identity } wasSentCheckVisible = isSentVisible wasReadCheckVisible = isReadVisible } private func updateStatusBackgroundVisibility() { guard let layout = currentLayout else { statusBackgroundView.isHidden = true return } // Telegram uses a dedicated status background on media messages. statusBackgroundView.isHidden = layout.messageType != .photo && layout.messageType != .emojiOnly } private func updateStatusBackgroundFrame() { guard !statusBackgroundView.isHidden else { return } var contentRect = timestampLabel.frame let statusNodes = [checkSentView, checkReadView, clockFrameView, clockMinView] for node in statusNodes where !node.isHidden { contentRect = contentRect.union(node.frame) } let insets = Self.statusBubbleInsets statusBackgroundView.frame = CGRect( x: contentRect.minX - insets.left, y: contentRect.minY - insets.top, width: contentRect.width + insets.left + insets.right, height: contentRect.height + insets.top + insets.bottom ) } private func bringStatusOverlayToFront() { bubbleView.bringSubviewToFront(statusBackgroundView) bubbleView.bringSubviewToFront(timestampLabel) bubbleView.bringSubviewToFront(checkSentView) bubbleView.bringSubviewToFront(checkReadView) bubbleView.bringSubviewToFront(clockFrameView) bubbleView.bringSubviewToFront(clockMinView) } #if DEBUG private func assertStatusLaneFramesValid(layout: MessageCellLayout) { // emojiOnly has no visible bubble — status pill floats below emoji guard layout.messageType != .emojiOnly else { return } let bubbleBounds = CGRect(origin: .zero, size: layout.bubbleSize) let frames = [ ("timestamp", layout.timestampFrame), ("checkSent", layout.checkSentFrame), ("checkRead", layout.checkReadFrame), ("clock", layout.clockFrame) ] for (name, frame) in frames { assert(frame.origin.x.isFinite && frame.origin.y.isFinite && frame.size.width.isFinite && frame.size.height.isFinite, "Status frame \(name) has non-finite values: \(frame)") assert(frame.width >= 0 && frame.height >= 0, "Status frame \(name) has negative size: \(frame)") guard !frame.isEmpty else { continue } let insetBounds = bubbleBounds.insetBy(dx: -1.0, dy: -1.0) assert(insetBounds.contains(frame), "Status frame \(name) is outside bubble bounds. frame=\(frame), bubble=\(bubbleBounds)") } } #endif // MARK: - Reuse override func prepareForReuse() { super.prepareForReuse() layer.removeAnimation(forKey: "insertionSlide") layer.removeAnimation(forKey: "insertionMove") contentView.layer.removeAnimation(forKey: "insertionAlpha") dateHeaderContainer.isHidden = true dateHeaderLabel.text = nil isInlineDateHeaderHidden = false message = nil actions = nil currentLayout = nil stopSendingClockAnimation() textLabel.textLayout = nil timestampLabel.text = nil checkSentView.image = nil checkSentView.isHidden = true checkSentView.alpha = 1 checkSentView.transform = .identity checkReadView.image = nil checkReadView.isHidden = true checkReadView.alpha = 1 checkReadView.transform = .identity clockFrameView.image = nil clockFrameView.isHidden = true clockMinView.image = nil clockMinView.isHidden = true wasSentCheckVisible = false wasReadCheckVisible = false statusBackgroundView.isHidden = true bubbleImageView.isHidden = false bubbleLayer.isHidden = false bubbleOutlineLayer.isHidden = false resetPhotoTiles() replyContainer.isHidden = true replyMessageId = nil highlightOverlay.alpha = 0 fileContainer.isHidden = true voiceView.isHidden = true cleanupVoiceBlob() callArrowView.isHidden = true callBackButton.isHidden = true groupInviteContainer.isHidden = true groupInviteString = nil currentInviteStatus = .notJoined inviteStatusTask?.cancel() inviteStatusTask = nil avatarImageView.image = nil avatarImageView.isHidden = true fileIconView.isHidden = false fileNameLabel.isHidden = false fileSizeLabel.isHidden = false forwardLabel.isHidden = true forwardAvatarView.isHidden = true forwardNameLabel.isHidden = true senderNameLabel.isHidden = true senderAdminIconView.isHidden = true senderAvatarContainer.isHidden = true senderAvatarInitialLabel.backgroundColor = .clear senderAvatarImageView.image = nil photoContainer.isHidden = true bubbleView.transform = .identity senderAvatarContainer.transform = .identity replyCircleView.alpha = 0 replyCircleView.transform = .identity replyIconView.alpha = 0 replyIconView.transform = .identity hasTriggeredSwipeHaptic = false swipeStartX = nil deliveryFailedButton.isHidden = true deliveryFailedButton.alpha = 0 isDeliveryFailedVisible = false // Selection: reset selected state on reuse, keep mode (same for all cells) isMessageSelected = false selectionCheckFill.isHidden = true selectionCheckmarkLayer.isHidden = true } // MARK: - Multi-Select func setSelectionMode(_ enabled: Bool, animated: Bool) { guard isInSelectionMode != enabled else { return } isInSelectionMode = enabled let newOffset: CGFloat = enabled ? 42 : 0 if animated { selectionCheckContainer.isHidden = false let fromAlpha: Float = enabled ? 0 : 1 let toAlpha: Float = enabled ? 1 : 0 let slideFrom = enabled ? -42.0 : 0.0 let slideTo = enabled ? 0.0 : -42.0 // Telegram: 0.2s easeOut for checkbox fade + slide let alphaAnim = CABasicAnimation(keyPath: "opacity") alphaAnim.fromValue = fromAlpha alphaAnim.toValue = toAlpha alphaAnim.duration = 0.2 alphaAnim.timingFunction = CAMediaTimingFunction(name: .easeOut) alphaAnim.fillMode = .forwards alphaAnim.isRemovedOnCompletion = false selectionCheckContainer.layer.add(alphaAnim, forKey: "selectionAlpha") let posAnim = CABasicAnimation(keyPath: "position.x") posAnim.fromValue = selectionCheckContainer.layer.position.x + slideFrom posAnim.toValue = selectionCheckContainer.layer.position.x + slideTo posAnim.duration = 0.2 posAnim.timingFunction = CAMediaTimingFunction(name: .easeOut) selectionCheckContainer.layer.add(posAnim, forKey: "selectionSlide") selectionCheckContainer.layer.opacity = toAlpha // Telegram: 0.2s easeOut for content shift selectionOffset = newOffset UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { self.setNeedsLayout() self.layoutIfNeeded() } completion: { _ in if !enabled { self.selectionCheckContainer.isHidden = true self.selectionCheckContainer.layer.removeAnimation(forKey: "selectionAlpha") self.selectionCheckContainer.layer.removeAnimation(forKey: "selectionSlide") self.selectionCheckContainer.layer.opacity = 1 } } } else { selectionOffset = newOffset selectionCheckContainer.isHidden = !enabled selectionCheckContainer.layer.opacity = enabled ? 1 : 0 setNeedsLayout() } } func setMessageSelected(_ selected: Bool, animated: Bool) { guard isMessageSelected != selected else { return } isMessageSelected = selected selectionCheckFill.isHidden = !selected selectionCheckmarkLayer.isHidden = !selected selectionCheckBorder.isHidden = selected if animated && selected { // Telegram CheckNode: 3-stage scale 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") selectionCheckmarkLayer.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") } } /// Called by NativeMessageList on every VoiceMessagePlayer progress tick for the active cell. func updateVoicePlayback(isPlaying: Bool, progress: CGFloat, currentTime: TimeInterval, duration: TimeInterval) { guard !voiceView.isHidden else { return } voiceView.updatePlaybackState(isPlaying: isPlaying, progress: progress) voiceView.updateDurationDuringPlayback(currentTime: currentTime, totalDuration: duration, isPlaying: isPlaying) updateVoiceBlobState(isPlaying: isPlaying) } // MARK: - Voice Blob (rendered in bubbleView, clipped to bubble bounds) private func updateVoiceBlobState(isPlaying: Bool) { if isPlaying { if voiceBlobView == nil { let blob = VoiceBlobView( frame: .zero, maxLevel: 0.3, smallBlobRange: (min: 0, max: 0), mediumBlobRange: (min: 0.7, max: 0.8), bigBlobRange: (min: 0.8, max: 0.9) ) let isDark = traitCollection.userInterfaceStyle == .dark let isOut = currentLayout?.isOutgoing ?? false let colors = RosettaColors.Voice.colors(isOutgoing: isOut, isDark: isDark) blob.setColor(colors.playButtonBg) // Even-odd mask to cut out the inner 44pt circle (ring only) let blobSize: CGFloat = 56 let maskLayer = CAShapeLayer() let fullRect = CGRect(origin: .zero, size: CGSize(width: blobSize, height: blobSize)) let path = UIBezierPath(rect: fullRect) let innerDiameter: CGFloat = 44 let innerOrigin = CGPoint(x: (blobSize - innerDiameter) / 2, y: (blobSize - innerDiameter) / 2) path.append(UIBezierPath(ovalIn: CGRect(origin: innerOrigin, size: CGSize(width: innerDiameter, height: innerDiameter)))) maskLayer.path = path.cgPath maskLayer.fillRule = .evenOdd blob.layer.mask = maskLayer // Insert below fileContainer so it's behind the play button bubbleView.insertSubview(blob, belowSubview: fileContainer) voiceBlobView = blob } // Force voiceView layout so playButton.frame is up-to-date voiceView.layoutIfNeeded() // Position blob centered on play button in bubbleView coords let playCenter = voiceView.convert(voiceView.playButtonCenter, to: bubbleView) let blobSize: CGFloat = 56 voiceBlobView?.frame = CGRect(x: playCenter.x - blobSize / 2, y: playCenter.y - blobSize / 2, width: blobSize, height: blobSize) voiceBlobView?.startAnimating() voiceBlobView?.updateLevel(0.2) } else { voiceBlobView?.stopAnimating() } } private func cleanupVoiceBlob() { voiceBlobView?.stopAnimating() voiceBlobView?.removeFromSuperview() voiceBlobView = nil } func voiceTransitionTargetFrame(in window: UIWindow) -> CGRect? { guard !voiceView.isHidden else { return nil } return voiceView.convert(voiceView.bounds, to: window) } func bubbleFrameInWindow(_ window: UIWindow) -> CGRect { bubbleView.convert(bubbleView.bounds, to: window) } } // MARK: - UIGestureRecognizerDelegate extension NativeMessageCell: UIGestureRecognizerDelegate { override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } if isInSelectionMode { return false } // No swipe in selection mode // If touch is on the waveform during playback, let the waveform's scrub gesture win if !voiceView.isHidden, voiceView.isScrubbingEnabled { let pointInVoice = pan.location(in: voiceView) if voiceView.waveformFrame.contains(pointInVoice) { return false } } let velocity = pan.velocity(in: contentView) // Telegram: only left swipe (negative velocity.x), clear horizontal dominance return velocity.x < 0 && abs(velocity.x) > abs(velocity.y) * 2.0 } } // MARK: - Bubble Path Cache /// Caches CGPath objects for bubble shapes to avoid recalculating Bezier paths every frame. /// Telegram equivalent: PrincipalThemeEssentialGraphics caches bubble images. final class BubblePathCache { static let shared = BubblePathCache() private let pathVersion = 9 private var cache: [String: CGPath] = [:] func path( size: CGSize, origin: CGPoint, mergeType: BubbleMergeType, isOutgoing: Bool, metrics: BubbleMetrics ) -> CGPath { let key = [ "v\(pathVersion)", "\(Int(size.width))x\(Int(size.height))", "ox\(Int(origin.x))", "oy\(Int(origin.y))", "\(mergeType)", "\(isOutgoing)", "r\(Int(metrics.mainRadius))", "m\(Int(metrics.auxiliaryRadius))", "t\(Int(metrics.tailProtrusion))", ].joined(separator: "_") if let cached = cache[key] { return cached } let rect = CGRect(origin: origin, size: size) let path = BubbleGeometryEngine.makeCGPath( in: rect, mergeType: mergeType, outgoing: isOutgoing, metrics: metrics ) cache[key] = path // Evict if cache grows too large if cache.count > 200 { cache.removeAll() } return path } }