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>
9 files changed,
+279
−37
changelog/2026-07-22-remove-from-profile.md+4 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+15 −0ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift+61 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+18 −0web/pages/index.html+1 −0web/static/groupactions.js+121 −10web/static/shell.js+48 −25web/static/style.css+4 −2web/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"] |
| +} |