Files
plspace/src/components/blog/PollView.svelte
T

178 lines
5.7 KiB
Svelte

<script lang="ts">
/** Mastodon/Pleroma poll choices, voting and results. */
import { untrack } from 'svelte'
import type { CustomEmoji, Poll } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import { formatCount } from '$lib/util/profile'
import EmojiText from '../common/EmojiText.svelte'
interface Props {
poll: Poll
emojis?: CustomEmoji[]
authorId?: string
onupdate?: (poll: Poll) => void
}
let { poll, emojis = poll.emojis ?? [], authorId, onupdate }: Props = $props()
const { endpoints, session } = useAppServices()
function pollSignature(value: Poll): string {
return [
value.id,
value.expired,
value.voted,
value.votes_count,
value.own_votes?.join(',') ?? '',
value.options.map((option) => option.votes_count ?? '').join(','),
].join(':')
}
let incomingSignature = $state(untrack(() => pollSignature(poll)))
let currentPoll = $state(untrack(() => poll))
let selectedChoices = $state<number[]>([])
let singleChoice = $state<number | null>(null)
let busy = $state(false)
let error = $state<string | null>(null)
const total = $derived(currentPoll.votes_count || 0)
const isAuthor = $derived(Boolean(authorId && session.me?.id === authorId))
const showResults = $derived(currentPoll.expired || Boolean(currentPoll.voted) || isAuthor)
const canChoose = $derived(
session.signedIn && !isAuthor && !currentPoll.expired && !currentPoll.voted && !busy,
)
const choices = $derived(
currentPoll.multiple
? selectedChoices
: singleChoice === null
? []
: [singleChoice],
)
$effect(() => {
const next = poll
const signature = pollSignature(next)
if (signature !== incomingSignature) {
incomingSignature = signature
currentPoll = next
selectedChoices = []
singleChoice = null
error = null
}
})
function share(votes: number | null): number {
if (!total || votes === null) return 0
return Math.round((votes / total) * 100)
}
function closesIn(value: string): string {
const seconds = Math.max(0, Math.ceil((new Date(value).getTime() - Date.now()) / 1000))
const units: Array<[string, number]> = [
['day', 86_400],
['hour', 3_600],
['minute', 60],
]
for (const [label, size] of units) {
if (seconds >= size) {
const count = Math.ceil(seconds / size)
return `${count} ${label}${count === 1 ? '' : 's'}`
}
}
return seconds > 0 ? `${seconds} second${seconds === 1 ? '' : 's'}` : 'soon'
}
async function submitVote(event: SubmitEvent): Promise<void> {
event.preventDefault()
if (!canChoose || choices.length === 0) return
busy = true
error = null
try {
const updated = await endpoints.votePoll(session.api, currentPoll.id, choices)
currentPoll = updated
selectedChoices = []
singleChoice = null
onupdate?.(updated)
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not submit your vote.'
} finally {
busy = false
}
}
</script>
<div
class="poll"
data-expired={currentPoll.expired ? 'true' : 'false'}
data-voted={currentPoll.voted ? 'true' : 'false'}
data-multiple={currentPoll.multiple ? 'true' : 'false'}
>
{#if showResults}
{#each currentPoll.options as option, index (index)}
<div class="poll-option" data-own-vote={currentPoll.own_votes?.includes(index) ? 'true' : 'false'}>
<div class="poll-option-label">
<span class="poll-option-title">
{#if currentPoll.own_votes?.includes(index)}
<span class="poll-own-vote" aria-label="Your vote"></span>
{/if}
<EmojiText text={option.title} {emojis} />
</span>
<span class="poll-option-share">
{option.votes_count === null ? '—' : `${share(option.votes_count)}%`}
</span>
</div>
<div class="poll-option-bar" aria-hidden="true">
<span class="poll-option-fill" style={`width: ${share(option.votes_count)}%`}></span>
</div>
</div>
{/each}
{:else}
<form class="poll-vote-form" onsubmit={submitVote}>
<fieldset class="poll-choices" disabled={!canChoose}>
<legend class="visually-hidden">
{currentPoll.multiple ? 'Choose one or more poll options' : 'Choose one poll option'}
</legend>
{#each currentPoll.options as option, index (index)}
<label class="poll-choice">
{#if currentPoll.multiple}
<input type="checkbox" value={index} bind:group={selectedChoices} />
{:else}
<input type="radio" name={`poll-${currentPoll.id}`} value={index} bind:group={singleChoice} />
{/if}
<EmojiText text={option.title} {emojis} />
</label>
{/each}
</fieldset>
{#if session.signedIn}
<div class="poll-vote-actions">
<button class="button button--small button--primary" type="submit" disabled={!canChoose || choices.length === 0}>
{busy ? 'Voting…' : 'Vote'}
</button>
{#if currentPoll.multiple}
<span class="field-hint">Choose one or more.</span>
{/if}
</div>
{:else}
<p class="poll-sign-in"><a href="#/login">Sign in to vote</a></p>
{/if}
</form>
{/if}
{#if isAuthor && !currentPoll.expired}
<p class="poll-notice">You created this poll.</p>
{/if}
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
<p class="poll-meta">
{formatCount(total)} vote{total === 1 ? '' : 's'}
{#if currentPoll.expired}
&middot; closed
{:else if currentPoll.expires_at}
&middot; closes in {closesIn(currentPoll.expires_at)}
{/if}
</p>
</div>