| 1 | import LChatCore |
| 2 | import 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. |
| 9 | struct 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. |
| 559 | private 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. |
| 584 | struct 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 | |