feat: implement NIP-88 polls #128
Reference in New Issue
Block a user
Delete Branch "(deleted):dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
This PR adds first-class Nostr poll support to Flotilla using NIP-88 concepts.
Addresses #77
Polls are now integrated as a content type across the app with:
What’s included
Poll domain logic
src/app/util/polls.tsfor:singlechoice/multiplechoice)UI components
src/app/components/PollCreate.sveltesrc/app/components/PollItem.sveltesrc/app/components/PollVotes.sveltesrc/app/components/NoteContentPoll.sveltesrc/app/components/NoteContentMinimalPoll.sveltesrc/app/components/NoteContent.sveltesrc/app/components/NoteContentMinimal.svelteRoutes
src/routes/spaces/[relay]/polls/+page.sveltesrc/routes/spaces/[relay]/polls/[id]/+page.svelteCommands and publishing
src/app/core/commands.tskind: 1018referencing poll event id (etag) withresponsetags.Navigation and pathing
src/app/util/routes.tssrc/app/util/title.tssrc/app/components/SpaceMenu.sveltesrc/app/components/ComposeMenu.sveltesrc/routes/spaces/[relay]/recent/+page.svelteSync/state integration
src/app/core/state.tssrc/app/core/sync.tsBehavior notes
1068(NIP-88 Poll)1018(NIP-88 PollResponse)Validation
pnpm run checkpassesFollow-ups (optional)
Some images related to work:-
Poll Option:
Poll Creation Format:
Poll Preview:
Poll after Casting Vote:
Commenting on Pole:
Does this deduplicate votes per pubkey?
@@ -63,0 +67,4 @@<li><Button onclick={createPoll}><Icon size={4} icon={Revote} />PollLet's call it "Ask a Question"
Sure. I've changed this. The compose action now says Ask a Question for clearer intent.
@@ -0,0 +11,4 @@const responses = deriveArray(deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [props.event.id]}]}),)Use
deriveEventsfromapp/state/core.tsUpdated. I switched response loading in minimal poll content to use deriveEvents from core state.
@@ -0,0 +16,4 @@const results = derived(responses, $responses => getPollResults(props.event, $responses))</script><div class="flex flex-col gap-1">No gap looks better here
Updated. I removed the extra gap in the minimal poll preview.
@@ -0,0 +12,4 @@const responses = deriveArray(deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [props.event.id]}]}),)Use
deriveEventsUpdated. I switched NoteContentPoll response derivation to deriveEvents.
@@ -0,0 +28,4 @@<p class="text-xs opacity-50">{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}{#if endsAt}· Ends {formatTimestampRelative(endsAt)}Use a bullet point instead of this symbol for a slightly heftier appearance.
Also, let's check if the timestamp is in the past and show "Ended at" instead of showing "closed"
Updated. I replaced the separator symbol with a bullet for the poll metadata line. Also, for ended polls with an end timestamp, the copy now uses Ended instead of showing Closed.
@@ -0,0 +51,4 @@<progress class="progress progress-primary" value={option.votes} max={maxVotes}></progress></div>{/each}</div>Let's make it possible to vote inline without clicking through to the detail page.
Updated. PollVotes is now rendered inline in note content so users can vote without opening detail view
@@ -0,0 +51,4 @@return pushToast({theme: "error", message: "Please provide at least two options."})}const parsedEndsAt = endsAt ? Math.floor(new Date(endsAt).getTime() / 1000) : undefinedUse
now()Updated. I switched PollCreate time handling to use now() for end-time validation logic.
@@ -0,0 +80,4 @@}let title = $state("")let pollType = $state<"singlechoice" | "multiplechoice">("singlechoice")Use
PollTypeUpdated. PollCreate now uses the shared PollType type.
@@ -0,0 +89,4 @@<ModalBody><ModalHeader><ModalTitle>Create a Poll</ModalTitle><ModalSubtitle>Ask the room a question with one or more answers.</ModalSubtitle>Change this to "Ask a multiple choice question"
Updated. I changed the subtitle copy to clarify question-style poll creation.
@@ -0,0 +115,4 @@{/snippet}{#snippet input()}<div class="flex flex-col gap-2">{#each options as option, index (index)}Let's make these draggable/droppable. I'm not sure using the index as the key will work in that case.
Updated. Poll options are now draggable/droppable, and I switched from index keys to stable option ids.
@@ -0,0 +132,4 @@<Button class="btn btn-ghost btn-sm self-start" onclick={addOption}><Icon icon={PlusCircle} size={4} />Add option</Button>Right align this and make it a btn-primary
Updated. The add-option action is now right-aligned and styled as a stronger action button.
@@ -0,0 +157,4 @@<inputbind:value={endsAt}class="input input-bordered w-full max-w-xs"type="datetime-local" />Use DateTimeInput for a better UX
Updated. PollCreate now uses DateTimeInput instead of raw datetime-local input.
@@ -0,0 +25,4 @@const responses = deriveArray(deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [event.id]}]}),)Use
deriveEventsUpdated. PollVotes now uses deriveEvents.
@@ -0,0 +32,4 @@const closed = isPollClosed(event)const endsAt = getPollEndsAt(event)const results = $derived.by(() => getPollResults(event, $responses))byis unnecessary, just use$derived(getPollResults(event, $responses))Updated. I removed unnecessary $derived.by usage and replaced it with direct $derived(getPollResults(event, $responses)) for poll results.
@@ -0,0 +37,4 @@() =>$responses.filter(response => response.pubkey === $pubkey).sort((left, right) => right.created_at - left.created_at)[0],This can be simplified to
$derived(sortEventsDesc($responses.filter(spec({pubkey}))))Updated. I simplified own-response selection logic to avoid the heavier inline sort/filter chain while still selecting the latest response by the current user.
@@ -0,0 +82,4 @@{/if}{#if closed}· Closed{/if}Handle past timestamps ("Ended at") and hide the closed one since it's covered by the copy change
Updated. Past timestamps now show Ended wording, and the separate Closed label was removed.
@@ -0,0 +10,4 @@votes: number}const isDefined = <T>(value: T | undefined): value is T => value !== undefinedImport this from
@welshman/libUpdated. Poll parsing now uses shared helper imports from Welshman utilities instead of a local isDefined helper.
@@ -0,0 +27,4 @@.filter(isDefined)export const getPollEndsAt = (event: TrustedEvent) => {const endsAt = getTagValue("endsAt", event.tags) || getTagValue("endsat", event.tags)Any particular reason to use the non standard
endsatformat?I initially added endsat as a defensive compatibility fallback in case older or non-compliant poll events were already circulating, but there wasn’t a strong enough reason to keep that non-standard path in core logic. I’ve removed it now and parse only the NIP-88-compliant endsAt tag so behavior stays spec-aligned and consistent across clients.
@@ -0,0 +45,4 @@export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => {const selections = getTags("response", event.tags).map(tag => tag[1]).filter(isDefined)This can be simplified to
removeUndefined(getTagValues('response', event.tags)).removeUndefinedcomes from@welshman/lib.Updated. Response tag parsing is simplified via getTagValues and no longer uses the manual map/filter pattern.
@@ -0,0 +71,4 @@<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full"><div class="col-3 ml-12 flex flex-col gap-3"><NoteContent showEntire event={$event} {url} /><PollVotes {url} event={$event} />We can remove this if we make it possible to vote in NoteContentPoll. No need to show the results twice.
Updated. I removed the duplicate PollVotes block from the detail page since voting/results are now inline in note content.
@@ -0,0 +123,4 @@{#if !closed}<div class="flex justify-end"><Button class="btn btn-primary btn-sm" onclick={submit}>Cast vote</Button>Disable this if nothing is selected, or if the user has already voted.
Updated. I also added an on-mount response request for poll content so we fetch responses directly instead of relying only on sync.
Looks pretty good, I've added lots of minor comments, but the main thing is to move PollVotes into NoteContentPoll so that users can cast votes without opening the poll's detail page.
@@ -0,0 +18,4 @@const endsAt = getPollEndsAt(props.event)const pollType = getPollType(props.event)const closed = isPollClosed(props.event)Add a call to load poll responses on mount to make sure we have everything (we shouldn't trust sync to fetch all responses)
Main review point addressed: PollVotes is now part of NoteContentPoll, so voting works inline from the feed. Empty selection is already blocked, but I have not yet added a disabled button state for “already voted”. I can add that behavior in the next patch if you want strict one-vote UX in the UI layer.
@@ -0,0 +14,4 @@</script><div class="flex flex-col gap-0"><span class="text-sm">{props.event.content || "Poll"}</span>Use
Contenthere so stuff gets rendered properlyUpdated. I switched the minimal poll renderer to use
Contentso links, mentions, and other content formatting render correctly.@@ -0,0 +21,4 @@</script><div class="flex flex-col gap-3"><p class="text-xl">{props.event.content || "Poll"}</p>Use
Contenthere so stuff gets rendered properlyUpdated. I switched the full poll renderer to use
Contentso poll text is rendered consistently with the rest of the app.@@ -0,0 +83,4 @@</script><div class="flex flex-col gap-3 rounded-box bg-base-200 p-4"><div class="flex flex-wrap items-center justify-between gap-2">Don't wrap this in a card to avoid too much nested noise (also, use card2 bg-alt instead of rounded-box bg-base-200)
Also, we'll need to preventdefault/stoppropagation when the user clicks to vote so the detail page doesn't get opened.
Updated. I changed the poll vote block to use
card2 bg-altand removed the extra card-style nesting to keep the layout cleaner. Also, vote clicks now stop propagation on the actual option inputs so interacting with a poll does not open the detail page.@@ -0,0 +135,4 @@<div class="flex justify-end"><Button class="btn btn-primary btn-sm" onclick={submit}>Cast vote</Button></div>{/if}Let's remove this button and publish events when the user checks an option. If the poll is multi-select, use a 10 second delay when passing to
publishThunk, canceling the previous thunk when a new selection is added. That way we can immediately show the new vote state on user action.Updated. I removed the Cast vote button and now publish as soon as the user changes a selection. For multi-select polls, updates are delayed by 10 seconds and the previous pending thunk is aborted when the selection changes.
Thanks @bhavishy2801, this looks pretty good. I made some small tweaks on another branch, but the git history was a little funky so I created a new commit (
fe20fbfd). In the future, try to rebase ondevto keep conflicts to a minimum.Pull request closed