aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-12 08:28:02 -0700
committerAdam Soltys <[email protected]>2026-09-12 08:28:02 -0700
commitf34b5a155e7cb63e15eff7e7ba9559292b31c5e1 (patch)
treed5fdb28f3f2212f24067db73fd259d76a6f8706a /tools
parentd4576229565d9b4411d0d9fc44780da03ef0b47b (diff)
downloadcoinosv3-f34b5a155e7cb63e15eff7e7ba9559292b31c5e1.tar.gz
coinosv3-f34b5a155e7cb63e15eff7e7ba9559292b31c5e1.zip
Answer a post: like it, boost it, quote it, or stop seeing its author.
Under every post now: a heart with its count, a boost with its count, and a quote. Behind an ellipsis beside reply and zap: quote, boost, copy the note's reference, mute the author, or block them. Likes and boosts come back in the SAME relay request the zap tallies already make — one round trip answers all three — and each action paints before any relay does, the way the zap chip learned to. A like is kind 7 and a boost kind 6, published to the author's own write relays as well as ours, so the person being answered actually sees it. A quote opens the composer with a nostr:nevent reference already in the text and tags the post on publish, so other clients render the embed. Taking a like back is the only withdrawal nostr has — a deletion request naming the reaction. Mute is the NIP-51 list (kind 10000): their posts leave the feed at once and the list is published, so it follows you to other clients. Block is the same list plus an unfollow, which is why it says so. And a bug the mute found, which the follow list had too: the optimistic paint was being read back as fact. Both toggles paint the answer immediately and then publish onto the newest list the relays will give them — but when the relays had nothing newer, "the newest list" WAS the optimistic paint, so the code saw the follow already present and published a list without it. Every follow was a no-op that looked like it worked. The base is now the relays' copy only when it's genuinely newer, and otherwise what we held before the paint. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'tools')
-rw-r--r--tools/post-actions-test.js119
1 files changed, 119 insertions, 0 deletions
diff --git a/tools/post-actions-test.js b/tools/post-actions-test.js
new file mode 100644
index 0000000..c1126ef
--- /dev/null
+++ b/tools/post-actions-test.js
@@ -0,0 +1,119 @@
+// What you can do to a post: like it, boost it, quote it, and — behind the
+// ellipsis — mute or block whoever wrote it. Muting hides their posts here
+// and goes onto the NIP-51 list that follows you to other clients.
+//
+// The like and boost tallies come back in the same REQ as the zaps; this
+// checks the rendering and the local half of each action (nothing here needs
+// a relay to answer, so the test doesn't depend on one).
+//
+// Run: bun tools/post-actions-test.js
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { cacheKeyFor } from '../src/wallet.js';
+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 html = await buildHtml({ minify: true, pwa: false });
+const png = await Bun.file('/tmp/claude-1000/-home-adam-coinosv3/61dea255-d26d-411d-be8b-92dfb0a75887/scratchpad/big.png').arrayBuffer().catch(() => null);
+const server = Bun.serve({ port: 5258, async fetch(req) {
+ const u = new URL(req.url);
+ if (u.pathname === '/pic.png' && png) return new Response(png, { headers: { 'content-type': 'image/png' } });
+ return 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();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)));
+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 waitText = async (x, ms = 25000) => { for (let i = 0; i < ms/250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const AUTHOR = 'a'.repeat(63) + '9';
+// a fresh id each run: '1'.repeat(64) is a real, much-reacted-to id on nostr
+// and the relays cheerfully return four likes for it
+const NOTE_ID = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, '0')).join('');
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5258/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('get started'); await sleep(500);
+ await click('import existing'); await sleep(400);
+ await page.waitForSelector('textarea');
+ const mn = generateMnemonic(wordlist);
+ await page.type('textarea', mn);
+ await click('open wallet');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+
+ await page.evaluate(([k, pk, nid]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now()/1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify([
+ { id: nid, pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000), content: 'More flowers http://localhost:5258/pic.png', tags: [] },
+ ]));
+ }, [base, AUTHOR, NOTE_ID]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
+ await sleep(1000);
+ await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); });
+ await sleep(2500);
+
+ const acts = await page.evaluate(() => [...document.querySelectorAll('.note-act')].map((b) => b.getAttribute('aria-label')));
+ check('a post carries like, boost and quote', acts.join(',') === 'Like,Boost,Quote', acts.join(',') || 'none');
+
+ // a like paints before any relay answers, and counts itself
+ const before = await page.evaluate(() => Number(document.querySelector('.note-act').textContent.replace(/\D/g, '') || 0));
+ await page.evaluate(() => document.querySelector('.note-act').click());
+ await sleep(900);
+ const liked = await page.evaluate(() => { const b = document.querySelector('.note-act'); return { on: b.className.includes('on'), n: Number(b.textContent.replace(/\D/g, '') || 0) }; });
+ check('liking shows immediately, with a count', liked.on && liked.n === before + 1, JSON.stringify(liked) + ' was ' + before);
+
+ // quoting carries the post into the composer as a nostr: reference
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.note-act')].find((x) => x.getAttribute('aria-label') === 'Quote'); b.click(); });
+ await sleep(900);
+ const draft = await page.evaluate(() => (document.querySelector('.chat-page textarea') || {}).value || '');
+ check('quoting opens the composer with the reference in it', /nostr:nevent1/.test(draft), draft.slice(0, 40) || 'empty');
+
+ // the ellipsis sheet, and muting from it
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.btn-sm')].find((x) => x.textContent.trim() === '⋯'); if (b) b.click(); });
+ await sleep(600);
+ const sheet = await page.evaluate(() => { const s = document.querySelector('.confirm-pop'); return s ? s.innerText.replace(/\n+/g, ' | ') : ''; });
+ check('the ellipsis offers mute and block', /Mute/.test(sheet) && /Block/.test(sheet), sheet.slice(0, 70));
+
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.confirm-pop button')].find((x) => /^🔇/.test(x.textContent)); if (b) b.click(); });
+ await sleep(2500);
+ const after = await page.evaluate((k) => ({
+ rows: document.querySelectorAll('.notes-feed > .row').length,
+ muted: (JSON.parse(localStorage.getItem(k + ':mutes') || 'null') || {}).tags || [],
+ }), base);
+ check('a muted author leaves the feed at once', after.rows === 0, after.rows + ' posts still shown');
+ check('...and goes onto the published mute list', after.muted.some((tg) => tg[1] === AUTHOR), JSON.stringify(after.muted).slice(0, 50));
+ // An image in a DM opens the same way a post's does — both bubbles render
+ // their text through the same function, so this is a check that they keep
+ // doing so.
+ await page.evaluate(([k, peer]) => {
+ const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
+ st.dms = { [peer]: [{ id: '2'.repeat(64), from: peer, text: 'look http://localhost:5258/pic.png', t: Math.floor(Date.now() / 1000) }] };
+ localStorage.setItem(k + ':messages', JSON.stringify(st));
+ }, [base, AUTHOR]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
+ await sleep(2500);
+ const opened = await page.evaluate(() => {
+ const row = [...document.querySelectorAll('.chat-thread-row')].find((r) => !/feed/i.test(r.textContent));
+ if (!row) return 'no dm row';
+ row.click();
+ return 'opened';
+ });
+ await sleep(1200);
+ const img = await page.evaluate(() => { const i = document.querySelector('.chat-bubble img.note-img'); if (!i) return null; i.click(); return true; });
+ await sleep(600);
+ check('an image in a DM opens full screen too', !!img && await page.evaluate(() => !!document.querySelector('.lightbox')), img ? 'tapped' : 'no image in the bubble (' + opened + ')');
+
+ check('no page errors', !errs.length, errs.join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ posts answer back' : '\n❌ failed');
+process.exit(ok ? 0 : 1);