import LChatCore import SwiftUI // πŸ€– The app's root screen: your spaces (the Mimestream shape β€” the account // screen sits BEHIND the mailbox you live in). Day to day you're in a space's // chat list, pushed on top of this; "back" brings you here to switch. The // space you live in leads under "Current", starred spaces follow, then the // rest in the order you know them (capped, with More… opening the full // management sheet). Creating a space is the top-right +, and your own avatar // (top-left) holds the account acts β€” Edit profile, Sign in/out. Archived // spaces stay hidden here, exactly as in the old switcher. struct SpacesHomeView: View { @EnvironmentObject var app: AppState @ObservedObject var nav: NavModel /// Shared with the first-run screen (owned by RootView): a device that has /// spaces but no Eleven session yet can sign in / back up from here. @ObservedObject var signIn: HomeSignInModel /// The space you actually live in (stamped by opening one) β€” nil on a /// device that doesn't know yet (fresh sign-in). Distinct from /// nav.instanceID, which may be a context-only selection. var currentID: String? // "Create a new space" gate β€” home must offer provisioning AND the member // must already have a space (the web's canCreateSpace; the server 403s too). var spacesOffered = false var onSelect: (SavedInstance) -> Void var onSignOut: () -> Void var onCreate: () -> Void @State private var showAllSpaces = false @State private var showProfile = false // The More group's disclosure, remembered across launches (Mimestream's // collapsible Favorites). @AppStorage("homeMoreExpanded") private var moreExpanded = true private var active: [SavedInstance] { app.instances.filter { !$0.isArchived } } // "Current" is a SHORTCUT β€” the space also keeps its ordinary slot in // Starred/More, so nothing reshuffles when the current space changes. private var starred: [SavedInstance] { active.filter(\.isStarred) } private var others: [SavedInstance] { active.filter { !$0.isStarred } } // Beyond the cap the list stops being a launcher β€” the full sheet has the rest. private let othersCap = 11 // The space whose hat you're currently wearing β€” the selected one, else // the first you'd land in. Names the avatar + scopes Edit profile. private var current: SavedInstance? { app.instances.first { $0.id == nav.instanceID } ?? active.first ?? app.instances.first } var body: some View { List { if let cur = app.instances.first(where: { $0.id == currentID }) { Section("Current") { row(cur) } } if !starred.isEmpty { Section("Starred") { ForEach(starred) { row($0) } .onMove { app.moveInGroup(starred: true, from: $0, to: $1) } } } if !others.isEmpty { Section { if moreExpanded || untitled { moreRows } } header: { // With nothing above it, a "More" header has no referent β€” // the rows just ARE the list, always shown. if !untitled { moreHeader } } } // EVERY terminal sign-in state renders inline β€” a silent reset // cost us a debugging round on the first device run. if let status = statusLine { Section { } footer: { Text(status).frame(maxWidth: .infinity, alignment: .center) } } } .navigationTitle("Spaces") .toolbar { ToolbarItem(placement: .topBarLeading) { accountMenu } // Create a new space β€” a quiet, secondary act: a plain toolbar // glyph, not a floating prominent button. if createOffered { ToolbarItem(placement: .topBarTrailing) { Button(action: onCreate) { Image(systemName: "plus") } .accessibilityLabel("Create a new space") } } } // The full space list (management lives there). .sheet(isPresented: $showAllSpaces) { SpaceListSheet(activeID: nav.instanceID) { inst in showAllSpaces = false onSelect(inst) } } // Edit profile β€” today this is the CURRENT space's profile (display // name, presence, theme). One shared profile across spaces: issue #576. .sheet(isPresented: $showProfile) { if let store = nav.store { ServerSettingsSheet(store: store) } } } private func row(_ inst: SavedInstance) -> some View { Button { onSelect(inst) } label: { SpaceRow(instance: inst, active: false, photo: app.serverAvatars[inst.id], chevron: true) } .foregroundStyle(.primary) .contextMenu { SpaceMenuItems(instance: inst) } } // ---- the More group (collapsible) ---------------------------------------- // No Current or Starred group above β†’ "More" would have no referent. private var untitled: Bool { currentID == nil && starred.isEmpty } @ViewBuilder private var moreRows: some View { ForEach(others.prefix(othersCap)) { row($0) } .onMove { app.moveInGroup(starred: false, from: $0, to: $1) } if others.count > othersCap { Button { showAllSpaces = true } label: { Label("All spaces…", systemImage: "ellipsis.circle") } .foregroundStyle(.primary) } } private var moreHeader: some View { Button { withAnimation(.easeInOut(duration: 0.2)) { moreExpanded.toggle() } } label: { HStack { Text("More") Spacer() Image(systemName: "chevron.down") .font(.caption2.weight(.semibold)) .rotationEffect(.degrees(moreExpanded ? 0 : -90)) } .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("More spaces") .accessibilityValue(moreExpanded ? "Expanded" : "Collapsed") .accessibilityAddTraits(.isHeader) } // ---- account (avatar β†’ Edit profile / Sign in / Sign out) --------------- private var accountMenu: some View { Menu { Button { showProfile = true } label: { Label("Edit profile", systemImage: "person.crop.circle") } .disabled(nav.store == nil) if #unavailable(iOS 18.0) { // The passkey ceremony (PRF) is iOS 18-only β€” no account // affordances below that. EmptyView() } else if signIn.sessionExists { Button(role: .destructive, action: onSignOut) { Label("Sign out", systemImage: "person.badge.key") } } else { Button { Task { await signIn.signIn(into: app) } } label: { Label(signIn.phase.busy ? "Signing in…" : "Sign in", systemImage: "person.badge.key") } .disabled(signIn.phase.busy) // Registering always creates a new anonymous account (the web // dropped create-on-sign-in in #323) β€” this is the explicit // act for a device with spaces and no Eleven passkey yet. if !app.instances.isEmpty { Button { Task { await signIn.backUp(into: app) } } label: { Label("Save your spaces to Eleven", systemImage: "arrow.up.doc") } .disabled(signIn.phase.busy) } } } label: { Avatar(name: current?.screenName ?? "?", size: 30, source: myAvatarSource) } .accessibilityLabel("Account") } // My profile photo in the current space β€” the store must have loaded its // roster (it has, whenever you've been in the space this launch); until // then the initials stand in, exactly like every other avatar. private var myAvatarSource: AvatarSource? { guard let store = nav.store, let me = store.roster.first(where: { $0.user_id == store.myUserId }) else { return nil } return store.avatarSource(for: me) } private var createOffered: Bool { if #available(iOS 18.0, *) { return signIn.sessionExists && spacesOffered && !app.instances.isEmpty } return false } // The sign-in acts' inline status β€” every terminal phase says something. private var statusLine: String? { switch signIn.phase { case .failed(let msg): return msg case .emptyBackup: return "Signed in β€” no spaces saved yet." case .backedUp(let n): return "Saved \(n) space\(n == 1 ? "" : "s") to Eleven." case .restored(let n): return "\(n) space\(n == 1 ? "" : "s") restored." case .sealed: return "Signed in β€” your spaces unlock with your passkey." case .syncing: return "Syncing your spaces…" case .idle, .working: return nil } } }