aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 09:42:16 -0700
committerAdam Soltys <[email protected]>2026-10-09 09:42:16 -0700
commit09834ebe3d22d1ed877a1e3620747d59c9c14aef (patch)
tree5c689a4727e9341bf759cc3da26d625e43dfb110 /tools
parent4d0fc6bf4fd0494df1405899f51f9abbf5ab34a1 (diff)
downloadcoinosv3-09834ebe3d22d1ed877a1e3620747d59c9c14aef.tar.gz
coinosv3-09834ebe3d22d1ed877a1e3620747d59c9c14aef.zip
Posts: edit and delete your own. The ⋯ menu on your post gains Edit (a kind 1010 edit beside the original — the draft NIP-41 Amethyst reads and writes; the feed, thread and quote cards show the newest text with "· edited") and Delete (a NIP-09 kind 5, after a confirmation). Edits and deletions are asked for in the same REQ as each post's tallies and its live subscription, and count only when signed by the post's own author; they're remembered between sessions (postEdits cache) so a deleted post never flashes back from a cached feed. A post signed by the wallet key before a login was linked is still signed by that key. A deleted thread root keeps its place as "deleted by its author". relay.coinos.io now accepts kind 1010. tools/post-edit-test.js
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
Diffstat (limited to 'tools')
-rw-r--r--tools/post-edit-test.js156
1 files changed, 156 insertions, 0 deletions
diff --git a/tools/post-edit-test.js b/tools/post-edit-test.js
new file mode 100644
index 0000000..405e4bc
--- /dev/null
+++ b/tools/post-edit-test.js
@@ -0,0 +1,156 @@
+// Editing and deleting posts: ⋯ on your own post offers Edit (a kind 1010
+// edit beside the original, the draft NIP-41 Amethyst speaks) and Delete (a
+// NIP-09 kind 5). Someone else's edits and deletions are honoured when their
+// relays carry them — but only when the post's own author signed them, and a
+// deletion is remembered across a reload.
+// No relay is written for real — the WebSocket shim swallows EVENT.
+// Run: bun tools/post-edit-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { buildHtml } from '../build.js';
+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; };
+const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const AUTHOR = key(), OTHER = key();
+const now = Math.floor(Date.now() / 1000);
+const post = (who, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, who.sk);
+const EDITED = post(AUTHOR, 'first draft with a tpyo', 300);
+const GONE = post(AUTHOR, 'a post its author took back', 400);
+const FORGED = post(AUTHOR, 'a post a stranger tried to delete', 500);
+const FIX = finalizeEvent({ kind: 1010, created_at: now - 100, tags: [['e', EDITED.id]], content: 'first draft without the typo' }, AUTHOR.sk);
+const fixtures = [
+ FIX,
+ finalizeEvent({ kind: 5, created_at: now - 90, tags: [['e', GONE.id]], content: '' }, AUTHOR.sk),
+ finalizeEvent({ kind: 5, created_at: now - 80, tags: [['e', FORGED.id]], content: '' }, OTHER.sk),
+ finalizeEvent({ kind: 1010, created_at: now - 70, tags: [['e', FORGED.id]], content: 'a stranger rewrote this' }, OTHER.sk),
+];
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5301, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((fx) => {
+ window.__published = [];
+ const Real = window.WebSocket;
+ const fits = (ev, f) => (!f.kinds || f.kinds.includes(ev.kind)) && (!f['#e'] || ev.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1])))
+ && (!f.authors || f.authors.includes(ev.pubkey));
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const fs = m.slice(2);
+ const evs = fx.filter((ev) => fs.some((f) => fits(ev, f)));
+ setTimeout(() => {
+ for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'EVENT' && m[1]) {
+ window.__published.push(m[1]);
+ setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0);
+ return;
+ }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, Real);
+}, fixtures);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const text = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitGone = async (x, ms = 10000) => { for (let i = 0; i < ms / 250; i++) { if (!(await text()).includes(x)) return true; await sleep(250); } return false; };
+const has = async (x) => (await text()).includes(x);
+const openFeed = async () => {
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await sleep(1200);
+};
+const more = (match) => page.evaluate((m) => {
+ const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => n.textContent.includes(m));
+ const b = r && [...r.querySelectorAll('button')].find((x) => x.getAttribute('aria-label') === 'More');
+ if (b) b.click();
+ return !!b;
+}, match);
+const sheetHas = (label) => page.evaluate((l) => [...document.querySelectorAll('.confirm-pop button')].some((b) => b.textContent.includes(l)), label);
+const sheetClick = (label) => page.evaluate((l) => { const b = [...document.querySelectorAll('.confirm-pop button')].find((x) => x.textContent.includes(l)); if (b) b.click(); return !!b; }, label);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5301/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ const ME_SK = privateKeyFromSeedWords(mn);
+ const ME = getPublicKey(ME_SK);
+ const MINE = finalizeEvent({ kind: 1, created_at: now - 200, tags: [], content: 'my own post, to be fixed' }, ME_SK);
+ await page.evaluate(([k, pks, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((pk) => ['p', pk]), c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify(Object.fromEntries(pks.map((pk, i) => [pk, { name: 'Person ' + i, t: Date.now() }]))));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, [AUTHOR.pk, ME], [MINE, EDITED, GONE, FORGED]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await openFeed();
+
+ console.log('\n[someone else\'s edits and deletions]');
+ check('the feed shows the posts', await waitText('a post a stranger tried to delete', 15000));
+ check('an edit by the author replaces the text', await waitText('first draft without the typo', 10000) && !(await has('tpyo')));
+ check('...marked as edited', await page.evaluate(() => [...document.querySelectorAll('[data-zap-post]')].some((n) => n.textContent.includes('without the typo') && /edited/.test(n.textContent))));
+ check('a deletion by the author hides the post', await waitGone('a post its author took back'));
+ check('a stranger\'s deletion and edit are ignored', await has('a post a stranger tried to delete') && !(await has('a stranger rewrote this')));
+ check('no Edit or Delete on someone else\'s post', await more('first draft without') && !(await sheetHas('Edit')) && !(await sheetHas('Delete')));
+ await sheetClick('Back'); await sleep(300);
+
+ console.log('\n[editing my own post]');
+ check('my post is in the feed', await waitText('my own post, to be fixed', 8000));
+ await more('my own post, to be fixed'); await sleep(300);
+ check('⋯ offers Edit and Delete', await sheetHas('Edit') && await sheetHas('Delete'));
+ await sheetClick('Edit'); await sleep(400);
+ const pre = await page.evaluate(() => (document.querySelector('.post-edit-input') || {}).value);
+ check('the edit box holds the post\'s words', pre === 'my own post, to be fixed', pre);
+ await page.evaluate(() => { window.__published = []; const e = document.querySelector('.post-edit-input'); e.focus(); e.value = 'my own post, fixed'; e.dispatchEvent(new Event('input', { bubbles: true })); });
+ await sheetClick('Save'); await sleep(1500);
+ const ed = (await page.evaluate(() => window.__published)).find((e) => e.kind === 1010);
+ check('a kind 1010 goes out, signed by me, naming the post', !!ed && ed.pubkey === ME && ed.content === 'my own post, fixed' && ed.tags.some((x) => x[0] === 'e' && x[1] === MINE.id), JSON.stringify(ed && ed.tags));
+ check('the feed shows the new words', await waitText('my own post, fixed', 5000) && !(await has('to be fixed')));
+
+ console.log('\n[deleting my own post]');
+ await more('my own post, fixed'); await sleep(300);
+ await sheetClick('Delete'); await sleep(300);
+ check('a confirmation asks first', await has('Delete this post?'));
+ await page.evaluate(() => { window.__published = []; });
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.confirm-pop .btn-danger')].find((x) => x.textContent.includes('Delete')); b.click(); });
+ await sleep(1500);
+ const del = (await page.evaluate(() => window.__published)).find((e) => e.kind === 5);
+ check('a kind 5 goes out, signed by me, naming the post', !!del && del.pubkey === ME && del.tags.some((x) => x[0] === 'e' && x[1] === MINE.id), JSON.stringify(del && del.tags));
+ check('the post is gone from the feed', await waitGone('my own post, fixed'));
+
+ console.log('\n[after a reload]');
+ await sleep(2500); // the index is written behind
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await openFeed();
+ check('the feed is back', await waitText('a post a stranger tried to delete', 15000));
+ check('my deleted post stays gone', !(await has('my own post')));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ post edits and deletions' : '\n❌ failed');
+process.exit(ok ? 0 : 1);