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 (native): the members list with add-people, and rename.
5// Key handling matches the web exactly β€” adding someone wraps the EXISTING
6// thread key to their public key; renaming re-encrypts {name} under the
7// thread key. The server never sees either in the clear.
8struct MembersSheet: View {
9 let store: ThreadStore
10 let thread: ChatThread
11 // A profile card's "Message them" β€” the presenter navigates; this sheet
12 // just gets out of the way first.
13 var onOpenThread: ((String) -> Void)?
14 @Environment(\.dismiss) private var dismiss
15
16 @State private var selected: Set<String> = []
17 @State private var busy = false
18 @State private var note: String?
19 // A clicked member's profile card β€” rename them for yourself there (#577).
20 @State private var profile: ProfileTarget?
21 struct ProfileTarget: Identifiable { let id: String }
22
23 private var members: [RosterEntry] {
24 thread.members.compactMap { id in store.roster.first { $0.user_id == id } }
25 }
26 private var others: [RosterEntry] {
27 // Only people who have ACCEPTED their invite: adding someone wraps the
28 // thread key to their public key, so an entry without one (invite minted,
29 // not yet redeemed) can't be added β€” the web applies the same filter.
30 store.roster.filter {
31 !thread.members.contains($0.user_id) && !$0.label.isEmpty && !$0.public_key.isEmpty
32 }
33 }
34
35 var body: some View {
36 VStack(alignment: .leading, spacing: 10) {
37 HStack {
38 Text("Members").font(.system(size: 14, weight: .semibold))
39 Spacer()
40 Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
41 }
42 ScrollView {
43 VStack(alignment: .leading, spacing: 1) {
44 ForEach(members, id: \.user_id) { person in
45 row(person, selectable: false)
46 }
47 if !others.isEmpty {
48 Text("ADD PEOPLE")
49 .font(.system(size: 10, weight: .semibold)).kerning(0.5)
50 .foregroundStyle(.tertiary)
51 .padding(EdgeInsets(top: 12, leading: 8, bottom: 2, trailing: 0))
52 ForEach(others, id: \.user_id) { person in
53 row(person, selectable: true)
54 }
55 }
56 }
57 }
58 .frame(maxHeight: 320)
59 if let note {
60 Text(note).font(.system(size: 12)).foregroundStyle(.secondary)
61 }
62 if !selected.isEmpty {
63 // Mac-assed action placement: a compact, bold push button at the
64 // sheet's bottom-right (dialog idiom), not an iOS full-width bar.
65 HStack {
66 Spacer()
67 Button {
68 busy = true
69 Task {
70 let added = await store.addMembers(thread, userIDs: Array(selected))
71 busy = false
72 selected = []
73 note = added.isEmpty ? "Couldn't add anyone β€” try again."
74 : "Added \(added.count)."
75 }
76 } label: {
77 if busy {
78 ProgressView().controlSize(.small)
79 } else {
80 Text(selected.count == 1 ? "Add to Group" : "Add \(selected.count) to Group")
81 .fontWeight(.semibold)
82 }
83 }
84 .buttonStyle(.borderedProminent)
85 .keyboardShortcut(.defaultAction)
86 .disabled(busy)
87 }
88 }
89 }
90 .padding(14)
91 .frame(width: 300)
92 .tint(accent)
93 .sheet(item: $profile) { target in
94 MemberProfileSheet(store: store, uid: target.id,
95 onOpenThread: onOpenThread.map { open in
96 { id in profile = nil; dismiss(); open(id) }
97 }) { e in
98 NativeChatView.ThreadAvatar(title: store.displayName(for: e),
99 source: store.avatarSource(for: e), size: 72)
100 }
101 }
102 }
103
104 // The user's chosen theme accent, resolved for light/dark β€” the hard-coded
105 // classic-light purple read as a random colour in dark mode / other themes.
106 @AppStorage("nativeTheme") private var themeName = "classic"
107 @Environment(\.colorScheme) private var scheme
108 private var accent: Color { ChatTheme.named(themeName).accent.resolved(scheme) }
109
110 private func row(_ person: RosterEntry, selectable: Bool) -> some View {
111 Button {
112 if selectable {
113 if selected.contains(person.user_id) {
114 selected.remove(person.user_id)
115 } else {
116 selected.insert(person.user_id)
117 }
118 } else if person.user_id != store.myUserId {
119 // A member's row opens their profile card β€” where "call them
120 // something else" and the rename notice live (#577).
121 profile = ProfileTarget(id: person.user_id)
122 }
123 } label: {
124 HStack(spacing: 8) {
125 NativeChatView.ThreadAvatar(title: store.displayName(for: person),
126 source: store.avatarSource(for: person), size: 26)
127 Text(store.displayName(for: person)).font(.system(size: 13, weight: .medium)).lineLimit(1)
128 if person.isAgent { AgentBadge(person.isAI ? "AI" : "BOT") }
129 Spacer()
130 if selectable {
131 Image(systemName: selected.contains(person.user_id)
132 ? "checkmark.circle.fill" : "circle")
133 .foregroundStyle(selected.contains(person.user_id)
134 ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
135 }
136 }
137 .padding(.horizontal, 8)
138 .padding(.vertical, 5)
139 .frame(maxWidth: .infinity, alignment: .leading)
140 .contentShape(Rectangle())
141 }
142 .buttonStyle(.plain)
143 }
144}
145
146// Invite a brand-new person straight into a DM (the web's inviteSomeone):
147// type their name, get the one-time join link (its #fragment carries the
148// secret β€” never sent to the server).
149struct InviteSomeoneSheet: View {
150 let store: ThreadStore
151 @Environment(\.dismiss) private var dismiss
152
153 @State private var name = ""
154 @State private var busy = false
155 @State private var error: String?
156 @State private var link: String?
157 @State private var copied = false
158
159 var body: some View {
160 VStack(alignment: .leading, spacing: 10) {
161 HStack {
162 Text("Invite someone").font(.system(size: 14, weight: .semibold))
163 Spacer()
164 Button(link == nil ? "Cancel" : "Done") { dismiss() }
165 .keyboardShortcut(.cancelAction)
166 }
167 if let link {
168 Text("Send this link to \(name.trimmingCharacters(in: .whitespaces)) β€” it's their one-time key into your chat.")
169 .font(.system(size: 12)).foregroundStyle(.secondary)
170 Text(link)
171 .font(.system(size: 11).monospaced())
172 .lineLimit(3)
173 .textSelection(.enabled)
174 .padding(8)
175 .frame(maxWidth: .infinity, alignment: .leading)
176 .background(RoundedRectangle(cornerRadius: 8).fill(.primary.opacity(0.05)))
177 Button {
178 NSPasteboard.general.clearContents()
179 NSPasteboard.general.setString(link, forType: .string)
180 copied = true
181 } label: {
182 Label(copied ? "Copied" : "Copy link",
183 systemImage: copied ? "checkmark" : "doc.on.doc")
184 .frame(maxWidth: .infinity)
185 }
186 .buttonStyle(.borderedProminent)
187 } else {
188 TextField("Their name", text: $name)
189 .textFieldStyle(.roundedBorder)
190 .onSubmit(create)
191 if let error {
192 Text(error).font(.system(size: 12)).foregroundStyle(.red)
193 }
194 Button(action: create) {
195 if busy {
196 ProgressView().controlSize(.small).frame(maxWidth: .infinity)
197 } else {
198 Text("Create invite").frame(maxWidth: .infinity)
199 }
200 }
201 .buttonStyle(.borderedProminent)
202 .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
203 }
204 }
205 .padding(14)
206 .frame(width: 340)
207 .tint(accent)
208 }
209
210 // Theme accent resolved for light/dark (see MembersSheet.accent).
211 @AppStorage("nativeTheme") private var themeName = "classic"
212 @Environment(\.colorScheme) private var scheme
213 private var accent: Color { ChatTheme.named(themeName).accent.resolved(scheme) }
214
215 private func create() {
216 let trimmed = name.trimmingCharacters(in: .whitespaces)
217 guard !trimmed.isEmpty, !busy else { return }
218 busy = true
219 error = nil
220 Task {
221 let r = await store.inviteSomeone(name: trimmed)
222 busy = false
223 if let l = r.link { link = l } else { error = r.error ?? "Couldn't create the invite." }
224 }
225 }
226}
227
228struct RenameGroupSheet: View {
229 let store: ThreadStore
230 let thread: ChatThread
231 @Environment(\.dismiss) private var dismiss
232
233 @State private var name = ""
234 @State private var busy = false
235
236 var body: some View {
237 VStack(alignment: .leading, spacing: 10) {
238 Text("Rename Group").font(.system(size: 14, weight: .semibold))
239 TextField("Group name", text: $name)
240 .textFieldStyle(.roundedBorder)
241 .onSubmit(save)
242 HStack {
243 Spacer()
244 Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
245 Button(action: save) {
246 if busy { ProgressView().controlSize(.small) } else { Text("Rename") }
247 }
248 .buttonStyle(.borderedProminent)
249 .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || busy)
250 }
251 }
252 .padding(14)
253 .frame(width: 300)
254 .onAppear { name = thread.title }
255 .tint(accent)
256 }
257
258 // Theme accent resolved for light/dark (see MembersSheet.accent).
259 @AppStorage("nativeTheme") private var themeName = "classic"
260 @Environment(\.colorScheme) private var scheme
261 private var accent: Color { ChatTheme.named(themeName).accent.resolved(scheme) }
262
263 private func save() {
264 let trimmed = name.trimmingCharacters(in: .whitespaces)
265 guard !trimmed.isEmpty else { return }
266 busy = true
267 Task {
268 _ = await store.renameGroup(thread, to: trimmed)
269 busy = false
270 dismiss()
271 }
272 }
273}
274