eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2
3// A member's profile card — the native twin of the web's viewProfile
4// (shell.js): photo, the name I show for them, their published name when I
5// call them something else, the keep/adopt choice after they rename, and
6// "Call them something else" (#577). Shared by iOS and Mac; each app presents
7// it as a sheet/popover from a member row or a DM header.
8public struct MemberProfileSheet<AvatarView: View>: View {
9 @ObservedObject var store: ThreadStore
10 let uid: String
11 // Message them (the web card's action): the presenter supplies the
12 // navigation to a thread id — nil when the card was reached from THEIR
13 // DM (where the button is noise) or from a surface that can't hop.
14 private let onOpenThread: ((String) -> Void)?
15 // The avatar slot: each app supplies its own avatar view (they live at the
16 // app layer, not in LChatCore).
17 private let avatar: (RosterEntry) -> AvatarView
18 @Environment(\.dismiss) private var dismiss
19
20 @State private var editing = false
21 @State private var draft = ""
22 @State private var syncing = false
23 @State private var showReport = false
24 @State private var msgBusy = false
25 @State private var msgNote: String?
26 @State private var blockBusy = false
27 @State private var blockFailed = false
28 @State private var removeBusy = false
29 @State private var removeFailed = false
30 @State private var confirmRemove = false
31 @State private var confirmBan = false
32 @FocusState private var draftFocused: Bool
33
34 public init(store: ThreadStore, uid: String,
35 onOpenThread: ((String) -> Void)? = nil,
36 @ViewBuilder avatar: @escaping (RosterEntry) -> AvatarView) {
37 self.store = store
38 self.uid = uid
39 self.onOpenThread = onOpenThread
40 self.avatar = avatar
41 }
42
43 private var entry: RosterEntry? { store.roster.first { $0.user_id == uid } }
44 private var shownName: String { store.displayName(for: uid) }
45 private var publishedName: String { entry?.label ?? "" }
46 private var isPerson: Bool { !(entry?.isAgent ?? false) && uid != store.myUserId }
47
48 public var body: some View {
49 VStack(spacing: 10) {
50 if let entry {
51 avatar(entry)
52 }
53 HStack(spacing: 6) {
54 Text(shownName.isEmpty ? "Someone" : shownName)
55 .font(.title2.weight(.semibold))
56 if let entry, entry.isAgent { AgentBadge(entry.isAI ? "AI" : "BOT") }
57 }
58
59 if isPerson {
60 if editing {
61 editor
62 } else {
63 nickArea
64 }
65 }
66
67 // Message them — the web card's action when it was reached from
68 // somewhere other than their DM (the member sheet, a tapped
69 // @mention); on someone you've blocked it's a contradiction
70 // (Unblock is below). Anyone else, bots included. Their DM door
71 // may be closed (#578) — say so in place, never a silent failure.
72 if let onOpenThread, uid != store.myUserId, !store.blockedPeople.contains(uid) {
73 if let note = msgNote {
74 // Inline, occupying layout space — never a floating toast.
75 Text(note)
76 .font(.footnote)
77 .foregroundStyle(.secondary)
78 .padding(.top, 4)
79 } else {
80 Button("Message \(shownName.isEmpty ? "them" : shownName)") {
81 msgBusy = true
82 Task {
83 if let id = await store.openOrCreateDM(uid: uid) {
84 dismiss()
85 onOpenThread(id)
86 } else {
87 // The store's own copy — "They're not
88 // accepting messages" on a closed DM door.
89 msgBusy = false
90 msgNote = store.loadError ?? "Couldn't start the chat. Try again."
91 }
92 }
93 }
94 .buttonStyle(.borderedProminent)
95 .disabled(msgBusy)
96 .padding(.top, 4)
97 }
98 }
99
100 // Mute them (#578) — their messages in shared chats fold to a
101 // quiet stub and never notify. Private: they don't find out, and
102 // the server can't read the muted set either. Anyone but yourself
103 // (bots included), matching the web's profile card.
104 if uid != store.myUserId {
105 Button(store.mutedPeople.contains(uid)
106 ? "Unmute \(shownName.isEmpty ? "them" : shownName)"
107 : "Mute \(shownName.isEmpty ? "them" : shownName)") {
108 let on = !store.mutedPeople.contains(uid)
109 Task { await store.setPersonMuted(uid: uid, muted: on) }
110 }
111 .buttonStyle(.borderless)
112 }
113
114 // Report them (#578) — the same sheet a message's Report action
115 // opens, minus the quoted message. People only (the web's
116 // !e.is_agent gate); a bot has no behaviour to adjudicate.
117 if isPerson {
118 Button("Report \(shownName.isEmpty ? "them" : shownName)") {
119 showReport = true
120 }
121 .buttonStyle(.borderless)
122 }
123
124 // Block them (#578): mute + end the DM + server-enforced no new
125 // contact — no new DMs, no adding each other to groups. They're
126 // never told. People only, like the web's profile card; the
127 // footnote says plainly what each direction does.
128 if isPerson {
129 let blocked = store.blockedPeople.contains(uid)
130 Button(blocked ? "Unblock \(shownName.isEmpty ? "them" : shownName)"
131 : "Block \(shownName.isEmpty ? "them" : shownName)",
132 role: blocked ? nil : .destructive) {
133 blockFailed = false
134 blockBusy = true
135 Task {
136 let ok = await store.setPersonBlocked(uid: uid, on: !blocked)
137 blockBusy = false
138 blockFailed = !ok
139 }
140 }
141 .buttonStyle(.borderless)
142 .disabled(blockBusy)
143 if blockFailed {
144 // Inline, occupying layout space — never a floating toast.
145 Text("Couldn't save — try again.")
146 .font(.footnote)
147 .foregroundStyle(.red)
148 } else {
149 Text(blocked
150 ? "Unblocking makes contact possible again — nothing reopens by itself."
151 : "Blocking mutes them, ends your direct messages, and stops new contact both ways. They won't be told.")
152 .font(.footnote)
153 .foregroundStyle(.secondary)
154 .multilineTextAlignment(.center)
155 .fixedSize(horizontal: false, vertical: true)
156 .frame(maxWidth: 280)
157 }
158 }
159
160 // The admin rungs (issue #578): the same Remove / Remove & ban the
161 // Admin app offers on a report, reachable straight from the card —
162 // an admin shouldn't need a report on file to act on a duplicate
163 // or unwanted account. People only, matching the web card; the
164 // server re-checks the admin role on both endpoints.
165 if isPerson && store.isAdmin {
166 let who = shownName.isEmpty ? "them" : shownName
167 Button("Remove \(who)", role: .destructive) {
168 confirmRemove = true
169 }
170 .buttonStyle(.borderless)
171 .disabled(removeBusy)
172 Button("Remove & ban \(who)", role: .destructive) {
173 confirmBan = true
174 }
175 .buttonStyle(.borderless)
176 .disabled(removeBusy)
177 if removeFailed {
178 // Inline, occupying layout space — never a floating toast.
179 Text("Couldn't remove them — try again.")
180 .font(.footnote)
181 .foregroundStyle(.red)
182 } else {
183 Text("Removing deletes their messages and access. Banning also stops them rejoining with any invite.")
184 .font(.footnote)
185 .foregroundStyle(.secondary)
186 .multilineTextAlignment(.center)
187 .fixedSize(horizontal: false, vertical: true)
188 .frame(maxWidth: 280)
189 }
190 }
191 }
192 .sheet(isPresented: $showReport) {
193 ReportSheet(store: store, uid: uid)
194 }
195 .confirmationDialog(
196 "Remove \(shownName.isEmpty ? "them" : shownName) from \(store.instanceName.isEmpty ? "this space" : store.instanceName)",
197 isPresented: $confirmRemove, titleVisibility: .visible
198 ) {
199 Button("Remove \(shownName.isEmpty ? "them" : shownName)", role: .destructive) { removeThem(ban: false) }
200 } message: {
201 Text("This completely removes them from the whole space — every chat, their messages, and their access. It can't be undone.")
202 }
203 .confirmationDialog(
204 "Ban \(shownName.isEmpty ? "them" : shownName)? They're removed from the whole space and can't rejoin with any invite. This can't be undone.",
205 isPresented: $confirmBan, titleVisibility: .visible
206 ) {
207 Button("Remove & ban", role: .destructive) { removeThem(ban: true) }
208 }
209 .padding(24)
210 .padding(.top, 8) // room for the Done control above the avatar
211 .frame(minWidth: 280)
212 // A sheet needs an explicit way out — macOS has no swipe-down and no
213 // click-outside, and Esc only works when a button claims cancelAction.
214 // While the rename editor is up, ITS Cancel owns Esc (cancel the edit,
215 // not the card), so Done yields the shortcut.
216 .overlay(alignment: .topTrailing) {
217 Button("Done") { dismiss() }
218 .buttonStyle(.borderless)
219 .keyboardShortcut(editing ? nil : .cancelAction)
220 .padding(10)
221 .accessibilityLabel("Close profile")
222 }
223 }
224
225 // The per-viewer names block: the pending keep/adopt choice, the
226 // "Goes by" line, and the rename entry point.
227 @ViewBuilder private var nickArea: some View {
228 if let notice = store.renameNotice(for: uid) {
229 VStack(spacing: 8) {
230 (Text(notice.from).bold() + Text(" now goes by ") + Text(notice.to).bold())
231 .font(.subheadline)
232 HStack(spacing: 10) {
233 Button("Use \(notice.to)") { resolve(keep: false) }
234 Button("Keep \(notice.from)") { resolve(keep: true) }
235 }
236 .buttonStyle(.bordered)
237 }
238 } else if let nick = store.nicknames[uid], !nick.name.isEmpty,
239 !publishedName.isEmpty, publishedName != nick.name {
240 Text("Goes by \(publishedName)")
241 .font(.subheadline)
242 .foregroundStyle(.secondary)
243 }
244
245 Button("Change the name you see") {
246 draft = shownName
247 editing = true
248 draftFocused = true
249 }
250 .buttonStyle(.borderless)
251
252 if syncing {
253 Text("Couldn't reach the server just now — saved here, still syncing.")
254 .font(.footnote)
255 .foregroundStyle(.secondary)
256 }
257 }
258
259 @ViewBuilder private var editor: some View {
260 TextField(publishedName, text: $draft)
261 .textFieldStyle(.roundedBorder)
262 .multilineTextAlignment(.center)
263 .frame(maxWidth: 240)
264 .focused($draftFocused)
265 .accessibilityLabel("Your name for them")
266 .onSubmit { save() }
267 HStack(spacing: 10) {
268 Button("Save") { save() }
269 .buttonStyle(.borderedProminent)
270 Button("Cancel") { editing = false }
271 .buttonStyle(.bordered)
272 .keyboardShortcut(.cancelAction) // Esc cancels the edit, not the card
273 }
274 }
275
276 private func save() {
277 let v = draft.trimmingCharacters(in: .whitespacesAndNewlines)
278 editing = false
279 Task {
280 // Typing their published name back = no override to keep.
281 let ok = await store.setNickname(uid: uid, name: v == publishedName ? "" : v)
282 syncing = !ok
283 }
284 }
285
286 private func resolve(keep: Bool) {
287 Task {
288 let ok = await store.resolveRename(uid: uid, keep: keep)
289 syncing = !ok
290 }
291 }
292
293 private func removeThem(ban: Bool) {
294 removeFailed = false
295 removeBusy = true
296 Task {
297 let ok = await store.removeFromSpace(uid: uid, ban: ban)
298 removeBusy = false
299 removeFailed = !ok
300 if ok { dismiss() } // they're gone; the card has nothing to show
301 }
302 }
303}
304