Add polls
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} />
|
||||||
|
Ask a Question
|
||||||
|
</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,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {ComponentProps} from "svelte"
|
||||||
|
import {derived} from "svelte/store"
|
||||||
|
import {PollResponse} from "nostr-tools/kinds"
|
||||||
|
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 = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}])
|
||||||
|
|
||||||
|
const results = derived(responses, $responses => getPollResults(props.event, $responses))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0">
|
||||||
|
<ContentMinimal {...props} />
|
||||||
|
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {ComponentProps} from "svelte"
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import {request} from "@welshman/net"
|
||||||
|
import {PollResponse} from "nostr-tools/kinds"
|
||||||
|
import PollVotes from "@app/components/PollVotes.svelte"
|
||||||
|
import Content from "@app/components/Content.svelte"
|
||||||
|
|
||||||
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!props.url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
request({
|
||||||
|
relays: [props.url],
|
||||||
|
filters: [{kinds: [PollResponse], "#e": [props.event.id]}],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<Content event={props.event} showEntire url={props.url} />
|
||||||
|
|
||||||
|
{#if props.url}
|
||||||
|
<PollVotes url={props.url} event={props.event} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
|
||||||
|
import {makeEvent} from "@welshman/util"
|
||||||
|
import {publishThunk} from "@welshman/app"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
|
||||||
|
import PlusCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
|
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
import {PROTECTED} from "@app/core/state"
|
||||||
|
import {canEnforceNip70} from "@app/core/commands"
|
||||||
|
import type {PollType} from "@app/util/polls"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
h?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, h}: Props = $props()
|
||||||
|
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
|
type DraftOption = {
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const addOption = () => {
|
||||||
|
options = [...options, {id: randomId(), value: ""}]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeOption = (id: string) => {
|
||||||
|
options = options.filter(option => option.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOption = (id: string, value: string) => {
|
||||||
|
options = options.map(option => (option.id === id ? {...option, value} : option))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
return pushToast({theme: "error", message: "Please provide a title for your poll."})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonEmptyOptions = removeUndefined(options.map(option => option.value.trim() || undefined))
|
||||||
|
|
||||||
|
if (nonEmptyOptions.length < 2) {
|
||||||
|
return pushToast({theme: "error", message: "Please provide at least two options."})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endsAt && endsAt <= now()) {
|
||||||
|
return pushToast({theme: "error", message: "End time must be in the future."})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
...nonEmptyOptions.map(option => ["option", randomId(), option]),
|
||||||
|
["polltype", pollType],
|
||||||
|
["relay", url],
|
||||||
|
]
|
||||||
|
|
||||||
|
if (endsAt) {
|
||||||
|
tags.push(["endsAt", String(endsAt)])
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PollType>("singlechoice")
|
||||||
|
let endsAt = $state<number | undefined>()
|
||||||
|
let options = $state<DraftOption[]>([
|
||||||
|
{id: randomId(), value: "Yes"},
|
||||||
|
{id: randomId(), value: "No"},
|
||||||
|
])
|
||||||
|
let draggedOptionId = $state<string | undefined>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Create a Poll</ModalTitle>
|
||||||
|
<ModalSubtitle>Ask a question and collect votes right in the feed.</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<div class="col-8 relative">
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Question*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
autofocus={!isMobile}
|
||||||
|
bind:value={title}
|
||||||
|
class="grow"
|
||||||
|
type="text"
|
||||||
|
placeholder="What would you like to ask?" />
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Options*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="flex flex-col gap-2" role="list">
|
||||||
|
{#each options as option, index (option.id)}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
draggable="true"
|
||||||
|
role="listitem"
|
||||||
|
ondragstart={e => onDragStart(e, option.id)}
|
||||||
|
ondragover={e => onDragOver(e, option.id)}
|
||||||
|
ondrop={e => onDrop(e, option.id)}
|
||||||
|
ondragend={onDragEnd}>
|
||||||
|
<div class="cursor-move opacity-70" aria-label="Drag handle">
|
||||||
|
<Icon icon={HamburgerMenu} size={4} />
|
||||||
|
</div>
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={option.value}
|
||||||
|
class="grow"
|
||||||
|
type="text"
|
||||||
|
placeholder={`Option ${index + 1}`}
|
||||||
|
oninput={e => updateOption(option.id, e.currentTarget.value)} />
|
||||||
|
</label>
|
||||||
|
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
|
||||||
|
<Icon icon={MinusCircle} size={4} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button class="btn btn-outline btn-sm self-end" onclick={addOption}>
|
||||||
|
<Icon icon={PlusCircle} size={4} />
|
||||||
|
Add option
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
Poll type
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select class="select select-bordered w-full max-w-xs" bind:value={pollType}>
|
||||||
|
<option value="singlechoice">Single choice</option>
|
||||||
|
<option value="multiplechoice">Multiple choice</option>
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
Ends at
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<DateTimeInput bind:value={endsAt} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="btn btn-primary">Create Poll</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue} from "@welshman/util"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
|
import CommentActions from "@app/components/CommentActions.svelte"
|
||||||
|
import RoomLink from "@app/components/RoomLink.svelte"
|
||||||
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
import {makePollPath} from "@app/util/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const h = getTagValue("h", event.tags)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||||
|
href={makePollPath(url, event.id)}>
|
||||||
|
<NoteContent {event} {url} />
|
||||||
|
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||||
|
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||||
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
|
{#if h}
|
||||||
|
in <RoomLink {url} {h} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<CommentActions segment="polls" showActivity {url} {event} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {tweened} from "svelte/motion"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {noop} from "@welshman/lib"
|
||||||
|
import {stopPropagation} from "@lib/html"
|
||||||
|
import {getPollType, isPollClosed} from "@app/util/polls"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
event: TrustedEvent
|
||||||
|
option: {id: string; label: string}
|
||||||
|
results: {voters: number; options: {id: string; votes: number}[]}
|
||||||
|
selectedIds: string[]
|
||||||
|
setSingleChoice: (id: string) => void
|
||||||
|
toggleMultipleChoice: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {event, option, results, selectedIds, setSingleChoice, toggleMultipleChoice}: Props =
|
||||||
|
$props()
|
||||||
|
|
||||||
|
const pollType = getPollType(event)
|
||||||
|
const closed = isPollClosed(event)
|
||||||
|
|
||||||
|
const selected = $derived(
|
||||||
|
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
|
||||||
|
)
|
||||||
|
const onselect = () =>
|
||||||
|
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
|
||||||
|
|
||||||
|
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
|
||||||
|
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
|
||||||
|
|
||||||
|
const tweenedVotes = tweened(votes, {duration: 300})
|
||||||
|
const tweenedMax = tweened(maxVotes, {duration: 300})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tweenedVotes.set(votes)
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tweenedMax.set(maxVotes)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
|
||||||
|
<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={selected}
|
||||||
|
onclick={stopPropagation(noop)}
|
||||||
|
onchange={onselect} />
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
|
checked={selected}
|
||||||
|
onclick={stopPropagation(noop)}
|
||||||
|
onchange={onselect} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<span class="truncate">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onDestroy} from "svelte"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
|
||||||
|
import {PollResponse} from "nostr-tools/kinds"
|
||||||
|
import {formatTimestampRelative} from "@welshman/lib"
|
||||||
|
import {deriveEvents} from "@app/core/state"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
import {makePollResponse} from "@app/core/commands"
|
||||||
|
import PollOption from "@app/components/PollOption.svelte"
|
||||||
|
import {
|
||||||
|
getPollEndsAt,
|
||||||
|
getPollOptions,
|
||||||
|
getPollResponseSelections,
|
||||||
|
getPollResults,
|
||||||
|
getPollType,
|
||||||
|
isPollClosed,
|
||||||
|
} from "@app/util/polls"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const responses = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}])
|
||||||
|
|
||||||
|
const pollType = getPollType(event)
|
||||||
|
const options = getPollOptions(event)
|
||||||
|
const closed = isPollClosed(event)
|
||||||
|
const endsAt = getPollEndsAt(event)
|
||||||
|
const publishDelay = pollType === "multiplechoice" ? 10_000 : undefined
|
||||||
|
|
||||||
|
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 publishSelection = (selection: string[]) => {
|
||||||
|
if (activeThunk) {
|
||||||
|
abortThunk(activeThunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.length === 0) {
|
||||||
|
activeThunk = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeThunk = publishThunk({
|
||||||
|
relays: [url],
|
||||||
|
event: makePollResponse({event, selectedIds: selection}),
|
||||||
|
delay: publishDelay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishCurrentSelection = () => {
|
||||||
|
const selection = pollType === "singlechoice" ? selectedIds.slice(0, 1) : selectedIds
|
||||||
|
|
||||||
|
if (selection.length === 0) {
|
||||||
|
return pushToast({theme: "error", message: "Please select at least one option."})
|
||||||
|
}
|
||||||
|
|
||||||
|
publishSelection(selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = $derived(getPollResults(event, $responses))
|
||||||
|
const ownResponse = $derived(getOwnResponse($responses))
|
||||||
|
|
||||||
|
const setSingleChoice = (id: string) => {
|
||||||
|
selectedIds = [id]
|
||||||
|
publishCurrentSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMultipleChoice = (id: string) => {
|
||||||
|
selectedIds = selectedIds.includes(id)
|
||||||
|
? selectedIds.filter(selectedId => selectedId !== id)
|
||||||
|
: [...selectedIds, id]
|
||||||
|
|
||||||
|
publishCurrentSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedIds = $state<string[]>([])
|
||||||
|
let activeThunk: ReturnType<typeof publishThunk> | undefined
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (ownResponse) {
|
||||||
|
selectedIds = getPollResponseSelections(ownResponse, pollType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (activeThunk) {
|
||||||
|
abortThunk(activeThunk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each options as option (option.id)}
|
||||||
|
<PollOption {event} {option} {results} {selectedIds} {setSingleChoice} {toggleMultipleChoice} />
|
||||||
|
{/each}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="text-sm opacity-75">
|
||||||
|
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
|
||||||
|
{#if endsAt}
|
||||||
|
{#if closed}
|
||||||
|
• Ended {formatTimestampRelative(endsAt)}
|
||||||
|
{:else}
|
||||||
|
• Ends {formatTimestampRelative(endsAt)}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -300,6 +302,7 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
filters: [
|
filters: [
|
||||||
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
|
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
|
||||||
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) {
|
||||||
@@ -311,7 +314,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,76 @@
|
|||||||
|
import {now, removeUndefined, uniq} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue, getTags, getTagValues} from "@welshman/util"
|
||||||
|
|
||||||
|
export type PollType = "singlechoice" | "multiplechoice"
|
||||||
|
|
||||||
|
export type PollOption = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
votes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPollType = (event: TrustedEvent): PollType =>
|
||||||
|
getTagValue("polltype", event.tags) === "multiplechoice" ? "multiplechoice" : "singlechoice"
|
||||||
|
|
||||||
|
export const getPollOptions = (event: TrustedEvent) =>
|
||||||
|
removeUndefined(
|
||||||
|
getTags("option", event.tags).map(tag => {
|
||||||
|
const [, id, label = id] = tag
|
||||||
|
|
||||||
|
if (!id) return undefined
|
||||||
|
|
||||||
|
return {id, label}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const getPollEndsAt = (event: TrustedEvent) => {
|
||||||
|
const endsAt = getTagValue("endsAt", event.tags)
|
||||||
|
|
||||||
|
if (!endsAt) return undefined
|
||||||
|
|
||||||
|
const timestamp = parseInt(endsAt)
|
||||||
|
|
||||||
|
return Number.isNaN(timestamp) ? undefined : timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPollClosed = (event: TrustedEvent) => {
|
||||||
|
const endsAt = getPollEndsAt(event)
|
||||||
|
|
||||||
|
return typeof endsAt === "number" ? endsAt <= now() : false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => {
|
||||||
|
const selections = getTagValues("response", event.tags)
|
||||||
|
|
||||||
|
return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {getTagValue} from "@welshman/util"
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
|
import PollIcon from "@assets/icons/revote.svg?dataurl"
|
||||||
|
import Add from "@assets/icons/add.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
|
import PollItem from "@app/components/PollItem.svelte"
|
||||||
|
import PollCreate from "@app/components/PollCreate.svelte"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
|
import {decodeRelay, makeCommentFilter} from "@app/core/state"
|
||||||
|
import {makeFeed} from "@app/core/requests"
|
||||||
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
|
const url = decodeRelay($page.params.relay!)
|
||||||
|
|
||||||
|
let loading = $state(true)
|
||||||
|
let element: HTMLElement | undefined = $state()
|
||||||
|
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
||||||
|
|
||||||
|
const createPoll = () => pushModal(PollCreate, {url})
|
||||||
|
|
||||||
|
const items = $derived.by(() => {
|
||||||
|
const scores = new Map<string, number[]>()
|
||||||
|
const [polls, comments] = partition(spec({kind: Poll}), $events)
|
||||||
|
|
||||||
|
for (const comment of comments) {
|
||||||
|
const id = getTagValue("E", comment.tags)
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
pushToMapKey(scores, id, comment.created_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), polls)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const feed = makeFeed({
|
||||||
|
url,
|
||||||
|
element: element!,
|
||||||
|
filters: [{kinds: [Poll]}, makeCommentFilter([Poll])],
|
||||||
|
onBackwardExhausted: () => {
|
||||||
|
loading = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
events = feed.events
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
feed.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SpaceBar>
|
||||||
|
{#snippet title()}
|
||||||
|
<Icon icon={PollIcon} />
|
||||||
|
<strong>Polls</strong>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet action()}
|
||||||
|
<Button class="btn btn-primary btn-sm" onclick={createPoll}>
|
||||||
|
<Icon icon={Add} />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</SpaceBar>
|
||||||
|
|
||||||
|
<PageContent bind:element class="flex flex-col gap-2 p-2 pt-4">
|
||||||
|
{#each items as event (event.id)}
|
||||||
|
<div in:fly>
|
||||||
|
<PollItem {url} event={$state.snapshot(event)} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<p class="flex h-10 items-center justify-center py-20">
|
||||||
|
<Spinner {loading}>
|
||||||
|
{#if loading}
|
||||||
|
Looking for polls...
|
||||||
|
{:else if items.length === 0}
|
||||||
|
No polls found.
|
||||||
|
{:else}
|
||||||
|
That's all!
|
||||||
|
{/if}
|
||||||
|
</Spinner>
|
||||||
|
</p>
|
||||||
|
</PageContent>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import {page} from "$app/stores"
|
||||||
|
import {sleep} from "@welshman/lib"
|
||||||
|
import type {MakeNonOptional} from "@welshman/lib"
|
||||||
|
import {COMMENT} from "@welshman/util"
|
||||||
|
import {repository} from "@welshman/app"
|
||||||
|
import {request} from "@welshman/net"
|
||||||
|
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
|
||||||
|
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
|
import CommentActions from "@app/components/CommentActions.svelte"
|
||||||
|
import EventReply from "@app/components/EventReply.svelte"
|
||||||
|
import {deriveEvent, decodeRelay} from "@app/core/state"
|
||||||
|
import {Poll, PollResponse} from "nostr-tools/kinds"
|
||||||
|
|
||||||
|
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
|
||||||
|
const url = decodeRelay(relay)
|
||||||
|
const event = deriveEvent(id, [url])
|
||||||
|
const filters = [{kinds: [COMMENT], "#E": [id]}]
|
||||||
|
const comments = deriveEventsAsc(deriveEventsById({repository, filters}))
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const openReply = () => {
|
||||||
|
showReply = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeReply = () => {
|
||||||
|
showReply = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const expand = () => {
|
||||||
|
showAll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let showAll = $state(false)
|
||||||
|
let showReply = $state(false)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
request({
|
||||||
|
relays: [url],
|
||||||
|
filters: [{kinds: [Poll], ids: [id]}, {kinds: [PollResponse], "#e": [id]}, ...filters],
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SpaceBar {back}>
|
||||||
|
{#snippet title()}
|
||||||
|
<h1 class="text-xl">{$event?.content || "Poll"}</h1>
|
||||||
|
{/snippet}
|
||||||
|
</SpaceBar>
|
||||||
|
|
||||||
|
<PageContent class="flex flex-col gap-3 p-2 pt-4">
|
||||||
|
{#if $event}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
||||||
|
<div class="col-3 ml-12 flex flex-col gap-3">
|
||||||
|
<NoteContent showEntire event={$event} {url} />
|
||||||
|
<CommentActions segment="polls" showActivity {url} event={$event} />
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
@@ -46,9 +46,11 @@
|
|||||||
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 {goToEvent} from "@app/util/routes"
|
import {goToEvent} from "@app/util/routes"
|
||||||
|
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)
|
||||||
@@ -306,6 +308,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