| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | |
| 4 | // 🤖 The app's root screen: your spaces (the Mimestream shape — the account |
| 5 | // screen sits BEHIND the mailbox you live in). Day to day you're in a space's |
| 6 | // chat list, pushed on top of this; "back" brings you here to switch. The |
| 7 | // space you live in leads under "Current", starred spaces follow, then the |
| 8 | // rest in the order you know them (capped, with More… opening the full |
| 9 | // management sheet). Creating a space is the top-right +, and your own avatar |
| 10 | // (top-left) holds the account acts — Edit profile, Sign in/out. Archived |
| 11 | // spaces stay hidden here, exactly as in the old switcher. |
| 12 | struct SpacesHomeView: View { |
| 13 | @EnvironmentObject var app: AppState |
| 14 | @ObservedObject var nav: NavModel |
| 15 | /// Shared with the first-run screen (owned by RootView): a device that has |
| 16 | /// spaces but no Eleven session yet can sign in / back up from here. |
| 17 | @ObservedObject var signIn: HomeSignInModel |
| 18 | /// The space you actually live in (stamped by opening one) — nil on a |
| 19 | /// device that doesn't know yet (fresh sign-in). Distinct from |
| 20 | /// nav.instanceID, which may be a context-only selection. |
| 21 | var currentID: String? |
| 22 | // "Create a new space" gate — home must offer provisioning AND the member |
| 23 | // must already have a space (the web's canCreateSpace; the server 403s too). |
| 24 | var spacesOffered = false |
| 25 | var onSelect: (SavedInstance) -> Void |
| 26 | var onSignOut: () -> Void |
| 27 | var onCreate: () -> Void |
| 28 | |
| 29 | @State private var showAllSpaces = false |
| 30 | @State private var showProfile = false |
| 31 | // The More group's disclosure, remembered across launches (Mimestream's |
| 32 | // collapsible Favorites). |
| 33 | @AppStorage("homeMoreExpanded") private var moreExpanded = true |
| 34 | |
| 35 | private var active: [SavedInstance] { app.instances.filter { !$0.isArchived } } |
| 36 | // "Current" is a SHORTCUT — the space also keeps its ordinary slot in |
| 37 | // Starred/More, so nothing reshuffles when the current space changes. |
| 38 | private var starred: [SavedInstance] { active.filter(\.isStarred) } |
| 39 | private var others: [SavedInstance] { active.filter { !$0.isStarred } } |
| 40 | // Beyond the cap the list stops being a launcher — the full sheet has the rest. |
| 41 | private let othersCap = 11 |
| 42 | |
| 43 | // The space whose hat you're currently wearing — the selected one, else |
| 44 | // the first you'd land in. Names the avatar + scopes Edit profile. |
| 45 | private var current: SavedInstance? { |
| 46 | app.instances.first { $0.id == nav.instanceID } ?? active.first ?? app.instances.first |
| 47 | } |
| 48 | |
| 49 | var body: some View { |
| 50 | List { |
| 51 | if let cur = app.instances.first(where: { $0.id == currentID }) { |
| 52 | Section("Current") { row(cur) } |
| 53 | } |
| 54 | if !starred.isEmpty { |
| 55 | Section("Starred") { |
| 56 | ForEach(starred) { row($0) } |
| 57 | .onMove { app.moveInGroup(starred: true, from: $0, to: $1) } |
| 58 | } |
| 59 | } |
| 60 | if !others.isEmpty { |
| 61 | Section { |
| 62 | if moreExpanded || untitled { moreRows } |
| 63 | } header: { |
| 64 | // With nothing above it, a "More" header has no referent — |
| 65 | // the rows just ARE the list, always shown. |
| 66 | if !untitled { moreHeader } |
| 67 | } |
| 68 | } |
| 69 | // EVERY terminal sign-in state renders inline — a silent reset |
| 70 | // cost us a debugging round on the first device run. |
| 71 | if let status = statusLine { |
| 72 | Section { } footer: { |
| 73 | Text(status).frame(maxWidth: .infinity, alignment: .center) |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | .navigationTitle("Spaces") |
| 78 | .toolbar { |
| 79 | ToolbarItem(placement: .topBarLeading) { accountMenu } |
| 80 | // Create a new space — a quiet, secondary act: a plain toolbar |
| 81 | // glyph, not a floating prominent button. |
| 82 | if createOffered { |
| 83 | ToolbarItem(placement: .topBarTrailing) { |
| 84 | Button(action: onCreate) { Image(systemName: "plus") } |
| 85 | .accessibilityLabel("Create a new space") |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | // The full space list (management lives there). |
| 90 | .sheet(isPresented: $showAllSpaces) { |
| 91 | SpaceListSheet(activeID: nav.instanceID) { inst in |
| 92 | showAllSpaces = false |
| 93 | onSelect(inst) |
| 94 | } |
| 95 | } |
| 96 | // Edit profile — today this is the CURRENT space's profile (display |
| 97 | // name, presence, theme). One shared profile across spaces: issue #576. |
| 98 | .sheet(isPresented: $showProfile) { |
| 99 | if let store = nav.store { |
| 100 | ServerSettingsSheet(store: store) |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | private func row(_ inst: SavedInstance) -> some View { |
| 106 | Button { onSelect(inst) } label: { |
| 107 | SpaceRow(instance: inst, active: false, |
| 108 | photo: app.serverAvatars[inst.id], chevron: true) |
| 109 | } |
| 110 | .foregroundStyle(.primary) |
| 111 | .contextMenu { SpaceMenuItems(instance: inst) } |
| 112 | } |
| 113 | |
| 114 | // ---- the More group (collapsible) ---------------------------------------- |
| 115 | |
| 116 | // No Current or Starred group above → "More" would have no referent. |
| 117 | private var untitled: Bool { currentID == nil && starred.isEmpty } |
| 118 | |
| 119 | @ViewBuilder private var moreRows: some View { |
| 120 | ForEach(others.prefix(othersCap)) { row($0) } |
| 121 | .onMove { app.moveInGroup(starred: false, from: $0, to: $1) } |
| 122 | if others.count > othersCap { |
| 123 | Button { showAllSpaces = true } label: { |
| 124 | Label("All spaces…", systemImage: "ellipsis.circle") |
| 125 | } |
| 126 | .foregroundStyle(.primary) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | private var moreHeader: some View { |
| 131 | Button { |
| 132 | withAnimation(.easeInOut(duration: 0.2)) { moreExpanded.toggle() } |
| 133 | } label: { |
| 134 | HStack { |
| 135 | Text("More") |
| 136 | Spacer() |
| 137 | Image(systemName: "chevron.down") |
| 138 | .font(.caption2.weight(.semibold)) |
| 139 | .rotationEffect(.degrees(moreExpanded ? 0 : -90)) |
| 140 | } |
| 141 | .contentShape(Rectangle()) |
| 142 | } |
| 143 | .buttonStyle(.plain) |
| 144 | .accessibilityLabel("More spaces") |
| 145 | .accessibilityValue(moreExpanded ? "Expanded" : "Collapsed") |
| 146 | .accessibilityAddTraits(.isHeader) |
| 147 | } |
| 148 | |
| 149 | // ---- account (avatar → Edit profile / Sign in / Sign out) --------------- |
| 150 | |
| 151 | private var accountMenu: some View { |
| 152 | Menu { |
| 153 | Button { showProfile = true } label: { |
| 154 | Label("Edit profile", systemImage: "person.crop.circle") |
| 155 | } |
| 156 | .disabled(nav.store == nil) |
| 157 | if #unavailable(iOS 18.0) { |
| 158 | // The passkey ceremony (PRF) is iOS 18-only — no account |
| 159 | // affordances below that. |
| 160 | EmptyView() |
| 161 | } else if signIn.sessionExists { |
| 162 | Button(role: .destructive, action: onSignOut) { |
| 163 | Label("Sign out", systemImage: "person.badge.key") |
| 164 | } |
| 165 | } else { |
| 166 | Button { |
| 167 | Task { await signIn.signIn(into: app) } |
| 168 | } label: { |
| 169 | Label(signIn.phase.busy ? "Signing in…" : "Sign in", |
| 170 | systemImage: "person.badge.key") |
| 171 | } |
| 172 | .disabled(signIn.phase.busy) |
| 173 | // Registering always creates a new anonymous account (the web |
| 174 | // dropped create-on-sign-in in #323) — this is the explicit |
| 175 | // act for a device with spaces and no Eleven passkey yet. |
| 176 | if !app.instances.isEmpty { |
| 177 | Button { |
| 178 | Task { await signIn.backUp(into: app) } |
| 179 | } label: { |
| 180 | Label("Save your spaces to Eleven", systemImage: "arrow.up.doc") |
| 181 | } |
| 182 | .disabled(signIn.phase.busy) |
| 183 | } |
| 184 | } |
| 185 | } label: { |
| 186 | Avatar(name: current?.screenName ?? "?", size: 30, source: myAvatarSource) |
| 187 | } |
| 188 | .accessibilityLabel("Account") |
| 189 | } |
| 190 | |
| 191 | // My profile photo in the current space — the store must have loaded its |
| 192 | // roster (it has, whenever you've been in the space this launch); until |
| 193 | // then the initials stand in, exactly like every other avatar. |
| 194 | private var myAvatarSource: AvatarSource? { |
| 195 | guard let store = nav.store, |
| 196 | let me = store.roster.first(where: { $0.user_id == store.myUserId }) |
| 197 | else { return nil } |
| 198 | return store.avatarSource(for: me) |
| 199 | } |
| 200 | |
| 201 | private var createOffered: Bool { |
| 202 | if #available(iOS 18.0, *) { |
| 203 | return signIn.sessionExists && spacesOffered && !app.instances.isEmpty |
| 204 | } |
| 205 | return false |
| 206 | } |
| 207 | |
| 208 | // The sign-in acts' inline status — every terminal phase says something. |
| 209 | private var statusLine: String? { |
| 210 | switch signIn.phase { |
| 211 | case .failed(let msg): return msg |
| 212 | case .emptyBackup: return "Signed in — no spaces saved yet." |
| 213 | case .backedUp(let n): return "Saved \(n) space\(n == 1 ? "" : "s") to Eleven." |
| 214 | case .restored(let n): return "\(n) space\(n == 1 ? "" : "s") restored." |
| 215 | case .sealed: return "Signed in — your spaces unlock with your passkey." |
| 216 | case .syncing: return "Syncing your spaces…" |
| 217 | case .idle, .working: return nil |
| 218 | } |
| 219 | } |
| 220 | } |
| 221 | |