feat: implement NIP-88 polls #128

Closed
Ghost wants to merge 11 commits from (deleted):dev into dev
3 changed files with 44 additions and 22 deletions
Showing only changes of commit 9b39858b49 - Show all commits
@@ -2,11 +2,11 @@
import type {ComponentProps} from "svelte"
import {derived} from "svelte/store"
import {PollResponse} from "nostr-tools/kinds"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import Content from "@app/components/Content.svelte"
import {deriveEvents} from "@app/core/state"
import {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof ContentMinimal> = $props()
const props: ComponentProps<typeof Content> = $props()
const responses = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}])
@@ -14,6 +14,6 @@
</script>
hodlbod marked this conversation as resolved
Review

Use deriveEvents from app/state/core.ts

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

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.
<div class="flex flex-col gap-0">
<span class="text-sm">{props.event.content || "Poll"}</span>
<Content event={props.event} url={props.url} />
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Use Content here so stuff gets rendered properly

Use `Content` here so stuff gets rendered properly
Outdated
Review

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.
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
</div>
hodlbod marked this conversation as resolved
Review

No gap looks better here

No gap looks better here
Review

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

Updated. I removed the extra gap in the minimal poll preview.
+1 -1
View File
2
@@ -21,7 +21,7 @@
</script>
hodlbod marked this conversation as resolved
Review

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

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.
<div class="flex flex-col gap-3">
<p class="text-xl">{props.event.content || "Poll"}</p>
<Content event={props.event} showEntire url={props.url} />
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Use Content here so stuff gets rendered properly

Use `Content` here so stuff gets rendered properly
Outdated
Review

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.
{#if props.url}
<PollVotes url={props.url} event={props.event} />
+40 -18
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import {onDestroy} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
import {PollResponse} from "nostr-tools/kinds"
import {formatTimestampRelative} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import {noop} from "@welshman/lib"
import {stopPropagation} from "@lib/html"
import {deriveEvents} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {publishPollResponse} from "@app/core/commands"
import {makePollResponse} from "@app/core/commands"
import {
getPollEndsAt,
getPollOptions,
2
@@ -29,6 +31,7 @@
const options = getPollOptions(event)
const closed = isPollClosed(event)
const endsAt = getPollEndsAt(event)
const publishDelay = pollType === "multiplechoice" ? 10_000 : undefined
hodlbod marked this conversation as resolved Outdated
Outdated
Review

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

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

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.
const getOwnResponse = (responses: TrustedEvent[]) => {
let latest: TrustedEvent | undefined
2
@@ -46,43 +49,66 @@
return latest
}
const results = $derived(getPollResults(event, $responses))
const ownResponse = $derived(getOwnResponse($responses))
const submit = async () => {
if (closed) {
return pushToast({theme: "error", message: "This poll is closed."})
const publishSelection = (selection: string[]) => {
if (activeThunk) {
abortThunk(activeThunk)
}
const selection = pollType === "singlechoice" ? [selectedIds[0]].filter(Boolean) : selectedIds
if (selection.length === 0) {
activeThunk = undefined
return
}
activeThunk = publishThunk({
relays: [url],
event: makePollResponse({event, selectedIds: selection}),
delay: publishDelay,
})
}
const publishCurrentSelection = () => {
const selection = pollType === "singlechoice" ? selectedIds.slice(0, 1) : selectedIds
if (selection.length === 0) {
return pushToast({theme: "error", message: "Please select at least one option."})
}
publishPollResponse({relays: [url], event, selectedIds: selection})
publishSelection(selection)
}
const results = $derived(getPollResults(event, $responses))
const ownResponse = $derived(getOwnResponse($responses))
const setSingleChoice = (id: string) => {
selectedIds = [id]
publishCurrentSelection()
}
hodlbod marked this conversation as resolved
Review

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
Review

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
Review

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

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.
Review

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.
const toggleMultipleChoice = (id: string) => {
selectedIds = selectedIds.includes(id)
? selectedIds.filter(selectedId => selectedId !== id)
: [...selectedIds, id]
publishCurrentSelection()
}
let selectedIds = $state<string[]>([])
let activeThunk: ReturnType<typeof publishThunk> | undefined
$effect(() => {
if (ownResponse) {
selectedIds = getPollResponseSelections(ownResponse, pollType)
}
})
onDestroy(() => {
if (activeThunk) {
abortThunk(activeThunk)
}
})
</script>
<div class="flex flex-col gap-3 rounded-box bg-base-200 p-4">
<div class="card2 bg-alt p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm opacity-75">
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
2
@@ -111,12 +137,14 @@
type="radio"
class="radio radio-primary radio-sm"
hodlbod marked this conversation as resolved
Review

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.
Review

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.
checked={selectedIds[0] === option.id}
onclick={stopPropagation(noop)}
onchange={() => setSingleChoice(option.id)} />
{:else}
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={selectedIds.includes(option.id)}
onclick={stopPropagation(noop)}
onchange={() => toggleMultipleChoice(option.id)} />
{/if}
{/if}
@@ -130,10 +158,4 @@
</div>
{/each}
</div>
{#if !closed}
<div class="flex justify-end">
<Button class="btn btn-primary btn-sm" onclick={submit}>Cast vote</Button>
</div>
{/if}
</div>