| 1 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 2 | import AppKit |
| 3 | import 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. |
| 11 | struct 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. |
| 100 | final 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 |
| 140 | public 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 | |
| 164 | private struct MessageMenuScopeKey: EnvironmentKey { |
| 165 | static let defaultValue: MessageMenuScope? = nil |
| 166 | } |
| 167 | |
| 168 | public 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 | |
| 176 | private 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 | |