eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import UserNotifications
4
5// THE takeover surface (docs/native-chat.md): a native thread list beside the
6// native feed — the Mac's main chat view once an instance has its native
7// identity. The webview stays one toggle away (View ▸ Use Web App) for what
8// still lives there: mini-apps, admin, profile editing, joining.
9struct NativeChatView: View {
10 let instance: SavedInstance
11 /// Whether this instance's pane is the one on screen (RootView keeps the
12 /// others mounted at opacity 0) — gates the feed's window-global swipe
13 /// monitor.
14 var isActive = true
15 @StateObject private var store: ThreadStore
16 @State private var selectedID: String?
17 // The right pane's thread id. It follows `selectedID` one runloop turn
18 // behind: swapping the pane (a feed fold or a webview build) in the same
19 // transaction as the click held the row highlight back until the pane
20 // finished building, which made every chat switch read as lag.
21 @State private var paneID: String?
22 // The just-left chat, held MOUNTED (identity preserved in the pane's
23 // ForEach) on top of the incoming feed while that feed runs its settle
24 // beat — so a ⌘[/⌘] switch cuts old content → new content with no blank
25 // frame between (the flash). Dropped the moment the incoming feed
26 // reports settled, or at a short cap so a cold load's spinner is never
27 // covered. lastSettledFeedID tracks the last feed that actually became
28 // visible: rapid cycling through never-settled chats keeps the last
29 // VISIBLE one as cover instead of promoting a blank pane.
30 @State private var outgoingFeedThread: ChatThread?
31 @State private var lastSettledFeedID: String?
32 @State private var outgoingGen = 0
33 @State private var showDiscover = false
34 // One instance-wide socket keeps the SIDEBAR live: the hub fans every
35 // thread's frames to us anyway, so any stored-message activity just
36 // schedules a (debounced) roster/thread refresh for previews + unread.
37 @State private var liveSocket: ChatSocket?
38 @State private var refreshPending = false
39
40 init(instance: SavedInstance, isActive: Bool = true) {
41 self.instance = instance
42 self.isActive = isActive
43 _store = StateObject(wrappedValue: ThreadStore(instance: instance))
44 }
45
46 @State private var search = ""
47 @FocusState private var searchFocused: Bool
48 @State private var showNewChat = false
49 @State private var showInvite = false
50 // The list is user-resizable (drag the divider); below the threshold it
51 // collapses to an avatar-only rail. Width persists across launches.
52 @AppStorage("macChatListWidth") private var listWidth = 300.0
53 @State private var dragStartWidth: Double?
54 @Environment(\.colorScheme) private var scheme
55 @AppStorage("nativeTheme") private var themeName = "classic"
56 @AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize
57 private var theme: ChatTheme { .named(themeName) }
58
59 private var collapsed: Bool { listWidth < 170 }
60
61 // The hidden-title-bar window is only draggable in a thin strip at the
62 // very top — extend the grab area over the whole chat header and the
63 // list's "Chats" row (down to the search box), like a real title bar.
64 // A box (not view state): the event-monitor closure must read live values.
65 // The two grab-zone frames are LiveFrames — read lazily from a passive
66 // backing view at mouse-event time, never tracked with a per-frame
67 // `onChange(of: CGRect)` (that churned on every window resize — #899).
68 @MainActor
69 private final class DragZones: ObservableObject {
70 let feed = LiveFrame() // the chat header row
71 let list = LiveFrame() // the sidebar header row above search
72 var down: NSEvent? // a press waiting to become a window drag
73 var isActive = true
74 }
75 @StateObject private var dragZones = DragZones()
76 @State private var windowDragMonitor: Any?
77
78 // Drag-on-move, not drag-on-press: the press passes through (buttons and
79 // menus in the header keep working); only actual movement moves the
80 // window. Double-click zooms, like a title bar.
81 private func installWindowDragMonitor() {
82 let zones = dragZones
83 windowDragMonitor = NSEvent.addLocalMonitorForEvents(
84 matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]
85 ) { event in
86 guard zones.isActive, let window = event.window,
87 let content = window.contentView else { return event }
88 switch event.type {
89 case .leftMouseDown:
90 let p = event.locationInWindow
91 let point = CGPoint(x: p.x, y: content.bounds.height - p.y)
92 // Each zone reaches the window's TOP edge: the sliver between
93 // a band and the frame edge is title-bar-shaped space, and
94 // treating it as part of the zone also gives it the
95 // double-click zoom a real title bar has.
96 let grabs = [zones.feed.rect, zones.list.rect].map { r in
97 r.isEmpty ? r : CGRect(x: r.minX, y: 0, width: r.width, height: r.maxY)
98 }
99 guard grabs.contains(where: { $0.contains(point) }) else { return event }
100 if event.clickCount == 2 {
101 window.zoom(nil)
102 return nil
103 }
104 zones.down = event
105 return event
106 case .leftMouseDragged:
107 if let down = zones.down {
108 zones.down = nil
109 window.performDrag(with: down)
110 return nil
111 }
112 return event
113 default:
114 zones.down = nil
115 return event
116 }
117 }
118 }
119
120 var body: some View {
121 HStack(spacing: 0) {
122 VStack(spacing: 0) {
123 if collapsed {
124 avatarRail
125 } else {
126 listHeader
127 threadList
128 }
129 }
130 .frame(width: collapsed ? 76 : listWidth)
131 .background(theme.sidebar.resolved(scheme))
132 resizeHandle
133 feedColumn
134 }
135 .background(theme.panel.resolved(scheme))
136 // ⌘F focuses the sidebar search (expanding the rail if collapsed).
137 .background(
138 Button("") {
139 if collapsed { listWidth = 300 }
140 DispatchQueue.main.async { searchFocused = true }
141 }
142 .keyboardShortcut("f", modifiers: .command)
143 .opacity(0)
144 .accessibilityHidden(true)
145 )
146 .task {
147 await store.refresh()
148 // Creators land in Eleven's admin SETUP conversation, fresh
149 // members in the WELCOME one (the web's maybeStartSetup /
150 // maybeStartWelcome, natively) — once per device, and only while
151 // the conversation is unfinished; the thread's webview runs the
152 // same client-local script the web runs.
153 if selectedID == nil,
154 let setup = await SetupLanding.threadToLand(instance: instance, threads: store.threads) {
155 selectedID = setup.id
156 }
157 if selectedID == nil,
158 let welcome = await WelcomeLanding.threadToLand(instance: instance, threads: store.threads) {
159 selectedID = welcome.id
160 }
161 if selectedID == nil {
162 selectedID = store.threads.first(where: { !$0.hidden })?.id
163 }
164 startLive()
165 // A chat parked before this space had any threads to match it
166 // against (a notification tap into a space this session hadn't
167 // opened yet) — the list is in hand now.
168 consumeOpenThread()
169 // Prewarm the top chats so first taps land on already-folded
170 // feeds — the web's prebuild, in miniature.
171 NativeFeedModelCache.shared.warm(instance: instance,
172 threads: store.threads.filter { !$0.hidden },
173 myUserId: store.myUserId)
174 }
175 .onDisappear {
176 liveSocket?.stop()
177 if let windowDragMonitor { NSEvent.removeMonitor(windowDragMonitor) }
178 windowDragMonitor = nil
179 }
180 // ⌘K: the active native surface is the palette's chat source (the
181 // webview's lchatPaletteItems equivalent), and openThread requests
182 // from the palette land here.
183 .onAppear {
184 publishPaletteChats()
185 refreshNotificationStatus()
186 dragZones.isActive = isActive
187 installWindowDragMonitor()
188 }
189 // The hop that decouples click feedback from pane cost: selection
190 // commits (and paints) first, the pane swap lands next turn.
191 .onChange(of: selectedID) { _, id in
192 DispatchQueue.main.async { paneID = id }
193 }
194 // Feed→feed switches hold the outgoing chat over the settle beat
195 // (see outgoingFeedThread). Switches involving app/companion panes
196 // keep today's behaviour — those surfaces paint their own way.
197 .onChange(of: paneID) { old, new in
198 outgoingGen += 1
199 let gen = outgoingGen
200 let newThread = store.threads.first { $0.id == new }
201 guard old != new, let newThread, isFeedSurface(newThread) else {
202 outgoingFeedThread = nil
203 return
204 }
205 if let oldThread = store.threads.first(where: { $0.id == old }),
206 isFeedSurface(oldThread), old == lastSettledFeedID {
207 outgoingFeedThread = oldThread
208 }
209 // else: the old pane never became visible (mid-cycle) — keep the
210 // current cover, which is still the last content on screen.
211 DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
212 if outgoingGen == gen { outgoingFeedThread = nil }
213 }
214 }
215 .onChange(of: isActive) { _, active in
216 dragZones.isActive = active
217 if active {
218 publishPaletteChats()
219 // A chat parked for THIS space while another was on screen (a
220 // notification tap switches space first): act on it now.
221 consumeOpenThread()
222 } else if app.nativeChatsOwner == instance.id {
223 app.nativeChats = []
224 app.nativeChatsOwner = nil
225 }
226 }
227 .onReceive(store.$threads) { _ in publishPaletteChats() }
228 .sheet(item: $membersSheetThread) { t in
229 MembersSheet(store: store, thread: t,
230 onOpenThread: { id in
231 membersSheetThread = nil
232 selectedID = id
233 })
234 }
235 // End conversation (#578): the destructive confirm behind the menu
236 // item — ending is mutual, so it never fires on a bare click.
237 .confirmationDialog("End this conversation?",
238 isPresented: Binding(
239 get: { endConversationTarget != nil },
240 set: { if !$0 { endConversationTarget = nil } }),
241 titleVisibility: .visible,
242 presenting: endConversationTarget) { t in
243 Button("End Conversation", role: .destructive) {
244 Task {
245 // Optimistic in the store; a refused POST reverts. Ending
246 // hides the chat, so move the selection off it (the Close
247 // item's rule).
248 if await store.endConversation(t), selectedID == t.id {
249 selectedID = store.threads.first(where: { !$0.hidden && $0.id != t.id })?.id
250 }
251 }
252 }
253 } message: { _ in
254 Text("It closes for both of you, and no new messages can be sent unless someone reopens it.")
255 }
256 .sheet(item: $headerProfile) { target in
257 // Message them selects (or creates) their DM — hidden when the
258 // selected chat already IS their DM (the web card's gate).
259 let theirDM = store.threads.first { $0.kind == "dm" && $0.members.contains(target.id) }
260 MemberProfileSheet(store: store, uid: target.id,
261 onOpenThread: (theirDM != nil && theirDM?.id == selectedID) ? nil : { id in
262 headerProfile = nil
263 selectedID = id
264 }) { e in
265 ThreadAvatar(title: store.displayName(for: e),
266 source: store.avatarSource(for: e), size: 72)
267 }
268 }
269 // Report a message to the admins (#578) — the shared sheet, with the
270 // message as evidence and the thread's client-side name.
271 .sheet(item: $reportTarget) { target in
272 ReportSheet(store: store, uid: target.uid, threadID: target.thread.id,
273 threadName: target.thread.title, evidence: target.evidence)
274 }
275 .sheet(item: $renameSheetThread) { t in
276 RenameGroupSheet(store: store, thread: t)
277 }
278 .sheet(item: $appsSheetThread) { t in
279 GroupAppsSheet(model: feedModel(t))
280 }
281 .sheet(isPresented: $showInvite) {
282 InviteSomeoneSheet(store: store)
283 }
284 // ⌘[ / ⌘] step through the chat list, CLAMPED at both ends — ⌘[ on
285 // the top row (or ⌘] on the last) does nothing; wrapping to the
286 // opposite end read as a jump to nowhere. The change's SIGN is the
287 // direction (LChatMacApp's menu items move the tick ±1).
288 .onChange(of: app.cycleChatTick) { old, new in
289 guard isActive else { return }
290 let list = visibleThreads
291 guard !list.isEmpty else { return }
292 let dir = new > old ? 1 : -1
293 let at = list.firstIndex { $0.id == selectedID } ?? (dir == 1 ? -1 : 0)
294 let next = at + dir
295 guard list.indices.contains(next) else { return }
296 selectedID = list[next].id
297 }
298 // App Studio's Open, forwarded by an app pane's webview bridge
299 // (MacWebBridge {openAppThread}): route it through the palette's
300 // "app:" open-or-create path below — the pane itself must never
301 // navigate, or the app renders inside App Studio's window.
302 .onReceive(NotificationCenter.default.publisher(for: .lchatOpenAppThread)) { note in
303 guard isActive, let name = note.userInfo?["app"] as? String, !name.isEmpty else { return }
304 app.nativeOpenThread = "app:" + name
305 }
306 .onChange(of: app.nativeOpenThread) { _, _ in consumeOpenThread() }
307 }
308
309 // A parked "open this chat" request (the ⌘K palette, App Studio's Open, a
310 // notification tap). Its own change is the usual trigger, but a request can
311 // also arrive for a space that isn't on screen — or isn't even mounted —
312 // yet: it stays parked until the space that owns it can act, so this is
313 // re-checked whenever this view becomes the active one and once its threads
314 // have loaded. Acting clears the request; a request for another space is
315 // simply left alone.
316 private func consumeOpenThread() {
317 guard isActive, let id = app.nativeOpenThread else { return }
318 // "user:"/"app:" rows have no thread yet — create (the server
319 // dedupes DMs) and land in it.
320 if id.hasPrefix("user:") {
321 app.nativeOpenThread = nil
322 let uid = String(id.dropFirst(5))
323 Task {
324 if let tid = await store.createChat(memberIDs: [uid], groupName: nil) {
325 selectedID = tid
326 }
327 }
328 return
329 }
330 if id.hasPrefix("app:") {
331 app.nativeOpenThread = nil
332 let appID = String(id.dropFirst(4))
333 let title = store.registry.title(appID)
334 Task {
335 if let tid = await store.openOrCreateAppThread(app: appID, title: title) {
336 selectedID = tid
337 }
338 }
339 return
340 }
341 guard let thread = store.threads.first(where: { $0.id == id }) else { return }
342 app.nativeOpenThread = nil
343 selectedID = id
344 store.markRead(thread)
345 store.reopenIfHidden(thread)
346 }
347
348 @EnvironmentObject private var app: MacAppState
349
350 private func publishPaletteChats() {
351 guard isActive else { return }
352 app.nativeChatsOwner = instance.id
353 // Every thread — hidden included, so ⌘K reopens closed chats — then
354 // people/bots with no DM yet ("user:" rows create one on pick) and
355 // personal apps not yet started ("app:" rows likewise).
356 var items: [MacAppState.NativePaletteChat] = store.threads.map {
357 .init(id: $0.id, title: $0.title, glyph: $0.glyph, avatar: $0.avatar, appId: $0.appId)
358 }
359 let dmPartners = Set(store.threads.filter { $0.kind == "dm" }.flatMap(\.members))
360 items += store.roster
361 .filter { $0.user_id != store.myUserId && !$0.label.isEmpty
362 && !dmPartners.contains($0.user_id) }
363 .map { .init(id: "user:" + $0.user_id, title: $0.label,
364 glyph: nil, avatar: store.avatarSource(for: $0)) }
365 let openApps = Set(store.threads.compactMap(\.appId))
366 items += store.registry.personalApps
367 .filter { !openApps.contains($0.name) }
368 .map { .init(id: "app:" + $0.name, title: $0.title, glyph: $0.glyph, avatar: nil, appId: $0.name) }
369 app.nativeChats = items
370 }
371
372 private var selectedThread: ChatThread? {
373 store.threads.first { $0.id == paneID }
374 }
375
376 // A surface the feed pane's ZStack renders itself (plain chat or group
377 // conversation) — the ones that take part in the outgoing-cover switch.
378 private func isFeedSurface(_ t: ChatThread) -> Bool {
379 !t.isApp && !isCompanion(t)
380 }
381
382 // A feed's position verifiably landed: record it as the last visible
383 // content, and if it's the CURRENT pane, drop the outgoing cover — the
384 // uncover and the incoming's reveal share one commit, so the switch cuts
385 // content-to-content.
386 private func feedDidSettle(_ threadID: String) {
387 lastSettledFeedID = threadID
388 if threadID == paneID, outgoingFeedThread != nil {
389 outgoingGen += 1
390 outgoingFeedThread = nil
391 }
392 }
393
394 // The pane's mounted feeds: the selected chat, plus the outgoing one
395 // being held over the settle beat. Keyed by thread id in the ForEach, so
396 // role swaps never change a feed's identity.
397 private var mountedFeedThreads: [ChatThread] {
398 var list: [ChatThread] = []
399 if let t = selectedThread { list.append(t) }
400 if let out = outgoingFeedThread, out.id != selectedThread?.id,
401 isFeedSurface(out) {
402 list.append(out)
403 }
404 return list
405 }
406
407 // ---- the resize handle + collapsed rail ---------------------------------
408
409 // A slim grab area over the divider; dragging resizes the list, and going
410 // under the threshold collapses it to the avatar rail.
411 private var resizeHandle: some View {
412 Divider()
413 .overlay(Color.clear.frame(width: 7).contentShape(Rectangle()))
414 .onHover { inside in
415 if inside { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() }
416 }
417 .gesture(
418 DragGesture(minimumDistance: 1, coordinateSpace: .global)
419 .onChanged { v in
420 let start = dragStartWidth ?? (collapsed ? 76 : listWidth)
421 dragStartWidth = start
422 listWidth = min(420, max(76, start + v.translation.width))
423 }
424 .onEnded { _ in dragStartWidth = nil }
425 )
426 }
427
428 // Avatars only — the narrow posture. Unread shows as a dot badge; the
429 // selected chat gets the accent ring. Titles ride in tooltips.
430 private var avatarRail: some View {
431 ScrollView {
432 VStack(spacing: 8) {
433 ForEach(visibleThreads) { thread in
434 Button {
435 selectThread(thread)
436 } label: {
437 ThreadAvatar(title: thread.title, source: thread.avatar, photo: thread.isEleven ? ElevenBrand.icon : nil, glyph: thread.glyph, symbol: AppIdentity.symbol(for: thread.appId), mark: AppIdentity.mark(for: thread.appId))
438 .overlay(alignment: .topTrailing) {
439 if thread.unread {
440 Circle().fill(theme.accent.resolved(scheme))
441 .frame(width: 10, height: 10)
442 .overlay(Circle().strokeBorder(
443 theme.sidebar.resolved(scheme), lineWidth: 2))
444 }
445 }
446 // The avatar itself is click-transparent (its tap
447 // gesture is lightbox-only), so the BUTTON needs
448 // its own hit shape — without one, a label made
449 // entirely of a non-hit-testing view leaves the
450 // button unclickable.
451 .contentShape(Circle())
452 }
453 .buttonStyle(RailRowButtonStyle(selected: thread.id == selectedID,
454 ring: theme.accent.resolved(scheme),
455 onPress: { selectThread(thread) }))
456 .help(thread.title)
457 .contextMenu { threadMenu(thread) }
458 }
459 }
460 .padding(.vertical, 12)
461 .frame(maxWidth: .infinity)
462 }
463 }
464
465 // The feed column: the web's chat header (avatar, title, ⋮ with pin/close)
466 // above the messages.
467 @ViewBuilder private var feedColumn: some View {
468 if let thread = selectedThread, !thread.isApp, !isCompanion(thread) {
469 // Plain chats: the header rides OVER the feed as a safe-area inset,
470 // frosted with a within-window blur, so messages visibly scroll
471 // beneath it — Messages.app's header treatment (see FrostedBand).
472 // The ZStack also carries the OUTGOING chat during a switch: keyed
473 // by thread id, so a feed keeps its identity (state, scroll,
474 // settled pixels) as it moves between the current and outgoing
475 // roles — the cover renders instantly, and dropping it uncovers
476 // the incoming feed in the same commit. No blank frame.
477 ZStack {
478 ForEach(mountedFeedThreads) { t in
479 Group {
480 if t.kind == "group" {
481 groupSurface(t)
482 } else {
483 nativeFeed(t)
484 }
485 }
486 .zIndex(t.id == outgoingFeedThread?.id ? 1 : 0)
487 .allowsHitTesting(t.id == thread.id)
488 }
489 }
490 .safeAreaInset(edge: .top, spacing: 0) {
491 VStack(spacing: 0) {
492 feedHeader(thread)
493 Divider()
494 // A DM partner renamed themself past my name for them
495 // (#577): the keep/adopt choice, inline under the header.
496 RenameBanner(store: store, thread: thread)
497 }
498 .background(FrostedBand())
499 // The whole visible band doubles as title bar — any
500 // non-interactive point in it drags the window.
501 .trackingFrame(into: dragZones.feed)
502 }
503 } else {
504 // App/companion surfaces host webviews that fill their frame (an
505 // NSView doesn't honor safe areas), so their header stays a
506 // stacked band above the pane.
507 VStack(spacing: 0) {
508 if let thread = selectedThread {
509 VStack(spacing: 0) {
510 feedHeader(thread)
511 Divider()
512 RenameBanner(store: store, thread: thread)
513 }
514 // Same title-bar duty as the plain-chat band above.
515 .trackingFrame(into: dragZones.feed)
516 }
517 feed
518 }
519 }
520 }
521
522 // "2 members, 1 bot, 2 AI agents · 1 online" — the web header's subtitle,
523 // from the roster's helper kinds (shell.js's count + ui.js
524 // memberCountLabel); the online count is people only.
525 private func memberStats(_ t: ChatThread) -> String? {
526 guard t.kind == "group" else { return nil }
527 var bots = 0, agents = 0
528 for id in t.members {
529 guard let e = store.roster.first(where: { $0.user_id == id }), e.isAgent else { continue }
530 if e.isAI { agents += 1 } else { bots += 1 }
531 }
532 let label = memberCountLabel(people: t.members.count - bots - agents, bots: bots, agents: agents)
533 let online = store.onlinePeopleCount(of: t)
534 return online > 0 ? label + " · \(online) online" : label
535 }
536
537 // Chat = the native feed; View = the app's interface; Both = chat with the
538 // interface in a resizable sidebar. Schema-described apps (To-do,
539 // Reminders) draw the interface pane NATIVE (SchemaAppView, assembled from
540 // the app's schema); anything else keeps the webview.
541 @ViewBuilder private func companionSplit(_ thread: ChatThread) -> some View {
542 let mode = appModes[thread.id] ?? .split
543 HStack(spacing: 0) {
544 if mode != .interface {
545 nativeFeed(thread)
546 }
547 if mode == .split {
548 appPaneResizeHandle
549 }
550 if mode != .chat {
551 appInterfacePane(thread)
552 .frame(width: mode == .split ? appPaneWidth : nil)
553 }
554 }
555 }
556
557 @ViewBuilder private func appInterfacePane(_ thread: ChatThread) -> some View {
558 if AppSchemaRegistry.hasSchema(for: thread.appId), !thread.threadKeyB64.isEmpty,
559 let schemaStore = SchemaAppStoreCache.shared.store(for: instance, thread: thread,
560 myUserId: store.myUserId) {
561 SchemaAppView(store: schemaStore)
562 .id(thread.id + "|" + thread.threadKeyB64 + "|schema")
563 } else {
564 AppWebSurface(instance: instance, thread: thread,
565 myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
566 .id(thread.id + "|" + thread.threadKeyB64 + "|app")
567 }
568 }
569
570 // A GROUP with shared apps (web: Chat | Split | <one slot per app>): same
571 // split shape as a companion app-thread, but the pane shows whichever
572 // added app the header dropdown picked. Folded reactively — the surface
573 // observes the thread's model, so an app added/removed by any member (or
574 // the New-group seeding) reshapes it live; a group with no apps is just
575 // the plain feed.
576 @ViewBuilder private func groupSurface(_ thread: ChatThread) -> some View {
577 GroupThreadSurface(model: feedModel(thread),
578 mode: groupModeBinding(thread),
579 selection: groupAppSelBinding(thread),
580 paneWidth: appPaneWidth,
581 feed: { AnyView(nativeFeed(thread)) },
582 handle: { AnyView(appPaneResizeHandle) },
583 pane: { appID in
584 AnyView(groupAppPane(thread, app: appID))
585 })
586 }
587
588 // A group's shared-app pane: schema-described apps (To-do, Reminders)
589 // render the shared instance NATIVE, folded from the group's log; the
590 // rest keep the webview embed.
591 @ViewBuilder private func groupAppPane(_ thread: ChatThread, app appID: String) -> some View {
592 if AppSchemaRegistry.hasSchema(for: appID), !thread.threadKeyB64.isEmpty,
593 let schemaStore = SchemaAppStoreCache.shared.store(for: instance, thread: thread,
594 myUserId: store.myUserId, appId: appID) {
595 SchemaAppView(store: schemaStore)
596 .id(thread.id + "|" + thread.threadKeyB64 + "|" + appID + "|group-schema")
597 } else {
598 AppWebSurface(instance: instance, thread: thread,
599 myUserId: store.myUserId, instanceKeyB64: store.instanceKey(),
600 app: appID)
601 .id(thread.id + "|" + thread.threadKeyB64 + "|group-pane")
602 }
603 }
604
605 // Chat = a bubble, Both = a split pane, View = the app's own face.
606 private func appModeSwitch(_ t: ChatThread) -> some View {
607 let binding = appModeBinding(t)
608 return HStack(spacing: 2) {
609 appModeButton(.chat, binding) {
610 Image(systemName: "bubble.left")
611 .font(.system(size: 12, weight: .medium))
612 }
613 appModeButton(.split, binding) {
614 Image(systemName: "rectangle.split.2x1")
615 .font(.system(size: 12, weight: .medium))
616 }
617 appModeButton(.interface, binding) {
618 ThreadAvatar(title: t.title, source: t.avatar, size: 16)
619 }
620 }
621 .padding(2)
622 .background(RoundedRectangle(cornerRadius: 7, style: .continuous)
623 .fill(.primary.opacity(0.06)))
624 }
625
626 private func appModeButton<Icon: View>(_ mode: AppViewMode,
627 _ binding: Binding<AppViewMode>,
628 @ViewBuilder icon: () -> Icon) -> some View {
629 Button {
630 binding.wrappedValue = mode
631 } label: {
632 icon()
633 .frame(width: 32, height: 20)
634 .background(RoundedRectangle(cornerRadius: 5, style: .continuous)
635 .fill(binding.wrappedValue == mode
636 ? AnyShapeStyle(theme.panel.resolved(scheme))
637 : AnyShapeStyle(.clear))
638 .shadow(color: .black.opacity(binding.wrappedValue == mode ? 0.15 : 0),
639 radius: 1, y: 0.5))
640 .contentShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
641 }
642 .buttonStyle(.plain)
643 .help(mode.rawValue)
644 }
645
646 private var appPaneResizeHandle: some View {
647 Divider()
648 .overlay(Color.clear.frame(width: 7).contentShape(Rectangle()))
649 .onHover { inside in
650 if inside { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() }
651 }
652 .gesture(
653 DragGesture(minimumDistance: 1, coordinateSpace: .global)
654 .onChanged { v in
655 let start = appPaneDragStart ?? appPaneWidth
656 appPaneDragStart = start
657 // The sidebar sits on the RIGHT: dragging left grows it.
658 appPaneWidth = min(640, max(260, start - v.translation.width))
659 }
660 .onEnded { _ in appPaneDragStart = nil }
661 )
662 }
663
664 // The plain native feed for a thread — shared by regular chats and the
665 // companion split's Chat pane.
666 private func nativeFeed(_ thread: ChatThread) -> some View {
667 let model = NativeFeedModelCache.shared.model(for: instance, thread: thread,
668 myUserId: store.myUserId)
669 return NativeFeedView(model: model,
670 thread: thread, myUserId: store.myUserId,
671 isGroup: thread.kind == "group" || thread.members.count > 2,
672 isActive: isActive,
673 roster: store.roster,
674 instance: instance, instanceKeyB64: store.instanceKey(),
675 store: store,
676 mentionAvatar: { [weak store] uid in
677 store.flatMap { s in
678 s.roster.first { $0.user_id == uid }.flatMap(s.avatarSource(for:))
679 }
680 },
681 // The closed banner's Reopen (#578) goes through
682 // the store so the sidebar row un-hides in the
683 // same beat as the composer's return.
684 onReopenDM: { [weak store] in
685 await store?.reopenDM(thread.id) ?? .failed
686 },
687 // Mute/unmute a sender (#578) — the feed reads
688 // the muted set off its model; writes go through
689 // the store's sealed-prefs save.
690 onMutePerson: { [weak store] uid, on in
691 Task { await store?.setPersonMuted(uid: uid, muted: on) }
692 },
693 // Report a sender (#578) — this view owns the
694 // sheet (it holds the store that seals to the
695 // admins' keys).
696 onReport: { m in
697 guard let uid = m.raw.uid, !uid.isEmpty else { return }
698 reportTarget = ReportTarget(
699 thread: thread, uid: uid,
700 evidence: ReportEvidence(message: m))
701 },
702 // The switch cover's drop signal: this feed is
703 // now visibly landed, so the outgoing chat held
704 // over its settle beat can go.
705 onSettled: { feedDidSettle(thread.id) },
706 // A tapped @mention opens the same profile card
707 // the DM header does (#577/#578).
708 onMemberProfile: { headerProfile = HeaderProfileTarget(id: $0) })
709 .id(thread.id + "|" + thread.threadKeyB64 + "|"
710 + String(UInt(bitPattern: ObjectIdentifier(model).hashValue)))
711 // The Text Size pref (View menu ⌘+/⌘−, Settings slider) drives the
712 // feed's reading size — bubbles + composer; the list stays put.
713 .environment(\.chatFontSize, CGFloat(ChatTextSize.snap(chatTextSize)))
714 }
715
716 private func feedHeader(_ t: ChatThread) -> some View {
717 HStack(spacing: 10) {
718 ThreadAvatar(title: t.title, source: t.avatar, size: 34, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph, symbol: AppIdentity.symbol(for: t.appId), mark: AppIdentity.mark(for: t.appId),
719 onTap: t.glyph == nil ? openAvatarLightbox(named: t.title) : nil)
720 VStack(alignment: .leading, spacing: 0) {
721 HStack(spacing: 5) {
722 Text(t.title)
723 .font(.system(size: 15, weight: .semibold))
724 .lineLimit(1)
725 if let badge = t.badge {
726 AgentBadge(badge)
727 }
728 }
729 if let stats = memberStats(t) {
730 // A fast path to the members list — the counts read as
731 // tappable, so wire them straight to the same MembersSheet
732 // the ⋯ menu's "View Members…" opens (which stays, as the
733 // labelled/fallback path). Pointing-hand cursor signals it.
734 Button { membersSheetThread = t } label: {
735 Text(stats)
736 .font(.system(size: 11))
737 .foregroundStyle(.secondary)
738 .lineLimit(1)
739 .contentShape(Rectangle())
740 }
741 .buttonStyle(.plain)
742 .onHover { $0 ? NSCursor.pointingHand.push() : NSCursor.pop() }
743 .accessibilityLabel(stats.replacingOccurrences(of: " · ", with: ", "))
744 .accessibilityHint("View members")
745 } else if t.kind == "dm" {
746 // Dot + Online/Away/Offline under the name (web parity).
747 PresenceStatusLine(t.presence)
748 .font(.system(size: 11))
749 .foregroundStyle(.secondary)
750 }
751 }
752 // A DM's header name opens the person's profile card — where
753 // "call them something else" and the rename notice live (#577),
754 // matching the web header title.
755 .contentShape(Rectangle())
756 .onTapGesture {
757 guard t.kind == "dm",
758 let other = t.members.first(where: { $0 != store.myUserId }) else { return }
759 headerProfile = HeaderProfileTarget(id: other)
760 }
761 .accessibilityAddTraits(t.kind == "dm" ? .isButton : [])
762 Spacer()
763 // A companion app's view switch (web: Chat | Split | Interface) —
764 // hand-rolled segments so the View segment can be the app's own
765 // avatar (a stock segmented control only takes text/images).
766 if isCompanion(t) {
767 appModeSwitch(t)
768 } else if t.kind == "group" {
769 // A group with shared apps gets the same bar, its third
770 // segment a dropdown of the added apps. Observes the thread's
771 // model so the bar appears/disappears as apps are added or
772 // removed (by anyone).
773 GroupAppsHeaderControls(model: feedModel(t)) { apps in
774 AnyView(groupModeSwitch(t, apps: apps))
775 }
776 }
777 Menu {
778 // The chat's VIEWS come first — the surfaces you switch into —
779 // then the management verbs, the same order the web's ▤ menu
780 // lists them in. Today that's one row (Threads); Memory and bot
781 // views, which the web already lists, have a home here when
782 // they land natively.
783 ThreadsMenuItem(model: feedModel(t)) { app.showReplyThreadList(t.id) }
784 threadMenu(t)
785 } label: {
786 Image(systemName: "ellipsis")
787 .font(.system(size: 14, weight: .medium))
788 .frame(width: 28, height: 28, alignment: .center)
789 .contentShape(Rectangle())
790 }
791 .menuStyle(.button)
792 .buttonStyle(.plain)
793 .menuIndicator(.hidden)
794 .hoverCircle()
795 }
796 .padding(.horizontal, 14)
797 // A hair shorter than the side-head, so its border sits 1pt above the
798 // search box's top edge. (The window-drag zone tracks the whole
799 // header band at the call sites, not this inner row — tracking only
800 // the row left the band's lower reaches unable to drag the window.)
801 .frame(height: Self.headerHeight - 1)
802 }
803
804 @State private var membersSheetThread: ChatThread?
805 @State private var renameSheetThread: ChatThread?
806 // A DM header's profile card (rename them for yourself there — #577).
807 @State private var headerProfile: HeaderProfileTarget?
808 struct HeaderProfileTarget: Identifiable { let id: String }
809 // A message's Report action (#578): reported sender + the message as
810 // evidence + the thread it happened in, feeding the shared ReportSheet.
811 @State private var reportTarget: ReportTarget?
812 struct ReportTarget: Identifiable {
813 let thread: ChatThread
814 let uid: String
815 let evidence: ReportEvidence
816 var id: Int64 { evidence.sid }
817 }
818
819 // One menu for every surface a thread shows on: rows, the rail, the header.
820 @ViewBuilder private func threadMenu(_ t: ChatThread) -> some View {
821 if t.kind == "group" {
822 Button("View Members…") { membersSheetThread = t }
823 Button("Rename Group…") { renameSheetThread = t }
824 Button("Manage Apps…") { appsSheetThread = t }
825 Divider()
826 }
827 // Media, Links & Docs — DMs and groups alike (NOT in the group
828 // block); app threads have no shared-content feed, so it hides there.
829 if !t.isApp {
830 // Its own window (resizable — a screenshot full of small text
831 // needs to be made big), not a sheet: MediaWindows.
832 Button("Media, Links & Docs…") {
833 MediaWindows.show(instance: instance, thread: t, store: store,
834 model: feedModel(t),
835 onJump: { sid in
836 // Land the feed on the message — the
837 // search hit's path.
838 selectedID = t.id
839 app.nativeJump = .init(thread: t.id, message: sid)
840 })
841 }
842 Divider()
843 }
844 Button(t.pinned ? "Unpin" : "Pin") { store.setPinned(t, !t.pinned) }
845 Button(t.isMuted ? "Unmute chat" : "Mute chat") { store.setMuted(t, !t.isMuted) }
846 Button(t.unread ? "Mark as Read" : "Mark as Unread") {
847 t.unread ? store.markRead(t) : store.markUnread(t)
848 }
849 if t.hidden {
850 Button("Reopen") { store.setHidden(t, false) }
851 } else {
852 Divider()
853 Button("Close") {
854 store.setHidden(t, true)
855 if selectedID == t.id {
856 selectedID = store.threads.first(where: { !$0.hidden && $0.id != t.id })?.id
857 }
858 }
859 }
860 // End conversation (#578): DM-only, stronger than Close — mutual and
861 // server-enforced, hence the confirm. Once ended, the closed banner's
862 // Reopen is the way back, so the item hides on an ended DM too.
863 if t.kind == "dm", !t.dmClosed {
864 Button("End Conversation…", role: .destructive) { endConversationTarget = t }
865 }
866#if DEBUG
867 Divider()
868 // Dev builds only (ClickLab's gate): a floating window of this
869 // thread's raw hub frames + its unread inputs — see ThreadDebug.swift.
870 Button("Debug Events") { ThreadDebugWindows.show(thread: t, store: store) }
871#endif
872 }
873
874 // The End-conversation confirm's target thread (#578) — nil = no dialog.
875 @State private var endConversationTarget: ChatThread?
876
877 // ---- companion apps: native chat beside the app's web interface --------
878
879 // The companion split (web: Chat | Split | Interface): the CHAT half is
880 // the native feed, the interface stays the app's webview in a resizable
881 // sidebar. Which apps are companion is the manifest's declaration
882 // (appWantsChatView, pre-answered in the registry catalogue) — no
883 // hand-kept set to keep in sync anymore.
884 enum AppViewMode: String, CaseIterable {
885 case chat = "Chat", split = "Both", interface = "View"
886 }
887 @State private var appModes: [String: AppViewMode] = [:] // per-thread, session-only
888 @AppStorage("macAppPaneWidth") private var appPaneWidth = 380.0
889 @State private var appPaneDragStart: Double?
890
891 private func isCompanion(_ t: ChatThread) -> Bool {
892 // The full presentation decision, not the bare companion flag: Eleven's
893 // conversations (setup/welcome) are chatOnly companions on the web but
894 // run on the FLOW interpreter here — presentation() puts flow first,
895 // and a chat-only companion has no interface to split with, so both
896 // fall through to the ladder's later branches (a bare isCompanion
897 // check split Eleven into two copies of the same chat).
898 guard t.isApp else { return false }
899 switch store.registry.presentation(t.appId ?? "") {
900 case .schema(let companion):
901 return companion
902 case .companion(_, let chatOnly, _):
903 return !chatOnly
904 default:
905 return false // flow and takeover
906 }
907 }
908
909 private func appModeBinding(_ t: ChatThread) -> Binding<AppViewMode> {
910 // First open honours the app's own defaultView ("interface" is how
911 // Admin opens on its console; the web's appDefaultView) — after that
912 // the member's per-session choice sticks.
913 Binding(get: { appModes[t.id] ?? initialMode(t) },
914 set: { appModes[t.id] = $0 })
915 }
916
917 private func initialMode(_ t: ChatThread) -> AppViewMode {
918 guard case .companion(let defaultView, let chatOnly, _) =
919 store.registry.presentation(t.appId ?? "") else { return .split }
920 if chatOnly { return .chat }
921 switch defaultView {
922 case "chat": return .chat
923 case "interface": return .interface
924 default: return .split
925 }
926 }
927
928 // ---- group apps: the shared widgets a group added (web's groupapps) ----
929
930 // Which added app a group's pane shows, per thread, session-only — the
931 // web's groupAppView.
932 @State private var groupAppSel: [String: String] = [:]
933 // The manage-apps sheet's thread (⋮ → Manage Apps…).
934 @State private var appsSheetThread: ChatThread?
935
936 private func feedModel(_ t: ChatThread) -> ChatFeedModel {
937 NativeFeedModelCache.shared.model(for: instance, thread: t, myUserId: store.myUserId)
938 }
939
940 // Groups default to the conversation ("a group is first a chat", the web's
941 // rule) — the companion binding above defaults to .split instead.
942 private func groupModeBinding(_ t: ChatThread) -> Binding<AppViewMode> {
943 Binding(get: { appModes[t.id] ?? .chat },
944 set: { appModes[t.id] = $0 })
945 }
946
947 private func groupAppSelBinding(_ t: ChatThread) -> Binding<String?> {
948 Binding(get: { groupAppSel[t.id] },
949 set: { groupAppSel[t.id] = $0 })
950 }
951
952 // The group header's controls: the SAME Chat | Both | View segments a
953 // companion app-thread gets (the third segment wears the shown app's
954 // glyph and zooms it full-pane), plus a separate menu button OUTSIDE the
955 // group — the view selector: pick which added app the pane shows, and
956 // Manage Apps….
957 private func groupModeSwitch(_ t: ChatThread, apps: [GroupApps.Item]) -> some View {
958 let binding = groupModeBinding(t)
959 let sel = groupAppSelBinding(t)
960 let active = apps.first { $0.id == sel.wrappedValue }?.id ?? apps.first?.id
961 return HStack(spacing: 6) {
962 HStack(spacing: 2) {
963 appModeButton(.chat, binding) {
964 Image(systemName: "bubble.left")
965 .font(.system(size: 12, weight: .medium))
966 }
967 appModeButton(.split, binding) {
968 Image(systemName: "rectangle.split.2x1")
969 .font(.system(size: 12, weight: .medium))
970 }
971 appModeButton(.interface, binding) {
972 Text(store.registry.glyph(active ?? ""))
973 .font(.system(size: 11))
974 }
975 }
976 .padding(2)
977 .background(RoundedRectangle(cornerRadius: 7, style: .continuous)
978 .fill(.primary.opacity(0.06)))
979 // The view selector, its own affordance beside the segments:
980 // glyph + name per added app (picking one shows it — beside the
981 // chat first, like tapping its web widget button), then the
982 // management door.
983 Menu {
984 ForEach(apps) { app in
985 Button {
986 sel.wrappedValue = app.id
987 if binding.wrappedValue == .chat { binding.wrappedValue = .split }
988 } label: {
989 Text("\(store.registry.glyph(app.id)) \(store.registry.title(app.id))")
990 }
991 }
992 Divider()
993 Button("Manage Apps…") { appsSheetThread = t }
994 } label: {
995 Image(systemName: "chevron.down")
996 .font(.system(size: 12, weight: .medium))
997 .frame(width: 28, height: 28, alignment: .center)
998 .contentShape(Rectangle())
999 }
1000 .menuStyle(.button)
1001 .buttonStyle(.plain)
1002 .menuIndicator(.hidden)
1003 .hoverCircle()
1004 .help("Apps")
1005 }
1006 }
1007
1008 @ViewBuilder private var feed: some View {
1009 if let sel = selectedID, sel.hasPrefix("space:") {
1010 spaceAppPane(String(sel.dropFirst(6)))
1011 } else if let thread = selectedThread, isCompanion(thread) {
1012 companionSplit(thread)
1013 } else if let thread = selectedThread,
1014 thread.appId == "setup" || thread.appId == "welcome" {
1015 // Eleven's onboarding conversations run FULLY native — the
1016 // bundled flow interpreter + a SwiftUI chat, no webview.
1017 ElevenConversationPane(instance: instance, thread: thread, store: store,
1018 selectedID: $selectedID)
1019 .id(thread.id + "|" + thread.threadKeyB64 + "|eleven")
1020 } else if let thread = selectedThread, thread.isApp {
1021 // Takeover mini-apps get the app's own interface, full pane —
1022 // never the raw native event-log feed. Schema-described apps
1023 // render it native; the rest keep their webview.
1024 appInterfacePane(thread)
1025 } else if let thread = selectedThread, thread.kind == "group" {
1026 groupSurface(thread)
1027 } else if let thread = selectedThread {
1028 nativeFeed(thread)
1029 } else {
1030 VStack(spacing: 10) {
1031 Text(store.loading ? "Loading chats…" : (store.loadError ?? "Pick a chat."))
1032 .font(.callout)
1033 .foregroundStyle(.secondary)
1034 }
1035 .frame(maxWidth: .infinity, maxHeight: .infinity)
1036 }
1037 }
1038
1039 // A space app rendered full-pane (the web's openSpaceApp): the webview
1040 // seeded with the SPACE thread + its key, wearing the app's identity —
1041 // bootEmbed's space flag routes it through the web's own space pane.
1042 @ViewBuilder private func spaceAppPane(_ app: String) -> some View {
1043 if let thread = store.spaceApps.surfaceThread(
1044 app: app, title: store.registry.title(app), glyph: store.registry.glyph(app)) {
1045 AppWebSurface(instance: instance, thread: thread, myUserId: store.myUserId,
1046 instanceKeyB64: store.instanceKey(), app: app, space: true)
1047 .id("space|" + thread.id + "|" + app)
1048 } else {
1049 Text("This app isn't ready yet — try again in a moment.")
1050 .font(.callout)
1051 .foregroundStyle(.secondary)
1052 .frame(maxWidth: .infinity, maxHeight: .infinity)
1053 }
1054 }
1055
1056 // ---- the sidebar header (the web's side-head: name, ⋮, new chat, search)
1057
1058 private var myAvatarSource: AvatarSource? {
1059 store.roster.first { $0.user_id == store.myUserId }.flatMap(store.avatarSource(for:))
1060 }
1061 private var myLabel: String {
1062 store.roster.first { $0.user_id == store.myUserId }?.label ?? instance.screenName
1063 }
1064
1065 private var listHeader: some View {
1066 VStack(spacing: 0) {
1067 // The web's side-head: your avatar, "Chats", then the
1068 // hover-highlight icon buttons (⋮ menu, new chat). Taller, with
1069 // its bottom border flush against the search box below.
1070 HStack(spacing: 10) {
1071 ThreadAvatar(title: myLabel, source: myAvatarSource, size: 34,
1072 onTap: openAvatarLightbox(named: myLabel))
1073 // The SPACE'S name, not a generic "Chats" — the sidebar
1074 // header is the one place that says where you are (the web's
1075 // side-head shows the group name the same way). "Chats" only
1076 // while the name hasn't loaded.
1077 Text(store.instanceName.isEmpty ? "Chats" : store.instanceName)
1078 .font(.system(size: 19, weight: .bold))
1079 .lineLimit(1)
1080 Spacer(minLength: 4)
1081 // menuStyle(.button) + plain buttonStyle: the label lays out
1082 // as OUR view (glyph centered in its 30pt box) — the
1083 // borderless-button style added its own asymmetric insets.
1084 Menu {
1085 Button("New Chat…") { showNewChat = true }
1086 Button(notificationsDenied ? "Notifications Off — Open Settings…"
1087 : "Enable Notifications…") {
1088 requestNotifications()
1089 }
1090 Divider()
1091 Button("Invite Someone…") { showInvite = true }
1092 Button("Discover Groups…") { showDiscover = true }
1093 Button("Refresh") { Task { await store.refresh() } }
1094 } label: {
1095 Image(systemName: "ellipsis")
1096 .font(.system(size: 15, weight: .medium))
1097 .frame(width: 30, height: 30, alignment: .center)
1098 .contentShape(Rectangle())
1099 }
1100 .menuStyle(.button)
1101 .buttonStyle(.plain)
1102 .menuIndicator(.hidden)
1103 .hoverCircle()
1104 Button {
1105 showNewChat = true
1106 } label: {
1107 Image(systemName: "square.and.pencil")
1108 .font(.system(size: 15, weight: .medium))
1109 .frame(width: 30, height: 30, alignment: .center)
1110 .contentShape(Rectangle())
1111 }
1112 .buttonStyle(.plain)
1113 .hoverCircle()
1114 .help("New chat")
1115 .popover(isPresented: $showNewChat, arrowEdge: .bottom) {
1116 NewChatPopover(store: store) { threadID in
1117 showNewChat = false
1118 selectedID = threadID
1119 }
1120 }
1121 .sheet(isPresented: $showDiscover) {
1122 // The community directory (shared view; web: showDiscover).
1123 VStack(spacing: 0) {
1124 DiscoverGroupsView(store: store) { threadID in
1125 showDiscover = false
1126 selectedID = threadID
1127 }
1128 Divider()
1129 HStack {
1130 Spacer()
1131 Button("Done") { showDiscover = false }
1132 .keyboardShortcut(.cancelAction)
1133 }
1134 .padding(12)
1135 }
1136 .frame(width: 380, height: 420)
1137 }
1138 }
1139 .padding(.horizontal, 12)
1140 .frame(height: Self.headerHeight)
1141 // Doubles as title bar, down to the search box's top edge.
1142 .trackingFrame(into: dragZones.list)
1143 // Thread search — like the web's, closed chats surface here too,
1144 // so search is also how a closed chat gets found and reopened.
1145 // Sits right under the header's border.
1146 HStack(spacing: 6) {
1147 Image(systemName: "magnifyingglass")
1148 .font(.system(size: 11))
1149 .foregroundStyle(.tertiary)
1150 TextField("Search", text: $search)
1151 .textFieldStyle(.plain)
1152 .font(.system(size: 13))
1153 .focused($searchFocused)
1154 // Keyboard-driven typeahead: arrows move the highlight
1155 // through the results panel, Return opens, Esc clears.
1156 .onKeyPress(.downArrow) {
1157 guard searchResultCount > 0 else { return .ignored }
1158 searchSel = min(searchSel + 1, searchResultCount - 1)
1159 return .handled
1160 }
1161 .onKeyPress(.upArrow) {
1162 guard searchResultCount > 0 else { return .ignored }
1163 searchSel = max(searchSel - 1, 0)
1164 return .handled
1165 }
1166 .onKeyPress(.return) {
1167 guard searching else { return .ignored }
1168 openSearchSelection()
1169 return .handled
1170 }
1171 .onKeyPress(.escape) {
1172 guard searching else { return .ignored }
1173 search = ""
1174 return .handled
1175 }
1176 // List filters (web's funnel beside search): online-only +
1177 // the People/Bots/Apps categories. Filled while narrowing.
1178 Menu {
1179 // A picker in a fixed order — the checkmark marks the view
1180 // you're in, and the usual accidental click (the top row,
1181 // while unfiltered) re-picks "All chats", a no-op.
1182 Picker("View", selection: $filterOnline) {
1183 Text("All chats").tag(false)
1184 Text("Online only").tag(true)
1185 }
1186 .pickerStyle(.inline)
1187 Divider()
1188 Toggle("People", isOn: $filterPeople)
1189 Toggle("Bots", isOn: $filterBots)
1190 Toggle("Apps", isOn: $filterApps)
1191 } label: {
1192 Image(systemName: filtering
1193 ? "line.3.horizontal.decrease.circle.fill"
1194 : "line.3.horizontal.decrease.circle")
1195 .font(.system(size: 12))
1196 .foregroundStyle(filtering ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
1197 .contentShape(Rectangle())
1198 }
1199 .menuStyle(.button)
1200 .buttonStyle(.plain)
1201 .menuIndicator(.hidden)
1202 .help("Filter chats")
1203 }
1204 .padding(.horizontal, 8)
1205 .padding(.vertical, 5)
1206 .background(RoundedRectangle(cornerRadius: 8, style: .continuous)
1207 .fill(.primary.opacity(0.06)))
1208 .padding(EdgeInsets(top: 0, leading: 12, bottom: 8, trailing: 12))
1209 }
1210 }
1211
1212 // ---- search typeahead (Messages.app-style dropdown) ----------------------
1213
1214 @State private var searchHits: [(thread: String, message: MessageStore.StoredMessage)] = []
1215 // The #584 deep walk (shared LChatCore runner — same one the iOS list
1216 // uses): fetch-and-file full history so search reaches the beginning.
1217 @StateObject private var backfill = HistoryBackfill()
1218 @State private var searchSel = 0
1219
1220 private var searching: Bool { !search.trimmingCharacters(in: .whitespaces).isEmpty }
1221
1222 // Conversations whose TITLE matches — hidden ones included, so search is
1223 // still how a closed chat gets found and reopened.
1224 private var convoMatches: [ChatThread] {
1225 let q = search.trimmingCharacters(in: .whitespaces).lowercased()
1226 guard !q.isEmpty else { return [] }
1227 return Array(store.threads.filter { $0.title.lowercased().contains(q) }.prefix(6))
1228 }
1229
1230 // FTS hits mapped back to their threads (newest first, one per row).
1231 private var messageMatches: [(thread: ChatThread, message: MessageStore.StoredMessage)] {
1232 searchHits.compactMap { hit in
1233 store.threads.first { $0.id == hit.thread }.map { ($0, hit.message) }
1234 }
1235 }
1236
1237 private var searchResultCount: Int { convoMatches.count + messageMatches.count }
1238
1239 private func openSearchSelection() {
1240 let convos = convoMatches
1241 let msgs = messageMatches
1242 let sel = min(searchSel, convos.count + msgs.count - 1)
1243 guard sel >= 0 else { return }
1244 let thread = sel < convos.count ? convos[sel] : msgs[sel - convos.count].thread
1245 selectedID = thread.id
1246 store.markRead(thread)
1247 store.reopenIfHidden(thread)
1248 // A message hit doesn't just open the chat — it lands ON the message
1249 // (the feed scrolls there and the bubble waves).
1250 if sel >= convos.count {
1251 app.nativeJump = .init(thread: thread.id,
1252 message: msgs[sel - convos.count].message.id)
1253 }
1254 search = ""
1255 }
1256
1257 private var searchPanel: some View {
1258 VStack(alignment: .leading, spacing: 0) {
1259 if !convoMatches.isEmpty {
1260 Text("Conversations")
1261 .font(.system(size: 11, weight: .semibold))
1262 .foregroundStyle(.tertiary)
1263 .padding(EdgeInsets(top: 8, leading: 12, bottom: 4, trailing: 12))
1264 ForEach(Array(convoMatches.enumerated()), id: \.element.id) { i, t in
1265 searchRow(index: i) {
1266 ThreadAvatar(title: t.title, source: t.avatar, size: 24, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph, symbol: AppIdentity.symbol(for: t.appId), mark: AppIdentity.mark(for: t.appId))
1267 Text(t.title).font(.system(size: 13, weight: .medium)).lineLimit(1)
1268 }
1269 }
1270 }
1271 if !messageMatches.isEmpty {
1272 if !convoMatches.isEmpty { Divider().padding(.vertical, 4) }
1273 Text("Messages")
1274 .font(.system(size: 11, weight: .semibold))
1275 .foregroundStyle(.tertiary)
1276 .padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
1277 ForEach(Array(messageMatches.enumerated()), id: \.offset) { i, hit in
1278 searchRow(index: convoMatches.count + i) {
1279 ThreadAvatar(title: hit.thread.title, source: hit.thread.avatar,
1280 size: 24, glyph: hit.thread.glyph,
1281 symbol: AppIdentity.symbol(for: hit.thread.appId),
1282 mark: AppIdentity.mark(for: hit.thread.appId))
1283 VStack(alignment: .leading, spacing: 0) {
1284 Text(hit.thread.title)
1285 .font(.system(size: 12, weight: .medium)).lineLimit(1)
1286 Text((hit.message.payload.name.map { "\($0): " } ?? "")
1287 + (hit.message.payload.text ?? ""))
1288 .font(.system(size: 12))
1289 .foregroundStyle(.secondary)
1290 .lineLimit(1)
1291 }
1292 }
1293 }
1294 }
1295 if searchResultCount == 0 {
1296 Text("No matches")
1297 .font(.system(size: 12))
1298 .foregroundStyle(.secondary)
1299 .padding(12)
1300 }
1301 // "Search earlier messages" (#584): offered until every chat's
1302 // history is local; while the walk runs, the #228 time machine
1303 // narrates and Stop cancels (the watermark persists — a later
1304 // run resumes where this one stopped).
1305 if !backfill.completed.contains(instance.id) {
1306 Divider().padding(.vertical, 4)
1307 if backfill.running {
1308 HStack(spacing: 8) {
1309 Text(backfill.progress ?? "Searching…")
1310 .font(.system(size: 12))
1311 .foregroundStyle(.secondary)
1312 Spacer(minLength: 0)
1313 Button("Stop") { backfill.cancel() }
1314 .buttonStyle(.plain)
1315 .font(.system(size: 12, weight: .semibold))
1316 .foregroundStyle(theme.accent.resolved(scheme))
1317 }
1318 .padding(.horizontal, 12)
1319 .padding(.vertical, 4)
1320 .accessibilityElement(children: .combine)
1321 } else {
1322 Button("Search earlier messages…") {
1323 backfill.start(instance: instance)
1324 }
1325 .buttonStyle(.plain)
1326 .font(.system(size: 12, weight: .semibold))
1327 .foregroundStyle(theme.accent.resolved(scheme))
1328 .padding(.horizontal, 12)
1329 .padding(.vertical, 4)
1330 }
1331 }
1332 }
1333 .padding(.bottom, 6)
1334 .frame(maxWidth: .infinity, alignment: .leading)
1335 .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
1336 .fill(theme.panel.resolved(scheme))
1337 .shadow(color: .black.opacity(0.18), radius: 10, y: 3))
1338 .padding(.horizontal, 8)
1339 // Debounced FTS against the local store (decrypted at ingest — the
1340 // search your SQLite proposal was for).
1341 // Keyed on the query AND the walk's page counter, so a running deep
1342 // search streams fresh hits into the open results.
1343 .task(id: "\(search)|\(backfill.pageTick)") {
1344 searchSel = 0
1345 let q = search.trimmingCharacters(in: .whitespaces)
1346 guard !q.isEmpty else { searchHits = []; return }
1347 try? await Task.sleep(nanoseconds: 120_000_000)
1348 guard !Task.isCancelled else { return }
1349 let escaped = "\"" + q.replacingOccurrences(of: "\"", with: "") + "\"*"
1350 searchHits = Array(await MessageStore.shared
1351 .search(instance: instance.id, query: escaped, limit: 8))
1352 }
1353 }
1354
1355 private func searchRow(index: Int, @ViewBuilder content: () -> some View) -> some View {
1356 Button {
1357 searchSel = index
1358 openSearchSelection()
1359 } label: {
1360 HStack(spacing: 8) { content(); Spacer(minLength: 0) }
1361 .padding(.horizontal, 10)
1362 .padding(.vertical, 4)
1363 .frame(maxWidth: .infinity, alignment: .leading)
1364 .background(RoundedRectangle(cornerRadius: 6, style: .continuous)
1365 .fill(index == searchSel ? theme.accent.resolved(scheme).opacity(0.18)
1366 : .clear))
1367 .contentShape(Rectangle())
1368 }
1369 .buttonStyle(.plain)
1370 .padding(.horizontal, 6)
1371 .onHover { inside in if inside { searchSel = index } }
1372 }
1373
1374 // One height for the side-head and the chat header, so their bottom edges
1375 // line up across the divider.
1376 static let headerHeight: CGFloat = 60
1377
1378 // Notification permission plumbing for the ⋮ menu: request when
1379 // undecided, route to System Settings once denied (apps can't re-prompt).
1380 @State private var notificationsDenied = false
1381
1382 private func refreshNotificationStatus() {
1383 UNUserNotificationCenter.current().getNotificationSettings { settings in
1384 let denied = settings.authorizationStatus == .denied
1385 DispatchQueue.main.async { notificationsDenied = denied }
1386 }
1387 }
1388
1389 private func requestNotifications() {
1390 UNUserNotificationCenter.current().getNotificationSettings { settings in
1391 DispatchQueue.main.async {
1392 switch settings.authorizationStatus {
1393 case .denied:
1394 NSWorkspace.shared.open(URL(
1395 string: "x-apple.systempreferences:com.apple.preference.notifications")!)
1396 default:
1397 UNUserNotificationCenter.current()
1398 .requestAuthorization(options: [.alert, .sound]) { _, _ in
1399 refreshNotificationStatus()
1400 }
1401 }
1402 }
1403 }
1404 }
1405
1406 // Tapping an avatar that has a real photo zooms it into the lightbox
1407 // (nil-op for initials-only circles).
1408 private func openAvatarLightbox(named name: String) -> (PlatformImage, CGRect) -> Void {
1409 { image, frame in
1410 app.lightbox = LightboxItem(id: "avatar|" + name, name: name,
1411 image: image, sourceFrame: frame)
1412 }
1413 }
1414
1415 // ---- the thread list ---------------------------------------------------
1416
1417 // The funnel menu's filters (web parity: Online only + People/Bots/Apps).
1418 @State private var filterOnline = false
1419 @State private var filterPeople = true
1420 @State private var filterBots = true
1421 @State private var filterApps = true
1422 private var filtering: Bool { filterOnline || !filterPeople || !filterBots || !filterApps }
1423
1424 private var visibleThreads: [ChatThread] {
1425 let q = search.trimmingCharacters(in: .whitespaces).lowercased()
1426 var base = q.isEmpty
1427 ? store.threads.filter { !$0.hidden }
1428 : store.threads.filter { $0.title.lowercased().contains(q) }
1429 if filtering {
1430 base = base.filter {
1431 store.passesFilter($0, onlineOnly: filterOnline,
1432 people: filterPeople, bots: filterBots, apps: filterApps)
1433 }
1434 }
1435 return base
1436 }
1437
1438 private var threadList: some View {
1439 ScrollView {
1440 LazyVStack(spacing: 1) {
1441 // An active funnel says so in words at the top of the list —
1442 // the filled glyph alone proved too quiet a cue when a chat
1443 // silently vanished. Inline (scrolls with the rows), with a
1444 // one-click way back to everything.
1445 if filtering { filterChip }
1446 ForEach(visibleThreads) { thread in
1447 ThreadRowCell(key: ThreadRowKey(thread: thread,
1448 selected: thread.id == selectedID,
1449 timeLabel: thread.lastAt > 0 ? relTime(thread.lastAt) : ""),
1450 content: { AnyView(threadRow(thread)) })
1451 .equatable()
1452 }
1453 // The SPACE BAND (docs/native-apps.md phase 4): one row per
1454 // space-wide install with an openable surface — the web
1455 // sidebar's space rows, folded from the space thread
1456 // (SpaceAppsStore) and filtered through the registry.
1457 if search.trimmingCharacters(in: .whitespaces).isEmpty,
1458 (!filtering || filterApps), !filterOnline {
1459 SpaceBandSection(spaceApps: store.spaceApps,
1460 registry: store.registry,
1461 selectedID: selectedID) { app in
1462 selectedID = "space:" + app
1463 }
1464 }
1465 // The funnel emptied the list — say so instead of presenting
1466 // a silently blank pane. (Search's empty case is the panel's.)
1467 if visibleThreads.isEmpty, filtering,
1468 search.trimmingCharacters(in: .whitespaces).isEmpty {
1469 Text(filterOnline ? "Nobody is online" : "No chats match your filters")
1470 .font(.system(size: 12))
1471 .foregroundStyle(.secondary)
1472 .frame(maxWidth: .infinity)
1473 .padding(.vertical, 14)
1474 }
1475 }
1476 .padding(6)
1477 }
1478 // The typeahead floats over the list, tucked under the search box.
1479 .overlay(alignment: .top) {
1480 if searching { searchPanel }
1481 }
1482 }
1483
1484 // The quiet capsule above the rows while the funnel narrows: what the
1485 // filter is doing in plain words (chatFilterSummary, shared with iOS)
1486 // plus the ✕ that restores the defaults. Tones and fill match the search
1487 // box above it — it informs, the list stays the subject.
1488 private var filterChip: some View {
1489 HStack(spacing: 5) {
1490 Image(systemName: "line.3.horizontal.decrease")
1491 .font(.system(size: 10, weight: .semibold))
1492 Text(chatFilterSummary(onlineOnly: filterOnline, people: filterPeople,
1493 bots: filterBots, apps: filterApps) ?? "")
1494 .font(.system(size: 11))
1495 .lineLimit(1)
1496 Button { resetFilters() } label: {
1497 Image(systemName: "xmark.circle.fill")
1498 .font(.system(size: 11))
1499 .foregroundStyle(.tertiary)
1500 .contentShape(Rectangle())
1501 }
1502 .buttonStyle(.plain)
1503 .help("Show all chats")
1504 }
1505 .foregroundStyle(.secondary)
1506 .padding(.horizontal, 10)
1507 .padding(.vertical, 4)
1508 .background(Capsule().fill(.primary.opacity(0.06)))
1509 .frame(maxWidth: .infinity)
1510 .padding(.bottom, 5)
1511 }
1512
1513 // Back to the funnel's defaults: every category on, online-only off.
1514 private func resetFilters() {
1515 filterOnline = false
1516 filterPeople = true
1517 filterBots = true
1518 filterApps = true
1519 }
1520
1521 // Selecting is one act whether it arrives on mouse-down (the style's
1522 // onPress) or on the Button's release-time action (keyboard/AX).
1523 private func selectThread(_ t: ChatThread) {
1524 selectedID = t.id
1525 store.markRead(t)
1526 store.reopenIfHidden(t) // opening a closed chat from search reopens it
1527 }
1528
1529 // The sidebar's half of issue #1071 (the ClickLab wedge samples split
1530 // between MessageBubble and threadList → ThreadAvatar): every store tick
1531 // re-ran every thread row's body, because the per-pass Button/menu
1532 // closures make rows unprovable-as-unchanged. The Equatable key lists
1533 // exactly what a row draws — the thread itself, the selection highlight,
1534 // and the rendered time label (so timestamps refresh exactly as often as
1535 // the list re-evaluates, same as before the gate).
1536 // ⚠️ Every render input must be listed — a field missing from the key
1537 // means its changes stop repainting rows.
1538 private struct ThreadRowKey: Equatable {
1539 let thread: ChatThread
1540 let selected: Bool
1541 let timeLabel: String
1542 }
1543
1544 private struct ThreadRowCell: View, Equatable {
1545 let key: ThreadRowKey
1546 // Lazy for the same reason as FeedRow: a skipped row must construct
1547 // nothing, not just skip its body.
1548 let content: () -> AnyView
1549 static func == (a: Self, b: Self) -> Bool { a.key == b.key }
1550 var body: some View { content() }
1551 }
1552
1553 private func threadRow(_ t: ChatThread) -> some View {
1554 Button {
1555 selectThread(t)
1556 } label: {
1557 HStack(spacing: 10) {
1558 ThreadAvatar(title: t.title, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph, symbol: AppIdentity.symbol(for: t.appId), mark: AppIdentity.mark(for: t.appId))
1559 VStack(alignment: .leading, spacing: 1) {
1560 HStack(spacing: 4) {
1561 Text(t.title)
1562 .font(.system(size: 14, weight: t.unread ? .bold : .semibold))
1563 .lineLimit(1)
1564 if let badge = t.badge {
1565 AgentBadge(badge)
1566 }
1567 Spacer(minLength: 4)
1568 // Pin sits left of the time — same trailing cluster as
1569 // the iOS row.
1570 if t.pinned {
1571 Image(systemName: "pin.fill")
1572 .font(.system(size: 8))
1573 .foregroundStyle(.tertiary)
1574 }
1575 if t.lastAt > 0 {
1576 Text(relTime(t.lastAt))
1577 .font(.system(size: 11))
1578 .foregroundStyle(.tertiary)
1579 }
1580 }
1581 // No recent message → no ghost preview block, so the
1582 // title centres beside the avatar instead of floating top.
1583 if !t.preview.isEmpty {
1584 Text(t.preview)
1585 .font(.system(size: 12, weight: t.unread ? .medium : .regular))
1586 .foregroundStyle(t.unread ? .primary : .secondary)
1587 .lineLimit(2, reservesSpace: true)
1588 .frame(maxWidth: .infinity, alignment: .leading)
1589 }
1590 }
1591 // Outside the text column so the dot centres on the ROW.
1592 // A muted chat never escalates: the slashed bell replaces the
1593 // unread dot — state stays visible, it just doesn't shout.
1594 if t.isMuted {
1595 Image(systemName: "bell.slash.fill")
1596 .font(.system(size: 10))
1597 .foregroundStyle(.secondary)
1598 .accessibilityLabel("Muted")
1599 } else if t.unread {
1600 Circle().fill(Color.accentColor).frame(width: 8, height: 8)
1601 }
1602 }
1603 .padding(8)
1604 .frame(maxWidth: .infinity, alignment: .leading)
1605 .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
1606 }
1607 .buttonStyle(ThreadRowButtonStyle(selected: t.id == selectedID,
1608 onPress: { selectThread(t) }))
1609 .contextMenu { threadMenu(t) }
1610 }
1611
1612 // A chat's face for the list: the decrypted group/person photo, else the
1613 // stable-hue initial circle (the same fallback the rail and mentions use).
1614 struct ThreadAvatar: View {
1615 let title: String
1616 let source: AvatarSource?
1617 var size: CGFloat = 40
1618 /// A ready image that wins over everything (Eleven's brand icon).
1619 var photo: PlatformImage? = nil
1620 /// A mini-app's emoji face — overrides photo/initials (apps have no
1621 /// member photo; a self-thread would show YOUR face otherwise).
1622 var glyph: String? = nil
1623 /// The app's identity mark (docs/icons.md): an SF Symbol for
1624 /// first-party apps, or a vendored brand template (GitHub, Linear, …).
1625 /// Either wins over the glyph — emoji is content, never chrome.
1626 var symbol: String? = nil
1627 var mark: PlatformImage? = nil
1628 /// When set and a real photo is loaded, clicking hands (image, window
1629 /// frame) over — the container zooms it into the lightbox.
1630 var onTap: ((PlatformImage, CGRect) -> Void)?
1631
1632 @State private var image: PlatformImage?
1633 // The tap-time window frame for the lightbox zoom origin, read lazily
1634 // (no per-frame `onChange(of: CGRect)` churn — see LiveFrame / #899).
1635 @State private var liveFrame = LiveFrame()
1636
1637 var body: some View {
1638 ZStack {
1639 if let photo {
1640 Image(platform: photo).resizable().scaledToFill()
1641 } else if let mark {
1642 Circle().fill(.primary.opacity(0.08))
1643 Image(platform: mark)
1644 .renderingMode(.template)
1645 .resizable()
1646 .scaledToFit()
1647 .frame(width: size * 0.5, height: size * 0.5)
1648 .foregroundStyle(.primary)
1649 } else if let symbol {
1650 Circle().fill(.primary.opacity(0.08))
1651 Image(systemName: symbol)
1652 .font(.system(size: size * 0.38, weight: .medium))
1653 .foregroundStyle(.primary)
1654 } else if let glyph {
1655 Circle().fill(.primary.opacity(0.08))
1656 Text(glyph).font(.system(size: size * 0.45))
1657 } else if let image {
1658 Image(platform: image).resizable().scaledToFill()
1659 } else {
1660 Circle().fill(Color(hue: Self.hue(title), saturation: 0.55, brightness: 0.78))
1661 Text(title.first.map { String($0).uppercased() } ?? "?")
1662 .font(.system(size: size * 0.4, weight: .semibold))
1663 .foregroundStyle(.white)
1664 }
1665 }
1666 .frame(width: size, height: size)
1667 .clipShape(Circle())
1668 .trackingFrame(into: liveFrame)
1669 // No handler → the avatar is transparent to clicks, so the row /
1670 // rail button underneath gets them (the gesture was swallowing
1671 // every tap and made the collapsed rail completely dead).
1672 .allowsHitTesting(onTap != nil)
1673 .onTapGesture {
1674 if let onTap, let image { onTap(image, liveFrame.rect) }
1675 }
1676 .task(id: source?.blobID) {
1677 guard let source else { return }
1678 if let hit = BlobImageLoader.shared.cached(source.blobID,
1679 maxPixel: avatarImageMaxPixel) {
1680 image = hit
1681 return
1682 }
1683 image = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel)
1684 }
1685 }
1686
1687 private static func hue(_ s: String) -> Double {
1688 var h: UInt64 = 5381
1689 for b in s.utf8 { h = (h &* 33) &+ UInt64(b) }
1690 return Double(h % 360) / 360.0
1691 }
1692 }
1693
1694 // ---- live sidebar ------------------------------------------------------
1695
1696
1697 private func startLive() {
1698 guard liveSocket == nil, let base = instance.baseURL else { return }
1699 let socket = ChatSocket(baseURL: base, token: instance.deviceToken)
1700 socket?.onFrame = { frame in
1701#if DEBUG
1702 // Every frame for the instance lands on this one socket — feed the
1703 // Debug Events windows before any routing/early return. The store
1704 // rides along for thread keys (payloads display decrypted).
1705 ThreadEventLog.shared.record(frame, store: store)
1706#endif
1707 // Presence needs no refetch — fold the transition straight into the
1708 // store (rows re-render off the published change).
1709 if frame.type == "presence", let uid = frame.user_id, let state = frame.presence {
1710 store.applyPresence(userID: uid, state: state)
1711 return
1712 }
1713 // Read on another of this user's devices: clear the dot in place —
1714 // no fetch needed, the frame carries the whole change.
1715 if frame.type == "read", let tid = frame.thread_id, let at = frame.last_read_at {
1716 store.applyReadFrame(threadID: tid, lastReadAt: at)
1717 return
1718 }
1719 // A DM ended or revived (#578), by either side, maybe elsewhere —
1720 // fold the flag in place like presence; the frame is the change.
1721 if frame.type == "dm_closed" || frame.type == "dm_reopened",
1722 let tid = frame.thread_id {
1723 store.applyDMClosed(threadID: tid, closed: frame.type == "dm_closed")
1724 return
1725 }
1726 // The space thread was just created somewhere (content-free prod,
1727 // spacethread.go) — fetch it and fold its installs.
1728 if frame.type == "space_thread" {
1729 Task { await store.reloadSpaceApps() }
1730 return
1731 }
1732 // The app set changed (a custom/member app deployed or updated,
1733 // anywhere): refresh the catalogue — through a command host,
1734 // minted if no page is alive to answer (an all-native session
1735 // may have none). An update or removal also refreshes every cached
1736 // app webview: a live page can't swap an app's code in place
1737 // (sandbox.js won't re-register a loaded id), so parked pages
1738 // reload invisibly and a visible one gets an inline reload offer
1739 // — without this a surface keeps running the old version until
1740 // its webview happens to be rebuilt. A brand-new app registers
1741 // live in-page, so "created" needs no reload.
1742 if frame.type == "apps_changed" {
1743 store.refreshAppCatalog()
1744 if frame.kind == "updated" || frame.kind == "removed" {
1745 AppWebViewCache.shared.handleAppsChanged(
1746 instanceID: store.instance.id, kind: frame.kind ?? "updated")
1747 }
1748 return
1749 }
1750 // A member lacks the instance key (#1376): if we hold it, wrap it
1751 // to them — the holder side of the keyless self-heal, mirroring
1752 // the web (shell.js). Without this a native-only space never
1753 // grants, and the joiner stays "Someone" to everyone.
1754 if frame.type == "instance_key_nudge", let uid = frame.user_id {
1755 store.handleInstanceKeyNudge(userID: uid)
1756 return
1757 }
1758 // The space thread's own frames: fold installs in place — the
1759 // thread never appears in /api/threads, so a refresh would learn
1760 // nothing about it.
1761 if let tid = frame.thread_id, tid == store.spaceApps.threadID {
1762 store.spaceApps.handleLive(frame)
1763 return
1764 }
1765 // Any stored-message mutation anywhere in the instance refreshes
1766 // the sidebar (previews, ordering, unread) — debounced so a burst
1767 // is one refresh. role_changed rides the same path: an admin
1768 // promotion is server-synthesized, not a stored-message mutation,
1769 // but invalidates the same roster reportableAdmins and
1770 // isAdmin-gated UI read — without this a stale copy would
1771 // persist until the next msg/edit/delete.
1772 guard frame.ephemeral != true,
1773 ["msg", "edit", "delete", "role_changed"].contains(frame.type) else { return }
1774 if frame.type == "msg" { notifyIfBackgrounded(frame) }
1775 scheduleRefresh()
1776 }
1777 socket?.start()
1778 liveSocket = socket
1779 }
1780
1781 // Local notifications for the NATIVE surface — the webview used to post
1782 // these (chat.js decrypted + suppressed), but the takeover doesn't mount
1783 // it, so native mode had gone silent. Same suppressions: your own
1784 // messages, and everything while the app is frontmost (you're reading).
1785 private func notifyIfBackgrounded(_ frame: ChatSocket.Frame) {
1786 guard !NSApp.isActive,
1787 let tid = frame.thread_id, let blob = frame.blob,
1788 let thread = store.threads.first(where: { $0.id == tid }),
1789 !thread.isMuted
1790 else { return }
1791 let myUserId = store.myUserId
1792 let instanceID = instance.id
1793 let origin = instance.origin
1794 // The row this banner is about — a tap lands ON it, not merely in the
1795 // chat (the tap route's nativeJump). Never stamped for a reaction
1796 // below: a reaction event has no bubble of its own to land on.
1797 let messageID = frame.id ?? 0
1798 let labels = store.senderLabels
1799 let mutedPeople = store.mutedPeople
1800 // The AES open runs detached — this fires for every message on the
1801 // instance socket while backgrounded; posting the banner is
1802 // thread-safe.
1803 Task.detached(priority: .utility) {
1804 guard let key = try? GroupKey(base64url: thread.threadKeyB64),
1805 let p = try? key.decryptJSON(MessagePayload.self, blob: blob),
1806 p.uid != myUserId,
1807 // A muted person never buzzes (#578) — their uid rides inside
1808 // the ciphertext, so only this post-decrypt point can know.
1809 // Local banners can drop outright where the iOS extension
1810 // must ship a passive entry.
1811 !(p.uid.map { mutedPeople.contains($0) } ?? false)
1812 else { return }
1813 // Senders resolve like the iOS extension: the name THIS member
1814 // shows for them (per-viewer names, #577), then the payload's
1815 // snapshot. nil = no real person to show (app/system events).
1816 let resolvedName: String? = {
1817 if let uid = p.uid, let named = labels[uid], !named.isEmpty { return named }
1818 if let n = p.name, !n.isEmpty { return n }
1819 return nil
1820 }()
1821 let sender = resolvedName ?? "Someone"
1822 let content = UNMutableNotificationContent()
1823 content.title = thread.title
1824 content.threadIdentifier = tid
1825 if p.isReactionEvent {
1826 // Reactions notify only the reacted-to message's author — and
1827 // the Mac posts local notifications, so unlike the iOS NSE it
1828 // can drop the rest outright. Un-reacts and reactions to
1829 // legacy uid-less messages notify no one.
1830 guard p.on == true, let target = p.targetUid, target == myUserId else { return }
1831 let emoji = p.emoji ?? ""
1832 content.body = thread.kind == "group"
1833 ? "\(sender) reacted \(emoji) to your message"
1834 : "Reacted \(emoji) to your message"
1835 content.sound = .default
1836 content.userInfo = ["instance": instanceID, "thread": tid]
1837 try? await UNUserNotificationCenter.current().add(
1838 UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
1839 return
1840 }
1841 // An APP EVENT banners only what a feed would say — the same
1842 // renderable() projection the iOS extension uses. A whiteboard
1843 // stroke or a todo re-order (state-only, no projection) stays
1844 // SILENT instead of buzzing "New message"; a projected event
1845 // speaks the app's own line ("added “buy milk”"), and apps
1846 // customise further via their events' notice field, which the
1847 // projections already read (remind.fire).
1848 var body = p
1849 var actorBaked = false
1850 if !p.isRenderableChat {
1851 guard let proj = ChatFeedModel.renderable(blob, key: key,
1852 group: thread.kind == "group",
1853 myUserId: myUserId)
1854 else { return }
1855 body = proj
1856 // Group action lines carry their actor in the text now
1857 // ("James added …") — prefixing the sender again would
1858 // stutter ("James James added …").
1859 actorBaked = thread.kind == "group" && proj.action == true
1860 }
1861 // The banner text mirrors the iOS extension exactly — and it must
1862 // never be empty: macOS re-lays-out a communication banner with no
1863 // body as "app name + sender", dropping the message entirely (a
1864 // textless app event rendered as the baffling "Eleven / James").
1865 var text = markdownPlainText(body.text ?? "")
1866 if text.isEmpty {
1867 let n = body.attachments?.count ?? 0
1868 text = n > 0 ? (n == 1 ? "📎 Attachment" : "📎 \(n) attachments") : "New message"
1869 }
1870 // An action line reads as one sentence ("Bob added “buy milk”");
1871 // in a DM the title already names the sender; groups prefix them.
1872 if body.action == true {
1873 content.body = actorBaked ? text : "\(sender) \(text)"
1874 } else {
1875 content.body = thread.kind == "group" ? "\(sender): \(text)" : text
1876 }
1877 content.sound = .default
1878 content.userInfo = ["instance": instanceID, "thread": tid]
1879 if messageID > 0 { content.userInfo["message"] = messageID }
1880 // Sender's face on the banner where possible — same restyle the
1881 // iOS extension applies (CommunicationNotification); a build
1882 // without the communication entitlement just keeps the banner.
1883 var deliver: UNNotificationContent = content
1884 if let name = resolvedName,
1885 let styled = CommunicationNotification.decorate(
1886 content, senderName: name, senderUID: p.uid,
1887 origin: origin, conversationID: tid,
1888 groupName: thread.kind == "group" ? thread.title : nil,
1889 messageText: text) {
1890 deliver = styled
1891 }
1892 try? await UNUserNotificationCenter.current().add(
1893 UNNotificationRequest(identifier: UUID().uuidString, content: deliver, trigger: nil))
1894 }
1895 }
1896
1897 private func scheduleRefresh() {
1898 guard !refreshPending else { return }
1899 refreshPending = true
1900 DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
1901 refreshPending = false
1902 Task {
1903 await store.refresh()
1904 // The chat that's open in front of you is being read — new
1905 // arrivals in it must not pile up an unread badge.
1906 if isActive, let t = selectedThread, t.unread {
1907 store.markRead(t)
1908 }
1909 }
1910 }
1911 }
1912}
1913
1914// Icon-button hover: the Finder's quiet rounded-rect behind the glyph (not a
1915// circle — toolbar affordances on the Mac are squircles).
1916private struct HoverSquircle: ViewModifier {
1917 @State private var hovering = false
1918 func body(content: Content) -> some View {
1919 content
1920 .background(RoundedRectangle(cornerRadius: 6, style: .continuous)
1921 .fill(.primary.opacity(hovering ? 0.08 : 0)))
1922 .onHover { hovering = $0 }
1923 }
1924}
1925
1926extension View {
1927 func hoverCircle() -> some View { modifier(HoverSquircle()) }
1928}
1929
1930// 🤖 The reactive shells around a group's shared apps. Both observe the
1931// thread's ChatFeedModel (its groupapps fold), so NativeChatView itself —
1932// which can't @ObservedObject a per-selection model — stays put while the
1933// bar and the split reshape live as apps are added and removed.
1934
1935// Renders the header's group view switch only while the group has apps.
1936private struct GroupAppsHeaderControls: View {
1937 @ObservedObject var model: ChatFeedModel
1938 let content: ([GroupApps.Item]) -> AnyView
1939
1940 var body: some View {
1941 if !model.groupApps.isEmpty {
1942 content(model.groupApps)
1943 }
1944 }
1945}
1946
1947// The kebab's Threads row, present exactly while this chat HAS a thread —
1948// observing the chat's own model, so one someone else starts mid-conversation
1949// surfaces the row without a reload, and the last one going takes it away
1950// (the web's lchat:views-changed repaint). A chat where nobody has ever used
1951// "Reply in Thread" is exactly as it was.
1952private struct ThreadsMenuItem: View {
1953 @ObservedObject var model: ChatFeedModel
1954 let open: () -> Void
1955
1956 var body: some View {
1957 if !model.threadListRows.isEmpty {
1958 // Plain-text row: nothing else in this menu carries an icon.
1959 Button("Threads…", action: open)
1960 Divider()
1961 }
1962 }
1963}
1964
1965// The group feed surface: the plain native feed until an app view is picked,
1966// then the same split shape a companion app-thread uses — feed | handle |
1967// pane — with the pane showing the dropdown's selected app. Falls back to
1968// the conversation whenever the picked app (or every app) is removed.
1969private struct GroupThreadSurface: View {
1970 @ObservedObject var model: ChatFeedModel
1971 @Binding var mode: NativeChatView.AppViewMode
1972 @Binding var selection: String?
1973 let paneWidth: Double
1974 let feed: () -> AnyView
1975 let handle: () -> AnyView
1976 let pane: (String) -> AnyView
1977
1978 // The app the pane shows: the sticky pick while it's still added, else
1979 // the first added app (the web's groupAppSelection).
1980 private var activeApp: String? {
1981 let apps = model.groupApps
1982 guard !apps.isEmpty else { return nil }
1983 if let sel = selection, apps.contains(where: { $0.id == sel }) { return sel }
1984 return apps.first?.id
1985 }
1986
1987 var body: some View {
1988 if let app = activeApp, mode != .chat {
1989 HStack(spacing: 0) {
1990 if mode != .interface { feed() }
1991 if mode == .split { handle() }
1992 pane(app)
1993 .frame(width: mode == .split ? paneWidth : nil)
1994 }
1995 } else {
1996 feed()
1997 }
1998 }
1999}
2000
2001// Chat-index buttons (rows, rail avatars): selection COMMITS on mouse-DOWN,
2002// like NSTableView — the style is the one place that sees the press, so it
2003// fires `onPress` the moment isPressed flips. Moving the selection itself
2004// (not just a pressed tint) keeps exactly one row highlighted: the old row
2005// un-fills in the same frame the new one lights. The Button's action still
2006// runs on release for keyboard/AX activation, which never sets isPressed.
2007// (These also deliberately skip .plain's mousedown dim, which read as a
2008// translucency flicker on every chat switch.)
2009struct ThreadRowButtonStyle: ButtonStyle {
2010 let selected: Bool
2011 let onPress: () -> Void
2012 func makeBody(configuration: Configuration) -> some View {
2013 configuration.label
2014 .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
2015 .fill(selected ? Color.accentColor.opacity(0.16) : .clear))
2016 .onChange(of: configuration.isPressed) { _, pressed in
2017 if pressed { onPress() }
2018 }
2019 }
2020}
2021
2022// The rail posture's accent ring, selection-committed the same way.
2023struct RailRowButtonStyle: ButtonStyle {
2024 let selected: Bool
2025 let ring: Color
2026 let onPress: () -> Void
2027 func makeBody(configuration: Configuration) -> some View {
2028 configuration.label
2029 .overlay {
2030 if selected {
2031 Circle().strokeBorder(ring, lineWidth: 2).padding(-3)
2032 }
2033 }
2034 .onChange(of: configuration.isPressed) { _, pressed in
2035 if pressed { onPress() }
2036 }
2037 }
2038}
2039
2040// 🤖 The sidebar's SPACE BAND (docs/native-apps.md phase 4): one row per
2041// space-wide install with an openable surface — the native twin of the web's
2042// space rows. Its own view so it observes the SpaceAppsStore and AppRegistry
2043// directly (ThreadStore doesn't republish their changes).
2044private struct SpaceBandSection: View {
2045 @ObservedObject var spaceApps: SpaceAppsStore
2046 @ObservedObject var registry: AppRegistry
2047 let selectedID: String?
2048 let onOpen: (String) -> Void
2049
2050 private var apps: [AppManifest] {
2051 registry.knownSpace(spaceApps.installs)
2052 .compactMap { registry.manifest($0.id) }
2053 .filter { $0.surface ?? true }
2054 }
2055
2056 var body: some View {
2057 if !apps.isEmpty {
2058 Text("SPACE")
2059 .font(.system(size: 10, weight: .semibold))
2060 .kerning(0.5)
2061 .foregroundStyle(.tertiary)
2062 .frame(maxWidth: .infinity, alignment: .leading)
2063 .padding(EdgeInsets(top: 10, leading: 10, bottom: 2, trailing: 0))
2064 .accessibilityAddTraits(.isHeader)
2065 ForEach(apps) { app in
2066 row(app)
2067 }
2068 }
2069 }
2070
2071 private func row(_ app: AppManifest) -> some View {
2072 let selected = selectedID == "space:" + app.name
2073 return Button { onOpen(app.name) } label: {
2074 HStack(spacing: 8) {
2075 Text(app.glyph)
2076 .font(.system(size: 15))
2077 .frame(width: 30, height: 30)
2078 .background(Circle().fill(.primary.opacity(0.06)))
2079 Text(app.title)
2080 .font(.system(size: 13, weight: .medium))
2081 .lineLimit(1)
2082 Spacer()
2083 Text("space")
2084 .font(.system(size: 9, weight: .semibold))
2085 .foregroundStyle(.secondary)
2086 .padding(.horizontal, 5)
2087 .padding(.vertical, 1)
2088 .background(Capsule().fill(.primary.opacity(0.08)))
2089 }
2090 .padding(.horizontal, 8)
2091 .padding(.vertical, 5)
2092 .frame(maxWidth: .infinity, alignment: .leading)
2093 .background(RoundedRectangle(cornerRadius: 8, style: .continuous)
2094 .fill(selected ? AnyShapeStyle(.selection) : AnyShapeStyle(.clear)))
2095 .contentShape(Rectangle())
2096 }
2097 .buttonStyle(.plain)
2098 .accessibilityLabel("\(app.title), space app")
2099 .accessibilityHint("Opens the app shared with everyone in this space")
2100 }
2101}
2102