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// Start a new chat, iMessage-style: the sheet lists people — tap one and the DM
5// starts immediately (the server dedupes, so an existing DM just opens). "New
6// group" pushes the multi-select flow. Agent helpers get their own section with
7// a BOT badge, mirroring the web picker. All key wrapping happens natively in
8// ThreadStore.createChat.
9struct NewChatView: View {
10 @ObservedObject var store: ThreadStore
11 @Environment(\.dismiss) private var dismiss
12 let onCreated: (String) -> Void
13
14 @State private var busyWith: String? // user id of the DM being created ("admin" = the Admin row)
15 @State private var error: String?
16 @State private var search = ""
17
18 private var query: String { search.trimmingCharacters(in: .whitespaces) }
19 private var others: [RosterEntry] { store.roster.filter { $0.user_id != store.myUserId } }
20 private var people: [RosterEntry] { store.rosterMatches(others.filter { !$0.isAgent }, query) }
21 // Bots off empties the Helpers section (matching the web picker) — existing
22 // bot DMs stay in the sidebar; only starting new ones hides.
23 private var helpers: [RosterEntry] {
24 store.botsEnabled ? store.rosterMatches(others.filter { $0.isAgent }, query) : []
25 }
26 // Personal apps from the registry (places ∋ "you", listed) — the web
27 // picker's Apps section, searchable by title like people are (Mac's
28 // NewChatPopover does the same). Hidden entirely when apps are off.
29 private var visibleApps: [AppManifest] {
30 guard store.appsEnabled else { return [] }
31 let apps = store.registry.personalApps
32 return query.isEmpty ? apps
33 : apps.filter { $0.title.localizedCaseInsensitiveContains(query) }
34 }
35
36 var body: some View {
37 NavigationStack {
38 List {
39 // The action rows step aside while a query is live — search
40 // results are people, not actions (clear the field to get
41 // them back). The web keeps its equivalents in the sheet
42 // header, so they never compete with matches there either.
43 if query.isEmpty {
44 Section {
45 NavigationLink {
46 NewGroupView(store: store, onCreated: finish)
47 } label: {
48 Label {
49 Text("New group").font(.body.weight(.medium))
50 } icon: {
51 Image(systemName: "person.3.fill")
52 .foregroundStyle(Color.accentColor)
53 }
54 }
55 // Bring someone new onto the server (if the admin allows
56 // member invites — the server says no otherwise).
57 NavigationLink {
58 InviteSomeoneView(store: store) { _ in dismiss() }
59 } label: {
60 Label {
61 Text("Invite someone").font(.body.weight(.medium))
62 } icon: {
63 Image(systemName: "person.badge.plus")
64 .foregroundStyle(Color.accentColor)
65 }
66 }
67 // The community directory: open groups anyone can join.
68 NavigationLink {
69 DiscoverGroupsView(store: store, onOpen: finish)
70 } label: {
71 Label {
72 Text("Discover groups").font(.body.weight(.medium))
73 } icon: {
74 Image(systemName: "safari")
75 .foregroundStyle(Color.accentColor)
76 }
77 }
78 // The Admin app, admins only (the roster carries our role; the
79 // server re-checks it) — same open-the-app-thread action as the
80 // web picker's Apps row, so /admin isn't the only way in.
81 if store.isAdmin {
82 Button { openAdmin() } label: {
83 HStack {
84 Label {
85 Text("Admin").font(.body.weight(.medium))
86 } icon: {
87 Image(systemName: "gearshape")
88 .foregroundStyle(Color.accentColor)
89 }
90 Spacer()
91 if busyWith == "admin" { ProgressView() }
92 }
93 .contentShape(Rectangle())
94 }
95 .buttonStyle(.plain)
96 .disabled(busyWith != nil)
97 .accessibilityLabel("Admin")
98 .accessibilityHint("Opens this space's admin panel")
99 }
100 }
101 }
102
103 if others.isEmpty {
104 Section {
105 Text("No one else has joined this chat yet.")
106 .foregroundStyle(.secondary)
107 }
108 } else if !query.isEmpty && people.isEmpty && helpers.isEmpty && visibleApps.isEmpty {
109 Section {
110 Text("No matches.")
111 .foregroundStyle(.secondary)
112 }
113 }
114 if !people.isEmpty {
115 Section("People") {
116 ForEach(people, id: \.user_id) { person in personRow(person) }
117 }
118 }
119 if !helpers.isEmpty {
120 Section("Helpers") {
121 ForEach(helpers, id: \.user_id) { person in personRow(person) }
122 }
123 }
124 if !visibleApps.isEmpty {
125 Section("Apps") {
126 ForEach(visibleApps) { app in appRow(app) }
127 }
128 }
129 if let error {
130 Section { Text(error).foregroundStyle(.red) }
131 }
132 }
133 .navigationTitle("New chat")
134 .navigationBarTitleDisplayMode(.inline)
135 // Always-visible, like the web picker's Search field — hiding it
136 // behind a pull-down would read as "no way to filter" all over again.
137 .searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always),
138 prompt: "Search")
139 // Return opens the top match, same as Enter on the web — but only
140 // mid-query: a stray Search tap on an empty field shouldn't start
141 // a DM with whoever happens to sort first.
142 .onSubmit(of: .search) {
143 guard !query.isEmpty, busyWith == nil else { return }
144 if let first = people.first ?? helpers.first { startDM(first) }
145 }
146 .toolbar {
147 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
148 }
149 }
150 }
151
152 private func personRow(_ person: RosterEntry) -> some View {
153 Button { startDM(person) } label: {
154 HStack(spacing: 12) {
155 // Nickname-resolved, like every other member list — the filter
156 // matches this name, so the row must show it too.
157 Avatar(name: store.displayName(for: person), size: 36,
158 source: store.avatarSource(for: person))
159 Text(store.displayName(for: person)).foregroundStyle(.primary)
160 if person.isAgent { BotBadge() }
161 Spacer()
162 if busyWith == person.user_id { ProgressView() }
163 }
164 // A .plain button only hit-tests its label's opaque content — the
165 // Spacer and row padding were dead zones. Make the whole row tappable.
166 .contentShape(Rectangle())
167 }
168 .buttonStyle(.plain)
169 .disabled(busyWith != nil)
170 }
171
172 private func startDM(_ person: RosterEntry) {
173 busyWith = person.user_id
174 error = nil
175 Task {
176 let id = await store.createChat(memberIDs: [person.user_id], groupName: nil)
177 busyWith = nil
178 if let id { finish(id) } else { error = store.loadError ?? "Couldn't start the chat." }
179 }
180 }
181
182 // A personal app row: open (creating on first use) its app-thread and
183 // dismiss into it — the same land-in-the-thread shape as picking a person.
184 private func appRow(_ app: AppManifest) -> some View {
185 Button {
186 busyWith = app.name
187 error = nil
188 Task {
189 let id = await store.openOrCreateAppThread(app: app.name, title: app.title)
190 busyWith = nil
191 if let id { finish(id) } else { error = store.loadError ?? "Couldn't open \(app.title)." }
192 }
193 } label: {
194 HStack(spacing: 12) {
195 Text(app.glyph)
196 .font(.system(size: 17))
197 .frame(width: 36, height: 36)
198 .background(Circle().fill(.primary.opacity(0.06)))
199 VStack(alignment: .leading, spacing: 1) {
200 Text(app.title).foregroundStyle(.primary)
201 if let blurb = app.blurb, !blurb.isEmpty {
202 Text(blurb).font(.footnote).foregroundStyle(.secondary).lineLimit(1)
203 }
204 }
205 Spacer()
206 if busyWith == app.name { ProgressView() }
207 }
208 .contentShape(Rectangle())
209 }
210 .buttonStyle(.plain)
211 .disabled(busyWith != nil)
212 .accessibilityLabel(app.title)
213 .accessibilityHint("Opens the \(app.title) app")
214 }
215
216 // Open (creating on first use) the Admin app-thread, then dismiss into it —
217 // the same land-in-the-thread shape as picking a person.
218 private func openAdmin() {
219 busyWith = "admin" // an app id can't collide with a user id here
220 error = nil
221 Task {
222 let id = await store.openOrCreateAppThread(app: "admin", title: "Admin")
223 busyWith = nil
224 if let id { finish(id) } else { error = store.loadError ?? "Couldn't open Admin." }
225 }
226 }
227
228 private func finish(_ id: String) {
229 onCreated(id)
230 dismiss()
231 }
232}
233
234// The multi-select group flow, pushed from NewChatView.
235struct NewGroupView: View {
236 @ObservedObject var store: ThreadStore
237 let onCreated: (String) -> Void
238
239 @State private var selected: Set<String> = []
240 @State private var selectedApps: Set<String> = []
241 @State private var groupName = ""
242 @State private var openGroup = false
243 @State private var busy = false
244 @State private var error: String?
245 @State private var search = ""
246
247 private var query: String { search.trimmingCharacters(in: .whitespaces) }
248 // Bots off drops agents from the member picker, same as the web's
249 // add-to-group sheet (adding a bot to a group is starting new bot use).
250 private var others: [RosterEntry] {
251 store.rosterMatches(store.roster.filter {
252 $0.user_id != store.myUserId && (store.botsEnabled || !$0.isAgent)
253 }, query)
254 }
255
256 var body: some View {
257 Form {
258 Section("Group name") {
259 TextField("Optional", text: $groupName)
260 .autocorrectionDisabled()
261 }
262 // Open community group — offered only once the instance key is
263 // here (it carries the group key to future joiners), matching the
264 // web sheet and how photos wait on the same key.
265 if store.instanceKey() != nil {
266 Section {
267 Toggle("Open group", isOn: $openGroup)
268 } footer: {
269 Text("Anyone in this space can find it under Discover and join.")
270 }
271 }
272 Section(header: Text(selected.isEmpty ? "Add people" : "\(selected.count) selected")) {
273 if others.isEmpty && !query.isEmpty {
274 Text("No matches.").foregroundStyle(.secondary)
275 }
276 ForEach(others, id: \.user_id) { person in
277 Button { toggle(person.user_id) } label: {
278 HStack(spacing: 12) {
279 Avatar(name: store.displayName(for: person), size: 32,
280 source: store.avatarSource(for: person))
281 Text(store.displayName(for: person)).foregroundStyle(.primary)
282 if person.isAgent { BotBadge() }
283 Spacer()
284 if selected.contains(person.user_id) {
285 Image(systemName: "checkmark.circle.fill").foregroundStyle(.tint)
286 } else {
287 Image(systemName: "circle").foregroundStyle(.tertiary)
288 }
289 }
290 // Same as personRow: without an explicit content shape only
291 // the glyphs hit-test, so selecting felt like tapping radio
292 // buttons. The whole row should toggle.
293 .contentShape(Rectangle())
294 }
295 .buttonStyle(.plain)
296 }
297 }
298 // Shared apps, picked at birth — each one lands as a groupapps.add
299 // in the fresh log, so every member folds the same list (the same
300 // apps you could add later from ⋯ → Manage Apps). Hidden entirely
301 // when the admin turned apps off.
302 if store.appsEnabled {
303 Section {
304 // Every group-capable app from the registry (places ∋
305 // "chat", listed) — the same list the ⋮ → Manage Apps
306 // sheet offers, custom and member apps included.
307 ForEach(store.registry.groupApps) { app in
308 Button { toggleApp(app.name) } label: {
309 HStack(spacing: 12) {
310 Image(systemName: AppIdentity.symbol(for: app.name) ?? "square.grid.2x2")
311 .font(.system(size: 14, weight: .medium))
312 .foregroundStyle(.primary)
313 .frame(width: 32, height: 32)
314 .background(Circle().fill(.primary.opacity(0.06)))
315 Text(app.title).foregroundStyle(.primary)
316 Spacer()
317 if selectedApps.contains(app.name) {
318 Image(systemName: "checkmark.circle.fill").foregroundStyle(.tint)
319 } else {
320 Image(systemName: "circle").foregroundStyle(.tertiary)
321 }
322 }
323 .contentShape(Rectangle())
324 }
325 .buttonStyle(.plain)
326 }
327 } header: {
328 Text("Apps")
329 } footer: {
330 Text("Apps are shared with everyone in the group — one list, one set of data.")
331 }
332 }
333 if let error {
334 Section { Text(error).foregroundStyle(.red) }
335 }
336 }
337 .navigationTitle("New group")
338 .navigationBarTitleDisplayMode(.inline)
339 .searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always),
340 prompt: "Search")
341 .toolbar {
342 ToolbarItem(placement: .confirmationAction) {
343 if busy { ProgressView() }
344 else {
345 // An open group may start with just its creator — others
346 // find it in Discover; a closed group still needs people.
347 Button("Create") { create() }
348 .disabled(selected.isEmpty && !openGroup)
349 }
350 }
351 }
352 }
353
354 private func toggle(_ id: String) {
355 if selected.contains(id) { selected.remove(id) } else { selected.insert(id) }
356 }
357
358 private func toggleApp(_ id: String) {
359 if selectedApps.contains(id) { selectedApps.remove(id) } else { selectedApps.insert(id) }
360 }
361
362 private func create() {
363 busy = true
364 error = nil
365 Task {
366 let id = await store.createChat(memberIDs: Array(selected), groupName: groupName,
367 group: true, apps: Array(selectedApps).sorted(),
368 openGroup: openGroup)
369 busy = false
370 if let id { onCreated(id) } else { error = store.loadError ?? "Couldn't create the group." }
371 }
372 }
373}
374
375// The "BOT" tag next to agent helpers — mirrors the web picker's badge.
376struct BotBadge: View {
377 var body: some View {
378 Text("BOT")
379 .font(.caption2.weight(.bold))
380 .padding(.horizontal, 5)
381 .padding(.vertical, 2)
382 .background(Color.accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 4))
383 .foregroundStyle(Color.accentColor)
384 }
385}
386