feat: add NIP-88 poll support

This commit is contained in:
Bhavishy
2026-04-03 00:54:04 +05:30
parent 63b1cd125e
commit 5ba920adec
18 changed files with 743 additions and 2 deletions
+10
View File
@@ -4,6 +4,7 @@
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/util/modal"
@@ -11,6 +12,7 @@
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
import PollCreate from "@app/components/PollCreate.svelte"
type Props = {
url: string
@@ -28,6 +30,8 @@
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
const createPoll = () => pushModal(PollCreate, {url, h})
let ul: Element
onMount(() => {
@@ -60,4 +64,10 @@
Create Thread
</Button>
</li>
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />
Poll
</Button>
</li>
</ul>
+4
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
import NoteContentThread from "@app/components/NoteContentThread.svelte"
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
@@ -19,6 +21,8 @@
<NoteContentClassified {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentGoal {...props} />
{:else if props.event.kind === Poll}
<NoteContentPoll {...props} />
{:else}
<Content {...props} />
{/if}
@@ -1,10 +1,12 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
const props: ComponentProps<typeof ContentMinimal> = $props()
@@ -19,6 +21,8 @@
<NoteContentMinimalClassified {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentMinimalGoal {...props} />
{:else if props.event.kind === Poll}
<NoteContentMinimalPoll {...props} />
{:else}
<ContentMinimal {...props} />
{/if}
@@ -0,0 +1,22 @@
<script lang="ts">
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 {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof ContentMinimal> = $props()
const responses = deriveArray(
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [props.event.id]}]}),
)
const results = derived(responses, $responses => getPollResults(props.event, $responses))
</script>
<div class="flex flex-col gap-1">
<span class="text-sm">{props.event.content || "Poll"}</span>
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
</div>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {derived} from "svelte/store"
import {formatTimestampRelative} from "@welshman/lib"
import {PollResponse} from "nostr-tools/kinds"
import {repository} from "@welshman/app"
import {deriveArray, deriveEventsById} from "@welshman/store"
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]}]}),
)
const results = derived(responses, $responses => getPollResults(props.event, $responses))
const endsAt = getPollEndsAt(props.event)
const pollType = getPollType(props.event)
const closed = isPollClosed(props.event)
</script>
<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>
<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>
</div>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import {randomId, 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 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 Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
h?: string
}
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const back = () => history.back()
const addOption = () => {
options = [...options, ""]
}
const removeOption = (index: number) => {
options = options.filter((_, optionIndex) => optionIndex !== index)
}
const submit = async () => {
if (!title.trim()) {
return pushToast({theme: "error", message: "Please provide a title for your poll."})
}
const nonEmptyOptions = removeUndefined(options.map(option => option.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
const tags: string[][] = [
...nonEmptyOptions.map(option => ["option", randomId(), option]),
["polltype", pollType],
["relay", url],
]
if (parsedEndsAt) {
tags.push(["endsAt", String(parsedEndsAt)])
}
if (h) {
tags.push(["h", h])
}
if (await shouldProtect) {
tags.push(PROTECTED)
}
publishThunk({
relays: [url],
event: makeEvent(Poll, {content: title.trim(), tags}),
})
history.back()
}
let title = $state("")
let pollType = $state<"singlechoice" | "multiplechoice">("singlechoice")
let endsAt = $state("")
let options = $state(["Yes", "No"])
</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>
</ModalHeader>
<div class="col-8 relative">
<Field>
{#snippet label()}
<p>Question*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
class="grow"
type="text"
placeholder="What would you like to ask?" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<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">
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={options[index]} class="grow" type="text" placeholder={`Option ${index + 1}`} />
</label>
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(index)}>
<Icon icon={MinusCircle} size={4} />
</Button>
</div>
{/each}
<Button class="btn btn-ghost btn-sm self-start" onclick={addOption}>
<Icon icon={PlusCircle} size={4} />
Add option
</Button>
</div>
{/snippet}
</Field>
<div class="flex flex-col gap-2">
<FieldInline>
{#snippet label()}
Poll type
{/snippet}
{#snippet input()}
<select class="select select-bordered w-full max-w-xs" bind:value={pollType}>
<option value="singlechoice">Single choice</option>
<option value="multiplechoice">Multiple choice</option>
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
Ends at
{/snippet}
{#snippet input()}
<input bind:value={endsAt} class="input input-bordered w-full max-w-xs" type="datetime-local" />
{/snippet}
</FieldInline>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary">Create Poll</Button>
</ModalFooter>
</Modal>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {makePollPath} from "@app/util/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const h = getTagValue("h", event.tags)
</script>
<Link class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md" href={makePollPath(url, event.id)}>
<NoteContent {event} {url} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<CommentActions segment="polls" showActivity {url} {event} />
</div>
</Link>
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {pubkey, repository} from "@welshman/app"
import {deriveArray, deriveEventsById} from "@welshman/store"
import {PollResponse} from "nostr-tools/kinds"
import {formatTimestampRelative} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import {pushToast} from "@app/util/toast"
import {publishPollResponse} from "@app/core/commands"
import {
getPollEndsAt,
getPollOptions,
getPollResponseSelections,
getPollResults,
getPollType,
isPollClosed,
} from "@app/util/polls"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const responses = deriveArray(
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [event.id]}]}),
)
const pollType = getPollType(event)
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 submit = async () => {
if (closed) {
return pushToast({theme: "error", message: "This poll is closed."})
}
const selection = pollType === "singlechoice" ? [selectedIds[0]].filter(Boolean) : selectedIds
if (selection.length === 0) {
return pushToast({theme: "error", message: "Please select at least one option."})
}
publishPollResponse({relays: [url], event, selectedIds: selection})
}
const setSingleChoice = (id: string) => {
selectedIds = [id]
}
const toggleMultipleChoice = (id: string) => {
selectedIds = selectedIds.includes(id)
? selectedIds.filter(selectedId => selectedId !== id)
: [...selectedIds, id]
}
let selectedIds = $state<string[]>([])
$effect(() => {
if (ownResponse) {
selectedIds = getPollResponseSelections(ownResponse, pollType)
}
})
</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">
<div class="text-sm opacity-75">
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
{#if endsAt}
· Ends {formatTimestampRelative(endsAt)}
{/if}
{#if closed}
· Closed
{/if}
</div>
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
</div>
<div class="flex flex-col gap-2">
{#each options as option (option.id)}
{@const maxVotes = Math.max(...results.options.map(result => result.votes), 1)}
{@const current = results.options.find(result => result.id === option.id)}
<div class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex items-center justify-between gap-2">
<label class="flex min-w-0 flex-grow items-center gap-2">
{#if !closed}
{#if pollType === "singlechoice"}
<input
name={event.id}
type="radio"
class="radio radio-primary radio-sm"
checked={selectedIds[0] === option.id}
onchange={() => setSingleChoice(option.id)} />
{:else}
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={selectedIds.includes(option.id)}
onchange={() => toggleMultipleChoice(option.id)} />
{/if}
{/if}
<span class="truncate">{option.label}</span>
</label>
<span class="whitespace-nowrap text-xs opacity-75">{current?.votes || 0} vote{(current?.votes || 0) === 1 ? "" : "s"}</span>
</div>
<progress class="progress progress-primary" value={current?.votes || 0} max={maxVotes}></progress>
</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>
+8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {derived} from "svelte/store"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import {deriveRelay, createSearch, pubkey} from "@welshman/app"
import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -17,6 +18,7 @@
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
@@ -69,6 +71,7 @@
const threadsPath = makeSpacePath(url, "threads")
const classifiedsPath = makeSpacePath(url, "classifieds")
const calendarPath = makeSpacePath(url, "calendar")
const pollsPath = makeSpacePath(url, "polls")
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const otherVoiceRooms = deriveOtherVoiceRooms(url)
@@ -257,6 +260,11 @@
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(Poll)}
<SecondaryNavItem href={pollsPath}>
<Icon icon={Revote} /> Polls
</SecondaryNavItem>
{/if}
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
<div class="h-2 flex-shrink-0"></div>