diff options
| author | Adam Soltys <[email protected]> | 2026-09-28 22:58:40 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-28 22:58:40 -0700 |
| commit | c0e428aff3ff34fec56a434bc1837e49ab9c1a81 (patch) | |
| tree | 63bb5e1f314d2a53b9551be17bf6fa4a261958e4 /src | |
| parent | 86d5609a188ce4dae248d1b56c4563bae299af31 (diff) | |
| download | coinosv3-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
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 132 | ||||
| -rw-r--r-- | src/feed-cache.js | 2 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/style.css | 18 |
4 files changed, 143 insertions, 14 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 |
