import LChatCore import SwiftUI // 🤖 Group management (native): the members list with add-people, and rename. // Key handling matches the web exactly — adding someone wraps the EXISTING // thread key to their public key; renaming re-encrypts {name} under the // thread key. The server never sees either in the clear. struct MembersSheet: View { let store: ThreadStore let thread: ChatThread // A profile card's "Message them" — the presenter navigates; this sheet // just gets out of the way first. var onOpenThread: ((String) -> Void)? @Environment(\.dismiss) private var dismiss @State private var selected: Set = [] @State private var busy = false @State private var note: String? // A clicked member's profile card — rename them for yourself there (#577). @State private var profile: ProfileTarget? struct ProfileTarget: Identifiable { let id: String } private var members: [RosterEntry] { thread.members.compactMap { id in store.roster.first { $0.user_id == id } } } private var others: [RosterEntry] { // Only people who have ACCEPTED their invite: adding someone wraps the // thread key to their public key, so an entry without one (invite minted, // not yet redeemed) can't be added — the web applies the same filter. store.roster.filter { !thread.members.contains($0.user_id) && !$0.label.isEmpty && !$0.public_key.isEmpty } } var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Text("Members").font(.system(size: 14, weight: .semibold)) Spacer() Button("Done") { dismiss() }.keyboardShortcut(.cancelAction) } ScrollView { VStack(alignment: .leading, spacing: 1) { ForEach(members, id: \.user_id) { person in row(person, selectable: false) } if !others.isEmpty { Text("ADD PEOPLE") .font(.system(size: 10, weight: .semibold)).kerning(0.5) .foregroundStyle(.tertiary) .padding(EdgeInsets(top: 12, leading: 8, bottom: 2, trailing: 0)) ForEach(others, id: \.user_id) { person in row(person, selectable: true) } } } } .frame(maxHeight: 320) if let note { Text(note).font(.system(size: 12)).foregroundStyle(.secondary) } if !selected.isEmpty { // Mac-assed action placement: a compact, bold push button at the // sheet's bottom-right (dialog idiom), not an iOS full-width bar. HStack { Spacer() Button { busy = true Task { let added = await store.addMembers(thread, userIDs: Array(selected)) busy = false selected = [] note = added.isEmpty ? "Couldn't add anyone — try again." : "Added \(added.count)." } } label: { if busy { ProgressView().controlSize(.small) } else { Text(selected.count == 1 ? "Add to Group" : "Add \(selected.count) to Group") .fontWeight(.semibold) } } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) .disabled(busy) } } } .padding(14) .frame(width: 300) .tint(accent) .sheet(item: $profile) { target in MemberProfileSheet(store: store, uid: target.id, onOpenThread: onOpenThread.map { open in { id in profile = nil; dismiss(); open(id) } }) { e in NativeChatView.ThreadAvatar(title: store.displayName(for: e), source: store.avatarSource(for: e), size: 72) } } } // The user's chosen theme accent, resolved for light/dark — the hard-coded // classic-light purple read as a random colour in dark mode / other themes. @AppStorage("nativeTheme") private var themeName = "classic" @Environment(\.colorScheme) private var scheme private var accent: Color { ChatTheme.named(themeName).accent.resolved(scheme) } private func row(_ person: RosterEntry, selectable: Bool) -> some View { Button { if selectable { if selected.contains(person.user_id) { selected.remove(person.user_id) } else { selected.insert(person.user_id) } } else if person.user_id != store.myUserId { // A member's row opens their profile card — where "call them // something else" and the rename notice live (#577). profile = ProfileTarget(id: person.user_id) } } label: { HStack(spacing: 8) { 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 selectable { 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, 5) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) } } // Invite a brand-new person straight into a DM (the web's inviteSomeone): // type their name, get the one-time join link (its #fragment carries the // secret — never sent to the server). struct InviteSomeoneSheet: View { let store: ThreadStore @Environment(\.dismiss) private var dismiss @State private var name = "" @State private var busy = false @State private var error: String? @State private var link: String? @State private var copied = false var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Text("Invite someone").font(.system(size: 14, weight: .semibold)) Spacer() Button(link == nil ? "Cancel" : "Done") { dismiss() } .keyboardShortcut(.cancelAction) } if let link { Text("Send this link to \(name.trimmingCharacters(in: .whitespaces)) — it's their one-time key into your chat.") .font(.system(size: 12)).foregroundStyle(.secondary) Text(link) .font(.system(size: 11).monospaced()) .lineLimit(3) .textSelection(.enabled) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 8).fill(.primary.opacity(0.05))) Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(link, forType: .string) copied = true } label: { Label(copied ? "Copied" : "Copy link", systemImage: copied ? "checkmark" : "doc.on.doc") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } else { TextField("Their name", text: $name) .textFieldStyle(.roundedBorder) .onSubmit(create) if let error { Text(error).font(.system(size: 12)).foregroundStyle(.red) } Button(action: create) { if busy { ProgressView().controlSize(.small).frame(maxWidth: .infinity) } else { Text("Create invite").frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) } } .padding(14) .frame(width: 340) .tint(accent) } // Theme accent resolved for light/dark (see MembersSheet.accent). @AppStorage("nativeTheme") private var themeName = "classic" @Environment(\.colorScheme) private var scheme private var accent: Color { ChatTheme.named(themeName).accent.resolved(scheme) } private func create() { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, !busy else { return } busy = true error = nil Task { let r = await store.inviteSomeone(name: trimmed) busy = false if let l = r.link { link = l } else { error = r.error ?? "Couldn't create the invite." } } } } struct RenameGroupSheet: View { let store: ThreadStore let thread: ChatThread @Environment(\.dismiss) private var dismiss @State private var name = "" @State private var busy = false var body: some View { VStack(alignment: .leading, spacing: 10) { Text("Rename Group").font(.system(size: 14, weight: .semibold)) TextField("Group name", text: $name) .textFieldStyle(.roundedBorder) .onSubmit(save) HStack { Spacer() Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) Button(action: save) { if busy { ProgressView().controlSize(.small) } else { Text("Rename") } } .buttonStyle(.borderedProminent) .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || busy) } } .padding(14) .frame(width: 300) .onAppear { name = thread.title } .tint(accent) } // Theme accent resolved for light/dark (see MembersSheet.accent). @AppStorage("nativeTheme") private var themeName = "classic" @Environment(\.colorScheme) private var scheme private var accent: Color { ChatTheme.named(themeName).accent.resolved(scheme) } private func save() { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } busy = true Task { _ = await store.renameGroup(thread, to: trimmed) busy = false dismiss() } } }