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