feat: implement NIP-88 polls #128
@@ -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
|
||||
|
||||
<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
hodlbod
commented
Use Use `Content` here so stuff gets rendered properly
Ghost
commented
Updated. I switched the minimal poll renderer to use 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
hodlbod
commented
No gap looks better here No gap looks better here
Ghost
commented
Updated. I removed the extra gap in the minimal poll preview. Updated. I removed the extra gap in the minimal poll preview.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</script>
|
||||
|
hodlbod marked this conversation as resolved
hodlbod
commented
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)
Ghost
commented
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
hodlbod
commented
Use Use `Content` here so stuff gets rendered properly
Ghost
commented
Updated. I switched the full poll renderer to use 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} />
|
||||
|
||||
@@ -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,
|
||||
@@ -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
hodlbod
commented
`by` is unnecessary, just use `$derived(getPollResults(event, $responses))`
Ghost
commented
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
|
||||
@@ -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
hodlbod
commented
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
Ghost
commented
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
hodlbod
commented
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)
hodlbod
commented
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.
Ghost
commented
Updated. I changed the poll vote block to use 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"}
|
||||
@@ -111,12 +137,14 @@
|
||||
type="radio"
|
||||
class="radio radio-primary radio-sm"
|
||||
|
hodlbod marked this conversation as resolved
hodlbod
commented
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 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.
Ghost
commented
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>
|
||||
|
||||
Use
deriveEventsfromapp/state/core.tsUpdated. I switched response loading in minimal poll content to use deriveEvents from core state.