eleven / ocho Public

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>
Paul Campbell 72439e7915cb4ac6b0fd0d323562db6fa4da4e5f parent f1d101e
9 files changed, +526 −577
  • ios/LChat/LChatApp.swift +148 −56
  • ios/LChat/Views/ElevenChatScreen.swift +3 −3
  • ios/LChat/Views/SpaceSwitchScene.swift +0 −121
  • ios/LChat/Views/SpaceSwitcher.swift +13 −265
  • ios/LChat/Views/SpacesHomeView.swift +219 −0
  • ios/LChat/Views/ThreadListView.swift +64 −129
  • ios/LChatCore/Sources/LChatCore/Keychain.swift +42 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +4 −2
  • ios/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.