iOS: Mimestream-style spaces home screen — back from your chats switches space (#579)
* ios: spaces home screen — the chat list is pushed, back switches space The Mimestream shape: the NavigationStack's root is a new SpacesHomeView (Starred section, then other spaces in vault order capped at 11 with More…, create-a-space as the bottom-right +, your avatar top-left holding Edit profile / Sign in / Sign out). The active space's chat list is pushed on top, titled with the space's name; launch still lands straight in the last space you were in (restored without animation), and a device that doesn't know (fresh sign-in) parks on home. The old top-left switcher popover is gone; SpaceListSheet/SpaceRow/SpaceMenuItems live on, shared with home. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: home gets a Current group + chevrons; + moves top-right; opening a space is a plain slide with an in-list loader Per device feedback: a Current section leads (the space you live in, tracked as currentSpaceID — context-only selections don't count), all home rows carry disclosure chevrons instead of the active checkmark, and create-a-space is a plain top-right toolbar glyph, not a prominent FAB. Selecting a space now does the standard iOS push (whole chrome slides); the smoke scene moved INTO the chat list as its first-load overlay (plays only when we don't already hold the message list), replacing the full-screen SpaceSwitchScene takeover, which is deleted. Reorders in Starred/Spaces map indices past the hidden Current row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: scope the first-load fade to the overlay — the space-name title crossfaded with it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: no fades around the chat list's first load — the space name only ever slides Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: entering a space renders the last-known chat list instantly — loading is first-visit-only The list you already decrypted is the truth on entry; the network refresh is background reconciliation. Three layers: NavModel reuses one ThreadStore per visited space (re-entry this session is instant); ThreadStore.refresh parks its folded rows in the Keychain (ThreadListSnapshot — rows carry thread keys and decrypted titles, the SharedThreadKeys posture; presence stripped; fingerprint-deduped off-main writes) and hydrate() republishes them before refresh on a fresh store, covering cold launches; and a zero-thread fetch against held rows no longer blanks the list (offline fetches degrade to empty). Removing a space clears its snapshot; sign-out already wipes the whole keychain service. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: hydrate before the push — the chat list is visible while it slides in Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: Current is a shortcut (space keeps its Starred/More slot), flip deferred past the slide; More group is collapsible with remembered state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+526
−577
ios/LChat/LChatApp.swift+148 −56ios/LChat/Views/ElevenChatScreen.swift+3 −3ios/LChat/Views/SpaceSwitchScene.swift+0 −121ios/LChat/Views/SpaceSwitcher.swift+13 −265ios/LChat/Views/SpacesHomeView.swift+219 −0ios/LChat/Views/ThreadListView.swift+64 −129ios/LChatCore/Sources/LChatCore/Keychain.swift+42 −0ios/LChatCore/Sources/LChatCore/Models.swift+4 −2ios/LChatCore/Sources/LChatCore/ThreadStore.swift+33 −1
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index c06234d..3c851df 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -82,6 +82,7 @@ final class AppState: ObservableObject { |
| func remove(_ inst: SavedInstance) { |
| instances.removeAll { $0.id == inst.id } |
| InstancesStore.save(instances) |
| + ThreadListSnapshot.clear(origin: inst.origin) // no keys linger for a space we left |
| // Sync never deletes from the vault — the removed space just stops being |
| // device-ordered and tails as a vault-only entry (see HomeSync). |
| HomeSync.pushSoon(instances: instances) |
| @@ -230,23 +231,50 @@ final class AppState: ObservableObject { |
| final class NavModel: ObservableObject { |
| @Published var instanceID: String? |
| @Published var store: ThreadStore? |
| + // One live store per visited space, reused on re-entry — its rows are |
| + // already folded, so going back into a space never shows loading again |
| + // this session (the Keychain snapshot covers the cross-launch case). |
| + private var stores: [String: ThreadStore] = [:] |
| - func selectInstance(_ inst: SavedInstance?) { |
| + /// `remember: false` selects a space as CONTEXT only (the home screen's |
| + /// avatar / Edit profile need one) without stamping it as where you live — |
| + /// only a space you actually open becomes the next launch's landing. |
| + func selectInstance(_ inst: SavedInstance?, remember: Bool = true) { |
| guard inst?.id != instanceID else { return } |
| instanceID = inst?.id |
| - store = inst.map { ThreadStore(instance: $0) } |
| + if let inst { |
| + // Reuse unless the credentials changed underneath (a re-join |
| + // replaces the SavedInstance in place with a fresh token/keypair). |
| + if let cached = stores[inst.id], cached.instance.deviceToken == inst.deviceToken { |
| + store = cached |
| + } else { |
| + let fresh = ThreadStore(instance: inst) |
| + stores[inst.id] = fresh |
| + store = fresh |
| + } |
| + } else { |
| + store = nil |
| + stores = [:] // sign-out: nothing decrypted survives in memory |
| + } |
| // Remembered across launches: reopening the app (or restoring a |
| // backup) lands in the space you were last in. |
| - if let id = inst?.id { |
| + if remember, let id = inst?.id { |
| UserDefaults.standard.set(id, forKey: "lastInstanceID") |
| } |
| } |
| } |
| -// The shell, structured like the Mac app translated to a phone: the main index is |
| -// the active server's chat list; the top-left space button (avatar + name) drops |
| -// down the space switcher to change spaces or create one; tapping a chat pushes |
| -// its feed. |
| +// What the NavigationStack pushes over the spaces home screen: the selected |
| +// space's chat list, then chats within it. |
| +enum Route: Hashable { |
| + case chats |
| + case thread(String) |
| +} |
| + |
| +// The shell, the Mimestream shape: the stack's ROOT is the spaces home screen |
| +// (SpacesHomeView); the active space's chat list is PUSHED on top, so "back" |
| +// from your chats is how you switch space. Day to day you never see home — |
| +// launch lands straight in the space you were last in. |
| struct RootView: View { |
| @EnvironmentObject var app: AppState |
| @EnvironmentObject private var push: PushManager |
| @@ -254,43 +282,84 @@ struct RootView: View { |
| @StateObject private var nav = NavModel() |
| // Drives the first-run "Sign in with Eleven" ceremony + restore state. |
| @StateObject private var signIn = HomeSignInModel() |
| - @State private var path: [String] = [] // pushed thread ids |
| - // Whether the space-switcher dropdown (anchored to the chat list's top-left |
| - // space button) is up. Owned here so external acts — a chat select, an |
| - // invite link — can close it. |
| - @State private var showSwitcher = false |
| + @State private var path: [Route] = [] // home → .chats → .thread(id)… |
| + // The space you actually LIVE in — stamped by opening one, mirrored from |
| + // lastInstanceID. nav.instanceID can additionally hold a context-only |
| + // selection (home's avatar needs one); this drives home's "Current" group. |
| + @State private var currentSpaceID: String? |
| + // Sign out clears the device — home's menu row only *requests* it; this |
| + // destructive confirmation gates the actual wipe. |
| + @State private var confirmSignOut = false |
| // Create-a-space (the goes-to-eleven flow): whether home offers it (GET |
| // /api/features), and the in-flight model while the instrument is up |
| // (item-based presentation — no present-before-content race). |
| @State private var spacesOffered = false |
| @State private var createModel: CreateSpaceModel? |
| - // The space-switch transition: the outgoing list fades into the brand's |
| - // smoke-and-motes scene while the next space loads (SpaceSwitchScene.swift). |
| - @StateObject private var switchScene = SpaceSwitchModel() |
| - |
| var body: some View { |
| NavigationStack(path: $path) { |
| Group { |
| - if let store = nav.store { |
| - ThreadListView(store: store, path: $path, showSwitcher: $showSwitcher, |
| - signIn: signIn, spacesOffered: spacesOffered, |
| + if app.instances.isEmpty { |
| + noServers |
| + } else { |
| + SpacesHomeView(nav: nav, signIn: signIn, |
| + currentID: currentSpaceID, |
| + spacesOffered: spacesOffered, |
| onSelect: select, |
| - onSignOut: signOut, |
| + onSignOut: { confirmSignOut = true }, |
| onCreate: { createModel = makeCreateModel() }) |
| - } else { |
| - noServers |
| } |
| } |
| - // The switch scene covers only the LIST — inside the stack, under |
| - // the navigation chrome, so the header, search and toolbar hold |
| - // still while the chats crossfade through smoke. |
| - .overlay { SpaceSwitchOverlay(model: switchScene) } |
| + .navigationDestination(for: Route.self) { route in |
| + // The store is always live when something is pushed (selection |
| + // precedes the push); the fallbacks just keep a race from |
| + // rendering nothing. |
| + switch route { |
| + case .chats: |
| + if let store = nav.store { |
| + ThreadListView(store: store, path: $path) |
| + } else { |
| + ProgressView() |
| + } |
| + case .thread(let id): |
| + if let store = nav.store { |
| + ThreadDestination(store: store, id: id, path: $path) |
| + } else { |
| + ProgressView() |
| + } |
| + } |
| + } |
| + } |
| + .confirmationDialog( |
| + "Sign out? Your spaces, messages and keys are removed from this iPhone. Sign back in to get them back.", |
| + isPresented: $confirmSignOut, titleVisibility: .visible |
| + ) { |
| + Button("Sign Out & Clear This iPhone", role: .destructive, action: signOut) |
| } |
| // Load server photos once at launch (and when the server set changes) so both |
| // the toolbar space button and the switcher show them without opening anything. |
| .task(id: Set(app.instances.map(\.id))) { await app.loadServerAvatars() } |
| .onAppear { |
| - if nav.instanceID == nil { nav.selectInstance(rememberedInstance) } |
| + if nav.instanceID == nil { |
| + // The space you were last in opens straight to its chats; a |
| + // device that doesn't KNOW where you live (fresh sign-in, |
| + // never opened a space) lands on the spaces home screen — |
| + // with the head of the list selected as context (remember: |
| + // false: only a space you open becomes the landing). |
| + if let known = knownLastInstance { |
| + nav.selectInstance(known) |
| + currentSpaceID = known.id |
| + // Restored state, not a navigation — landing in your space |
| + // must not play a push animation over the home screen. The |
| + // hydrate races the first frame so last session's rows are |
| + // up before (or with) the view's own load. |
| + Task { await nav.store?.hydrate() } |
| + var t = Transaction() |
| + t.disablesAnimations = true |
| + withTransaction(t) { path = [.chats] } |
| + } else { |
| + nav.selectInstance(fallbackInstance, remember: false) |
| + } |
| + } |
| // Someone with chats wants to hear about them (no-op without the |
| // push entitlement; asks permission the first time). |
| if !app.instances.isEmpty { push.enablePush() } |
| @@ -301,11 +370,12 @@ struct RootView: View { |
| routeChangelogTap() |
| } |
| // Sign-in restore populates instances while the first-run screen is |
| - // up — carry the user straight into their spaces rather than leaving |
| - // them parked on the welcome screen. |
| + // up — the root folds to the spaces home screen (the restored list), |
| + // with the head of it selected as context. Deliberately NOT pushed |
| + // into a space: a fresh sign-in doesn't know where you live yet. |
| .onChange(of: app.instances.map(\.id)) { _, _ in |
| - guard nav.instanceID == nil, let inst = rememberedInstance else { return } |
| - nav.selectInstance(inst) |
| + guard nav.instanceID == nil, let inst = fallbackInstance else { return } |
| + nav.selectInstance(inst, remember: false) |
| push.enablePush() |
| } |
| // A tap that arrives while we're already up: the published id changes and |
| @@ -338,9 +408,10 @@ struct RootView: View { |
| CreateSpaceSheet(model: model) |
| } |
| // A features() fetch that failed once must not hide the affordance for |
| - // the whole session — re-check whenever the switcher opens. |
| - .onChange(of: showSwitcher) { _, open in |
| - guard open, !spacesOffered, signIn.sessionExists, !app.instances.isEmpty else { return } |
| + // the whole session — re-check whenever you come back to the home |
| + // screen (the only place the + lives). |
| + .onChange(of: path) { _, p in |
| + guard p.isEmpty, !spacesOffered, signIn.sessionExists, !app.instances.isEmpty else { return } |
| Task { spacesOffered = await HomeClient().features() } |
| } |
| } |
| @@ -368,11 +439,17 @@ struct RootView: View { |
| cancel: { createModel = nil }) |
| } |
| - // The space to open when none is selected: the one last open on this |
| - // device, else the first in the list (vault order — starred first). |
| - private var rememberedInstance: SavedInstance? { |
| + // The space this device KNOWS you were last in — the launch-straight-to- |
| + // chats case. nil when there's no record (fresh sign-in): home shows. |
| + private var knownLastInstance: SavedInstance? { |
| let last = UserDefaults.standard.string(forKey: "lastInstanceID") |
| - return app.instances.first { $0.id == last } ?? app.instances.first |
| + return app.instances.first { $0.id == last } |
| + } |
| + |
| + // The context space while you're parked on home: the head of the list |
| + // (vault order — starred first), skipping archived. |
| + private var fallbackInstance: SavedInstance? { |
| + app.instances.first { !$0.isArchived } ?? app.instances.first |
| } |
| // The silent vault pull (no-op when signed out or rate-limited). |
| @@ -399,10 +476,10 @@ struct RootView: View { |
| guard let entry = SharedThreadKeys.lookup(threadID: threadID), |
| let inst = app.instances.first(where: { $0.origin == entry.origin }) else { return } |
| if nav.instanceID != inst.id { |
| - path = [] |
| nav.selectInstance(inst) |
| } |
| - if path.last != threadID { path.append(threadID) } |
| + currentSpaceID = inst.id |
| + if path.last != .thread(threadID) { path = [.chats, .thread(threadID)] } |
| } |
| // A tapped "What's new" banner (the release-notes push, announce.go). The |
| @@ -414,24 +491,39 @@ struct RootView: View { |
| guard push.pendingChangelogTap else { return } |
| push.pendingChangelogTap = false |
| guard let admin = nav.store?.threads.first(where: { $0.appId == "admin" }) else { return } |
| - if path.last != admin.id { path.append(admin.id) } |
| + if path.last != .thread(admin.id) { path = [.chats, .thread(admin.id)] } |
| } |
| + // Opening a space is an ordinary navigation: the chat list slides in with |
| + // its chrome, and shows the loading scene itself if its messages aren't |
| + // there yet (ThreadListView's first-load overlay). |
| private func select(_ inst: SavedInstance) { |
| - showSwitcher = false |
| - // Re-picking the space you're already in loads nothing — no transition. |
| - guard inst.id != nav.instanceID else { return } |
| - // First join included: ask for notification permission the moment there's |
| - // a chat worth hearing about, and (re)register this instance. |
| - push.enablePush() |
| - push.subscribeNewInstance(inst) |
| - // The current chat list fades into the smoke-and-motes scene; the real |
| - // swap happens at full cover, and the reveal waits for the new space's |
| - // chats plus a minimum beat (SpaceSwitchModel has the choreography). |
| - switchScene.begin { |
| - path = [] // pushed chats belong to the previous server |
| + if inst.id != nav.instanceID { |
| + // First join included: ask for notification permission the moment |
| + // there's a chat worth hearing about, and (re)register this instance. |
| + push.enablePush() |
| + push.subscribeNewInstance(inst) |
| nav.selectInstance(inst) |
| - return nav.store |
| + } else { |
| + // Already selected as context — but a context selection deliberately |
| + // didn't stamp "last" (and selectInstance no-ops on a same-id |
| + // re-call), so opening it stamps here. |
| + UserDefaults.standard.set(inst.id, forKey: "lastInstanceID") |
| + } |
| + // Rows first, then the slide: hydrate is a few-ms Keychain read (a |
| + // no-op on a store that's already live), so the chat list is visible |
| + // DURING the push animation instead of materialising after it. |
| + let store = nav.store |
| + Task { |
| + await store?.hydrate() |
| + if path != [.chats] { path = [.chats] } |
| + } |
| + // The Current row is a shortcut — flipping it mid-slide repaints home |
| + // under the outgoing view. Hold the flip until the push has fully |
| + // covered it (the row is correct by the time home is next seen). |
| + Task { |
| + try? await Task.sleep(for: .seconds(0.75)) |
| + currentSpaceID = inst.id |
| } |
| } |
| @@ -439,7 +531,7 @@ struct RootView: View { |
| // account; signing back in restores them). A last sync push first, so even |
| // seconds-old changes are in the account before the wipe; then wipe, drop |
| // the in-memory mirrors, and fold to first-run in place. The destructive |
| - // confirmation already happened (ThreadListView's dialog). |
| + // confirmation already happened (RootView's dialog). |
| private func signOut() { |
| Task { |
| if let session = HomeSessionStore.load() { |
| @@ -449,8 +541,8 @@ struct RootView: View { |
| app.eraseAll() |
| signIn.refreshSession() |
| path = [] |
| + currentSpaceID = nil |
| nav.selectInstance(nil) |
| - showSwitcher = false |
| } |
| } |
diff --git a/ios/LChat/Views/ElevenChatScreen.swift b/ios/LChat/Views/ElevenChatScreen.swift| index 9a8ee6f..8c5780a 100644 |
| --- a/ios/LChat/Views/ElevenChatScreen.swift |
| +++ b/ios/LChat/Views/ElevenChatScreen.swift |
| @@ -9,7 +9,7 @@ struct ElevenChatScreen: View { |
| let instance: SavedInstance |
| let thread: ChatThread |
| @ObservedObject var store: ThreadStore |
| - @Binding var path: [String] |
| + @Binding var path: [Route] |
| @State private var model: ElevenConversationModel? |
| @ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17 |
| @AppStorage("nativeTheme") private var themeName = "classic" |
| @@ -29,11 +29,11 @@ struct ElevenChatScreen: View { |
| guard model == nil else { return } |
| let m = ElevenConversationCache.shared.model(for: instance, thread: thread, |
| myUserId: store.myUserId) |
| - m?.openThread = { id in path.append(id) } |
| + m?.openThread = { id in path.append(.thread(id)) } |
| m?.openAdmin = { |
| Task { @MainActor in |
| if let id = await store.openOrCreateAppThread(app: "admin", title: "Admin") { |
| - path.append(id) |
| + path.append(.thread(id)) |
| } |
| } |
| } |
diff --git a/ios/LChat/Views/SpaceSwitchScene.swift b/ios/LChat/Views/SpaceSwitchScene.swift| deleted file mode 100644 |
| index 1c18a32..0000000 |
| --- a/ios/LChat/Views/SpaceSwitchScene.swift |
| +++ /dev/null |
| @@ -1,121 +0,0 @@ |
| -import LChatCore |
| -import SwiftUI |
| - |
| -// 🤖 Switching spaces fades through the brand's smoke-and-motes scene instead |
| -// of flashing a spinner: the current chat list fades out under the scene, the |
| -// store swap happens at full cover, the smoke plays while the new space's |
| -// chats load (a small spinner joins only if that takes a while), and the new |
| -// list fades back in as the scene dissolves. The scene itself is |
| -// SmokeSceneView (LChatCore) — web bootscene.js's native twin. |
| -@MainActor |
| -final class SpaceSwitchModel: ObservableObject { |
| - @Published private(set) var active = false // scene mounted at all |
| - @Published private(set) var covered = false // overlay at full opacity |
| - @Published private(set) var spinner = false // the slow-load spinner, over the scene |
| - |
| - // The choreography: a quick fade in, hold the smoke at least the minimum |
| - // beat once covered (so an instant load still reads as one crossfade |
| - // through smoke, not a flash), spinner only past ~2s, and a hard cap so a |
| - // hung load can never trap anyone in smoke — past it the reveal happens |
| - // regardless and whatever loading/empty state exists today shows beneath. |
| - // Timings tuned on device with Paul, four passes down from 0.3/0.35/0.75: |
| - // the whole switch should read ~200ms — a barely-there blink of smoke — |
| - // and a load slower than half a second earns the spinner. A spinner that |
| - // DID appear stays a comfortable beat (spinnerHold): flashing one off the |
| - // instant the load lands reads as a glitch, not feedback. |
| - static let cover = 0.08 |
| - static let reveal = 0.12 |
| - static let minBeat = 0.0 |
| - static let spinnerAfter = 0.5 |
| - static let spinnerHold = 0.7 |
| - static let maxWait = 10.0 |
| - |
| - private var run: Task<Void, Never>? |
| - |
| - /// Start the transition. `swap` performs the actual instance switch and |
| - /// returns the fresh store to watch — called only once the scene fully |
| - /// covers the old list, so the outgoing space visibly fades into smoke |
| - /// rather than being yanked out from under the fade. |
| - func begin(swap: @escaping @MainActor () -> ThreadStore?) { |
| - run?.cancel() |
| - spinner = false |
| - // A re-select while a transition is still up keeps the cover it has |
| - // (resetting to transparent would flash the half-loaded list). |
| - let alreadyCovered = active && covered |
| - active = true |
| - run = Task { @MainActor [weak self] in |
| - guard let self else { return } |
| - if !alreadyCovered { |
| - // Mount at opacity 0 for a tick first, or the fade-in is skipped. |
| - await Task.yield() |
| - guard !Task.isCancelled else { return } |
| - withAnimation(.easeInOut(duration: Self.cover)) { self.covered = true } |
| - try? await Task.sleep(for: .seconds(Self.cover)) |
| - guard !Task.isCancelled else { return } |
| - } |
| - |
| - let began = Date() |
| - let store = swap() |
| - // "Settled" = the new store's refresh ran and finished (loading |
| - // rose then fell), errored before it could start, or rows landed — |
| - // failure settles too, so the empty/error state is reachable. |
| - var sawLoading = false |
| - var spinnerAt: Date? |
| - while !Task.isCancelled { |
| - let elapsed = Date().timeIntervalSince(began) |
| - if let store { |
| - if store.loading { sawLoading = true } |
| - } |
| - let settled = store == nil |
| - || store!.loadError != nil |
| - || (sawLoading && !store!.loading) |
| - || !store!.threads.isEmpty |
| - if !self.spinner, !settled, elapsed >= Self.spinnerAfter { |
| - spinnerAt = Date() |
| - withAnimation(.easeInOut(duration: 0.25)) { self.spinner = true } |
| - } |
| - let spinnerDone = spinnerAt.map { Date().timeIntervalSince($0) >= Self.spinnerHold } ?? true |
| - if (settled && elapsed >= Self.minBeat && spinnerDone) || elapsed >= Self.maxWait { break } |
| - try? await Task.sleep(for: .milliseconds(40)) |
| - } |
| - guard !Task.isCancelled else { return } |
| - |
| - withAnimation(.easeInOut(duration: Self.reveal)) { |
| - self.covered = false |
| - self.spinner = false |
| - } |
| - try? await Task.sleep(for: .seconds(Self.reveal)) |
| - if !Task.isCancelled { self.active = false } |
| - } |
| - } |
| -} |
| - |
| -/// The full-screen transition surface RootView overlays on the chat list: an |
| -/// opaque background (so nothing beneath flashes) carrying the smoke scene, |
| -/// plus the sanctioned late-load spinner — it sits on a transition surface, |
| -/// never over content someone is reading. Reduce Motion drops the drifting |
| -/// particles and keeps the plain crossfade. |
| -struct SpaceSwitchOverlay: View { |
| - @ObservedObject var model: SpaceSwitchModel |
| - @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| - |
| - var body: some View { |
| - if model.active { |
| - ZStack { |
| - Color(.systemBackground).ignoresSafeArea() |
| - // A fraction of full strength: the shared AmbientField is |
| - // tuned for the create-space stage, where the scene IS the |
| - // show — in a transition it's barely noticeable, by design. |
| - if !reduceMotion { SmokeSceneView().opacity(0.2) } |
| - if model.spinner { |
| - ProgressView() |
| - .accessibilityLabel("Loading chats") |
| - } |
| - } |
| - .opacity(model.covered ? 1 : 0) |
| - // Inert the moment the reveal starts, so the incoming list is |
| - // immediately tappable under the dissolving smoke. |
| - .allowsHitTesting(model.covered) |
| - } |
| - } |
| -} |
diff --git a/ios/LChat/Views/SpaceSwitcher.swift b/ios/LChat/Views/SpaceSwitcher.swift| index 7658dee..8ebcedb 100644 |
| --- a/ios/LChat/Views/SpaceSwitcher.swift |
| +++ b/ios/LChat/Views/SpaceSwitcher.swift |
| @@ -2,265 +2,7 @@ import LChatCore |
| import SwiftUI |
| import UIKit |
| -// The space switcher: a menu-styled dropdown anchored under the chat list's |
| -// top-left space button (it replaced the old slide-in ServerDrawer). The panel |
| -// shows the favourites (starred spaces, capped at 11) plus the fixed actions — |
| -// More (the full management sheet), Create a new space, and Sign in/out. |
| -// |
| -// Row gestures, disambiguated by time-then-movement: |
| -// tap → switch to that space |
| -// ~0.2s hold, then slide → drag-reorder the favourites (haptic on lift) |
| -// full still long-press → the row's context menu (Star/Archive/Remove) |
| -struct SpaceSwitcherPanel: View { |
| - @EnvironmentObject var app: AppState |
| - let activeID: String? |
| - /// Shared with the first-run screen (owned by RootView): the panel offers |
| - /// "Sign in with Eleven" until a home session exists, so a device that |
| - /// joined by invite link can start syncing its server list any time. |
| - @ObservedObject var signIn: HomeSignInModel |
| - // "Create a new space" gate — home must offer provisioning AND the member |
| - // must already have a space (the web's canCreateSpace; the server 403s too). |
| - var spacesOffered = false |
| - var onSelect: (SavedInstance) -> Void |
| - var onMore: () -> Void |
| - var onCreate: () -> Void |
| - var onSignOut: () -> Void |
| - |
| - // Live reorder state: which favourite the finger holds and how far it has |
| - // slid. @GestureState so a system-cancelled gesture — notably the context |
| - // menu taking over a stationary press — snaps the lift back by itself. |
| - private struct DragState: Equatable { |
| - var id: String |
| - var offset: CGFloat |
| - } |
| - @GestureState private var drag: DragState? |
| - |
| - private let rowHeight: CGFloat = 52 |
| - private let panelWidth: CGFloat = 300 |
| - |
| - private var active: [SavedInstance] { app.instances.filter { !$0.isArchived } } |
| - private var starred: [SavedInstance] { active.filter(\.isStarred) } |
| - // Up to 11 favourites. With nothing starred the switcher still has to be |
| - // useful, so it falls back to the head of the list; anything beyond the |
| - // cap is reachable through More. |
| - private var shown: [SavedInstance] { Array((starred.isEmpty ? active : starred).prefix(11)) } |
| - |
| - var body: some View { |
| - VStack(spacing: 0) { |
| - favouriteRows |
| - Divider().padding(.vertical, 4) |
| - Button(action: onMore) { |
| - actionRow("More", systemImage: "ellipsis.circle") |
| - } |
| - .buttonStyle(.plain) |
| - if #available(iOS 18.0, *), signIn.sessionExists, |
| - spacesOffered, !app.instances.isEmpty { |
| - Button(action: onCreate) { |
| - actionRow("Create a new space", systemImage: "plus.circle", tint: .accentColor) |
| - } |
| - .buttonStyle(.plain) |
| - } |
| - if #unavailable(iOS 18.0) { |
| - // The passkey ceremony (PRF) is iOS 18-only — no account |
| - // affordances below that; the panel is just the space list. |
| - EmptyView() |
| - } else { |
| - Divider().padding(.vertical, 4) |
| - accountSection |
| - } |
| - } |
| - .padding(.vertical, 8) |
| - .frame(width: panelWidth) |
| - } |
| - |
| - // ---- favourites (tap = switch, hold+slide = reorder) -------------------- |
| - |
| - private var favouriteRows: some View { |
| - // Fixed row heights so the reorder math is pure arithmetic; the list |
| - // only scrolls past eight rows (the 11-favourite worst case on a small |
| - // phone), and stays a plain column otherwise so the drag never has to |
| - // fight a scroll recognizer. |
| - ScrollView { |
| - VStack(spacing: 0) { |
| - ForEach(Array(shown.enumerated()), id: \.element.id) { index, inst in |
| - favouriteRow(inst, index: index) |
| - } |
| - } |
| - } |
| - .frame(height: min(CGFloat(shown.count), 8) * rowHeight) |
| - .scrollDisabled(shown.count <= 8) |
| - } |
| - |
| - private func favouriteRow(_ inst: SavedInstance, index: Int) -> some View { |
| - let lifted = drag?.id == inst.id |
| - let offset = rowOffset(index) |
| - return Button { onSelect(inst) } label: { |
| - SpaceRow(instance: inst, active: inst.id == activeID, |
| - photo: app.serverAvatars[inst.id]) |
| - .padding(.horizontal, 10) |
| - .frame(height: rowHeight) |
| - } |
| - .buttonStyle(.plain) |
| - .padding(.horizontal, 6) |
| - .contextMenu { SpaceMenuItems(instance: inst) } |
| - .offset(y: offset) |
| - .scaleEffect(lifted ? 1.03 : 1) |
| - .shadow(color: .black.opacity(lifted ? 0.15 : 0), radius: 8, y: 3) |
| - .zIndex(lifted ? 1 : 0) |
| - // Neighbours animate out of the way; the lifted row tracks the finger raw. |
| - .animation(lifted ? nil : .easeInOut(duration: 0.18), value: offset) |
| - .gesture(reorderGesture(inst)) |
| - } |
| - |
| - // Where a row sits while a drag is in flight: the held row follows the |
| - // finger; rows between its start and its would-be slot shift one row over. |
| - private func rowOffset(_ index: Int) -> CGFloat { |
| - guard let drag, let src = shown.firstIndex(where: { $0.id == drag.id }) else { return 0 } |
| - if index == src { return drag.offset } |
| - let dst = targetIndex(src: src, offset: drag.offset) |
| - if src < dst, index > src, index <= dst { return -rowHeight } |
| - if dst < src, index >= dst, index < src { return rowHeight } |
| - return 0 |
| - } |
| - |
| - private func targetIndex(src: Int, offset: CGFloat) -> Int { |
| - let moved = Int((offset / rowHeight).rounded()) |
| - return min(max(src + moved, 0), shown.count - 1) |
| - } |
| - |
| - // The tap/drag/long-press disambiguation: a 0.2s hold gates the drag (so a |
| - // plain tap stays the Button's), and the lift waits for actual movement — |
| - // a press that stays put is en route to the context menu, whose recognizer |
| - // then cancels this gesture (@GestureState resets the lift on its own). |
| - private func reorderGesture(_ inst: SavedInstance) -> some Gesture { |
| - LongPressGesture(minimumDuration: 0.2) |
| - .sequenced(before: DragGesture(minimumDistance: 0)) |
| - .updating($drag) { value, state, _ in |
| - guard case .second(true, let dragValue) = value else { return } |
| - let dy = dragValue?.translation.height ?? 0 |
| - if state == nil { |
| - guard abs(dy) > 3 else { return } |
| - UIImpactFeedbackGenerator(style: .medium).impactOccurred() // lift |
| - } else if let s = state, let src = shown.firstIndex(where: { $0.id == inst.id }), |
| - targetIndex(src: src, offset: s.offset) != targetIndex(src: src, offset: dy) { |
| - UISelectionFeedbackGenerator().selectionChanged() // slot change |
| - } |
| - state = DragState(id: inst.id, offset: dy) |
| - } |
| - .onEnded { value in |
| - guard case .second(true, let dragValue?) = value else { return } |
| - commitReorder(of: inst, by: dragValue.translation.height) |
| - } |
| - } |
| - |
| - private func commitReorder(of inst: SavedInstance, by dy: CGFloat) { |
| - guard abs(dy) > 3, let src = shown.firstIndex(where: { $0.id == inst.id }) else { return } |
| - let dst = targetIndex(src: src, offset: dy) |
| - guard dst != src else { return } |
| - // move(fromOffsets:toOffset:) semantics: moving down, the destination |
| - // names the slot in the pre-removal array, so it's one past the target. |
| - let to = dst > src ? dst + 1 : dst |
| - if starred.isEmpty { |
| - // The unstarred fallback list — reorder the whole list, like the |
| - // old drawer's flat mode. |
| - app.move(from: IndexSet(integer: src), to: to) |
| - } else { |
| - app.moveInGroup(starred: true, from: IndexSet(integer: src), to: to) |
| - } |
| - UIImpactFeedbackGenerator(style: .light).impactOccurred() |
| - } |
| - |
| - // ---- account (sign in / sign out) --------------------------------------- |
| - |
| - @ViewBuilder private var accountSection: some View { |
| - if signIn.sessionExists { |
| - // Sign out clears the DEVICE — there's no half-signed-out state |
| - // with a passkey; the spaces live in the account and come back on |
| - // the next sign-in. Hence the confirmation (owned by the presenter, |
| - // so it survives this popover's dismissal). |
| - Button(action: onSignOut) { |
| - actionRow("Sign out", systemImage: "person.badge.key") |
| - } |
| - .buttonStyle(.plain) |
| - } else { |
| - Button { |
| - Task { await signIn.signIn(into: app) } |
| - } label: { |
| - actionRow(signIn.phase.busy ? "Signing in…" : "Sign in", |
| - systemImage: "person.badge.key", busy: signIn.phase.busy) |
| - } |
| - .buttonStyle(.plain) |
| - .disabled(signIn.phase.busy) |
| - // Minting is deliberately NOT part of Sign in (registering always |
| - // creates a new anonymous account — the web dropped the |
| - // create-on-sign-in fallback in #323). This is the explicit act |
| - // for a device with servers and no Eleven passkey yet. |
| - if !app.instances.isEmpty { |
| - Button { |
| - Task { await signIn.backUp(into: app) } |
| - } label: { |
| - Text("New to Eleven? Save your spaces") |
| - .font(.footnote) |
| - .foregroundStyle(.secondary) |
| - .frame(maxWidth: .infinity, alignment: .leading) |
| - .padding(.horizontal, 16) |
| - .padding(.vertical, 6) |
| - .contentShape(Rectangle()) |
| - } |
| - .buttonStyle(.plain) |
| - .disabled(signIn.phase.busy) |
| - } |
| - // EVERY terminal state renders inline — a silent reset cost us a |
| - // debugging round on the first device run. |
| - if let status = statusLine { |
| - Text(status) |
| - .font(.caption) |
| - .foregroundStyle(.secondary) |
| - .padding(.horizontal, 16) |
| - .padding(.vertical, 4) |
| - .frame(maxWidth: .infinity, alignment: .leading) |
| - } |
| - } |
| - } |
| - |
| - // The sign-in row's inline status — every terminal phase says something. |
| - private var statusLine: String? { |
| - switch signIn.phase { |
| - case .failed(let msg): return msg |
| - case .emptyBackup: return "Signed in — no spaces saved yet." |
| - case .backedUp(let n): return "Saved \(n) space\(n == 1 ? "" : "s") to Eleven." |
| - case .restored(let n): return "\(n) space\(n == 1 ? "" : "s") restored." |
| - case .syncing: return "Syncing your spaces…" |
| - case .idle, .working: return nil |
| - } |
| - } |
| - |
| - // A fixed action row, its glyph centered on the avatar column above. |
| - private func actionRow(_ title: String, systemImage: String, |
| - tint: Color? = nil, busy: Bool = false) -> some View { |
| - HStack(spacing: 12) { |
| - Group { |
| - if busy { |
| - ProgressView().controlSize(.small) |
| - } else { |
| - Image(systemName: systemImage) |
| - .font(.title3) |
| - .foregroundStyle(tint ?? Color.primary) |
| - } |
| - } |
| - .frame(width: 36) |
| - Text(title) |
| - .font(.body.weight(.medium)) |
| - .foregroundStyle(tint ?? Color.primary) |
| - Spacer() |
| - } |
| - .padding(.horizontal, 16) |
| - .frame(height: 44) |
| - .contentShape(Rectangle()) |
| - } |
| -} |
| - |
| -// The full space list — everything the dropdown elides: ALL active spaces |
| +// The full space list — everything the home screen elides: ALL active spaces |
| // (favourites first, the rest below), standard List drag-to-reorder within |
| // each group, and the manage context menu. Archived spaces stay hidden here |
| // too (they live in the account — unarchive on the home page). |
| @@ -317,12 +59,14 @@ struct SpaceListSheet: View { |
| } |
| } |
| -// One space: avatar, name, host subtitle, checkmark on the active one — |
| -// shared by the dropdown's favourites and the More sheet. |
| -private struct SpaceRow: View { |
| +// One space: avatar, name, host subtitle — shared by the home screen's lists |
| +// (a trailing disclosure chevron: every row navigates) and the More sheet |
| +// (a checkmark on the active one instead). |
| +struct SpaceRow: View { |
| let instance: SavedInstance |
| let active: Bool |
| var photo: UIImage? |
| + var chevron = false |
| var body: some View { |
| HStack(spacing: 12) { |
| @@ -336,7 +80,11 @@ private struct SpaceRow: View { |
| } |
| } |
| Spacer() |
| - if active { |
| + if chevron { |
| + Image(systemName: "chevron.right") |
| + .font(.footnote.weight(.semibold)) |
| + .foregroundStyle(.tertiary) |
| + } else if active { |
| Image(systemName: "checkmark") |
| .font(.subheadline.weight(.semibold)) |
| .foregroundStyle(Color.accentColor) |
| @@ -346,8 +94,8 @@ private struct SpaceRow: View { |
| } |
| } |
| -// The manage menu shared by the dropdown rows and the More sheet rows. |
| -private struct SpaceMenuItems: View { |
| +// The manage menu shared by the home screen rows and the More sheet rows. |
| +struct SpaceMenuItems: View { |
| @EnvironmentObject var app: AppState |
| let instance: SavedInstance |
diff --git a/ios/LChat/Views/SpacesHomeView.swift b/ios/LChat/Views/SpacesHomeView.swift| new file mode 100644 |
| index 0000000..c0a4216 |
| --- /dev/null |
| +++ b/ios/LChat/Views/SpacesHomeView.swift |
| @@ -0,0 +1,219 @@ |
| +import LChatCore |
| +import SwiftUI |
| + |
| +// 🤖 The app's root screen: your spaces (the Mimestream shape — the account |
| +// screen sits BEHIND the mailbox you live in). Day to day you're in a space's |
| +// chat list, pushed on top of this; "back" brings you here to switch. The |
| +// space you live in leads under "Current", starred spaces follow, then the |
| +// rest in the order you know them (capped, with More… opening the full |
| +// management sheet). Creating a space is the top-right +, and your own avatar |
| +// (top-left) holds the account acts — Edit profile, Sign in/out. Archived |
| +// spaces stay hidden here, exactly as in the old switcher. |
| +struct SpacesHomeView: View { |
| + @EnvironmentObject var app: AppState |
| + @ObservedObject var nav: NavModel |
| + /// Shared with the first-run screen (owned by RootView): a device that has |
| + /// spaces but no Eleven session yet can sign in / back up from here. |
| + @ObservedObject var signIn: HomeSignInModel |
| + /// The space you actually live in (stamped by opening one) — nil on a |
| + /// device that doesn't know yet (fresh sign-in). Distinct from |
| + /// nav.instanceID, which may be a context-only selection. |
| + var currentID: String? |
| + // "Create a new space" gate — home must offer provisioning AND the member |
| + // must already have a space (the web's canCreateSpace; the server 403s too). |
| + var spacesOffered = false |
| + var onSelect: (SavedInstance) -> Void |
| + var onSignOut: () -> Void |
| + var onCreate: () -> Void |
| + |
| + @State private var showAllSpaces = false |
| + @State private var showProfile = false |
| + // The More group's disclosure, remembered across launches (Mimestream's |
| + // collapsible Favorites). |
| + @AppStorage("homeMoreExpanded") private var moreExpanded = true |
| + |
| + private var active: [SavedInstance] { app.instances.filter { !$0.isArchived } } |
| + // "Current" is a SHORTCUT — the space also keeps its ordinary slot in |
| + // Starred/More, so nothing reshuffles when the current space changes. |
| + private var starred: [SavedInstance] { active.filter(\.isStarred) } |
| + private var others: [SavedInstance] { active.filter { !$0.isStarred } } |
| + // Beyond the cap the list stops being a launcher — the full sheet has the rest. |
| + private let othersCap = 11 |
| + |
| + // The space whose hat you're currently wearing — the selected one, else |
| + // the first you'd land in. Names the avatar + scopes Edit profile. |
| + private var current: SavedInstance? { |
| + app.instances.first { $0.id == nav.instanceID } ?? active.first ?? app.instances.first |
| + } |
| + |
| + var body: some View { |
| + List { |
| + if let cur = app.instances.first(where: { $0.id == currentID }) { |
| + Section("Current") { row(cur) } |
| + } |
| + if !starred.isEmpty { |
| + Section("Starred") { |
| + ForEach(starred) { row($0) } |
| + .onMove { app.moveInGroup(starred: true, from: $0, to: $1) } |
| + } |
| + } |
| + if !others.isEmpty { |
| + Section { |
| + if moreExpanded || untitled { moreRows } |
| + } header: { |
| + // With nothing above it, a "More" header has no referent — |
| + // the rows just ARE the list, always shown. |
| + if !untitled { moreHeader } |
| + } |
| + } |
| + // EVERY terminal sign-in state renders inline — a silent reset |
| + // cost us a debugging round on the first device run. |
| + if let status = statusLine { |
| + Section { } footer: { |
| + Text(status).frame(maxWidth: .infinity, alignment: .center) |
| + } |
| + } |
| + } |
| + .navigationTitle("Spaces") |
| + .toolbar { |
| + ToolbarItem(placement: .topBarLeading) { accountMenu } |
| + // Create a new space — a quiet, secondary act: a plain toolbar |
| + // glyph, not a floating prominent button. |
| + if createOffered { |
| + ToolbarItem(placement: .topBarTrailing) { |
| + Button(action: onCreate) { Image(systemName: "plus") } |
| + .accessibilityLabel("Create a new space") |
| + } |
| + } |
| + } |
| + // The full space list (management lives there). |
| + .sheet(isPresented: $showAllSpaces) { |
| + SpaceListSheet(activeID: nav.instanceID) { inst in |
| + showAllSpaces = false |
| + onSelect(inst) |
| + } |
| + } |
| + // Edit profile — today this is the CURRENT space's profile (display |
| + // name, presence, theme). One shared profile across spaces: issue #576. |
| + .sheet(isPresented: $showProfile) { |
| + if let store = nav.store { |
| + ServerSettingsSheet(store: store) |
| + } |
| + } |
| + } |
| + |
| + private func row(_ inst: SavedInstance) -> some View { |
| + Button { onSelect(inst) } label: { |
| + SpaceRow(instance: inst, active: false, |
| + photo: app.serverAvatars[inst.id], chevron: true) |
| + } |
| + .foregroundStyle(.primary) |
| + .contextMenu { SpaceMenuItems(instance: inst) } |
| + } |
| + |
| + // ---- the More group (collapsible) ---------------------------------------- |
| + |
| + // No Current or Starred group above → "More" would have no referent. |
| + private var untitled: Bool { currentID == nil && starred.isEmpty } |
| + |
| + @ViewBuilder private var moreRows: some View { |
| + ForEach(others.prefix(othersCap)) { row($0) } |
| + .onMove { app.moveInGroup(starred: false, from: $0, to: $1) } |
| + if others.count > othersCap { |
| + Button { showAllSpaces = true } label: { |
| + Label("All spaces…", systemImage: "ellipsis.circle") |
| + } |
| + .foregroundStyle(.primary) |
| + } |
| + } |
| + |
| + private var moreHeader: some View { |
| + Button { |
| + withAnimation(.easeInOut(duration: 0.2)) { moreExpanded.toggle() } |
| + } label: { |
| + HStack { |
| + Text("More") |
| + Spacer() |
| + Image(systemName: "chevron.down") |
| + .font(.caption2.weight(.semibold)) |
| + .rotationEffect(.degrees(moreExpanded ? 0 : -90)) |
| + } |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + .accessibilityLabel("More spaces") |
| + .accessibilityValue(moreExpanded ? "Expanded" : "Collapsed") |
| + .accessibilityAddTraits(.isHeader) |
| + } |
| + |
| + // ---- account (avatar → Edit profile / Sign in / Sign out) --------------- |
| + |
| + private var accountMenu: some View { |
| + Menu { |
| + Button { showProfile = true } label: { |
| + Label("Edit profile", systemImage: "person.crop.circle") |
| + } |
| + .disabled(nav.store == nil) |
| + if #unavailable(iOS 18.0) { |
| + // The passkey ceremony (PRF) is iOS 18-only — no account |
| + // affordances below that. |
| + EmptyView() |
| + } else if signIn.sessionExists { |
| + Button(role: .destructive, action: onSignOut) { |
| + Label("Sign out", systemImage: "person.badge.key") |
| + } |
| + } else { |
| + Button { |
| + Task { await signIn.signIn(into: app) } |
| + } label: { |
| + Label(signIn.phase.busy ? "Signing in…" : "Sign in", |
| + systemImage: "person.badge.key") |
| + } |
| + .disabled(signIn.phase.busy) |
| + // Registering always creates a new anonymous account (the web |
| + // dropped create-on-sign-in in #323) — this is the explicit |
| + // act for a device with spaces and no Eleven passkey yet. |
| + if !app.instances.isEmpty { |
| + Button { |
| + Task { await signIn.backUp(into: app) } |
| + } label: { |
| + Label("Save your spaces to Eleven", systemImage: "arrow.up.doc") |
| + } |
| + .disabled(signIn.phase.busy) |
| + } |
| + } |
| + } label: { |
| + Avatar(name: current?.screenName ?? "?", size: 30, source: myAvatarSource) |
| + } |
| + .accessibilityLabel("Account") |
| + } |
| + |
| + // My profile photo in the current space — the store must have loaded its |
| + // roster (it has, whenever you've been in the space this launch); until |
| + // then the initials stand in, exactly like every other avatar. |
| + private var myAvatarSource: AvatarSource? { |
| + guard let store = nav.store, |
| + let me = store.roster.first(where: { $0.user_id == store.myUserId }) |
| + else { return nil } |
| + return store.avatarSource(for: me) |
| + } |
| + |
| + private var createOffered: Bool { |
| + if #available(iOS 18.0, *) { |
| + return signIn.sessionExists && spacesOffered && !app.instances.isEmpty |
| + } |
| + return false |
| + } |
| + |
| + // The sign-in acts' inline status — every terminal phase says something. |
| + private var statusLine: String? { |
| + switch signIn.phase { |
| + case .failed(let msg): return msg |
| + case .emptyBackup: return "Signed in — no spaces saved yet." |
| + case .backedUp(let n): return "Saved \(n) space\(n == 1 ? "" : "s") to Eleven." |
| + case .restored(let n): return "\(n) space\(n == 1 ? "" : "s") restored." |
| + case .syncing: return "Syncing your spaces…" |
| + case .idle, .working: return nil |
| + } |
| + } |
| +} |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index 29e5d46..d1d4ab0 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -2,22 +2,15 @@ import LChatCore |
| import SwiftUI |
| // The main index: the active server's chats (DMs, groups, Notes), each with a |
| -// decrypted name + last-message preview. Tapping one pushes its feed; the top-left |
| -// space button (avatar + name) drops down the space switcher. The ThreadStore does |
| -// the native ECIES unwrap + preview decryption; this view is just presentation. |
| +// decrypted name + last-message preview, titled with the space's name — pushed |
| +// over the spaces home screen, so "back" is how you switch space. Tapping a |
| +// chat pushes its feed. The ThreadStore does the native ECIES unwrap + preview |
| +// decryption; this view is just presentation. |
| struct ThreadListView: View { |
| @State private var showSettings = false |
| @EnvironmentObject var app: AppState |
| @ObservedObject var store: ThreadStore |
| - @Binding var path: [String] |
| - @Binding var showSwitcher: Bool |
| - // The switcher's collaborators, owned by RootView (which owns the nav, |
| - // the sign-in model and the create-a-space flow). |
| - let signIn: HomeSignInModel |
| - var spacesOffered = false |
| - var onSelect: (SavedInstance) -> Void = { _ in } |
| - var onSignOut: () -> Void = {} |
| - var onCreate: () -> Void = {} |
| + @Binding var path: [Route] |
| @State private var showNew = false |
| @State private var search = "" |
| @Environment(\.scenePhase) private var scenePhase |
| @@ -28,14 +21,6 @@ struct ThreadListView: View { |
| // makes it once per device+instance across launches). |
| @State private var welcomeChecked = false |
| - // The switcher's follow-up surfaces: the More sheet and the sign-out |
| - // confirmation present here (not inside the popover), so they outlive its |
| - // dismissal; `followUp` carries the tapped action across that dismissal. |
| - private enum SwitcherFollowUp { case more, create, signOut } |
| - @State private var followUp: SwitcherFollowUp? |
| - @State private var showAllSpaces = false |
| - @State private var confirmSignOut = false |
| - |
| // The funnel menu's filters (web parity: Online only + People/Bots/Apps). |
| @State private var filterOnline = false |
| @State private var filterPeople = true |
| @@ -73,7 +58,7 @@ struct ThreadListView: View { |
| if search.trimmingCharacters(in: .whitespaces).isEmpty { |
| ThreadListCollection( |
| threads: visibleThreads, |
| - onSelect: { path.append($0) }, |
| + onSelect: { path.append(.thread($0)) }, |
| onClose: { store.setHidden($0, true) }, |
| onPin: { store.setPinned($0, !$0.pinned) }, |
| onToggleUnread: { $0.unread ? store.markRead($0) : store.markUnread($0) } |
| @@ -109,15 +94,24 @@ struct ThreadListView: View { |
| // the WELCOME one — mutually exclusive by construction (a |
| // creator has no welcome thread, a joiner no pending setup). |
| if let setup = await SetupLanding.threadToLand(instance: store.instance, threads: list), |
| - path.isEmpty { |
| - path.append(setup.id) |
| + path == [.chats] { |
| + path.append(.thread(setup.id)) |
| } else if let welcome = await WelcomeLanding.threadToLand(instance: store.instance, threads: list), |
| - path.isEmpty { |
| - path.append(welcome.id) |
| + path == [.chats] { |
| + path.append(.thread(welcome.id)) |
| } |
| } |
| } |
| - .overlay { if store.loading && store.threads.isEmpty { ProgressView() } } |
| + // First load only — entering a space whose chats we already hold shows |
| + // them immediately; otherwise the brand's smoke plays under the (already |
| + // slid-in) chrome until the rows land. Deliberately NO fade in or out: |
| + // the only motion is the navigation slide itself — any animation here |
| + // repaints around the large title and reads as the space name fading. |
| + .overlay { |
| + if store.loading && store.threads.isEmpty { |
| + ChatsLoadingScene() |
| + } |
| + } |
| .overlay { if !store.loading && store.threads.isEmpty { empty } } |
| // The funnel can narrow the list to nothing — say so instead of |
| // presenting a silently blank pane. |
| @@ -127,53 +121,12 @@ struct ThreadListView: View { |
| filteredEmpty |
| } |
| } |
| - .navigationTitle("Chats") // also backs the chat screen's back button |
| - .navigationBarTitleDisplayMode(.inline) |
| + // The space's name IS the screen's title (the Mimestream "Inbox: |
| + // Primary" shape) — and it backs the chat screen's back button, so |
| + // leaving a chat reads "‹ Family". Back from HERE is the spaces home. |
| + .navigationTitle(serverTitle) |
| + .navigationBarTitleDisplayMode(.large) |
| .toolbar { |
| - // The screen's identity: the space's avatar + name, one control — |
| - // tapping it drops down the space switcher, anchored underneath. |
| - ToolbarItem(placement: .topBarLeading) { |
| - Button { showSwitcher = true } label: { |
| - HStack(spacing: 7) { |
| - Avatar(name: serverTitle, size: 26, |
| - photo: app.serverAvatars[store.instance.id]) |
| - Text(serverTitle) |
| - .font(.headline) |
| - .foregroundStyle(.primary) |
| - .lineLimit(1) |
| - Image(systemName: "chevron.down") |
| - .font(.caption2.weight(.semibold)) |
| - .foregroundStyle(.secondary) |
| - } |
| - .frame(maxWidth: 200) |
| - } |
| - .accessibilityLabel("Servers") |
| - // On iPhone .popover presents as a sheet unless told otherwise |
| - // (iOS 16.4+); adapted, it's the menu-like anchored card we |
| - // want — rounded, shadowed, dismissed by tapping outside. |
| - .popover(isPresented: $showSwitcher, arrowEdge: .top) { |
| - SpaceSwitcherPanel( |
| - activeID: store.instance.id, |
| - signIn: signIn, |
| - spacesOffered: spacesOffered, |
| - onSelect: onSelect, |
| - onMore: { queueFollowUp(.more) }, |
| - onCreate: { queueFollowUp(.create) }, |
| - onSignOut: { queueFollowUp(.signOut) }) |
| - .presentationCompactAdaptation(.popover) |
| - // Runs the follow-up a switcher row queued, once the |
| - // popover content is actually off screen (presenting |
| - // in the same tick as a dismissal reliably drops it). |
| - .onDisappear(perform: runFollowUp) |
| - } |
| - } |
| - // The space name in the leading button is the title now — blank |
| - // the principal slot so the inline navigationTitle ("Chats", which |
| - // pushed screens' back buttons still need) doesn't render a |
| - // second, centered title. |
| - ToolbarItem(placement: .principal) { |
| - Text(verbatim: "") |
| - } |
| // Cog then filter, each its OWN glass capsule (the spacer splits |
| // them) — settings and filtering are unrelated acts, not a group. |
| ToolbarItem(placement: .topBarTrailing) { |
| @@ -223,26 +176,11 @@ struct ThreadListView: View { |
| } |
| } |
| .sheet(isPresented: $showNew) { |
| - NewChatView(store: store) { newID in path.append(newID) } |
| + NewChatView(store: store) { newID in path.append(.thread(newID)) } |
| } |
| .sheet(isPresented: $showSettings) { |
| ServerSettingsSheet(store: store) |
| } |
| - // The switcher's More: the full space list (management lives there). |
| - .sheet(isPresented: $showAllSpaces) { |
| - SpaceListSheet(activeID: store.instance.id) { inst in |
| - showAllSpaces = false |
| - onSelect(inst) |
| - } |
| - } |
| - // Sign out clears the device — the switcher row only *requests* it; |
| - // the destructive confirmation gates the actual wipe. |
| - .confirmationDialog( |
| - "Sign out? Your spaces, messages and keys are removed from this iPhone. Sign back in to get them back.", |
| - isPresented: $confirmSignOut, titleVisibility: .visible |
| - ) { |
| - Button("Sign Out & Clear This iPhone", role: .destructive, action: onSignOut) |
| - } |
| // Shared from the system share sheet: pick which chat gets the files. |
| .sheet(isPresented: Binding(get: { !shareFiles.isEmpty }, |
| set: { if !$0 { shareFiles = [] } })) { |
| @@ -251,7 +189,7 @@ struct ThreadListView: View { |
| Button { |
| NativeChatScreen.pendingShare = (t.id, shareFiles) |
| shareFiles = [] |
| - path.append(t.id) |
| + path.append(.thread(t.id)) |
| } label: { |
| HStack(spacing: 10) { |
| Avatar(name: t.title, size: 34, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph) |
| @@ -277,9 +215,6 @@ struct ThreadListView: View { |
| if phase == .active { shareFiles = ShareInbox.pending() } |
| } |
| .onAppear { shareFiles = ShareInbox.pending() } |
| - .navigationDestination(for: String.self) { id in |
| - ThreadDestination(store: store, id: id, path: $path) |
| - } |
| // Keyed on the instance: when the store swaps in place (switching server), |
| // SwiftUI reuses this view, so an un-keyed .task would not re-fire and the new |
| // server's chats would never load. |
| @@ -302,7 +237,7 @@ struct ThreadListView: View { |
| Section("Conversations") { |
| ForEach(visibleThreads) { t in |
| Button { |
| - path.append(t.id) |
| + path.append(.thread(t.id)) |
| } label: { |
| HStack(spacing: 10) { |
| Avatar(name: t.title, size: 34, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph) |
| @@ -319,7 +254,7 @@ struct ThreadListView: View { |
| ForEach(Array(messageMatches.enumerated()), id: \.offset) { _, hit in |
| Button { |
| NativeChatScreen.pendingJump = (hit.thread.id, hit.message.id) |
| - path.append(hit.thread.id) |
| + path.append(.thread(hit.thread.id)) |
| } label: { |
| HStack(spacing: 10) { |
| Avatar(name: hit.thread.title, size: 34, |
| @@ -424,39 +359,6 @@ struct ThreadListView: View { |
| .padding() |
| } |
| - // A follow-up action should feel like a menu item: the popover vanishes |
| - // instantly (a system menu doesn't make you sit through its dismiss |
| - // animation before the thing you asked for appears) and the next surface |
| - // is the only transition you see. Animations are force-disabled for the |
| - // dismissal tick because the popover animates in UIKit, out of reach of |
| - // the SwiftUI transaction alone. |
| - private func queueFollowUp(_ next: SwitcherFollowUp) { |
| - followUp = next |
| - var t = Transaction() |
| - t.disablesAnimations = true |
| - UIView.setAnimationsEnabled(false) |
| - withTransaction(t) { showSwitcher = false } |
| - // Re-enabled in runFollowUp, once the popover is actually gone; the |
| - // timed fallback only covers a dismissal that never reaches onDisappear. |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { UIView.setAnimationsEnabled(true) } |
| - } |
| - |
| - // The switcher row that was tapped, executed from the popover content's |
| - // onDisappear: More / create / sign-out each present a new surface, which |
| - // drops if presented in the same tick the popover is dismissing. |
| - private func runFollowUp() { |
| - UIView.setAnimationsEnabled(true) |
| - guard let next = followUp else { return } |
| - followUp = nil |
| - DispatchQueue.main.async { |
| - switch next { |
| - case .more: showAllSpaces = true |
| - case .create: onCreate() |
| - case .signOut: confirmSignOut = true |
| - } |
| - } |
| - } |
| - |
| // One instance-wide socket keeps the LIST live (the Mac sidebar's trick): |
| // the hub fans every thread's frames to any member connection, so stored- |
| // message activity — including a brand-new member's first message — |
| @@ -496,6 +398,9 @@ struct ThreadListView: View { |
| } |
| private func load() async { |
| + // Local first: last session's rows render instantly (loading is a |
| + // truly-first-visit state); the network refresh reconciles behind them. |
| + await store.hydrate() |
| await store.refresh() |
| startLive() |
| app.note(id: store.instance.id, myUserId: store.myUserId, groupName: store.instanceName) |
| @@ -509,6 +414,34 @@ struct ThreadListView: View { |
| } |
| } |
| +// The chat list's first-load state: an opaque surface carrying the brand's |
| +// smoke-and-motes scene (web bootscene.js's native twin), with the spinner |
| +// joining only if the load drags — the same tuned feel as the retired |
| +// space-switch scene it replaces. Reduce Motion drops the drifting particles. |
| +// The surface stays out of the large-title region (it respects the safe area) |
| +// so the space name never has loader pixels moving under it. |
| +private struct ChatsLoadingScene: View { |
| + @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| + @State private var spinner = false |
| + |
| + var body: some View { |
| + ZStack { |
| + Color(.systemBackground) |
| + // A fraction of full strength: the shared AmbientField is tuned |
| + // for the create-space stage, where the scene IS the show — in a |
| + // loading beat it's barely noticeable, by design. |
| + if !reduceMotion { SmokeSceneView().opacity(0.2) } |
| + if spinner { |
| + ProgressView().accessibilityLabel("Loading chats") |
| + } |
| + } |
| + .task { |
| + try? await Task.sleep(for: .seconds(0.5)) |
| + spinner = true |
| + } |
| + } |
| +} |
| + |
| // Resolves a pushed thread id against the (observed) store, tolerating the store |
| // still loading — a notification tap routes here before the fresh ThreadStore has |
| // fetched anything, and bailing to "not found" on that race strands the tap. |
| @@ -517,7 +450,7 @@ struct ThreadDestination: View { |
| let id: String |
| // The navigation path, for surfaces that hop threads (the Eleven |
| // conversation's "Say hi to {inviter}" chip). |
| - @Binding var path: [String] |
| + @Binding var path: [Route] |
| var body: some View { |
| if let t = store.threads.first(where: { $0.id == id }) { |
| @@ -533,7 +466,9 @@ struct ThreadDestination: View { |
| } |
| } else if store.loading || store.threads.isEmpty { |
| ProgressView() |
| - .task { await store.refresh() } // cold-launch tap: make sure a load is in flight |
| + // Cold-launch tap: the snapshot usually resolves the thread |
| + // instantly; the refresh covers a genuinely new one. |
| + .task { await store.hydrate(); await store.refresh() } |
| } else { |
| ContentUnavailableView("Chat not found", systemImage: "bubble.left") |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/Keychain.swift b/ios/LChatCore/Sources/LChatCore/Keychain.swift| index bd8f8f9..6b4ac47 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Keychain.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Keychain.swift |
| @@ -109,6 +109,48 @@ public enum InstancesStore { |
| } |
| } |
| +// 🤖 The last folded thread list per space, so re-entering one renders its |
| +// chats instantly (ThreadStore.hydrate) instead of waiting on the network — |
| +// loading is a first-visit-only state. Keychain, not a file: the rows carry |
| +// unwrapped thread keys and decrypted titles/previews — the SharedThreadKeys |
| +// posture. Presence dots are stripped on save (they're live-only state). |
| +public enum ThreadListSnapshot { |
| + public struct Snap: Codable { |
| + public var name: String // the space's group name (title) |
| + public var rows: [ChatThread] |
| + } |
| + |
| + // Refresh refires on every socket frame; skip the (5-50ms) Keychain write |
| + // when nothing changed — the SharedThreadKeys fingerprint trick. |
| + private static var savedFingerprints: [String: Int] = [:] |
| + private static let lock = NSLock() |
| + |
| + private static func account(_ origin: String) -> String { "threads-snapshot|\(origin)" } |
| + |
| + public static func load(origin: String) -> Snap? { |
| + Keychain.load(Snap.self, account: account(origin)) |
| + } |
| + |
| + /// Call off the main thread (hydrate/refresh already do). |
| + public static func save(origin: String, name: String, rows: [ChatThread]) { |
| + var snap = Snap(name: name, rows: rows) |
| + for i in snap.rows.indices { snap.rows[i].presence = nil } |
| + guard let data = try? JSONEncoder().encode(snap) else { return } |
| + let fp = data.hashValue |
| + lock.lock() |
| + let unchanged = savedFingerprints[origin] == fp |
| + if !unchanged { savedFingerprints[origin] = fp } |
| + lock.unlock() |
| + guard !unchanged else { return } |
| + Keychain.save(snap, account: account(origin)) |
| + } |
| + |
| + public static func clear(origin: String) { |
| + Keychain.clear(account: account(origin)) |
| + lock.lock(); savedFingerprints[origin] = nil; lock.unlock() |
| + } |
| +} |
| + |
| // 🤖 The most recent chat the member had open — the share extension's |
| // "Suggested" row. Kept in the shared-keychain group (NOT app-group defaults) |
| // because the decrypted title is user content: same posture as |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 4156cff..dc7c834 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -225,7 +225,7 @@ public struct LinkPreview: Equatable { |
| // (no UIKit) so it can ride in the shared model; AvatarLoader turns it into an |
| // image. Mirrors avatarFor / threadAvatarFor in web/static/chat.js — the server |
| // only ever serves ciphertext, and the key never leaves the device. |
| -public struct AvatarSource: Equatable { |
| +public struct AvatarSource: Equatable, Codable { |
| public var blobID: String |
| public var keyB64: String |
| public var baseURL: URL |
| @@ -245,7 +245,9 @@ public struct AvatarSource: Equatable { |
| // One row in the native thread sidebar — fully decrypted, ready to render. |
| // (ChatThread, not Thread: an unqualified `Thread` from a library would collide |
| // with Foundation's in every importing file.) |
| -public struct ChatThread: Identifiable, Equatable { |
| +// Codable so ThreadListSnapshot can park the folded rows (Keychain — they |
| +// carry thread keys) for instant re-entry into a space. |
| +public struct ChatThread: Identifiable, Equatable, Codable { |
| /// Eleven-the-guide's conversation threads (setup / welcome) — they wear |
| /// the brand icon (ElevenBrand.icon) instead of an app glyph. |
| public var isEleven: Bool { appId == "setup" || appId == "welcome" } |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 26abac0..6201d87 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -49,6 +49,22 @@ public final class ThreadStore: ObservableObject { |
| return AvatarSource(blobID: blobID, keyB64: key, baseURL: base, token: instance.deviceToken) |
| } |
| + /// Local-first render: publish the last session's folded rows (parked in |
| + /// the Keychain by refresh) so entering a space you've been to shows its |
| + /// chats immediately — the network refresh then reconciles in the |
| + /// background, however long it takes. A no-op once anything is loaded, so |
| + /// the loading scene is a truly-first-visit state. |
| + public func hydrate() async { |
| + guard threads.isEmpty else { return } |
| + let origin = instance.origin |
| + guard let snap = await Task.detached(priority: .userInitiated, operation: { |
| + ThreadListSnapshot.load(origin: origin) |
| + }).value else { return } |
| + guard threads.isEmpty else { return } // a refresh raced us — it wins |
| + threads = snap.rows |
| + if instanceName.isEmpty { instanceName = snap.name } |
| + } |
| + |
| public func refresh() async { |
| guard let api else { loadError = "This instance's address is invalid."; return } |
| loading = true |
| @@ -90,7 +106,23 @@ public final class ThreadStore: ObservableObject { |
| for (tid, mark) in folded.readMarks { |
| serverReadMarks[tid] = max(serverReadMarks[tid] ?? 0, mark) |
| } |
| - threads = folded.rows |
| + // A zero-thread fetch against rows we already hold is almost certainly |
| + // a failed/offline fetch (the API calls degrade to empty) — keep what |
| + // we have and let the next refresh reconcile, rather than blanking a |
| + // list the member is looking at (and its snapshot with it). |
| + if !folded.rows.isEmpty || threads.isEmpty { |
| + threads = folded.rows |
| + } |
| + // Park the rows for next time: the hydrate() above is what makes |
| + // re-entering a space instant across launches. Off-main, dedup-skipped |
| + // (refresh refires on every socket frame; Keychain writes aren't free). |
| + if !folded.rows.isEmpty { |
| + let name = instanceName |
| + let rows = folded.rows |
| + Task.detached(priority: .utility) { |
| + ThreadListSnapshot.save(origin: origin, name: name, rows: rows) |
| + } |
| + } |
| } |
| // Everything refresh() publishes, computed off the main actor. |