aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-28 22:58:40 -0700
committerAdam Soltys <[email protected]>2026-09-28 22:58:40 -0700
commitc0e428aff3ff34fec56a434bc1837e49ab9c1a81 (patch)
tree63bb5e1f314d2a53b9551be17bf6fa4a261958e4
parent86d5609a188ce4dae248d1b56c4563bae299af31 (diff)
downloadcoinosv3-c0e428aff3ff34fec56a434bc1837e49ab9c1a81.tar.gz
coinosv3-c0e428aff3ff34fec56a434bc1837e49ab9c1a81.zip
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp
-rw-r--r--src/features/messages.js132
-rw-r--r--src/feed-cache.js2
-rw-r--r--src/i18n.js5
-rw-r--r--src/style.css18
-rw-r--r--tools/poll-test.js93
-rw-r--r--tools/thread-cache-test.js3
6 files changed, 238 insertions, 15 deletions
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 }));
}