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
3import UIKit
4
5@main
6struct LChatApp: App {
7 @UIApplicationDelegateAdaptor(PushManager.self) private var push
8 @StateObject private var app = AppState()
9
10 init() {
11 // Reading a chat clears its delivered notifications (PR #260) — wired
12 // here because the shared ThreadStore can't depend on the app target.
13 ThreadStore.onMarkRead = { PushManager.clearDelivered(thread: $0) }
14 // Sockets heartbeat "active" while the app is frontmost (presence +
15 // push suppression) — wired per platform because LChatCore can't see UIKit.
16 ChatSocket.appIsActive = { UIApplication.shared.applicationState == .active }
17 // Start the row-height cache's disk load now, off-main — merged long
18 // before the first chat opens, so first opens skip the measure pass
19 // that cost seconds on device (see FeedHeightCache).
20 FeedHeightCache.warm()
21 }
22
23 var body: some Scene {
24 WindowGroup {
25 RootView()
26 .environmentObject(app)
27 }
28 }
29}
30
31// An invite link that arrived from outside the app (the join page's scheme
32// or a universal link), validated and parked for its confirming tap.
33// Identifiable so the confirm sheet presents item-based — a second link
34// landing while the sheet is up swaps the content in place (newest wins,
35// like the Mac receiver).
36struct PendingAddLink: Identifiable, Equatable {
37 let link: String
38 var id: String { link }
39}
40
41// Owns the persisted list of instances (workspaces). Each instance carries its own
42// keypair; the list lives in the Keychain (InstancesStore).
43final class AppState: ObservableObject {
44 @Published var instances: [SavedInstance]
45 // The web→app handoff's pre-fill (RootView's onOpenURL stashes the
46 // validated link; AddFromLinkConfirm consumes it).
47 @Published var pendingAddLink: PendingAddLink?
48 // Per-instance server photos (id -> image) shown in the server switcher. Public
49 // branding served in the clear at /api/server-avatar (not an E2E blob), so it's
50 // fetched directly here rather than through BlobImageLoader.
51 @Published var serverAvatars: [String: UIImage] = [:]
52 // Sealed-avatar latch: instance ids whose sealed photo we already tried to
53 // resolve this launch (the resolver is a 600k-iteration PBKDF2 — once).
54 private var sealedAvatarAttempted: Set<String> = []
55
56 init() {
57 instances = InstancesStore.load()
58 #if DEBUG
59 // The sim rig's door (LChatUITests, 2026-07-21): a dev-server join
60 // link in the environment seeds the space natively — no accounts
61 // flow, no hands. Debug-only, and only into an empty install.
62 if instances.isEmpty,
63 let link = ProcessInfo.processInfo.environment["LCHAT_DEV_JOIN"],
64 !link.isEmpty {
65 Task { @MainActor in
66 guard self.instances.isEmpty,
67 let inst = try? await SpaceClaim.claim(
68 setupLink: link, screenName: "Sim") else { return }
69 self.instances.append(inst)
70 InstancesStore.save(self.instances)
71 }
72 }
73 #endif
74 }
75
76 /// Sign-out's second half: AppReset has already wiped the stores — drop
77 /// the in-memory mirror so the UI folds back to first-run without
78 /// relaunching (nothing here re-persists).
79 func eraseAll() {
80 instances = []
81 serverAvatars = [:]
82 sealedAvatarAttempted = []
83 }
84
85 /// Fetch each server's photo for the switcher. /api/group is public and reports
86 /// whether a photo is set, so this works with no key and only fetches the bytes
87 /// when one exists (and only when we don't already have it). Cheap + idempotent —
88 /// safe to call whenever the switcher appears or the instance set changes.
89 @MainActor
90 func loadServerAvatars() async {
91 for inst in instances {
92 guard let base = inst.baseURL else { continue }
93 let api = LChatAPI(baseURL: base, token: inst.deviceToken)
94 let info = await api.serverInfo()
95 if info.hasAvatar {
96 if serverAvatars[inst.id] == nil, let data = await api.serverAvatar(),
97 let image = UIImage(data: data) {
98 serverAvatars[inst.id] = image
99 }
100 } else if info.sealed {
101 // Sealed branding: the photo (if any) is ciphertext — we're a
102 // member with keys, so decrypt it (SealedBranding.swift). One
103 // attempt per launch per instance: the resolver costs a 600k
104 // PBKDF2, and a photo-less sealed instance would otherwise
105 // re-pay it on every task refire (the image cache never fills).
106 if serverAvatars[inst.id] == nil, !sealedAvatarAttempted.contains(inst.id) {
107 sealedAvatarAttempted.insert(inst.id)
108 if let opened = await SealedBrandingResolver.open(api: api, userPrivPkcs8B64: inst.userPriv),
109 let data = await api.serverAvatarSealed(key: opened.key),
110 let image = UIImage(data: data) {
111 serverAvatars[inst.id] = image
112 }
113 }
114 } else if serverAvatars[inst.id] != nil {
115 serverAvatars[inst.id] = nil // photo removed on the server — drop back to initials
116 }
117 }
118 }
119
120 /// Add a new instance, or replace one with the same id (re-join). Both are
121 /// deliberate acts, so the entry is stamped addedAt=now (unless the caller
122 /// set it) — that's what lets it beat an older home-page removal tombstone.
123 func add(_ inst: SavedInstance) {
124 var inst = inst
125 if inst.addedAt == nil { inst.addedAt = Date().timeIntervalSince1970 * 1000 }
126 if let i = instances.firstIndex(where: { $0.id == inst.id }) {
127 instances[i] = inst
128 } else {
129 instances.append(inst)
130 }
131 InstancesStore.save(instances)
132 HomeSync.pushSoon(instances: instances)
133 }
134
135 func remove(_ inst: SavedInstance) {
136 instances.removeAll { $0.id == inst.id }
137 InstancesStore.save(instances)
138 ThreadListSnapshot.clear(origin: inst.origin) // no keys linger for a space we left
139 // The cached branding key goes too — it opens the (publicly served)
140 // sealed avatar ciphertext, so it must not outlive membership here.
141 if let base = inst.baseURL { SealedBrandingResolver.clearCache(origin: base.absoluteString) }
142 // Sync never deletes from the vault — the removed space just stops being
143 // device-ordered and tails as a vault-only entry (see HomeSync).
144 HomeSync.pushSoon(instances: instances)
145 }
146
147 /// Reorder the server list (drag in the switcher). The array order IS the
148 /// display order, so persisting it is all there is to it.
149 func move(from source: IndexSet, to destination: Int) {
150 instances.move(fromOffsets: source, toOffset: destination)
151 InstancesStore.save(instances)
152 HomeSync.pushSoon(instances: instances)
153 }
154
155 /// Sign-in merge — the vault wins: apply its stars + names to servers this
156 /// device already has, and its array order (device-only servers tail, in
157 /// their existing relative order). One save, no push — the caller pushes
158 /// once after the whole merge, so the write-back reflects the merged state.
159 func applyVault(_ entries: [(url: String, starred: Bool, archived: Bool, name: String?)]) {
160 for e in entries {
161 guard let i = instances.firstIndex(where: { $0.origin == e.url }) else { continue }
162 instances[i].starred = e.starred ? true : nil
163 instances[i].archived = e.archived ? true : nil
164 if let n = e.name, !n.isEmpty { instances[i].groupName = n }
165 }
166 var rest = instances
167 var out: [SavedInstance] = []
168 for url in entries.map(\.url) {
169 if let i = rest.firstIndex(where: { $0.origin == url }) { out.append(rest.remove(at: i)) }
170 }
171 instances = out + rest
172 InstancesStore.save(instances)
173 }
174
175 /// Reorder within ONE star group (the switcher's favourites and the More
176 /// sheet's sections each drag independently — home's independent lists). Offsets
177 /// are relative to that group's own array; the other group is untouched.
178 func moveInGroup(starred: Bool, from source: IndexSet, to destination: Int) {
179 var group = instances.filter { $0.isStarred == starred }
180 let other = instances.filter { $0.isStarred != starred }
181 group.move(fromOffsets: source, toOffset: destination)
182 // Normalised starred-first, matching toggleStar's boundary invariant.
183 instances = starred ? group + other : other + group
184 InstancesStore.save(instances)
185 HomeSync.pushSoon(instances: instances)
186 }
187
188 /// Star or unstar a space. Starring sends it to the END of the starred group;
189 /// unstarring sends it to the TOP of the rest — matching the home vault's
190 /// toggleStar so the array order stays in lockstep (the vault write-back,
191 /// when signed in, is layered on top of this).
192 func toggleStar(_ inst: SavedInstance) {
193 guard let target = instances.first(where: { $0.id == inst.id }) else { return }
194 var updated = target
195 updated.starred = target.isStarred ? nil : true
196 let starred = instances.filter { $0.id != inst.id && $0.isStarred }
197 let others = instances.filter { $0.id != inst.id && !$0.isStarred }
198 instances = starred + [updated] + others
199 InstancesStore.save(instances)
200 HomeSync.pushSoon(instances: instances)
201 }
202
203 /// Merge a PULLED vault into this device (the foreground sync): removals
204 /// made on the home page apply first (tombstones drop our stale copies),
205 /// spaces signed into elsewhere are added (their credentials ride the
206 /// vault), then the vault's stars/archive/names/order apply — the same
207 /// vault-wins rule as sign-in. No push back: the device state now equals
208 /// the vault.
209 func merge(vault contents: HomeVault.Contents) {
210 dropTombstoned(contents.removed)
211 for s in contents.servers {
212 guard let creds = s.credentials,
213 !instances.contains(where: { $0.origin == s.url }),
214 // An entry AND a tombstone (a stale client re-pushed after the
215 // removal): adding it would stamp addedAt=now, and the next
216 // push would read that as a deliberate re-add and clear the
217 // tombstone vault-wide. Let the vault heal instead.
218 HomeVault.tombstonedAt(contents.removed, url: s.url) == 0 else { continue }
219 instances.append(SavedInstance(
220 origin: s.url, deviceToken: creds.token,
221 screenName: creds.name ?? s.name ?? "",
222 userPub: creds.userPub, userPriv: creds.userPriv,
223 groupName: s.name ?? "",
224 starred: s.isStarred ? true : nil,
225 archived: s.isArchived ? true : nil,
226 addedAt: Date().timeIntervalSince1970 * 1000))
227 }
228 applyVault(contents.servers.map {
229 (url: $0.url, starred: $0.isStarred, archived: $0.isArchived, name: $0.name)
230 })
231 }
232
233 /// Apply home-page removals to this device: drop any instance whose vault
234 /// tombstone is newer than when the device gained it. In-memory + persist
235 /// only — deliberately no push (push independently skips tombstoned
236 /// entries, so there's nothing to write back).
237 func dropTombstoned(_ removed: [HomeVault.Tombstone]?) {
238 guard let removed, !removed.isEmpty else { return }
239 let before = instances.count
240 instances.removeAll {
241 HomeVault.tombstonedAt(removed, url: $0.origin) > ($0.addedAt ?? 0)
242 }
243 if instances.count != before { InstancesStore.save(instances) }
244 }
245
246 /// Archive or unarchive a space: hidden from the switcher's lists,
247 /// grouped under Archived. Repositions with the home vault's setArchived
248 /// semantics so the array order stays in lockstep: archiving drops the star
249 /// and lands at the TOP of the archived group; unarchiving lands at the
250 /// BOTTOM of the active list. Both are the active/archived boundary — one
251 /// insert.
252 func toggleArchive(_ inst: SavedInstance) {
253 guard let target = instances.first(where: { $0.id == inst.id }) else { return }
254 var updated = target
255 let on = !target.isArchived
256 updated.archived = on ? true : nil
257 if on { updated.starred = nil } // leaving the rail
258 let starred = instances.filter { $0.id != inst.id && $0.isStarred && !$0.isArchived }
259 let others = instances.filter { $0.id != inst.id && !$0.isStarred && !$0.isArchived }
260 let archived = instances.filter { $0.id != inst.id && $0.isArchived }
261 instances = starred + others + [updated] + archived
262 InstancesStore.save(instances)
263 HomeSync.pushSoon(instances: instances)
264 }
265
266 /// Persist resolved fields discovered after first load (our user id, group name).
267 func note(id: String, myUserId: String? = nil, groupName: String? = nil) {
268 guard let i = instances.firstIndex(where: { $0.id == id }) else { return }
269 var changed = false
270 if let uid = myUserId, !uid.isEmpty, instances[i].myUserId != uid {
271 instances[i].myUserId = uid; changed = true
272 }
273 if let name = groupName, !name.isEmpty, instances[i].groupName != name {
274 instances[i].groupName = name; changed = true
275 }
276 if changed {
277 InstancesStore.save(instances)
278 HomeSync.pushSoon(instances: instances) // names feed the vault entries
279 }
280 }
281}
282
283// Holds the live navigation: which instance (server) is open and its ThreadStore.
284// One ThreadStore at a time, swapped when the server changes, so the chat list and
285// the feed observe the same store.
286@MainActor
287final class NavModel: ObservableObject {
288 @Published var instanceID: String?
289 @Published var store: ThreadStore?
290 // One live store per visited space, reused on re-entry — its rows are
291 // already folded, so going back into a space never shows loading again
292 // this session (the Keychain snapshot covers the cross-launch case).
293 private var stores: [String: ThreadStore] = [:]
294
295 /// `remember: false` selects a space as CONTEXT only (the home screen's
296 /// avatar / Edit profile need one) without stamping it as where you live —
297 /// only a space you actually open becomes the next launch's landing.
298 func selectInstance(_ inst: SavedInstance?, remember: Bool = true) {
299 guard inst?.id != instanceID else { return }
300 instanceID = inst?.id
301 if let inst {
302 // Reuse unless the credentials changed underneath (a re-join
303 // replaces the SavedInstance in place with a fresh token/keypair).
304 if let cached = stores[inst.id], cached.instance.deviceToken == inst.deviceToken {
305 store = cached
306 } else {
307 let fresh = ThreadStore(instance: inst)
308 stores[inst.id] = fresh
309 store = fresh
310 }
311 } else {
312 store = nil
313 stores = [:] // sign-out: nothing decrypted survives in memory
314 }
315 // Remembered across launches: reopening the app (or restoring a
316 // backup) lands in the space you were last in.
317 if remember, let id = inst?.id {
318 UserDefaults.standard.set(id, forKey: "lastInstanceID")
319 }
320 }
321}
322
323// What the NavigationStack pushes over the spaces home screen: the selected
324// space's chat list, then chats within it.
325enum Route: Hashable {
326 case chats
327 case thread(String)
328 case spaceApp(String) // a space-wide app's full-pane surface (docs/native-apps.md phase 4)
329}
330
331// The shell, the Mimestream shape: the stack's ROOT is the spaces home screen
332// (SpacesHomeView); the active space's chat list is PUSHED on top, so "back"
333// from your chats is how you switch space. Day to day you never see home —
334// launch lands straight in the space you were last in.
335struct RootView: View {
336 @EnvironmentObject var app: AppState
337 @EnvironmentObject private var push: PushManager
338 @Environment(\.scenePhase) private var scenePhase
339 @StateObject private var nav = NavModel()
340 // Drives the first-run "Sign in with Eleven" ceremony + restore state.
341 @StateObject private var signIn = HomeSignInModel()
342 @State private var path: [Route] = [] // home → .chats → .thread(id)…
343 // The space you actually LIVE in — stamped by opening one, mirrored from
344 // lastInstanceID. nav.instanceID can additionally hold a context-only
345 // selection (home's avatar needs one); this drives home's "Current" group.
346 @State private var currentSpaceID: String?
347 // Sign out clears the device — home's menu row only *requests* it; this
348 // destructive confirmation gates the actual wipe.
349 @State private var confirmSignOut = false
350 // Create-a-space (the goes-to-eleven flow): whether home offers it (GET
351 // /api/features), and the in-flight model while the instrument is up
352 // (item-based presentation — no present-before-content race).
353 @State private var spacesOffered = false
354 @State private var createModel: CreateSpaceModel?
355 var body: some View {
356 NavigationStack(path: $path) {
357 Group {
358 if app.instances.isEmpty {
359 noServers
360 } else {
361 SpacesHomeView(nav: nav, signIn: signIn,
362 currentID: currentSpaceID,
363 spacesOffered: spacesOffered,
364 onSelect: select,
365 onSignOut: { confirmSignOut = true },
366 onCreate: { createModel = makeCreateModel() })
367 }
368 }
369 .navigationDestination(for: Route.self) { route in
370 // The store is always live when something is pushed (selection
371 // precedes the push); the fallbacks just keep a race from
372 // rendering nothing.
373 switch route {
374 case .chats:
375 if let store = nav.store {
376 ThreadListView(store: store, path: $path)
377 } else {
378 ProgressView()
379 }
380 case .thread(let id):
381 if let store = nav.store {
382 ThreadDestination(store: store, id: id, path: $path)
383 } else {
384 ProgressView()
385 }
386 case .spaceApp(let app):
387 if let store = nav.store {
388 SpaceAppScreen(store: store, app: app)
389 } else {
390 ProgressView()
391 }
392 }
393 }
394 }
395 .confirmationDialog(
396 "Sign out? Your spaces, messages and keys are removed from this \(DeviceName.current). Sign back in to get them back.",
397 isPresented: $confirmSignOut, titleVisibility: .visible
398 ) {
399 Button("Sign Out & Clear This \(DeviceName.current)", role: .destructive, action: signOut)
400 }
401 // Load server photos once at launch (and when the server set changes) so both
402 // the toolbar space button and the switcher show them without opening anything.
403 .task(id: Set(app.instances.map(\.id))) { await app.loadServerAvatars() }
404 .onAppear {
405 Perf.noteFirstFrame()
406 if nav.instanceID == nil {
407 // The space you were last in opens straight to its chats; a
408 // device that doesn't KNOW where you live (fresh sign-in,
409 // never opened a space) lands on the spaces home screen —
410 // with the head of the list selected as context (remember:
411 // false: only a space you open becomes the landing).
412 if let known = knownLastInstance {
413 nav.selectInstance(known)
414 currentSpaceID = known.id
415 // Restored state, not a navigation — landing in your space
416 // must not play a push animation over the home screen. The
417 // hydrate races the first frame so last session's rows are
418 // up before (or with) the view's own load.
419 Task { await nav.store?.hydrate() }
420 var t = Transaction()
421 t.disablesAnimations = true
422 withTransaction(t) { path = [.chats] }
423 } else {
424 nav.selectInstance(fallbackInstance, remember: false)
425 }
426 }
427 // Someone with chats wants to hear about them (no-op without the
428 // push entitlement; asks permission the first time).
429 if !app.instances.isEmpty { push.enablePush() }
430 // A cold-launch notification tap is already waiting here (delivered
431 // before this view could subscribe) — route it now.
432 routeNotificationTap()
433 pullHome()
434 routeChangelogTap()
435 // The DEK-sync migrate-by-addition moment (#1089, flag-gated):
436 // an existing signed-in install adds the synchronizable DEK item
437 // on its first unlocked launch — write-gated on the account
438 // holding a SiwA anchor; a no-op for everyone else.
439 Task { await DEKSync.writeIfEligible() }
440 }
441 // Sign-in restore populates instances while the first-run screen is
442 // up — the root folds to the spaces home screen (the restored list),
443 // with the head of it selected as context. Deliberately NOT pushed
444 // into a space: a fresh sign-in doesn't know where you live yet.
445 .onChange(of: app.instances.map(\.id)) { _, _ in
446 guard nav.instanceID == nil, let inst = fallbackInstance else { return }
447 nav.selectInstance(inst, remember: false)
448 push.enablePush()
449 }
450 // A tap that arrives while we're already up: the published id changes and
451 // this fires. (onAppear covers the cold-launch case where it was set first.)
452 .onChange(of: push.pendingThreadID) { _, _ in routeNotificationTap() }
453 .onChange(of: push.pendingChangelogTap) { _, _ in routeChangelogTap() }
454 // App Studio's Open, forwarded by an app pane's webview bridge
455 // (WebBridge {openAppThread}): open — or create — the app's personal
456 // thread and push it, like a notification tap. The pane itself must
457 // never navigate, or the app renders inside App Studio's screen.
458 .onReceive(NotificationCenter.default.publisher(for: .lchatOpenAppThread)) { note in
459 guard let store = nav.store,
460 let name = note.userInfo?["app"] as? String, !name.isEmpty else { return }
461 let title = note.userInfo?["title"] as? String ?? name
462 Task {
463 if let id = await store.openOrCreateAppThread(app: name, title: title),
464 path.last != .thread(id) {
465 path = [.chats, .thread(id)]
466 }
467 }
468 }
469 // Coming back to the app re-fetches the active server's chats, so a chat
470 // started with us while we were away (thread creation sends no push) is
471 // there when we look. The `.task(id:)` in ThreadListView only fires on first
472 // appear / server switch, so foregrounding otherwise never refreshed.
473 .onChange(of: scenePhase) { _, phase in
474 refreshInbox(when: phase == .active)
475 // Foregrounding also pulls the vault: a star/archive made on the
476 // home page (or another device) lands here without a re-sign-in.
477 if phase == .active { pullHome() }
478 // …and lifts the store's suspension gate from the last background.
479 if phase == .active { Task { await MessageStore.shared.resume() } }
480 // Leaving for the background arms the next Background App Refresh
481 // wake, so chats keep syncing into the local store while we sleep.
482 if phase == .background, !app.instances.isEmpty { BackgroundRefresh.schedule() }
483 // …and releases the shared store's sqlite connection: a lock on an
484 // app-group file held across suspension is the 0xdead10cc kill
485 // (MessageStore.suspend). suspend() queues behind any in-flight
486 // store write on the actor, so hold a background-task assertion
487 // until the close lands — without it iOS can suspend the process
488 // mid-statement with the lock held (the Aug 2026 recurrence was a
489 // page-fold write straddling exactly this moment).
490 if phase == .background {
491 let assertion = UIApplication.shared.beginBackgroundTask(withName: "store-suspend")
492 Task {
493 await MessageStore.shared.suspend()
494 if assertion != .invalid { UIApplication.shared.endBackgroundTask(assertion) }
495 }
496 }
497 }
498 // A push landing while we're open (see PushManager.inboxPing) refreshes live.
499 .onChange(of: push.inboxPing) { _, _ in refreshInbox(when: true) }
500 // Whether the switcher offers "Create a new space" — home must offer it
501 // and the member must already have a space (the web's canCreateSpace
502 // gate; the server enforces the same with 403 not_eligible).
503 .task(id: "\(app.instances.isEmpty)-\(signIn.sessionExists)") {
504 guard signIn.sessionExists, !app.instances.isEmpty else { return }
505 spacesOffered = await HomeClient().features()
506 }
507 .fullScreenCover(item: $createModel) { model in
508 CreateSpaceSheet(model: model)
509 }
510 // The web→app handoff receiver (the Mac onOpenURL's iOS sibling): the
511 // join page's "Open in the app" scheme (elevenmessenger://add?url=…,
512 // the secret riding encoded inside the query value — it never reaches
513 // any server) and universal links on covered instance domains both
514 // land here. Validated, then PRE-FILLED into the confirm card rather
515 // than joined — one confirming tap for any URL that arrived from
516 // outside. Non-invite URLs (e.g. message permalinks) fall through.
517 .onOpenURL { url in
518 guard let link = inviteLink(fromOpenedURL: url),
519 parseJoinLink(link) != nil else { return }
520 app.pendingAddLink = PendingAddLink(link: link)
521 }
522 .sheet(item: $app.pendingAddLink) { pending in
523 AddFromLinkConfirm(link: pending.link) { inst in
524 app.pendingAddLink = nil
525 select(inst)
526 }
527 }
528 // A features() fetch that failed once must not hide the affordance for
529 // the whole session — re-check whenever you come back to the home
530 // screen (the only place the + lives).
531 .onChange(of: path) { _, p in
532 guard p.isEmpty, !spacesOffered, signIn.sessionExists, !app.instances.isEmpty else { return }
533 Task { spacesOffered = await HomeClient().features() }
534 }
535 }
536
537 // The goes-to-eleven create flow. The WHOLE pipeline (provision → claim →
538 // setup_complete) runs inside `create`, under "Building your space" — the
539 // instance is persisted (and vault-pushed) before success is ever shown,
540 // so quitting at the confetti can't strand a half-born space.
541 private func makeCreateModel() -> CreateSpaceModel {
542 CreateSpaceModel(
543 create: { [weak app] in
544 // Carry the name you already go by (your most recent space).
545 let screenName = await MainActor.run {
546 app?.instances.first(where: { !$0.isArchived })?.screenName
547 ?? app?.instances.first?.screenName ?? ""
548 }
549 let inst = try await SpaceCreationFlow.run(screenName: screenName)
550 await MainActor.run { app?.add(inst) }
551 return inst
552 },
553 finish: { inst, _ in
554 select(inst)
555 createModel = nil
556 },
557 cancel: { createModel = nil })
558 }
559
560 // The space this device KNOWS you were last in — the launch-straight-to-
561 // chats case. nil when there's no record (fresh sign-in): home shows.
562 private var knownLastInstance: SavedInstance? {
563 let last = UserDefaults.standard.string(forKey: "lastInstanceID")
564 return app.instances.first { $0.id == last }
565 }
566
567 // The context space while you're parked on home: the head of the list
568 // (vault order — starred first), skipping archived.
569 private var fallbackInstance: SavedInstance? {
570 app.instances.first { !$0.isArchived } ?? app.instances.first
571 }
572
573 // The silent vault pull (no-op when signed out or rate-limited).
574 private func pullHome() {
575 Task {
576 if let contents = await HomeSync.pull() { app.merge(vault: contents) }
577 }
578 }
579
580 // Re-fetch the active server's thread list, unless one is already in flight (the
581 // cold-launch `.task` load, a pull-to-refresh, or an overlapping trigger).
582 private func refreshInbox(when: Bool) {
583 guard when, let store = nav.store, !store.loading else { return }
584 Task { await store.refresh() }
585 }
586
587 // A tapped notification lands in its chat: the shared key store knows which
588 // instance the thread belongs to, so we switch servers if needed and push the
589 // chat. Idempotent — clears the pending id so re-firing (incl. our own nil-out)
590 // is a no-op.
591 private func routeNotificationTap() {
592 guard let threadID = push.pendingThreadID else { return }
593 let stampedOrigin = push.pendingThreadOrigin
594 let stampedMessage = push.pendingMessageID
595 push.pendingThreadID = nil
596 push.pendingThreadOrigin = nil
597 push.pendingMessageID = nil
598 // The NSE's delivery-time origin stamp is the fast path; an unstamped
599 // banner falls down the ladder — parked SharedThreadKeys entry, the
600 // spaces' thread snapshots, the only signed-in space, and finally the
601 // space this device was last in (NotificationRoute) — a tap must
602 // never silently strand the member on the chat list.
603 let inst: SavedInstance
604 switch NotificationRoute.resolve(threadID: threadID,
605 origin: stampedOrigin,
606 lastActiveID: UserDefaults.standard.string(forKey: "lastInstanceID"),
607 instances: app.instances) {
608 case .stamped(let i), .parkedKey(let i), .snapshot(let i),
609 .onlySpace(let i), .lastActive(let i):
610 inst = i
611 case .unresolved:
612 // Nothing to even guess with (no spaces, or multi-space with no
613 // record of ever opening one). Leave the member where the launch
614 // landed them, and say why in the log (the one place field reports
615 // of "the tap did nothing" can be diagnosed from).
616 NSLog("notification tap: thread not resolvable to a space, dropped")
617 return
618 }
619 if nav.instanceID != inst.id {
620 nav.selectInstance(inst)
621 }
622 currentSpaceID = inst.id
623 // Land on the MESSAGE, not just its chat: the banner's stamped row id
624 // rides the same pending-jump seam a search hit uses, so the chat opens
625 // on it and waves it — paging history first when it isn't loaded, and
626 // opening its thread when the reply lives only there (jumpAndWave →
627 // jumpIntoThread). Armed BEFORE the push so the screen's own
628 // appear/ready pass finds it; the post covers the chat that's already
629 // on screen (nothing re-appears there).
630 let landOn = (stampedMessage ?? 0) > 0 ? stampedMessage : nil
631 if let landOn { NativeChatScreen.pendingJump = (threadID, landOn) }
632 if path.last != .thread(threadID) { path = [.chats, .thread(threadID)] }
633 if landOn != nil {
634 NotificationCenter.default.post(name: .lchatJumpToMessage, object: nil)
635 }
636 }
637
638 // A tapped "What's new" banner (the release-notes push, announce.go). The
639 // payload carries no thread id — the Admin thread's name rides encrypted, so
640 // the server can't address it — so resolve locally: open the CURRENT space's
641 // Admin app-thread if it has one, else just land in the app. The banner
642 // title names the space, so a multi-space admin can see which one updated.
643 private func routeChangelogTap() {
644 guard push.pendingChangelogTap else { return }
645 push.pendingChangelogTap = false
646 guard let admin = nav.store?.threads.first(where: { $0.appId == "admin" }) else { return }
647 if path.last != .thread(admin.id) { path = [.chats, .thread(admin.id)] }
648 }
649
650 // Opening a space is an ordinary navigation: the chat list slides in with
651 // its chrome, and shows the loading scene itself if its messages aren't
652 // there yet (ThreadListView's first-load overlay).
653 private func select(_ inst: SavedInstance) {
654 if inst.id != nav.instanceID {
655 // First join included: ask for notification permission the moment
656 // there's a chat worth hearing about, and (re)register this instance.
657 push.enablePush()
658 push.subscribeNewInstance(inst)
659 nav.selectInstance(inst)
660 } else {
661 // Already selected as context — but a context selection deliberately
662 // didn't stamp "last" (and selectInstance no-ops on a same-id
663 // re-call), so opening it stamps here.
664 UserDefaults.standard.set(inst.id, forKey: "lastInstanceID")
665 }
666 // Rows first, then the slide: hydrate is a few-ms Keychain read (a
667 // no-op on a store that's already live), so the chat list is visible
668 // DURING the push animation instead of materialising after it.
669 let store = nav.store
670 Task {
671 await store?.hydrate()
672 if path != [.chats] { path = [.chats] }
673 }
674 // The Current row is a shortcut — flipping it mid-slide repaints home
675 // under the outgoing view. Hold the flip until the push has fully
676 // covered it (the row is correct by the time home is next seen).
677 Task {
678 try? await Task.sleep(for: .seconds(0.75))
679 currentSpaceID = inst.id
680 }
681 }
682
683 // Sign out = clear this device (the spaces live in the member's Eleven
684 // account; signing back in restores them). A last sync push first, so even
685 // seconds-old changes are in the account before the wipe; then wipe, drop
686 // the in-memory mirrors, and fold to first-run in place. The destructive
687 // confirmation already happened (RootView's dialog).
688 private func signOut() {
689 Task {
690 if let session = HomeSessionStore.load() {
691 _ = await HomeSync.push(instances: app.instances, session: session)
692 }
693 // Stop the pushes before the wipe kills the tokens that authorise
694 // stopping them — otherwise every space keeps pushing sealed
695 // payloads at this phone forever (the NSE can't decrypt or drop
696 // them once the keys are gone).
697 await push.unsubscribeAll()
698 AppReset.wipeAll()
699 app.eraseAll()
700 signIn.refreshSession()
701 path = []
702 currentSpaceID = nil
703 nav.selectInstance(nil)
704 }
705 }
706
707 // First run: the sign-in doors — Apple leading, email beneath, the
708 // passkey demoted (last-used promoted; see SignInFirstRun). Still no
709 // paste-a-link (Paul's call — joining lives on the web; a native join
710 // flow comes later). The screen is iOS 18-gated with the passkey
711 // ceremony (PRF), so iOS 17 says so instead.
712 @ViewBuilder private var noServers: some View {
713 if #available(iOS 18.0, *) {
714 SignInFirstRun(model: signIn)
715 } else {
716 VStack(spacing: 14) {
717 Image(systemName: "person.badge.key")
718 .font(.system(size: 44)).foregroundStyle(.secondary)
719 Text("Sign in needs iOS 18").font(.headline)
720 Text("Update your \(DeviceName.current) to sign in to Eleven — or use your space in the browser meanwhile.")
721 .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
722 }
723 .padding()
724 }
725 }
726}
727