import LChatCore import SwiftUI // The main index: the active server's chats (DMs, groups, Notes), each with a // decrypted name + last-message preview, titled with the space's name — pushed // over the spaces home screen, so "back" is how you switch space. Tapping a // chat pushes its feed. The ThreadStore does the native ECIES unwrap + preview // decryption; this view is just presentation. struct ThreadListView: View { @State private var showSettings = false @EnvironmentObject var app: AppState @ObservedObject var store: ThreadStore @Binding var path: [Route] @State private var showNew = false @State private var search = "" @Environment(\.scenePhase) private var scenePhase // Files waiting in the share-extension inbox (app-group container). @State private var shareFiles: [URL] = [] // One landing check per view lifetime (WelcomeLanding's UserDefaults flag // makes it once per device+instance across launches). @State private var welcomeChecked = false // 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 } // Mirrors renderThreadList in web/static/chat.js: a search reveals everything // that matches (so a closed chat can be found and reopened), while the default // list hides closed (hidden) chats. Case-insensitive match on the chat name. // The funnel filters then narrow whatever the search/default produced. private var visibleThreads: [ChatThread] { let q = search.trimmingCharacters(in: .whitespaces) var base = q.isEmpty ? store.threads.filter { !$0.hidden } : store.threads.filter { $0.title.localizedCaseInsensitiveContains(q) } if filtering { base = base.filter { store.passesFilter($0, onlineOnly: filterOnline, people: filterPeople, bots: filterBots, apps: filterApps) } } return base } // The active server's name (falls back to the host until the group name loads). private var serverTitle: String { store.instanceName.isEmpty ? store.instance.displayName : store.instanceName } // The chat currently on top of the stack (nil = this list is on top) — // feeds the idempotent-push guard so queued taps can't stack duplicates. private var topThreadID: String? { if case let .thread(id)? = path.last { return id } return nil } var body: some View { // A UIKit UICollectionView list, not SwiftUI's List — see ThreadListCollection // for why (stock UITableView row highlight that SwiftUI's List can't match). Group { if search.trimmingCharacters(in: .whitespaces).isEmpty { ThreadListCollection( threads: visibleThreads, // Idempotent push (issue #580): while the main thread is // stalled a member's taps queue, and each ran append — so // "back" surfaced a phantom second copy of the chat. Skip // the push when that chat is already on top. onSelect: { id in if ChatNavGuard.shouldPush(threadID: id, topThreadID: topThreadID) { path.append(.thread(id)) } }, onClose: { store.setHidden($0, true) }, onPin: { store.setPinned($0, !$0.pinned) }, onToggleUnread: { $0.unread ? store.markRead($0) : store.markUnread($0) }, onMute: { store.setMuted($0, !$0.isMuted) } ) // Extend under the nav bar (and home indicator) so the list scrolls // *behind* the frosted bar; the collection view's automatic content // inset still rests the first row below the bar. .ignoresSafeArea(edges: [.top, .bottom]) } else { // Searching swaps in the results view (the Mac typeahead's iOS // shape): conversations by title, then full-text message hits // from the local store. searchResults } } // An active funnel says so in words, in the layout, right where the // missing rows would be — the toolbar's filled glyph alone proved too // quiet a cue when a chat silently vanished from the list. Occupies // safe-area space (never floats over rows), with a one-tap way back. .safeAreaInset(edge: .top, spacing: 0) { VStack(spacing: 0) { // Tamper-evidence failure banner (docs/tamper-evidence.md) — shows // only when this space fails an integrity check; "What's this?" // runs /integrity into the report alert below. IntegrityBanner(monitor: NativeFeedModelCache.shared.integrityMonitor(for: store.instance)) { integrityReport = NativeFeedModelCache.shared .integrityMonitor(for: store.instance).reportLines().joined(separator: "\n") } if filtering { filterChip } } } // The SPACE BAND (docs/native-apps.md phase 4): space-wide installs, // folded from the space thread and filtered through the registry — // in-layout via the inset, tucked above the home indicator. .safeAreaInset(edge: .bottom, spacing: 0) { if search.trimmingCharacters(in: .whitespaces).isEmpty { SpaceBand(spaceApps: store.spaceApps, registry: store.registry) { app in path.append(.spaceApp(app)) } } } .searchable(text: $search, prompt: "Search chats") // A fresh member lands straight in Eleven's welcome conversation (the // web's maybeStartWelcome, natively): open the welcome app-thread on // first entry while its conversation is unfinished — the thread's // webview runs the same client-local script the web runs. .onReceive(store.$threads) { list in guard !welcomeChecked, !list.isEmpty else { return } welcomeChecked = true Task { // Creators land in the admin SETUP conversation, joiners in // the WELCOME one — mutually exclusive by construction (a // creator has no welcome thread, a joiner no pending setup). if let setup = await SetupLanding.threadToLand(instance: store.instance, threads: list), path == [.chats] { path.append(.thread(setup.id)) } else if let welcome = await WelcomeLanding.threadToLand(instance: store.instance, threads: list), path == [.chats] { path.append(.thread(welcome.id)) } } } // First load only — entering a space whose chats we already hold shows // them immediately; otherwise the brand's smoke plays under the (already // slid-in) chrome until the rows land. Deliberately NO fade in or out: // the only motion is the navigation slide itself — any animation here // repaints around the large title and reads as the space name fading. .overlay { if store.loading && store.threads.isEmpty { ChatsLoadingScene() } } .overlay { if !store.loading && store.threads.isEmpty { empty } } // The funnel can narrow the list to nothing — say so instead of // presenting a silently blank pane. .overlay { if !store.loading, !store.threads.isEmpty, filtering, visibleThreads.isEmpty, search.trimmingCharacters(in: .whitespaces).isEmpty { filteredEmpty } } // The space's name IS the screen's title (the Mimestream "Inbox: // Primary" shape) — and it backs the chat screen's back button, so // leaving a chat reads "‹ Family". Back from HERE is the spaces home. .navigationTitle(serverTitle) .navigationBarTitleDisplayMode(.large) .toolbar { // Cog then filter, each its OWN glass capsule (the spacer splits // them) — settings and filtering are unrelated acts, not a group. ToolbarItem(placement: .topBarTrailing) { Button { showSettings = true } label: { Image(systemName: "gearshape") } .accessibilityLabel("Server settings") } if #available(iOS 26.0, *) { ToolbarSpacer(.fixed, placement: .topBarTrailing) } // List filters (web's funnel beside search): online-only + the // People/Bots/Apps categories. Filled glyph while narrowing. ToolbarItem(placement: .topBarTrailing) { Menu { // A picker in a fixed order — the checkmark marks the view // you're in, and the usual accidental tap (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") } .accessibilityLabel("Filter chats") } // Compose sits beside the bottom search field, like Messages. // Pre-26 systems keep it top-right (no bottom search bar there). if #available(iOS 26.0, *) { DefaultToolbarItem(kind: .search, placement: .bottomBar) ToolbarSpacer(.fixed, placement: .bottomBar) ToolbarItem(placement: .bottomBar) { Button { showNew = true } label: { Image(systemName: "square.and.pencil") } .accessibilityLabel("New chat") } } else { ToolbarItem(placement: .primaryAction) { Button { showNew = true } label: { Image(systemName: "square.and.pencil") } .accessibilityLabel("New chat") } } } .sheet(isPresented: $showNew) { NewChatView(store: store) { newID in path.append(.thread(newID)) } } .sheet(isPresented: $showSettings) { ServerSettingsSheet(store: store) } // Shared from the system share sheet: pick which chat gets the files. .sheet(isPresented: Binding(get: { !shareFiles.isEmpty }, set: { if !$0 { shareFiles = [] } })) { NavigationStack { List(store.threads.filter { !$0.hidden && !$0.isApp }) { t in Button { NativeChatScreen.pendingShare = (t.id, shareFiles) shareFiles = [] path.append(.thread(t.id)) } label: { HStack(spacing: 10) { Avatar(name: t.title, size: 34, source: t.avatar, 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(.body.weight(.medium)).lineLimit(1) if let badge = t.badge { AgentBadge(badge) } } } .foregroundStyle(.primary) } .navigationTitle("Send \(shareFiles.count == 1 ? "photo" : "\(shareFiles.count) items") to…") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { ShareInbox.clear(shareFiles) shareFiles = [] } } } } } .onChange(of: scenePhase) { _, phase in if phase == .active { shareFiles = ShareInbox.pending() // Tamper evidence: re-verify the checkpoint chain on foreground // (the native twin of the web's poll on ws.onopen). let inst = store.instance Task { await NativeFeedModelCache.shared.pollIntegrity(for: inst) } } } .onAppear { shareFiles = ShareInbox.pending() } // Keyed on the instance: when the store swaps in place (switching server), // SwiftUI reuses this view, so an un-keyed .task would not re-fire and the new // server's chats would never load. .task(id: store.instance.id) { await NativeFeedModelCache.shared.pollIntegrity(for: store.instance) await load() } // /integrity read-out, shown from the banner's "What's this?". .alert("Integrity check", isPresented: .init( get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } }) ) { Button("OK", role: .cancel) { integrityReport = nil } } message: { Text(integrityReport ?? "") } } @State private var integrityReport: String? // ---- search results (conversations + message full-text) ----------------- @State private var searchHits: [(thread: String, message: MessageStore.StoredMessage)] = [] // The #584 deep walk: fetch-and-file full history so search reaches the // beginning. One runner per list; its pageTick streams hits in below. @StateObject private var backfill = HistoryBackfill() 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 searchResults: some View { List { if !visibleThreads.isEmpty { Section("Conversations") { ForEach(visibleThreads) { t in Button { path.append(.thread(t.id)) } label: { HStack(spacing: 10) { Avatar(name: t.title, size: 34, source: t.avatar, 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(.body.weight(.medium)).lineLimit(1) if let badge = t.badge { AgentBadge(badge) } } } .foregroundStyle(.primary) } } } if !messageMatches.isEmpty { Section("Messages") { ForEach(Array(messageMatches.enumerated()), id: \.offset) { _, hit in Button { NativeChatScreen.pendingJump = (hit.thread.id, hit.message.id) path.append(.thread(hit.thread.id)) } label: { HStack(spacing: 10) { Avatar(name: hit.thread.title, size: 34, source: hit.thread.avatar, glyph: hit.thread.glyph, symbol: AppIdentity.symbol(for: hit.thread.appId), mark: AppIdentity.mark(for: hit.thread.appId)) VStack(alignment: .leading, spacing: 1) { Text(hit.thread.title) .font(.subheadline.weight(.medium)).lineLimit(1) Text((hit.message.payload.name.map { "\($0): " } ?? "") + (hit.message.payload.text ?? "")) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) } } } .foregroundStyle(.primary) } } } if visibleThreads.isEmpty && messageMatches.isEmpty { ContentUnavailableView.search(text: search) .listRowBackground(Color.clear) } // "Search earlier messages" (#584): offered until every chat's // history has fully backfilled; while running, the #228 time // machine narrates and Stop cancels (the watermark persists, so // stopping costs nothing — a later run resumes). if !search.trimmingCharacters(in: .whitespaces).isEmpty, !backfill.completed.contains(store.instance.id) { Section { if backfill.running { HStack { Text(backfill.progress ?? "Searching…") .font(.footnote) .foregroundStyle(.secondary) Spacer() Button("Stop") { backfill.cancel() } .font(.footnote.weight(.semibold)) } .accessibilityElement(children: .combine) } else { Button("Search earlier messages…") { backfill.start(instance: store.instance) } .font(.subheadline) } } } } .listStyle(.insetGrouped) // Debounced FTS against the local store — decrypted once at ingest, // exactly what the SQLite proposal bought us. // 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)") { 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: store.instance.id, query: escaped, limit: 12)) } } private var empty: some View { VStack(spacing: 8) { Image(systemName: "tray").font(.system(size: 36)).foregroundStyle(.secondary) Text("No chats yet").font(.headline) Text("Tap the compose button to start one.") .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) Button { showNew = true } label: { Text("New chat").frame(maxWidth: 200) } .buttonStyle(.borderedProminent) } .padding() } // The quiet capsule under the nav bar while the funnel narrows: what the // filter is doing in plain words (chatFilterSummary, shared with the Mac) // plus the ✕ that restores the defaults. Secondary tones on purpose — it // informs, the list stays the subject. private var filterChip: some View { HStack(spacing: 5) { Image(systemName: "line.3.horizontal.decrease") .font(.caption2.weight(.semibold)) Text(chatFilterSummary(onlineOnly: filterOnline, people: filterPeople, bots: filterBots, apps: filterApps) ?? "") .font(.footnote) .lineLimit(1) Button { resetFilters() } label: { Image(systemName: "xmark.circle.fill") .font(.footnote) .foregroundStyle(.tertiary) // Grow the tap target without growing the capsule. .padding(6) .contentShape(Rectangle()) } .padding(-6) .accessibilityLabel("Show all chats") } .foregroundStyle(.secondary) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Capsule().fill(.fill.tertiary)) .padding(.vertical, 6) .frame(maxWidth: .infinity) // The list scrolls behind this strip (it ignores the top safe area), // so frost it like the bar above rather than letting rows collide. .background(.bar) } // Back to the funnel's defaults: every category on, online-only off. private func resetFilters() { filterOnline = false filterPeople = true filterBots = true filterApps = true } // The funnel emptied the list. Name the online case — it's the one people // actually hit — and fall back to a generic line for the category toggles. private var filteredEmpty: some View { VStack(spacing: 8) { Image(systemName: "line.3.horizontal.decrease.circle") .font(.system(size: 36)).foregroundStyle(.secondary) Text(filterOnline ? "Nobody is online" : "No chats match your filters") .font(.callout).foregroundStyle(.secondary) } .padding() } // One instance-wide socket keeps the LIST live (the Mac sidebar's trick): // the hub fans every thread's frames to any member connection, so stored- // message activity — including a brand-new member's first message — // debounce-refreshes the list without waiting for a pull. @State private var liveSocket: ChatSocket? @State private var refreshPending = false private func startLive() { guard liveSocket == nil, let base = store.instance.baseURL else { return } let socket = ChatSocket(baseURL: base, token: store.instance.deviceToken) socket?.onFrame = { frame in // 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 } // 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" { FeedWebCache.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 } // role_changed rides the same debounced refresh: an admin // promotion (main.go adoptKeyInvite) is server-synthesized, not a // stored-message mutation, but the roster it invalidates is the // same one reportableAdmins and isAdmin-gated UI read — a stale // copy would otherwise persist until the next msg/edit/delete. guard frame.ephemeral != true, ["msg", "edit", "delete", "role_changed"].contains(frame.type) else { return } guard !refreshPending else { return } refreshPending = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { refreshPending = false Task { #if DEBUG let t0 = CFAbsoluteTimeGetCurrent() await store.refresh() HitchLogger.shared.note("live store.refresh \(Int((CFAbsoluteTimeGetCurrent() - t0) * 1000))ms") #else await store.refresh() #endif } } } socket?.start() liveSocket = socket } private func load() async { // Local first: last session's rows render instantly (loading is a // truly-first-visit state); the network refresh reconciles behind them. await store.hydrate() await store.refresh() startLive() app.note(id: store.instance.id, myUserId: store.myUserId, groupName: store.instanceName) // Warm the top chats off-screen so tapping one attaches an already-built // feed — the native mirror of the web's prebuild. Which cache depends on // which surface will render: parked ChatFeedModels for the native feed // (folded messages + live socket), webviews otherwise. NativeFeedModelCache.shared.warm(instance: store.instance, threads: store.threads.filter { !$0.hidden }, myUserId: store.myUserId) } } // The chat list's first-load state: an opaque surface carrying the brand's // smoke-and-motes scene (web bootscene.js's native twin), with the spinner // joining only if the load drags — the same tuned feel as the retired // space-switch scene it replaces. Reduce Motion drops the drifting particles. // The surface stays out of the large-title region (it respects the safe area) // so the space name never has loader pixels moving under it. private struct ChatsLoadingScene: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var spinner = false var body: some View { ZStack { Color(.systemBackground) // A fraction of full strength: the shared AmbientField is tuned // for the create-space stage, where the scene IS the show — in a // loading beat it's barely noticeable, by design. if !reduceMotion { SmokeSceneView().opacity(0.2) } if spinner { ProgressView().accessibilityLabel("Loading chats") } } .task { try? await Task.sleep(for: .seconds(0.5)) spinner = true } } } // Resolves a pushed thread id against the (observed) store, tolerating the store // still loading — a notification tap routes here before the fresh ThreadStore has // fetched anything, and bailing to "not found" on that race strands the tap. struct ThreadDestination: View { @ObservedObject var store: ThreadStore let id: String // The navigation path, for surfaces that hop threads (the Eleven // conversation's "Say hi to {inviter}" chip). @Binding var path: [Route] var body: some View { if let t = store.threads.first(where: { $0.id == id }) { chatSurface(t) .id(store.instance.id + "|" + t.id) // stable identity per thread (webview reused via FeedWebCache) .onAppear { store.markRead(t) store.reopenIfHidden(t) // opening a closed chat reopens it (mirrors web openThread) PushManager.activeThreadID = t.id // suppress banners for the open chat } .onDisappear { if PushManager.activeThreadID == t.id { PushManager.activeThreadID = nil } } } else if store.loading || store.threads.isEmpty { ProgressView() // Cold-launch tap: the snapshot usually resolves the thread // instantly; the refresh covers a genuinely new one. .task { await store.hydrate(); await store.refresh() } } else { ContentUnavailableView("Chat not found", systemImage: "bubble.left") } } @ViewBuilder private func chatSurface(_ t: ChatThread) -> some View { // One registry decision (docs/native-apps.md Layer 3) instead of // hard-coded app-id checks: the flow interpreter and the schema // engine are native enhancements, and everything else is // AppThreadScreen — native chat, with the app's own interface (the // only part a webview may carry) a toggle away. Chat never renders // in a webview. if !t.isApp { NativeChatScreen(instance: store.instance, thread: t, store: store, path: $path) } else { switch store.registry.presentation(t.appId ?? "") { case .flow: ElevenChatScreen(instance: store.instance, thread: t, store: store, path: $path) case .schema where !t.threadKeyB64.isEmpty: if let screen = SchemaAppScreen(instance: store.instance, thread: t, store: store, path: $path) { screen } else { AppThreadScreen(instance: store.instance, thread: t, store: store, path: $path) } default: AppThreadScreen(instance: store.instance, thread: t, store: store, path: $path) } } } }