eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import 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.
12struct 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