aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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
-rw-r--r--tools/chat-remove-member-test.js224
6 files changed, 328 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 {
diff --git a/tools/chat-remove-member-test.js b/tools/chat-remove-member-test.js
new file mode 100644
index 0000000..1007973
--- /dev/null
+++ b/tools/chat-remove-member-test.js
@@ -0,0 +1,224 @@
+// A community's admin taps a message and removes its author, with everything
+// they said: one owner-signed ban-list edition (CORD-04 vsk 4) that every
+// client folds. "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).
+//
+// Everything runs against a relay inside this process: the page's sockets all
+// go there, so nothing reaches a live room.
+//
+// Run: bun tools/chat-remove-member-test.js
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import {
+ parseInviteLink, openInviteBundle, channelStream, channelEpoch, controlKey, wrapRumor, openWrap, msTags,
+ foldControl, banlistHead, makeBanlist,
+} from '../src/concord.js';
+import { hexToBytes } from '@noble/hashes/utils';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+// ---- a relay in a few lines: stores what it's given, serves it by filter ----
+const events = new Map();
+const socks = new Map(); // ws -> Map(subId -> filters)
+const matches = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
+ && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until)
+ && Object.entries(f).every(([k, v]) => k[0] !== '#' || e.tags.some((t) => t[0] === k.slice(1) && v.includes(t[1])));
+const store = (e) => {
+ if (events.has(e.id)) return;
+ events.set(e.id, e);
+ for (const [ws, subs] of socks) for (const [id, fs] of subs) if (fs.some((f) => matches(f, e))) ws.send(JSON.stringify(['EVENT', id, e]));
+};
+const RELAY_PORT = 27811, RELAY = `ws://127.0.0.1:${RELAY_PORT}`;
+const relay = Bun.serve({
+ port: RELAY_PORT, hostname: '127.0.0.1',
+ fetch(req, srv) { return srv.upgrade(req) ? undefined : new Response('relay'); },
+ websocket: {
+ open(ws) { socks.set(ws, new Map()); },
+ close(ws) { socks.delete(ws); },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') { store(m[1]); ws.send(JSON.stringify(['OK', m[1].id, true, ''])); }
+ else if (m[0] === 'REQ') {
+ const fs = m.slice(2);
+ socks.get(ws).set(m[1], fs);
+ for (const f of fs) {
+ const hit = [...events.values()].filter((e) => matches(f, e)).sort((a, b) => b.created_at - a.created_at);
+ for (const e of f.limit ? hit.slice(0, f.limit) : hit) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ }
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') socks.get(ws)?.delete(m[1]);
+ else if (m[0] === 'COUNT') ws.send(JSON.stringify(['COUNT', m[1], { count: 0 }]));
+ else if (/^NEG-/.test(m[0])) ws.send(JSON.stringify(['NEG-ERR', m[1], 'blocked: unsupported']));
+ },
+ },
+});
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5263, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 1100, height: 850 });
+page.on('pageerror', (e) => { console.log(' page error:', e.message); ok = false; });
+await page.evaluateOnNewDocument((relayUrl) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, protocols) { return new Real(/^wss?:/.test(url) ? relayUrl : url, protocols); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ // the invite link is "copied": keep it where the test can read it
+ try { Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (v) => { window.__clip = v; }, readText: async () => window.__clip || '' }, configurable: true }); } catch {}
+}, RELAY);
+
+const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t);
+const setInput = (sel, v) => page.evaluate((s, val) => { const e = document.querySelector(s); if (!e) return false; e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); return true; }, sel, v);
+const until = async (fn, ms = 20000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(300); } };
+const bodyText = () => page.evaluate(() => document.body.innerText);
+const waitText = (x, ms = 20000) => until(async () => (await bodyText()).toLowerCase().includes(x.toLowerCase()), ms);
+const logText = () => page.evaluate(() => document.querySelector('.chat-log')?.innerText || '');
+const say = async (text) => {
+ await page.waitForSelector('#msg-draft', { timeout: 15000 });
+ await page.evaluate((v) => {
+ const e = document.querySelector('#msg-draft');
+ const ta = e.shadowRoot ? e.shadowRoot.querySelector('textarea') : (e.querySelector && e.querySelector('textarea')) || e;
+ ta.focus(); ta.value = v;
+ ta.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
+ }, text);
+ await sleep(150);
+ await page.keyboard.press('Enter');
+};
+const tapBubble = (text) => page.evaluate((x) => {
+ const b = [...document.querySelectorAll('.chat-log .chat-bubble')].find((n) => n.innerText.includes(x));
+ if (b) b.click();
+ return !!b;
+}, text);
+const sheet = () => page.evaluate(() => [...document.querySelectorAll('.msg-sheet .msg-sheet-item')].map((b) => b.innerText.trim()));
+
+try {
+ await page.goto('http://localhost:5263/', { waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('button', 'Create a new wallet');
+ await sleep(300);
+ await click('button', 'Have an existing seed');
+ await sleep(300);
+ await page.waitForSelector('textarea');
+ await page.type('textarea', generateMnemonic(wordlist));
+ await click('button', 'Open wallet');
+ await waitText('receive', 15000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click());
+ await sleep(800);
+ await click('button', 'Create');
+ await sleep(300);
+ await setInput('input[placeholder*="name" i]', 'removal test');
+ await sleep(200);
+ await page.evaluate(() => { const i = document.querySelector('input[placeholder*="name" i]'); const b = i && i.parentElement.querySelector('button'); if (b) b.click(); });
+ await waitText('removal test', 25000);
+ await click('.item', 'removal test');
+ await page.waitForSelector('#msg-draft', { timeout: 15000 });
+ await say('the admin says hello');
+ check('the admin posts in their community', await until(async () => (await logText()).includes('the admin says hello')));
+
+ // the join material, the way anyone gets it: the invite link
+ await page.evaluate(() => document.querySelector('[aria-label="Add person"]')?.click());
+ await sleep(300);
+ await click('button', 'Copy invite link');
+ const link = await until(() => page.evaluate(() => window.__clip || null), 10000);
+ const parsed = parseInviteLink(link || '');
+ const bundleEv = parsed && [...events.values()].find((e) => e.kind === 33301 && e.pubkey === parsed.signerPk);
+ const jm = bundleEv && openInviteBundle(bundleEv, parsed.token);
+ check('invite link opens the community', !!(jm && jm.community_root), link ? link.slice(0, 50) + '…' : 'no link');
+ if (!jm) throw new Error('no join material');
+ await page.evaluate(() => document.querySelector('[aria-label="Add person"]')?.click());
+ const ch = jm.channels[0];
+ const stream = channelStream(jm, ch);
+ const post = async (sk, text) => {
+ const { created_at, ms } = msTags(Date.now());
+ const rumor = { kind: 9, pubkey: getPublicKey(sk), content: text, tags: [['channel', ch.id], ['epoch', String(channelEpoch(jm, ch))], ms], created_at };
+ store(await wrapRumor(rumor, sk, stream));
+ };
+ const spammer = generateSecretKey(), friend = generateSecretKey();
+ await post(spammer, 'Can I have some free sats');
+ await sleep(30);
+ await post(friend, 'good evening all');
+ await sleep(30);
+ await post(spammer, 'Any donation can help');
+ check('members\' messages arrive', await until(async () => { const x = await logText(); return x.includes('free sats') && x.includes('good evening') && x.includes('donation'); }));
+
+ console.log('\n[the sheet]');
+ await tapBubble('the admin says hello');
+ await sleep(300);
+ let items = await sheet();
+ check('own message: no remove option', items.length > 0 && !items.some((x) => /remove/i.test(x)), items.join(' / '));
+ await page.keyboard.press('Escape');
+ await page.evaluate(() => document.querySelector('.confirm-pop-backdrop')?.click());
+ await sleep(300);
+ await tapBubble('free sats');
+ await sleep(300);
+ items = await sheet();
+ check('someone else\'s message: "Remove … and their messages"', items.some((x) => /Remove .+ and their messages$/.test(x)), items.join(' / '));
+ await click('.msg-sheet-item', 'and their messages');
+ await sleep(300);
+ items = await sheet();
+ check('first tap only asks', items.some((x) => /tap again/i.test(x)) && (await logText()).includes('free sats'), items.join(' / '));
+ await click('.msg-sheet-item', 'tap again');
+ const gone = await until(async () => { const x = await logText(); return !x.includes('free sats') && !x.includes('donation'); }, 15000);
+ check('second tap: every message of theirs is gone', !!gone);
+ const after = await logText();
+ check('everyone else\'s stay', after.includes('good evening') && after.includes('the admin says hello'));
+
+ console.log('\n[what other clients see]');
+ const control = controlKey(hexToBytes(jm.community_root), jm.community_id, jm.root_epoch || 0);
+ const entries = () => [...events.values()].filter((e) => e.kind === 1059 && e.pubkey === control.pk).map((w) => openWrap(w, control)).filter(Boolean);
+ let folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id });
+ check('the relay holds an owner-signed ban list naming them', folded.banned.has(getPublicKey(spammer)) && folded.banned.size === 1);
+ // a member (not the owner) signing a higher version can't undo it
+ const head = banlistHead(entries(), { ownerHex: jm.owner, cid: jm.community_id });
+ const forged = { ...makeBanlist({ ...head, version: head.version + 5 }, [], Date.now()), pubkey: getPublicKey(spammer) };
+ store(await wrapRumor(forged, spammer, control, { plaintext: true }));
+ folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id });
+ check('a member\'s own "empty list" edition changes nothing', folded.banned.has(getPublicKey(spammer)));
+ await post(spammer, 'me again, free sats?');
+ await post(friend, 'still here');
+ await until(async () => (await logText()).includes('still here'));
+ check('their new messages never show', !(await logText()).includes('me again'));
+
+ console.log('\n[after a reload, and a second removal]');
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await until(async () => (await logText()).includes('good evening'), 25000);
+ await sleep(1500);
+ check('still gone after a reload', !/free sats|donation|me again/.test(await logText()) && (await logText()).includes('good evening'));
+ const second = generateSecretKey();
+ await post(second, 'buy my course');
+ await until(async () => (await logText()).includes('buy my course'));
+ await tapBubble('buy my course');
+ await sleep(300);
+ await click('.msg-sheet-item', 'and their messages');
+ await sleep(300);
+ await click('.msg-sheet-item', 'tap again');
+ await until(async () => !(await logText()).includes('buy my course'), 15000);
+ folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id });
+ check('the second removal keeps the first', folded.banned.has(getPublicKey(spammer)) && folded.banned.has(getPublicKey(second)) && folded.banned.size === 2, `${folded.banned.size} on the list`);
+
+ console.log('\n[putting someone back]');
+ await page.evaluate(() => document.querySelector('.chat-head .col.clickable')?.click());
+ await sleep(500);
+ const hasList = await until(async () => (await bodyText()).includes('Removed from this community'), 5000);
+ check('the member list shows who was removed (to the admin)', !!hasList);
+ await page.evaluate(() => { const rows = [...document.querySelectorAll('.chat-members .item')].filter((r) => [...r.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Restore')); rows.at(-1)?.querySelector('button')?.click(); });
+ const back = await until(async () => (await logText()).includes('buy my course'), 15000);
+ check('Restore brings their messages back', !!back);
+ folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id });
+ check('and the list keeps the other', folded.banned.has(getPublicKey(spammer)) && folded.banned.size === 1);
+} catch (e) {
+ console.log(' error:', e.message); ok = false;
+} finally {
+ await browser.close();
+ server.stop(true); relay.stop(true);
+}
+console.log(ok ? '\nPASS' : '\nFAIL');
+process.exit(ok ? 0 : 1);