mac: render the ⋮ dropdowns as native macOS menus (#135)
On the Mac app the sidebar and thread-header kebab menus now open as a real NSMenu instead of the in-page HTML dropdown — same items, same actions. chat.js mirrors its live DOM menu to a new lchatMenu bridge (gated on the handler, so web/iOS are unchanged); native pops the menu and, on pick, clicks the real button so there's a single source of truth for each option.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3 files changed,
+86
−0
changelog/2026-07-04-mac-native-menus.md+1 −0ios/LChatMac/ChatFeedWebView.swift+49 −0web/static/chat.js+36 −0
diff --git a/changelog/2026-07-04-mac-native-menus.md b/changelog/2026-07-04-mac-native-menus.md| new file mode 100644 |
| index 0000000..17aedb0 |
| --- /dev/null |
| +++ b/changelog/2026-07-04-mac-native-menus.md |
| @@ -0,0 +1 @@ |
| +On the Mac app, the "⋮" menus (next to New chat, and in a chat's header) now open as proper macOS menus — the same options, doing the same things, but with native look, keyboard navigation, and placement. |
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift| index fbd770b..d5479c5 100644 |
| --- a/ios/LChatMac/ChatFeedWebView.swift |
| +++ b/ios/LChatMac/ChatFeedWebView.swift |
| @@ -47,6 +47,7 @@ struct ChatFeedWebView: NSViewRepresentable { |
| cfg.userContentController.add(context.coordinator, name: "lchatDrag") |
| cfg.userContentController.add(context.coordinator, name: "lchatNotify") |
| cfg.userContentController.add(context.coordinator, name: "lchatTheme") |
| + cfg.userContentController.add(context.coordinator, name: "lchatMenu") |
| cfg.userContentController.addUserScript( |
| WKUserScript(source: Self.injectedScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true) |
| ) |
| @@ -212,6 +213,7 @@ struct ChatFeedWebView: NSViewRepresentable { |
| switch message.name { |
| case "lchatDrag": moveWindow(message) |
| case "lchatNotify": showNotification(message) |
| + case "lchatMenu": showNativeMenu(message) |
| case "lchatTheme": |
| if let body = message.body as? [String: Any], let mode = body["mode"] as? String { |
| app.setAppearance(mode: mode, for: chat.id) |
| @@ -247,6 +249,53 @@ struct ChatFeedWebView: NSViewRepresentable { |
| ) |
| } |
| + // Render a web kebab dropdown as a real NSMenu. chat.js posts the visible |
| + // items (mirroring its own DOM menu) plus the button's rect; we pop up a |
| + // native menu and, on selection, call back so the web layer clicks the |
| + // real item — one source of truth for what each option does. A `.danger` |
| + // item (Leave / Delete) gets a leading separator, the native idiom for a |
| + // destructive action set off from the rest. |
| + private func showNativeMenu(_ message: WKScriptMessage) { |
| + guard let web = message.webView ?? webView, |
| + let body = message.body as? [String: Any], |
| + let token = body["token"] as? Int, |
| + let rawItems = body["items"] as? [[String: Any]] else { return } |
| + let menu = NSMenu() |
| + menu.autoenablesItems = false |
| + for raw in rawItems { |
| + guard let id = raw["id"] as? String, let label = raw["label"] as? String else { continue } |
| + if (raw["danger"] as? Bool) == true, let last = menu.items.last, !last.isSeparatorItem { |
| + menu.addItem(.separator()) |
| + } |
| + let item = NSMenuItem(title: label, action: #selector(nativeMenuPicked(_:)), keyEquivalent: "") |
| + item.target = self |
| + item.representedObject = ["token": token, "id": id, "web": web] |
| + menu.addItem(item) |
| + } |
| + guard !menu.items.isEmpty else { return } |
| + // Drop the menu from the button's bottom-left. The web rect is |
| + // top-left origin / y-down; convert into the webview's coordinate |
| + // space (which may or may not be flipped). |
| + let anchor = body["anchor"] as? [String: Any] |
| + let x = (anchor?["x"] as? Double) ?? 0 |
| + let bottom = ((anchor?["y"] as? Double) ?? 0) + ((anchor?["h"] as? Double) ?? 0) |
| + let point = NSPoint(x: x, y: web.isFlipped ? bottom : web.bounds.height - bottom) |
| + // popUp blocks until the menu closes; the item action (if any) has |
| + // fired by then. The trailing null-pick clears the pending token on |
| + // dismissal, and is a no-op if an item was already picked. |
| + menu.popUp(positioning: nil, at: point, in: web) |
| + web.evaluateJavaScript("window.__lchatMenuPick && window.__lchatMenuPick(\(token), null)", completionHandler: nil) |
| + } |
| + |
| + @objc private func nativeMenuPicked(_ sender: NSMenuItem) { |
| + guard let info = sender.representedObject as? [String: Any], |
| + let token = info["token"] as? Int, |
| + let id = info["id"] as? String, |
| + let web = info["web"] as? WKWebView else { return } |
| + let safeID = id.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "'", with: "\\'") |
| + web.evaluateJavaScript("window.__lchatMenuPick && window.__lchatMenuPick(\(token), '\(safeID)')", completionHandler: nil) |
| + } |
| + |
| // After the origin loads, if this origin has no stored session, claim the |
| // invite once via the join link. Guarded so we never loop or re-claim. |
| func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { |
diff --git a/web/static/chat.js b/web/static/chat.js| index d97e4a4..5a6e6c6 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -343,6 +343,40 @@ function notifyError(msg) { |
| else alert(msg); |
| } |
| +// Native dropdown menus (macOS app only). The Mac shell registers an "lchatMenu" |
| +// handler; when present, a kebab renders as a real NSMenu instead of the in-page |
| +// HTML menu. We mirror the live DOM menu — each visible .menu-item becomes a |
| +// native item — and picking one just .click()s the real button, so there's a |
| +// single source of truth for what every option does and says. |
| +const nativeMenu = |
| + (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lchatMenu) || null; |
| +let nativeMenuSeq = 0; |
| +const nativeMenuPending = new Set(); |
| + |
| +// Pop up `menuEl`'s items as a native menu, anchored under `anchorEl`. Skips |
| +// items the web layer has hidden (inline display:none — group-only entries, the |
| +// notifications toggle where push is unusable), so the native menu matches |
| +// exactly what the HTML one would show. |
| +function openNativeMenu(menuEl, anchorEl) { |
| + const items = [...menuEl.querySelectorAll(".menu-item")] |
| + .filter((el) => el.style.display !== "none") |
| + .map((el) => ({ id: el.id, label: el.textContent.trim(), danger: el.classList.contains("danger") })); |
| + if (!items.length) return; |
| + const token = ++nativeMenuSeq; |
| + nativeMenuPending.add(token); |
| + const r = anchorEl.getBoundingClientRect(); |
| + nativeMenu.postMessage({ token, items, anchor: { x: r.left, y: r.top, w: r.width, h: r.height } }); |
| +} |
| + |
| +// Called by the Mac shell after the user picks an item (id) or dismisses the |
| +// menu (id null). Clicking the real button runs the identical handler the HTML |
| +// item would have. Idempotent: an unknown/spent token is ignored. |
| +window.__lchatMenuPick = (token, id) => { |
| + if (!nativeMenuPending.delete(token) || !id) return; |
| + const item = document.getElementById(id); |
| + if (item) item.click(); |
| +}; |
| + |
| // App state. |
| let roster = []; // [{user_id, label, public_key}] |
| let myUserId = ""; |
| @@ -976,6 +1010,7 @@ function renderApp() { |
| const closeMenu = () => menu.classList.add("hidden"); |
| menuBtn.onclick = (e) => { |
| e.stopPropagation(); |
| + if (nativeMenu) return openNativeMenu(menu, menuBtn); // native NSMenu on the Mac app |
| menu.classList.toggle("hidden"); |
| }; |
| document.addEventListener("click", (e) => { |
| @@ -1023,6 +1058,7 @@ function renderApp() { |
| const closeTMenu = () => tmenu.classList.add("hidden"); |
| tmenuBtn.onclick = (e) => { |
| e.stopPropagation(); |
| + if (nativeMenu) return openNativeMenu(tmenu, tmenuBtn); // native NSMenu on the Mac app |
| tmenu.classList.toggle("hidden"); |
| }; |
| document.addEventListener("click", (e) => { |