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