feat: implement NIP-88 polls #128

Closed
Ghost wants to merge 11 commits from (deleted):dev into dev
7 changed files with 142 additions and 102 deletions
Showing only changes of commit 70ce54c5a5 - Show all commits
+1 -1
View File
@@ -67,7 +67,7 @@
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />
Poll
Ask a Question
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Let's call it "Ask a Question"

Let's call it "Ask a Question"
Outdated
Review

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.
</Button>
</li>
</ul>
@@ -2,21 +2,18 @@
import type {ComponentProps} from "svelte"
import {derived} from "svelte/store"
import {PollResponse} from "nostr-tools/kinds"
import {repository} from "@welshman/app"
import {deriveArray, deriveEventsById} from "@welshman/store"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {deriveEvents} from "@app/core/state"
import {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof ContentMinimal> = $props()
const responses = deriveArray(
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [props.event.id]}]}),
)
const responses = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}])
const results = derived(responses, $responses => getPollResults(props.event, $responses))
</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-1">
<div class="flex flex-col gap-0">
<span class="text-sm">{props.event.content || "Poll"}</span>
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.
+16 -42
View File
@@ -1,55 +1,29 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {derived} from "svelte/store"
import {formatTimestampRelative} from "@welshman/lib"
import {onMount} from "svelte"
import {request} from "@welshman/net"
import {PollResponse} from "nostr-tools/kinds"
import {repository} from "@welshman/app"
import {deriveArray, deriveEventsById} from "@welshman/store"
import PollVotes from "@app/components/PollVotes.svelte"
import Content from "@app/components/Content.svelte"
import {getPollEndsAt, getPollResults, getPollType, isPollClosed} from "@app/util/polls"
const props: ComponentProps<typeof Content> = $props()
const responses = deriveArray(
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [props.event.id]}]}),
)
onMount(() => {
if (!props.url) {
return
}
hodlbod marked this conversation as resolved
Review

Use deriveEvents

Use `deriveEvents`
Review

Updated. I switched NoteContentPoll response derivation to deriveEvents.

Updated. I switched NoteContentPoll response derivation to deriveEvents.
const results = derived(responses, $responses => getPollResults(props.event, $responses))
const endsAt = getPollEndsAt(props.event)
const pollType = getPollType(props.event)
const closed = isPollClosed(props.event)
request({
relays: [props.url],
filters: [{kinds: [PollResponse], "#e": [props.event.id]}],
})
})
</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">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-grow">
<p class="text-xl">{props.event.content || "Poll"}</p>
<p class="text-xs opacity-50">
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
{#if endsAt}
· Ends {formatTimestampRelative(endsAt)}
{/if}
{#if closed}
· Closed
{/if}
</p>
</div>
<p class="whitespace-nowrap text-xs opacity-50">
{$results.voters} voter{$results.voters === 1 ? "" : "s"}
</p>
</div>
<p class="text-xl">{props.event.content || "Poll"}</p>
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.
<div class="flex flex-col gap-2">
{#each $results.options as option (option.id)}
{@const maxVotes = Math.max(...$results.options.map(item => item.votes), 1)}
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between gap-2 text-sm">
<span class="truncate">{option.label}</span>
<span class="whitespace-nowrap opacity-75">{option.votes}</span>
</div>
<progress class="progress progress-primary" value={option.votes} max={maxVotes}></progress>
</div>
{/each}
</div>
{#if props.url}
<PollVotes url={props.url} event={props.event} />
{/if}
</div>
+88 -23
View File
@@ -1,15 +1,17 @@
<script lang="ts">
import {randomId, removeUndefined} from "@welshman/lib"
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {Poll} from "nostr-tools/kinds"
import {isMobile, preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
import PlusCircle from "@assets/icons/add-circle.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
@@ -20,6 +22,7 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {canEnforceNip70} from "@app/core/commands"
import type {PollType} from "@app/util/polls"
type Props = {
url: string
@@ -30,14 +33,62 @@
const shouldProtect = canEnforceNip70(url)
type DraftOption = {
id: string
value: string
}
const back = () => history.back()
const addOption = () => {
options = [...options, ""]
options = [...options, {id: randomId(), value: ""}]
}
const removeOption = (index: number) => {
options = options.filter((_, optionIndex) => optionIndex !== index)
const removeOption = (id: string) => {
options = options.filter(option => option.id !== id)
}
const updateOption = (id: string, value: string) => {
options = options.map(option => (option.id === id ? {...option, value} : option))
}
hodlbod marked this conversation as resolved
Review

Use now()

Use `now()`
Review

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.
const reorderOptions = (targetId: string) => {
if (!draggedOptionId) {
return
}
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
const targetIndex = options.findIndex(option => option.id === targetId)
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
return
}
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
}
const onDragStart = (e: DragEvent, id: string) => {
draggedOptionId = id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", id)
}
}
const onDragOver = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
}
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Use PollType

Use `PollType`
Outdated
Review

Updated. PollCreate now uses the shared PollType type.

Updated. PollCreate now uses the shared PollType type.
const onDrop = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
draggedOptionId = undefined
}
const onDragEnd = () => {
draggedOptionId = undefined
}
hodlbod marked this conversation as resolved
Review

Change this to "Ask a multiple choice question"

Change this to "Ask a multiple choice question"
Review

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

Updated. I changed the subtitle copy to clarify question-style poll creation.
const submit = async () => {
@@ -45,13 +96,15 @@
return pushToast({theme: "error", message: "Please provide a title for your poll."})
}
const nonEmptyOptions = removeUndefined(options.map(option => option.trim() || undefined))
const nonEmptyOptions = removeUndefined(options.map(option => option.value.trim() || undefined))
if (nonEmptyOptions.length < 2) {
return pushToast({theme: "error", message: "Please provide at least two options."})
}
const parsedEndsAt = endsAt ? Math.floor(new Date(endsAt).getTime() / 1000) : undefined
if (endsAt && endsAt <= now()) {
return pushToast({theme: "error", message: "End time must be in the future."})
}
const tags: string[][] = [
...nonEmptyOptions.map(option => ["option", randomId(), option]),
@@ -59,8 +112,8 @@
["relay", url],
]
if (parsedEndsAt) {
tags.push(["endsAt", String(parsedEndsAt)])
if (endsAt) {
tags.push(["endsAt", String(endsAt)])
}
hodlbod marked this conversation as resolved
Review

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

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.
if (h) {
@@ -80,16 +133,20 @@
}
let title = $state("")
hodlbod marked this conversation as resolved
Review

Right align this and make it a btn-primary

Right align this and make it a btn-primary
Review

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.
let pollType = $state<"singlechoice" | "multiplechoice">("singlechoice")
let endsAt = $state("")
let options = $state(["Yes", "No"])
let pollType = $state<PollType>("singlechoice")
let endsAt = $state<number | undefined>()
let options = $state<DraftOption[]>([
{id: randomId(), value: "Yes"},
{id: randomId(), value: "No"},
])
let draggedOptionId = $state<string | undefined>()
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Poll</ModalTitle>
<ModalSubtitle>Ask the room a question with one or more answers.</ModalSubtitle>
<ModalSubtitle>Ask a question and collect votes right in the feed.</ModalSubtitle>
</ModalHeader>
<div class="col-8 relative">
<Field>
2
@@ -114,22 +171,33 @@
<p>Options*</p>
{/snippet}
{#snippet input()}
<div class="flex flex-col gap-2">
{#each options as option, index (index)}
<div class="flex items-center gap-2">
<div class="flex flex-col gap-2" role="list">
{#each options as option, index (option.id)}
<div
class="flex items-center gap-2"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, option.id)}
ondragover={e => onDragOver(e, option.id)}
ondrop={e => onDrop(e, option.id)}
ondragend={onDragEnd}>
<div class="cursor-move opacity-70" aria-label="Drag handle">
<Icon icon={HamburgerMenu} size={4} />
</div>
<label class="input input-bordered flex w-full items-center gap-2">
<input
bind:value={options[index]}
value={option.value}
class="grow"
type="text"
placeholder={`Option ${index + 1}`} />
placeholder={`Option ${index + 1}`}
oninput={e => updateOption(option.id, e.currentTarget.value)} />
</label>
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(index)}>
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
<Icon icon={MinusCircle} size={4} />
</Button>
</div>
{/each}
<Button class="btn btn-ghost btn-sm self-start" onclick={addOption}>
<Button class="btn btn-outline btn-sm self-end" onclick={addOption}>
<Icon icon={PlusCircle} size={4} />
Add option
</Button>
@@ -154,10 +222,7 @@
Ends at
{/snippet}
{#snippet input()}
<input
bind:value={endsAt}
class="input input-bordered w-full max-w-xs"
type="datetime-local" />
<DateTimeInput bind:value={endsAt} />
{/snippet}
</FieldInline>
</div>
+26 -16
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {pubkey, repository} from "@welshman/app"
import {deriveArray, deriveEventsById} from "@welshman/store"
import {pubkey} from "@welshman/app"
import {PollResponse} from "nostr-tools/kinds"
import {formatTimestampRelative} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import {deriveEvents} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {publishPollResponse} from "@app/core/commands"
import {
@@ -23,22 +23,31 @@
const {url, event}: Props = $props()
const responses = deriveArray(
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [event.id]}]}),
)
const responses = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}])
const pollType = getPollType(event)
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Use deriveEvents

Use `deriveEvents`
Outdated
Review

Updated. PollVotes now uses deriveEvents.

Updated. PollVotes now uses deriveEvents.
const options = getPollOptions(event)
const closed = isPollClosed(event)
const endsAt = getPollEndsAt(event)
const results = $derived.by(() => getPollResults(event, $responses))
const ownResponse = $derived.by(
() =>
$responses
.filter(response => response.pubkey === $pubkey)
.sort((left, right) => right.created_at - left.created_at)[0],
)
const getOwnResponse = (responses: TrustedEvent[]) => {
let latest: TrustedEvent | 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.
for (const response of responses) {
if (response.pubkey !== $pubkey) {
continue
}
hodlbod marked this conversation as resolved Outdated
Outdated
Review

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

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

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.
if (!latest || response.created_at > latest.created_at) {
latest = response
}
}
return latest
}
const results = $derived(getPollResults(event, $responses))
const ownResponse = $derived(getOwnResponse($responses))
const submit = async () => {
if (closed) {
5
@@ -78,10 +87,11 @@
<div class="text-sm opacity-75">
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
{#if endsAt}
· Ends {formatTimestampRelative(endsAt)}
{/if}
{#if closed}
· Closed
{#if closed}
• Ended {formatTimestampRelative(endsAt)}
{:else}
• Ends {formatTimestampRelative(endsAt)}
{/if}
{/if}
</div>
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
4
+8 -12
View File
@@ -1,6 +1,6 @@
import {now, uniq} from "@welshman/lib"
import {now, removeUndefined, uniq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getTagValue, getTags} from "@welshman/util"
import {getTagValue, getTags, getTagValues} from "@welshman/util"
export type PollType = "singlechoice" | "multiplechoice"
@@ -10,24 +10,22 @@ export type PollOption = {
votes: number
}
const isDefined = <T>(value: T | undefined): value is T => value !== undefined
export const getPollType = (event: TrustedEvent): PollType =>
hodlbod marked this conversation as resolved
Review

Import this from @welshman/lib

Import this from `@welshman/lib`
Review

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.
getTagValue("polltype", event.tags) === "multiplechoice" ? "multiplechoice" : "singlechoice"
export const getPollOptions = (event: TrustedEvent) =>
getTags("option", event.tags)
.map(tag => {
removeUndefined(
getTags("option", event.tags).map(tag => {
const [, id, label = id] = tag
if (!id) return undefined
return {id, label}
})
.filter(isDefined)
}),
)
export const getPollEndsAt = (event: TrustedEvent) => {
const endsAt = getTagValue("endsAt", event.tags) || getTagValue("endsat", event.tags)
const endsAt = getTagValue("endsAt", event.tags)
if (!endsAt) return undefined
hodlbod marked this conversation as resolved
Review

Any particular reason to use the non standard endsat format?

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

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.
@@ -43,9 +41,7 @@ export const isPollClosed = (event: TrustedEvent) => {
}
export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => {
const selections = getTags("response", event.tags)
.map(tag => tag[1])
.filter(isDefined)
const selections = getTagValues("response", event.tags)
return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
}
2
@@ -15,7 +15,6 @@
import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import PollVotes from "@app/components/PollVotes.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte"
import {deriveEvent, decodeRelay} from "@app/core/state"
@@ -71,7 +70,6 @@
<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} />
<CommentActions segment="polls" showActivity {url} event={$event} />
</div>
hodlbod marked this conversation as resolved
Review

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

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.
</NoteCard>