import LChatCore import SwiftUI // Start a chat from the native sidebar, iMessage-style like the iOS picker: // click a person and the DM starts immediately (the server dedupes, so an // existing DM just opens); tick people + name for a group. All key wrapping is // native (ThreadStore.createChat — a fresh thread key ECIES-wrapped to every // member). struct NewChatPopover: View { @ObservedObject var store: ThreadStore let onCreated: (String) -> Void @State private var groupMode = false @State private var selected: Set = [] @State private var selectedApps: Set = [] @State private var groupName = "" @State private var openGroup = false @State private var busy = false @State private var error: String? // Invite-someone mode (the iOS InviteSomeoneView, popover-sized): type a // name, get a one-time join link to share. The #fragment carries the // secret — it never touches the server. @State private var inviteMode = false @State private var inviteName = "" @State private var inviteLink: String? @State private var copied = false // Bumped by Back/close: an in-flight createInvite completion only applies // when its generation still matches, so a stale result can't leak into a // reset (or re-opened) pane. @State private var inviteGen = 0 @Environment(\.colorScheme) private var scheme private var accent: Color { ChatTheme.classic.accent.resolved(scheme) } @State private var search = "" private var query: String { search.trimmingCharacters(in: .whitespaces) } // Bots off drops agents from the picker (matching the web sheet) — existing // bot DMs stay in the sidebar; only starting new ones hides. private var others: [RosterEntry] { store.rosterMatches(store.roster.filter { $0.user_id != store.myUserId && !$0.label.isEmpty && (store.botsEnabled || !$0.isAgent) }, query) } // The web picker searches apps by title too (matchingApps/adminMatches) — // typing "to" should surface To-do just like it does there. The list is // the registry's (places ∋ "you", listed) — custom and member apps // included, identically to the web's New chat ▸ Apps. private var visibleApps: [(id: String, title: String, glyph: String)] { let apps = store.registry.personalApps.map { (id: $0.name, title: $0.title, glyph: $0.glyph) } return query.isEmpty ? apps : apps.filter { $0.title.localizedCaseInsensitiveContains(query) } } private var adminVisible: Bool { store.isAdmin && (query.isEmpty || "Admin".localizedCaseInsensitiveContains(query)) } // "No matches." mirrors the web sheet: only when nothing at all survives the // query. Group mode searches people only (shared apps are picked, not found). private var noMatches: Bool { guard !query.isEmpty, others.isEmpty else { return false } if groupMode { return true } return !(store.appsEnabled && !visibleApps.isEmpty) && !adminVisible } var body: some View { Group { if inviteMode { invitePane } else { pickerPane } } // A closed popover forgets the invite pane — reopening always starts // at the picker, never a stale link (or someone else's name). .onDisappear { resetInvitePane() } } private var pickerPane: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text(groupMode ? "New group" : "New chat") .font(.system(size: 13, weight: .semibold)) Spacer() Button(groupMode ? "Single chat" : "New group…") { groupMode.toggle() selected = [] selectedApps = [] search = "" } .buttonStyle(.link) .font(.system(size: 12)) } if groupMode { TextField("Group name (optional)", text: $groupName) .textFieldStyle(.roundedBorder) .font(.system(size: 13)) // Open community group — offered only once the instance key // is here (it carries the group key to future joiners). if store.instanceKey() != nil { Toggle(isOn: $openGroup) { VStack(alignment: .leading, spacing: 1) { Text("Open group").font(.system(size: 13)) Text("Anyone in this space can find it under Discover and join.") .font(.system(size: 11)) .foregroundStyle(.secondary) } } .toggleStyle(.checkbox) } } // The web sheet's Search field, right above the list. Enter opens // the top match (or, in group mode, creates the group) — same as // the web's submit-on-Enter. TextField("Search", text: $search) .textFieldStyle(.roundedBorder) .font(.system(size: 13)) .onSubmit { guard !busy else { return } if groupMode { if !selected.isEmpty || openGroup { create(Array(selected), name: groupName) } } else if let first = others.first { create([first.user_id], name: nil) } } Divider() ScrollView { VStack(alignment: .leading, spacing: 1) { ForEach(others, id: \.user_id) { person in personRow(person) } if noMatches { Text("No matches.") .font(.system(size: 12)) .foregroundStyle(.secondary) .padding(.horizontal, 8) .padding(.vertical, 6) } // Bring someone new onto the server (the iOS picker's row; // the server says no if the admin turned member invites off). if !groupMode { inviteSomeoneRow } // Personal apps, like the web's New-chat APPS section: // click to open (created on first use). Not group members, // so in group mode this becomes SHARED APPS instead — // tick the apps the new group should start with (each one // lands as a groupapps.add in the fresh log). Both vanish // when the admin turned apps off — except the Admin row, // which is how an admin would switch apps back on. if !groupMode && ((store.appsEnabled && !visibleApps.isEmpty) || adminVisible) { Text("APPS") .font(.system(size: 10, weight: .semibold)) .kerning(0.5) .foregroundStyle(.tertiary) .padding(EdgeInsets(top: 10, leading: 8, bottom: 2, trailing: 0)) if store.appsEnabled { ForEach(visibleApps, id: \.id) { appEntry in appRow(appEntry) } } // The Admin app is system-scope (never in personalApps); // admins get its row here all the same — the discoverable // way in beyond /admin. Same open action, popover closes // into the thread via onCreated like every app row. // Deliberately exempt from the apps gate. if adminVisible { appRow(("admin", "Admin", "🛠️")) .accessibilityLabel("Admin") .accessibilityHint("Opens this space's admin panel") } } else if groupMode && store.appsEnabled { Text("SHARED APPS") .font(.system(size: 10, weight: .semibold)) .kerning(0.5) .foregroundStyle(.tertiary) .padding(EdgeInsets(top: 10, leading: 8, bottom: 2, trailing: 0)) ForEach(store.registry.groupApps) { app in groupAppRow((id: app.name, title: app.title, glyph: app.glyph, symbol: AppIdentity.symbol(for: app.name))) } } } } .frame(maxHeight: 300) if let error { Text(error).font(.system(size: 12)).foregroundStyle(.red) } if groupMode { Button { create(Array(selected), name: groupName) } label: { if busy { ProgressView().controlSize(.small).frame(maxWidth: .infinity) } else { Text("Create group").frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) // An open group may start with just its creator — others find // it in Discover; a closed group still needs people picked. .disabled((selected.isEmpty && !openGroup) || busy) } } .padding(12) .frame(width: 260) // The web app's accent, not system blue — the Create button, the mode // link and the checkmarks all follow it. .tint(accent) } // The row that flips to invite mode — sits with the people, like iOS. private var inviteSomeoneRow: some View { Button { inviteMode = true } label: { HStack(spacing: 8) { Image(systemName: "person.badge.plus") .font(.system(size: 14)) .foregroundStyle(.tint) .frame(width: 26, height: 26) Text("Invite someone") .font(.system(size: 13, weight: .medium)) Spacer() } .padding(.horizontal, 8) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(busy) } private var invitePane: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Invite someone") .font(.system(size: 13, weight: .semibold)) Spacer() Button("Back") { resetInvitePane() } .buttonStyle(.link) .font(.system(size: 12)) .disabled(busy) // mid-mint, Back would strand a live invite } Divider() if let link = inviteLink { 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.") .font(.system(size: 12)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Text(link) .font(.system(size: 11).monospaced()) .lineLimit(3) .truncationMode(.middle) .textSelection(.enabled) HStack(spacing: 8) { Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(link, forType: .string) copied = true } label: { Label(copied ? "Copied" : "Copy Link", systemImage: copied ? "checkmark" : "doc.on.doc") } if let url = URL(string: link) { ShareLink(item: url) { Label("Share…", systemImage: "square.and.arrow.up") } } } } else { Text("Creates a pending chat with them and a join link only they can use.") .font(.system(size: 12)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) TextField("Their name", text: $inviteName) .textFieldStyle(.roundedBorder) .font(.system(size: 13)) .onSubmit(createInvite) Button { createInvite() } label: { if busy { ProgressView().controlSize(.small).frame(maxWidth: .infinity) } else { Text("Create invite").frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .disabled(busy || inviteName.trimmingCharacters(in: .whitespaces).isEmpty) } if let error { Text(error).font(.system(size: 12)).foregroundStyle(.red) } } .padding(12) .frame(width: 260) .tint(accent) } private func resetInvitePane() { inviteGen += 1 // orphan any in-flight mint's completion inviteMode = false inviteLink = nil inviteName = "" copied = false error = nil busy = false search = "" // a reopened popover starts unfiltered, like the web sheet } private func createInvite() { let trimmed = inviteName.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, !busy else { return } busy = true error = nil let gen = inviteGen Task { let r = await store.inviteSomeone(name: trimmed) guard gen == inviteGen else { return } // pane was reset meanwhile busy = false if let l = r.link { inviteLink = l } else { error = r.error ?? "Couldn't create the invite." } } } private func personRow(_ person: RosterEntry) -> some View { Button { if groupMode { if selected.contains(person.user_id) { selected.remove(person.user_id) } else { selected.insert(person.user_id) } } else { create([person.user_id], name: nil) } } label: { HStack(spacing: 8) { // Nickname-resolved, like every other member list — the filter // matches this name, so the row must show it too. NativeChatView.ThreadAvatar(title: store.displayName(for: person), source: store.avatarSource(for: person), size: 26) Text(store.displayName(for: person)) .font(.system(size: 13, weight: .medium)) .lineLimit(1) if person.isAgent { AgentBadge(person.isAI ? "AI" : "BOT") } Spacer() if groupMode { Image(systemName: selected.contains(person.user_id) ? "checkmark.circle.fill" : "circle") .foregroundStyle(selected.contains(person.user_id) ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary)) } } .padding(.horizontal, 8) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) // whole row taps (the PR #258 lesson) } .buttonStyle(.plain) .disabled(busy) } private func appRow(_ entry: (id: String, title: String, glyph: String)) -> some View { Button { busy = true error = nil Task { let threadID = await store.openOrCreateAppThread(app: entry.id, title: entry.title) busy = false if let threadID { onCreated(threadID) } else { error = "Couldn't open \(entry.title)." } } } label: { HStack(spacing: 8) { NativeChatView.ThreadAvatar(title: entry.title, source: nil, size: 26, glyph: entry.glyph, symbol: AppIdentity.symbol(for: entry.id), mark: AppIdentity.mark(for: entry.id)) Text(entry.title) .font(.system(size: 13, weight: .medium)) .lineLimit(1) Spacer() } .padding(.horizontal, 8) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(busy) } // A tickable row for a shared app in group mode — same shape as personRow's // multi-select, the app's glyph for a face. private func groupAppRow(_ entry: (id: String, title: String, glyph: String, symbol: String?)) -> some View { Button { if selectedApps.contains(entry.id) { selectedApps.remove(entry.id) } else { selectedApps.insert(entry.id) } } label: { HStack(spacing: 8) { NativeChatView.ThreadAvatar(title: entry.title, source: nil, size: 26, glyph: entry.glyph, symbol: entry.symbol) Text(entry.title) .font(.system(size: 13, weight: .medium)) .lineLimit(1) Spacer() Image(systemName: selectedApps.contains(entry.id) ? "checkmark.circle.fill" : "circle") .foregroundStyle(selectedApps.contains(entry.id) ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary)) } .padding(.horizontal, 8) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(busy) } private func create(_ memberIDs: [String], name: String?) { busy = true error = nil let apps = groupMode ? Array(selectedApps).sorted() : [] Task { let threadID = await store.createChat(memberIDs: memberIDs, groupName: name, group: groupMode, apps: apps, openGroup: groupMode && openGroup) busy = false if let threadID { onCreated(threadID) } else { error = store.loadError ?? "Couldn't start the chat." } } } }