eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import UIKit
2
3// The long-press message menu, OWNED — no UIContextMenuInteraction anywhere.
4// The 2026-07-21 hunt proved every system route broken for chat bubbles on
5// device: the collection view's item pipeline platters the whole CELL (the
6// "aura", immune to every preview API), a manual interaction on the list is
7// starved by the built-in one, and per-cell interactions never fire under
8// the hosted SwiftUI gesture graph. Messages-grade behaviour needs the
9// bubble to be the star, so this draws the web app's menu itself: dim the
10// feed, float the bubble's own pixels where they already are, the reaction
11// palette above, a labelled menu card below. Tap anywhere else dismisses.
12@MainActor
13final class FeedMenuOverlay: UIView {
14 struct Item {
15 let title: String
16 let icon: String
17 var destructive = false
18 let handler: () -> Void
19 }
20
21 private let onDismiss: () -> Void
22 private var actionAfterDismiss: (() -> Void)?
23
24 init(container: UIView, bubbleImage: UIImage, bubbleFrame: CGRect,
25 mine: Bool, reactions: [String], onReact: ((String) -> Void)?,
26 items: [Item], onDismiss: @escaping () -> Void) {
27 self.onDismiss = onDismiss
28 super.init(frame: container.bounds)
29 autoresizingMask = [.flexibleWidth, .flexibleHeight]
30
31 let dim = UIView(frame: bounds)
32 dim.autoresizingMask = [.flexibleWidth, .flexibleHeight]
33 dim.backgroundColor = UIColor.black.withAlphaComponent(0.35)
34 dim.alpha = 0
35 addSubview(dim)
36 self.dim = dim
37
38 let bubble = UIImageView(image: bubbleImage)
39 bubble.frame = bubbleFrame
40 bubble.layer.shadowColor = UIColor.black.cgColor
41 bubble.layer.shadowOpacity = 0.25
42 bubble.layer.shadowRadius = 18
43 bubble.layer.shadowOffset = CGSize(width: 0, height: 6)
44 addSubview(bubble)
45
46 // The palette: the web's reaction row, a capsule of emoji above the
47 // bubble, aligned to its speaking side.
48 var palette: UIView?
49 if let onReact, !reactions.isEmpty {
50 let stack = UIStackView()
51 stack.axis = .horizontal
52 stack.spacing = 2
53 for emoji in reactions {
54 // .custom, not .system: system buttons TINT their titles,
55 // rendering emoji as monochrome boxes.
56 let b = UIButton(type: .custom)
57 b.setTitle(emoji, for: .normal)
58 b.titleLabel?.font = .systemFont(ofSize: 26)
59 b.accessibilityLabel = "React \(emoji)"
60 b.addAction(UIAction { [weak self] _ in
61 self?.actionAfterDismiss = { onReact(emoji) }
62 self?.dismiss()
63 }, for: .touchUpInside)
64 b.widthAnchor.constraint(equalToConstant: 44).isActive = true
65 b.heightAnchor.constraint(equalToConstant: 44).isActive = true
66 stack.addArrangedSubview(b)
67 }
68 let card = Self.card()
69 card.layer.cornerRadius = 25
70 stack.translatesAutoresizingMaskIntoConstraints = false
71 card.contentView.addSubview(stack)
72 NSLayoutConstraint.activate([
73 stack.topAnchor.constraint(equalTo: card.contentView.topAnchor, constant: 3),
74 stack.bottomAnchor.constraint(equalTo: card.contentView.bottomAnchor, constant: -3),
75 stack.leadingAnchor.constraint(equalTo: card.contentView.leadingAnchor, constant: 6),
76 stack.trailingAnchor.constraint(equalTo: card.contentView.trailingAnchor, constant: -6),
77 ])
78 addSubview(card)
79 palette = card
80 }
81
82 // The menu card: labelled rows, destructive tinted, separators.
83 var menuCard: UIView?
84 if !items.isEmpty {
85 let stack = UIStackView()
86 stack.axis = .vertical
87 stack.spacing = 0
88 for (i, item) in items.enumerated() {
89 if i > 0 {
90 let sep = UIView()
91 sep.backgroundColor = .separator
92 sep.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
93 stack.addArrangedSubview(sep)
94 }
95 var config = UIButton.Configuration.plain()
96 config.title = item.title
97 config.image = UIImage(systemName: item.icon)
98 config.imagePlacement = .trailing
99 config.baseForegroundColor = item.destructive ? .systemRed : .label
100 config.contentInsets = NSDirectionalEdgeInsets(
101 top: 12, leading: 16, bottom: 12, trailing: 16)
102 let b = UIButton(configuration: config)
103 b.contentHorizontalAlignment = .fill
104 b.addAction(UIAction { [weak self] _ in
105 self?.actionAfterDismiss = item.handler
106 self?.dismiss()
107 }, for: .touchUpInside)
108 stack.addArrangedSubview(b)
109 }
110 let card = Self.card()
111 card.layer.cornerRadius = 14
112 stack.translatesAutoresizingMaskIntoConstraints = false
113 card.contentView.addSubview(stack)
114 NSLayoutConstraint.activate([
115 stack.topAnchor.constraint(equalTo: card.contentView.topAnchor, constant: 4),
116 stack.bottomAnchor.constraint(equalTo: card.contentView.bottomAnchor, constant: -4),
117 stack.leadingAnchor.constraint(equalTo: card.contentView.leadingAnchor),
118 stack.trailingAnchor.constraint(equalTo: card.contentView.trailingAnchor),
119 card.widthAnchor.constraint(equalToConstant: 250),
120 ])
121 addSubview(card)
122 menuCard = card
123 }
124
125 // Place palette above / menu below the bubble on its speaking side,
126 // clamped to the container (and nudging the bubble when a tall
127 // bubble + menu can't otherwise fit).
128 let pad: CGFloat = 8
129 let safe = container.safeAreaInsets
130 let paletteSize = palette?.systemLayoutSizeFitting(
131 UIView.layoutFittingCompressedSize) ?? .zero
132 let menuSize = menuCard?.systemLayoutSizeFitting(
133 UIView.layoutFittingCompressedSize) ?? .zero
134 var bFrame = bubbleFrame
135 let needAbove = paletteSize.height + pad
136 let needBelow = menuSize.height + pad
137 let topLimit = safe.top + 8
138 let bottomLimit = bounds.height - safe.bottom - 8
139 bFrame.origin.y = min(max(bFrame.origin.y, topLimit + needAbove),
140 bottomLimit - needBelow - bFrame.height)
141 bubble.frame = bFrame
142 func xAligned(_ w: CGFloat) -> CGFloat {
143 let x = mine ? bFrame.maxX - w : bFrame.minX
144 return min(max(x, 8), bounds.width - w - 8)
145 }
146 palette?.frame = CGRect(
147 x: xAligned(paletteSize.width),
148 y: bFrame.minY - pad - paletteSize.height,
149 width: paletteSize.width, height: paletteSize.height)
150 menuCard?.frame = CGRect(
151 x: xAligned(menuSize.width),
152 y: bFrame.maxY + pad,
153 width: menuSize.width, height: menuSize.height)
154
155 // Entrance: dim fades, palette/menu grow in from the bubble.
156 let reduceMotion = UIAccessibility.isReduceMotionEnabled
157 for card in [palette, menuCard].compactMap({ $0 }) {
158 card.alpha = 0
159 if !reduceMotion {
160 card.transform = CGAffineTransform(scaleX: 0.6, y: 0.6)
161 .translatedBy(x: 0, y: mine ? 0 : 0)
162 }
163 }
164 UIView.animate(withDuration: reduceMotion ? 0.1 : 0.22, delay: 0,
165 usingSpringWithDamping: 0.86, initialSpringVelocity: 0) {
166 dim.alpha = 1
167 for card in [palette, menuCard].compactMap({ $0 }) {
168 card.alpha = 1
169 card.transform = .identity
170 }
171 }
172
173 let tap = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped(_:)))
174 tap.cancelsTouchesInView = false
175 addGestureRecognizer(tap)
176
177 isAccessibilityElement = false
178 accessibilityViewIsModal = true
179 UIAccessibility.post(notification: .screenChanged, argument: palette ?? menuCard)
180 }
181
182 required init?(coder: NSCoder) { fatalError("unused") }
183
184 // A material card (palette capsule / menu list) — one look for both.
185 private static func card() -> UIVisualEffectView {
186 let v = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
187 v.clipsToBounds = true
188 return v
189 }
190
191 private weak var dim: UIView?
192
193 @objc private func backgroundTapped(_ g: UITapGestureRecognizer) {
194 // Card taps are handled by their buttons; anywhere else dismisses.
195 let p = g.location(in: self)
196 for sub in subviews where sub !== dim && sub.frame.contains(p) { return }
197 dismiss()
198 }
199
200 func dismiss() {
201 let after = actionAfterDismiss
202 actionAfterDismiss = nil
203 UIView.animate(withDuration: 0.16, animations: {
204 self.alpha = 0
205 }, completion: { _ in
206 self.removeFromSuperview()
207 self.onDismiss()
208 after?()
209 })
210 }
211}
212