eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3
4// The main index: the active server's chats (DMs, groups, Notes), each with a
5// decrypted name + last-message preview, titled with the space's name — pushed
6// over the spaces home screen, so "back" is how you switch space. Tapping a
7// chat pushes its feed. The ThreadStore does the native ECIES unwrap + preview
8// decryption; this view is just presentation.
9struct ThreadListView: View {
10 @State private var showSettings = false
11 @EnvironmentObject var app: AppState
12 @ObservedObject var store: ThreadStore
13 @Binding var path: [Route]
14 @State private var showNew = false
15 @State private var search = ""
16 @Environment(\.scenePhase) private var scenePhase
17 // Files waiting in the share-extension inbox (app-group container).
18 @State private var shareFiles: [URL] = []
19
20 // One landing check per view lifetime (WelcomeLanding's UserDefaults flag
21 // makes it once per device+instance across launches).
22 @State private var welcomeChecked = false
23
24 // The funnel menu's filters (web parity: Online only + People/Bots/Apps).
25 @State private var filterOnline = false
26 @State private var filterPeople = true
27 @State private var filterBots = true
28 @State private var filterApps = true
29 private var filtering: Bool { filterOnline || !filterPeople || !filterBots || !filterApps }
30
31 // Mirrors renderThreadList in web/static/chat.js: a search reveals everything
32 // that matches (so a closed chat can be found and reopened), while the default
33 // list hides closed (hidden) chats. Case-insensitive match on the chat name.
34 // The funnel filters then narrow whatever the search/default produced.
35 private var visibleThreads: [ChatThread] {
36 let q = search.trimmingCharacters(in: .whitespaces)
37 var base = q.isEmpty
38 ? store.threads.filter { !$0.hidden }
39 : store.threads.filter { $0.title.localizedCaseInsensitiveContains(q) }
40 if filtering {
41 base = base.filter {
42 store.passesFilter($0, onlineOnly: filterOnline,
43 people: filterPeople, bots: filterBots, apps: filterApps)
44 }
45 }
46 return base
47 }
48
49 // The active server's name (falls back to the host until the group name loads).
50 private var serverTitle: String {
51 store.instanceName.isEmpty ? store.instance.displayName : store.instanceName
52 }
53
54 // The chat currently on top of the stack (nil = this list is on top) —
55 // feeds the idempotent-push guard so queued taps can't stack duplicates.
56 private var topThreadID: String? {
57 if case let .thread(id)? = path.last { return id }
58 return nil
59 }
60
61 var body: some View {
62 // A UIKit UICollectionView list, not SwiftUI's List — see ThreadListCollection
63 // for why (stock UITableView row highlight that SwiftUI's List can't match).
64 Group {
65 if search.trimmingCharacters(in: .whitespaces).isEmpty {
66 ThreadListCollection(
67 threads: visibleThreads,
68 // Idempotent push (issue #580): while the main thread is
69 // stalled a member's taps queue, and each ran append — so
70 // "back" surfaced a phantom second copy of the chat. Skip
71 // the push when that chat is already on top.
72 onSelect: { id in
73 if ChatNavGuard.shouldPush(threadID: id, topThreadID: topThreadID) {
74 path.append(.thread(id))
75 }
76 },
77 onClose: { store.setHidden($0, true) },
78 onPin: { store.setPinned($0, !$0.pinned) },
79 onToggleUnread: { $0.unread ? store.markRead($0) : store.markUnread($0) },
80 onMute: { store.setMuted($0, !$0.isMuted) }
81 )
82 // Extend under the nav bar (and home indicator) so the list scrolls
83 // *behind* the frosted bar; the collection view's automatic content
84 // inset still rests the first row below the bar.
85 .ignoresSafeArea(edges: [.top, .bottom])
86 } else {
87 // Searching swaps in the results view (the Mac typeahead's iOS
88 // shape): conversations by title, then full-text message hits
89 // from the local store.
90 searchResults
91 }
92 }
93 // An active funnel says so in words, in the layout, right where the
94 // missing rows would be — the toolbar's filled glyph alone proved too
95 // quiet a cue when a chat silently vanished from the list. Occupies
96 // safe-area space (never floats over rows), with a one-tap way back.
97 .safeAreaInset(edge: .top, spacing: 0) {
98 VStack(spacing: 0) {
99 // Tamper-evidence failure banner (docs/tamper-evidence.md) — shows
100 // only when this space fails an integrity check; "What's this?"
101 // runs /integrity into the report alert below.
102 IntegrityBanner(monitor: NativeFeedModelCache.shared.integrityMonitor(for: store.instance)) {
103 integrityReport = NativeFeedModelCache.shared
104 .integrityMonitor(for: store.instance).reportLines().joined(separator: "\n")
105 }
106 if filtering { filterChip }
107 }
108 }
109 // The SPACE BAND (docs/native-apps.md phase 4): space-wide installs,
110 // folded from the space thread and filtered through the registry —
111 // in-layout via the inset, tucked above the home indicator.
112 .safeAreaInset(edge: .bottom, spacing: 0) {
113 if search.trimmingCharacters(in: .whitespaces).isEmpty {
114 SpaceBand(spaceApps: store.spaceApps, registry: store.registry) { app in
115 path.append(.spaceApp(app))
116 }
117 }
118 }
119 .searchable(text: $search, prompt: "Search chats")
120 // A fresh member lands straight in Eleven's welcome conversation (the
121 // web's maybeStartWelcome, natively): open the welcome app-thread on
122 // first entry while its conversation is unfinished — the thread's
123 // webview runs the same client-local script the web runs.
124 .onReceive(store.$threads) { list in
125 guard !welcomeChecked, !list.isEmpty else { return }
126 welcomeChecked = true
127 Task {
128 // Creators land in the admin SETUP conversation, joiners in
129 // the WELCOME one — mutually exclusive by construction (a
130 // creator has no welcome thread, a joiner no pending setup).
131 if let setup = await SetupLanding.threadToLand(instance: store.instance, threads: list),
132 path == [.chats] {
133 path.append(.thread(setup.id))
134 } else if let welcome = await WelcomeLanding.threadToLand(instance: store.instance, threads: list),
135 path == [.chats] {
136 path.append(.thread(welcome.id))
137 }
138 }
139 }
140 // First load only — entering a space whose chats we already hold shows
141 // them immediately; otherwise the brand's smoke plays under the (already
142 // slid-in) chrome until the rows land. Deliberately NO fade in or out:
143 // the only motion is the navigation slide itself — any animation here
144 // repaints around the large title and reads as the space name fading.
145 .overlay {
146 if store.loading && store.threads.isEmpty {
147 ChatsLoadingScene()
148 }
149 }
150 .overlay { if !store.loading && store.threads.isEmpty { empty } }
151 // The funnel can narrow the list to nothing — say so instead of
152 // presenting a silently blank pane.
153 .overlay {
154 if !store.loading, !store.threads.isEmpty, filtering, visibleThreads.isEmpty,
155 search.trimmingCharacters(in: .whitespaces).isEmpty {
156 filteredEmpty
157 }
158 }
159 // The space's name IS the screen's title (the Mimestream "Inbox:
160 // Primary" shape) — and it backs the chat screen's back button, so
161 // leaving a chat reads "‹ Family". Back from HERE is the spaces home.
162 .navigationTitle(serverTitle)
163 .navigationBarTitleDisplayMode(.large)
164 .toolbar {
165 // Cog then filter, each its OWN glass capsule (the spacer splits
166 // them) — settings and filtering are unrelated acts, not a group.
167 ToolbarItem(placement: .topBarTrailing) {
168 Button { showSettings = true } label: { Image(systemName: "gearshape") }
169 .accessibilityLabel("Server settings")
170 }
171 if #available(iOS 26.0, *) {
172 ToolbarSpacer(.fixed, placement: .topBarTrailing)
173 }
174 // List filters (web's funnel beside search): online-only + the
175 // People/Bots/Apps categories. Filled glyph while narrowing.
176 ToolbarItem(placement: .topBarTrailing) {
177 Menu {
178 // A picker in a fixed order — the checkmark marks the view
179 // you're in, and the usual accidental tap (the top row,
180 // while unfiltered) re-picks "All chats", a no-op.
181 Picker("View", selection: $filterOnline) {
182 Text("All chats").tag(false)
183 Text("Online only").tag(true)
184 }
185 .pickerStyle(.inline)
186 Divider()
187 Toggle("People", isOn: $filterPeople)
188 Toggle("Bots", isOn: $filterBots)
189 Toggle("Apps", isOn: $filterApps)
190 } label: {
191 Image(systemName: filtering
192 ? "line.3.horizontal.decrease.circle.fill"
193 : "line.3.horizontal.decrease.circle")
194 }
195 .accessibilityLabel("Filter chats")
196 }
197 // Compose sits beside the bottom search field, like Messages.
198 // Pre-26 systems keep it top-right (no bottom search bar there).
199 if #available(iOS 26.0, *) {
200 DefaultToolbarItem(kind: .search, placement: .bottomBar)
201 ToolbarSpacer(.fixed, placement: .bottomBar)
202 ToolbarItem(placement: .bottomBar) {
203 Button { showNew = true } label: { Image(systemName: "square.and.pencil") }
204 .accessibilityLabel("New chat")
205 }
206 } else {
207 ToolbarItem(placement: .primaryAction) {
208 Button { showNew = true } label: { Image(systemName: "square.and.pencil") }
209 .accessibilityLabel("New chat")
210 }
211 }
212 }
213 .sheet(isPresented: $showNew) {
214 NewChatView(store: store) { newID in path.append(.thread(newID)) }
215 }
216 .sheet(isPresented: $showSettings) {
217 ServerSettingsSheet(store: store)
218 }
219 // Shared from the system share sheet: pick which chat gets the files.
220 .sheet(isPresented: Binding(get: { !shareFiles.isEmpty },
221 set: { if !$0 { shareFiles = [] } })) {
222 NavigationStack {
223 List(store.threads.filter { !$0.hidden && !$0.isApp }) { t in
224 Button {
225 NativeChatScreen.pendingShare = (t.id, shareFiles)
226 shareFiles = []
227 path.append(.thread(t.id))
228 } label: {
229 HStack(spacing: 10) {
230 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))
231 Text(t.title).font(.body.weight(.medium)).lineLimit(1)
232 if let badge = t.badge { AgentBadge(badge) }
233 }
234 }
235 .foregroundStyle(.primary)
236 }
237 .navigationTitle("Send \(shareFiles.count == 1 ? "photo" : "\(shareFiles.count) items") to…")
238 .navigationBarTitleDisplayMode(.inline)
239 .toolbar {
240 ToolbarItem(placement: .cancellationAction) {
241 Button("Cancel") {
242 ShareInbox.clear(shareFiles)
243 shareFiles = []
244 }
245 }
246 }
247 }
248 }
249 .onChange(of: scenePhase) { _, phase in
250 if phase == .active {
251 shareFiles = ShareInbox.pending()
252 // Tamper evidence: re-verify the checkpoint chain on foreground
253 // (the native twin of the web's poll on ws.onopen).
254 let inst = store.instance
255 Task { await NativeFeedModelCache.shared.pollIntegrity(for: inst) }
256 }
257 }
258 .onAppear { shareFiles = ShareInbox.pending() }
259 // Keyed on the instance: when the store swaps in place (switching server),
260 // SwiftUI reuses this view, so an un-keyed .task would not re-fire and the new
261 // server's chats would never load.
262 .task(id: store.instance.id) {
263 await NativeFeedModelCache.shared.pollIntegrity(for: store.instance)
264 await load()
265 }
266 // /integrity read-out, shown from the banner's "What's this?".
267 .alert("Integrity check", isPresented: .init(
268 get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } })
269 ) {
270 Button("OK", role: .cancel) { integrityReport = nil }
271 } message: {
272 Text(integrityReport ?? "")
273 }
274 }
275 @State private var integrityReport: String?
276
277 // ---- search results (conversations + message full-text) -----------------
278
279 @State private var searchHits: [(thread: String, message: MessageStore.StoredMessage)] = []
280 // The #584 deep walk: fetch-and-file full history so search reaches the
281 // beginning. One runner per list; its pageTick streams hits in below.
282 @StateObject private var backfill = HistoryBackfill()
283
284 private var messageMatches: [(thread: ChatThread, message: MessageStore.StoredMessage)] {
285 searchHits.compactMap { hit in
286 store.threads.first { $0.id == hit.thread }.map { ($0, hit.message) }
287 }
288 }
289
290 private var searchResults: some View {
291 List {
292 if !visibleThreads.isEmpty {
293 Section("Conversations") {
294 ForEach(visibleThreads) { t in
295 Button {
296 path.append(.thread(t.id))
297 } label: {
298 HStack(spacing: 10) {
299 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))
300 Text(t.title).font(.body.weight(.medium)).lineLimit(1)
301 if let badge = t.badge { AgentBadge(badge) }
302 }
303 }
304 .foregroundStyle(.primary)
305 }
306 }
307 }
308 if !messageMatches.isEmpty {
309 Section("Messages") {
310 ForEach(Array(messageMatches.enumerated()), id: \.offset) { _, hit in
311 Button {
312 NativeChatScreen.pendingJump = (hit.thread.id, hit.message.id)
313 path.append(.thread(hit.thread.id))
314 } label: {
315 HStack(spacing: 10) {
316 Avatar(name: hit.thread.title, size: 34,
317 source: hit.thread.avatar, glyph: hit.thread.glyph,
318 symbol: AppIdentity.symbol(for: hit.thread.appId),
319 mark: AppIdentity.mark(for: hit.thread.appId))
320 VStack(alignment: .leading, spacing: 1) {
321 Text(hit.thread.title)
322 .font(.subheadline.weight(.medium)).lineLimit(1)
323 Text((hit.message.payload.name.map { "\($0): " } ?? "")
324 + (hit.message.payload.text ?? ""))
325 .font(.subheadline)
326 .foregroundStyle(.secondary)
327 .lineLimit(2)
328 }
329 }
330 }
331 .foregroundStyle(.primary)
332 }
333 }
334 }
335 if visibleThreads.isEmpty && messageMatches.isEmpty {
336 ContentUnavailableView.search(text: search)
337 .listRowBackground(Color.clear)
338 }
339 // "Search earlier messages" (#584): offered until every chat's
340 // history has fully backfilled; while running, the #228 time
341 // machine narrates and Stop cancels (the watermark persists, so
342 // stopping costs nothing — a later run resumes).
343 if !search.trimmingCharacters(in: .whitespaces).isEmpty,
344 !backfill.completed.contains(store.instance.id) {
345 Section {
346 if backfill.running {
347 HStack {
348 Text(backfill.progress ?? "Searching…")
349 .font(.footnote)
350 .foregroundStyle(.secondary)
351 Spacer()
352 Button("Stop") { backfill.cancel() }
353 .font(.footnote.weight(.semibold))
354 }
355 .accessibilityElement(children: .combine)
356 } else {
357 Button("Search earlier messages…") {
358 backfill.start(instance: store.instance)
359 }
360 .font(.subheadline)
361 }
362 }
363 }
364 }
365 .listStyle(.insetGrouped)
366 // Debounced FTS against the local store — decrypted once at ingest,
367 // exactly what the SQLite proposal bought us.
368 // Keyed on the query AND the walk's page counter, so a running deep
369 // search streams fresh hits into the open results.
370 .task(id: "\(search)|\(backfill.pageTick)") {
371 let q = search.trimmingCharacters(in: .whitespaces)
372 guard !q.isEmpty else { searchHits = []; return }
373 try? await Task.sleep(nanoseconds: 120_000_000)
374 guard !Task.isCancelled else { return }
375 let escaped = "\"" + q.replacingOccurrences(of: "\"", with: "") + "\"*"
376 searchHits = Array(await MessageStore.shared
377 .search(instance: store.instance.id, query: escaped, limit: 12))
378 }
379 }
380
381 private var empty: some View {
382 VStack(spacing: 8) {
383 Image(systemName: "tray").font(.system(size: 36)).foregroundStyle(.secondary)
384 Text("No chats yet").font(.headline)
385 Text("Tap the compose button to start one.")
386 .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
387 Button { showNew = true } label: { Text("New chat").frame(maxWidth: 200) }
388 .buttonStyle(.borderedProminent)
389 }
390 .padding()
391 }
392
393 // The quiet capsule under the nav bar while the funnel narrows: what the
394 // filter is doing in plain words (chatFilterSummary, shared with the Mac)
395 // plus the ✕ that restores the defaults. Secondary tones on purpose — it
396 // informs, the list stays the subject.
397 private var filterChip: some View {
398 HStack(spacing: 5) {
399 Image(systemName: "line.3.horizontal.decrease")
400 .font(.caption2.weight(.semibold))
401 Text(chatFilterSummary(onlineOnly: filterOnline, people: filterPeople,
402 bots: filterBots, apps: filterApps) ?? "")
403 .font(.footnote)
404 .lineLimit(1)
405 Button { resetFilters() } label: {
406 Image(systemName: "xmark.circle.fill")
407 .font(.footnote)
408 .foregroundStyle(.tertiary)
409 // Grow the tap target without growing the capsule.
410 .padding(6)
411 .contentShape(Rectangle())
412 }
413 .padding(-6)
414 .accessibilityLabel("Show all chats")
415 }
416 .foregroundStyle(.secondary)
417 .padding(.horizontal, 12)
418 .padding(.vertical, 6)
419 .background(Capsule().fill(.fill.tertiary))
420 .padding(.vertical, 6)
421 .frame(maxWidth: .infinity)
422 // The list scrolls behind this strip (it ignores the top safe area),
423 // so frost it like the bar above rather than letting rows collide.
424 .background(.bar)
425 }
426
427 // Back to the funnel's defaults: every category on, online-only off.
428 private func resetFilters() {
429 filterOnline = false
430 filterPeople = true
431 filterBots = true
432 filterApps = true
433 }
434
435 // The funnel emptied the list. Name the online case — it's the one people
436 // actually hit — and fall back to a generic line for the category toggles.
437 private var filteredEmpty: some View {
438 VStack(spacing: 8) {
439 Image(systemName: "line.3.horizontal.decrease.circle")
440 .font(.system(size: 36)).foregroundStyle(.secondary)
441 Text(filterOnline ? "Nobody is online" : "No chats match your filters")
442 .font(.callout).foregroundStyle(.secondary)
443 }
444 .padding()
445 }
446
447 // One instance-wide socket keeps the LIST live (the Mac sidebar's trick):
448 // the hub fans every thread's frames to any member connection, so stored-
449 // message activity — including a brand-new member's first message —
450 // debounce-refreshes the list without waiting for a pull.
451 @State private var liveSocket: ChatSocket?
452 @State private var refreshPending = false
453
454 private func startLive() {
455 guard liveSocket == nil, let base = store.instance.baseURL else { return }
456 let socket = ChatSocket(baseURL: base, token: store.instance.deviceToken)
457 socket?.onFrame = { frame in
458 // Presence needs no refetch — fold the transition straight into the
459 // store (rows re-render off the published change).
460 if frame.type == "presence", let uid = frame.user_id, let state = frame.presence {
461 store.applyPresence(userID: uid, state: state)
462 return
463 }
464 // A DM ended or revived (#578), by either side, maybe elsewhere —
465 // fold the flag in place like presence; the frame is the change.
466 if frame.type == "dm_closed" || frame.type == "dm_reopened",
467 let tid = frame.thread_id {
468 store.applyDMClosed(threadID: tid, closed: frame.type == "dm_closed")
469 return
470 }
471 // The space thread was just created somewhere (content-free prod,
472 // spacethread.go) — fetch it and fold its installs.
473 if frame.type == "space_thread" {
474 Task { await store.reloadSpaceApps() }
475 return
476 }
477 // The app set changed (a custom/member app deployed or updated,
478 // anywhere): refresh the catalogue — through a command host,
479 // minted if no page is alive to answer (an all-native session
480 // may have none). An update or removal also refreshes every cached
481 // app webview: a live page can't swap an app's code in place
482 // (sandbox.js won't re-register a loaded id), so parked pages
483 // reload invisibly and a visible one gets an inline reload offer
484 // — without this a surface keeps running the old version until
485 // its webview happens to be rebuilt. A brand-new app registers
486 // live in-page, so "created" needs no reload.
487 if frame.type == "apps_changed" {
488 store.refreshAppCatalog()
489 if frame.kind == "updated" || frame.kind == "removed" {
490 FeedWebCache.shared.handleAppsChanged(
491 instanceID: store.instance.id, kind: frame.kind ?? "updated")
492 }
493 return
494 }
495 // A member lacks the instance key (#1376): if we hold it, wrap it
496 // to them — the holder side of the keyless self-heal, mirroring
497 // the web (shell.js). Without this a native-only space never
498 // grants, and the joiner stays "Someone" to everyone.
499 if frame.type == "instance_key_nudge", let uid = frame.user_id {
500 store.handleInstanceKeyNudge(userID: uid)
501 return
502 }
503 // The space thread's own frames: fold installs in place — the
504 // thread never appears in /api/threads, so a refresh would learn
505 // nothing about it.
506 if let tid = frame.thread_id, tid == store.spaceApps.threadID {
507 store.spaceApps.handleLive(frame)
508 return
509 }
510 // role_changed rides the same debounced refresh: an admin
511 // promotion (main.go adoptKeyInvite) is server-synthesized, not a
512 // stored-message mutation, but the roster it invalidates is the
513 // same one reportableAdmins and isAdmin-gated UI read — a stale
514 // copy would otherwise persist until the next msg/edit/delete.
515 guard frame.ephemeral != true,
516 ["msg", "edit", "delete", "role_changed"].contains(frame.type) else { return }
517 guard !refreshPending else { return }
518 refreshPending = true
519 DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
520 refreshPending = false
521 Task {
522 #if DEBUG
523 let t0 = CFAbsoluteTimeGetCurrent()
524 await store.refresh()
525 HitchLogger.shared.note("live store.refresh \(Int((CFAbsoluteTimeGetCurrent() - t0) * 1000))ms")
526 #else
527 await store.refresh()
528 #endif
529 }
530 }
531 }
532 socket?.start()
533 liveSocket = socket
534 }
535
536 private func load() async {
537 // Local first: last session's rows render instantly (loading is a
538 // truly-first-visit state); the network refresh reconciles behind them.
539 await store.hydrate()
540 await store.refresh()
541 startLive()
542 app.note(id: store.instance.id, myUserId: store.myUserId, groupName: store.instanceName)
543 // Warm the top chats off-screen so tapping one attaches an already-built
544 // feed — the native mirror of the web's prebuild. Which cache depends on
545 // which surface will render: parked ChatFeedModels for the native feed
546 // (folded messages + live socket), webviews otherwise.
547 NativeFeedModelCache.shared.warm(instance: store.instance,
548 threads: store.threads.filter { !$0.hidden },
549 myUserId: store.myUserId)
550 }
551}
552
553// The chat list's first-load state: an opaque surface carrying the brand's
554// smoke-and-motes scene (web bootscene.js's native twin), with the spinner
555// joining only if the load drags — the same tuned feel as the retired
556// space-switch scene it replaces. Reduce Motion drops the drifting particles.
557// The surface stays out of the large-title region (it respects the safe area)
558// so the space name never has loader pixels moving under it.
559private struct ChatsLoadingScene: View {
560 @Environment(\.accessibilityReduceMotion) private var reduceMotion
561 @State private var spinner = false
562
563 var body: some View {
564 ZStack {
565 Color(.systemBackground)
566 // A fraction of full strength: the shared AmbientField is tuned
567 // for the create-space stage, where the scene IS the show — in a
568 // loading beat it's barely noticeable, by design.
569 if !reduceMotion { SmokeSceneView().opacity(0.2) }
570 if spinner {
571 ProgressView().accessibilityLabel("Loading chats")
572 }
573 }
574 .task {
575 try? await Task.sleep(for: .seconds(0.5))
576 spinner = true
577 }
578 }
579}
580
581// Resolves a pushed thread id against the (observed) store, tolerating the store
582// still loading — a notification tap routes here before the fresh ThreadStore has
583// fetched anything, and bailing to "not found" on that race strands the tap.
584struct ThreadDestination: View {
585 @ObservedObject var store: ThreadStore
586 let id: String
587 // The navigation path, for surfaces that hop threads (the Eleven
588 // conversation's "Say hi to {inviter}" chip).
589 @Binding var path: [Route]
590
591 var body: some View {
592 if let t = store.threads.first(where: { $0.id == id }) {
593 chatSurface(t)
594 .id(store.instance.id + "|" + t.id) // stable identity per thread (webview reused via FeedWebCache)
595 .onAppear {
596 store.markRead(t)
597 store.reopenIfHidden(t) // opening a closed chat reopens it (mirrors web openThread)
598 PushManager.activeThreadID = t.id // suppress banners for the open chat
599 }
600 .onDisappear {
601 if PushManager.activeThreadID == t.id { PushManager.activeThreadID = nil }
602 }
603 } else if store.loading || store.threads.isEmpty {
604 ProgressView()
605 // Cold-launch tap: the snapshot usually resolves the thread
606 // instantly; the refresh covers a genuinely new one.
607 .task { await store.hydrate(); await store.refresh() }
608 } else {
609 ContentUnavailableView("Chat not found", systemImage: "bubble.left")
610 }
611 }
612
613 @ViewBuilder private func chatSurface(_ t: ChatThread) -> some View {
614 // One registry decision (docs/native-apps.md Layer 3) instead of
615 // hard-coded app-id checks: the flow interpreter and the schema
616 // engine are native enhancements, and everything else is
617 // AppThreadScreen — native chat, with the app's own interface (the
618 // only part a webview may carry) a toggle away. Chat never renders
619 // in a webview.
620 if !t.isApp {
621 NativeChatScreen(instance: store.instance, thread: t, store: store, path: $path)
622 } else {
623 switch store.registry.presentation(t.appId ?? "") {
624 case .flow:
625 ElevenChatScreen(instance: store.instance, thread: t, store: store, path: $path)
626 case .schema where !t.threadKeyB64.isEmpty:
627 if let screen = SchemaAppScreen(instance: store.instance, thread: t, store: store, path: $path) {
628 screen
629 } else {
630 AppThreadScreen(instance: store.instance, thread: t, store: store, path: $path)
631 }
632 default:
633 AppThreadScreen(instance: store.instance, thread: t, store: store, path: $path)
634 }
635 }
636 }
637}
638