feat: implement NIP-88 polls #128

Closed
Ghost wants to merge 11 commits from (deleted):dev into dev
18 changed files with 823 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} />
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>
+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,19 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {derived} from "svelte/store"
import {PollResponse} from "nostr-tools/kinds"
import Content from "@app/components/Content.svelte"
import {deriveEvents} from "@app/core/state"
import {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof Content> = $props()
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-0">
<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.
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {onMount} from "svelte"
import {request} from "@welshman/net"
import {PollResponse} from "nostr-tools/kinds"
import PollVotes from "@app/components/PollVotes.svelte"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
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.
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">
<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} />
{/if}
</div>
+238
View File
@@ -0,0 +1,238 @@
<script lang="ts">
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"
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"
import type {PollType} from "@app/util/polls"
type Props = {
url: string
h?: string
}
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
type DraftOption = {
id: string
value: string
}
const back = () => history.back()
const addOption = () => {
options = [...options, {id: randomId(), value: ""}]
}
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 () => {
if (!title.trim()) {
return pushToast({theme: "error", message: "Please provide a title for your poll."})
}
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."})
}
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]),
["polltype", pollType],
["relay", url],
]
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) {
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("")
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<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 a question and collect votes right in the feed.</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}
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Use DateTimeInput for a better UX

Use DateTimeInput for a better UX
Outdated
Review

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

Updated. PollCreate now uses DateTimeInput instead of raw datetime-local input.
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" 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
value={option.value}
class="grow"
type="text"
placeholder={`Option ${index + 1}`}
oninput={e => updateOption(option.id, e.currentTarget.value)} />
</label>
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
<Icon icon={MinusCircle} size={4} />
</Button>
</div>
{/each}
<Button class="btn btn-outline btn-sm self-end" 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()}
<DateTimeInput bind:value={endsAt} />
{/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>
+34
View File
@@ -0,0 +1,34 @@
<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>
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import {onDestroy} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
import {PollResponse} from "nostr-tools/kinds"
import {formatTimestampRelative} from "@welshman/lib"
import {noop} from "@welshman/lib"
import {stopPropagation} from "@lib/html"
import {deriveEvents} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {makePollResponse} 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 = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}])
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 pollType = getPollType(event)
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
for (const response of responses) {
if (response.pubkey !== $pubkey) {
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.
continue
}
if (!latest || response.created_at > latest.created_at) {
latest = response
}
}
return latest
}
const publishSelection = (selection: string[]) => {
if (activeThunk) {
abortThunk(activeThunk)
}
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."})
}
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="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"}
{#if endsAt}
{#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>
</div>
<div class="flex flex-col gap-2">
hodlbod marked this conversation as resolved Outdated
Outdated
Review

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

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.
{#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"
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}
<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>
</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>
+17
View File
@@ -18,6 +18,7 @@ import {
import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor"
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
import {PollResponse} from "nostr-tools/kinds"
import {
DELETE,
REPORT,
@@ -351,6 +352,22 @@ export const publishReaction = ({relays, ...params}: ReactionParams & {relays: s
publishThunk({event: makeReaction({url: relays[0], ...params}), relays})
}
// Polls
export type PollResponseParams = {
event: TrustedEvent
selectedIds: string[]
}
export const makePollResponse = ({event, selectedIds}: PollResponseParams) =>
makeEvent(PollResponse, {
content: "",
tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])],
})
export const publishPollResponse = ({relays, ...params}: PollResponseParams & {relays: string[]}) =>
publishThunk({event: makePollResponse(params), relays})
// Comments
export type CommentParams = {
+2 -1
View File
@@ -3,6 +3,7 @@ import {context as pomadeContext} from "@pomade/core"
import {Capacitor} from "@capacitor/core"
import {derived, readable, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {Poll} from "nostr-tools/kinds"
import {
on,
gt,
@@ -325,7 +326,7 @@ if (ENABLE_ZAPS) {
REACTION_KINDS.push(ZAP_RESPONSE)
}
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED]
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, Poll]
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
+4 -1
View File
@@ -2,6 +2,7 @@ import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store"
import {derived, get} from "svelte/store"
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {PollResponse} from "nostr-tools/kinds"
import {
getListTags,
getRelayTagValues,
@@ -281,6 +282,7 @@ const syncSpace = (url: string, rooms: string[]) => {
filters: [
{kinds: MESSAGE_KINDS, since, "#h": [room]},
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
{kinds: [PollResponse], since},
],
})
}
@@ -300,6 +302,7 @@ const syncSpace = (url: string, rooms: string[]) => {
filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
makeCommentFilter(CONTENT_KINDS, {since}),
{kinds: [PollResponse], since},
],
onEvent: event => {
if (event.kind === ROOM_META) {
@@ -311,7 +314,7 @@ const syncSpace = (url: string, rooms: string[]) => {
listen({
url,
signal: controller.signal,
filters: [{kinds: REACTION_KINDS}],
filters: [{kinds: REACTION_KINDS}, {kinds: [PollResponse]}],
})
return () => controller.abort()
+76
View File
@@ -0,0 +1,76 @@
import {now, removeUndefined, uniq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getTagValue, getTags, getTagValues} from "@welshman/util"
export type PollType = "singlechoice" | "multiplechoice"
export type PollOption = {
id: string
label: string
votes: number
}
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) =>
removeUndefined(
getTags("option", event.tags).map(tag => {
const [, id, label = id] = tag
if (!id) return undefined
return {id, label}
}),
)
export const getPollEndsAt = (event: TrustedEvent) => {
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.
const timestamp = parseInt(endsAt)
return Number.isNaN(timestamp) ? undefined : timestamp
}
export const isPollClosed = (event: TrustedEvent) => {
const endsAt = getPollEndsAt(event)
return typeof endsAt === "number" ? endsAt <= now() : false
}
export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => {
const selections = getTagValues("response", event.tags)
return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
}
hodlbod marked this conversation as resolved
Review

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

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.
export const getPollResults = (event: TrustedEvent, responses: TrustedEvent[]) => {
const options = getPollOptions(event).map(option => ({...option, votes: 0}))
const counts = new Map(options.map(option => [option.id, option]))
const latestByPubkey = new Map<string, TrustedEvent>()
for (const response of responses) {
const current = latestByPubkey.get(response.pubkey)
if (!current || response.created_at > current.created_at) {
latestByPubkey.set(response.pubkey, response)
}
}
for (const response of latestByPubkey.values()) {
for (const optionId of getPollResponseSelections(response, getPollType(event))) {
const option = counts.get(optionId)
if (option) {
option.votes += 1
}
}
}
return {
options,
voters: latestByPubkey.size,
}
}
+9
View File
@@ -6,6 +6,7 @@ import {page} from "$app/stores"
import {nthEq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import {tracker, userMessagingRelayList} from "@welshman/app"
import {identity} from "@welshman/lib"
import {
@@ -90,6 +91,8 @@ export const makeClassifiedPath = (url: string, address?: string) =>
export const makeCalendarPath = (url: string, address?: string) =>
makeSpacePath(url, "calendar", address)
export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id)
export const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any
@@ -146,6 +149,10 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
return makeCalendarPath(url, getAddress(event))
}
if (event.kind === Poll) {
return makePollPath(url, event.id)
}
if (event.kind === MESSAGE) {
return makeMessagePath(url, event)
}
@@ -192,5 +199,7 @@ export const getRoomItemPath = (url: string, event: TrustedEvent) => {
return makeGoalPath(url, event.id)
case EVENT_TIME:
return makeCalendarPath(url, getAddress(event))
case Poll:
return makePollPath(url, event.id)
}
}
+2
View File
@@ -17,6 +17,7 @@ const staticTitles = new Map<string, string>([
["/spaces/[relay]/classifieds", "Classifieds"],
["/spaces/[relay]/calendar", "Calendar"],
["/spaces/[relay]/goals", "Goals"],
["/spaces/[relay]/polls", "Polls"],
["/chat", "Messages"],
["/join", "Join Space"],
["/people", "Find People"],
@@ -35,6 +36,7 @@ const eventRoutes = new Set([
"/spaces/[relay]/goals/[id]",
"/spaces/[relay]/calendar/[address]",
"/spaces/[relay]/classifieds/[address]",
"/spaces/[relay]/polls/[id]",
])
type RouteParams = Record<string, string | undefined>
@@ -0,0 +1,95 @@
<script lang="ts">
import {onMount} from "svelte"
import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
import {page} from "$app/stores"
import {sortBy, partition, spec, pushToMapKey, max} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import {fly} from "@lib/transition"
import PollIcon from "@assets/icons/revote.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import PollItem from "@app/components/PollItem.svelte"
import PollCreate from "@app/components/PollCreate.svelte"
import {Poll} from "nostr-tools/kinds"
import {decodeRelay, makeCommentFilter} from "@app/core/state"
import {makeFeed} from "@app/core/requests"
import {pushModal} from "@app/util/modal"
const url = decodeRelay($page.params.relay!)
let loading = $state(true)
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $state(readable([]))
const createPoll = () => pushModal(PollCreate, {url})
const items = $derived.by(() => {
const scores = new Map<string, number[]>()
const [polls, comments] = partition(spec({kind: Poll}), $events)
for (const comment of comments) {
const id = getTagValue("E", comment.tags)
if (id) {
pushToMapKey(scores, id, comment.created_at)
}
}
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), polls)
})
onMount(() => {
const feed = makeFeed({
url,
element: element!,
filters: [{kinds: [Poll]}, makeCommentFilter([Poll])],
onBackwardExhausted: () => {
loading = false
},
})
events = feed.events
return () => {
feed.cleanup()
}
})
</script>
<SpaceBar>
{#snippet title()}
<Icon icon={PollIcon} />
<strong>Polls</strong>
{/snippet}
{#snippet action()}
<Button class="btn btn-primary btn-sm" onclick={createPoll}>
<Icon icon={Add} />
Create
</Button>
{/snippet}
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2 pt-4">
{#each items as event (event.id)}
<div in:fly>
<PollItem {url} event={$state.snapshot(event)} />
</div>
{/each}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading}
Looking for polls...
{:else if items.length === 0}
No polls found.
{:else}
That's all!
{/if}
</Spinner>
</p>
</PageContent>
@@ -0,0 +1,107 @@
<script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {repository} from "@welshman/app"
import {request} from "@welshman/net"
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte"
import {deriveEvent, decodeRelay} from "@app/core/state"
import {Poll, PollResponse} from "nostr-tools/kinds"
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
const event = deriveEvent(id, [url])
const filters = [{kinds: [COMMENT], "#E": [id]}]
const comments = deriveEventsAsc(deriveEventsById({repository, filters}))
const back = () => history.back()
const openReply = () => {
showReply = true
}
const closeReply = () => {
showReply = false
}
const expand = () => {
showAll = true
}
let showAll = $state(false)
let showReply = $state(false)
onMount(() => {
const controller = new AbortController()
request({
relays: [url],
filters: [{kinds: [Poll], ids: [id]}, {kinds: [PollResponse], "#e": [id]}, ...filters],
signal: controller.signal,
})
return () => {
controller.abort()
}
})
</script>
<SpaceBar {back}>
{#snippet title()}
<h1 class="text-xl">{$event?.content || "Poll"}</h1>
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-3 p-2 pt-4">
{#if $event}
<div class="flex flex-col gap-3">
<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} />
<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>
{#if !showAll && $comments.length > 4}
<div class="flex justify-center">
<Button class="btn btn-link" onclick={expand}>
<Icon icon={SortVertical} />
Show all {$comments.length} comments
</Button>
</div>
{/if}
{#each $comments.slice(0, showAll ? undefined : 4) as reply (reply.id)}
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12">
<NoteContent showEntire event={reply} {url} />
<CommentActions segment="polls" event={reply} {url} />
</div>
</NoteCard>
{/each}
</div>
{#if showReply}
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
{:else}
<div class="flex justify-end p-2">
<Button class="btn btn-primary" onclick={openReply}>Comment on this poll</Button>
</div>
{/if}
{:else}
{#await sleep(5000)}
<Spinner loading>Loading poll...</Spinner>
{:then}
<p>Failed to load poll.</p>
{/await}
{/if}
</PageContent>
@@ -26,8 +26,10 @@
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import PollItem from "@app/components/PollItem.svelte"
import RecentConversation from "@app/components/RecentConversation.svelte"
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state"
import {Poll} from "nostr-tools/kinds"
const url = decodeRelay($page.params.relay!)
const since = ago(3, MONTH)
@@ -126,6 +128,8 @@
<GoalItem {url} {event} />
{:else if event.kind === EVENT_TIME}
<CalendarEventItem {url} {event} />
{:else if event.kind === Poll}
<PollItem {url} {event} />
{:else}
<NoteItem {url} {event} />
{/if}