diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 09:42:16 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 09:42:16 -0700 |
| commit | 09834ebe3d22d1ed877a1e3620747d59c9c14aef (patch) | |
| tree | 5c689a4727e9341bf759cc3da26d625e43dfb110 /tools | |
| parent | 4d0fc6bf4fd0494df1405899f51f9abbf5ab34a1 (diff) | |
| download | coinosv3-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.js | 156 |
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); |
