import LChatCore import SwiftUI import UserNotifications // THE takeover surface (docs/native-chat.md): a native thread list beside the // native feed — the Mac's main chat view once an instance has its native // identity. The webview stays one toggle away (View ▸ Use Web App) for what // still lives there: mini-apps, admin, profile editing, joining. struct NativeChatView: View { let instance: SavedInstance /// Whether this instance's pane is the one on screen (RootView keeps the /// others mounted at opacity 0) — gates the feed's window-global swipe /// monitor. var isActive = true @StateObject private var store: ThreadStore @State private var selectedID: String? // The right pane's thread id. It follows `selectedID` one runloop turn // behind: swapping the pane (a feed fold or a webview build) in the same // transaction as the click held the row highlight back until the pane // finished building, which made every chat switch read as lag. @State private var paneID: String? // The just-left chat, held MOUNTED (identity preserved in the pane's // ForEach) on top of the incoming feed while that feed runs its settle // beat — so a ⌘[/⌘] switch cuts old content → new content with no blank // frame between (the flash). Dropped the moment the incoming feed // reports settled, or at a short cap so a cold load's spinner is never // covered. lastSettledFeedID tracks the last feed that actually became // visible: rapid cycling through never-settled chats keeps the last // VISIBLE one as cover instead of promoting a blank pane. @State private var outgoingFeedThread: ChatThread? @State private var lastSettledFeedID: String? @State private var outgoingGen = 0 @State private var showDiscover = false // One instance-wide socket keeps the SIDEBAR live: the hub fans every // thread's frames to us anyway, so any stored-message activity just // schedules a (debounced) roster/thread refresh for previews + unread. @State private var liveSocket: ChatSocket? @State private var refreshPending = false init(instance: SavedInstance, isActive: Bool = true) { self.instance = instance self.isActive = isActive _store = StateObject(wrappedValue: ThreadStore(instance: instance)) } @State private var search = "" @FocusState private var searchFocused: Bool @State private var showNewChat = false @State private var showInvite = false // The list is user-resizable (drag the divider); below the threshold it // collapses to an avatar-only rail. Width persists across launches. @AppStorage("macChatListWidth") private var listWidth = 300.0 @State private var dragStartWidth: Double? @Environment(\.colorScheme) private var scheme @AppStorage("nativeTheme") private var themeName = "classic" @AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize private var theme: ChatTheme { .named(themeName) } private var collapsed: Bool { listWidth < 170 } // The hidden-title-bar window is only draggable in a thin strip at the // very top — extend the grab area over the whole chat header and the // list's "Chats" row (down to the search box), like a real title bar. // A box (not view state): the event-monitor closure must read live values. // The two grab-zone frames are LiveFrames — read lazily from a passive // backing view at mouse-event time, never tracked with a per-frame // `onChange(of: CGRect)` (that churned on every window resize — #899). @MainActor private final class DragZones: ObservableObject { let feed = LiveFrame() // the chat header row let list = LiveFrame() // the sidebar header row above search var down: NSEvent? // a press waiting to become a window drag var isActive = true } @StateObject private var dragZones = DragZones() @State private var windowDragMonitor: Any? // Drag-on-move, not drag-on-press: the press passes through (buttons and // menus in the header keep working); only actual movement moves the // window. Double-click zooms, like a title bar. private func installWindowDragMonitor() { let zones = dragZones windowDragMonitor = NSEvent.addLocalMonitorForEvents( matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp] ) { event in guard zones.isActive, let window = event.window, let content = window.contentView else { return event } switch event.type { case .leftMouseDown: let p = event.locationInWindow let point = CGPoint(x: p.x, y: content.bounds.height - p.y) // Each zone reaches the window's TOP edge: the sliver between // a band and the frame edge is title-bar-shaped space, and // treating it as part of the zone also gives it the // double-click zoom a real title bar has. let grabs = [zones.feed.rect, zones.list.rect].map { r in r.isEmpty ? r : CGRect(x: r.minX, y: 0, width: r.width, height: r.maxY) } guard grabs.contains(where: { $0.contains(point) }) else { return event } if event.clickCount == 2 { window.zoom(nil) return nil } zones.down = event return event case .leftMouseDragged: if let down = zones.down { zones.down = nil window.performDrag(with: down) return nil } return event default: zones.down = nil return event } } } var body: some View { HStack(spacing: 0) { VStack(spacing: 0) { if collapsed { avatarRail } else { listHeader threadList } } .frame(width: collapsed ? 76 : listWidth) .background(theme.sidebar.resolved(scheme)) resizeHandle feedColumn } .background(theme.panel.resolved(scheme)) // ⌘F focuses the sidebar search (expanding the rail if collapsed). .background( Button("") { if collapsed { listWidth = 300 } DispatchQueue.main.async { searchFocused = true } } .keyboardShortcut("f", modifiers: .command) .opacity(0) .accessibilityHidden(true) ) .task { await store.refresh() // Creators land in Eleven's admin SETUP conversation, fresh // members in the WELCOME one (the web's maybeStartSetup / // maybeStartWelcome, natively) — once per device, and only while // the conversation is unfinished; the thread's webview runs the // same client-local script the web runs. if selectedID == nil, let setup = await SetupLanding.threadToLand(instance: instance, threads: store.threads) { selectedID = setup.id } if selectedID == nil, let welcome = await WelcomeLanding.threadToLand(instance: instance, threads: store.threads) { selectedID = welcome.id } if selectedID == nil { selectedID = store.threads.first(where: { !$0.hidden })?.id } startLive() // A chat parked before this space had any threads to match it // against (a notification tap into a space this session hadn't // opened yet) — the list is in hand now. consumeOpenThread() // Prewarm the top chats so first taps land on already-folded // feeds — the web's prebuild, in miniature. NativeFeedModelCache.shared.warm(instance: instance, threads: store.threads.filter { !$0.hidden }, myUserId: store.myUserId) } .onDisappear { liveSocket?.stop() if let windowDragMonitor { NSEvent.removeMonitor(windowDragMonitor) } windowDragMonitor = nil } // ⌘K: the active native surface is the palette's chat source (the // webview's lchatPaletteItems equivalent), and openThread requests // from the palette land here. .onAppear { publishPaletteChats() refreshNotificationStatus() dragZones.isActive = isActive installWindowDragMonitor() } // The hop that decouples click feedback from pane cost: selection // commits (and paints) first, the pane swap lands next turn. .onChange(of: selectedID) { _, id in DispatchQueue.main.async { paneID = id } } // Feed→feed switches hold the outgoing chat over the settle beat // (see outgoingFeedThread). Switches involving app/companion panes // keep today's behaviour — those surfaces paint their own way. .onChange(of: paneID) { old, new in outgoingGen += 1 let gen = outgoingGen let newThread = store.threads.first { $0.id == new } guard old != new, let newThread, isFeedSurface(newThread) else { outgoingFeedThread = nil return } if let oldThread = store.threads.first(where: { $0.id == old }), isFeedSurface(oldThread), old == lastSettledFeedID { outgoingFeedThread = oldThread } // else: the old pane never became visible (mid-cycle) — keep the // current cover, which is still the last content on screen. DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { if outgoingGen == gen { outgoingFeedThread = nil } } } .onChange(of: isActive) { _, active in dragZones.isActive = active if active { publishPaletteChats() // A chat parked for THIS space while another was on screen (a // notification tap switches space first): act on it now. consumeOpenThread() } else if app.nativeChatsOwner == instance.id { app.nativeChats = [] app.nativeChatsOwner = nil } } .onReceive(store.$threads) { _ in publishPaletteChats() } .sheet(item: $membersSheetThread) { t in MembersSheet(store: store, thread: t, onOpenThread: { id in membersSheetThread = nil selectedID = id }) } // End conversation (#578): the destructive confirm behind the menu // item — ending is mutual, so it never fires on a bare click. .confirmationDialog("End this conversation?", isPresented: Binding( get: { endConversationTarget != nil }, set: { if !$0 { endConversationTarget = nil } }), titleVisibility: .visible, presenting: endConversationTarget) { t in Button("End Conversation", role: .destructive) { Task { // Optimistic in the store; a refused POST reverts. Ending // hides the chat, so move the selection off it (the Close // item's rule). if await store.endConversation(t), selectedID == t.id { selectedID = store.threads.first(where: { !$0.hidden && $0.id != t.id })?.id } } } } message: { _ in Text("It closes for both of you, and no new messages can be sent unless someone reopens it.") } .sheet(item: $headerProfile) { target in // Message them selects (or creates) their DM — hidden when the // selected chat already IS their DM (the web card's gate). let theirDM = store.threads.first { $0.kind == "dm" && $0.members.contains(target.id) } MemberProfileSheet(store: store, uid: target.id, onOpenThread: (theirDM != nil && theirDM?.id == selectedID) ? nil : { id in headerProfile = nil selectedID = id }) { e in ThreadAvatar(title: store.displayName(for: e), source: store.avatarSource(for: e), size: 72) } } // Report a message to the admins (#578) — the shared sheet, with the // message as evidence and the thread's client-side name. .sheet(item: $reportTarget) { target in ReportSheet(store: store, uid: target.uid, threadID: target.thread.id, threadName: target.thread.title, evidence: target.evidence) } .sheet(item: $renameSheetThread) { t in RenameGroupSheet(store: store, thread: t) } .sheet(item: $appsSheetThread) { t in GroupAppsSheet(model: feedModel(t)) } .sheet(isPresented: $showInvite) { InviteSomeoneSheet(store: store) } // ⌘[ / ⌘] step through the chat list, CLAMPED at both ends — ⌘[ on // the top row (or ⌘] on the last) does nothing; wrapping to the // opposite end read as a jump to nowhere. The change's SIGN is the // direction (LChatMacApp's menu items move the tick ±1). .onChange(of: app.cycleChatTick) { old, new in guard isActive else { return } let list = visibleThreads guard !list.isEmpty else { return } let dir = new > old ? 1 : -1 let at = list.firstIndex { $0.id == selectedID } ?? (dir == 1 ? -1 : 0) let next = at + dir guard list.indices.contains(next) else { return } selectedID = list[next].id } // App Studio's Open, forwarded by an app pane's webview bridge // (MacWebBridge {openAppThread}): route it through the palette's // "app:" open-or-create path below — the pane itself must never // navigate, or the app renders inside App Studio's window. .onReceive(NotificationCenter.default.publisher(for: .lchatOpenAppThread)) { note in guard isActive, let name = note.userInfo?["app"] as? String, !name.isEmpty else { return } app.nativeOpenThread = "app:" + name } .onChange(of: app.nativeOpenThread) { _, _ in consumeOpenThread() } } // A parked "open this chat" request (the ⌘K palette, App Studio's Open, a // notification tap). Its own change is the usual trigger, but a request can // also arrive for a space that isn't on screen — or isn't even mounted — // yet: it stays parked until the space that owns it can act, so this is // re-checked whenever this view becomes the active one and once its threads // have loaded. Acting clears the request; a request for another space is // simply left alone. private func consumeOpenThread() { guard isActive, let id = app.nativeOpenThread else { return } // "user:"/"app:" rows have no thread yet — create (the server // dedupes DMs) and land in it. if id.hasPrefix("user:") { app.nativeOpenThread = nil let uid = String(id.dropFirst(5)) Task { if let tid = await store.createChat(memberIDs: [uid], groupName: nil) { selectedID = tid } } return } if id.hasPrefix("app:") { app.nativeOpenThread = nil let appID = String(id.dropFirst(4)) let title = store.registry.title(appID) Task { if let tid = await store.openOrCreateAppThread(app: appID, title: title) { selectedID = tid } } return } guard let thread = store.threads.first(where: { $0.id == id }) else { return } app.nativeOpenThread = nil selectedID = id store.markRead(thread) store.reopenIfHidden(thread) } @EnvironmentObject private var app: MacAppState private func publishPaletteChats() { guard isActive else { return } app.nativeChatsOwner = instance.id // Every thread — hidden included, so ⌘K reopens closed chats — then // people/bots with no DM yet ("user:" rows create one on pick) and // personal apps not yet started ("app:" rows likewise). var items: [MacAppState.NativePaletteChat] = store.threads.map { .init(id: $0.id, title: $0.title, glyph: $0.glyph, avatar: $0.avatar, appId: $0.appId) } let dmPartners = Set(store.threads.filter { $0.kind == "dm" }.flatMap(\.members)) items += store.roster .filter { $0.user_id != store.myUserId && !$0.label.isEmpty && !dmPartners.contains($0.user_id) } .map { .init(id: "user:" + $0.user_id, title: $0.label, glyph: nil, avatar: store.avatarSource(for: $0)) } let openApps = Set(store.threads.compactMap(\.appId)) items += store.registry.personalApps .filter { !openApps.contains($0.name) } .map { .init(id: "app:" + $0.name, title: $0.title, glyph: $0.glyph, avatar: nil, appId: $0.name) } app.nativeChats = items } private var selectedThread: ChatThread? { store.threads.first { $0.id == paneID } } // A surface the feed pane's ZStack renders itself (plain chat or group // conversation) — the ones that take part in the outgoing-cover switch. private func isFeedSurface(_ t: ChatThread) -> Bool { !t.isApp && !isCompanion(t) } // A feed's position verifiably landed: record it as the last visible // content, and if it's the CURRENT pane, drop the outgoing cover — the // uncover and the incoming's reveal share one commit, so the switch cuts // content-to-content. private func feedDidSettle(_ threadID: String) { lastSettledFeedID = threadID if threadID == paneID, outgoingFeedThread != nil { outgoingGen += 1 outgoingFeedThread = nil } } // The pane's mounted feeds: the selected chat, plus the outgoing one // being held over the settle beat. Keyed by thread id in the ForEach, so // role swaps never change a feed's identity. private var mountedFeedThreads: [ChatThread] { var list: [ChatThread] = [] if let t = selectedThread { list.append(t) } if let out = outgoingFeedThread, out.id != selectedThread?.id, isFeedSurface(out) { list.append(out) } return list } // ---- the resize handle + collapsed rail --------------------------------- // A slim grab area over the divider; dragging resizes the list, and going // under the threshold collapses it to the avatar rail. private var resizeHandle: some View { Divider() .overlay(Color.clear.frame(width: 7).contentShape(Rectangle())) .onHover { inside in if inside { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } } .gesture( DragGesture(minimumDistance: 1, coordinateSpace: .global) .onChanged { v in let start = dragStartWidth ?? (collapsed ? 76 : listWidth) dragStartWidth = start listWidth = min(420, max(76, start + v.translation.width)) } .onEnded { _ in dragStartWidth = nil } ) } // Avatars only — the narrow posture. Unread shows as a dot badge; the // selected chat gets the accent ring. Titles ride in tooltips. private var avatarRail: some View { ScrollView { VStack(spacing: 8) { ForEach(visibleThreads) { thread in Button { selectThread(thread) } label: { 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)) .overlay(alignment: .topTrailing) { if thread.unread { Circle().fill(theme.accent.resolved(scheme)) .frame(width: 10, height: 10) .overlay(Circle().strokeBorder( theme.sidebar.resolved(scheme), lineWidth: 2)) } } // The avatar itself is click-transparent (its tap // gesture is lightbox-only), so the BUTTON needs // its own hit shape — without one, a label made // entirely of a non-hit-testing view leaves the // button unclickable. .contentShape(Circle()) } .buttonStyle(RailRowButtonStyle(selected: thread.id == selectedID, ring: theme.accent.resolved(scheme), onPress: { selectThread(thread) })) .help(thread.title) .contextMenu { threadMenu(thread) } } } .padding(.vertical, 12) .frame(maxWidth: .infinity) } } // The feed column: the web's chat header (avatar, title, ⋮ with pin/close) // above the messages. @ViewBuilder private var feedColumn: some View { if let thread = selectedThread, !thread.isApp, !isCompanion(thread) { // Plain chats: the header rides OVER the feed as a safe-area inset, // frosted with a within-window blur, so messages visibly scroll // beneath it — Messages.app's header treatment (see FrostedBand). // The ZStack also carries the OUTGOING chat during a switch: keyed // by thread id, so a feed keeps its identity (state, scroll, // settled pixels) as it moves between the current and outgoing // roles — the cover renders instantly, and dropping it uncovers // the incoming feed in the same commit. No blank frame. ZStack { ForEach(mountedFeedThreads) { t in Group { if t.kind == "group" { groupSurface(t) } else { nativeFeed(t) } } .zIndex(t.id == outgoingFeedThread?.id ? 1 : 0) .allowsHitTesting(t.id == thread.id) } } .safeAreaInset(edge: .top, spacing: 0) { VStack(spacing: 0) { feedHeader(thread) Divider() // A DM partner renamed themself past my name for them // (#577): the keep/adopt choice, inline under the header. RenameBanner(store: store, thread: thread) } .background(FrostedBand()) // The whole visible band doubles as title bar — any // non-interactive point in it drags the window. .trackingFrame(into: dragZones.feed) } } else { // App/companion surfaces host webviews that fill their frame (an // NSView doesn't honor safe areas), so their header stays a // stacked band above the pane. VStack(spacing: 0) { if let thread = selectedThread { VStack(spacing: 0) { feedHeader(thread) Divider() RenameBanner(store: store, thread: thread) } // Same title-bar duty as the plain-chat band above. .trackingFrame(into: dragZones.feed) } feed } } } // "2 members, 1 bot, 2 AI agents · 1 online" — the web header's subtitle, // from the roster's helper kinds (shell.js's count + ui.js // memberCountLabel); the online count is people only. private func memberStats(_ t: ChatThread) -> String? { guard t.kind == "group" else { return nil } var bots = 0, agents = 0 for id in t.members { guard let e = store.roster.first(where: { $0.user_id == id }), e.isAgent else { continue } if e.isAI { agents += 1 } else { bots += 1 } } let label = memberCountLabel(people: t.members.count - bots - agents, bots: bots, agents: agents) let online = store.onlinePeopleCount(of: t) return online > 0 ? label + " · \(online) online" : label } // Chat = the native feed; View = the app's interface; Both = chat with the // interface in a resizable sidebar. Schema-described apps (To-do, // Reminders) draw the interface pane NATIVE (SchemaAppView, assembled from // the app's schema); anything else keeps the webview. @ViewBuilder private func companionSplit(_ thread: ChatThread) -> some View { let mode = appModes[thread.id] ?? .split HStack(spacing: 0) { if mode != .interface { nativeFeed(thread) } if mode == .split { appPaneResizeHandle } if mode != .chat { appInterfacePane(thread) .frame(width: mode == .split ? appPaneWidth : nil) } } } @ViewBuilder private func appInterfacePane(_ thread: ChatThread) -> some View { if AppSchemaRegistry.hasSchema(for: thread.appId), !thread.threadKeyB64.isEmpty, let schemaStore = SchemaAppStoreCache.shared.store(for: instance, thread: thread, myUserId: store.myUserId) { SchemaAppView(store: schemaStore) .id(thread.id + "|" + thread.threadKeyB64 + "|schema") } else { AppWebSurface(instance: instance, thread: thread, myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) .id(thread.id + "|" + thread.threadKeyB64 + "|app") } } // A GROUP with shared apps (web: Chat | Split | ): same // split shape as a companion app-thread, but the pane shows whichever // added app the header dropdown picked. Folded reactively — the surface // observes the thread's model, so an app added/removed by any member (or // the New-group seeding) reshapes it live; a group with no apps is just // the plain feed. @ViewBuilder private func groupSurface(_ thread: ChatThread) -> some View { GroupThreadSurface(model: feedModel(thread), mode: groupModeBinding(thread), selection: groupAppSelBinding(thread), paneWidth: appPaneWidth, feed: { AnyView(nativeFeed(thread)) }, handle: { AnyView(appPaneResizeHandle) }, pane: { appID in AnyView(groupAppPane(thread, app: appID)) }) } // A group's shared-app pane: schema-described apps (To-do, Reminders) // render the shared instance NATIVE, folded from the group's log; the // rest keep the webview embed. @ViewBuilder private func groupAppPane(_ thread: ChatThread, app appID: String) -> some View { if AppSchemaRegistry.hasSchema(for: appID), !thread.threadKeyB64.isEmpty, let schemaStore = SchemaAppStoreCache.shared.store(for: instance, thread: thread, myUserId: store.myUserId, appId: appID) { SchemaAppView(store: schemaStore) .id(thread.id + "|" + thread.threadKeyB64 + "|" + appID + "|group-schema") } else { AppWebSurface(instance: instance, thread: thread, myUserId: store.myUserId, instanceKeyB64: store.instanceKey(), app: appID) .id(thread.id + "|" + thread.threadKeyB64 + "|group-pane") } } // Chat = a bubble, Both = a split pane, View = the app's own face. private func appModeSwitch(_ t: ChatThread) -> some View { let binding = appModeBinding(t) return HStack(spacing: 2) { appModeButton(.chat, binding) { Image(systemName: "bubble.left") .font(.system(size: 12, weight: .medium)) } appModeButton(.split, binding) { Image(systemName: "rectangle.split.2x1") .font(.system(size: 12, weight: .medium)) } appModeButton(.interface, binding) { ThreadAvatar(title: t.title, source: t.avatar, size: 16) } } .padding(2) .background(RoundedRectangle(cornerRadius: 7, style: .continuous) .fill(.primary.opacity(0.06))) } private func appModeButton(_ mode: AppViewMode, _ binding: Binding, @ViewBuilder icon: () -> Icon) -> some View { Button { binding.wrappedValue = mode } label: { icon() .frame(width: 32, height: 20) .background(RoundedRectangle(cornerRadius: 5, style: .continuous) .fill(binding.wrappedValue == mode ? AnyShapeStyle(theme.panel.resolved(scheme)) : AnyShapeStyle(.clear)) .shadow(color: .black.opacity(binding.wrappedValue == mode ? 0.15 : 0), radius: 1, y: 0.5)) .contentShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) } .buttonStyle(.plain) .help(mode.rawValue) } private var appPaneResizeHandle: some View { Divider() .overlay(Color.clear.frame(width: 7).contentShape(Rectangle())) .onHover { inside in if inside { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } } .gesture( DragGesture(minimumDistance: 1, coordinateSpace: .global) .onChanged { v in let start = appPaneDragStart ?? appPaneWidth appPaneDragStart = start // The sidebar sits on the RIGHT: dragging left grows it. appPaneWidth = min(640, max(260, start - v.translation.width)) } .onEnded { _ in appPaneDragStart = nil } ) } // The plain native feed for a thread — shared by regular chats and the // companion split's Chat pane. private func nativeFeed(_ thread: ChatThread) -> some View { let model = NativeFeedModelCache.shared.model(for: instance, thread: thread, myUserId: store.myUserId) return NativeFeedView(model: model, thread: thread, myUserId: store.myUserId, isGroup: thread.kind == "group" || thread.members.count > 2, isActive: isActive, roster: store.roster, instance: instance, instanceKeyB64: store.instanceKey(), store: store, mentionAvatar: { [weak store] uid in store.flatMap { s in s.roster.first { $0.user_id == uid }.flatMap(s.avatarSource(for:)) } }, // The closed banner's Reopen (#578) goes through // the store so the sidebar row un-hides in the // same beat as the composer's return. onReopenDM: { [weak store] in await store?.reopenDM(thread.id) ?? .failed }, // Mute/unmute a sender (#578) — the feed reads // the muted set off its model; writes go through // the store's sealed-prefs save. onMutePerson: { [weak store] uid, on in Task { await store?.setPersonMuted(uid: uid, muted: on) } }, // Report a sender (#578) — this view owns the // sheet (it holds the store that seals to the // admins' keys). onReport: { m in guard let uid = m.raw.uid, !uid.isEmpty else { return } reportTarget = ReportTarget( thread: thread, uid: uid, evidence: ReportEvidence(message: m)) }, // The switch cover's drop signal: this feed is // now visibly landed, so the outgoing chat held // over its settle beat can go. onSettled: { feedDidSettle(thread.id) }, // A tapped @mention opens the same profile card // the DM header does (#577/#578). onMemberProfile: { headerProfile = HeaderProfileTarget(id: $0) }) .id(thread.id + "|" + thread.threadKeyB64 + "|" + String(UInt(bitPattern: ObjectIdentifier(model).hashValue))) // The Text Size pref (View menu ⌘+/⌘−, Settings slider) drives the // feed's reading size — bubbles + composer; the list stays put. .environment(\.chatFontSize, CGFloat(ChatTextSize.snap(chatTextSize))) } private func feedHeader(_ t: ChatThread) -> some View { HStack(spacing: 10) { 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), onTap: t.glyph == nil ? openAvatarLightbox(named: t.title) : nil) VStack(alignment: .leading, spacing: 0) { HStack(spacing: 5) { Text(t.title) .font(.system(size: 15, weight: .semibold)) .lineLimit(1) if let badge = t.badge { AgentBadge(badge) } } if let stats = memberStats(t) { // A fast path to the members list — the counts read as // tappable, so wire them straight to the same MembersSheet // the ⋯ menu's "View Members…" opens (which stays, as the // labelled/fallback path). Pointing-hand cursor signals it. Button { membersSheetThread = t } label: { Text(stats) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) .contentShape(Rectangle()) } .buttonStyle(.plain) .onHover { $0 ? NSCursor.pointingHand.push() : NSCursor.pop() } .accessibilityLabel(stats.replacingOccurrences(of: " · ", with: ", ")) .accessibilityHint("View members") } else if t.kind == "dm" { // Dot + Online/Away/Offline under the name (web parity). PresenceStatusLine(t.presence) .font(.system(size: 11)) .foregroundStyle(.secondary) } } // A DM's header name opens the person's profile card — where // "call them something else" and the rename notice live (#577), // matching the web header title. .contentShape(Rectangle()) .onTapGesture { guard t.kind == "dm", let other = t.members.first(where: { $0 != store.myUserId }) else { return } headerProfile = HeaderProfileTarget(id: other) } .accessibilityAddTraits(t.kind == "dm" ? .isButton : []) Spacer() // A companion app's view switch (web: Chat | Split | Interface) — // hand-rolled segments so the View segment can be the app's own // avatar (a stock segmented control only takes text/images). if isCompanion(t) { appModeSwitch(t) } else if t.kind == "group" { // A group with shared apps gets the same bar, its third // segment a dropdown of the added apps. Observes the thread's // model so the bar appears/disappears as apps are added or // removed (by anyone). GroupAppsHeaderControls(model: feedModel(t)) { apps in AnyView(groupModeSwitch(t, apps: apps)) } } Menu { // The chat's VIEWS come first — the surfaces you switch into — // then the management verbs, the same order the web's ▤ menu // lists them in. Today that's one row (Threads); Memory and bot // views, which the web already lists, have a home here when // they land natively. ThreadsMenuItem(model: feedModel(t)) { app.showReplyThreadList(t.id) } threadMenu(t) } label: { Image(systemName: "ellipsis") .font(.system(size: 14, weight: .medium)) .frame(width: 28, height: 28, alignment: .center) .contentShape(Rectangle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .hoverCircle() } .padding(.horizontal, 14) // A hair shorter than the side-head, so its border sits 1pt above the // search box's top edge. (The window-drag zone tracks the whole // header band at the call sites, not this inner row — tracking only // the row left the band's lower reaches unable to drag the window.) .frame(height: Self.headerHeight - 1) } @State private var membersSheetThread: ChatThread? @State private var renameSheetThread: ChatThread? // A DM header's profile card (rename them for yourself there — #577). @State private var headerProfile: HeaderProfileTarget? struct HeaderProfileTarget: Identifiable { let id: String } // A message's Report action (#578): reported sender + the message as // evidence + the thread it happened in, feeding the shared ReportSheet. @State private var reportTarget: ReportTarget? struct ReportTarget: Identifiable { let thread: ChatThread let uid: String let evidence: ReportEvidence var id: Int64 { evidence.sid } } // One menu for every surface a thread shows on: rows, the rail, the header. @ViewBuilder private func threadMenu(_ t: ChatThread) -> some View { if t.kind == "group" { Button("View Members…") { membersSheetThread = t } Button("Rename Group…") { renameSheetThread = t } Button("Manage Apps…") { appsSheetThread = t } Divider() } // Media, Links & Docs — DMs and groups alike (NOT in the group // block); app threads have no shared-content feed, so it hides there. if !t.isApp { // Its own window (resizable — a screenshot full of small text // needs to be made big), not a sheet: MediaWindows. Button("Media, Links & Docs…") { MediaWindows.show(instance: instance, thread: t, store: store, model: feedModel(t), onJump: { sid in // Land the feed on the message — the // search hit's path. selectedID = t.id app.nativeJump = .init(thread: t.id, message: sid) }) } Divider() } Button(t.pinned ? "Unpin" : "Pin") { store.setPinned(t, !t.pinned) } Button(t.isMuted ? "Unmute chat" : "Mute chat") { store.setMuted(t, !t.isMuted) } Button(t.unread ? "Mark as Read" : "Mark as Unread") { t.unread ? store.markRead(t) : store.markUnread(t) } if t.hidden { Button("Reopen") { store.setHidden(t, false) } } else { Divider() Button("Close") { store.setHidden(t, true) if selectedID == t.id { selectedID = store.threads.first(where: { !$0.hidden && $0.id != t.id })?.id } } } // End conversation (#578): DM-only, stronger than Close — mutual and // server-enforced, hence the confirm. Once ended, the closed banner's // Reopen is the way back, so the item hides on an ended DM too. if t.kind == "dm", !t.dmClosed { Button("End Conversation…", role: .destructive) { endConversationTarget = t } } #if DEBUG Divider() // Dev builds only (ClickLab's gate): a floating window of this // thread's raw hub frames + its unread inputs — see ThreadDebug.swift. Button("Debug Events") { ThreadDebugWindows.show(thread: t, store: store) } #endif } // The End-conversation confirm's target thread (#578) — nil = no dialog. @State private var endConversationTarget: ChatThread? // ---- companion apps: native chat beside the app's web interface -------- // The companion split (web: Chat | Split | Interface): the CHAT half is // the native feed, the interface stays the app's webview in a resizable // sidebar. Which apps are companion is the manifest's declaration // (appWantsChatView, pre-answered in the registry catalogue) — no // hand-kept set to keep in sync anymore. enum AppViewMode: String, CaseIterable { case chat = "Chat", split = "Both", interface = "View" } @State private var appModes: [String: AppViewMode] = [:] // per-thread, session-only @AppStorage("macAppPaneWidth") private var appPaneWidth = 380.0 @State private var appPaneDragStart: Double? private func isCompanion(_ t: ChatThread) -> Bool { // The full presentation decision, not the bare companion flag: Eleven's // conversations (setup/welcome) are chatOnly companions on the web but // run on the FLOW interpreter here — presentation() puts flow first, // and a chat-only companion has no interface to split with, so both // fall through to the ladder's later branches (a bare isCompanion // check split Eleven into two copies of the same chat). guard t.isApp else { return false } switch store.registry.presentation(t.appId ?? "") { case .schema(let companion): return companion case .companion(_, let chatOnly, _): return !chatOnly default: return false // flow and takeover } } private func appModeBinding(_ t: ChatThread) -> Binding { // First open honours the app's own defaultView ("interface" is how // Admin opens on its console; the web's appDefaultView) — after that // the member's per-session choice sticks. Binding(get: { appModes[t.id] ?? initialMode(t) }, set: { appModes[t.id] = $0 }) } private func initialMode(_ t: ChatThread) -> AppViewMode { guard case .companion(let defaultView, let chatOnly, _) = store.registry.presentation(t.appId ?? "") else { return .split } if chatOnly { return .chat } switch defaultView { case "chat": return .chat case "interface": return .interface default: return .split } } // ---- group apps: the shared widgets a group added (web's groupapps) ---- // Which added app a group's pane shows, per thread, session-only — the // web's groupAppView. @State private var groupAppSel: [String: String] = [:] // The manage-apps sheet's thread (⋮ → Manage Apps…). @State private var appsSheetThread: ChatThread? private func feedModel(_ t: ChatThread) -> ChatFeedModel { NativeFeedModelCache.shared.model(for: instance, thread: t, myUserId: store.myUserId) } // Groups default to the conversation ("a group is first a chat", the web's // rule) — the companion binding above defaults to .split instead. private func groupModeBinding(_ t: ChatThread) -> Binding { Binding(get: { appModes[t.id] ?? .chat }, set: { appModes[t.id] = $0 }) } private func groupAppSelBinding(_ t: ChatThread) -> Binding { Binding(get: { groupAppSel[t.id] }, set: { groupAppSel[t.id] = $0 }) } // The group header's controls: the SAME Chat | Both | View segments a // companion app-thread gets (the third segment wears the shown app's // glyph and zooms it full-pane), plus a separate menu button OUTSIDE the // group — the view selector: pick which added app the pane shows, and // Manage Apps…. private func groupModeSwitch(_ t: ChatThread, apps: [GroupApps.Item]) -> some View { let binding = groupModeBinding(t) let sel = groupAppSelBinding(t) let active = apps.first { $0.id == sel.wrappedValue }?.id ?? apps.first?.id return HStack(spacing: 6) { HStack(spacing: 2) { appModeButton(.chat, binding) { Image(systemName: "bubble.left") .font(.system(size: 12, weight: .medium)) } appModeButton(.split, binding) { Image(systemName: "rectangle.split.2x1") .font(.system(size: 12, weight: .medium)) } appModeButton(.interface, binding) { Text(store.registry.glyph(active ?? "")) .font(.system(size: 11)) } } .padding(2) .background(RoundedRectangle(cornerRadius: 7, style: .continuous) .fill(.primary.opacity(0.06))) // The view selector, its own affordance beside the segments: // glyph + name per added app (picking one shows it — beside the // chat first, like tapping its web widget button), then the // management door. Menu { ForEach(apps) { app in Button { sel.wrappedValue = app.id if binding.wrappedValue == .chat { binding.wrappedValue = .split } } label: { Text("\(store.registry.glyph(app.id)) \(store.registry.title(app.id))") } } Divider() Button("Manage Apps…") { appsSheetThread = t } } label: { Image(systemName: "chevron.down") .font(.system(size: 12, weight: .medium)) .frame(width: 28, height: 28, alignment: .center) .contentShape(Rectangle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .hoverCircle() .help("Apps") } } @ViewBuilder private var feed: some View { if let sel = selectedID, sel.hasPrefix("space:") { spaceAppPane(String(sel.dropFirst(6))) } else if let thread = selectedThread, isCompanion(thread) { companionSplit(thread) } else if let thread = selectedThread, thread.appId == "setup" || thread.appId == "welcome" { // Eleven's onboarding conversations run FULLY native — the // bundled flow interpreter + a SwiftUI chat, no webview. ElevenConversationPane(instance: instance, thread: thread, store: store, selectedID: $selectedID) .id(thread.id + "|" + thread.threadKeyB64 + "|eleven") } else if let thread = selectedThread, thread.isApp { // Takeover mini-apps get the app's own interface, full pane — // never the raw native event-log feed. Schema-described apps // render it native; the rest keep their webview. appInterfacePane(thread) } else if let thread = selectedThread, thread.kind == "group" { groupSurface(thread) } else if let thread = selectedThread { nativeFeed(thread) } else { VStack(spacing: 10) { Text(store.loading ? "Loading chats…" : (store.loadError ?? "Pick a chat.")) .font(.callout) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // A space app rendered full-pane (the web's openSpaceApp): the webview // seeded with the SPACE thread + its key, wearing the app's identity — // bootEmbed's space flag routes it through the web's own space pane. @ViewBuilder private func spaceAppPane(_ app: String) -> some View { if let thread = store.spaceApps.surfaceThread( app: app, title: store.registry.title(app), glyph: store.registry.glyph(app)) { AppWebSurface(instance: instance, thread: thread, myUserId: store.myUserId, instanceKeyB64: store.instanceKey(), app: app, space: true) .id("space|" + thread.id + "|" + app) } else { Text("This app isn't ready yet — try again in a moment.") .font(.callout) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) } } // ---- the sidebar header (the web's side-head: name, ⋮, new chat, search) private var myAvatarSource: AvatarSource? { store.roster.first { $0.user_id == store.myUserId }.flatMap(store.avatarSource(for:)) } private var myLabel: String { store.roster.first { $0.user_id == store.myUserId }?.label ?? instance.screenName } private var listHeader: some View { VStack(spacing: 0) { // The web's side-head: your avatar, "Chats", then the // hover-highlight icon buttons (⋮ menu, new chat). Taller, with // its bottom border flush against the search box below. HStack(spacing: 10) { ThreadAvatar(title: myLabel, source: myAvatarSource, size: 34, onTap: openAvatarLightbox(named: myLabel)) // The SPACE'S name, not a generic "Chats" — the sidebar // header is the one place that says where you are (the web's // side-head shows the group name the same way). "Chats" only // while the name hasn't loaded. Text(store.instanceName.isEmpty ? "Chats" : store.instanceName) .font(.system(size: 19, weight: .bold)) .lineLimit(1) Spacer(minLength: 4) // menuStyle(.button) + plain buttonStyle: the label lays out // as OUR view (glyph centered in its 30pt box) — the // borderless-button style added its own asymmetric insets. Menu { Button("New Chat…") { showNewChat = true } Button(notificationsDenied ? "Notifications Off — Open Settings…" : "Enable Notifications…") { requestNotifications() } Divider() Button("Invite Someone…") { showInvite = true } Button("Discover Groups…") { showDiscover = true } Button("Refresh") { Task { await store.refresh() } } } label: { Image(systemName: "ellipsis") .font(.system(size: 15, weight: .medium)) .frame(width: 30, height: 30, alignment: .center) .contentShape(Rectangle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .hoverCircle() Button { showNewChat = true } label: { Image(systemName: "square.and.pencil") .font(.system(size: 15, weight: .medium)) .frame(width: 30, height: 30, alignment: .center) .contentShape(Rectangle()) } .buttonStyle(.plain) .hoverCircle() .help("New chat") .popover(isPresented: $showNewChat, arrowEdge: .bottom) { NewChatPopover(store: store) { threadID in showNewChat = false selectedID = threadID } } .sheet(isPresented: $showDiscover) { // The community directory (shared view; web: showDiscover). VStack(spacing: 0) { DiscoverGroupsView(store: store) { threadID in showDiscover = false selectedID = threadID } Divider() HStack { Spacer() Button("Done") { showDiscover = false } .keyboardShortcut(.cancelAction) } .padding(12) } .frame(width: 380, height: 420) } } .padding(.horizontal, 12) .frame(height: Self.headerHeight) // Doubles as title bar, down to the search box's top edge. .trackingFrame(into: dragZones.list) // Thread search — like the web's, closed chats surface here too, // so search is also how a closed chat gets found and reopened. // Sits right under the header's border. HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: 11)) .foregroundStyle(.tertiary) TextField("Search", text: $search) .textFieldStyle(.plain) .font(.system(size: 13)) .focused($searchFocused) // Keyboard-driven typeahead: arrows move the highlight // through the results panel, Return opens, Esc clears. .onKeyPress(.downArrow) { guard searchResultCount > 0 else { return .ignored } searchSel = min(searchSel + 1, searchResultCount - 1) return .handled } .onKeyPress(.upArrow) { guard searchResultCount > 0 else { return .ignored } searchSel = max(searchSel - 1, 0) return .handled } .onKeyPress(.return) { guard searching else { return .ignored } openSearchSelection() return .handled } .onKeyPress(.escape) { guard searching else { return .ignored } search = "" return .handled } // List filters (web's funnel beside search): online-only + // the People/Bots/Apps categories. Filled while narrowing. Menu { // A picker in a fixed order — the checkmark marks the view // you're in, and the usual accidental click (the top row, // while unfiltered) re-picks "All chats", a no-op. Picker("View", selection: $filterOnline) { Text("All chats").tag(false) Text("Online only").tag(true) } .pickerStyle(.inline) Divider() Toggle("People", isOn: $filterPeople) Toggle("Bots", isOn: $filterBots) Toggle("Apps", isOn: $filterApps) } label: { Image(systemName: filtering ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") .font(.system(size: 12)) .foregroundStyle(filtering ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary)) .contentShape(Rectangle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .help("Filter chats") } .padding(.horizontal, 8) .padding(.vertical, 5) .background(RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(.primary.opacity(0.06))) .padding(EdgeInsets(top: 0, leading: 12, bottom: 8, trailing: 12)) } } // ---- search typeahead (Messages.app-style dropdown) ---------------------- @State private var searchHits: [(thread: String, message: MessageStore.StoredMessage)] = [] // The #584 deep walk (shared LChatCore runner — same one the iOS list // uses): fetch-and-file full history so search reaches the beginning. @StateObject private var backfill = HistoryBackfill() @State private var searchSel = 0 private var searching: Bool { !search.trimmingCharacters(in: .whitespaces).isEmpty } // Conversations whose TITLE matches — hidden ones included, so search is // still how a closed chat gets found and reopened. private var convoMatches: [ChatThread] { let q = search.trimmingCharacters(in: .whitespaces).lowercased() guard !q.isEmpty else { return [] } return Array(store.threads.filter { $0.title.lowercased().contains(q) }.prefix(6)) } // FTS hits mapped back to their threads (newest first, one per row). private var messageMatches: [(thread: ChatThread, message: MessageStore.StoredMessage)] { searchHits.compactMap { hit in store.threads.first { $0.id == hit.thread }.map { ($0, hit.message) } } } private var searchResultCount: Int { convoMatches.count + messageMatches.count } private func openSearchSelection() { let convos = convoMatches let msgs = messageMatches let sel = min(searchSel, convos.count + msgs.count - 1) guard sel >= 0 else { return } let thread = sel < convos.count ? convos[sel] : msgs[sel - convos.count].thread selectedID = thread.id store.markRead(thread) store.reopenIfHidden(thread) // A message hit doesn't just open the chat — it lands ON the message // (the feed scrolls there and the bubble waves). if sel >= convos.count { app.nativeJump = .init(thread: thread.id, message: msgs[sel - convos.count].message.id) } search = "" } private var searchPanel: some View { VStack(alignment: .leading, spacing: 0) { if !convoMatches.isEmpty { Text("Conversations") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.tertiary) .padding(EdgeInsets(top: 8, leading: 12, bottom: 4, trailing: 12)) ForEach(Array(convoMatches.enumerated()), id: \.element.id) { i, t in searchRow(index: i) { 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)) Text(t.title).font(.system(size: 13, weight: .medium)).lineLimit(1) } } } if !messageMatches.isEmpty { if !convoMatches.isEmpty { Divider().padding(.vertical, 4) } Text("Messages") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.tertiary) .padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) ForEach(Array(messageMatches.enumerated()), id: \.offset) { i, hit in searchRow(index: convoMatches.count + i) { ThreadAvatar(title: hit.thread.title, source: hit.thread.avatar, size: 24, glyph: hit.thread.glyph, symbol: AppIdentity.symbol(for: hit.thread.appId), mark: AppIdentity.mark(for: hit.thread.appId)) VStack(alignment: .leading, spacing: 0) { Text(hit.thread.title) .font(.system(size: 12, weight: .medium)).lineLimit(1) Text((hit.message.payload.name.map { "\($0): " } ?? "") + (hit.message.payload.text ?? "")) .font(.system(size: 12)) .foregroundStyle(.secondary) .lineLimit(1) } } } } if searchResultCount == 0 { Text("No matches") .font(.system(size: 12)) .foregroundStyle(.secondary) .padding(12) } // "Search earlier messages" (#584): offered until every chat's // history is local; while the walk runs, the #228 time machine // narrates and Stop cancels (the watermark persists — a later // run resumes where this one stopped). if !backfill.completed.contains(instance.id) { Divider().padding(.vertical, 4) if backfill.running { HStack(spacing: 8) { Text(backfill.progress ?? "Searching…") .font(.system(size: 12)) .foregroundStyle(.secondary) Spacer(minLength: 0) Button("Stop") { backfill.cancel() } .buttonStyle(.plain) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(theme.accent.resolved(scheme)) } .padding(.horizontal, 12) .padding(.vertical, 4) .accessibilityElement(children: .combine) } else { Button("Search earlier messages…") { backfill.start(instance: instance) } .buttonStyle(.plain) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(theme.accent.resolved(scheme)) .padding(.horizontal, 12) .padding(.vertical, 4) } } } .padding(.bottom, 6) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(theme.panel.resolved(scheme)) .shadow(color: .black.opacity(0.18), radius: 10, y: 3)) .padding(.horizontal, 8) // Debounced FTS against the local store (decrypted at ingest — the // search your SQLite proposal was for). // Keyed on the query AND the walk's page counter, so a running deep // search streams fresh hits into the open results. .task(id: "\(search)|\(backfill.pageTick)") { searchSel = 0 let q = search.trimmingCharacters(in: .whitespaces) guard !q.isEmpty else { searchHits = []; return } try? await Task.sleep(nanoseconds: 120_000_000) guard !Task.isCancelled else { return } let escaped = "\"" + q.replacingOccurrences(of: "\"", with: "") + "\"*" searchHits = Array(await MessageStore.shared .search(instance: instance.id, query: escaped, limit: 8)) } } private func searchRow(index: Int, @ViewBuilder content: () -> some View) -> some View { Button { searchSel = index openSearchSelection() } label: { HStack(spacing: 8) { content(); Spacer(minLength: 0) } .padding(.horizontal, 10) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(index == searchSel ? theme.accent.resolved(scheme).opacity(0.18) : .clear)) .contentShape(Rectangle()) } .buttonStyle(.plain) .padding(.horizontal, 6) .onHover { inside in if inside { searchSel = index } } } // One height for the side-head and the chat header, so their bottom edges // line up across the divider. static let headerHeight: CGFloat = 60 // Notification permission plumbing for the ⋮ menu: request when // undecided, route to System Settings once denied (apps can't re-prompt). @State private var notificationsDenied = false private func refreshNotificationStatus() { UNUserNotificationCenter.current().getNotificationSettings { settings in let denied = settings.authorizationStatus == .denied DispatchQueue.main.async { notificationsDenied = denied } } } private func requestNotifications() { UNUserNotificationCenter.current().getNotificationSettings { settings in DispatchQueue.main.async { switch settings.authorizationStatus { case .denied: NSWorkspace.shared.open(URL( string: "x-apple.systempreferences:com.apple.preference.notifications")!) default: UNUserNotificationCenter.current() .requestAuthorization(options: [.alert, .sound]) { _, _ in refreshNotificationStatus() } } } } } // Tapping an avatar that has a real photo zooms it into the lightbox // (nil-op for initials-only circles). private func openAvatarLightbox(named name: String) -> (PlatformImage, CGRect) -> Void { { image, frame in app.lightbox = LightboxItem(id: "avatar|" + name, name: name, image: image, sourceFrame: frame) } } // ---- the thread list --------------------------------------------------- // The funnel menu's filters (web parity: Online only + People/Bots/Apps). @State private var filterOnline = false @State private var filterPeople = true @State private var filterBots = true @State private var filterApps = true private var filtering: Bool { filterOnline || !filterPeople || !filterBots || !filterApps } private var visibleThreads: [ChatThread] { let q = search.trimmingCharacters(in: .whitespaces).lowercased() var base = q.isEmpty ? store.threads.filter { !$0.hidden } : store.threads.filter { $0.title.lowercased().contains(q) } if filtering { base = base.filter { store.passesFilter($0, onlineOnly: filterOnline, people: filterPeople, bots: filterBots, apps: filterApps) } } return base } private var threadList: some View { ScrollView { LazyVStack(spacing: 1) { // An active funnel says so in words at the top of the list — // the filled glyph alone proved too quiet a cue when a chat // silently vanished. Inline (scrolls with the rows), with a // one-click way back to everything. if filtering { filterChip } ForEach(visibleThreads) { thread in ThreadRowCell(key: ThreadRowKey(thread: thread, selected: thread.id == selectedID, timeLabel: thread.lastAt > 0 ? relTime(thread.lastAt) : ""), content: { AnyView(threadRow(thread)) }) .equatable() } // The SPACE BAND (docs/native-apps.md phase 4): one row per // space-wide install with an openable surface — the web // sidebar's space rows, folded from the space thread // (SpaceAppsStore) and filtered through the registry. if search.trimmingCharacters(in: .whitespaces).isEmpty, (!filtering || filterApps), !filterOnline { SpaceBandSection(spaceApps: store.spaceApps, registry: store.registry, selectedID: selectedID) { app in selectedID = "space:" + app } } // The funnel emptied the list — say so instead of presenting // a silently blank pane. (Search's empty case is the panel's.) if visibleThreads.isEmpty, filtering, search.trimmingCharacters(in: .whitespaces).isEmpty { Text(filterOnline ? "Nobody is online" : "No chats match your filters") .font(.system(size: 12)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 14) } } .padding(6) } // The typeahead floats over the list, tucked under the search box. .overlay(alignment: .top) { if searching { searchPanel } } } // The quiet capsule above the rows while the funnel narrows: what the // filter is doing in plain words (chatFilterSummary, shared with iOS) // plus the ✕ that restores the defaults. Tones and fill match the search // box above it — it informs, the list stays the subject. private var filterChip: some View { HStack(spacing: 5) { Image(systemName: "line.3.horizontal.decrease") .font(.system(size: 10, weight: .semibold)) Text(chatFilterSummary(onlineOnly: filterOnline, people: filterPeople, bots: filterBots, apps: filterApps) ?? "") .font(.system(size: 11)) .lineLimit(1) Button { resetFilters() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 11)) .foregroundStyle(.tertiary) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("Show all chats") } .foregroundStyle(.secondary) .padding(.horizontal, 10) .padding(.vertical, 4) .background(Capsule().fill(.primary.opacity(0.06))) .frame(maxWidth: .infinity) .padding(.bottom, 5) } // Back to the funnel's defaults: every category on, online-only off. private func resetFilters() { filterOnline = false filterPeople = true filterBots = true filterApps = true } // Selecting is one act whether it arrives on mouse-down (the style's // onPress) or on the Button's release-time action (keyboard/AX). private func selectThread(_ t: ChatThread) { selectedID = t.id store.markRead(t) store.reopenIfHidden(t) // opening a closed chat from search reopens it } // The sidebar's half of issue #1071 (the ClickLab wedge samples split // between MessageBubble and threadList → ThreadAvatar): every store tick // re-ran every thread row's body, because the per-pass Button/menu // closures make rows unprovable-as-unchanged. The Equatable key lists // exactly what a row draws — the thread itself, the selection highlight, // and the rendered time label (so timestamps refresh exactly as often as // the list re-evaluates, same as before the gate). // ⚠️ Every render input must be listed — a field missing from the key // means its changes stop repainting rows. private struct ThreadRowKey: Equatable { let thread: ChatThread let selected: Bool let timeLabel: String } private struct ThreadRowCell: View, Equatable { let key: ThreadRowKey // Lazy for the same reason as FeedRow: a skipped row must construct // nothing, not just skip its body. let content: () -> AnyView static func == (a: Self, b: Self) -> Bool { a.key == b.key } var body: some View { content() } } private func threadRow(_ t: ChatThread) -> some View { Button { selectThread(t) } label: { HStack(spacing: 10) { 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)) VStack(alignment: .leading, spacing: 1) { HStack(spacing: 4) { Text(t.title) .font(.system(size: 14, weight: t.unread ? .bold : .semibold)) .lineLimit(1) if let badge = t.badge { AgentBadge(badge) } Spacer(minLength: 4) // Pin sits left of the time — same trailing cluster as // the iOS row. if t.pinned { Image(systemName: "pin.fill") .font(.system(size: 8)) .foregroundStyle(.tertiary) } if t.lastAt > 0 { Text(relTime(t.lastAt)) .font(.system(size: 11)) .foregroundStyle(.tertiary) } } // No recent message → no ghost preview block, so the // title centres beside the avatar instead of floating top. if !t.preview.isEmpty { Text(t.preview) .font(.system(size: 12, weight: t.unread ? .medium : .regular)) .foregroundStyle(t.unread ? .primary : .secondary) .lineLimit(2, reservesSpace: true) .frame(maxWidth: .infinity, alignment: .leading) } } // Outside the text column so the dot centres on the ROW. // A muted chat never escalates: the slashed bell replaces the // unread dot — state stays visible, it just doesn't shout. if t.isMuted { Image(systemName: "bell.slash.fill") .font(.system(size: 10)) .foregroundStyle(.secondary) .accessibilityLabel("Muted") } else if t.unread { Circle().fill(Color.accentColor).frame(width: 8, height: 8) } } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } .buttonStyle(ThreadRowButtonStyle(selected: t.id == selectedID, onPress: { selectThread(t) })) .contextMenu { threadMenu(t) } } // A chat's face for the list: the decrypted group/person photo, else the // stable-hue initial circle (the same fallback the rail and mentions use). struct ThreadAvatar: View { let title: String let source: AvatarSource? var size: CGFloat = 40 /// A ready image that wins over everything (Eleven's brand icon). var photo: PlatformImage? = nil /// A mini-app's emoji face — overrides photo/initials (apps have no /// member photo; a self-thread would show YOUR face otherwise). var glyph: String? = nil /// The app's identity mark (docs/icons.md): an SF Symbol for /// first-party apps, or a vendored brand template (GitHub, Linear, …). /// Either wins over the glyph — emoji is content, never chrome. var symbol: String? = nil var mark: PlatformImage? = nil /// When set and a real photo is loaded, clicking hands (image, window /// frame) over — the container zooms it into the lightbox. var onTap: ((PlatformImage, CGRect) -> Void)? @State private var image: PlatformImage? // The tap-time window frame for the lightbox zoom origin, read lazily // (no per-frame `onChange(of: CGRect)` churn — see LiveFrame / #899). @State private var liveFrame = LiveFrame() var body: some View { ZStack { if let photo { Image(platform: photo).resizable().scaledToFill() } else if let mark { Circle().fill(.primary.opacity(0.08)) Image(platform: mark) .renderingMode(.template) .resizable() .scaledToFit() .frame(width: size * 0.5, height: size * 0.5) .foregroundStyle(.primary) } else if let symbol { Circle().fill(.primary.opacity(0.08)) Image(systemName: symbol) .font(.system(size: size * 0.38, weight: .medium)) .foregroundStyle(.primary) } else if let glyph { Circle().fill(.primary.opacity(0.08)) Text(glyph).font(.system(size: size * 0.45)) } else if let image { Image(platform: image).resizable().scaledToFill() } else { Circle().fill(Color(hue: Self.hue(title), saturation: 0.55, brightness: 0.78)) Text(title.first.map { String($0).uppercased() } ?? "?") .font(.system(size: size * 0.4, weight: .semibold)) .foregroundStyle(.white) } } .frame(width: size, height: size) .clipShape(Circle()) .trackingFrame(into: liveFrame) // No handler → the avatar is transparent to clicks, so the row / // rail button underneath gets them (the gesture was swallowing // every tap and made the collapsed rail completely dead). .allowsHitTesting(onTap != nil) .onTapGesture { if let onTap, let image { onTap(image, liveFrame.rect) } } .task(id: source?.blobID) { guard let source else { return } if let hit = BlobImageLoader.shared.cached(source.blobID, maxPixel: avatarImageMaxPixel) { image = hit return } image = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel) } } private static func hue(_ s: String) -> Double { var h: UInt64 = 5381 for b in s.utf8 { h = (h &* 33) &+ UInt64(b) } return Double(h % 360) / 360.0 } } // ---- live sidebar ------------------------------------------------------ private func startLive() { guard liveSocket == nil, let base = instance.baseURL else { return } let socket = ChatSocket(baseURL: base, token: instance.deviceToken) socket?.onFrame = { frame in #if DEBUG // Every frame for the instance lands on this one socket — feed the // Debug Events windows before any routing/early return. The store // rides along for thread keys (payloads display decrypted). ThreadEventLog.shared.record(frame, store: store) #endif // Presence needs no refetch — fold the transition straight into the // store (rows re-render off the published change). if frame.type == "presence", let uid = frame.user_id, let state = frame.presence { store.applyPresence(userID: uid, state: state) return } // Read on another of this user's devices: clear the dot in place — // no fetch needed, the frame carries the whole change. if frame.type == "read", let tid = frame.thread_id, let at = frame.last_read_at { store.applyReadFrame(threadID: tid, lastReadAt: at) return } // A DM ended or revived (#578), by either side, maybe elsewhere — // fold the flag in place like presence; the frame is the change. if frame.type == "dm_closed" || frame.type == "dm_reopened", let tid = frame.thread_id { store.applyDMClosed(threadID: tid, closed: frame.type == "dm_closed") return } // The space thread was just created somewhere (content-free prod, // spacethread.go) — fetch it and fold its installs. if frame.type == "space_thread" { Task { await store.reloadSpaceApps() } return } // The app set changed (a custom/member app deployed or updated, // anywhere): refresh the catalogue — through a command host, // minted if no page is alive to answer (an all-native session // may have none). An update or removal also refreshes every cached // app webview: a live page can't swap an app's code in place // (sandbox.js won't re-register a loaded id), so parked pages // reload invisibly and a visible one gets an inline reload offer // — without this a surface keeps running the old version until // its webview happens to be rebuilt. A brand-new app registers // live in-page, so "created" needs no reload. if frame.type == "apps_changed" { store.refreshAppCatalog() if frame.kind == "updated" || frame.kind == "removed" { AppWebViewCache.shared.handleAppsChanged( instanceID: store.instance.id, kind: frame.kind ?? "updated") } return } // A member lacks the instance key (#1376): if we hold it, wrap it // to them — the holder side of the keyless self-heal, mirroring // the web (shell.js). Without this a native-only space never // grants, and the joiner stays "Someone" to everyone. if frame.type == "instance_key_nudge", let uid = frame.user_id { store.handleInstanceKeyNudge(userID: uid) return } // The space thread's own frames: fold installs in place — the // thread never appears in /api/threads, so a refresh would learn // nothing about it. if let tid = frame.thread_id, tid == store.spaceApps.threadID { store.spaceApps.handleLive(frame) return } // Any stored-message mutation anywhere in the instance refreshes // the sidebar (previews, ordering, unread) — debounced so a burst // is one refresh. role_changed rides the same path: an admin // promotion is server-synthesized, not a stored-message mutation, // but invalidates the same roster reportableAdmins and // isAdmin-gated UI read — without this a stale copy would // persist until the next msg/edit/delete. guard frame.ephemeral != true, ["msg", "edit", "delete", "role_changed"].contains(frame.type) else { return } if frame.type == "msg" { notifyIfBackgrounded(frame) } scheduleRefresh() } socket?.start() liveSocket = socket } // Local notifications for the NATIVE surface — the webview used to post // these (chat.js decrypted + suppressed), but the takeover doesn't mount // it, so native mode had gone silent. Same suppressions: your own // messages, and everything while the app is frontmost (you're reading). private func notifyIfBackgrounded(_ frame: ChatSocket.Frame) { guard !NSApp.isActive, let tid = frame.thread_id, let blob = frame.blob, let thread = store.threads.first(where: { $0.id == tid }), !thread.isMuted else { return } let myUserId = store.myUserId let instanceID = instance.id let origin = instance.origin // The row this banner is about — a tap lands ON it, not merely in the // chat (the tap route's nativeJump). Never stamped for a reaction // below: a reaction event has no bubble of its own to land on. let messageID = frame.id ?? 0 let labels = store.senderLabels let mutedPeople = store.mutedPeople // The AES open runs detached — this fires for every message on the // instance socket while backgrounded; posting the banner is // thread-safe. Task.detached(priority: .utility) { guard let key = try? GroupKey(base64url: thread.threadKeyB64), let p = try? key.decryptJSON(MessagePayload.self, blob: blob), p.uid != myUserId, // A muted person never buzzes (#578) — their uid rides inside // the ciphertext, so only this post-decrypt point can know. // Local banners can drop outright where the iOS extension // must ship a passive entry. !(p.uid.map { mutedPeople.contains($0) } ?? false) else { return } // Senders resolve like the iOS extension: the name THIS member // shows for them (per-viewer names, #577), then the payload's // snapshot. nil = no real person to show (app/system events). let resolvedName: String? = { if let uid = p.uid, let named = labels[uid], !named.isEmpty { return named } if let n = p.name, !n.isEmpty { return n } return nil }() let sender = resolvedName ?? "Someone" let content = UNMutableNotificationContent() content.title = thread.title content.threadIdentifier = tid if p.isReactionEvent { // Reactions notify only the reacted-to message's author — and // the Mac posts local notifications, so unlike the iOS NSE it // can drop the rest outright. Un-reacts and reactions to // legacy uid-less messages notify no one. guard p.on == true, let target = p.targetUid, target == myUserId else { return } let emoji = p.emoji ?? "" content.body = thread.kind == "group" ? "\(sender) reacted \(emoji) to your message" : "Reacted \(emoji) to your message" content.sound = .default content.userInfo = ["instance": instanceID, "thread": tid] try? await UNUserNotificationCenter.current().add( UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) return } // An APP EVENT banners only what a feed would say — the same // renderable() projection the iOS extension uses. A whiteboard // stroke or a todo re-order (state-only, no projection) stays // SILENT instead of buzzing "New message"; a projected event // speaks the app's own line ("added “buy milk”"), and apps // customise further via their events' notice field, which the // projections already read (remind.fire). var body = p var actorBaked = false if !p.isRenderableChat { guard let proj = ChatFeedModel.renderable(blob, key: key, group: thread.kind == "group", myUserId: myUserId) else { return } body = proj // Group action lines carry their actor in the text now // ("James added …") — prefixing the sender again would // stutter ("James James added …"). actorBaked = thread.kind == "group" && proj.action == true } // The banner text mirrors the iOS extension exactly — and it must // never be empty: macOS re-lays-out a communication banner with no // body as "app name + sender", dropping the message entirely (a // textless app event rendered as the baffling "Eleven / James"). var text = markdownPlainText(body.text ?? "") if text.isEmpty { let n = body.attachments?.count ?? 0 text = n > 0 ? (n == 1 ? "📎 Attachment" : "📎 \(n) attachments") : "New message" } // An action line reads as one sentence ("Bob added “buy milk”"); // in a DM the title already names the sender; groups prefix them. if body.action == true { content.body = actorBaked ? text : "\(sender) \(text)" } else { content.body = thread.kind == "group" ? "\(sender): \(text)" : text } content.sound = .default content.userInfo = ["instance": instanceID, "thread": tid] if messageID > 0 { content.userInfo["message"] = messageID } // Sender's face on the banner where possible — same restyle the // iOS extension applies (CommunicationNotification); a build // without the communication entitlement just keeps the banner. var deliver: UNNotificationContent = content if let name = resolvedName, let styled = CommunicationNotification.decorate( content, senderName: name, senderUID: p.uid, origin: origin, conversationID: tid, groupName: thread.kind == "group" ? thread.title : nil, messageText: text) { deliver = styled } try? await UNUserNotificationCenter.current().add( UNNotificationRequest(identifier: UUID().uuidString, content: deliver, trigger: nil)) } } private func scheduleRefresh() { guard !refreshPending else { return } refreshPending = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { refreshPending = false Task { await store.refresh() // The chat that's open in front of you is being read — new // arrivals in it must not pile up an unread badge. if isActive, let t = selectedThread, t.unread { store.markRead(t) } } } } } // Icon-button hover: the Finder's quiet rounded-rect behind the glyph (not a // circle — toolbar affordances on the Mac are squircles). private struct HoverSquircle: ViewModifier { @State private var hovering = false func body(content: Content) -> some View { content .background(RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(.primary.opacity(hovering ? 0.08 : 0))) .onHover { hovering = $0 } } } extension View { func hoverCircle() -> some View { modifier(HoverSquircle()) } } // 🤖 The reactive shells around a group's shared apps. Both observe the // thread's ChatFeedModel (its groupapps fold), so NativeChatView itself — // which can't @ObservedObject a per-selection model — stays put while the // bar and the split reshape live as apps are added and removed. // Renders the header's group view switch only while the group has apps. private struct GroupAppsHeaderControls: View { @ObservedObject var model: ChatFeedModel let content: ([GroupApps.Item]) -> AnyView var body: some View { if !model.groupApps.isEmpty { content(model.groupApps) } } } // The kebab's Threads row, present exactly while this chat HAS a thread — // observing the chat's own model, so one someone else starts mid-conversation // surfaces the row without a reload, and the last one going takes it away // (the web's lchat:views-changed repaint). A chat where nobody has ever used // "Reply in Thread" is exactly as it was. private struct ThreadsMenuItem: View { @ObservedObject var model: ChatFeedModel let open: () -> Void var body: some View { if !model.threadListRows.isEmpty { // Plain-text row: nothing else in this menu carries an icon. Button("Threads…", action: open) Divider() } } } // The group feed surface: the plain native feed until an app view is picked, // then the same split shape a companion app-thread uses — feed | handle | // pane — with the pane showing the dropdown's selected app. Falls back to // the conversation whenever the picked app (or every app) is removed. private struct GroupThreadSurface: View { @ObservedObject var model: ChatFeedModel @Binding var mode: NativeChatView.AppViewMode @Binding var selection: String? let paneWidth: Double let feed: () -> AnyView let handle: () -> AnyView let pane: (String) -> AnyView // The app the pane shows: the sticky pick while it's still added, else // the first added app (the web's groupAppSelection). private var activeApp: String? { let apps = model.groupApps guard !apps.isEmpty else { return nil } if let sel = selection, apps.contains(where: { $0.id == sel }) { return sel } return apps.first?.id } var body: some View { if let app = activeApp, mode != .chat { HStack(spacing: 0) { if mode != .interface { feed() } if mode == .split { handle() } pane(app) .frame(width: mode == .split ? paneWidth : nil) } } else { feed() } } } // Chat-index buttons (rows, rail avatars): selection COMMITS on mouse-DOWN, // like NSTableView — the style is the one place that sees the press, so it // fires `onPress` the moment isPressed flips. Moving the selection itself // (not just a pressed tint) keeps exactly one row highlighted: the old row // un-fills in the same frame the new one lights. The Button's action still // runs on release for keyboard/AX activation, which never sets isPressed. // (These also deliberately skip .plain's mousedown dim, which read as a // translucency flicker on every chat switch.) struct ThreadRowButtonStyle: ButtonStyle { let selected: Bool let onPress: () -> Void func makeBody(configuration: Configuration) -> some View { configuration.label .background(RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(selected ? Color.accentColor.opacity(0.16) : .clear)) .onChange(of: configuration.isPressed) { _, pressed in if pressed { onPress() } } } } // The rail posture's accent ring, selection-committed the same way. struct RailRowButtonStyle: ButtonStyle { let selected: Bool let ring: Color let onPress: () -> Void func makeBody(configuration: Configuration) -> some View { configuration.label .overlay { if selected { Circle().strokeBorder(ring, lineWidth: 2).padding(-3) } } .onChange(of: configuration.isPressed) { _, pressed in if pressed { onPress() } } } } // 🤖 The sidebar's SPACE BAND (docs/native-apps.md phase 4): one row per // space-wide install with an openable surface — the native twin of the web's // space rows. Its own view so it observes the SpaceAppsStore and AppRegistry // directly (ThreadStore doesn't republish their changes). private struct SpaceBandSection: View { @ObservedObject var spaceApps: SpaceAppsStore @ObservedObject var registry: AppRegistry let selectedID: String? let onOpen: (String) -> Void private var apps: [AppManifest] { registry.knownSpace(spaceApps.installs) .compactMap { registry.manifest($0.id) } .filter { $0.surface ?? true } } var body: some View { if !apps.isEmpty { Text("SPACE") .font(.system(size: 10, weight: .semibold)) .kerning(0.5) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, alignment: .leading) .padding(EdgeInsets(top: 10, leading: 10, bottom: 2, trailing: 0)) .accessibilityAddTraits(.isHeader) ForEach(apps) { app in row(app) } } } private func row(_ app: AppManifest) -> some View { let selected = selectedID == "space:" + app.name return Button { onOpen(app.name) } label: { HStack(spacing: 8) { Text(app.glyph) .font(.system(size: 15)) .frame(width: 30, height: 30) .background(Circle().fill(.primary.opacity(0.06))) Text(app.title) .font(.system(size: 13, weight: .medium)) .lineLimit(1) Spacer() Text("space") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) .padding(.horizontal, 5) .padding(.vertical, 1) .background(Capsule().fill(.primary.opacity(0.08))) } .padding(.horizontal, 8) .padding(.vertical, 5) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(selected ? AnyShapeStyle(.selection) : AnyShapeStyle(.clear))) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("\(app.title), space app") .accessibilityHint("Opens the app shared with everyone in this space") } }