eleven / messenger Public

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

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 if let copy = actions.onCopy {
69 menu.addItem(ClosureMenuItem(title: "Copy", handler: copy))
70 }
71 if actions.canEdit, let edit = actions.onEdit {
72 menu.addItem(ClosureMenuItem(title: "Edit…", handler: edit))
73 }
74 if let mute = actions.onMutePerson {
75 menu.addItem(ClosureMenuItem(title: actions.mutePersonTitle, handler: mute))
76 }
77 if let report = actions.onReport {
78 menu.addItem(ClosureMenuItem(title: "Report", handler: report))
79 }
80 if mine, let delete = actions.onDelete {
81 menu.addItem(.separator())
82 let item = ClosureMenuItem(title: "Delete…", handler: delete)
83 item.attributedTitle = NSAttributedString(
84 string: "Delete…",
85 attributes: [.foregroundColor: NSColor.systemRed,
86 .font: NSFont.menuFont(ofSize: 0)])
87 menu.addItem(item)
88 }
89 if let toggleTimes = actions.onToggleTimes {
90 menu.addItem(.separator())
91 menu.addItem(ClosureMenuItem(title: actions.timesShown ? "Hide Times" : "Show Times",
92 handler: toggleTimes))
93 }
94 return menu
95 }
96}
97
98// Passes left events through to whatever is beneath (the selectable Text) and
99// only exists, hit-testing-wise, for right/ctrl-clicks.
100final class RightClickCatcher: NSView {
101 var makeMenu: (() -> NSMenu)?
102
103 override func hitTest(_ point: NSPoint) -> NSView? {
104 guard bounds.contains(convert(point, from: superview)) else { return nil }
105 guard let event = NSApp.currentEvent else { return nil }
106 switch event.type {
107 case .rightMouseDown, .rightMouseUp:
108 return self
109 case .leftMouseDown where event.modifierFlags.contains(.control):
110 return self // ctrl-click is a context click too
111 default:
112 return nil // left clicks/drags belong to the text underneath
113 }
114 }
115
116 override func rightMouseDown(with event: NSEvent) { pop(event) }
117 override func mouseDown(with event: NSEvent) {
118 if event.modifierFlags.contains(.control) { pop(event) }
119 }
120
121 private func pop(_ event: NSEvent) {
122 guard let menu = makeMenu?() else { return }
123 NSMenu.popUpContextMenu(menu, with: event, for: self)
124 }
125
126 // ⌘T's entry (issue #392): the SAME menu a right-click gives, hung off the
127 // bubble's bottom-left instead of the pointer, so the Tapback row lands
128 // right under the message.
129 func popFromKeyboard() {
130 guard let menu = makeMenu?() else { return }
131 menu.popUp(positioning: nil, at: .zero, in: self)
132 }
133}
134
135// 🤖 One feed surface's message menus, addressed by message id — how ⌘T
136// reaches the right bubble's catcher without a global registry that two
137// mounted panes (or a feed and its open reply-thread view) could collide in.
138// Handed down the Environment, so MessageBubble's call sites are untouched.
139@MainActor
140public final class MessageMenuScope {
141 private struct Weak { weak var view: RightClickCatcher? }
142 private var catchers: [Int64: Weak] = [:]
143
144 public init() {}
145
146 func register(_ id: Int64, _ view: RightClickCatcher) {
147 catchers[id] = Weak(view: view)
148 }
149
150 /// Pop the message menu for `id` exactly as a right-click would.
151 /// False when that message has no catcher on screen — it scrolled out of
152 /// the lazy stack, or it's an action line, which has no menu at all.
153 @discardableResult
154 public func popUpMenu(for id: Int64) -> Bool {
155 // Rows are recycled constantly; prune on the way past rather than
156 // asking every torn-down bubble to unregister itself.
157 catchers = catchers.filter { $0.value.view?.window != nil }
158 guard let view = catchers[id]?.view else { return false }
159 view.popFromKeyboard()
160 return true
161 }
162}
163
164private struct MessageMenuScopeKey: EnvironmentKey {
165 static let defaultValue: MessageMenuScope? = nil
166}
167
168public extension EnvironmentValues {
169 /// The surface whose message menus ⌘T can open (nil = no keyboard route).
170 var messageMenuScope: MessageMenuScope? {
171 get { self[MessageMenuScopeKey.self] }
172 set { self[MessageMenuScopeKey.self] = newValue }
173 }
174}
175
176private final class ClosureMenuItem: NSMenuItem {
177 private let handler: () -> Void
178
179 init(title: String, handler: @escaping () -> Void) {
180 self.handler = handler
181 super.init(title: title, action: #selector(fire), keyEquivalent: "")
182 target = self
183 }
184
185 required init(coder: NSCoder) { fatalError("unused") }
186
187 @objc private func fire() { handler() }
188}
189
190#endif
191