eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1#if canImport(AppKit) && !targetEnvironment(macCatalyst)
2import AppKit
3import SwiftUI
4
5// The Mac bubble menu, Messages-style: a real NSMenu whose Tapback emoji row
6// is a macOS 14 palette submenu (see buildMenu). It exists because SwiftUI's
7// .contextMenu and selectable text can't
8// coexist — the text system claims right-click for Look Up/Translate — so a
9// transparent catcher overlays the bubble, passes every LEFT event through
10// (drag-to-select keeps working underneath) and claims right/ctrl-click only.
11struct MessageRightClick: NSViewRepresentable {
12 let id: Int64
13 let actions: MessageActions
14 let mine: Bool
15 let timestamp: String
16
17 func makeNSView(context: Context) -> RightClickCatcher {
18 let view = RightClickCatcher()
19 wire(view, context: context)
20 return view
21 }
22
23 func updateNSView(_ view: RightClickCatcher, context: Context) {
24 wire(view, context: context)
25 }
26
27 // The catcher carries what it needs to pop the menu on its own — from a
28 // right-click, or from ⌘T reaching it through the surrounding surface's
29 // MessageMenuScope (issue #392).
30 private func wire(_ view: RightClickCatcher, context: Context) {
31 view.makeMenu = { Self.buildMenu(actions: actions, mine: mine, timestamp: timestamp) }
32 context.environment.messageMenuScope?.register(id, view)
33 }
34
35 static func buildMenu(actions: MessageActions, mine: Bool, timestamp: String) -> NSMenu {
36 let menu = NSMenu()
37 menu.autoenablesItems = false
38
39 if let react = actions.onReact {
40 // The Tapback row, as a macOS 14 PALETTE submenu: presentation
41 // style .palette lays its items out in a horizontal strip, and —
42 // the whole point — they are ORDINARY menu items rather than a
43 // hosted custom view. So AppKit treats them like any other item:
44 // ← → walk along the row, Return picks, and 1…6 fire as bare key
45 // equivalents. A custom-view row could never take a key at all
46 // (proven: AppKit skips view-bearing items when matching), which
47 // is why the emoji are rasterised into image labels here exactly
48 // as the SwiftUI ControlGroup palette does on iOS.
49 let palette = NSMenu()
50 palette.presentationStyle = .palette
51 for (index, emoji) in ChatFeedModel.allowedReactions.enumerated() {
52 let item = ClosureMenuItem(title: emoji, handler: { react(emoji) })
53 item.image = MessageBubble.emojiImage(emoji)
54 item.keyEquivalent = "\(index + 1)"
55 // A BARE digit: ⌘1…⌘9 already switch spaces. A palette has no
56 // shortcut column, so the number never shows — it just works.
57 item.keyEquivalentModifierMask = []
58 palette.addItem(item)
59 }
60 let row = NSMenuItem()
61 row.submenu = palette
62 menu.addItem(row)
63 menu.addItem(.separator())
64 }
65 if let reply = actions.onReply {
66 menu.addItem(ClosureMenuItem(title: "Reply…", handler: reply))
67 }
68 // Right after Reply, as on the web — the label alone tells them apart
69 // (the Mac menu carries no glyphs, so there's nothing icon-only here).
70 if let inThread = actions.onReplyInThread {
71 menu.addItem(ClosureMenuItem(title: "Reply in Thread…", handler: inThread))
72 }
73 // Only when there IS an inline chain under this message to move — the
74 // same gate the SwiftUI menu uses, so the two can't offer different
75 // things on the same bubble.
76 if let convert = actions.onConvertToThread {
77 menu.addItem(ClosureMenuItem(title: "Convert to Thread…", handler: convert))
78 }
79 if let copy = actions.onCopy {
80 menu.addItem(ClosureMenuItem(title: "Copy", handler: copy))
81 }
82 if actions.canEdit, let edit = actions.onEdit {
83 menu.addItem(ClosureMenuItem(title: "Edit…", handler: edit))
84 }
85 if let mute = actions.onMutePerson {
86 menu.addItem(ClosureMenuItem(title: actions.mutePersonTitle, handler: mute))
87 }
88 if let report = actions.onReport {
89 menu.addItem(ClosureMenuItem(title: "Report", handler: report))
90 }
91 if mine, let delete = actions.onDelete {
92 menu.addItem(.separator())
93 let item = ClosureMenuItem(title: "Delete…", handler: delete)
94 item.attributedTitle = NSAttributedString(
95 string: "Delete…",
96 attributes: [.foregroundColor: NSColor.systemRed,
97 .font: NSFont.menuFont(ofSize: 0)])
98 menu.addItem(item)
99 }
100 if let toggleTimes = actions.onToggleTimes {
101 menu.addItem(.separator())
102 menu.addItem(ClosureMenuItem(title: actions.timesShown ? "Hide Times" : "Show Times",
103 handler: toggleTimes))
104 }
105 return menu
106 }
107}
108
109// Passes left events through to whatever is beneath (the selectable Text) and
110// only exists, hit-testing-wise, for right/ctrl-clicks.
111final class RightClickCatcher: NSView {
112 var makeMenu: (() -> NSMenu)?
113
114 override func hitTest(_ point: NSPoint) -> NSView? {
115 guard bounds.contains(convert(point, from: superview)) else { return nil }
116 guard let event = NSApp.currentEvent else { return nil }
117 switch event.type {
118 case .rightMouseDown, .rightMouseUp:
119 return self
120 case .leftMouseDown where event.modifierFlags.contains(.control):
121 return self // ctrl-click is a context click too
122 default:
123 return nil // left clicks/drags belong to the text underneath
124 }
125 }
126
127 override func rightMouseDown(with event: NSEvent) { pop(event) }
128 override func mouseDown(with event: NSEvent) {
129 if event.modifierFlags.contains(.control) { pop(event) }
130 }
131
132 private func pop(_ event: NSEvent) {
133 guard let menu = makeMenu?() else { return }
134 NSMenu.popUpContextMenu(menu, with: event, for: self)
135 }
136
137 // ⌘T's entry (issue #392): the SAME menu a right-click gives, hung off the
138 // bubble's bottom-left instead of the pointer, so the Tapback row lands
139 // right under the message.
140 func popFromKeyboard() {
141 guard let menu = makeMenu?() else { return }
142 menu.popUp(positioning: nil, at: .zero, in: self)
143 }
144}
145
146// 🤖 One feed surface's message menus, addressed by message id — how ⌘T
147// reaches the right bubble's catcher without a global registry that two
148// mounted panes (or a feed and its open reply-thread view) could collide in.
149// Handed down the Environment, so MessageBubble's call sites are untouched.
150@MainActor
151public final class MessageMenuScope {
152 private struct Weak { weak var view: RightClickCatcher? }
153 private var catchers: [Int64: Weak] = [:]
154
155 public init() {}
156
157 func register(_ id: Int64, _ view: RightClickCatcher) {
158 catchers[id] = Weak(view: view)
159 }
160
161 /// Pop the message menu for `id` exactly as a right-click would.
162 /// False when that message has no catcher on screen — it scrolled out of
163 /// the lazy stack, or it's an action line, which has no menu at all.
164 @discardableResult
165 public func popUpMenu(for id: Int64) -> Bool {
166 // Rows are recycled constantly; prune on the way past rather than
167 // asking every torn-down bubble to unregister itself.
168 catchers = catchers.filter { $0.value.view?.window != nil }
169 guard let view = catchers[id]?.view else { return false }
170 view.popFromKeyboard()
171 return true
172 }
173}
174
175private struct MessageMenuScopeKey: EnvironmentKey {
176 static let defaultValue: MessageMenuScope? = nil
177}
178
179public extension EnvironmentValues {
180 /// The surface whose message menus ⌘T can open (nil = no keyboard route).
181 var messageMenuScope: MessageMenuScope? {
182 get { self[MessageMenuScopeKey.self] }
183 set { self[MessageMenuScopeKey.self] = newValue }
184 }
185}
186
187private final class ClosureMenuItem: NSMenuItem {
188 private let handler: () -> Void
189
190 init(title: String, handler: @escaping () -> Void) {
191 self.handler = handler
192 super.init(title: title, action: #selector(fire), keyEquivalent: "")
193 target = self
194 }
195
196 required init(coder: NSCoder) { fatalError("unused") }
197
198 @objc private func fire() { handler() }
199}
200
201#endif
202