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>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />
Poll
Ask a Question
</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>
<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-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
</div>
+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
}
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>
<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>
<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))
}
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 () => {
@@ -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)])
}
if (h) {
@@ -80,16 +133,20 @@
}
let title = $state("")
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>
@@ -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)
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
for (const response of responses) {
if (response.pubkey !== $pubkey) {
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 () => {
if (closed) {
@@ -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>
+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 =>
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
@@ -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)
}
@@ -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>
</NoteCard>