| 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 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. |
| 257 | private 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. |
| 265 | private let paneDroppableTypes: [UTType] = { |
| 266 | var types: [UTType] = [.fileURL, .image, .movie] |
| 267 | types += NSFilePromiseReceiver.readableDraggedTypes.compactMap { UTType($0) } |
| 268 | return types |
| 269 | }() |
| 270 | |
| 271 | private 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). |
| 300 | private 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 | |
| 314 | private 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. |
| 361 | enum RailTooltip { static let space = "rail" } |
| 362 | |
| 363 | struct RailTooltipInfo: Equatable { |
| 364 | var text: String |
| 365 | var y: CGFloat |
| 366 | } |
| 367 | |
| 368 | struct RailTooltipKey: PreferenceKey { |
| 369 | static let defaultValue: RailTooltipInfo? = nil |
| 370 | static func reduce(value: inout RailTooltipInfo?, nextValue: () -> RailTooltipInfo?) { |
| 371 | value = value ?? nextValue() |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | struct 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. |
| 398 | struct 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. |
| 411 | private 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 | |
| 418 | private struct PaletteWebItems: Decodable { |
| 419 | var chats: [PaletteChat] |
| 420 | } |
| 421 | |
| 422 | // One flat, keyboard-navigable list mixing all four row kinds. |
| 423 | private 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. |
| 441 | struct 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 | |