aboutsummaryrefslogtreecommitdiffstats
path: root/tools/feed-conversations-test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-16 17:02:00 -0700
committerAdam Soltys <[email protected]>2026-10-16 17:02:00 -0700
commit885bc2647a3558554a53a794f1d56e9dbd7657e6 (patch)
tree6f22f27cc12d452f383cee6ef28b643288e2987b /tools/feed-conversations-test.js
parent73bffd93caefa53af3a5d28e70059a3cfbe68939 (diff)
downloadcoinosv3-885bc2647a3558554a53a794f1d56e9dbd7657e6.tar.gz
coinosv3-885bc2647a3558554a53a794f1d56e9dbd7657e6.zip
Conversations: a feed of the replies the people you follow write, each under the post it answers.
Amethyst's Conversations beside its New Threads (Adam, 2026-10-16: "a replies mode or Conversations mode like Amethyst has"). The Following feed showed their posts and dropped every reply, so what people were saying TO each other only turned up by opening threads. Now a built-in Conversations chip sits beside Following (id 'replies'; its own notes, clock, disk cache and relay choice like the other built-ins): the same people, asked for notes and NIP-22 comments, keeping the replies — a reply as NIP-10 marks it, a comment, never a quote or a repost (feedKinds/feedKeeps; the merge, seeding, borrowing, the live watch and the media index all go by them). Each row shows what it answers above it — "↩ Replying to" and the quote card of the parent (the e tag marked reply, else root, else the last; a comment's e or E), tapping the card opens that post, tapping the reply its thread at the reply — and the header drops its "reply" tag there. The parent is usually in hand already (the Following feed, a thread, someone's page: noteInHand, which quotedNote checks before any relay — nostr:nevent quotes gain the same), and a thread opened from such a row paints its root at once instead of fetching it. In a conversations feed the parent counts as part of the row, so it arrives whole with the first screen (noteReady's parent option, per feed); a profile's Replies tab shows the same context and fills its cards in. Not a shareable URL (it is nobody's timeline), so no Share in its menu. Any custom feed can be one: "Replies instead of posts" in the editor (def.replies, synced with the definition), named in its summary. The disk cache now keeps comments. tools/feed-conversations-test.js: Following keeps posts only; the chip; the replies newest first with the parent card from hand (no relay asked) and a missing parent said so; the menu without Share; the reply's own tap opens its thread at the reply, with the root painted from hand; the card's tap opens the parent; Back lands on Conversations; the profile's Replies tab shows the context, Posts none; a custom conversations feed via the editor borrows the replies from the feeds in hand, not the posts. feed-custom-test lists the new chip. Pre-existing failures not touched: feed-custom-test's "a tagged post lands in it"/"lands in Everything" and feed-streaming-test's pill position fail on clean HEAD the same way; feed-follow, feed-cache, own-post-feeds, feed-list, feed-media-views, feed-switch-seed, profile-pager, thread-pager, feed-repost, feed-live, feed-new-posts, feed-window, feed-choice-per-wallet, feed-nav-destination, feed-wallet-back, moderation, feed-mention-tap and feed-reboost pass. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
Diffstat (limited to 'tools/feed-conversations-test.js')
-rw-r--r--tools/feed-conversations-test.js127
1 files changed, 127 insertions, 0 deletions
diff --git a/tools/feed-conversations-test.js b/tools/feed-conversations-test.js
new file mode 100644
index 0000000..0e408fd
--- /dev/null
+++ b/tools/feed-conversations-test.js
@@ -0,0 +1,127 @@
+// A Conversations feed beside Following: the replies the people you follow
+// write, each under the post it answers (Amethyst's Conversations). The
+// Following feed keeps only their posts; a profile's Replies tab shows the
+// same context; a custom feed can be a conversations feed too.
+// Run: bun tools/feed-conversations-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+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 SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk = SKA, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
+const B1 = post('bob root here, a question for everyone', 400, SKB);
+const A1 = post('alice root post about nothing', 300);
+const MISSING = 'f'.repeat(64);
+const R1 = post('alice answers bob right here', 200, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]);
+const R2 = post('alice replies to a post nobody has', 100, SKA, [['e', MISSING, '', 'root']]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ 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)));
+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 waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const rowIds = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')));
+const contextOf = (id) => page.evaluate((id) => {
+ const r = document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]');
+ const c = r && r.querySelector('.note-context');
+ return c ? { of: c.getAttribute('data-context-of'), text: c.innerText, card: !!c.querySelector('.quote-card.clickable') } : null;
+}, id);
+
+try {
+ await page.setViewport({ width: 390, height: 844, hasTouch: true });
+ await page.goto(server.url.href, { 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');
+ await page.evaluate(([k, alice, bob, roots, replies]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(roots));
+ localStorage.setItem(k + ':feedNotes:replies', JSON.stringify(replies));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Replier', about: 'alice bio here', t: Date.now() }, [bob]: { name: 'Bob Asker', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, ALICE, BOB, [A1, B1], [R1, R2]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('Following paints their posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000));
+ const following = await rowIds();
+ check('…only posts: no replies in Following', following.includes(A1.id) && following.includes(B1.id) && !following.includes(R1.id) && !following.includes(R2.id), following.map((x) => x.slice(0, 6)).join(','));
+ const chips = await page.$$eval('.feed-chip[data-key]', (l) => l.map((e) => e.getAttribute('data-key') + '=' + e.textContent.trim()));
+ check('a Conversations chip beside Following', chips[0] === 'chip:following=Following' && chips[1] === 'chip:replies=Conversations', chips.join(' '));
+
+ console.log('\n[Conversations]');
+ await page.evaluate(() => document.querySelector('.feed-chip[data-key="chip:replies"]').click());
+ check('the replies paint', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000));
+ const convo = await rowIds();
+ check('…only replies, newest first', JSON.stringify(convo) === JSON.stringify([R2.id, R1.id]), convo.map((x) => x.slice(0, 6)).join(','));
+ check('chip marked on', await page.$eval('.feed-chip[data-key="chip:replies"]', (e) => e.classList.contains('on')));
+ const c1 = await contextOf(R1.id);
+ check('a reply sits under the post it answers (in hand from Following, no relay asked)', !!c1 && c1.of === B1.id && c1.card && /Replying to/.test(c1.text) && /Bob Asker/.test(c1.text) && /bob root here/.test(c1.text), JSON.stringify(c1));
+ check('…and the header no longer repeats "reply"', !(await page.$eval('.notes-feed .note-post[data-zap-post="' + R1.id + '"] .note-header', (e) => /\breply\b/i.test(e.innerText))));
+ check('a parent nobody has: the card says so', await waitFor((id) => { const c = document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context'); return !!c && /find that note/i.test(c.innerText); }, R2.id, 8000), (await contextOf(R2.id) || {}).text);
+ await page.evaluate(() => document.querySelector('button[aria-label="Feed options"]').click()); await sleep(300);
+ const menu = await page.evaluate(() => document.querySelector('.msg-sheet')?.innerText || '');
+ check('the feed menu has relays but no share link for it (it is nobody\'s timeline)', /relays/i.test(menu) && !/share/i.test(menu), menu.replace(/\n/g, ' | '));
+ await click('back'); await sleep(300);
+ // the reply's own tap (its text, not the card above it): its thread, at the reply
+ await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), R1.id);
+ check('tapping the reply opens the thread at the reply', await waitFor((id) => !!document.querySelector('[data-focus-note="1"][data-zap-post="' + id + '"]'), R1.id, 8000));
+ check('…with Bob\'s post as its root', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('.feed-chips'), B1.id, 4000));
+ await page.goBack(); await sleep(800);
+ check('Back lands on Conversations again', await waitFor(() => document.querySelector('.feed-chip[data-key="chip:replies"]')?.classList.contains('on'), null, 6000));
+ // the context card's tap: the parent's own thread
+ await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context .quote-card').click(), R1.id);
+ check('tapping the post it answers opens that post, at the top', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('[data-focus-note]') && !document.querySelector('.feed-chips'), B1.id, 8000));
+ await page.goBack(); await sleep(800);
+ await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
+
+ console.log('\n[a profile\'s Replies tab]');
+ await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-avatar').click(), R1.id);
+ check('Alice\'s profile opens', await waitText('alice bio here', 8000));
+ await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="replies"]').click());
+ check('her Replies tab lists the reply', await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), R1.id, 8000));
+ const pc = await contextOf(R1.id);
+ check('…under the post it answers', !!pc && pc.of === B1.id && /bob root here/.test(pc.text), JSON.stringify(pc));
+ await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="posts"]').click());
+ await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), A1.id, 8000);
+ check('her Posts tab shows no context block', (await page.$$('.notes-feed .note-context')).length === 0);
+ await page.goBack(); await sleep(800);
+ await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
+
+ console.log('\n[a custom conversations feed]');
+ await page.evaluate(() => document.querySelector('.feed-chip.add').click());
+ check('the editor offers replies instead of posts', await waitFor(() => !!document.querySelector('.feed-replies-toggle'), null, 4000));
+ await page.evaluate(() => {
+ const name = document.querySelector('.chat-page input[type=text]'); name.value = 'Alice talks'; name.dispatchEvent(new Event('input', { bubbles: true }));
+ const follows = [...document.querySelectorAll('label')].find((l) => /Everyone I follow/.test(l.textContent)).querySelector('input'); follows.click();
+ document.querySelector('.feed-replies-toggle').click();
+ });
+ await click('save'); await sleep(600);
+ check('saved as a chip', await waitFor(() => [...document.querySelectorAll('.feed-chip')].some((e) => e.textContent.trim() === 'Alice talks' && e.classList.contains('on')), null, 6000));
+ check('…holding the replies (borrowed from the feeds in hand), not the posts', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
+ check('…each with its context', (await contextOf(R1.id) || {}).of === B1.id);
+ await page.evaluate(() => document.querySelector('.feed-chip[data-key="chip:following"]').click());
+ check('Following is still posts only', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.includes(ids[0]) && !got.includes(ids[1]); }, [A1.id, R1.id], 8000));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ conversations: the replies they write, under what they answer' : '\n❌ failed');
+process.exit(ok ? 0 : 1);