eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3
4// πŸ€– Group management (iOS): the members list with add-people, and rename β€”
5// the Mac's GroupSheets in the touch idiom (sheets with detents, a prominent
6// bottom action). Key handling is identical and lives in ThreadStore: adding
7// someone wraps the EXISTING thread key to their public key; renaming
8// re-encrypts {name} under the thread key. The server sees neither.
9struct MembersSheet: View {
10 let store: ThreadStore
11 let thread: ChatThread
12 // A profile card's "Message them" β€” the presenter navigates; this sheet
13 // just gets out of the way first.
14 var onOpenThread: ((String) -> Void)?
15 @Environment(\.dismiss) private var dismiss
16
17 @State private var selected: Set<String> = []
18 @State private var busy = false
19 @State private var note: String?
20 // A tapped member's profile card β€” rename them for yourself there (#577).
21 @State private var profile: ProfileTarget?
22 struct ProfileTarget: Identifiable { let id: String }
23
24 private var members: [RosterEntry] {
25 thread.members.compactMap { id in store.roster.first { $0.user_id == id } }
26 }
27 // Only people who have ACCEPTED their invite: adding someone wraps the
28 // thread key to their public key, and a pending invitee has none β€” the
29 // web and the Mac sheet apply the same filter.
30 private var others: [RosterEntry] {
31 store.roster.filter {
32 !thread.members.contains($0.user_id) && !$0.label.isEmpty && !$0.public_key.isEmpty
33 }
34 }
35
36 var body: some View {
37 NavigationStack {
38 List {
39 Section("Members") {
40 ForEach(members, id: \.user_id) { person in
41 row(person, selectable: false)
42 }
43 }
44 if !others.isEmpty {
45 Section("Add People") {
46 ForEach(others, id: \.user_id) { person in
47 row(person, selectable: true)
48 }
49 }
50 }
51 if let note {
52 Text(note).font(.footnote).foregroundStyle(.secondary)
53 .listRowBackground(Color.clear)
54 }
55 }
56 .navigationTitle("Members")
57 .navigationBarTitleDisplayMode(.inline)
58 .toolbar {
59 ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
60 }
61 .sheet(item: $profile) { target in
62 MemberProfileSheet(store: store, uid: target.id,
63 onOpenThread: onOpenThread.map { open in
64 { id in profile = nil; dismiss(); open(id) }
65 }) { e in
66 Avatar(name: store.displayName(for: e), size: 72,
67 source: store.avatarSource(for: e))
68 }
69 .presentationDetents([.medium])
70 }
71 .safeAreaInset(edge: .bottom) {
72 if !selected.isEmpty {
73 Button {
74 busy = true
75 Task {
76 let added = await store.addMembers(thread, userIDs: Array(selected))
77 busy = false
78 selected = []
79 note = added.isEmpty ? "Couldn't add anyone β€” try again."
80 : "Added \(added.count)."
81 }
82 } label: {
83 Group {
84 if busy {
85 ProgressView().tint(.white)
86 } else {
87 Text(selected.count == 1 ? "Add to Group"
88 : "Add \(selected.count) to Group")
89 .fontWeight(.semibold)
90 }
91 }
92 .frame(maxWidth: .infinity)
93 }
94 .buttonStyle(.borderedProminent)
95 .controlSize(.large)
96 .disabled(busy)
97 .padding(.horizontal)
98 .padding(.bottom, 6)
99 }
100 }
101 }
102 .presentationDetents([.medium, .large])
103 }
104
105 private func row(_ person: RosterEntry, selectable: Bool) -> some View {
106 Button {
107 if selectable {
108 if selected.contains(person.user_id) {
109 selected.remove(person.user_id)
110 } else {
111 selected.insert(person.user_id)
112 }
113 } else if person.user_id != store.myUserId {
114 // A member's row opens their profile card β€” where "call them
115 // something else" and the rename notice live (#577).
116 profile = ProfileTarget(id: person.user_id)
117 }
118 } label: {
119 HStack(spacing: 10) {
120 Avatar(name: store.displayName(for: person), size: 32,
121 source: store.avatarSource(for: person))
122 Text(store.displayName(for: person)).foregroundStyle(.primary).lineLimit(1)
123 if person.isAgent { AgentBadge(person.isAI ? "AI" : "BOT") }
124 Spacer()
125 if selectable {
126 Image(systemName: selected.contains(person.user_id)
127 ? "checkmark.circle.fill" : "circle")
128 .foregroundStyle(selected.contains(person.user_id)
129 ? AnyShapeStyle(Color.accentColor)
130 : AnyShapeStyle(.tertiary))
131 }
132 }
133 }
134 }
135}
136
137struct RenameGroupSheet: View {
138 let store: ThreadStore
139 let thread: ChatThread
140 @Environment(\.dismiss) private var dismiss
141
142 @State private var name = ""
143 @State private var busy = false
144 @FocusState private var focused: Bool
145
146 var body: some View {
147 NavigationStack {
148 Form {
149 TextField("Group name", text: $name)
150 .focused($focused)
151 .onSubmit(save)
152 }
153 .navigationTitle("Rename Group")
154 .navigationBarTitleDisplayMode(.inline)
155 .toolbar {
156 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
157 ToolbarItem(placement: .confirmationAction) {
158 if busy {
159 ProgressView()
160 } else {
161 Button("Rename", action: save)
162 .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
163 }
164 }
165 }
166 }
167 .presentationDetents([.height(180)])
168 .onAppear {
169 name = thread.title
170 focused = true
171 }
172 }
173
174 private func save() {
175 let trimmed = name.trimmingCharacters(in: .whitespaces)
176 guard !trimmed.isEmpty, !busy else { return }
177 busy = true
178 Task {
179 _ = await store.renameGroup(thread, to: trimmed)
180 busy = false
181 dismiss()
182 }
183 }
184}
185