From 2bbff9ac91bf310f63216523cb418474ca64db64 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:25:57 -0700 Subject: A reloaded thread paints whole, quoted posts included, with no spinner. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread cache kept the replies but not the posts quoted inside them, so every reload showed "Fetching note…" until a relay answered. Quotes (of any kind — the one that showed it was a poll) are now saved with the thread and restored before the first paint. Co-Authored-By: Claude Opus 5.5 --- tools/thread-cache-test.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'tools/thread-cache-test.js') diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js index 485e874..38da691 100644 --- a/tools/thread-cache-test.js +++ b/tools/thread-cache-test.js @@ -19,7 +19,7 @@ check('a fresh store restores full content and tags by any viewed event', () => const long = { ...reply, content: 'x'.repeat(5000), tags: [...reply.tags, ['emoji', 'wave', 'https://example.com/wave.png']] }; store.save({ root, replies: [parent, long] }, long.id); const restored = createThreadStore(storage).find(long.id); - assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent] }); + assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent], quotes: [] }); assert.equal(store.find(parent.id).root.id, root.id); }); check('thread authors keep their names and local avatar thumbnails across reloads', () => { @@ -36,6 +36,12 @@ check('thread action totals are present before relay refresh and survive a conte store.save({ root, replies: [parent, reply] }, reply.id); assert.deepEqual(createThreadStore(storage).find(reply.id).counts[reply.id], { likes: 29, boosts: 7, sats: 210 }); }); +check('posts quoted inside the thread are restored with it, and a later save keeps them', () => { + const q = note(77), poll = { ...note(78), kind: 1068 }; // a quote can be any kind + store.save({ root, replies: [parent, reply] }, reply.id, {}, {}, [q, poll]); + store.save({ root, replies: [parent, reply] }, reply.id); // a relay refresh with no quotes resolved yet + assert.deepEqual(createThreadStore(storage).find(reply.id).quotes, [q, poll]); +}); check('a large thread retains the selected reply and its ancestors', () => { const siblings = Array.from({ length: 200 }, (_, i) => note(1000 + i, [['e', root.id, '', 'root']])); store.save({ root, replies: [...siblings, parent, reply] }, reply.id); -- cgit v1.2.3 From 86d5609a188ce4dae248d1b56c4563bae299af31 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:53:37 -0700 Subject: A thread that starts from a poll (or any non-kind-1 post) is cached too, so a reload paints its replies at once. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread store accepted only kind 1 and silently skipped saving a thread rooted in a kind 1068 poll — every refresh then waited on the relays behind a spinner. Also guard the profile Back listener for window-less tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 2 +- src/thread-cache.js | 7 +++++-- tools/thread-cache-test.js | 8 ++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) (limited to 'tools/thread-cache-test.js') diff --git a/src/features/messages.js b/src/features/messages.js index dd31f46..2a3b400 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3025,7 +3025,7 @@ export function messagesFeature(ctx) { // reader deep in that person's posts, which read as a thread. Back (a // popstate) puts them where they were on the page they left. const profReturn = []; // [{ pk, y }] per profile opened, newest last - window.addEventListener('popstate', () => { + if (typeof window !== 'undefined') window.addEventListener('popstate', () => { const top = profReturn[profReturn.length - 1]; if (!top || ui.profilePk === top.pk) return; profReturn.pop(); diff --git a/src/thread-cache.js b/src/thread-cache.js index 0606f12..fca64e6 100644 --- a/src/thread-cache.js +++ b/src/thread-cache.js @@ -6,13 +6,16 @@ const MAX_THREADS = 12; const MAX_EVENTS = 160; const MAX_QUOTES = 24; // posts quoted inside the thread, so a reload paints them too const MAX_BYTES = 1_000_000; // JSON code units; at most about 2 MB in storage -const validNote = (e) => e?.kind === 1 && typeof e.id === 'string' +// Any kind: a thread can hang off a poll (1068), a picture (20), a comment +// (1111)… — requiring kind 1 left those threads uncached, so every reload +// sat on a spinner while the relays answered. +const validNote = (e) => Number.isSafeInteger(e?.kind) && e.kind >= 0 && typeof e.id === 'string' && /^[0-9a-f]{64}$/.test(e.id) && typeof e.pubkey === 'string' && /^[0-9a-f]{64}$/.test(e.pubkey) && typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags) && e.tags.every((t) => Array.isArray(t) && t.every((v) => typeof v === 'string')); // A quote can be any kind of event (a poll, an article…), not only a note. -const validQuote = (e) => Number.isSafeInteger(e?.kind) && e.kind >= 0 && validNote({ ...e, kind: 1 }); +const validQuote = validNote; const savedNote = ({ id, pubkey, kind, created_at, content, tags }) => ({ id, pubkey, kind, created_at, content, tags }); const savedProfile = (p) => { if (!p || typeof p !== 'object') return null; diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js index 38da691..e8b0ccf 100644 --- a/tools/thread-cache-test.js +++ b/tools/thread-cache-test.js @@ -22,6 +22,14 @@ check('a fresh store restores full content and tags by any viewed event', () => assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent], quotes: [] }); assert.equal(store.find(parent.id).root.id, root.id); }); +check('a thread rooted in a poll (kind 1068) is kept too', () => { + const poll = { ...note(40), kind: 1068, tags: [['option', '0', 'Yes!'], ['option', '1', 'No!']] }; + const answer = note(41, [['e', poll.id, '', 'root']]); + store.save({ root: poll, replies: [answer] }, poll.id); + const restored = createThreadStore(storage).find(poll.id); + assert.equal(restored?.root.kind, 1068); + assert.deepEqual(restored.replies, [answer]); +}); check('thread authors keep their names and local avatar thumbnails across reloads', () => { const thumb = 'data:image/webp;base64,AAAA'; const face = { name: 'Nostrich', picture: 'https://example.com/face.webp', eventAt: 50, -- cgit v1.2.3 From c0e428aff3ff34fec56a434bc1837e49ab9c1a81 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:58:40 -0700 Subject: Polls (NIP-88): options with each one's share of the vote, votable from the feed and the thread. A kind 1068 poll showed as "an event this app doesn't show". Now its options are rows with the vote share behind them (kind 1018 votes, each person's latest counts, single/multiple choice, endsAt honored); a tap votes, marked as yours at once. A quoted poll is pictured in its card. Feeds and profiles list polls alongside notes, and a thread's root is looked up by id with any kind, so replies to a poll find it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 132 ++++++++++++++++++++++++++++++++++++++++----- src/feed-cache.js | 2 +- src/i18n.js | 5 ++ src/style.css | 18 +++++++ tools/poll-test.js | 93 ++++++++++++++++++++++++++++++++ tools/thread-cache-test.js | 3 +- 6 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 tools/poll-test.js (limited to 'tools/thread-cache-test.js') diff --git a/src/features/messages.js b/src/features/messages.js index 2a3b400..c6a99b5 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -446,6 +446,8 @@ export function messagesFeature(ctx) { if (wallet.nostr && wallet.nostr.sk) return { pubkey: wallet.nostr.pk, signer: wallet.nostr.sk }; return null; } + // what a feed or a profile lists: notes, and polls (NIP-88) + const FEED_KINDS = [1, 1068]; const myPubkeys = () => { const pks = []; const id = hook('nostrLoginIdentity'); @@ -3461,7 +3463,7 @@ export function messagesFeature(ctx) { : { status: 'loading', notes: seed }; notesCache.set(pk, c); (async () => { - const evs = await queryOn(await notesRelays(pk), { kinds: [1], authors: [pk], limit: 30 }, 4500); + const evs = await queryOn(await notesRelays(pk), { kinds: FEED_KINDS, authors: [pk], limit: 30 }, 4500); const seen = new Set(); const fresh = (evs || []) .filter((e) => !seen.has(e.id) && seen.add(e.id)) @@ -3507,7 +3509,7 @@ export function messagesFeature(ctx) { render(); try { const evs = await queryOn(await notesRelays(pk), - { kinds: [1], authors: [pk], limit: 30, until: oldest.created_at - 1 }, 4500); + { kinds: FEED_KINDS, authors: [pk], limit: 30, until: oldest.created_at - 1 }, 4500); const seen = new Set(c.notes.map((e) => e.id)); const older = (evs || []) .filter((e) => !seen.has(e.id) && seen.add(e.id)) @@ -4290,10 +4292,10 @@ export function messagesFeature(ctx) { return; } try { - const events = await queryOn(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: [1], limit: FEED_PAGE, + const events = await queryOn(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE, ...(authors.length ? { authors } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000); if (c.stopped) return; - await mergeFeed(events.filter((e) => e.kind === 1 && !isReply(e) && !hidden(e)) + await mergeFeed(events.filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e)) .sort((a, b) => b.created_at - a.created_at).slice(0, FEED_PAGE), {}, c); } catch {} finally { if (!c.stopped) { @@ -4744,7 +4746,7 @@ export function messagesFeature(ctx) { if (!staged) feedStaged.set(c, staged = new Set()); const known = new Set([...c.notes, ...(c.catchup || []), ...(c.deferred || [])].map((e) => e.id)); for (const e of evs || []) noteForSpam(e); - let add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !hidden(e) + let add = (evs || []).filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e) && !known.has(e.id) && !staged.has(e.id) && known.add(e.id) && staged.add(e.id)); if (!add.length) return false; const def = feedDef(c.id); @@ -4872,7 +4874,7 @@ export function messagesFeature(ctx) { // the firehose asks its relays for everything if (!topics.length && !def.all) return false; const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS); - const evs = await queryOn(relays, { kinds: [1], ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); + const evs = await queryOn(relays, { kinds: FEED_KINDS, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); } return got; } @@ -4883,7 +4885,7 @@ export function messagesFeature(ctx) { const chunks = []; for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS)); return chunks.map(async (chunk) => { - const evs = await queryOn(relays, { kinds: [1], authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); + const evs = await queryOn(relays, { kinds: FEED_KINDS, authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); } }); })); @@ -5054,11 +5056,11 @@ export function messagesFeature(ctx) { const on = (ev) => { mergeFeed([ev], { live: true }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; const authors = feedAuthors(def); if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; } - if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: [1], ...tag, since }, on)); return; } + if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: FEED_KINDS, ...tag, since }, on)); return; } const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); for (const { relays, authors: a } of plan) for (let i = 0; i < a.length; i += REQ_AUTHORS) - feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); + feedUnsubs.push(subscribeOn(relays, { kinds: FEED_KINDS, authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); } function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } @@ -5379,7 +5381,8 @@ export function messagesFeature(ctx) { h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, // one level deep only: a quote of a quote of a quote is a rabbit // hole, and the inner one stays a link you can follow - ...noteBody(ev.content, depth + 1, emojiTagMap(ev.tags)))); + ...noteBody(ev.content, depth + 1, emojiTagMap(ev.tags))), + ev.kind === POLL ? pollBox(ev, { interactive: false }) : null); } // One URL, rendered as whatever it points at. `isImage` is markdown saying @@ -5853,6 +5856,108 @@ export function messagesFeature(ctx) { } // React with whichever emoji was picked (NIP-25 takes any content). + // ---- polls (NIP-88) ------------------------------------------------------- + // A kind 1068 poll is its question (content) plus ["option", id, label] + // tags; votes are kind 1018 events e-tagging it with ["response", id]. + // Each person's LATEST vote counts (one option unless the poll says + // "multiplechoice"), and nothing cast after its endsAt. + const POLL = 1068, POLL_VOTE = 1018; + const pollOptions = (ev) => ev.tags.filter((x) => x[0] === 'option' && x[1] != null && x[2]) + .map((x) => ({ id: String(x[1]), label: String(x[2]) })); + const pollMulti = (ev) => (ev.tags.find((x) => x[0] === 'polltype') || [])[1] === 'multiplechoice'; + const pollEnds = (ev) => { const v = Number((ev.tags.find((x) => x[0] === 'endsAt') || [])[1]); return Number.isFinite(v) && v > 0 ? v : 0; }; + const pollEnded = (ev) => !!pollEnds(ev) && Date.now() / 1000 > pollEnds(ev); + const pollRelays = async (ev) => [...new Set([ + ...ev.tags.filter((x) => x[0] === 'relay' && /^wss?:\/\//i.test(x[1] || '')).map((x) => x[1]), + ...(await noteRelaysFor(ev).catch(() => zapRelays())), + ])].slice(0, 12); + const pollVotes = new Map(); // poll id -> { at, loaded, loading, by: Map(pk -> { t, ids }) } + function pollState(ev) { + let st = pollVotes.get(ev.id); + if (!st) { st = { at: 0, loaded: false, loading: false, by: new Map() }; pollVotes.set(ev.id, st); } + return st; + } + function pollCount(poll, v) { + const st = pollState(poll); + const ends = pollEnds(poll); + if (ends && v.created_at > ends) return; + const prev = st.by.get(v.pubkey); + if (prev && prev.t >= v.created_at) return; + const valid = new Set(pollOptions(poll).map((o) => o.id)); + let ids = [...new Set(v.tags.filter((x) => x[0] === 'response').map((x) => String(x[1])))].filter((id) => valid.has(id)); + if (!pollMulti(poll)) ids = ids.slice(0, 1); + st.by.set(v.pubkey, { t: v.created_at, ids }); + } + // Votes are asked for when a poll is painted, and again a minute on. + function pollTally(ev) { + const st = pollState(ev); + if (!st.loading && Date.now() - st.at > 60_000) { + st.loading = true; st.at = Date.now(); + (async () => { + const evs = await queryOn(await pollRelays(ev), { kinds: [POLL_VOTE], '#e': [ev.id], limit: 1000 }, 5000).catch(() => []); + for (const v of evs || []) if (v.kind === POLL_VOTE) pollCount(ev, v); + })().finally(() => { st.loading = false; st.loaded = true; scheduleRepaint(); }); + } + return st; + } + async function votePoll(poll, optId) { + if (pollEnded(poll)) return; + const id = await requireIdentity(); + const st = pollState(poll); + const prev = st.by.get(id.pubkey); + const mine = prev?.ids || []; + const ids = pollMulti(poll) ? (mine.includes(optId) ? mine.filter((x) => x !== optId) : [...mine, optId]) : [optId]; + if (!pollMulti(poll) && mine[0] === optId) return; + const partial = { + kind: POLL_VOTE, content: '', + // strictly newer than our last vote, or relays and counters keep the old one + created_at: Math.max(Math.floor(Date.now() / 1000), (prev?.t || 0) + 1), + tags: [['e', poll.id], ['p', poll.pubkey], ...ids.map((x) => ['response', x]), CLIENT_TAG], + }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + pollCount(poll, evt); // counted on screen before the relays answer + render(); + const ok = await publishOn(await pollRelays(poll), evt).catch(() => false); + if (!ok) toast(t('msgSendFailed')); + } + // The options as rows with their share of the vote. In a quote card + // (`interactive` off) they are a picture of the poll — the tap opens it. + function pollBox(ev, { interactive = true } = {}) { + const opts = pollOptions(ev); + if (!opts.length) return null; + const st = pollTally(ev); + const counts = new Map(opts.map((o) => [o.id, 0])); + let voters = 0; + for (const v of st.by.values()) { + if (!v.ids.length) continue; + voters++; + for (const x of v.ids) counts.set(x, (counts.get(x) || 0) + 1); + } + const mine = new Set(myPubkeys().flatMap((pk) => st.by.get(pk)?.ids || [])); + const ended = pollEnded(ev); + const canVote = interactive && !ended; + const shown = st.loaded || voters > 0; + const foot = [ + shown ? t(voters === 1 ? 'pollVoter' : 'pollVoters', { n: voters }) : null, + pollMulti(ev) ? t('pollMulti') : null, + ended ? t('pollEnded') : pollEnds(ev) ? t('pollEndsAt', { when: new Date(pollEnds(ev) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) }) : null, + ].filter(Boolean).join(' \u00b7 '); + return h('div', { class: 'poll' }, + ...opts.map((o) => { + const n = counts.get(o.id) || 0; + const pct = voters ? Math.round((n / voters) * 100) : 0; + return h(canVote ? 'button' : 'div', { + class: 'poll-opt' + (mine.has(o.id) ? ' mine' : '') + (canVote ? '' : ' static'), + type: canVote ? 'button' : undefined, + onClick: canVote ? (e) => { e.stopPropagation(); votePoll(ev, o.id).catch(() => {}); } : undefined, + }, + h('span', { class: 'poll-bar', style: 'width:' + (shown ? pct : 0) + '%' }), + h('span', { class: 'poll-label' }, (mine.has(o.id) ? '\u2713 ' : '') + o.label), + shown ? h('span', { class: 'poll-pct' }, pct + '%') : null); + }), + foot ? h('div', { class: 'small faint poll-foot' }, foot) : null); + } + async function reactTo(ev, emoji) { const id = await requireIdentity(); const relays = await noteRelaysFor(ev); @@ -6495,7 +6600,7 @@ export function messagesFeature(ctx) { // An event of a kind this app has no renderer for (a chess game, kind // 64, reached through a reaction to it) is shown as what its author's // client said it is (NIP-31 alt), never as raw content dressed as a post. - const foreign = ev.kind !== 1 && ev.kind !== 30023; + const foreign = ev.kind !== 1 && ev.kind !== 30023 && ev.kind !== POLL; const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null; const isReply = !foreign && ev.tags.some((x) => x[0] === 'e'); const canZap = canZapPk(pk); @@ -6545,6 +6650,7 @@ export function messagesFeature(ctx) { foreign ? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText) : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content, 0, emojiTagMap(ev.tags))), + ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), !pending && whoOpen(ev.id) ? whoPanel(ev) : null)); } @@ -6667,7 +6773,7 @@ export function messagesFeature(ctx) { }; let relays = await relaysFor(c.root || seed); const [roots, replies] = await Promise.all([ - c.root ? Promise.resolve([]) : queryOn(relays, { kinds: [1], ids: [rootId] }, 4000), + c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500), ]); if (!c.root) c.root = (roots || [])[0] || null; @@ -6681,7 +6787,7 @@ export function messagesFeature(ctx) { if (up === top.id) break; relays = [...new Set([...relays, ...(await relaysFor(top))])].slice(0, 14); const [ups, more] = await Promise.all([ - queryOn(relays, { kinds: [1], ids: [up] }, 4000), + queryOn(relays, { ids: [up] }, 4000), queryOn(relays, { kinds: [1], '#e': [up], limit: 80 }, 4500), ]); const upNote = (ups || [])[0]; diff --git a/src/feed-cache.js b/src/feed-cache.js index ea562f7..7394393 100644 --- a/src/feed-cache.js +++ b/src/feed-cache.js @@ -3,7 +3,7 @@ export const FEED_CACHE_POSTS = 30; const MAX_FEEDS = 4, MAX_UNITS = 256_000, MAX_AGE = 7 * 86400_000; export function createFeedCache(storage, prefix, now = Date.now) { const keyOf = (id) => id === 'following' ? prefix : prefix + ':' + id; - const valid = (e) => e?.kind === 1 && typeof e.id === 'string' && typeof e.pubkey === 'string' + const valid = (e) => (e?.kind === 1 || e?.kind === 1068) && typeof e.id === 'string' && typeof e.pubkey === 'string' && typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags); function compact(notes) { const kept = []; let size = 0; diff --git a/src/i18n.js b/src/i18n.js index ce5bf7f..02c60aa 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -460,6 +460,11 @@ const STR = { arkFeeService: 'Service fee', arkCoinsExpired: 'expired', noteForeignKind: 'A kind {kind} event this app doesn’t show.', + pollVoter: '{n} vote', + pollVoters: '{n} votes', + pollMulti: 'Pick any', + pollEnded: 'Poll closed', + pollEndsAt: 'Closes {when}', arkExpiredDust: '{amount} has expired and is too small to renew. You can forget it from the Manage page.', arkCoinForget: 'Forget', arkCoinForgetTitle: 'Give this coin up: it has expired and is too small to renew or exit.', diff --git a/src/style.css b/src/style.css index 15fcad0..9b0bf45 100644 --- a/src/style.css +++ b/src/style.css @@ -768,6 +768,24 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .quote-card.clickable { cursor: pointer; } .quote-card.clickable:hover { border-color: var(--line-strong); } .quote-loading { flex-direction: row; align-items: center; gap: 8px; } +/* A poll (NIP-88): each option a row with its share of the vote as a bar + behind the label. Only the bar's width changes as votes arrive, so the + post never changes height under the reader. */ +.poll { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } +.poll-opt { + position: relative; overflow: hidden; display: flex; align-items: center; gap: 8px; + width: 100%; min-height: 40px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; + background: var(--surface2); color: inherit; font: inherit; text-align: left; cursor: pointer; +} +.poll-opt.static { cursor: inherit; min-height: 34px; padding: 6px 10px; } +.poll-opt:not(.static):hover { border-color: var(--line-strong); } +.poll-opt.mine { border-color: var(--accent); } +.poll-bar { position: absolute; inset: 0 auto 0 0; background: var(--line); transition: width .3s ease; pointer-events: none; } +.poll-opt.mine .poll-bar { background: var(--line-strong); } +.poll-label { position: relative; flex: 1; min-width: 0; overflow-wrap: anywhere; } +.poll-opt.mine .poll-label { font-weight: 600; } +.poll-pct { position: relative; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 14px; } +.poll-foot { margin-top: 2px; } /* Posts that landed while you were reading further down. Floats over the feed near the top, out of the way of the thumb, and takes you up to them when tapped. Fixed, not sticky: the feed is the page scroller, and a diff --git a/tools/poll-test.js b/tools/poll-test.js new file mode 100644 index 0000000..e9cb0d6 --- /dev/null +++ b/tools/poll-test.js @@ -0,0 +1,93 @@ +// Polls (NIP-88, kind 1068): shown as their options with each one's share of +// the vote (kind 1018), votable from the feed and the thread, and pictured +// in a quote card. Uses a real poll from the relays; outgoing votes are +// caught at the socket so the test never votes on someone's real poll. +// +// Run: bun tools/poll-test.js +import puppeteer from 'puppeteer-core'; +import { SimplePool } from 'nostr-tools/pool'; +import { cacheKeyFor } from '../src/wallet.js'; +import { neventOf } from '../src/nostr.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 POLL_ID = '000002f7c6a38223a9843d6e93ce8c15a3048bc95003690d74341cf9de372bfc'; +const pool = new SimplePool(); +const [poll] = await pool.querySync(['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'], { ids: [POLL_ID] }, { maxWait: 6000 }); +if (!poll) { console.log(' ✗ the poll is not on the relays'); process.exit(1); } +const labels = poll.tags.filter((x) => x[0] === 'option').map((x) => x[2]); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5273, fetch: () => 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(); +await page.evaluateOnNewDocument(() => { + window.__votes = []; + const send = WebSocket.prototype.send; + WebSocket.prototype.send = function (d) { + if (typeof d === 'string' && d.startsWith('["EVENT"') && /"kind":1018\b/.test(d)) { window.__votes.push(JSON.parse(d)[1]); return; } + return send.call(this, d); + }; +}); +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, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5273/', { 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 Q = 'a'.repeat(63) + '9'; + await page.evaluate(([k, poll, q, ref]) => { + const now = Math.floor(Date.now() / 1000); + const quoting = { id: 'd'.repeat(64), pubkey: q, kind: 1, created_at: now, content: 'vote on this nostr:' + ref, tags: [] }; + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', poll.pubkey], ['p', q]], c: '', at: now })); + localStorage.setItem(k + ':feedNotes', JSON.stringify([quoting, poll])); + localStorage.setItem(k + ':profiles', JSON.stringify({ [poll.pubkey]: { name: 'Poll Author', t: Date.now() }, [q]: { name: 'Quoter', t: Date.now() } })); + }, [base, poll, Q, neventOf(poll.id, poll.pubkey)]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await waitFor(() => document.querySelectorAll('.notes-feed .poll').length >= 2, 20000); + + const feedPoll = await page.evaluate(() => { + const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((r) => r.querySelector(':scope .poll button.poll-opt')); + return row && { opts: [...row.querySelectorAll('.poll-opt .poll-label')].map((n) => n.textContent), text: row.innerText }; + }); + check('a poll in the feed shows its options', !!feedPoll && JSON.stringify(feedPoll.opts) === JSON.stringify(labels), JSON.stringify(feedPoll?.opts)); + check('...and not the "doesn’t show" placeholder', !(await page.evaluate(() => document.body.innerText.includes('doesn’t show')))); + check('its votes come in from the relays', await waitFor(() => /\d+ votes?/.test(document.querySelector('.notes-feed .poll-foot')?.textContent || ''), 15000), + await page.evaluate(() => document.querySelector('.notes-feed .poll-foot')?.textContent)); + const quoted = await page.evaluate(() => { const q = document.querySelector('.quote-card .poll'); return q && { n: q.querySelectorAll('.poll-opt.static').length, buttons: q.querySelectorAll('button').length }; }); + check('a quoted poll is pictured in its card, not votable there', !!quoted && quoted.n === labels.length && quoted.buttons === 0, JSON.stringify(quoted)); + + // vote for the last option from the feed + const before = await page.evaluate(() => location.href); + await page.evaluate(() => { const b = [...document.querySelectorAll('.notes-feed button.poll-opt')].pop(); b.click(); }); + await waitFor(() => window.__votes.length > 0, 10000); + const vote = await page.evaluate(() => window.__votes[0]); + const lastId = poll.tags.filter((x) => x[0] === 'option').pop()[1]; + check('tapping an option publishes a kind 1018 vote for it', vote && vote.kind === 1018 + && vote.tags.some((x) => x[0] === 'e' && x[1] === poll.id) && vote.tags.filter((x) => x[0] === 'response').map((x) => x[1]).join() === lastId, + JSON.stringify(vote?.tags)); + check('...marked as mine at once', await waitFor(() => !!document.querySelector('.notes-feed button.poll-opt.mine'), 3000)); + check('...without opening the thread', (await page.evaluate(() => location.href)) === before); + + // the thread: the poll as its root + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.querySelector(':scope .poll button.poll-opt')); r.querySelector('.note-text').click(); }); + check('the poll opens as a thread', await waitFor(() => !!document.querySelector('.thread-page .poll button.poll-opt'), 10000)); + check('...still showing my vote', await page.evaluate(() => !!document.querySelector('.thread-page .poll-opt.mine'))); + if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(500); await page.screenshot({ path: process.env.SHOT }); } + check('...with its replies', await waitFor(() => document.querySelectorAll('.thread-page [data-zap-post]').length > 1, 15000)); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ polls read and vote' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js index e8b0ccf..6bac353 100644 --- a/tools/thread-cache-test.js +++ b/tools/thread-cache-test.js @@ -84,7 +84,8 @@ try { const { messagesFeature } = await import(path); let requests = [], waiting = []; globalThis.__threadQuery = (filter) => { - if (filter.kinds?.includes(1) && (filter.ids || filter['#e'])) { + // root lookups by id carry no kind (a root can be a poll); reply queries are kind 1 + if (filter.ids || (filter.kinds?.includes(1) && filter['#e'])) { requests.push(filter); return new Promise((resolve) => waiting.push({ filter, resolve })); } -- cgit v1.2.3