eleven / ocho Public

ios: space switcher is a dropdown on the header button — favourites, More sheet, create + sign out (#558)

* ios: space switcher is a dropdown on the header button

The chat list's top-left button becomes the screen's identity — the
space's avatar + name (with a chevron), replacing the centered "Chats"
title. Tapping it drops down a menu-styled popover anchored under the
button (presentationCompactAdaptation keeps it a popover on iPhone):
up to 11 favourite (starred) spaces — falling back to the head of the
list when nothing is starred — then More, Create a new space (same
iOS 18 / sessionExists / spacesOffered / non-empty gates), and Sign
out (or the Sign in affordances when signed out).

Favourite rows disambiguate three gestures by time-then-movement: tap
switches; a ~0.2s hold then slide drag-reorders (LongPressGesture
sequenced before DragGesture, manual row offsets + haptics, committed
through the existing move/moveInGroup persistence); a full still
long-press falls through to the context menu (Star/Archive/Remove),
whose recognizer cancels the reorder gesture — @GestureState resets
the lift on its own.

"More" opens the full management sheet: all active spaces, favourites
first, standard List drag-to-reorder per group, and the same context
menu. Follow-up surfaces (More sheet, sign-out confirmation, create
flow) present from ThreadListView after the popover has dismissed —
queuing them in the same tick reliably dropped them.

The slide-in ServerDrawer and its whole gesture stack are gone:
InstanceSidebar.swift, EdgeTouchReporter.swift, the drawer overlay,
drawerPos/dragBaseline machinery, and the thread list's lockScroll
plumbing (the edge-swipe affordance is intentionally dropped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the active space row keeps just the checkmark — the fill highlight fought the popover material

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: switcher follow-ups fire on the popover's onDisappear, not a 150ms guess — More/create/sign-out open immediately

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: switcher actions dismiss the popover instantly, menu-style — the dismiss animation was the 'More' delay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 19b148f86f8722043d5220618cf834e5b3abe76b parent 6d6a770
8 files changed, +510 −516
  • ios/LChat/LChatApp.swift +37 −162
  • ios/LChat/Views/EdgeTouchReporter.swift +0 −89
  • ios/LChat/Views/InstanceSidebar.swift +0 −244
  • ios/LChat/Views/NativeChatScreen.swift +2 −2
  • ios/LChat/Views/SignInWithEleven.swift +1 −1
  • ios/LChat/Views/SpaceSwitcher.swift +367 −0
  • ios/LChat/Views/ThreadListCollection.swift +0 −4
  • ios/LChat/Views/ThreadListView.swift +103 −14
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 9af8e3b..78fce4c 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -46,7 +46,7 @@ final class AppState: ObservableObject {
/// Fetch each server's photo for the switcher. /api/group is public and reports
/// whether a photo is set, so this works with no key and only fetches the bytes
/// when one exists (and only when we don't already have it). Cheap + idempotent —
- /// safe to call whenever the drawer appears or the instance set changes.
+ /// safe to call whenever the switcher appears or the instance set changes.
@MainActor
func loadServerAvatars() async {
for inst in instances {
@@ -87,7 +87,7 @@ final class AppState: ObservableObject {
HomeSync.pushSoon(instances: instances)
}
- /// Reorder the server list (drag in the drawer). The array order IS the
+ /// Reorder the server list (drag in the switcher). The array order IS the
/// display order, so persisting it is all there is to it.
func move(from source: IndexSet, to destination: Int) {
instances.move(fromOffsets: source, toOffset: destination)
@@ -115,8 +115,8 @@ final class AppState: ObservableObject {
InstancesStore.save(instances)
}
- /// Reorder within ONE star group (the drawer's starred and "More" sections
- /// each drag independently — mirroring home's independent lists). Offsets
+ /// Reorder within ONE star group (the switcher's favourites and the More
+ /// sheet's sections each drag independently — home's independent lists). Offsets
/// are relative to that group's own array; the other group is untouched.
func moveInGroup(starred: Bool, from source: IndexSet, to destination: Int) {
var group = instances.filter { $0.isStarred == starred }
@@ -186,7 +186,7 @@ final class AppState: ObservableObject {
if instances.count != before { InstancesStore.save(instances) }
}
- /// Archive or unarchive a space: hidden from the drawer's main lists,
+ /// Archive or unarchive a space: hidden from the switcher's lists,
/// grouped under Archived. Repositions with the home vault's setArchived
/// semantics so the array order stays in lockstep: archiving drops the star
/// and lands at the TOP of the archived group; unarchiving lands at the
@@ -244,8 +244,8 @@ final class NavModel: ObservableObject {
}
// The shell, structured like the Mac app translated to a phone: the main index is
-// the active server's chat list; the top-left server button slides in an overlay
-// (the Mac rail as a drawer) to switch servers or add one; tapping a chat pushes
+// 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.
struct RootView: View {
@EnvironmentObject var app: AppState
@@ -255,82 +255,33 @@ struct RootView: View {
// Drives the first-run "Sign in with Eleven" ceremony + restore state.
@StateObject private var signIn = HomeSignInModel()
@State private var path: [String] = [] // pushed thread ids
- // Logical open state: drives hit-testing, accessibility, and external opens
- // (the server button, a chat select, an invite link). Kept in step with
- // `drawerPos` by onChange below.
- @State private var showServers = false
- // Where the drawer actually sits: 0 closed … 1 open. The single source of
- // truth for the panel's position, so it can't glitch on a fast flick: a drag
- // writes it live, and release animates it straight to a rest state. (The old
- // design summed a `showServers ? 1 : 0` base with the live drag; at release
- // those two updates landed on different ticks, so the drawer darted to one
- // and snapped back to the other — the flick "bounce".)
- @State private var drawerPos: CGFloat = 0
- // The drawer position captured when the current drag began, or nil when no
- // drag is in flight — the baseline the live translation is added onto.
- @State private var dragBaseline: CGFloat?
- // True only while a finger is down. @GestureState so a system-cancelled drag
- // (the list's scroll recognizer wins) auto-clears it; we watch it to snap a
- // stranded drawer back to rest instead of wedging the screen half-open.
- @GestureState private var dragging = false
- // True from the instant a finger lands in the drawer's opening zone (the left
- // edge) until it lifts. We freeze the list's scroll on this, not on the drag
- // engaging: the list's pan starts in the first few points of movement, and
- // once it's scrolling `.scrollDisabled` can't call it back — so the lock has
- // to be in place *before* the finger moves. Set from touch-down by a UIKit
- // probe (EdgeTouchReporter) that observes the touch without consuming it; a
- // SwiftUI zero-distance drag would do the same but swallow the list's taps.
- @State private var edgeTouch = false
+ // 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
// 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?
- private let drawerWidth: CGFloat = 300
- // Slide-in / settle curve. easeOut decelerates into place and never
- // overshoots, so there's no bounce by construction.
- private let drawerSettle: Animation = .easeOut(duration: 0.22)
-
var body: some View {
NavigationStack(path: $path) {
Group {
if let store = nav.store {
- // Freeze the list's own scroll while a finger that could be
- // opening the drawer is down at the edge, so a diagonal pull
- // slides the drawer without the list behind scrolling under it
- // (the drag is simultaneous, so the list keeps its recognizer).
- ThreadListView(store: store, path: $path, showServers: $showServers,
- lockScroll: edgeTouch)
+ ThreadListView(store: store, path: $path, showSwitcher: $showSwitcher,
+ signIn: signIn, spacesOffered: spacesOffered,
+ onSelect: select,
+ onSignOut: signOut,
+ onCreate: { createModel = makeCreateModel() })
} else {
noServers
}
}
}
- .overlay { drawer }
// Load server photos once at launch (and when the server set changes) so both
- // the toolbar server button and the drawer show them without opening anything.
+ // the toolbar space button and the switcher show them without opening anything.
.task(id: Set(app.instances.map(\.id))) { await app.loadServerAvatars() }
- .simultaneousGesture(drawerDragGesture)
- // Flips edgeTouch the moment a finger lands in the drawer's opening zone,
- // so the list is already scroll-locked before it can move. A UIKit probe
- // (not a SwiftUI gesture) so it observes the touch-down without stealing
- // the list's row taps.
- .background(EdgeTouchReporter(edgeWidth: 44) { edgeTouch = $0 })
- // Any open/close that isn't a drag — the server button, a chat select, an
- // invite link — just flips showServers; the drawer follows it here, so the
- // slide is defined in exactly one place (the implicit .animation below).
- .onChange(of: showServers) { _, open in drawerPos = open ? 1 : 0 }
- // A drag the system cancelled fires no onEnded (a recognizer stole it),
- // so dragBaseline is still set: snap the stranded panel to its nearest
- // rest state rather than leaving it parked mid-screen.
- .onChange(of: dragging) { _, active in
- guard !active, dragBaseline != nil else { return }
- dragBaseline = nil
- let open = drawerPos > 0.5
- showServers = open
- drawerPos = open ? 1 : 0
- }
.onAppear {
if nav.instanceID == nil { nav.selectInstance(rememberedInstance) }
// Someone with chats wants to hear about them (no-op without the
@@ -369,7 +320,7 @@ struct RootView: View {
}
// A push landing while we're open (see PushManager.inboxPing) refreshes live.
.onChange(of: push.inboxPing) { _, _ in refreshInbox(when: true) }
- // Whether the drawer offers "Create a new space" — home must offer it
+ // Whether the switcher offers "Create a new space" — home must offer it
// and the member must already have a space (the web's canCreateSpace
// gate; the server enforces the same with 403 not_eligible).
.task(id: "\(app.instances.isEmpty)-\(signIn.sessionExists)") {
@@ -380,8 +331,8 @@ 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 drawer opens.
- .onChange(of: showServers) { _, open in
+ // the whole session — re-check whenever the switcher opens.
+ .onChange(of: showSwitcher) { _, open in
guard open, !spacesOffered, signIn.sessionExists, !app.instances.isEmpty else { return }
Task { spacesOffered = await HomeClient().features() }
}
@@ -466,102 +417,26 @@ struct RootView: View {
// a chat worth hearing about, and (re)register this instance.
push.enablePush()
push.subscribeNewInstance(inst)
- showServers = false
+ showSwitcher = false
}
- // The drawer is always present, positioned by drawerPos — that's what
- // lets a drag slide it in under the finger rather than just toggling it.
- @ViewBuilder private var drawer: some View {
- let progress = drawerPos
- ZStack(alignment: .leading) {
- Color.black.opacity(0.35 * progress)
- .ignoresSafeArea()
- .onTapGesture { showServers = false }
- // The scrim carries its own copy of the drag: gestures attached
- // right where the touch lands are the only ones that reliably
- // beat the UIKit recognizers layered through this hierarchy.
- .gesture(drawerDragGesture)
- // Hit-testing must NOT depend on the live drag: flipping it
- // mid-gesture pulls the surface out from under the finger and
- // cancels the very drag that's closing the drawer.
- .allowsHitTesting(showServers)
- ServerDrawer(
- activeID: nav.instanceID,
- signIn: signIn,
- onSelect: select,
- onSignOut: {
- // Sign out = clear this device (the spaces live in the
- // member's Eleven 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.
- Task {
- if let session = HomeSessionStore.load() {
- _ = await HomeSync.push(instances: app.instances, session: session)
- }
- AppReset.wipeAll()
- app.eraseAll()
- signIn.refreshSession()
- path = []
- nav.selectInstance(nil)
- showServers = false
- }
- },
- spacesOffered: spacesOffered,
- onCreate: {
- createModel = makeCreateModel()
- showServers = false
- }
- )
- .offset(x: (progress - 1) * drawerWidth)
- .allowsHitTesting(showServers)
- // Fully closed = gone: VoiceOver shouldn't focus an offscreen drawer.
- .accessibilityHidden(progress == 0)
- }
- // Snaps animate; while the finger is down (dragBaseline set) the drawer
- // tracks it raw. Because progress is now a single value that the drag
- // leaves exactly where the finger let go, this tween always runs from the
- // release point straight to the rest state — nothing to bounce off.
- .animation(dragBaseline == nil ? drawerSettle : nil, value: progress)
- // The overlay owns hit-testing while open, so it must carry the drag
- // gesture too — the root's copy only ever sees the edge-open.
- .simultaneousGesture(drawerDragGesture)
- }
-
- // Drag from the left edge to pull the drawer in (or drag it back closed from
- // anywhere while open). Root screen only — in a pushed chat the left edge
- // belongs to back-swipe, and the feed owns its own horizontal gestures.
- private var drawerDragGesture: some Gesture {
- DragGesture(minimumDistance: 15)
- // Just a liveness flag; @GestureState auto-clears it on cancel so the
- // onChange(dragging) recovery above can rescue a stranded drag.
- .updating($dragging) { _, state, _ in state = true }
- .onChanged { v in
- if dragBaseline == nil {
- // Engage only for a horizontal pull — from the edge to open,
- // from anywhere to close — so list scrolling stays untouched.
- guard engages(v) else { return }
- dragBaseline = drawerPos
- }
- guard let base = dragBaseline else { return }
- drawerPos = min(1, max(0, base + v.translation.width / drawerWidth))
- }
- .onEnded { v in
- guard let base = dragBaseline else { return }
- dragBaseline = nil
- // Settle by where the flick was *headed*, not where the finger
- // happened to lift — a quick flick commits even if it barely moved.
- let predicted = min(1, max(0, base + v.predictedEndTranslation.width / drawerWidth))
- let open = predicted > 0.5
- showServers = open
- drawerPos = open ? 1 : 0
+ // Sign out = clear this device (the spaces live in the member's Eleven
+ // 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).
+ private func signOut() {
+ Task {
+ if let session = HomeSessionStore.load() {
+ _ = await HomeSync.push(instances: app.instances, session: session)
}
- }
-
- private func engages(_ v: DragGesture.Value) -> Bool {
- guard path.isEmpty else { return false }
- guard abs(v.translation.width) > abs(v.translation.height) else { return false }
- return showServers || v.startLocation.x < 44
+ AppReset.wipeAll()
+ app.eraseAll()
+ signIn.refreshSession()
+ path = []
+ nav.selectInstance(nil)
+ showSwitcher = false
+ }
}
// First run: "Sign in with Eleven" is the ONLY door (Paul's call — no
diff --git a/ios/LChat/Views/EdgeTouchReporter.swift b/ios/LChat/Views/EdgeTouchReporter.swift
deleted file mode 100644
index f8e7784..0000000
--- a/ios/LChat/Views/EdgeTouchReporter.swift
+++ /dev/null
@@ -1,89 +0,0 @@
-import SwiftUI
-import UIKit
-
-// Reports, via `onChange`, whether a finger is currently pressing in the left
-// screen-edge zone — the strip where a drawer-open drag begins — from the moment
-// it lands until it lifts. RootView freezes the chat list's scroll on this, so an
-// edge pull slides the drawer in without the list scrolling out from under it.
-//
-// Why UIKit and not a SwiftUI `DragGesture(minimumDistance: 0)`: that gesture
-// recognises on touch-down and, even as a `.simultaneousGesture`, swallows the
-// list rows' taps. A `UILongPressGestureRecognizer` with `cancelsTouchesInView =
-// false` observes the touch-down without stealing it — we only need to *know* a
-// touch began at the edge, not consume it, so taps and scrolling stay intact.
-struct EdgeTouchReporter: UIViewRepresentable {
- var edgeWidth: CGFloat = 44
- var onChange: (Bool) -> Void
-
- func makeCoordinator() -> Coordinator { Coordinator(edgeWidth: edgeWidth, onChange: onChange) }
-
- func makeUIView(context: Context) -> UIView { Probe(coordinator: context.coordinator) }
-
- func updateUIView(_ uiView: UIView, context: Context) {
- context.coordinator.edgeWidth = edgeWidth
- context.coordinator.onChange = onChange
- }
-
- final class Coordinator: NSObject, UIGestureRecognizerDelegate {
- var edgeWidth: CGFloat
- var onChange: (Bool) -> Void
-
- init(edgeWidth: CGFloat, onChange: @escaping (Bool) -> Void) {
- self.edgeWidth = edgeWidth
- self.onChange = onChange
- }
-
- @objc func handle(_ g: UILongPressGestureRecognizer) {
- switch g.state {
- case .began:
- onChange(g.location(in: g.view).x < edgeWidth)
- case .ended, .cancelled, .failed:
- onChange(false)
- default:
- break
- }
- }
-
- // Coexist with everything — the scroll view, the drawer drag, row taps.
- func gestureRecognizer(_ g: UIGestureRecognizer,
- shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true }
- }
-
- // A zero-size, non-interactive probe that hangs the recognizer on its
- // superview — an ancestor of the list — so the recognizer sees the list's
- // touches while the probe itself never appears in hit-testing.
- final class Probe: UIView {
- private let recognizer: UILongPressGestureRecognizer
- private weak var host: UIView?
-
- init(coordinator: Coordinator) {
- recognizer = UILongPressGestureRecognizer(target: coordinator,
- action: #selector(Coordinator.handle(_:)))
- // Fire at touch-down, and don't bail when the finger then moves off to
- // become a drag — we want the whole press, down to up.
- recognizer.minimumPressDuration = 0
- recognizer.allowableMovement = .greatestFiniteMagnitude
- recognizer.cancelsTouchesInView = false
- recognizer.delaysTouchesBegan = false
- recognizer.delaysTouchesEnded = false
- super.init(frame: .zero)
- recognizer.delegate = coordinator
- isUserInteractionEnabled = false
- }
- required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
-
- override func didMoveToWindow() {
- super.didMoveToWindow()
- guard window != nil, let host = superview, recognizer.view !== host else { return }
- recognizer.view?.removeGestureRecognizer(recognizer)
- host.addGestureRecognizer(recognizer)
- self.host = host
- }
-
- override func removeFromSuperview() {
- host?.removeGestureRecognizer(recognizer)
- host = nil
- super.removeFromSuperview()
- }
- }
-}
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift
deleted file mode 100644
index 9478b8b..0000000
--- a/ios/LChat/Views/InstanceSidebar.swift
+++ /dev/null
@@ -1,244 +0,0 @@
-import LChatCore
-import SwiftUI
-
-// The server switcher: the Mac app's rail, as a slide-in drawer. Every saved
-// server, the active one ticked, plus the same "Add a chat" flow as the Mac.
-// Presented by RootView as a leading overlay; tapping the dimmed area dismisses.
-struct ServerDrawer: View {
- @EnvironmentObject var app: AppState
- let activeID: String?
- /// Shared with the first-run screen (owned by RootView): the drawer 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
- var onSelect: (SavedInstance) -> Void
- // Sign out = full local reset, owned by RootView (final sync push, wipe,
- // fold nav/session back to first-run).
- var onSignOut: () -> Void = {}
- // "Create a new space" (the goes-to-eleven flow, owned by RootView). Shown
- // only when home offers provisioning AND the member already has a space —
- // mirroring the web's canCreateSpace() gate (and the server's 403).
- var spacesOffered = false
- var onCreate: () -> Void = {}
- // "More" starts expanded if the active space is one of the collapsed ones,
- // so you never lose sight of where you are.
- @State private var showMore = false
- @State private var confirmSignOut = false
-
- private var active: [SavedInstance] { app.instances.filter { !$0.isArchived } }
- private var starred: [SavedInstance] { active.filter(\.isStarred) }
- private var others: [SavedInstance] { active.filter { !$0.isStarred } }
-
- var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- // Once anything is starred, the rail shows only the stars and folds
- // the rest under "More" (matching the home page); with nothing starred
- // it's the flat, drag-reorderable list. No title — the list IS the
- // drawer (Paul's call), so it starts at the top.
- List {
- if starred.isEmpty {
- ForEach(active) { inst in serverRow(inst) }
- .onMove { app.move(from: $0, to: $1) }
- } else {
- // Each group drags independently (home's independent lists);
- // without these, starring ANYTHING killed reorder entirely.
- ForEach(starred) { inst in serverRow(inst) }
- .onMove { app.moveInGroup(starred: true, from: $0, to: $1) }
- if !others.isEmpty {
- DisclosureGroup(isExpanded: $showMore) {
- ForEach(others) { inst in serverRow(inst) }
- .onMove { app.moveInGroup(starred: false, from: $0, to: $1) }
- } label: {
- Text("More")
- .font(.subheadline.weight(.medium))
- .foregroundStyle(.secondary)
- }
- .listRowSeparator(.hidden)
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
- }
- }
- // Archived spaces don't show here at all — they live in the
- // account (unarchive on the home page); the drawer stays the
- // active list. "Create a new space" closes the list, where a
- // new entry would land.
- if #available(iOS 18.0, *), signIn.sessionExists,
- spacesOffered, !app.instances.isEmpty {
- Button(action: onCreate) {
- // On the server rows' grid: the + sits in the avatar
- // column, centered with its label.
- HStack(spacing: 12) {
- Image(systemName: "plus.circle")
- .font(.title2)
- .foregroundStyle(Color.accentColor)
- .frame(width: 36, height: 36)
- Text("Create a new space")
- .font(.headline)
- Spacer()
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .listRowSeparator(.hidden)
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets(top: 1, leading: 10, bottom: 1, trailing: 10))
- }
- }
- .listStyle(.plain)
- .scrollContentBackground(.hidden)
- // Headroom under the Dynamic Island — the first row shouldn't
- // graze the status bar.
- .contentMargins(.top, 26, for: .scrollContent)
- .onAppear { showMore = others.contains { $0.id == activeID } }
-
- Divider()
-
-
- if #unavailable(iOS 18.0) {
- // The passkey ceremony (PRF) is iOS 18-only — no sign-in
- // affordances below that; the drawer is just the server list.
- EmptyView()
- } else 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.
- Button {
- confirmSignOut = true
- } label: {
- Label("Sign out", systemImage: "person.badge.key")
- .font(.body.weight(.medium))
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 20)
- .padding(.top, 14) // breathing room under the divider
- .padding(.bottom, 16)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .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)
- }
- } else {
- Button {
- Task { await signIn.signIn(into: app) }
- } label: {
- HStack(spacing: 8) {
- if signIn.phase.busy {
- ProgressView().controlSize(.small)
- } else {
- Image(systemName: "person.badge.key")
- }
- Text(signIn.phase.busy ? "Signing in…" : "Sign in")
- .font(.body.weight(.medium))
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 20)
- .padding(.bottom, 16)
- .contentShape(Rectangle())
- }
- .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, 20)
- .padding(.bottom, 14)
- .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, 20)
- .padding(.bottom, 12)
- }
- }
- }
- .frame(width: 300)
- .frame(maxHeight: .infinity)
- // The panel paints to the screen edge, but the content (the Add row) stays
- // inside the safe area so it clears the home indicator.
- .background(Color(.systemBackground).ignoresSafeArea())
- }
-
- // 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 server row plus the common list-row chrome (used in every section).
- private func serverRow(_ inst: SavedInstance) -> some View {
- row(inst)
- .listRowSeparator(.hidden)
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets(top: 1, leading: 10, bottom: 1, trailing: 10))
- }
-
- private func row(_ inst: SavedInstance) -> some View {
- Button { onSelect(inst) } label: {
- HStack(spacing: 12) {
- Avatar(name: inst.displayName, size: 36, photo: app.serverAvatars[inst.id])
- VStack(alignment: .leading, spacing: 2) {
- Text(inst.displayName).font(.headline).lineLimit(1)
- // The host, unless the title already IS the host (no group
- // name yet) — no point showing it twice.
- if inst.displayName != inst.hostLabel {
- Text(inst.hostLabel).font(.caption).foregroundStyle(.secondary).lineLimit(1)
- }
- }
- Spacer()
- // No star glyph: starred spaces already read as starred by
- // being the rail (the fold under More is the tell).
- if inst.id == activeID {
- Image(systemName: "checkmark")
- .font(.subheadline.weight(.semibold))
- .foregroundStyle(Color.accentColor)
- }
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .background(inst.id == activeID ? Color(.secondarySystemFill) : .clear,
- in: RoundedRectangle(cornerRadius: 10))
- .contextMenu {
- Button { app.toggleStar(inst) } label: {
- Label(inst.isStarred ? "Unstar" : "Star",
- systemImage: inst.isStarred ? "star.slash" : "star")
- }
- Button { app.toggleArchive(inst) } label: {
- Label(inst.isArchived ? "Unarchive" : "Archive",
- systemImage: inst.isArchived ? "tray.and.arrow.up" : "archivebox")
- }
- Button(role: .destructive) { app.remove(inst) } label: {
- Label("Remove", systemImage: "trash")
- }
- }
- }
-}
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 2ebe23a..80a838b 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -7,8 +7,8 @@ import UniformTypeIdentifiers
// The iOS native feed (docs/native-chat.md, iOS adoption): the same shared
// pieces the Mac takeover renders — ChatFeedModel (parked via
// NativeFeedModelCache), MessageBubble and friends — in a phone-shaped
-// container. Behind the "Native chat (beta)" toggle in the server drawer;
-// ChatView's webview feed stays the default until this reaches parity.
+// container. The default surface for plain chats (ThreadDestination); app
+// threads keep ChatView's webview feed.
//
// Phone-appropriate divergences from the Mac container: actions ride the
// long-press context menu MessageBubble already provides on iOS (no hover, no
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift
index 8727f44..763cb00 100644
--- a/ios/LChat/Views/SignInWithEleven.swift
+++ b/ios/LChat/Views/SignInWithEleven.swift
@@ -34,7 +34,7 @@ final class HomeSignInModel: ObservableObject {
}
@Published private(set) var phase: Phase = .idle
/// Whether a home session exists on this device — @Published so EVERY
- /// surface (first-run, drawer) reacts to a sign-in made on any of them.
+ /// surface (first-run, space switcher) reacts to a sign-in made on any of them.
/// A stale one-shot copy here once left "Back up this device" visible
/// after sign-in, a path to accidentally minting a second account.
@Published private(set) var sessionExists = HomeSessionStore.load() != nil
diff --git a/ios/LChat/Views/SpaceSwitcher.swift b/ios/LChat/Views/SpaceSwitcher.swift
new file mode 100644
index 0000000..7658dee
--- /dev/null
+++ b/ios/LChat/Views/SpaceSwitcher.swift
@@ -0,0 +1,367 @@
+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
+// (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).
+struct SpaceListSheet: View {
+ @EnvironmentObject var app: AppState
+ @Environment(\.dismiss) private var dismiss
+ let activeID: String?
+ var onSelect: (SavedInstance) -> Void
+
+ private var active: [SavedInstance] { app.instances.filter { !$0.isArchived } }
+ private var starred: [SavedInstance] { active.filter(\.isStarred) }
+ private var others: [SavedInstance] { active.filter { !$0.isStarred } }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ if starred.isEmpty {
+ Section {
+ ForEach(active) { row($0) }
+ .onMove { app.move(from: $0, to: $1) }
+ }
+ } else {
+ // Each group drags independently (home's independent lists);
+ // without these, starring ANYTHING killed reorder entirely.
+ Section("Favourites") {
+ ForEach(starred) { row($0) }
+ .onMove { app.moveInGroup(starred: true, from: $0, to: $1) }
+ }
+ if !others.isEmpty {
+ Section("More") {
+ ForEach(others) { row($0) }
+ .onMove { app.moveInGroup(starred: false, from: $0, to: $1) }
+ }
+ }
+ }
+ }
+ .navigationTitle("Spaces")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ }
+ }
+
+ private func row(_ inst: SavedInstance) -> some View {
+ Button { onSelect(inst) } label: {
+ SpaceRow(instance: inst, active: inst.id == activeID,
+ photo: app.serverAvatars[inst.id])
+ }
+ .foregroundStyle(.primary)
+ .contextMenu { SpaceMenuItems(instance: inst) }
+ }
+}
+
+// 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 {
+ let instance: SavedInstance
+ let active: Bool
+ var photo: UIImage?
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Avatar(name: instance.displayName, size: 36, photo: photo)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(instance.displayName).font(.headline).lineLimit(1)
+ // The host, unless the title already IS the host (no group
+ // name yet) — no point showing it twice.
+ if instance.displayName != instance.hostLabel {
+ Text(instance.hostLabel).font(.caption).foregroundStyle(.secondary).lineLimit(1)
+ }
+ }
+ Spacer()
+ if active {
+ Image(systemName: "checkmark")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(Color.accentColor)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+}
+
+// The manage menu shared by the dropdown rows and the More sheet rows.
+private struct SpaceMenuItems: View {
+ @EnvironmentObject var app: AppState
+ let instance: SavedInstance
+
+ var body: some View {
+ Button { app.toggleStar(instance) } label: {
+ Label(instance.isStarred ? "Unstar" : "Star",
+ systemImage: instance.isStarred ? "star.slash" : "star")
+ }
+ Button { app.toggleArchive(instance) } label: {
+ Label(instance.isArchived ? "Unarchive" : "Archive",
+ systemImage: instance.isArchived ? "tray.and.arrow.up" : "archivebox")
+ }
+ Button(role: .destructive) { app.remove(instance) } label: {
+ Label("Remove", systemImage: "trash")
+ }
+ }
+}
diff --git a/ios/LChat/Views/ThreadListCollection.swift b/ios/LChat/Views/ThreadListCollection.swift
index 911e3af..c613572 100644
--- a/ios/LChat/Views/ThreadListCollection.swift
+++ b/ios/LChat/Views/ThreadListCollection.swift
@@ -15,7 +15,6 @@ import UIKit
// the reference for feeling natively UIKit.)
struct ThreadListCollection: UIViewControllerRepresentable {
var threads: [ChatThread]
- var lockScroll: Bool
var onSelect: (String) -> Void
var onClose: (ChatThread) -> Void
var onPin: (ChatThread) -> Void
@@ -30,9 +29,6 @@ struct ThreadListCollection: UIViewControllerRepresentable {
vc.onPin = onPin
vc.onToggleUnread = onToggleUnread
vc.onRefresh = onRefresh
- // Freeze scrolling while an edge-drag is opening the server drawer (mirrors
- // the old .scrollDisabled(lockScroll)).
- vc.collectionView.isScrollEnabled = !lockScroll
vc.apply(threads)
}
}
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 86761e1..d01022c 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -3,17 +3,21 @@ 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
-// server button slides in the server drawer (see RootView). The ThreadStore does
+// space button (avatar + name) drops down the space switcher. 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 showServers: Bool
- // Freezes this list's scroll while a finger that might be opening the drawer
- // is down, so the panel slides in without the list scrolling under it.
- var lockScroll: Bool = false
+ @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 = {}
@State private var showNew = false
@State private var search = ""
@Environment(\.scenePhase) private var scenePhase
@@ -24,6 +28,14 @@ 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
@@ -61,7 +73,6 @@ struct ThreadListView: View {
if search.trimmingCharacters(in: .whitespaces).isEmpty {
ThreadListCollection(
threads: visibleThreads,
- lockScroll: lockScroll,
onSelect: { path.append($0) },
onClose: { store.setHidden($0, true) },
onPin: { store.setPinned($0, !$0.pinned) },
@@ -120,16 +131,49 @@ struct ThreadListView: View {
.navigationTitle("Chats") // also backs the chat screen's back button
.navigationBarTitleDisplayMode(.inline)
.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 { showServers = true } label: {
- Avatar(name: serverTitle, size: 30, photo: app.serverAvatars[store.instance.id])
+ 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)
+ }
}
- // Plain "Chats", like the Mac sidebar — which server you're in is
- // the avatar on the left (it opens the server switcher).
+ // 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("Chats").font(.headline)
+ Text(verbatim: "")
}
// Cog then filter, each its OWN glass capsule (the spacer splits
// them) — settings and filtering are unrelated acts, not a group.
@@ -185,6 +229,21 @@ struct ThreadListView: View {
.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 = [] } })) {
@@ -368,6 +427,39 @@ 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 —
@@ -429,9 +521,6 @@ struct ThreadDestination: View {
// The navigation path, for surfaces that hop threads (the Eleven
// conversation's "Say hi to {inviter}" chip).
@Binding var path: [String]
- // iOS adoption of the native feed (docs/native-chat.md), behind the server
- // drawer's "Native chat (beta)" toggle — the webview ChatView stays the
- // default until parity.
var body: some View {
if let t = store.threads.first(where: { $0.id == id }) {