fix: refine poll UX and review and fix requested changes

This commit is contained in:
Bhavishy
2026-04-03 04:04:45 +05:30
parent 9727a3d537
commit 70ce54c5a5
7 changed files with 142 additions and 102 deletions
+1 -1
View File
@@ -67,7 +67,7 @@
<li> <li>
<Button onclick={createPoll}> <Button onclick={createPoll}>
<Icon size={4} icon={Revote} /> <Icon size={4} icon={Revote} />
Poll Ask a Question
</Button> </Button>
</li> </li>
</ul> </ul>
@@ -2,21 +2,18 @@
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {PollResponse} from "nostr-tools/kinds" 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 ContentMinimal from "@app/components/ContentMinimal.svelte"
import {deriveEvents} from "@app/core/state"
import {getPollResults} from "@app/util/polls" import {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof ContentMinimal> = $props() const props: ComponentProps<typeof ContentMinimal> = $props()
const responses = deriveArray( const responses = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}])
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [props.event.id]}]}),
)
const results = derived(responses, $responses => getPollResults(props.event, $responses)) const results = derived(responses, $responses => getPollResults(props.event, $responses))
</script> </script>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-0">
<span class="text-sm">{props.event.content || "Poll"}</span> <span class="text-sm">{props.event.content || "Poll"}</span>
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span> <span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
</div> </div>
+16 -42
View File
@@ -1,55 +1,29 @@
<script lang="ts"> <script lang="ts">
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {derived} from "svelte/store" import {onMount} from "svelte"
import {formatTimestampRelative} from "@welshman/lib" import {request} from "@welshman/net"
import {PollResponse} from "nostr-tools/kinds" import {PollResponse} from "nostr-tools/kinds"
import {repository} from "@welshman/app" import PollVotes from "@app/components/PollVotes.svelte"
import {deriveArray, deriveEventsById} from "@welshman/store"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import {getPollEndsAt, getPollResults, getPollType, isPollClosed} from "@app/util/polls"
const props: ComponentProps<typeof Content> = $props() const props: ComponentProps<typeof Content> = $props()
const responses = deriveArray( onMount(() => {
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [props.event.id]}]}), if (!props.url) {
) return
}
const results = derived(responses, $responses => getPollResults(props.event, $responses)) request({
relays: [props.url],
const endsAt = getPollEndsAt(props.event) filters: [{kinds: [PollResponse], "#e": [props.event.id]}],
const pollType = getPollType(props.event) })
const closed = isPollClosed(props.event) })
</script> </script>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex flex-wrap items-start justify-between gap-2"> <p class="text-xl">{props.event.content || "Poll"}</p>
<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"> {#if props.url}
{#each $results.options as option (option.id)} <PollVotes url={props.url} event={props.event} />
{@const maxVotes = Math.max(...$results.options.map(item => item.votes), 1)} {/if}
<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> </div>
+88 -23
View File
@@ -1,15 +1,17 @@
<script lang="ts"> <script lang="ts">
import {randomId, removeUndefined} from "@welshman/lib" import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent} from "@welshman/util" import {makeEvent} from "@welshman/util"
import {publishThunk} from "@welshman/app" import {publishThunk} from "@welshman/app"
import {Poll} from "nostr-tools/kinds" import {Poll} from "nostr-tools/kinds"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" 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 PlusCircle from "@assets/icons/add-circle.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl" import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
@@ -20,6 +22,7 @@
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70} from "@app/core/commands"
import type {PollType} from "@app/util/polls"
type Props = { type Props = {
url: string url: string
@@ -30,14 +33,62 @@
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
type DraftOption = {
id: string
value: string
}
const back = () => history.back() const back = () => history.back()
const addOption = () => { const addOption = () => {
options = [...options, ""] options = [...options, {id: randomId(), value: ""}]
} }
const removeOption = (index: number) => { const removeOption = (id: string) => {
options = options.filter((_, optionIndex) => optionIndex !== index) options = options.filter(option => option.id !== id)
}
const updateOption = (id: string, value: string) => {
options = options.map(option => (option.id === id ? {...option, value} : option))
}
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)
}
const onDrop = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
draggedOptionId = undefined
}
const onDragEnd = () => {
draggedOptionId = undefined
} }
const submit = async () => { const submit = async () => {
@@ -45,13 +96,15 @@
return pushToast({theme: "error", message: "Please provide a title for your poll."}) 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) { if (nonEmptyOptions.length < 2) {
return pushToast({theme: "error", message: "Please provide at least two options."}) 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[][] = [ const tags: string[][] = [
...nonEmptyOptions.map(option => ["option", randomId(), option]), ...nonEmptyOptions.map(option => ["option", randomId(), option]),
@@ -59,8 +112,8 @@
["relay", url], ["relay", url],
] ]
if (parsedEndsAt) { if (endsAt) {
tags.push(["endsAt", String(parsedEndsAt)]) tags.push(["endsAt", String(endsAt)])
} }
if (h) { if (h) {
@@ -80,16 +133,20 @@
} }
let title = $state("") let title = $state("")
let pollType = $state<"singlechoice" | "multiplechoice">("singlechoice") let pollType = $state<PollType>("singlechoice")
let endsAt = $state("") let endsAt = $state<number | undefined>()
let options = $state(["Yes", "No"]) let options = $state<DraftOption[]>([
{id: randomId(), value: "Yes"},
{id: randomId(), value: "No"},
])
let draggedOptionId = $state<string | undefined>()
</script> </script>
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Create a Poll</ModalTitle> <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> </ModalHeader>
<div class="col-8 relative"> <div class="col-8 relative">
<Field> <Field>
@@ -114,22 +171,33 @@
<p>Options*</p> <p>Options*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2" role="list">
{#each options as option, index (index)} {#each options as option, index (option.id)}
<div class="flex items-center gap-2"> <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"> <label class="input input-bordered flex w-full items-center gap-2">
<input <input
bind:value={options[index]} value={option.value}
class="grow" class="grow"
type="text" type="text"
placeholder={`Option ${index + 1}`} /> placeholder={`Option ${index + 1}`}
oninput={e => updateOption(option.id, e.currentTarget.value)} />
</label> </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} /> <Icon icon={MinusCircle} size={4} />
</Button> </Button>
</div> </div>
{/each} {/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} /> <Icon icon={PlusCircle} size={4} />
Add option Add option
</Button> </Button>
@@ -154,10 +222,7 @@
Ends at Ends at
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<input <DateTimeInput bind:value={endsAt} />
bind:value={endsAt}
class="input input-bordered w-full max-w-xs"
type="datetime-local" />
{/snippet} {/snippet}
</FieldInline> </FieldInline>
</div> </div>
+26 -16
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {pubkey, repository} from "@welshman/app" import {pubkey} from "@welshman/app"
import {deriveArray, deriveEventsById} from "@welshman/store"
import {PollResponse} from "nostr-tools/kinds" import {PollResponse} from "nostr-tools/kinds"
import {formatTimestampRelative} from "@welshman/lib" import {formatTimestampRelative} from "@welshman/lib"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {deriveEvents} from "@app/core/state"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {publishPollResponse} from "@app/core/commands" import {publishPollResponse} from "@app/core/commands"
import { import {
@@ -23,22 +23,31 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const responses = deriveArray( const responses = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}])
deriveEventsById({repository, filters: [{kinds: [PollResponse], "#e": [event.id]}]}),
)
const pollType = getPollType(event) const pollType = getPollType(event)
const options = getPollOptions(event) const options = getPollOptions(event)
const closed = isPollClosed(event) const closed = isPollClosed(event)
const endsAt = getPollEndsAt(event) const endsAt = getPollEndsAt(event)
const results = $derived.by(() => getPollResults(event, $responses)) const getOwnResponse = (responses: TrustedEvent[]) => {
const ownResponse = $derived.by( let latest: TrustedEvent | undefined
() =>
$responses for (const response of responses) {
.filter(response => response.pubkey === $pubkey) if (response.pubkey !== $pubkey) {
.sort((left, right) => right.created_at - left.created_at)[0], continue
) }
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 () => { const submit = async () => {
if (closed) { if (closed) {
@@ -78,10 +87,11 @@
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"} {pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
{#if endsAt} {#if endsAt}
· Ends {formatTimestampRelative(endsAt)} {#if closed}
{/if} • Ended {formatTimestampRelative(endsAt)}
{#if closed} {:else}
· Closed • Ends {formatTimestampRelative(endsAt)}
{/if}
{/if} {/if}
</div> </div>
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div> <div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
+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 type {TrustedEvent} from "@welshman/util"
import {getTagValue, getTags} from "@welshman/util" import {getTagValue, getTags, getTagValues} from "@welshman/util"
export type PollType = "singlechoice" | "multiplechoice" export type PollType = "singlechoice" | "multiplechoice"
@@ -10,24 +10,22 @@ export type PollOption = {
votes: number votes: number
} }
const isDefined = <T>(value: T | undefined): value is T => value !== undefined
export const getPollType = (event: TrustedEvent): PollType => export const getPollType = (event: TrustedEvent): PollType =>
getTagValue("polltype", event.tags) === "multiplechoice" ? "multiplechoice" : "singlechoice" getTagValue("polltype", event.tags) === "multiplechoice" ? "multiplechoice" : "singlechoice"
export const getPollOptions = (event: TrustedEvent) => export const getPollOptions = (event: TrustedEvent) =>
getTags("option", event.tags) removeUndefined(
.map(tag => { getTags("option", event.tags).map(tag => {
const [, id, label = id] = tag const [, id, label = id] = tag
if (!id) return undefined if (!id) return undefined
return {id, label} return {id, label}
}) }),
.filter(isDefined) )
export const getPollEndsAt = (event: TrustedEvent) => { 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 if (!endsAt) return undefined
@@ -43,9 +41,7 @@ export const isPollClosed = (event: TrustedEvent) => {
} }
export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => { export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => {
const selections = getTags("response", event.tags) const selections = getTagValues("response", event.tags)
.map(tag => tag[1])
.filter(isDefined)
return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections) return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
} }
@@ -15,7 +15,6 @@
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
import PollVotes from "@app/components/PollVotes.svelte"
import CommentActions from "@app/components/CommentActions.svelte" import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte" import EventReply from "@app/components/EventReply.svelte"
import {deriveEvent, decodeRelay} from "@app/core/state" import {deriveEvent, decodeRelay} from "@app/core/state"
@@ -71,7 +70,6 @@
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full"> <NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12 flex flex-col gap-3"> <div class="col-3 ml-12 flex flex-col gap-3">
<NoteContent showEntire event={$event} {url} /> <NoteContent showEntire event={$event} {url} />
<PollVotes {url} event={$event} />
<CommentActions segment="polls" showActivity {url} event={$event} /> <CommentActions segment="polls" showActivity {url} event={$event} />
</div> </div>
</NoteCard> </NoteCard>