| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import 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). |
| 10 | struct 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. |
| 242 | private 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. |
| 250 | private let paneDroppableTypes: [UTType] = { |
| 251 | var types: [UTType] = [.fileURL, .image, .movie] |
| 252 | types += NSFilePromiseReceiver.readableDraggedTypes.compactMap { UTType($0) } |
| 253 | return types |
| 254 | }() |
| 255 | |
| 256 | private 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). |
| 276 | private 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 | |
| 288 | private 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. |
| 317 | enum RailTooltip { static let space = "rail" } |
| 318 | |
| 319 | struct RailTooltipInfo: Equatable { |
| 320 | var text: String |
| 321 | var y: CGFloat |
| 322 | } |
| 323 | |
| 324 | struct RailTooltipKey: PreferenceKey { |
| 325 | static let defaultValue: RailTooltipInfo? = nil |
| 326 | static func reduce(value: inout RailTooltipInfo?, nextValue: () -> RailTooltipInfo?) { |
| 327 | value = value ?? nextValue() |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | struct 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. |
| 354 | struct 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. |
| 367 | private 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 | |
| 374 | private struct PaletteWebItems: Decodable { |
| 375 | var chats: [PaletteChat] |
| 376 | } |
| 377 | |
| 378 | // One flat, keyboard-navigable list mixing all four row kinds. |
| 379 | private 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. |
| 397 | struct 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 | |