import LChatCore import SwiftUI // 🤖 Group management (iOS): the members list with add-people, and rename — // the Mac's GroupSheets in the touch idiom (sheets with detents, a prominent // bottom action). Key handling is identical and lives in ThreadStore: adding // someone wraps the EXISTING thread key to their public key; renaming // re-encrypts {name} under the thread key. The server sees neither. 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 tapped 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 } } } // Only people who have ACCEPTED their invite: adding someone wraps the // thread key to their public key, and a pending invitee has none — the // web and the Mac sheet apply the same filter. private var others: [RosterEntry] { store.roster.filter { !thread.members.contains($0.user_id) && !$0.label.isEmpty && !$0.public_key.isEmpty } } var body: some View { NavigationStack { List { Section("Members") { ForEach(members, id: \.user_id) { person in row(person, selectable: false) } } if !others.isEmpty { Section("Add People") { ForEach(others, id: \.user_id) { person in row(person, selectable: true) } } } if let note { Text(note).font(.footnote).foregroundStyle(.secondary) .listRowBackground(Color.clear) } } .navigationTitle("Members") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .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 Avatar(name: store.displayName(for: e), size: 72, source: store.avatarSource(for: e)) } .presentationDetents([.medium]) } .safeAreaInset(edge: .bottom) { if !selected.isEmpty { 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: { Group { if busy { ProgressView().tint(.white) } else { Text(selected.count == 1 ? "Add to Group" : "Add \(selected.count) to Group") .fontWeight(.semibold) } } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(busy) .padding(.horizontal) .padding(.bottom, 6) } } } .presentationDetents([.medium, .large]) } 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: 10) { Avatar(name: store.displayName(for: person), size: 32, source: store.avatarSource(for: person)) Text(store.displayName(for: person)).foregroundStyle(.primary).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(Color.accentColor) : AnyShapeStyle(.tertiary)) } } } } } struct RenameGroupSheet: View { let store: ThreadStore let thread: ChatThread @Environment(\.dismiss) private var dismiss @State private var name = "" @State private var busy = false @FocusState private var focused: Bool var body: some View { NavigationStack { Form { TextField("Group name", text: $name) .focused($focused) .onSubmit(save) } .navigationTitle("Rename Group") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if busy { ProgressView() } else { Button("Rename", action: save) .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) } } } } .presentationDetents([.height(180)]) .onAppear { name = thread.title focused = true } } private func save() { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, !busy else { return } busy = true Task { _ = await store.renameGroup(thread, to: trimmed) busy = false dismiss() } } }