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>
8 files changed,
+510
−516
ios/LChat/LChatApp.swift+37 −162ios/LChat/Views/EdgeTouchReporter.swift+0 −89ios/LChat/Views/InstanceSidebar.swift+0 −244ios/LChat/Views/NativeChatScreen.swift+2 −2ios/LChat/Views/SignInWithEleven.swift+1 −1ios/LChat/Views/SpaceSwitcher.swift+367 −0ios/LChat/Views/ThreadListCollection.swift+0 −4ios/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 }) { |