eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

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