| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import 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. |
| 9 | struct 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). |
| 1872 | private 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 | |
| 1882 | extension 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. |
| 1892 | private 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. |
| 1907 | private 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.) |
| 1947 | struct 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. |
| 1961 | struct 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). |
| 1982 | private 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 | |