feat: implement NIP-88 polls #128

Closed
Ghost wants to merge 11 commits from (deleted):dev into dev

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:

  • poll creation
  • poll listing in spaces
  • poll detail and voting
  • result aggregation and display
  • routing/navigation/sync integration

What’s included

Poll domain logic

  • Added poll helpers in src/app/util/polls.ts for:
    • poll type handling (singlechoice / multiplechoice)
    • option extraction
    • endsAt parsing and closed-state detection
    • poll response parsing
    • results aggregation (latest vote per pubkey)

UI components

  • Added:
    • src/app/components/PollCreate.svelte
    • src/app/components/PollItem.svelte
    • src/app/components/PollVotes.svelte
    • src/app/components/NoteContentPoll.svelte
    • src/app/components/NoteContentMinimalPoll.svelte
  • Wired poll rendering into:
    • src/app/components/NoteContent.svelte
    • src/app/components/NoteContentMinimal.svelte

Routes

  • Added poll routes:
    • src/routes/spaces/[relay]/polls/+page.svelte
    • src/routes/spaces/[relay]/polls/[id]/+page.svelte

Commands and publishing

  • Added poll response publishing in:
    • src/app/core/commands.ts
  • Poll responses are published as kind: 1018 referencing poll event id (e tag) with response tags.

Navigation and pathing

  • Added poll path support in:
    • src/app/util/routes.ts
  • Added poll title routing in:
    • src/app/util/title.ts
  • Added menu and compose entry points in:
    • src/app/components/SpaceMenu.svelte
    • src/app/components/ComposeMenu.svelte
  • Added recent activity handling in:
    • src/routes/spaces/[relay]/recent/+page.svelte

Sync/state integration

  • Poll kind is included in content kinds:
    • src/app/core/state.ts
  • Poll responses are synced with space content:
    • src/app/core/sync.ts

Behavior notes

  • Poll event kind: 1068 (NIP-88 Poll)
  • Poll response kind: 1018 (NIP-88 PollResponse)
  • Single-choice polls count first response only.
  • Multiple-choice polls deduplicate repeated option selections in a single response.
  • Vote tally uses latest response per pubkey.

Validation

  • pnpm run check passes
  • Pre-commit lint/check hooks pass

Follow-ups (optional)

  • poll edit flow
  • richer analytics UI (percentages, participation breakdown)
  • explicit relay selection UX for poll responses

Some images related to work:-

Poll Option:

PollOption.png

Poll Creation Format:

PollCreationFormat.png

Poll Preview:

PollPreview.png

Poll after Casting Vote:

PollAfterCastingVote.png

Commenting on Pole:

CommentingOnPole.png

## 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: - poll creation - poll listing in spaces - poll detail and voting - result aggregation and display - routing/navigation/sync integration ## What’s included ### Poll domain logic - Added poll helpers in `src/app/util/polls.ts` for: - poll type handling (`singlechoice` / `multiplechoice`) - option extraction - endsAt parsing and closed-state detection - poll response parsing - results aggregation (latest vote per pubkey) ### UI components - Added: - `src/app/components/PollCreate.svelte` - `src/app/components/PollItem.svelte` - `src/app/components/PollVotes.svelte` - `src/app/components/NoteContentPoll.svelte` - `src/app/components/NoteContentMinimalPoll.svelte` - Wired poll rendering into: - `src/app/components/NoteContent.svelte` - `src/app/components/NoteContentMinimal.svelte` ### Routes - Added poll routes: - `src/routes/spaces/[relay]/polls/+page.svelte` - `src/routes/spaces/[relay]/polls/[id]/+page.svelte` ### Commands and publishing - Added poll response publishing in: - `src/app/core/commands.ts` - Poll responses are published as `kind: 1018` referencing poll event id (`e` tag) with `response` tags. ### Navigation and pathing - Added poll path support in: - `src/app/util/routes.ts` - Added poll title routing in: - `src/app/util/title.ts` - Added menu and compose entry points in: - `src/app/components/SpaceMenu.svelte` - `src/app/components/ComposeMenu.svelte` - Added recent activity handling in: - `src/routes/spaces/[relay]/recent/+page.svelte` ### Sync/state integration - Poll kind is included in content kinds: - `src/app/core/state.ts` - Poll responses are synced with space content: - `src/app/core/sync.ts` ## Behavior notes - Poll event kind: `1068` (NIP-88 Poll) - Poll response kind: `1018` (NIP-88 PollResponse) - Single-choice polls count first response only. - Multiple-choice polls deduplicate repeated option selections in a single response. - Vote tally uses latest response per pubkey. ## Validation - `pnpm run check` passes - Pre-commit lint/check hooks pass ## Follow-ups (optional) - poll edit flow - richer analytics UI (percentages, participation breakdown) - explicit relay selection UX for poll responses ## Some images related to work:- ### Poll Option: ![PollOption.png](/attachments/68fbf0f6-c7ed-4de8-b03b-141964697b8a) ### Poll Creation Format: ![PollCreationFormat.png](/attachments/a32db6e4-e0fc-4008-977b-ea46d3112c7e) ### Poll Preview: ![PollPreview.png](/attachments/49449c4d-cd9f-454f-b075-91f2f56a31e2) ### Poll after Casting Vote: ![PollAfterCastingVote.png](/attachments/4c45a082-4923-41a4-9bf0-30d226a36088) ### Commenting on Pole: ![CommentingOnPole.png](/attachments/aba07921-988a-4805-861a-1a5197b82852)
Ghost added 7 commits 2026-04-02 19:47:20 +00:00
Ghost added 1 commit 2026-04-02 19:56:01 +00:00
hodlbod reviewed 2026-04-02 22:13:33 +00:00
hodlbod left a comment
Owner

Does this deduplicate votes per pubkey?

Does this deduplicate votes per pubkey?
@@ -63,0 +67,4 @@
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />
Poll
Owner

Let's call it "Ask a Question"

Let's call it "Ask a Question"

Sure. I've changed this. The compose action now says Ask a Question for clearer intent.

Sure. I've changed this. The compose action now says Ask a Question for clearer intent.
hodlbod marked this conversation as resolved
@@ -0,0 +11,4 @@
const responses = deriveArray(
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [props.event.id]}]}),
)
Owner

Use deriveEvents from app/state/core.ts

Use `deriveEvents` from `app/state/core.ts`

Updated. I switched response loading in minimal poll content to use deriveEvents from core state.

Updated. I switched response loading in minimal poll content to use deriveEvents from core state.
hodlbod marked this conversation as resolved
@@ -0,0 +16,4 @@
const results = derived(responses, $responses => getPollResults(props.event, $responses))
</script>
<div class="flex flex-col gap-1">
Owner

No gap looks better here

No gap looks better here

Updated. I removed the extra gap in the minimal poll preview.

Updated. I removed the extra gap in the minimal poll preview.
hodlbod marked this conversation as resolved
@@ -0,0 +12,4 @@
const responses = deriveArray(
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [props.event.id]}]}),
)
Owner

Use deriveEvents

Use `deriveEvents`

Updated. I switched NoteContentPoll response derivation to deriveEvents.

Updated. I switched NoteContentPoll response derivation to deriveEvents.
hodlbod marked this conversation as resolved
@@ -0,0 +28,4 @@
<p class="text-xs opacity-50">
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
{#if endsAt}
· Ends {formatTimestampRelative(endsAt)}
Owner

Use a bullet point instead of this symbol for a slightly heftier appearance.

Use a bullet point instead of this symbol for a slightly heftier appearance.
Owner

Also, let's check if the timestamp is in the past and show "Ended at" instead of showing "closed"

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.

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.
hodlbod marked this conversation as resolved
@@ -0,0 +51,4 @@
<progress class="progress progress-primary" value={option.votes} max={maxVotes}></progress>
</div>
{/each}
</div>
Owner

Let's make it possible to vote inline without clicking through to the detail page.

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

Updated. PollVotes is now rendered inline in note content so users can vote without opening detail view
hodlbod marked this conversation as resolved
@@ -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) : undefined
Owner

Use now()

Use `now()`

Updated. I switched PollCreate time handling to use now() for end-time validation logic.

Updated. I switched PollCreate time handling to use now() for end-time validation logic.
hodlbod marked this conversation as resolved
@@ -0,0 +80,4 @@
}
let title = $state("")
let pollType = $state<"singlechoice" | "multiplechoice">("singlechoice")
Owner

Use PollType

Use `PollType`

Updated. PollCreate now uses the shared PollType type.

Updated. PollCreate now uses the shared PollType type.
hodlbod marked this conversation as resolved
@@ -0,0 +89,4 @@
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Poll</ModalTitle>
<ModalSubtitle>Ask the room a question with one or more answers.</ModalSubtitle>
Owner

Change this to "Ask a multiple choice question"

Change this to "Ask a multiple choice question"

Updated. I changed the subtitle copy to clarify question-style poll creation.

Updated. I changed the subtitle copy to clarify question-style poll creation.
hodlbod marked this conversation as resolved
@@ -0,0 +115,4 @@
{/snippet}
{#snippet input()}
<div class="flex flex-col gap-2">
{#each options as option, index (index)}
Owner

Let's make these draggable/droppable. I'm not sure using the index as the key will work in that case.

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.

Updated. Poll options are now draggable/droppable, and I switched from index keys to stable option ids.
hodlbod marked this conversation as resolved
@@ -0,0 +132,4 @@
<Button class="btn btn-ghost btn-sm self-start" onclick={addOption}>
<Icon icon={PlusCircle} size={4} />
Add option
</Button>
Owner

Right align this and make it a btn-primary

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.

Updated. The add-option action is now right-aligned and styled as a stronger action button.
hodlbod marked this conversation as resolved
@@ -0,0 +157,4 @@
<input
bind:value={endsAt}
class="input input-bordered w-full max-w-xs"
type="datetime-local" />
Owner

Use DateTimeInput for a better UX

Use DateTimeInput for a better UX

Updated. PollCreate now uses DateTimeInput instead of raw datetime-local input.

Updated. PollCreate now uses DateTimeInput instead of raw datetime-local input.
hodlbod marked this conversation as resolved
@@ -0,0 +25,4 @@
const responses = deriveArray(
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [event.id]}]}),
)
Owner

Use deriveEvents

Use `deriveEvents`

Updated. PollVotes now uses deriveEvents.

Updated. PollVotes now uses deriveEvents.
hodlbod marked this conversation as resolved
@@ -0,0 +32,4 @@
const closed = isPollClosed(event)
const endsAt = getPollEndsAt(event)
const results = $derived.by(() => getPollResults(event, $responses))
Owner

by is unnecessary, just use $derived(getPollResults(event, $responses))

`by` is 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.

Updated. I removed unnecessary $derived.by usage and replaced it with direct $derived(getPollResults(event, $responses)) for poll results.
hodlbod marked this conversation as resolved
@@ -0,0 +37,4 @@
() =>
$responses
.filter(response => response.pubkey === $pubkey)
.sort((left, right) => right.created_at - left.created_at)[0],
Owner

This can be simplified to $derived(sortEventsDesc($responses.filter(spec({pubkey}))))

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.

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.
hodlbod marked this conversation as resolved
@@ -0,0 +82,4 @@
{/if}
{#if closed}
· Closed
{/if}
Owner

Handle past timestamps ("Ended at") and hide the closed one since it's covered by the copy change

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.

Updated. Past timestamps now show Ended wording, and the separate Closed label was removed.
hodlbod marked this conversation as resolved
@@ -0,0 +10,4 @@
votes: number
}
const isDefined = <T>(value: T | undefined): value is T => value !== undefined
Owner

Import this from @welshman/lib

Import this from `@welshman/lib`

Updated. Poll parsing now uses shared helper imports from Welshman utilities instead of a local isDefined helper.

Updated. Poll parsing now uses shared helper imports from Welshman utilities instead of a local isDefined helper.
hodlbod marked this conversation as resolved
@@ -0,0 +27,4 @@
.filter(isDefined)
export const getPollEndsAt = (event: TrustedEvent) => {
const endsAt = getTagValue("endsAt", event.tags) || getTagValue("endsat", event.tags)
Owner

Any particular reason to use the non standard endsat format?

Any particular reason to use the non standard `endsat` format?

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.

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.
hodlbod marked this conversation as resolved
@@ -0,0 +45,4 @@
export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => {
const selections = getTags("response", event.tags)
.map(tag => tag[1])
.filter(isDefined)
Owner

This can be simplified to removeUndefined(getTagValues('response', event.tags)). removeUndefined comes from @welshman/lib.

This can be simplified to `removeUndefined(getTagValues('response', event.tags))`. `removeUndefined` comes from `@welshman/lib`.

Updated. Response tag parsing is simplified via getTagValues and no longer uses the manual map/filter pattern.

Updated. Response tag parsing is simplified via getTagValues and no longer uses the manual map/filter pattern.
hodlbod marked this conversation as resolved
@@ -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} />
Owner

We can remove this if we make it possible to vote in NoteContentPoll. No need to show the results twice.

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.

Updated. I removed the duplicate PollVotes block from the detail page since voting/results are now inline in note content.
hodlbod marked this conversation as resolved
hodlbod reviewed 2026-04-02 22:14:28 +00:00
@@ -0,0 +123,4 @@
{#if !closed}
<div class="flex justify-end">
<Button class="btn btn-primary btn-sm" onclick={submit}>Cast vote</Button>
Owner

Disable this if nothing is selected, or if the user has already voted.

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.

Updated. I also added an on-mount response request for poll content so we fetch responses directly instead of relying only on sync.
hodlbod marked this conversation as resolved
hodlbod reviewed 2026-04-02 22:21:35 +00:00
hodlbod left a comment
Owner

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.

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)
Owner

Add a call to load poll responses on mount to make sure we have everything (we shouldn't trust sync to fetch all responses)

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.

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.
hodlbod marked this conversation as resolved
Ghost added 1 commit 2026-04-02 22:37:13 +00:00
hodlbod requested changes 2026-04-02 23:20:21 +00:00
@@ -0,0 +14,4 @@
</script>
<div class="flex flex-col gap-0">
<span class="text-sm">{props.event.content || "Poll"}</span>
Owner

Use Content here so stuff gets rendered properly

Use `Content` here so stuff gets rendered properly

Updated. I switched the minimal poll renderer to use Content so links, mentions, and other content formatting render correctly.

Updated. I switched the minimal poll renderer to use `Content` so links, mentions, and other content formatting render correctly.
hodlbod marked this conversation as resolved
@@ -0,0 +21,4 @@
</script>
<div class="flex flex-col gap-3">
<p class="text-xl">{props.event.content || "Poll"}</p>
Owner

Use Content here so stuff gets rendered properly

Use `Content` here so stuff gets rendered properly

Updated. I switched the full poll renderer to use Content so poll text is rendered consistently with the rest of the app.

Updated. I switched the full poll renderer to use `Content` so poll text is rendered consistently with the rest of the app.
hodlbod marked this conversation as resolved
@@ -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">
Owner

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)

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)
Owner

Also, we'll need to preventdefault/stoppropagation when the user clicks to vote so the detail page doesn't get opened.

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-alt and 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.

Updated. I changed the poll vote block to use `card2 bg-alt` and 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.
hodlbod marked this conversation as resolved
@@ -0,0 +135,4 @@
<div class="flex justify-end">
<Button class="btn btn-primary btn-sm" onclick={submit}>Cast vote</Button>
</div>
{/if}
Owner

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.

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.

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.
hodlbod marked this conversation as resolved
Ghost added 1 commit 2026-04-03 08:17:41 +00:00
Ghost added 1 commit 2026-04-03 08:22:55 +00:00
Owner

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 on dev to keep conflicts to a minimum.

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 on `dev` to keep conflicts to a minimum.
hodlbod closed this pull request 2026-04-03 17:56:17 +00:00

Pull request closed

Sign in to join this conversation.