// 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 PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'); const server = Bun.serve({ port: 5301, fetch: (req) => new URL(req.url).pathname === '/kek.png' ? new Response(PNG, { headers: { 'content-type': 'image/png' } }) : 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.concat(window.__extra || []).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]]); // my reaction with a custom emoji, arriving after the row is drawn const KEK = finalizeEvent({ kind: 7, created_at: now - 50, content: ':kek_lord:', tags: [['e', FORGED.id], ['p', AUTHOR.pk], ['emoji', 'kek_lord', 'http://localhost:5301/kek.png']] }, ME_SK); await page.evaluateOnNewDocument((e) => { window.__extra = e; }, [KEK]); 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('my custom-emoji reaction shows as its picture, not :kek_lord:', await page.evaluate(() => { const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => n.textContent.includes('a stranger tried to delete')); return !!r && !!r.querySelector('.note-acts img.cemoji[alt=":kek_lord:"]') && !r.querySelector('.note-acts').textContent.includes(':kek_lord:'); })); 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);