import UIKit // The long-press message menu, OWNED — no UIContextMenuInteraction anywhere. // The 2026-07-21 hunt proved every system route broken for chat bubbles on // device: the collection view's item pipeline platters the whole CELL (the // "aura", immune to every preview API), a manual interaction on the list is // starved by the built-in one, and per-cell interactions never fire under // the hosted SwiftUI gesture graph. Messages-grade behaviour needs the // bubble to be the star, so this draws the web app's menu itself: dim the // feed, float the bubble's own pixels where they already are, the reaction // palette above, a labelled menu card below. Tap anywhere else dismisses. @MainActor final class FeedMenuOverlay: UIView { struct Item { let title: String let icon: String var destructive = false let handler: () -> Void } private let onDismiss: () -> Void private var actionAfterDismiss: (() -> Void)? init(container: UIView, bubbleImage: UIImage, bubbleFrame: CGRect, mine: Bool, reactions: [String], onReact: ((String) -> Void)?, items: [Item], onDismiss: @escaping () -> Void) { self.onDismiss = onDismiss super.init(frame: container.bounds) autoresizingMask = [.flexibleWidth, .flexibleHeight] let dim = UIView(frame: bounds) dim.autoresizingMask = [.flexibleWidth, .flexibleHeight] dim.backgroundColor = UIColor.black.withAlphaComponent(0.35) dim.alpha = 0 addSubview(dim) self.dim = dim let bubble = UIImageView(image: bubbleImage) bubble.frame = bubbleFrame bubble.layer.shadowColor = UIColor.black.cgColor bubble.layer.shadowOpacity = 0.25 bubble.layer.shadowRadius = 18 bubble.layer.shadowOffset = CGSize(width: 0, height: 6) addSubview(bubble) // The palette: the web's reaction row, a capsule of emoji above the // bubble, aligned to its speaking side. var palette: UIView? if let onReact, !reactions.isEmpty { let stack = UIStackView() stack.axis = .horizontal stack.spacing = 2 for emoji in reactions { // .custom, not .system: system buttons TINT their titles, // rendering emoji as monochrome boxes. let b = UIButton(type: .custom) b.setTitle(emoji, for: .normal) b.titleLabel?.font = .systemFont(ofSize: 26) b.accessibilityLabel = "React \(emoji)" b.addAction(UIAction { [weak self] _ in self?.actionAfterDismiss = { onReact(emoji) } self?.dismiss() }, for: .touchUpInside) b.widthAnchor.constraint(equalToConstant: 44).isActive = true b.heightAnchor.constraint(equalToConstant: 44).isActive = true stack.addArrangedSubview(b) } let card = Self.card() card.layer.cornerRadius = 25 stack.translatesAutoresizingMaskIntoConstraints = false card.contentView.addSubview(stack) NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: card.contentView.topAnchor, constant: 3), stack.bottomAnchor.constraint(equalTo: card.contentView.bottomAnchor, constant: -3), stack.leadingAnchor.constraint(equalTo: card.contentView.leadingAnchor, constant: 6), stack.trailingAnchor.constraint(equalTo: card.contentView.trailingAnchor, constant: -6), ]) addSubview(card) palette = card } // The menu card: labelled rows, destructive tinted, separators. var menuCard: UIView? if !items.isEmpty { let stack = UIStackView() stack.axis = .vertical stack.spacing = 0 for (i, item) in items.enumerated() { if i > 0 { let sep = UIView() sep.backgroundColor = .separator sep.heightAnchor.constraint(equalToConstant: 0.5).isActive = true stack.addArrangedSubview(sep) } var config = UIButton.Configuration.plain() config.title = item.title config.image = UIImage(systemName: item.icon) config.imagePlacement = .trailing config.baseForegroundColor = item.destructive ? .systemRed : .label config.contentInsets = NSDirectionalEdgeInsets( top: 12, leading: 16, bottom: 12, trailing: 16) let b = UIButton(configuration: config) b.contentHorizontalAlignment = .fill b.addAction(UIAction { [weak self] _ in self?.actionAfterDismiss = item.handler self?.dismiss() }, for: .touchUpInside) stack.addArrangedSubview(b) } let card = Self.card() card.layer.cornerRadius = 14 stack.translatesAutoresizingMaskIntoConstraints = false card.contentView.addSubview(stack) NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: card.contentView.topAnchor, constant: 4), stack.bottomAnchor.constraint(equalTo: card.contentView.bottomAnchor, constant: -4), stack.leadingAnchor.constraint(equalTo: card.contentView.leadingAnchor), stack.trailingAnchor.constraint(equalTo: card.contentView.trailingAnchor), card.widthAnchor.constraint(equalToConstant: 250), ]) addSubview(card) menuCard = card } // Place palette above / menu below the bubble on its speaking side, // clamped to the container (and nudging the bubble when a tall // bubble + menu can't otherwise fit). let pad: CGFloat = 8 let safe = container.safeAreaInsets let paletteSize = palette?.systemLayoutSizeFitting( UIView.layoutFittingCompressedSize) ?? .zero let menuSize = menuCard?.systemLayoutSizeFitting( UIView.layoutFittingCompressedSize) ?? .zero var bFrame = bubbleFrame let needAbove = paletteSize.height + pad let needBelow = menuSize.height + pad let topLimit = safe.top + 8 let bottomLimit = bounds.height - safe.bottom - 8 bFrame.origin.y = min(max(bFrame.origin.y, topLimit + needAbove), bottomLimit - needBelow - bFrame.height) bubble.frame = bFrame func xAligned(_ w: CGFloat) -> CGFloat { let x = mine ? bFrame.maxX - w : bFrame.minX return min(max(x, 8), bounds.width - w - 8) } palette?.frame = CGRect( x: xAligned(paletteSize.width), y: bFrame.minY - pad - paletteSize.height, width: paletteSize.width, height: paletteSize.height) menuCard?.frame = CGRect( x: xAligned(menuSize.width), y: bFrame.maxY + pad, width: menuSize.width, height: menuSize.height) // Entrance: dim fades, palette/menu grow in from the bubble. let reduceMotion = UIAccessibility.isReduceMotionEnabled for card in [palette, menuCard].compactMap({ $0 }) { card.alpha = 0 if !reduceMotion { card.transform = CGAffineTransform(scaleX: 0.6, y: 0.6) .translatedBy(x: 0, y: mine ? 0 : 0) } } UIView.animate(withDuration: reduceMotion ? 0.1 : 0.22, delay: 0, usingSpringWithDamping: 0.86, initialSpringVelocity: 0) { dim.alpha = 1 for card in [palette, menuCard].compactMap({ $0 }) { card.alpha = 1 card.transform = .identity } } let tap = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped(_:))) tap.cancelsTouchesInView = false addGestureRecognizer(tap) isAccessibilityElement = false accessibilityViewIsModal = true UIAccessibility.post(notification: .screenChanged, argument: palette ?? menuCard) } required init?(coder: NSCoder) { fatalError("unused") } // A material card (palette capsule / menu list) — one look for both. private static func card() -> UIVisualEffectView { let v = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) v.clipsToBounds = true return v } private weak var dim: UIView? @objc private func backgroundTapped(_ g: UITapGestureRecognizer) { // Card taps are handled by their buttons; anywhere else dismisses. let p = g.location(in: self) for sub in subviews where sub !== dim && sub.frame.contains(p) { return } dismiss() } func dismiss() { let after = actionAfterDismiss actionAfterDismiss = nil UIView.animate(withDuration: 0.16, animations: { self.alpha = 0 }, completion: { _ in self.removeFromSuperview() self.onDismiss() after?() }) } }