| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import UIKit |
| 4 | |
| 5 | @main |
| 6 | struct 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). |
| 36 | struct 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). |
| 43 | final 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 |
| 287 | final 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. |
| 325 | enum 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. |
| 335 | struct 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 | |