eleven / messenger Public

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

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