diff options
| author | Adam Soltys <[email protected]> | 2026-11-10 14:13:23 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-10 14:13:23 -0700 |
| commit | 83abfea172d09d6e730859f3028f3cb6870136c5 (patch) | |
| tree | 83711954868aa63b70853119e708f17f5b2e1647 | |
| parent | 0fa2ea40ce21b642552d2d64e31bd80461cfa7ef (diff) | |
| download | coinosv3-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]>
| -rw-r--r-- | src/concord.js | 25 | ||||
| -rw-r--r-- | src/features/messages.js | 70 | ||||
| -rw-r--r-- | src/i18n.js | 6 | ||||
| -rw-r--r-- | src/locales/it.json | 6 | ||||
| -rw-r--r-- | src/style.css | 3 | ||||
| -rw-r--r-- | tools/chat-remove-member-test.js | 224 |
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); |
