eleven / messenger Public

Admin Remove / Remove & ban on the member profile card (#1263)

* web+native: admin Remove / Remove & ban on the member profile card

The remove/ban endpoints and the Admin app's report-queue actions already
existed; this surfaces the same two rungs directly on a member's profile
card for admins — no report needed. Web: shell.js's bridge verbs extracted
to named functions and registered on the verbs seam; groupactions.js adds
the admin-gated buttons with the Admin app's confirm copy. Native: shared
MemberProfileSheet (iOS+Mac) gains the same buttons behind store.isAdmin,
via LChatAPI.adminRemoveUser + ThreadStore.removeFromSpace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* status: ssh ledger — why-entry for the remove-member canary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: DM kebab Remove rung + drop the pill border on danger menu rows

The kebab is where people look (the Block precedent), so admins get a
'Remove <name> from space' item there too, opening the same profile card
whose buttons carry the confirms. button.danger's pill border (from the
moderation stack) was leaking onto flat .menu-item / .msg-sheet-item rows —
danger rows read by text colour alone now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web+native: Remove rung says just 'Remove <name>'; a dialog carries the whole-space warning

Paul's review: the kebab item reads 'Remove Dreo'; confirming happens in a
proper dialog headed 'Remove Dreo from <space name>' (document.title on web,
store.instanceName on native) warning it removes them from the whole space.
The profile card's Remove routes through the same dialog; ban keeps its
confirm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web+native: removing someone closes your DM with them

Post-removal their DM is a husk — hide it like Close chat (server-synced,
leaves the pane if it's on screen) on both remove and ban paths; closeChat
refactored into an id-taking closeThread. Native mirrors via setHidden
before the refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1a4e3feefac86952456223837e296da36187b11e parent 16722b2
9 files changed, +279 −37
  • changelog/2026-07-22-remove-from-profile.md +4 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +15 −0
  • ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift +61 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +18 −0
  • web/pages/index.html +1 −0
  • web/static/groupactions.js +121 −10
  • web/static/shell.js +48 −25
  • web/static/style.css +4 −2
  • web/status/ssh/2026-07-22-remove-member-canary.json +7 −0
diff --git a/changelog/2026-07-22-remove-from-profile.md b/changelog/2026-07-22-remove-from-profile.md
new file mode 100644
index 0000000..08c4de8
--- /dev/null
+++ b/changelog/2026-07-22-remove-from-profile.md
@@ -0,0 +1,4 @@
+🤖 Space admins can now remove someone directly from their profile card —
+open their profile and choose "Remove from this space", or "Remove & ban" to
+also stop them rejoining with any invite. Before, removal was only offered
+on a filed report in the Admin app.
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index de0f5be..237272d 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -813,6 +813,21 @@ public struct LChatAPI {
return (resp as? HTTPURLResponse)?.statusCode == 200
}
+ /// Remove a member from the space, optionally banning their key from ever
+ /// re-registering (issue #578) — the same admin-gated endpoints the web
+ /// Admin app's report queue uses. Bearer = this device's token; the
+ /// server checks the member behind it holds the admin role.
+ public func adminRemoveUser(uid: String, ban: Bool) async -> Bool {
+ let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid
+ guard let url = URL(string: "api/admin/users/\(escaped)/\(ban ? "ban" : "remove")",
+ relativeTo: baseURL) else { return false }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ guard let (_, resp) = try? await session.data(for: req) else { return false }
+ return (resp as? HTTPURLResponse)?.statusCode == 200
+ }
+
/// Add a member to a thread: their copy of the thread key, wrapped to
/// their public key (the server stores a blob it can't open).
@discardableResult
diff --git a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift
index 74ceb71..1e65037 100644
--- a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift
+++ b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift
@@ -19,6 +19,10 @@ public struct MemberProfileSheet<AvatarView: View>: View {
@State private var showReport = false
@State private var blockBusy = false
@State private var blockFailed = false
+ @State private var removeBusy = false
+ @State private var removeFailed = false
+ @State private var confirmRemove = false
+ @State private var confirmBan = false
@FocusState private var draftFocused: Bool
public init(store: ThreadStore, uid: String,
@@ -111,10 +115,56 @@ public struct MemberProfileSheet<AvatarView: View>: View {
.frame(maxWidth: 280)
}
}
+
+ // The admin rungs (issue #578): the same Remove / Remove & ban the
+ // Admin app offers on a report, reachable straight from the card —
+ // an admin shouldn't need a report on file to act on a duplicate
+ // or unwanted account. People only, matching the web card; the
+ // server re-checks the admin role on both endpoints.
+ if isPerson && store.isAdmin {
+ let who = shownName.isEmpty ? "them" : shownName
+ Button("Remove \(who)", role: .destructive) {
+ confirmRemove = true
+ }
+ .buttonStyle(.borderless)
+ .disabled(removeBusy)
+ Button("Remove & ban \(who)", role: .destructive) {
+ confirmBan = true
+ }
+ .buttonStyle(.borderless)
+ .disabled(removeBusy)
+ if removeFailed {
+ // Inline, occupying layout space — never a floating toast.
+ Text("Couldn't remove them — try again.")
+ .font(.footnote)
+ .foregroundStyle(.red)
+ } else {
+ Text("Removing deletes their messages and access. Banning also stops them rejoining with any invite.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: 280)
+ }
+ }
}
.sheet(isPresented: $showReport) {
ReportSheet(store: store, uid: uid)
}
+ .confirmationDialog(
+ "Remove \(shownName.isEmpty ? "them" : shownName) from \(store.instanceName.isEmpty ? "this space" : store.instanceName)",
+ isPresented: $confirmRemove, titleVisibility: .visible
+ ) {
+ Button("Remove \(shownName.isEmpty ? "them" : shownName)", role: .destructive) { removeThem(ban: false) }
+ } message: {
+ Text("This completely removes them from the whole space — every chat, their messages, and their access. It can't be undone.")
+ }
+ .confirmationDialog(
+ "Ban \(shownName.isEmpty ? "them" : shownName)? They're removed from the whole space and can't rejoin with any invite. This can't be undone.",
+ isPresented: $confirmBan, titleVisibility: .visible
+ ) {
+ Button("Remove & ban", role: .destructive) { removeThem(ban: true) }
+ }
.padding(24)
.padding(.top, 8) // room for the Done control above the avatar
.frame(minWidth: 280)
@@ -198,4 +248,15 @@ public struct MemberProfileSheet<AvatarView: View>: View {
syncing = !ok
}
}
+
+ private func removeThem(ban: Bool) {
+ removeFailed = false
+ removeBusy = true
+ Task {
+ let ok = await store.removeFromSpace(uid: uid, ban: ban)
+ removeBusy = false
+ removeFailed = !ok
+ if ok { dismiss() } // they're gone; the card has nothing to show
+ }
+ }
}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 3c157e6..eb4a095 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -897,6 +897,24 @@ public final class ThreadStore: ObservableObject {
return true
}
+ /// Remove a member from the space — or remove AND ban their key from
+ /// re-registering (issue #578). Admin only; the profile card gates on
+ /// isAdmin as a nicety and the server re-checks the role. Server-first,
+ /// then a full refresh so the roster and threads repaint without them.
+ @discardableResult
+ public func removeFromSpace(uid: String, ban: Bool) async -> Bool {
+ guard !uid.isEmpty, uid != myUserId, let api else { return false }
+ guard await api.adminRemoveUser(uid: uid, ban: ban) else { return false }
+ // Your DM with them (if any) is a husk now — close it like Close
+ // chat, so it drops off the list instead of lingering headed by a
+ // ghost (matches the web's remove dialog).
+ if let dm = threads.first(where: { $0.kind == "dm" && $0.members.contains(uid) }) {
+ setHidden(dm, true)
+ }
+ await refresh()
+ return true
+ }
+
// Pinned chats first, then most-recent activity — mirrors the web list.
nonisolated public static func order(_ a: ChatThread, _ b: ChatThread) -> Bool {
(a.pinned != b.pinned) ? a.pinned : a.lastAt > b.lastAt
diff --git a/web/pages/index.html b/web/pages/index.html
index 8e10d19..7b807e8 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -242,6 +242,7 @@
<button class="menu-item" id="thread-mute" role="menuitem">Mute chat</button>
<button class="menu-item" id="thread-close" role="menuitem">Close chat</button>
<button class="menu-item danger" id="thread-block" role="menuitem">Block</button>
+ <button class="menu-item danger" id="thread-remove" role="menuitem">Remove</button>
<button class="menu-item danger" id="thread-end" role="menuitem">End conversation</button>
<button class="menu-item danger" id="thread-leave" role="menuitem">Leave group</button>
</div>
diff --git a/web/static/groupactions.js b/web/static/groupactions.js
index 89f5998..07aa527 100644
--- a/web/static/groupactions.js
+++ b/web/static/groupactions.js
@@ -604,9 +604,113 @@ export function viewProfile(uid) {
blockRow.append(blockBtn, blockNote);
card.appendChild(blockRow);
}
+
+ // The admin rungs (issue #578): the same Remove / Remove & ban the Admin
+ // app offers on a report, reachable straight from the card — an admin
+ // shouldn't need a report on file to act on a duplicate or unwanted
+ // account. Showing them here is a nicety only; the server re-checks the
+ // admin role on both endpoints. People only — bots are managed from the
+ // admin console.
+ if (!e.is_agent && verbs.isAdmin()) {
+ const who = displayNameFor(nicknames[uid], e.label) || "them";
+ const adminRow = document.createElement("div");
+ adminRow.className = "row";
+ adminRow.style.cssText = "justify-content:center;margin-top:10px;flex-direction:column;align-items:center;gap:6px";
+ const adminNote = document.createElement("div");
+ adminNote.className = "field-note";
+ adminNote.style.textAlign = "center";
+ // A polite live region so a failure is announced without moving focus.
+ adminNote.setAttribute("role", "status");
+ adminNote.textContent = "Removing deletes their messages and access. Banning also stops them rejoining with any invite.";
+ const removeBtn = document.createElement("button");
+ removeBtn.className = "danger";
+ removeBtn.textContent = `Remove ${who}`;
+ // The dialog carries the whole-space warning; closing this card on
+ // success leaves nothing showing a member who no longer exists.
+ removeBtn.onclick = () => confirmRemoveFromSpace(uid, () => backdrop.remove());
+ const banBtn = document.createElement("button");
+ banBtn.className = "danger";
+ banBtn.textContent = `Remove & ban ${who}`;
+ banBtn.onclick = async () => {
+ if (!confirm(`Ban ${who}? They're removed from the whole space and can't rejoin with any invite. This can't be undone.`)) return;
+ banBtn.disabled = removeBtn.disabled = true;
+ if (await verbs.banFromSpace(uid)) {
+ // Same husk cleanup as the remove dialog: close your DM with them.
+ const dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(uid));
+ if (dm) closeThread(dm.id);
+ // Gone from the roster (the verb reloaded it) — repaint the list
+ // and any open thread so their rows/bubbles fold away.
+ backdrop.remove();
+ verbs.renderThreadList();
+ verbs.renderActiveSurfaces();
+ return;
+ }
+ banBtn.disabled = removeBtn.disabled = false;
+ adminNote.textContent = "Couldn't ban them — try again.";
+ };
+ adminRow.append(removeBtn, banBtn, adminNote);
+ card.appendChild(adminRow);
+ }
}
}
+// The remove-from-space dialog (issue #578): the heading names the person AND
+// the space, so an admin sees the blast radius before confirming — this is
+// whole-space removal, not leaving one chat. Opened from the DM kebab's
+// "Remove <name>" and the profile card's admin rung. document.title is the
+// one runtime source of the space's name (shell.js paintSpaceName). onDone
+// runs after a successful removal — the profile card passes its own close.
+export function confirmRemoveFromSpace(uid, onDone) {
+ const e = roster.find((x) => x.user_id === uid);
+ if (!e) return;
+ const who = displayNameFor(nicknames[uid], e.label) || "them";
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = `Remove ${who} from ${document.title || "this space"}`;
+ card.appendChild(h);
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.textContent = "This completely removes them from the whole space — every chat, their messages, and their access. It can't be undone.";
+ card.appendChild(p);
+ const note = document.createElement("div");
+ note.className = "field-note";
+ note.style.textAlign = "center";
+ // A polite live region so a failure is announced without moving focus.
+ note.setAttribute("role", "status");
+ const row = document.createElement("div");
+ row.className = "row";
+ row.style.cssText = "justify-content:center;gap:10px;margin-top:12px";
+ const go = document.createElement("button");
+ go.className = "danger";
+ go.textContent = `Remove ${who}`;
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => backdrop.remove();
+ go.onclick = async () => {
+ go.disabled = cancel.disabled = true;
+ if (await verbs.removeFromSpace(uid)) {
+ // Your DM with them (if any) is a husk now — close it like Close chat,
+ // so it drops off the list (and off screen) instead of lingering
+ // headed by a ghost.
+ const dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(uid));
+ if (dm) closeThread(dm.id);
+ // Gone from the roster (the verb reloaded it) — repaint the list and
+ // any open thread so their rows/bubbles fold away.
+ backdrop.remove();
+ verbs.renderThreadList();
+ verbs.renderActiveSurfaces();
+ if (onDone) onDone();
+ return;
+ }
+ go.disabled = cancel.disabled = false;
+ note.textContent = "Couldn't remove them — try again.";
+ };
+ row.append(go, cancel);
+ card.append(row, note);
+}
+
// A DM partner renamed themself while I hold my own name for them (#577): a
// slim inline row between the header and the feed — occupying layout space,
// never floating — offering the choice once. Clears itself when there's
@@ -803,23 +907,30 @@ async function removeMember(uid, name, backdrop) {
if (activeId === id) verbs.paintThreadHeader(id); // update the member count / title
}
-// Close (hide) the open chat: it drops off the list until there's new activity or
-// you find it via search. Per-user, synced across your devices.
-export async function closeChat() {
- const id = activeId;
- if (!id) return;
+// Close (hide) a chat by id: it drops off the list until there's new activity
+// or you find it via search. Per-user, synced across your devices. Leaves the
+// pane only when the closed chat is the one on screen — remove-from-space
+// closes a DM that may not be active.
+export function closeThread(id) {
hidden[id] = true;
fetch(`/api/threads/${encodeURIComponent(id)}/hidden`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: session.token, hidden: true }),
}).catch(() => {});
- setActiveId(null);
- localStorage.removeItem(ACTIVE_KEY); // don't reopen a closed chat on refresh
+ if (activeId === id) {
+ setActiveId(null);
+ localStorage.removeItem(ACTIVE_KEY); // don't reopen a closed chat on refresh
+ document.getElementById("thread-view").classList.add("hidden");
+ document.getElementById("thread-empty").classList.remove("hidden");
+ verbs.showList();
+ }
verbs.renderThreadList();
- document.getElementById("thread-view").classList.add("hidden");
- document.getElementById("thread-empty").classList.remove("hidden");
- verbs.showList();
+}
+
+// Close the open chat (the kebab's Close chat item).
+export async function closeChat() {
+ if (activeId) closeThread(activeId);
}
// End the open DM for both people (issue #578): it drops off both lists and
diff --git a/web/static/shell.js b/web/static/shell.js
index cea5a1e..7180917 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -144,7 +144,7 @@ import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-
import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf } from "/static/apps/chat.js";
import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js";
import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
-import { editGroup, openOrCreateDM, showMembers, viewProfile, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js";
+import { editGroup, openOrCreateDM, showMembers, viewProfile, confirmRemoveFromSpace, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js";
import { existingDM, showNewChat, showMembership, flash, showDiscover, showApps, createThread, pickerApps } from "/static/newchat.js";
import { addDevice, openInApp } from "/static/adddevice.js";
import { loadTiers, applyTiers, setTierShown, subscribeToLevel, cancelLevelSub, resumeLevelSub, changeLevelSub, levelPortal, billingDate, levelPrice, ensureTierKeys } from "/static/billingui.js";
@@ -204,6 +204,7 @@ Object.assign(verbs, {
loadTiers, setNickname, syncThreadKeysToSW,
inviteSomeone, resolveAppThread, billingDate, levelPrice, inviteAllowed,
decryptRosterLabels, openThreadById, seedAppState, welcomePending, isAdmin,
+ removeFromSpace, banFromSpace,
cacheTiersRaw,
manageGroupApps, openSpaceApp,
});
@@ -2890,6 +2891,11 @@ function renderApp() {
const uid = e.currentTarget.dataset.uid;
if (uid) viewProfile(uid); // the profile's Block button + its consequence note
};
+ document.getElementById("thread-remove").onclick = (e) => {
+ closeTMenu();
+ const uid = e.currentTarget.dataset.uid;
+ if (uid) confirmRemoveFromSpace(uid); // its dialog carries the whole-space warning
+ };
document.getElementById("thread-leave").onclick = () => {
closeTMenu();
leaveGroup();
@@ -3566,6 +3572,17 @@ function paintThreadHeader(id) {
: `Block ${rosterLabel(dmOther)}`;
blockEl.dataset.uid = dmOther;
}
+ // Admins also get the remove rung here — a short label; the dialog it
+ // opens carries the whole-space warning (the card keeps the ban option).
+ const removeEl = document.getElementById("thread-remove");
+ if (removeEl) {
+ const canRemove = canBlock && isAdmin();
+ removeEl.style.display = canRemove ? "" : "none";
+ if (canRemove) {
+ removeEl.textContent = `Remove ${rosterLabel(dmOther)}`;
+ removeEl.dataset.uid = dmOther;
+ }
+ }
}
paintViewToggle(id);
paintHeaderAvatar(id);
@@ -5520,6 +5537,34 @@ async function saveGroupSettings(patch) {
return "error";
}
}
+// Remove / remove-and-ban a member (issue #578) — shared by the Admin app's
+// report queue (via the bridge below) and the profile card's admin rungs (via
+// the verbs seam). Both endpoints are admin-gated server-side; r.ok is the
+// caller's only signal.
+async function removeFromSpace(uid) {
+ try {
+ const r = await fetch(`/api/admin/users/${encodeURIComponent(uid)}/remove`, {
+ method: "POST",
+ headers: adminAuth(),
+ });
+ if (r.ok) await loadRoster();
+ return r.ok;
+ } catch {
+ return false;
+ }
+}
+async function banFromSpace(uid) {
+ try {
+ const r = await fetch(`/api/admin/users/${encodeURIComponent(uid)}/ban`, {
+ method: "POST",
+ headers: adminAuth(),
+ });
+ if (r.ok) await loadRoster();
+ return r.ok;
+ } catch {
+ return false;
+ }
+}
setSetupBridge({
myName: () => session.name || "",
setMyName: async (name) => {
@@ -5708,30 +5753,8 @@ setSetupBridge({
return false;
}
},
- banFromSpace: async (uid) => {
- try {
- const r = await fetch(`/api/admin/users/${encodeURIComponent(uid)}/ban`, {
- method: "POST",
- headers: adminAuth(),
- });
- if (r.ok) await loadRoster();
- return r.ok;
- } catch {
- return false;
- }
- },
- removeFromSpace: async (uid) => {
- try {
- const r = await fetch(`/api/admin/users/${encodeURIComponent(uid)}/remove`, {
- method: "POST",
- headers: adminAuth(),
- });
- if (r.ok) await loadRoster();
- return r.ok;
- } catch {
- return false;
- }
- },
+ banFromSpace,
+ removeFromSpace,
removeFromThread: async (threadId, uid) => {
try {
const r = await fetch(`/api/threads/${encodeURIComponent(threadId)}/remove`, {
diff --git a/web/static/style.css b/web/static/style.css
index 3bd1bf0..b5f3aeb 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -481,7 +481,9 @@ select:disabled { opacity: 0.5; cursor: default; }
white-space: normal;
}
.menu-item:hover { background: var(--bubble-them); }
-.menu-item.danger { color: var(--danger); }
+/* Menu rows read danger by text colour alone — the red pill border
+ (button.danger) is for standalone buttons, not flat list rows. */
+.menu-item.danger { border: none; color: var(--danger); }
.menu-item.disabled,
.menu-item.disabled:hover { color: var(--muted); cursor: default; background: transparent; }
.msg {
@@ -1191,7 +1193,7 @@ select:disabled { opacity: 0.5; cursor: default; }
color: var(--ink);
}
.msg-sheet-item:active { background: var(--bubble-them); }
-.msg-sheet-item.danger { color: var(--danger); }
+.msg-sheet-item.danger { border: none; color: var(--danger); } /* flat row, no pill border (see .menu-item.danger) */
.msg-sheet-icon { display: inline-flex; color: var(--muted); flex: none; }
.msg-sheet-item.danger .msg-sheet-icon { color: var(--danger); }
.msg-sheet-icon svg { width: 20px; height: 20px; }
diff --git a/web/status/ssh/2026-07-22-remove-member-canary.json b/web/status/ssh/2026-07-22-remove-member-canary.json
new file mode 100644
index 0000000..3629689
--- /dev/null
+++ b/web/status/ssh/2026-07-22-remove-member-canary.json
@@ -0,0 +1,7 @@
+{
+ "date": "2026-07-22",
+ "operator": "Paul Campbell",
+ "window": "2026-07-22 13:30 – 14:30 UTC (approx)",
+ "why": "Stood up and deployed the test-remove-member canary for the profile-card admin remove/ban feature, and staged its setup/admin links for the operator.",
+ "refs": ["https://github.com/elevenmessenger/messenger/pull/1263"]
+}