eleven / messenger Public

ios: group management — View Members (add people) + Rename Group (#385)

Mac parity for the known iOS gap: the chat's ⋯ menu (groups only) gains View
Members and Rename Group. The members sheet lists the group, offers everyone
else as ADD PEOPLE — only people who have ACCEPTED their invite (adding wraps
the thread key to their public key; a pending invitee has none — the same
filter the web and the Mac sheet apply) — with a prominent bottom "Add N to
Group". Rename is a compact detent sheet. All key handling is the shared
ThreadStore (addMembers / renameGroup); the server sees neither names nor keys.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell ca0b8b5a01f22430455d6836911c9c7867ecf9e5 parent 52ffca2
2 files changed, +180 −0
  • ios/LChat/Views/GroupSheets.swift +162 −0
  • ios/LChat/Views/NativeChatScreen.swift +18 −0
diff --git a/ios/LChat/Views/GroupSheets.swift b/ios/LChat/Views/GroupSheets.swift
new file mode 100644
index 0000000..8ed8ee7
--- /dev/null
+++ b/ios/LChat/Views/GroupSheets.swift
@@ -0,0 +1,162 @@
+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
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var selected: Set<String> = []
+ @State private var busy = false
+ @State private var note: 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() } }
+ }
+ .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 {
+ guard selectable else { return }
+ if selected.contains(person.user_id) {
+ selected.remove(person.user_id)
+ } else {
+ selected.insert(person.user_id)
+ }
+ } label: {
+ HStack(spacing: 10) {
+ Avatar(name: person.label, size: 32, source: store.avatarSource(for: person))
+ Text(person.label).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()
+ }
+ }
+}
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 1751f31..4123c4c 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -122,6 +122,9 @@ struct NativeChatScreen: View {
// Whether the bottom sentinel is on screen — gates the scroll pins.
@State private var bottomVisible = false
+ @State private var showMembers = false
+ @State private var showRename = false
+
private var isPinned: Bool {
store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned
}
@@ -168,6 +171,19 @@ struct NativeChatScreen: View {
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
+ // Group management (Mac parity: View Members / Rename).
+ if isGroup {
+ Button {
+ showMembers = true
+ } label: {
+ Label("View Members", systemImage: "person.2")
+ }
+ Button {
+ showRename = true
+ } label: {
+ Label("Rename Group", systemImage: "pencil")
+ }
+ }
Button {
store.setPinned(thread, !isPinned)
} label: {
@@ -192,6 +208,8 @@ struct NativeChatScreen: View {
.accessibilityLabel("Chat options")
}
}
+ .sheet(isPresented: $showMembers) { MembersSheet(store: store, thread: thread) }
+ .sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) }
.onAppear {
model.start()
LastOpenedChat.note(origin: instance.origin, thread: thread.id,