eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3
4// Start a chat from the native sidebar, iMessage-style like the iOS picker:
5// click a person and the DM starts immediately (the server dedupes, so an
6// existing DM just opens); tick people + name for a group. All key wrapping is
7// native (ThreadStore.createChat — a fresh thread key ECIES-wrapped to every
8// member).
9struct NewChatPopover: View {
10 @ObservedObject var store: ThreadStore
11 let onCreated: (String) -> Void
12
13 @State private var groupMode = false
14 @State private var selected: Set<String> = []
15 @State private var selectedApps: Set<String> = []
16 @State private var groupName = ""
17 @State private var openGroup = false
18 @State private var busy = false
19 @State private var error: String?
20 // Invite-someone mode (the iOS InviteSomeoneView, popover-sized): type a
21 // name, get a one-time join link to share. The #fragment carries the
22 // secret — it never touches the server.
23 @State private var inviteMode = false
24 @State private var inviteName = ""
25 @State private var inviteLink: String?
26 @State private var copied = false
27 // Bumped by Back/close: an in-flight createInvite completion only applies
28 // when its generation still matches, so a stale result can't leak into a
29 // reset (or re-opened) pane.
30 @State private var inviteGen = 0
31 @Environment(\.colorScheme) private var scheme
32 private var accent: Color { ChatTheme.classic.accent.resolved(scheme) }
33
34 @State private var search = ""
35 private var query: String { search.trimmingCharacters(in: .whitespaces) }
36
37 // Bots off drops agents from the picker (matching the web sheet) — existing
38 // bot DMs stay in the sidebar; only starting new ones hides.
39 private var others: [RosterEntry] {
40 store.rosterMatches(store.roster.filter {
41 $0.user_id != store.myUserId && !$0.label.isEmpty && (store.botsEnabled || !$0.isAgent)
42 }, query)
43 }
44
45 // The web picker searches apps by title too (matchingApps/adminMatches) —
46 // typing "to" should surface To-do just like it does there. The list is
47 // the registry's (places ∋ "you", listed) — custom and member apps
48 // included, identically to the web's New chat ▸ Apps.
49 private var visibleApps: [(id: String, title: String, glyph: String)] {
50 let apps = store.registry.personalApps.map { (id: $0.name, title: $0.title, glyph: $0.glyph) }
51 return query.isEmpty ? apps
52 : apps.filter { $0.title.localizedCaseInsensitiveContains(query) }
53 }
54 private var adminVisible: Bool {
55 store.isAdmin && (query.isEmpty || "Admin".localizedCaseInsensitiveContains(query))
56 }
57 // "No matches." mirrors the web sheet: only when nothing at all survives the
58 // query. Group mode searches people only (shared apps are picked, not found).
59 private var noMatches: Bool {
60 guard !query.isEmpty, others.isEmpty else { return false }
61 if groupMode { return true }
62 return !(store.appsEnabled && !visibleApps.isEmpty) && !adminVisible
63 }
64
65 var body: some View {
66 Group {
67 if inviteMode {
68 invitePane
69 } else {
70 pickerPane
71 }
72 }
73 // A closed popover forgets the invite pane — reopening always starts
74 // at the picker, never a stale link (or someone else's name).
75 .onDisappear { resetInvitePane() }
76 }
77
78 private var pickerPane: some View {
79 VStack(alignment: .leading, spacing: 8) {
80 HStack {
81 Text(groupMode ? "New group" : "New chat")
82 .font(.system(size: 13, weight: .semibold))
83 Spacer()
84 Button(groupMode ? "Single chat" : "New group…") {
85 groupMode.toggle()
86 selected = []
87 selectedApps = []
88 search = ""
89 }
90 .buttonStyle(.link)
91 .font(.system(size: 12))
92 }
93 if groupMode {
94 TextField("Group name (optional)", text: $groupName)
95 .textFieldStyle(.roundedBorder)
96 .font(.system(size: 13))
97 // Open community group — offered only once the instance key
98 // is here (it carries the group key to future joiners).
99 if store.instanceKey() != nil {
100 Toggle(isOn: $openGroup) {
101 VStack(alignment: .leading, spacing: 1) {
102 Text("Open group").font(.system(size: 13))
103 Text("Anyone in this space can find it under Discover and join.")
104 .font(.system(size: 11))
105 .foregroundStyle(.secondary)
106 }
107 }
108 .toggleStyle(.checkbox)
109 }
110 }
111 // The web sheet's Search field, right above the list. Enter opens
112 // the top match (or, in group mode, creates the group) — same as
113 // the web's submit-on-Enter.
114 TextField("Search", text: $search)
115 .textFieldStyle(.roundedBorder)
116 .font(.system(size: 13))
117 .onSubmit {
118 guard !busy else { return }
119 if groupMode {
120 if !selected.isEmpty || openGroup { create(Array(selected), name: groupName) }
121 } else if let first = others.first {
122 create([first.user_id], name: nil)
123 }
124 }
125 Divider()
126 ScrollView {
127 VStack(alignment: .leading, spacing: 1) {
128 ForEach(others, id: \.user_id) { person in
129 personRow(person)
130 }
131 if noMatches {
132 Text("No matches.")
133 .font(.system(size: 12))
134 .foregroundStyle(.secondary)
135 .padding(.horizontal, 8)
136 .padding(.vertical, 6)
137 }
138 // Bring someone new onto the server (the iOS picker's row;
139 // the server says no if the admin turned member invites off).
140 if !groupMode {
141 inviteSomeoneRow
142 }
143 // Personal apps, like the web's New-chat APPS section:
144 // click to open (created on first use). Not group members,
145 // so in group mode this becomes SHARED APPS instead —
146 // tick the apps the new group should start with (each one
147 // lands as a groupapps.add in the fresh log). Both vanish
148 // when the admin turned apps off — except the Admin row,
149 // which is how an admin would switch apps back on.
150 if !groupMode && ((store.appsEnabled && !visibleApps.isEmpty) || adminVisible) {
151 Text("APPS")
152 .font(.system(size: 10, weight: .semibold))
153 .kerning(0.5)
154 .foregroundStyle(.tertiary)
155 .padding(EdgeInsets(top: 10, leading: 8, bottom: 2, trailing: 0))
156 if store.appsEnabled {
157 ForEach(visibleApps, id: \.id) { appEntry in
158 appRow(appEntry)
159 }
160 }
161 // The Admin app is system-scope (never in personalApps);
162 // admins get its row here all the same — the discoverable
163 // way in beyond /admin. Same open action, popover closes
164 // into the thread via onCreated like every app row.
165 // Deliberately exempt from the apps gate.
166 if adminVisible {
167 appRow(("admin", "Admin", "🛠️"))
168 .accessibilityLabel("Admin")
169 .accessibilityHint("Opens this space's admin panel")
170 }
171 } else if groupMode && store.appsEnabled {
172 Text("SHARED APPS")
173 .font(.system(size: 10, weight: .semibold))
174 .kerning(0.5)
175 .foregroundStyle(.tertiary)
176 .padding(EdgeInsets(top: 10, leading: 8, bottom: 2, trailing: 0))
177 ForEach(store.registry.groupApps) { app in
178 groupAppRow((id: app.name, title: app.title, glyph: app.glyph,
179 symbol: AppIdentity.symbol(for: app.name)))
180 }
181 }
182 }
183 }
184 .frame(maxHeight: 300)
185 if let error {
186 Text(error).font(.system(size: 12)).foregroundStyle(.red)
187 }
188 if groupMode {
189 Button {
190 create(Array(selected), name: groupName)
191 } label: {
192 if busy {
193 ProgressView().controlSize(.small).frame(maxWidth: .infinity)
194 } else {
195 Text("Create group").frame(maxWidth: .infinity)
196 }
197 }
198 .buttonStyle(.borderedProminent)
199 // An open group may start with just its creator — others find
200 // it in Discover; a closed group still needs people picked.
201 .disabled((selected.isEmpty && !openGroup) || busy)
202 }
203 }
204 .padding(12)
205 .frame(width: 260)
206 // The web app's accent, not system blue — the Create button, the mode
207 // link and the checkmarks all follow it.
208 .tint(accent)
209 }
210
211 // The row that flips to invite mode — sits with the people, like iOS.
212 private var inviteSomeoneRow: some View {
213 Button {
214 inviteMode = true
215 } label: {
216 HStack(spacing: 8) {
217 Image(systemName: "person.badge.plus")
218 .font(.system(size: 14))
219 .foregroundStyle(.tint)
220 .frame(width: 26, height: 26)
221 Text("Invite someone")
222 .font(.system(size: 13, weight: .medium))
223 Spacer()
224 }
225 .padding(.horizontal, 8)
226 .padding(.vertical, 6)
227 .frame(maxWidth: .infinity, alignment: .leading)
228 .contentShape(Rectangle())
229 }
230 .buttonStyle(.plain)
231 .disabled(busy)
232 }
233
234 private var invitePane: some View {
235 VStack(alignment: .leading, spacing: 8) {
236 HStack {
237 Text("Invite someone")
238 .font(.system(size: 13, weight: .semibold))
239 Spacer()
240 Button("Back") { resetInvitePane() }
241 .buttonStyle(.link)
242 .font(.system(size: 12))
243 .disabled(busy) // mid-mint, Back would strand a live invite
244 }
245 Divider()
246 if let link = inviteLink {
247 Text("Send this link to \(inviteName.trimmingCharacters(in: .whitespaces)) — it's their one-time key into your chat. A new one can be minted later if it's lost.")
248 .font(.system(size: 12))
249 .foregroundStyle(.secondary)
250 .fixedSize(horizontal: false, vertical: true)
251 Text(link)
252 .font(.system(size: 11).monospaced())
253 .lineLimit(3)
254 .truncationMode(.middle)
255 .textSelection(.enabled)
256 HStack(spacing: 8) {
257 Button {
258 NSPasteboard.general.clearContents()
259 NSPasteboard.general.setString(link, forType: .string)
260 copied = true
261 } label: {
262 Label(copied ? "Copied" : "Copy Link",
263 systemImage: copied ? "checkmark" : "doc.on.doc")
264 }
265 if let url = URL(string: link) {
266 ShareLink(item: url) {
267 Label("Share…", systemImage: "square.and.arrow.up")
268 }
269 }
270 }
271 } else {
272 Text("Creates a pending chat with them and a join link only they can use.")
273 .font(.system(size: 12))
274 .foregroundStyle(.secondary)
275 .fixedSize(horizontal: false, vertical: true)
276 TextField("Their name", text: $inviteName)
277 .textFieldStyle(.roundedBorder)
278 .font(.system(size: 13))
279 .onSubmit(createInvite)
280 Button {
281 createInvite()
282 } label: {
283 if busy {
284 ProgressView().controlSize(.small).frame(maxWidth: .infinity)
285 } else {
286 Text("Create invite").frame(maxWidth: .infinity)
287 }
288 }
289 .buttonStyle(.borderedProminent)
290 .disabled(busy || inviteName.trimmingCharacters(in: .whitespaces).isEmpty)
291 }
292 if let error {
293 Text(error).font(.system(size: 12)).foregroundStyle(.red)
294 }
295 }
296 .padding(12)
297 .frame(width: 260)
298 .tint(accent)
299 }
300
301 private func resetInvitePane() {
302 inviteGen += 1 // orphan any in-flight mint's completion
303 inviteMode = false
304 inviteLink = nil
305 inviteName = ""
306 copied = false
307 error = nil
308 busy = false
309 search = "" // a reopened popover starts unfiltered, like the web sheet
310 }
311
312 private func createInvite() {
313 let trimmed = inviteName.trimmingCharacters(in: .whitespaces)
314 guard !trimmed.isEmpty, !busy else { return }
315 busy = true
316 error = nil
317 let gen = inviteGen
318 Task {
319 let r = await store.inviteSomeone(name: trimmed)
320 guard gen == inviteGen else { return } // pane was reset meanwhile
321 busy = false
322 if let l = r.link {
323 inviteLink = l
324 } else {
325 error = r.error ?? "Couldn't create the invite."
326 }
327 }
328 }
329
330 private func personRow(_ person: RosterEntry) -> some View {
331 Button {
332 if groupMode {
333 if selected.contains(person.user_id) {
334 selected.remove(person.user_id)
335 } else {
336 selected.insert(person.user_id)
337 }
338 } else {
339 create([person.user_id], name: nil)
340 }
341 } label: {
342 HStack(spacing: 8) {
343 // Nickname-resolved, like every other member list — the filter
344 // matches this name, so the row must show it too.
345 NativeChatView.ThreadAvatar(title: store.displayName(for: person),
346 source: store.avatarSource(for: person), size: 26)
347 Text(store.displayName(for: person))
348 .font(.system(size: 13, weight: .medium))
349 .lineLimit(1)
350 if person.isAgent {
351 AgentBadge(person.isAI ? "AI" : "BOT")
352 }
353 Spacer()
354 if groupMode {
355 Image(systemName: selected.contains(person.user_id)
356 ? "checkmark.circle.fill" : "circle")
357 .foregroundStyle(selected.contains(person.user_id)
358 ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
359 }
360 }
361 .padding(.horizontal, 8)
362 .padding(.vertical, 6)
363 .frame(maxWidth: .infinity, alignment: .leading)
364 .contentShape(Rectangle()) // whole row taps (the PR #258 lesson)
365 }
366 .buttonStyle(.plain)
367 .disabled(busy)
368 }
369
370 private func appRow(_ entry: (id: String, title: String, glyph: String)) -> some View {
371 Button {
372 busy = true
373 error = nil
374 Task {
375 let threadID = await store.openOrCreateAppThread(app: entry.id, title: entry.title)
376 busy = false
377 if let threadID {
378 onCreated(threadID)
379 } else {
380 error = "Couldn't open \(entry.title)."
381 }
382 }
383 } label: {
384 HStack(spacing: 8) {
385 NativeChatView.ThreadAvatar(title: entry.title, source: nil,
386 size: 26, glyph: entry.glyph,
387 symbol: AppIdentity.symbol(for: entry.id),
388 mark: AppIdentity.mark(for: entry.id))
389 Text(entry.title)
390 .font(.system(size: 13, weight: .medium))
391 .lineLimit(1)
392 Spacer()
393 }
394 .padding(.horizontal, 8)
395 .padding(.vertical, 6)
396 .frame(maxWidth: .infinity, alignment: .leading)
397 .contentShape(Rectangle())
398 }
399 .buttonStyle(.plain)
400 .disabled(busy)
401 }
402
403 // A tickable row for a shared app in group mode — same shape as personRow's
404 // multi-select, the app's glyph for a face.
405 private func groupAppRow(_ entry: (id: String, title: String, glyph: String, symbol: String?)) -> some View {
406 Button {
407 if selectedApps.contains(entry.id) {
408 selectedApps.remove(entry.id)
409 } else {
410 selectedApps.insert(entry.id)
411 }
412 } label: {
413 HStack(spacing: 8) {
414 NativeChatView.ThreadAvatar(title: entry.title, source: nil,
415 size: 26, glyph: entry.glyph,
416 symbol: entry.symbol)
417 Text(entry.title)
418 .font(.system(size: 13, weight: .medium))
419 .lineLimit(1)
420 Spacer()
421 Image(systemName: selectedApps.contains(entry.id)
422 ? "checkmark.circle.fill" : "circle")
423 .foregroundStyle(selectedApps.contains(entry.id)
424 ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
425 }
426 .padding(.horizontal, 8)
427 .padding(.vertical, 6)
428 .frame(maxWidth: .infinity, alignment: .leading)
429 .contentShape(Rectangle())
430 }
431 .buttonStyle(.plain)
432 .disabled(busy)
433 }
434
435 private func create(_ memberIDs: [String], name: String?) {
436 busy = true
437 error = nil
438 let apps = groupMode ? Array(selectedApps).sorted() : []
439 Task {
440 let threadID = await store.createChat(memberIDs: memberIDs, groupName: name,
441 group: groupMode, apps: apps,
442 openGroup: groupMode && openGroup)
443 busy = false
444 if let threadID {
445 onCreated(threadID)
446 } else {
447 error = store.loadError ?? "Couldn't start the chat."
448 }
449 }
450 }
451}
452