From 885bc2647a3558554a53a794f1d56e9dbd7657e6 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 16 Oct 2026 17:02:00 -0700 Subject: Conversations: a feed of the replies the people you follow write, each under the post it answers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp --- tools/feed-conversations-test.js | 127 +++++++++++++++++++++++++++++++++++++++ tools/feed-custom-test.js | 10 +-- 2 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 tools/feed-conversations-test.js (limited to 'tools') 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); diff --git a/tools/feed-custom-test.js b/tools/feed-custom-test.js index 122cb0a..6f345f1 100644 --- a/tools/feed-custom-test.js +++ b/tools/feed-custom-test.js @@ -108,7 +108,7 @@ try { await waitText('receive', 20000); await openFeed(); await sleep(1200); - check('the feed opens on Following, with a + chip', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Popular', '#gardenstr', '+']), JSON.stringify(await chips())); + check('the feed opens on Following, with a + chip', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Conversations', 'Popular', '#gardenstr', '+']), JSON.stringify(await chips())); console.log('\n[a new feed: everyone I follow, on one topic]'); await page.click('.feed-chip.add'); await sleep(300); @@ -117,7 +117,7 @@ try { await page.click('input[type=checkbox]:not(.feed-all-toggle)'); // everyone I follow (the first box is the relays' every-post one) await type('input[placeholder="#bitcoin #nostr"]', '#Bitcoin, lightning'); await click('save'); await sleep(1500); - check('the new feed is on screen and selected', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', '*Coin talk', '+']), JSON.stringify(await chips())); + check('the new feed is on screen and selected', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Conversations', 'Popular', '#gardenstr', '*Coin talk', '+']), JSON.stringify(await chips())); const req = (await page.evaluate(() => window.__reqs)).find((f) => f['#t'] && f.authors); check('it asks relays for the follows AND the topics', !!req && req.authors?.includes(AUTHOR) && JSON.stringify(req['#t']) === JSON.stringify(['bitcoin', 'lightning']), JSON.stringify(req || null)); const tagged = signed('fresh #bitcoin post', 0, [['t', 'bitcoin']]); @@ -132,7 +132,7 @@ try { await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].find((c) => c.textContent.trim() === 'Following').click()); await sleep(1200); check('Following again, with its own posts', (await chips()).includes('*Following') && await waitText('plain post', 4000)); await page.evaluate(() => { const a = [...document.querySelectorAll('.notes-feed a')].find((x) => x.textContent === '#bitcoin'); if (a) a.click(); }); await sleep(1200); - check('a #tag opens a topic feed of its own', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', 'Coin talk', '*#bitcoin', '+']) && await page.evaluate(() => { const m = document.querySelector('[aria-label="Feed options"]'); if (!m) return false; m.click(); return true; }) && await waitText('save feed', 2000) && await page.evaluate(() => { const b = [...document.querySelectorAll('.msg-sheet button')].find((x) => /back/i.test(x.textContent)); if (b) b.click(); return true; }), JSON.stringify(await chips())); + check('a #tag opens a topic feed of its own', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Conversations', 'Popular', '#gardenstr', 'Coin talk', '*#bitcoin', '+']) && await page.evaluate(() => { const m = document.querySelector('[aria-label="Feed options"]'); if (!m) return false; m.click(); return true; }) && await waitText('save feed', 2000) && await page.evaluate(() => { const b = [...document.querySelectorAll('.msg-sheet button')].find((x) => /back/i.test(x.textContent)); if (b) b.click(); return true; }), JSON.stringify(await chips())); const treq = (await page.evaluate(() => window.__reqs)).filter((f) => f['#t'] && !f.authors).pop(); check('...asked for by topic alone', !!treq && JSON.stringify(treq['#t']) === JSON.stringify(['bitcoin']), JSON.stringify(treq || null)); @@ -183,7 +183,7 @@ try { await waitText('receive', 20000); await openFeed(); await sleep(1200); // (the feed you were on is remembered: Follows + lists, saved last) - check('the saved feeds are still there; the session topic is not', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', 'Coin talk', 'Pack feed', 'Everything', '*Follows + lists', '+']), JSON.stringify(await chips())); + check('the saved feeds are still there; the session topic is not', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Conversations', 'Popular', '#gardenstr', 'Coin talk', 'Pack feed', 'Everything', '*Follows + lists', '+']), JSON.stringify(await chips())); // (the chat home no longer lists the feed with its summary: its editor shows the pack) await chip('Pack feed'); await sleep(800); await page.click('[aria-label="Feed options"]'); await sleep(300); @@ -199,7 +199,7 @@ try { await page.click('[aria-label="Feed options"]'); await sleep(300); check('the menu offers Delete feed beside Edit', await page.evaluate(() => { const b = [...document.querySelectorAll('.msg-sheet button')].map((x) => x.textContent); return b.some((x) => /edit feed/i.test(x)) && b.some((x) => /delete feed/i.test(x)); })); await page.evaluate(() => [...document.querySelectorAll('.msg-sheet button')].find((b) => /delete feed/i.test(b.textContent)).click()); await sleep(1000); - check('deleting it from the menu lands back on Following', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Popular', '#gardenstr', 'Pack feed', 'Everything', 'Follows + lists', '+']), JSON.stringify(await chips())); + check('deleting it from the menu lands back on Following', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Conversations', 'Popular', '#gardenstr', 'Pack feed', 'Everything', 'Follows + lists', '+']), JSON.stringify(await chips())); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ feeds: Following is one of many' : '\n❌ failed'); -- cgit v1.2.3