From 509d632595399bc355fcf3826875214dc9d3b68c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 1 Oct 2026 08:55:18 -0700 Subject: Each post's reply button shows how many replies it has. Replies (kind 1 and NIP-22 comments) are asked in their own batched #e request beside the likes/zaps one and counted under every post of the conversation they name; quotes ('mention') aren't replies, muted authors aren't counted, and a sent reply counts at once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VP5Ljqk7EYcm5QQAbzDwog --- src/features/messages.js | 36 +++++++++++++++++++++++-- tools/reply-count-test.js | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tools/reply-count-test.js diff --git a/src/features/messages.js b/src/features/messages.js index 73d4c03..89e9425 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3131,6 +3131,11 @@ export function messagesFeature(ctx) { // ...and, in the same REQ, what else happened to a note: likes (kind 7) // and boosts (kind 6). One round trip for all three. const NOTE_KINDS = [9735, 9737, 7, 6]; + // Replies (kind 1, and NIP-22 comments) so the reply button can say how + // many — asked separately: relays cap a REQ's answer, and a busy post's + // replies must not crowd its likes and zaps out of it. + const REPLY_KINDS = [1, 1111]; + const replyIds = new Map(); // note id -> Map(reply id -> author pubkey) const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set }) // Resolve from this reaction's own tag: packs can reuse the same shortcode // for different pictures, and an unrelated event must not change its face. @@ -3196,6 +3201,7 @@ export function messagesFeature(ctx) { // Where an event about a note goes. A zap receipt is money and has its own // bookkeeping (see below); a like and a boost are just tallies of who. function noteEvent(ev) { + if (ev.kind === 1 || ev.kind === 1111) { noteReply(ev); return; } if (ev.kind === 7 || ev.kind === 6) { if (seenNoteEv.has(ev.id)) return; seenNoteEv.add(ev.id); @@ -3227,6 +3233,28 @@ export function messagesFeature(ctx) { noteReceipt(ev); } + // A reply counts under every post of the conversation it names: NIP-10's + // root and reply (or the bare positional e tags of older clients), NIP-22's + // E and e. A 'mention' e tag is a quote, not a reply. + function noteReply(ev) { + let changed = false; + for (const x of ev.tags || []) { + if (!x[1] || x[1] === ev.id) continue; + if (!(x[0] === 'e' || (ev.kind === 1111 && x[0] === 'E')) || x[3] === 'mention') continue; + let m = replyIds.get(x[1]); + if (!m) replyIds.set(x[1], m = new Map()); + if (!m.has(ev.id)) { m.set(ev.id, ev.pubkey); changed = true; } + } + if (changed) scheduleRepaint(); + } + function replyCount(id) { + const m = replyIds.get(id); + if (!m) return 0; + let n = 0; + for (const pk of m.values()) if (!hiddenPk(pk)) n++; + return n; + } + function noteReceipt(ev) { const sats = receiptSats(ev); if (!sats) return; @@ -3264,6 +3292,7 @@ export function messagesFeature(ctx) { const relays = zapRelays(); for (let i = 0; i < batch.length; i += 150) { const slice = batch.slice(i, i + 150); + queryOn(relays, { kinds: REPLY_KINDS, '#e': slice }, 4000).then((evs) => evs.forEach(noteReply)).catch(() => {}); queryOn(relays, { kinds: NOTE_KINDS, '#e': slice }, 4000) .then((evs) => { evs.forEach(noteEvent); @@ -3283,7 +3312,7 @@ export function messagesFeature(ctx) { } zapRecent = [...batch.reverse(), ...zapRecent.filter((x) => !batch.includes(x))].slice(0, 200); if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} } - zapLiveUnsub = subscribeOn(relays, { kinds: NOTE_KINDS, '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteEvent); + zapLiveUnsub = subscribeOn(relays, { kinds: [...NOTE_KINDS, ...REPLY_KINDS], '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteEvent); }, 250); } // Tallies are asked of the relays again on every boot, and that takes @@ -6817,7 +6846,7 @@ export function messagesFeature(ctx) { const zapMine = !!((z && z.mine) || optimistic); const zapLabel = zapFlying ? t('zapSending') : zapSats ? t('zapTallyTitle', { n: zapSats.toLocaleString() }) : t('zapTitle'); return h('div', { class: 'row note-acts' }, - btn(I_REPLY, t('msgReply'), 0, false, () => { if (!signinAsk()) replyToNote(ev); }), + btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev); }), btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})), btn(I_QUOTE, t('postQuote'), 0, false, () => { if (!signinAsk()) quoteNote(ev); }), // tap to choose how you feel about it; tap again to take it back @@ -7269,6 +7298,7 @@ export function messagesFeature(ctx) { const ok = await publishOn(relays, evt); if (!ok) throw new Error(t('msgSendFailed')); c.replies = [...c.replies, evt]; + noteReply(evt); // the count under the post goes up at once persistThread(c); return evt; } @@ -7276,6 +7306,8 @@ export function messagesFeature(ctx) { function threadScreen() { const s = ui.noteThread; const c = threadFor(s.seed); + // the conversation on screen is the best count of its own replies + for (const e of c.replies) noteReply(e); const boxUnder = (ev) => !!(s.replying || (s.draft || '').trim()) && (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id)); const row = (ev) => { const focus = ev.id === s.focusId && ev.id !== c.rootId; diff --git a/tools/reply-count-test.js b/tools/reply-count-test.js new file mode 100644 index 0000000..659d629 --- /dev/null +++ b/tools/reply-count-test.js @@ -0,0 +1,68 @@ +// Each post's reply button carries the number of replies the relays know. +// Run: bun tools/reply-count-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +import { generateSecretKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000); +const notes = Array.from({ length: 40 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 60 - i * 60, tags: [], + content: `Post number ${i}. I walked down to the harbour this morning and watched the boats come in while the coffee went cold in my hands.` }, sk)); +const replier = generateSecretKey(); +const reply = (target, n, tags) => finalizeEvent({ kind: 1, created_at: now - 30 + n, content: 'Reply ' + n, tags }, replier); +// post 0: two NIP-10 replies and a nested one (its root counts all three); +// post 1: one positional reply and a quote that is not a reply +const r1 = reply(notes[0], 1, [['e', notes[0].id, '', 'root']]); +const replies = [r1, reply(notes[0], 2, [['e', notes[0].id, '', 'root']]), + reply(notes[0], 3, [['e', notes[0].id, '', 'root'], ['e', r1.id, '', 'reply']]), + reply(notes[1], 4, [['e', notes[1].id]]), + reply(notes[1], 5, [['e', notes[1].id, '', 'mention']])]; +const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Poster' }) }, sk); +const html = await buildHtml({ minify: true, pwa: false }); +// the public feed's shared snapshot (Popular): the posts arrive over HTTP +const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes, profiles: [profile], popUntil: now - 3600 }); +const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/api/feed' + ? new Response(snapshot, { headers: { 'content-type': 'application/json' } }) + : new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); + await page.evaluateOnNewDocument(([notes, profile, replies]) => { + class FakeSocket extends EventTarget { + constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set(); + setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); } + emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); } + message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); } + send(raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; } + if (m[0] !== 'REQ') return; + const [, id, f] = m; this.subs.add(id); + let evs = []; + if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until) + && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))).slice(0, f.limit || 100); + if (f.kinds?.includes(0) && f.authors?.includes(profile.pubkey)) evs = [profile]; + if (f['#e']) evs = f.kinds?.includes(1) ? replies.filter((r) => r.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1]))) : []; + if (f.ids) evs = notes.filter((e) => f.ids.includes(e.id)); + setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30); + } + close() { this.readyState = 3; this.emit('close', new Event('close')); } + } + Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + window.WebSocket = FakeSocket; + }, [notes, profile, replies]); + await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 10, { timeout: 20000 }).catch(async (e) => { console.log(await page.evaluate(() => ({ t: document.body.innerText.slice(0, 600), path: location.pathname, cls: [...document.querySelectorAll("[class*=feed]")].map(e=>e.className).slice(0,10) })), errors); throw e; }); + const count = (id) => page.evaluate((id) => document.querySelector(`.notes-feed > [data-key="${id}"] .note-acts > .note-act:first-child .note-act-n`)?.textContent || '', id); + await page.waitForFunction((id) => document.querySelector(`.notes-feed > [data-key="${id}"] .note-acts > .note-act:first-child .note-act-n`), { timeout: 10000 }, notes[0].id); + assert.equal(await count(notes[0].id), '3', 'root post counts its whole conversation'); + assert.equal(await count(notes[1].id), '1', 'a quote is not a reply'); + assert.equal(await count(notes[2].id), '', 'no replies, no number'); + assert.deepEqual(errors, []); + console.log('✓ reply buttons show 3 / 1 / nothing'); +} finally { await browser.close(); server.stop(true); } -- cgit v1.2.3