eleven / messenger Public

Mac: faces + Open-first in the rail's overflow menu (#499)

* mac: faces + Open-first in the rail's overflow menu

Each space row in the … menu carries its face — the server photo
circle-cropped to 18pt, else the same name-hashed monogram the
notification faces use (MonogramImage) — and the submenu leads with Open
(the row's own action, spelled out) before Star/Archive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: menu faces need titleAndIcon; inert rows (Open is the opener); Esc closes the takeover

macOS menus render title-only by default — the face was silently dropped
without an explicit labelStyle. The row's primaryAction is gone (hover
reveals the submenu; Open is the single opener). Esc cancels the create
takeover via an invisible cancel-action button, gated on busy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Merge main; drop the invisible-button Esc (superseded by #505's monitor)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e27f0fdcd59b8a71cbac056603890e2e1d98b73a parent ca9d641
1 file changed, +44 −6
  • ios/LChatMac/Sidebar.swift +44 −6
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift
index f839425..d00558a 100644
--- a/ios/LChatMac/Sidebar.swift
+++ b/ios/LChatMac/Sidebar.swift
@@ -178,19 +178,57 @@ struct Sidebar: View {
.railTooltip("More")
}
- // A space as a menu row: click opens it; the submenu holds star/archive
- // (identity-gated, like the icon context menu).
+ // A space as a menu row — its face beside its name. The row itself is
+ // INERT (hover/click just reveals the submenu); Open is the only opener,
+ // then star/archive (identity-gated, like the icon context menu).
@ViewBuilder private func chatMenuItem(_ chat: SavedChat) -> some View {
if app.canStar(chat) {
- Menu(app.serverName(chat)) {
+ Menu {
+ Button("Open") { app.selection = chat.id }
+ Divider()
Button(app.isStarred(chat) ? "Unstar" : "Star") { app.toggleStar(chat) }
Button(app.isArchived(chat) ? "Unarchive" : "Archive") { app.toggleArchive(chat) }
- } primaryAction: {
- app.selection = chat.id
+ } label: {
+ menuRowLabel(chat)
}
} else {
- Button(app.serverName(chat)) { app.selection = chat.id }
+ Button { app.selection = chat.id } label: { menuRowLabel(chat) }
+ }
+ }
+
+ private func menuRowLabel(_ chat: SavedChat) -> some View {
+ Label {
+ Text(app.serverName(chat))
+ } icon: {
+ if let icon = menuIcon(chat) { Image(nsImage: icon) }
+ }
+ // macOS menus render title-only by default — the face needs an
+ // explicit titleAndIcon or it's silently dropped.
+ .labelStyle(.titleAndIcon)
+ }
+
+ // A small circular face for the menu row: the server photo, else the same
+ // monogram the notification faces use (MonogramImage — name-hashed colour,
+ // first initial). Menus want a fixed-size template-ish image, so draw the
+ // circle crop here rather than fighting SwiftUI clipping inside NSMenu.
+ private func menuIcon(_ chat: SavedChat) -> NSImage? {
+ let source: NSImage?
+ if let photo = app.avatars[chat.id] {
+ source = photo
+ } else if let data = MonogramImage.pngData(name: app.serverName(chat), size: 64) {
+ source = NSImage(data: data)
+ } else {
+ source = nil
}
+ guard let source else { return nil }
+ let side: CGFloat = 18
+ let img = NSImage(size: NSSize(width: side, height: side))
+ img.lockFocus()
+ NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: side, height: side)).addClip()
+ source.draw(in: NSRect(x: 0, y: 0, width: side, height: side),
+ from: .zero, operation: .sourceOver, fraction: 1)
+ img.unlockFocus()
+ return img
}