eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import UniformTypeIdentifiers
4
5// Native layout: a fixed server rail on the left, the selected instance's
6// NATIVE chat surface on the right. A plain HStack (not NavigationSplitView)
7// keeps the rail always visible — no collapse / "Hide Sidebar" toggle. The
8// title bar is hidden (LChatMacApp); the rail's top and the chat headers act
9// as the drag region (NativeChatView's window-drag monitor).
10struct RootView: View {
11 @EnvironmentObject var app: MacAppState
12
13 // Chats whose pane has been created. Once opened, a pane stays mounted
14 // (just hidden) so switching back is instant — models stay parked, sockets live.
15 @State private var activated: Set<SavedChat.ID> = []
16
17 // Drives the first-run "Sign in" ceremony (empty rail only). Settings owns
18 // its own MacHomeSignIn; both read the same Keychain session.
19 @StateObject private var firstRunSignIn = MacHomeSignIn()
20
21 // The create-a-space takeover (the rail's + / File ▸ New Space, via
22 // app.createSpaceTick). Non-nil = the instrument owns the window.
23 @State private var createModel: CreateSpaceModel?
24 // Spaces ▸ Add from link… (via app.addFromLinkTick).
25 @State private var showAddFromLink = false
26 // Esc-to-close for the takeover, installed only while it's up. A local
27 // NSEvent monitor, not a SwiftUI key hook: the webview behind still holds
28 // first responder, so .onKeyPress/.onExitCommand never fire here.
29 @State private var createEscMonitor: Any?
30
31 // The detail area's window frame, used ONLY to turn the SwiftUI
32 // safety-net's region-local drop point into the global point the router
33 // speaks (see PaneDropDelegate.performDrop). A LiveFrame, read lazily at
34 // drop time — never a tracked `onChange(of: CGRect)` (window-resize
35 // churn, #899).
36 @State private var detailFrame = LiveFrame()
37
38 // The goes-to-eleven create flow. The WHOLE pipeline (provision → claim →
39 // setup_complete) runs inside `create`, under "Building your space" — the
40 // instance is persisted (and vault-pushed) before success is ever shown.
41 private func makeCreateModel() -> CreateSpaceModel {
42 CreateSpaceModel(
43 create: { [weak app] in
44 // Carry the name you already go by (your rail's first space).
45 let screenName = await MainActor.run {
46 app?.instancesInRailOrder().first(where: { !$0.isArchived })?.screenName
47 ?? app?.instances.first?.screenName ?? ""
48 }
49 let inst = try await SpaceCreationFlow.run(screenName: screenName)
50 await MainActor.run { app?.addInstance(inst) }
51 return inst
52 },
53 finish: { _, _ in createModel = nil },
54 cancel: { createModel = nil })
55 }
56
57 var body: some View {
58 HStack(spacing: 0) {
59 Sidebar()
60 .frame(width: 76)
61 Divider()
62 detail
63 }
64 .ignoresSafeArea()
65 .coordinateSpace(.named(RailTooltip.space))
66 // The rail is narrow and clips, so the hover label is drawn here, over the
67 // whole window, positioned to the right of the hovered icon.
68 .overlayPreferenceValue(RailTooltipKey.self) { info in
69 Group {
70 if let info {
71 RailTooltipLabel(text: info.text)
72 .offset(x: 64, y: info.y - 13)
73 }
74 }
75 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
76 .allowsHitTesting(false)
77 }
78 // Create-a-space takes over the WHOLE window (Paul's call — the sheet
79 // read small; the haze and dust want the full stage). Item-based
80 // (model != nil), so there's no present-before-content race.
81 .overlay {
82 if let model = createModel {
83 CreateSpaceView(model: model)
84 .frame(maxWidth: .infinity, maxHeight: .infinity)
85 .background(Color(nsColor: .windowBackgroundColor))
86 .transition(.opacity)
87 // Esc backs out exactly where the Cancel button would
88 // (model.canCancel — never mid-create). The takeover owns
89 // the key while it's up: even when cancelling is gated
90 // off, Esc must not leak to the webview underneath.
91 .onAppear {
92 createEscMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
93 guard event.keyCode == 53 else { return event } // Esc
94 model.requestCancel()
95 return nil
96 }
97 }
98 .onDisappear {
99 if let createEscMonitor { NSEvent.removeMonitor(createEscMonitor) }
100 createEscMonitor = nil
101 }
102 }
103 }
104 .animation(.easeOut(duration: 0.18), value: createModel == nil)
105 .onChange(of: app.createSpaceTick) { _, _ in createModel = makeCreateModel() }
106 // Spaces ▸ Add from link… — the native paste-a-link claim.
107 .onChange(of: app.addFromLinkTick) { _, _ in showAddFromLink = true }
108 // …and the browser handoff pre-fills the same sheet (onOpenURL's
109 // elevenmessenger://add — the sheet is the confirming click).
110 .onChange(of: app.pendingAddLink) { _, link in
111 if link != nil { showAddFromLink = true }
112 }
113 .sheet(isPresented: $showAddFromLink) { AddFromLinkSheet() }
114 // ⌘K quick-switch palette, drawn over the whole window (above the web feed).
115 .overlay { if app.showPalette { CommandPalette() } }
116 // The photo / video lightboxes, zooming out of their bubble over black.
117 .overlay {
118 if let item = app.lightbox {
119 LightboxOverlay(item: item) { app.lightbox = nil }
120 }
121 }
122 .overlay {
123 if let item = app.videoLightbox {
124 VideoLightboxOverlay(item: item) { app.videoLightbox = nil }
125 }
126 }
127 .overlay {
128 if let item = app.youtubeLightbox {
129 YouTubeLightboxOverlay(item: item) { app.youtubeLightbox = nil }
130 }
131 }
132 // Drop-to-attach for the WHOLE window. An AppKit catcher, not a
133 // SwiftUI .onDrop: AppKit hands a drag to the deepest REGISTERED view
134 // under the cursor, so the composer's NSTextView (whose default drop
135 // pastes the file's PATH as text) and any parked pane's WKWebView beat
136 // the hosting view's region wherever they overlapped it — the "drops
137 // only catch in a strip above the composer" bug. Topmost in the view
138 // tree, this catcher wins everywhere; clicks fall straight through it.
139 // No hover overlay — the drag's + badge is affordance enough.
140 //
141 // One region for the whole window means the REGION can't say where a
142 // drop landed — so the location rides along with the pasteboard and
143 // the receiving feed decides from it which composer stages the file.
144 .overlay {
145 WindowDropZone(
146 // The create takeover owns the window; a drop would stage
147 // invisibly into the pane underneath it.
148 canDrop: { createModel == nil && PaneDropRouter.shared.canDrop(over: selectedPaneKey(app)) },
149 perform: { PaneDropRouter.shared.deliver($0, at: $1, over: selectedPaneKey(app)) })
150 }
151 }
152
153 private func nativeInstance(for chat: SavedChat) -> SavedInstance? {
154 guard let origin = chat.origin?.absoluteString else { return nil }
155 return app.instances.first { $0.origin == origin }
156 }
157
158 // A rail entry with no native identity: signing in again pulls the key
159 // from the member's Eleven account; there is deliberately no web fallback.
160 // Sign-in leads, but it is never the only rung — a one-rung ladder reads
161 // as "the app is broken", so the pane always offers the add-device link
162 // and re-invite paths too, and every failure leaves them in reach
163 // (accounts-and-recovery.md, phase 0). Removal stays as the destructive
164 // escape hatch.
165 private func lockedPane(_ chat: SavedChat) -> some View {
166 ContentUnavailableView {
167 Label(app.serverName(chat), systemImage: "lock")
168 } description: {
169 Text("This space can't be unlocked on this Mac yet. Sign in with Eleven to bring its key back, link it from another device, or ask a member for a fresh invite.")
170 } actions: {
171 if firstRunSignIn.phase.busy {
172 ProgressView().controlSize(.small)
173 } else {
174 Button("Sign In with Eleven…") {
175 Task { await firstRunSignIn.signIn(into: app) }
176 }
177 .buttonStyle(.borderedProminent)
178 }
179 Button("Link from Your iPhone…") {
180 app.requestAddFromLink(hint: "On your iPhone (or anywhere you're already in this space), open the space menu, choose Add a device, and bring the link here — it signs this Mac in as you, with your history.")
181 }
182 Button("Ask for a New Invite…") {
183 app.requestAddFromLink(hint: "Any member of this space can send you a fresh invite link — add it here. You'll rejoin as a new member.")
184 }
185 Button("Remove from This Mac", role: .destructive) { app.remove(chat) }
186 if case .failed(let msg) = firstRunSignIn.phase {
187 Text(msg).font(.footnote).foregroundStyle(.red)
188 .frame(maxWidth: 420)
189 } else if firstRunSignIn.phase.signedIn {
190 // Signed in, yet this pane still renders: the account didn't
191 // hold a key for this space — say so instead of dead-ending.
192 Text("You're signed in, but that account doesn't hold a key for this space. Link it from a device that's already in, or ask a member for a fresh invite.")
193 .font(.footnote).foregroundStyle(.secondary)
194 .frame(maxWidth: 420)
195 }
196 }
197 }
198
199 // Every opened instance stays alive in the ZStack; only the selected one is
200 // visible and interactive. A newly-selected instance is added to `activated`,
201 // so its webview is created once and then cached (its WebSocket stays live in
202 // the background, so switching back shows an up-to-date feed immediately).
203 private var detail: some View {
204 ZStack {
205 ForEach(app.chats) { chat in
206 if activated.contains(chat.id) {
207 Group {
208 // Native chat is the ONLY surface — the full-frame
209 // webview (and its paste-a-link join) is gone: the app
210 // is entered by signing in with Eleven, identities ride
211 // the vault. A chat without one is an out-of-sync
212 // remnant and says so, natively.
213 if let inst = nativeInstance(for: chat) {
214 NativeChatView(instance: inst, isActive: chat.id == app.selection)
215 .id(inst.id)
216 } else {
217 lockedPane(chat)
218 }
219 }
220 .opacity(chat.id == app.selection ? 1 : 0)
221 .allowsHitTesting(chat.id == app.selection)
222 }
223 }
224 if app.chats.isEmpty {
225 // Fresh Mac: lead with Sign in (restore your spaces), the
226 // invite link as the secondary path — iOS's first-run screen.
227 MacFirstRun(model: firstRunSignIn)
228 } else if app.selected == nil {
229 ContentUnavailableView(
230 "No space selected",
231 systemImage: "bubble.left.and.bubble.right",
232 description: Text("Pick a space from the rail.")
233 )
234 }
235 }
236 .frame(maxWidth: .infinity, maxHeight: .infinity)
237 .onAppear { if let s = app.selection { activated.insert(s) } }
238 .onChange(of: app.selection) { _, s in if let s { activated.insert(s) } }
239 // SAFETY NET only — the window-wide AppKit catcher (WindowDropZone,
240 // end of body) claims drags first everywhere it is consulted; this
241 // SwiftUI region keeps the pre-catcher behaviour alive for any drag
242 // path that never reaches it. Still ONE region, never per-pane: the
243 // keep-alive ZStack means overlapping regions, and drop targeting
244 // picks the topmost one regardless of opacity or allowsHitTesting.
245 .onDrop(of: paneDroppableTypes,
246 delegate: PaneDropDelegate(app: app, detailFrame: detailFrame))
247 // …and this net's drop point is region-LOCAL, so its own frame is
248 // what converts it to the global point the router speaks.
249 .trackingFrame(into: detailFrame)
250 }
251}
252
253// The drop's pane, resolved at DRAG time from the live selection — never from
254// view-lifecycle events (an isActive-flip claim scheme lost the owner to a
255// coalesced onChange; see PaneDropRouter). The key mirrors nativeInstance(for:):
256// a chat routes to the feed registered under its instance's origin.
257private func selectedPaneKey(_ app: MacAppState) -> String? {
258 guard let sel = app.selection,
259 let chat = app.chats.first(where: { $0.id == sel }) else { return nil }
260 return chat.origin?.absoluteString
261}
262
263// What drop-to-attach accepts: real files, promised files (how most apps vend
264// drags — screenshots, Photos), and raw image/movie data.
265private let paneDroppableTypes: [UTType] = {
266 var types: [UTType] = [.fileURL, .image, .movie]
267 types += NSFilePromiseReceiver.readableDraggedTypes.compactMap { UTType($0) }
268 return types
269}()
270
271private struct PaneDropDelegate: DropDelegate {
272 let app: MacAppState
273 let detailFrame: LiveFrame
274 // Consulted per drag session (unlike the registered type list), so the
275 // + badge tracks the live selection: no registered feed → the drag bounces.
276 func validateDrop(info: DropInfo) -> Bool {
277 PaneDropRouter.shared.canDrop(over: selectedPaneKey(app))
278 }
279 func performDrop(info: DropInfo) -> Bool {
280 // COORDINATE SPACES. `info.location` is in this drop region's OWN
281 // space — SwiftUI, top-left origin, measured from the detail area's
282 // corner. The router speaks SwiftUI `.global` (top-left, measured
283 // from the window's content view), the space LiveFrame.rect answers
284 // in, so the one conversion on this path is the region's own origin.
285 let origin = detailFrame.rect.origin
286 let point = CGPoint(x: info.location.x + origin.x, y: info.location.y + origin.y)
287 // The drag pasteboard directly (DropInfo never exposes it) — the same
288 // payload the catcher delivers, so the router speaks one language.
289 return PaneDropRouter.shared.deliver(NSPasteboard(name: .drag), at: point,
290 over: selectedPaneKey(app))
291 }
292}
293
294// The invisible whole-window drag destination. Its NSView registers for the
295// droppable types and, sitting above every other view, is what AppKit's
296// deepest-registered-view search finds first — the NSTextView and WKWebView
297// interceptors underneath never see the drag. hitTest returns nil so ordinary
298// mouse events keep falling through to the real UI (drag-destination search
299// walks registered frames, not the hitTest override).
300private struct WindowDropZone: NSViewRepresentable {
301 var canDrop: () -> Bool
302 // The drop's location comes with it, in SwiftUI `.global` space — see
303 // DropCatcherView.globalPoint.
304 var perform: (NSPasteboard, CGPoint) -> Bool
305
306 func makeNSView(context: Context) -> DropCatcherView { DropCatcherView() }
307
308 func updateNSView(_ view: DropCatcherView, context: Context) {
309 view.canDrop = canDrop
310 view.perform = perform
311 }
312}
313
314private final class DropCatcherView: NSView {
315 var canDrop: () -> Bool = { false }
316 var perform: (NSPasteboard, CGPoint) -> Bool = { _, _ in false }
317
318 override init(frame frameRect: NSRect) {
319 super.init(frame: frameRect)
320 var types: [NSPasteboard.PasteboardType] = [.fileURL, .png, .tiff]
321 types += NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) }
322 registerForDraggedTypes(types)
323 }
324 required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
325
326 override func hitTest(_ point: NSPoint) -> NSView? { nil }
327
328 override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
329 // A drag that STARTED here (an image pulled out of a bubble, a rail
330 // reorder) is leaving, not arriving — never a drop-to-attach.
331 guard sender.draggingSource == nil, canDrop() else { return [] }
332 return .copy
333 }
334 override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
335 perform(sender.draggingPasteboard, globalPoint(sender))
336 }
337
338 // COORDINATE SPACES — the one AppKit→SwiftUI flip on the drop path.
339 // `draggingLocation` is in the destination WINDOW's base coordinate
340 // system: bottom-left origin (the same space `NSEvent.locationInWindow`
341 // uses). Everything downstream — the router, and the LiveFrame rects the
342 // feed tests the point against — speaks SwiftUI `.global`: top-left
343 // origin, measured from the window's content view. So flip against the
344 // content view's height, exactly as LiveFrame.rect and the feed's
345 // scroll/drag monitors do, and hand on a point in that space only.
346 private func globalPoint(_ sender: NSDraggingInfo) -> CGPoint {
347 let p = sender.draggingLocation // window base coords, bottom-left origin
348 // Windowless is impossible mid-drag; if it ever happens, .zero sits
349 // in the feed column, so the drop lands on the visible channel
350 // composer rather than a pane the reader may not be looking at.
351 guard let window else { return .zero }
352 let h = window.contentView?.bounds.height ?? window.frame.height
353 return CGPoint(x: p.x, y: h - p.y)
354 }
355}
356
357// Discord-style rail tooltip: appears instantly on hover, in a fixed position to
358// the right of the hovered icon (unlike the native ~1s-delay tooltip near the
359// cursor). Icons publish their name + vertical midpoint via a preference; RootView
360// reads it and draws the label in an overlay so it isn't clipped by the rail.
361enum RailTooltip { static let space = "rail" }
362
363struct RailTooltipInfo: Equatable {
364 var text: String
365 var y: CGFloat
366}
367
368struct RailTooltipKey: PreferenceKey {
369 static let defaultValue: RailTooltipInfo? = nil
370 static func reduce(value: inout RailTooltipInfo?, nextValue: () -> RailTooltipInfo?) {
371 value = value ?? nextValue()
372 }
373}
374
375struct RailTooltipLabel: View {
376 let text: String
377 private let fill = Color(white: 0.14)
378
379 var body: some View {
380 HStack(spacing: 0) {
381 LeftArrow()
382 .fill(fill)
383 .frame(width: 6, height: 11)
384 Text(text)
385 .font(.callout.weight(.semibold))
386 .foregroundStyle(.white)
387 .lineLimit(1)
388 .padding(.horizontal, 10)
389 .padding(.vertical, 5)
390 .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(fill))
391 }
392 .shadow(color: .black.opacity(0.28), radius: 6, y: 1)
393 .fixedSize()
394 }
395}
396
397// A small left-pointing triangle for the tooltip — apex toward the icon.
398struct LeftArrow: Shape {
399 func path(in rect: CGRect) -> Path {
400 var p = Path()
401 p.move(to: CGPoint(x: rect.maxX, y: rect.minY))
402 p.addLine(to: CGPoint(x: rect.minX, y: rect.midY))
403 p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
404 p.closeSubpath()
405 return p
406 }
407}
408
409// What the web layer offers the palette (window.lchatPaletteItems in shell.js):
410// the current server's chats plus the actions available to this member right now.
411private struct PaletteChat: Decodable, Identifiable {
412 let id: String
413 let name: String
414 let glyph: String? // set for an app-thread (To-do, …); nil = person/group
415 let active: Bool
416}
417
418private struct PaletteWebItems: Decodable {
419 var chats: [PaletteChat]
420}
421
422// One flat, keyboard-navigable list mixing all four row kinds.
423private enum PaletteRow: Identifiable {
424 case chat(PaletteChat)
425 case server(SavedChat)
426 var id: String {
427 switch self {
428 case .chat(let c): return "chat:" + c.id
429 case .server(let s): return "server:" + s.id
430 }
431 }
432}
433
434// ⌘K command palette: jump to any chat on the current server, run an action
435// (new chat, invite someone, theme, …), or switch to another server. Servers are
436// native state; the chats and actions come from the web layer over the
437// lchatPaletteItems / lchatPaletteRun bridge (shell.js) — labels and behaviour
438// keep one source of truth there, native only draws rows and posts the pick
439// back. Drawn as an overlay (like the rail tooltip) so it sits above the
440// WKWebView feed; type to filter, ↑/↓ to move, ↩ to open, esc to dismiss.
441struct CommandPalette: View {
442 @EnvironmentObject var app: MacAppState
443 @State private var query = ""
444 @State private var index = 0
445 @State private var web = PaletteWebItems(chats: [])
446 @FocusState private var focused: Bool
447
448 // Recency rank for a server (0 = most recently opened); unopened sort last.
449 private func recencyRank(_ chat: SavedChat) -> Int {
450 app.recent.firstIndex(of: chat.id) ?? Int.max
451 }
452
453 // Other servers, phrased as a switch ("Switch to Paul's team") — the one
454 // you're on is represented by its chats above, so it isn't offered again.
455 // With no query: most-recently-opened first, then any never-opened in saved
456 // order. With a query: fuzzy subsequence match on the row label or host, best
457 // score first, ties broken by recency — so "cfc" (or "switch") finds
458 // "Switch to Campbell family chat", and among comparable matches the one
459 // used most recently wins.
460 private func serverMatches(_ q: String) -> [SavedChat] {
461 let ranked = app.chats.filter { $0.id != app.selection }.enumerated().map {
462 (chat: $0.element, order: $0.offset, recency: recencyRank($0.element))
463 }
464 if q.isEmpty {
465 return ranked
466 .sorted { $0.recency != $1.recency ? $0.recency < $1.recency : $0.order < $1.order }
467 .map(\.chat)
468 }
469 return ranked
470 .compactMap { r -> (chat: SavedChat, score: Int, order: Int, recency: Int)? in
471 let scores = [fuzzyScore(q, "Switch to " + app.serverName(r.chat)),
472 fuzzyScore(q, r.chat.origin?.host ?? "")].compactMap { $0 }
473 guard let best = scores.max() else { return nil }
474 return (r.chat, best, r.order, r.recency)
475 }
476 .sorted {
477 if $0.score != $1.score { return $0.score > $1.score }
478 if $0.recency != $1.recency { return $0.recency < $1.recency }
479 return $0.order < $1.order
480 }
481 .map(\.chat)
482 }
483
484 // The full list, in section order: chats on this server (sorted
485 // pinned-then-recent by the publisher, capped when idle so servers stay in
486 // reach), then other servers.
487 private var rows: [PaletteRow] {
488 let q = query.filter { !$0.isWhitespace }
489 var out: [PaletteRow] = []
490 if q.isEmpty {
491 out += web.chats.prefix(6).map { .chat($0) }
492 } else {
493 out += web.chats
494 .compactMap { c in fuzzyScore(q, c.name).map { (chat: c, score: $0) } }
495 .sorted { $0.score > $1.score }
496 .map { .chat($0.chat) }
497 }
498 out += serverMatches(q).map { .server($0) }
499 return out
500 }
501
502 // Query chars must appear in `text` in order (case-insensitive). Word-boundary
503 // and contiguous matches score higher; nil when it isn't a subsequence at all.
504 private func fuzzyScore(_ query: String, _ text: String) -> Int? {
505 let q = Array(query.lowercased())
506 let t = Array(text.lowercased())
507 var qi = 0, score = 0, prevMatch = -2
508 var prevChar: Character?
509 for (ti, ch) in t.enumerated() {
510 if qi < q.count, ch == q[qi] {
511 if prevChar == nil || !(prevChar!.isLetter || prevChar!.isNumber) { score += 10 }
512 if ti == prevMatch + 1 { score += 5 }
513 score += 1
514 prevMatch = ti
515 qi += 1
516 }
517 prevChar = ch
518 }
519 return qi == q.count ? score : nil
520 }
521
522 var body: some View {
523 let inits = app.initials()
524 let rows = self.rows
525 return ZStack(alignment: .top) {
526 Color.black.opacity(0.2).ignoresSafeArea()
527 .onTapGesture { close() }
528 VStack(spacing: 0) {
529 // Search field: leading magnifying glass + a big, quiet placeholder.
530 HStack(spacing: 12) {
531 Image(systemName: "magnifyingglass")
532 .font(.system(size: 18, weight: .medium))
533 .foregroundStyle(.secondary)
534 TextField("Jump to…", text: $query)
535 .textFieldStyle(.plain)
536 .font(.system(size: 21))
537 .focused($focused)
538 .onSubmit(activate)
539 }
540 .padding(.horizontal, 20)
541 .padding(.vertical, 17)
542 Divider().opacity(0.5)
543 ScrollViewReader { proxy in
544 ScrollView {
545 VStack(spacing: 4) {
546 ForEach(Array(rows.enumerated()), id: \.element.id) { i, row in
547 paletteRow(row, inits: inits, highlighted: i == index)
548 .id(row.id)
549 }
550 }
551 .padding(8)
552 }
553 .frame(maxHeight: 340)
554 // Keep the keyboard highlight in view as ↑/↓ walk the list. An
555 // explicit directional anchor (not nil, which macOS sometimes
556 // ignores): moving down pins the row to the bottom edge, moving
557 // up to the top — the ScrollView clamps, so nothing moves until
558 // the highlight actually reaches an edge.
559 .onChange(of: index) { old, new in
560 if new < rows.count {
561 proxy.scrollTo(rows[new].id, anchor: new > old ? .bottom : .top)
562 }
563 }
564 }
565 Divider().opacity(0.5)
566 // Footer hint bar, à la TinyStart's status line.
567 HStack(spacing: 16) {
568 hint("↩", "Open")
569 hint("↑ ↓", "Navigate")
570 hint("esc", "Dismiss")
571 Spacer()
572 }
573 .padding(.horizontal, 20)
574 .padding(.vertical, 9)
575 }
576 .frame(width: 600)
577 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
578 .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).strokeBorder(.white.opacity(0.12)))
579 .shadow(color: .black.opacity(0.35), radius: 30, y: 12)
580 .padding(.top, 80)
581 }
582 .onAppear {
583 index = 0
584 loadWebItems()
585 DispatchQueue.main.async { focused = true }
586 }
587 // While the palette is up it owns the keyboard. If anything else grabs
588 // first responder (switching away and back re-focuses the web composer;
589 // see ChatFeedWebView.focusComposer, which also guards on showPalette),
590 // take it straight back — and re-assert on app re-activation too, since
591 // a focus lost to another app comes back to whoever asks.
592 .onChange(of: focused) { _, isFocused in
593 if !isFocused && app.showPalette {
594 DispatchQueue.main.async { focused = true }
595 }
596 }
597 .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
598 DispatchQueue.main.async { focused = true }
599 }
600 .onChange(of: query) { _, _ in index = 0 }
601 .onKeyPress(.downArrow) { index = min(index + 1, rows.count - 1); return .handled }
602 .onKeyPress(.upArrow) { index = max(index - 1, 0); return .handled }
603 .onExitCommand { close() }
604 }
605
606 // Ask the selected instance's surface what it can offer. The native
607 // takeover publishes its threads on MacAppState (no webview to ask); the
608 // webview path asks lchatPaletteItems. Fails soft either way: the palette
609 // still switches servers — just no chat rows.
610 private func loadWebItems() {
611 // The active NativeChatView publishes its threads on MacAppState —
612 // the sole chat source now the webview bridge is gone.
613 web = PaletteWebItems(chats: app.nativeChats.map {
614 PaletteChat(id: $0.id, name: $0.title, glyph: $0.glyph, active: false)
615 })
616 }
617
618 private func activate() {
619 let rows = self.rows
620 guard index < rows.count else { return }
621 pick(rows[index])
622 }
623
624 private func pick(_ row: PaletteRow) {
625 switch row {
626 case .chat(let c):
627 app.nativeOpenThread = c.id // consumed by the active NativeChatView
628 close()
629 case .server(let s):
630 app.selection = s.id
631 close()
632 }
633 }
634
635 private func close() { app.showPalette = false; query = "" }
636
637 @ViewBuilder
638 private func paletteRow(_ row: PaletteRow, inits: [String: String], highlighted: Bool) -> some View {
639 switch row {
640 case .chat(let c):
641 chatRow(c, highlighted: highlighted)
642 case .server(let s):
643 serverRow(s, initial: inits[s.id] ?? "?", highlighted: highlighted)
644 }
645 }
646
647 // A chat on the current server: initials badge coloured by thread id (or the
648 // app's glyph for an app-thread), accent dot on the one that's open.
649 @ViewBuilder
650 private func chatRow(_ c: PaletteChat, highlighted: Bool) -> some View {
651 rowShell(highlighted: highlighted, action: { pick(.chat(c)) }) {
652 statusDot(current: c.active)
653 // The native surface knows each chat's real (decrypted) photo and
654 // which app a thread belongs to — show the photo, or the app's
655 // identity mark (docs/icons.md); glyph/initials remain the webview
656 // path's fallback.
657 let native = app.nativeChats.first(where: { $0.id == c.id })
658 if let appId = native?.appId,
659 AppIdentity.symbol(for: appId) != nil || AppIdentity.mark(for: appId) != nil {
660 NativeChatView.ThreadAvatar(title: c.name, source: nil, size: 38,
661 glyph: native?.glyph,
662 symbol: AppIdentity.symbol(for: appId),
663 mark: AppIdentity.mark(for: appId))
664 } else if let avatar = native?.avatar {
665 NativeChatView.ThreadAvatar(title: c.name, source: avatar, size: 38)
666 } else {
667 Text(c.glyph ?? chatInitials(c.name))
668 .font(.system(size: 16, weight: .semibold))
669 .lineLimit(1).minimumScaleFactor(0.6)
670 .foregroundStyle(c.glyph == nil ? .white : .primary)
671 .frame(width: 38, height: 38)
672 .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
673 .fill(c.glyph == nil
674 ? Color(hue: app.hue(for: c.id), saturation: 0.55, brightness: 0.78)
675 : Color.secondary.opacity(0.18)))
676 }
677 Text(c.name).font(.system(size: 16, weight: .semibold))
678 }
679 }
680
681 // Another server, TinyStart-style: colour badge with the instance initial,
682 // "Switch to <name>" + host.
683 @ViewBuilder
684 private func serverRow(_ chat: SavedChat, initial: String, highlighted: Bool) -> some View {
685 rowShell(highlighted: highlighted, action: { pick(.server(chat)) }) {
686 statusDot(current: false)
687 Text(initial)
688 .font(.system(size: 16, weight: .semibold))
689 .lineLimit(1).minimumScaleFactor(0.6)
690 .foregroundStyle(.white)
691 .frame(width: 38, height: 38)
692 .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
693 .fill(Color(hue: app.hue(for: chat.id), saturation: 0.55, brightness: 0.78)))
694 VStack(alignment: .leading, spacing: 1) {
695 Text("Switch to \(app.serverName(chat))").font(.system(size: 16, weight: .semibold))
696 if let host = chat.origin?.host, host != app.serverName(chat) {
697 Text(host).font(.system(size: 12)).foregroundStyle(.secondary)
698 }
699 }
700 }
701 }
702
703 // Chat-name initials, mirroring MacAppState.initials(): one letter for a
704 // single-word name, first letters of the first two words otherwise.
705 private func chatInitials(_ name: String) -> String {
706 let ws = name.split { !($0.isLetter || $0.isNumber) }
707 func head(_ w: Substring?) -> String? { w?.first.map { String($0).uppercased() } }
708 guard let first = head(ws.first) else { return "?" }
709 return ws.count >= 2 ? first + (head(ws[1]) ?? "") : first
710 }
711
712 private func actionSymbol(_ id: String) -> String {
713 switch id {
714 case "new-chat": return "square.and.pencil"
715 case "new-group": return "person.2"
716 case "invite": return "person.badge.plus"
717 case "apps": return "square.grid.2x2"
718 case "add-device": return "laptopcomputer.and.iphone"
719 case "theme": return "paintpalette"
720 case "edit-profile": return "person.crop.circle"
721 case "leave": return "rectangle.portrait.and.arrow.right"
722 default: return "bolt"
723 }
724 }
725
726 // Shared row chrome: the highlight background, trailing ↩, padding + button.
727 @ViewBuilder
728 private func rowShell<Content: View>(highlighted: Bool, action: @escaping () -> Void, @ViewBuilder content: () -> Content) -> some View {
729 Button(action: action) {
730 HStack(spacing: 12) {
731 content()
732 Spacer(minLength: 8)
733 Image(systemName: "return")
734 .font(.system(size: 14, weight: .medium))
735 .foregroundStyle(Color.accentColor)
736 .opacity(highlighted ? 1 : 0)
737 }
738 .padding(.horizontal, 12)
739 .padding(.vertical, 8)
740 .frame(maxWidth: .infinity, alignment: .leading)
741 .background(RoundedRectangle(cornerRadius: 12, style: .continuous)
742 .fill(highlighted ? Color.accentColor.opacity(0.22) : .clear))
743 .contentShape(Rectangle())
744 }
745 .buttonStyle(.plain)
746 }
747
748 private func statusDot(current: Bool) -> some View {
749 Circle()
750 .fill(current ? Color.accentColor : Color.secondary.opacity(0.35))
751 .frame(width: 6, height: 6)
752 }
753
754 @ViewBuilder
755 private func hint(_ key: String, _ label: String) -> some View {
756 HStack(spacing: 5) {
757 Text(key)
758 .font(.system(size: 11, weight: .semibold))
759 .padding(.horizontal, 5).padding(.vertical, 2)
760 .background(RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.18)))
761 Text(label).font(.system(size: 11)).foregroundStyle(.secondary)
762 }
763 }
764}
765