eleven / messenger Public

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>
Paul Campbell d37cab855538d939f747f480737b891395af1d43 parent be9c09d
3 files changed, +86 −0
  • changelog/2026-07-04-mac-native-menus.md +1 −0
  • ios/LChatMac/ChatFeedWebView.swift +49 −0
  • web/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) => {