feat: add NIP-88 poll support
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||||
import CaseMinimalistic from "@assets/icons/case-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 Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||||
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
||||||
import GoalCreate from "@app/components/GoalCreate.svelte"
|
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||||
|
import PollCreate from "@app/components/PollCreate.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
@@ -28,6 +30,8 @@
|
|||||||
|
|
||||||
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
|
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
|
||||||
|
|
||||||
|
const createPoll = () => pushModal(PollCreate, {url, h})
|
||||||
|
|
||||||
let ul: Element
|
let ul: Element
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -60,4 +64,10 @@
|
|||||||
Create Thread
|
Create Thread
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button onclick={createPoll}>
|
||||||
|
<Icon size={4} icon={Revote} />
|
||||||
|
Poll
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
||||||
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
||||||
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
||||||
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
||||||
|
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
@@ -19,6 +21,8 @@
|
|||||||
<NoteContentClassified {...props} />
|
<NoteContentClassified {...props} />
|
||||||
{:else if props.event.kind === ZAP_GOAL}
|
{:else if props.event.kind === ZAP_GOAL}
|
||||||
<NoteContentGoal {...props} />
|
<NoteContentGoal {...props} />
|
||||||
|
{:else if props.event.kind === Poll}
|
||||||
|
<NoteContentPoll {...props} />
|
||||||
{:else}
|
{:else}
|
||||||
<Content {...props} />
|
<Content {...props} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
||||||
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
||||||
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
||||||
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
||||||
|
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
|
||||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||||
@@ -19,6 +21,8 @@
|
|||||||
<NoteContentMinimalClassified {...props} />
|
<NoteContentMinimalClassified {...props} />
|
||||||
{:else if props.event.kind === ZAP_GOAL}
|
{:else if props.event.kind === ZAP_GOAL}
|
||||||
<NoteContentMinimalGoal {...props} />
|
<NoteContentMinimalGoal {...props} />
|
||||||
|
{:else if props.event.kind === Poll}
|
||||||
|
<NoteContentMinimalPoll {...props} />
|
||||||
{:else}
|
{:else}
|
||||||
<ContentMinimal {...props} />
|
<ContentMinimal {...props} />
|
||||||
{/if}
|
{/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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
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 {deriveRelay, createSearch, pubkey} from "@welshman/app"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||||
import CaseMinimalistic from "@assets/icons/case-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 AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
@@ -69,6 +71,7 @@
|
|||||||
const threadsPath = makeSpacePath(url, "threads")
|
const threadsPath = makeSpacePath(url, "threads")
|
||||||
const classifiedsPath = makeSpacePath(url, "classifieds")
|
const classifiedsPath = makeSpacePath(url, "classifieds")
|
||||||
const calendarPath = makeSpacePath(url, "calendar")
|
const calendarPath = makeSpacePath(url, "calendar")
|
||||||
|
const pollsPath = makeSpacePath(url, "polls")
|
||||||
const userRooms = deriveUserRooms(url)
|
const userRooms = deriveUserRooms(url)
|
||||||
const otherRooms = deriveOtherRooms(url)
|
const otherRooms = deriveOtherRooms(url)
|
||||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||||
@@ -257,6 +260,11 @@
|
|||||||
<Icon icon={CalendarMinimalistic} /> Calendar
|
<Icon icon={CalendarMinimalistic} /> Calendar
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $spaceKinds.has(Poll)}
|
||||||
|
<SecondaryNavItem href={pollsPath}>
|
||||||
|
<Icon icon={Revote} /> Polls
|
||||||
|
</SecondaryNavItem>
|
||||||
|
{/if}
|
||||||
{#if hasNip29($relay)}
|
{#if hasNip29($relay)}
|
||||||
{#if $userRooms.length > 0}
|
{#if $userRooms.length > 0}
|
||||||
<div class="h-2 flex-shrink-0"></div>
|
<div class="h-2 flex-shrink-0"></div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import {Nip01Signer} from "@welshman/signer"
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
import type {UploadTask} from "@welshman/editor"
|
import type {UploadTask} from "@welshman/editor"
|
||||||
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
|
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
|
||||||
|
import {PollResponse} from "nostr-tools/kinds"
|
||||||
import {
|
import {
|
||||||
DELETE,
|
DELETE,
|
||||||
REPORT,
|
REPORT,
|
||||||
@@ -351,6 +352,22 @@ export const publishReaction = ({relays, ...params}: ReactionParams & {relays: s
|
|||||||
publishThunk({event: makeReaction({url: relays[0], ...params}), relays})
|
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
|
// Comments
|
||||||
|
|
||||||
export type CommentParams = {
|
export type CommentParams = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {context as pomadeContext} from "@pomade/core"
|
|||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {derived, readable, writable} from "svelte/store"
|
import {derived, readable, writable} from "svelte/store"
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
import {
|
import {
|
||||||
on,
|
on,
|
||||||
gt,
|
gt,
|
||||||
@@ -325,7 +326,7 @@ if (ENABLE_ZAPS) {
|
|||||||
REACTION_KINDS.push(ZAP_RESPONSE)
|
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]
|
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {page} from "$app/stores"
|
|||||||
import type {Unsubscriber} from "svelte/store"
|
import type {Unsubscriber} from "svelte/store"
|
||||||
import {derived, get} from "svelte/store"
|
import {derived, get} from "svelte/store"
|
||||||
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
|
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
|
||||||
|
import {PollResponse} from "nostr-tools/kinds"
|
||||||
import {
|
import {
|
||||||
getListTags,
|
getListTags,
|
||||||
getRelayTagValues,
|
getRelayTagValues,
|
||||||
@@ -281,6 +282,7 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
filters: [
|
filters: [
|
||||||
{kinds: MESSAGE_KINDS, since, "#h": [room]},
|
{kinds: MESSAGE_KINDS, since, "#h": [room]},
|
||||||
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
|
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
|
||||||
|
{kinds: [PollResponse], since},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -303,6 +305,7 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
{kinds: roomMemberKinds},
|
{kinds: roomMemberKinds},
|
||||||
{kinds: MESSAGE_KINDS, since},
|
{kinds: MESSAGE_KINDS, since},
|
||||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||||
|
{kinds: [PollResponse], since},
|
||||||
],
|
],
|
||||||
onEvent: event => {
|
onEvent: event => {
|
||||||
if (event.kind === ROOM_META) {
|
if (event.kind === ROOM_META) {
|
||||||
@@ -314,7 +317,7 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
listen({
|
listen({
|
||||||
url,
|
url,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
filters: [{kinds: REACTION_KINDS}],
|
filters: [{kinds: REACTION_KINDS}, {kinds: [PollResponse]}],
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {now, uniq} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue, getTags} from "@welshman/util"
|
||||||
|
|
||||||
|
export type PollType = "singlechoice" | "multiplechoice"
|
||||||
|
|
||||||
|
export type PollOption = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
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 => {
|
||||||
|
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)
|
||||||
|
|
||||||
|
if (!endsAt) return undefined
|
||||||
|
|
||||||
|
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 = getTags("response", event.tags)
|
||||||
|
.map(tag => tag[1])
|
||||||
|
.filter(isDefined)
|
||||||
|
|
||||||
|
return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {page} from "$app/stores"
|
|||||||
import {nthEq} from "@welshman/lib"
|
import {nthEq} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getAddress} from "@welshman/util"
|
import {getAddress} from "@welshman/util"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
import {tracker, userMessagingRelayList} from "@welshman/app"
|
import {tracker, userMessagingRelayList} from "@welshman/app"
|
||||||
import {identity} from "@welshman/lib"
|
import {identity} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
@@ -90,6 +91,8 @@ export const makeClassifiedPath = (url: string, address?: string) =>
|
|||||||
export const makeCalendarPath = (url: string, address?: string) =>
|
export const makeCalendarPath = (url: string, address?: string) =>
|
||||||
makeSpacePath(url, "calendar", address)
|
makeSpacePath(url, "calendar", address)
|
||||||
|
|
||||||
|
export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id)
|
||||||
|
|
||||||
export const scrollToEvent = (id: string) => {
|
export const scrollToEvent = (id: string) => {
|
||||||
const element = document.querySelector(`[data-event="${id}"]`) as any
|
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))
|
return makeCalendarPath(url, getAddress(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.kind === Poll) {
|
||||||
|
return makePollPath(url, event.id)
|
||||||
|
}
|
||||||
|
|
||||||
if (event.kind === MESSAGE) {
|
if (event.kind === MESSAGE) {
|
||||||
return makeMessagePath(url, event)
|
return makeMessagePath(url, event)
|
||||||
}
|
}
|
||||||
@@ -192,5 +199,7 @@ export const getRoomItemPath = (url: string, event: TrustedEvent) => {
|
|||||||
return makeGoalPath(url, event.id)
|
return makeGoalPath(url, event.id)
|
||||||
case EVENT_TIME:
|
case EVENT_TIME:
|
||||||
return makeCalendarPath(url, getAddress(event))
|
return makeCalendarPath(url, getAddress(event))
|
||||||
|
case Poll:
|
||||||
|
return makePollPath(url, event.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const staticTitles = new Map<string, string>([
|
|||||||
["/spaces/[relay]/classifieds", "Classifieds"],
|
["/spaces/[relay]/classifieds", "Classifieds"],
|
||||||
["/spaces/[relay]/calendar", "Calendar"],
|
["/spaces/[relay]/calendar", "Calendar"],
|
||||||
["/spaces/[relay]/goals", "Goals"],
|
["/spaces/[relay]/goals", "Goals"],
|
||||||
|
["/spaces/[relay]/polls", "Polls"],
|
||||||
["/chat", "Messages"],
|
["/chat", "Messages"],
|
||||||
["/join", "Join Space"],
|
["/join", "Join Space"],
|
||||||
["/people", "Find People"],
|
["/people", "Find People"],
|
||||||
@@ -35,6 +36,7 @@ const eventRoutes = new Set([
|
|||||||
"/spaces/[relay]/goals/[id]",
|
"/spaces/[relay]/goals/[id]",
|
||||||
"/spaces/[relay]/calendar/[address]",
|
"/spaces/[relay]/calendar/[address]",
|
||||||
"/spaces/[relay]/classifieds/[address]",
|
"/spaces/[relay]/classifieds/[address]",
|
||||||
|
"/spaces/[relay]/polls/[id]",
|
||||||
])
|
])
|
||||||
|
|
||||||
type RouteParams = Record<string, string | undefined>
|
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 {COMMENT, 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,105 @@
|
|||||||
|
<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 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"
|
||||||
|
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} />
|
||||||
|
<PollVotes {url} event={$event} />
|
||||||
|
<CommentActions segment="polls" showActivity {url} event={$event} />
|
||||||
|
</div>
|
||||||
|
</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 ClassifiedItem from "@app/components/ClassifiedItem.svelte"
|
||||||
import GoalItem from "@app/components/GoalItem.svelte"
|
import GoalItem from "@app/components/GoalItem.svelte"
|
||||||
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
||||||
|
import PollItem from "@app/components/PollItem.svelte"
|
||||||
import RecentConversation from "@app/components/RecentConversation.svelte"
|
import RecentConversation from "@app/components/RecentConversation.svelte"
|
||||||
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state"
|
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
|
|
||||||
const url = decodeRelay($page.params.relay!)
|
const url = decodeRelay($page.params.relay!)
|
||||||
const since = ago(3, MONTH)
|
const since = ago(3, MONTH)
|
||||||
@@ -126,6 +128,8 @@
|
|||||||
<GoalItem {url} {event} />
|
<GoalItem {url} {event} />
|
||||||
{:else if event.kind === EVENT_TIME}
|
{:else if event.kind === EVENT_TIME}
|
||||||
<CalendarEventItem {url} {event} />
|
<CalendarEventItem {url} {event} />
|
||||||
|
{:else if event.kind === Poll}
|
||||||
|
<PollItem {url} {event} />
|
||||||
{:else}
|
{:else}
|
||||||
<NoteItem {url} {event} />
|
<NoteItem {url} {event} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user