aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-10 14:13:23 -0700
committerAdam Soltys <[email protected]>2026-11-10 14:13:23 -0700
commit83abfea172d09d6e730859f3028f3cb6870136c5 (patch)
tree83711954868aa63b70853119e708f17f5b2e1647 /src
parent0fa2ea40ce21b642552d2d64e31bd80461cfa7ef (diff)
downloadcoinosv3-83abfea172d09d6e730859f3028f3cb6870136c5.tar.gz
coinosv3-83abfea172d09d6e730859f3028f3cb6870136c5.zip
A community's admin taps a message and removes its author, with everything they said. "can i have some control as a chat group admin to click on a message and see an option to remove the user and all their messages from the group?" (Adam, 2026-11-10). The message sheet gains "Remove <name> and their messages" for the owner on anyone else's message; a first tap asks ("for everyone? Tap again"), the second does it. It is the owner's ban list (CORD-04 vsk 4): one owner-signed edition naming every removed key, which the app, the public /chat page and other Concord clients already fold: those authors' messages, old and new, drop out and they leave the member list. The list is replaced whole, so setBanned asks the relays for the control stream first and builds on the newest edition (banlistHead/makeBanlist chain version and hash), stopping if the relays hand back nothing. The member list shows the admin who was removed, each with Restore. Offered only where the app holds the control stream's signing key (the built-in community and ones founded here). Removed people no longer light a room's unread dot. And the control fold now lets only the owner's editions compete for a head: any member holds the control stream's key in a public community, and a higher-versioned edition of their own used to shadow the owner's ban list, name or channels (dropped, leaving nothing). tools/chat-remove-member-test.js runs it against a relay inside the test
Co-Authored-By: Claude Fable 5.2 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/concord.js25
-rw-r--r--src/features/messages.js70
-rw-r--r--src/i18n.js6
-rw-r--r--src/locales/it.json6
-rw-r--r--src/style.css3
5 files changed, 104 insertions, 6 deletions
diff --git a/src/concord.js b/src/concord.js
index 63a83b9..521bfc8 100644
--- a/src/concord.js
+++ b/src/concord.js
@@ -187,6 +187,11 @@ export const foldControl = (entries, { ownerHex, cid }) => {
// entries: [{ rumor, author }] — group by (vsk, eid), take highest intact version
let byEntity = new Map();
for (let e of entries) {
+ // Only the owner's editions are honored below, so only they compete for
+ // a head: anyone holding the control stream's key (every member of a
+ // public community) could otherwise sign a higher version and blank the
+ // owner's ban list, name or channels.
+ if (e.author !== ownerHex) continue;
let tag = (k) => e.rumor.tags?.find((t) => t[0] === k)?.[1];
let vsk = tag("vsk"), eid = tag("eid"), ev = +tag("ev");
if (vsk === undefined || !eid || !(ev >= 1)) continue;
@@ -225,6 +230,26 @@ export const foldControl = (entries, { ownerHex, cid }) => {
return state;
};
+// The owner's ban list as it stands: the head edition (its version, and the
+// hash a successor chains to) and the keys on it. Same winner as the fold.
+export const banlistHead = (entries, { ownerHex, cid }) => {
+ let eid = banlistEid(cid), cur = null;
+ for (let e of entries) {
+ if (e.author !== ownerHex || e.rumor.kind !== 3308) continue;
+ let tag = (k) => e.rumor.tags?.find((t) => t[0] === k)?.[1];
+ let ev = +tag("ev");
+ if (tag("vsk") !== "4" || tag("eid") !== eid || !(ev >= 1)) continue;
+ if (!cur || ev > cur.ev || (ev === cur.ev && e.rumor.id < cur.id)) cur = { ev, id: e.rumor.id, ep: tag("ep") || null, content: e.rumor.content };
+ }
+ let list = [];
+ try { let c = cur && JSON.parse(cur.content); if (Array.isArray(c)) list = c.filter((x) => /^[0-9a-f]{64}$/.test(x)); } catch {}
+ return { eid, version: cur ? cur.ev : 0, hash: cur ? editionHash(eid, cur.ev, cur.ep, cur.content) : null, list };
+};
+
+// The next ban-list edition: `list` replaces the one that stands.
+export const makeBanlist = (head, list, nowMs) =>
+ makeEdition({ vsk: 4, eid: head.eid, version: head.version + 1, prev: head.hash || undefined, content: JSON.stringify(list) }, nowMs);
+
// ---- CORD-02 §5 guestbook fold ----
// entries: [{ rumor, author }], returns Map(npub -> { state: 'join'|'leave'|'kick', t })
export const foldGuestbook = (entries, { nowMs, banned = new Set(), ownerHex } = {}) => {
diff --git a/src/features/messages.js b/src/features/messages.js
index 592c5dc..f72d71d 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -22,7 +22,7 @@ import {
import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack
import {
channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId,
- foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition,
+ foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, banlistHead, makeBanlist,
communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle,
} from '../concord.js';
import { makeDMRumor, makeDMReaction, unwrapDM, wrapDM } from '../dm.js';
@@ -488,8 +488,9 @@ export function messagesFeature(ctx) {
function roomUnread(room) {
const s = st();
const my = myPubkeys();
+ const banned = room.folded && room.folded.banned;
for (const [id, msgs] of room.byChannel)
- if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > (s.read[chRead(id)] || 0)) return true;
+ if (newestFrom(msgs.values(), (m) => !my.includes(m.author) && !(banned && banned.has(m.author))) > (s.read[chRead(id)] || 0)) return true;
return false;
}
@@ -2017,9 +2018,12 @@ export function messagesFeature(ctx) {
const mine = my.includes(m.author);
const reacts = room.reactions.get(m.rumor.id);
const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
- const close = () => { ui.msgSheet = null; ui.emojiPick = null; render(); };
- const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick },
+ const close = () => { ui.msgSheet = null; ui.emojiPick = null; ui.msgBanArm = null; render(); };
+ const item = (icon, label, onClick, cls = '') => h('button', { class: 'msg-sheet-item' + cls, onClick },
h('span', { class: 'msg-sheet-ico' }, icon), label);
+ // the community's admin can take a person out, with everything they said
+ const canBan = !mine && canModerate(room) && m.author !== room.jm.owner;
+ const armed = ui.msgBanArm === m.rumor.id;
return h('div', {
class: 'confirm-pop-backdrop',
onClick: (e) => { if (e.target === e.currentTarget) close(); },
@@ -2036,7 +2040,54 @@ export function messagesFeature(ctx) {
try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {}
close();
}),
- !ui.emojiPick && mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null));
+ !ui.emojiPick && mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null,
+ // two taps: the first asks, the second does it
+ !ui.emojiPick && canBan ? item('🚫', t(armed ? 'msgBanConfirm' : 'msgBan', { name: displayName(m.author) }), () => {
+ if (!armed) { ui.msgBanArm = m.rumor.id; render(); return; }
+ close(); setBanned(room, m.author, true);
+ }, ' danger' + (armed ? ' armed' : '')) : null));
+ }
+
+ // Removing someone from a community is the owner's ban list (CORD-04 vsk 4):
+ // one owner-signed edition naming every removed key. Every client that
+ // folds the control plane drops those authors' messages, old and new, and
+ // takes them off the member list. Only where we hold the control stream's
+ // signing key (a community founded here, or the built-in one).
+ const canModerate = (room) => !!room.control.sk && myPubkeys().includes(room.jm.owner);
+ let banBusy = false;
+ async function setBanned(room, pk, banned) {
+ if (banBusy) return false;
+ const id = await identity();
+ if (!id || id.pubkey !== room.jm.owner || !room.control.sk) { toast(t('msgOwnerOnly')); return false; }
+ banBusy = true;
+ try {
+ // The new edition replaces the list whole, so it must be built on the
+ // newest one: ask the relays now rather than trust what has streamed
+ // in. A relay that hands back nothing at all (the founding editions
+ // are always there) is not an answer, and we stop.
+ const wraps = await queryOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, 5000).catch(() => []);
+ if (!wraps.length) { toast(t('msgSendFailed')); return false; }
+ const have = new Set(room.controlEntries.map((e) => e.rumor.id));
+ for (const w of wraps) {
+ const o = openWrap(w, room.control);
+ if (o && o.rumor.kind === 3308 && !have.has(o.rumor.id)) { have.add(o.rumor.id); room.controlEntries.push(o); }
+ }
+ const head = banlistHead(room.controlEntries, { ownerHex: room.jm.owner, cid: room.jm.community_id });
+ const list = head.list.filter((x) => x !== pk);
+ if (banned) list.push(pk);
+ if (list.length === head.list.length) { room.scheduleFold(); return true; } // already so
+ const edition = { ...makeBanlist(head, list, Date.now()), pubkey: id.pubkey };
+ const wrap = await wrapRumor(edition, id.signer, room.control, { plaintext: true });
+ if (!(await publishOn(room.relays, wrap))) { toast(t('msgSendFailed')); return false; }
+ room.controlEntries.push({ rumor: rumorWithId(edition), author: id.pubkey });
+ room.scheduleFold();
+ bumpMsgRev();
+ toast(t(banned ? 'msgBanned' : 'msgUnbanned', { name: displayName(pk) }));
+ return true;
+ } catch (e) {
+ toast(t('msgSendFailed'));
+ return false;
+ } finally { banBusy = false; }
}
// The composer's "replying to" context bar, with its way out.
@@ -17889,7 +17940,14 @@ export function messagesFeature(ctx) {
h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
h('span', { class: 'chat-name' }, displayName(r.pk)),
r.pk === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null),
- h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk))))));
+ h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk))))),
+ ...(canModerate(room) && room.folded && room.folded.banned.size ? [
+ h('div', { class: 'small muted chat-removed-head' }, t('msgBannedHead')),
+ ...[...room.folded.banned].map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) },
+ h('span', { class: 'ava-wrap' }, avatar(pk)),
+ h('span', { class: 'chat-name grow', style: 'min-width:0' }, displayName(pk)),
+ h('button', { class: 'btn-sm', onClick: (e) => { e.stopPropagation(); setBanned(room, pk, false); } }, t('msgUnban')))),
+ ] : []));
}
function invitePanel(room) {
diff --git a/src/i18n.js b/src/i18n.js
index 06afc11..9c7827d 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -713,6 +713,12 @@ const STR = {
publicChatOpenApp: 'Join the chat',
publicChatLoading: 'Loading messages…',
msgDelete: 'Delete message',
+ msgBan: 'Remove {name} and their messages',
+ msgBanConfirm: 'Remove {name} for everyone? Tap again',
+ msgBanned: '{name} removed',
+ msgUnbanned: '{name} restored',
+ msgBannedHead: 'Removed from this community',
+ msgUnban: 'Restore',
msgNoIdentity: "Your nostr signer isn't connected. Reconnect it to send as yourself",
msgSendFailed: 'No relay accepted the message',
msgLockedChat: 'Your wallet is locked, so it can’t sign messages.',
diff --git a/src/locales/it.json b/src/locales/it.json
index 30b05a8..112296f 100644
--- a/src/locales/it.json
+++ b/src/locales/it.json
@@ -490,6 +490,12 @@
"publicChatOpenApp": "Partecipa alla chat",
"publicChatLoading": "Caricamento messaggi…",
"msgDelete": "Elimina messaggio",
+ "msgBan": "Rimuovi {name} e i suoi messaggi",
+ "msgBanConfirm": "Rimuovere {name} per tutti? Tocca di nuovo",
+ "msgBanned": "{name} rimosso",
+ "msgUnbanned": "{name} ripristinato",
+ "msgBannedHead": "Rimossi da questa community",
+ "msgUnban": "Ripristina",
"msgNoIdentity": "Il tuo signer nostr non è connesso. Riconnettilo per inviare a tuo nome",
"msgSendFailed": "Nessun relay ha accettato il messaggio",
"msgLockedChat": "Il tuo portafoglio è bloccato, quindi non può firmare i messaggi.",
diff --git a/src/style.css b/src/style.css
index 9c6974b..803979f 100644
--- a/src/style.css
+++ b/src/style.css
@@ -579,6 +579,9 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
border: none; background: none; padding: 11px 8px; font-weight: 550; border-radius: 10px;
}
.msg-sheet-item:hover { background: var(--surface2); }
+.msg-sheet-item.danger, .msg-sheet-item.danger .msg-sheet-ico { color: var(--red); }
+.msg-sheet-item.danger.armed { background: color-mix(in srgb, var(--red) 12%, transparent); }
+.chat-removed-head { padding: 10px 4px 2px; }
.msg-sheet-ico { width: 20px; text-align: center; color: var(--muted); flex-shrink: 0; }
/* the quoted message a reply carries, and the composer's replying-to bar */
.chat-quote {