fix: refine poll UX and review and fix requested changes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user