| 1 | import 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. |
| 8 | public 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 | |