mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
polls
This commit is contained in:
@@ -208,7 +208,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if entry.poll}
|
{#if entry.poll}
|
||||||
<PollView poll={entry.poll} />
|
<PollView
|
||||||
|
poll={entry.poll}
|
||||||
|
authorId={entry.account.id}
|
||||||
|
onupdate={(poll) => onupdate?.(applyLocal(status, { poll }))}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
|
{#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
|
||||||
|
|||||||
@@ -39,17 +39,96 @@
|
|||||||
untrack(() => inReplyTo?.visibility ?? initialVisibility),
|
untrack(() => inReplyTo?.visibility ?? initialVisibility),
|
||||||
)
|
)
|
||||||
let attachments = $state<MediaAttachment[]>([])
|
let attachments = $state<MediaAttachment[]>([])
|
||||||
|
let showPoll = $state(false)
|
||||||
|
let pollOptions = $state(['', ''])
|
||||||
|
let pollMultiple = $state(false)
|
||||||
|
let pollExpiresIn = $state(86_400)
|
||||||
let busy = $state(false)
|
let busy = $state(false)
|
||||||
let uploading = $state(false)
|
let uploading = $state(false)
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
|
|
||||||
const maxCharacters = $derived(session.instance?.configuration?.statuses?.max_characters ?? 500)
|
const maxCharacters = $derived(session.instance?.configuration?.statuses?.max_characters ?? 500)
|
||||||
const maxAttachments = $derived(session.instance?.configuration?.statuses?.max_media_attachments ?? 4)
|
const maxAttachments = $derived(session.instance?.configuration?.statuses?.max_media_attachments ?? 4)
|
||||||
|
const maxPollOptions = $derived(session.instance?.configuration?.polls?.max_options ?? 4)
|
||||||
|
const maxPollOptionCharacters = $derived(
|
||||||
|
session.instance?.configuration?.polls?.max_characters_per_option ?? 50,
|
||||||
|
)
|
||||||
|
const minPollExpiration = $derived(session.instance?.configuration?.polls?.min_expiration ?? 300)
|
||||||
|
const maxPollExpiration = $derived(
|
||||||
|
session.instance?.configuration?.polls?.max_expiration ?? 2_592_000,
|
||||||
|
)
|
||||||
|
const pollDurations = $derived(durationChoices(minPollExpiration, maxPollExpiration))
|
||||||
|
const completedPollOptions = $derived(
|
||||||
|
pollOptions.map((option) => option.trim()).filter(Boolean),
|
||||||
|
)
|
||||||
|
const pollValid = $derived(
|
||||||
|
!showPoll ||
|
||||||
|
(completedPollOptions.length >= 2 &&
|
||||||
|
completedPollOptions.every((option) => option.length <= maxPollOptionCharacters)),
|
||||||
|
)
|
||||||
const remaining = $derived(maxCharacters - text.length - warning.length)
|
const remaining = $derived(maxCharacters - text.length - warning.length)
|
||||||
const canPost = $derived(
|
const canPost = $derived(
|
||||||
!busy && !uploading && remaining >= 0 && (text.trim().length > 0 || attachments.length > 0),
|
!busy &&
|
||||||
|
!uploading &&
|
||||||
|
remaining >= 0 &&
|
||||||
|
pollValid &&
|
||||||
|
!(showPoll && attachments.length > 0) &&
|
||||||
|
(text.trim().length > 0 || attachments.length > 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const POLL_DURATION_PRESETS = [
|
||||||
|
300,
|
||||||
|
1_800,
|
||||||
|
3_600,
|
||||||
|
6 * 3_600,
|
||||||
|
86_400,
|
||||||
|
3 * 86_400,
|
||||||
|
7 * 86_400,
|
||||||
|
30 * 86_400,
|
||||||
|
]
|
||||||
|
|
||||||
|
function durationChoices(minimum: number, maximum: number): number[] {
|
||||||
|
const min = Math.max(0, minimum)
|
||||||
|
const max = Math.max(min, maximum)
|
||||||
|
const preferred = Math.min(max, Math.max(min, 86_400))
|
||||||
|
return [...new Set([...POLL_DURATION_PRESETS.filter((value) => value >= min && value <= max), preferred])]
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(seconds: number): string {
|
||||||
|
if (seconds % 86_400 === 0) {
|
||||||
|
const days = seconds / 86_400
|
||||||
|
return `${days} day${days === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
if (seconds % 3_600 === 0) {
|
||||||
|
const hours = seconds / 3_600
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
const minutes = Math.max(1, Math.round(seconds / 60))
|
||||||
|
return `${minutes} minute${minutes === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePoll(): void {
|
||||||
|
if (showPoll) {
|
||||||
|
showPoll = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (attachments.length > 0) return
|
||||||
|
showPoll = true
|
||||||
|
pollOptions = ['', '']
|
||||||
|
pollMultiple = false
|
||||||
|
pollExpiresIn = pollDurations.includes(86_400) ? 86_400 : (pollDurations[0] ?? 86_400)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPollOption(): void {
|
||||||
|
if (pollOptions.length < maxPollOptions) pollOptions = [...pollOptions, '']
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePollOption(index: number): void {
|
||||||
|
if (pollOptions.length <= 2) return
|
||||||
|
pollOptions = pollOptions.filter((_, optionIndex) => optionIndex !== index)
|
||||||
|
}
|
||||||
|
|
||||||
async function onFiles(event: Event): Promise<void> {
|
async function onFiles(event: Event): Promise<void> {
|
||||||
const input = event.currentTarget as HTMLInputElement
|
const input = event.currentTarget as HTMLInputElement
|
||||||
const files = Array.from(input.files ?? [])
|
const files = Array.from(input.files ?? [])
|
||||||
@@ -87,11 +166,21 @@
|
|||||||
visibility,
|
visibility,
|
||||||
spoiler_text: showWarning ? warning : undefined,
|
spoiler_text: showWarning ? warning : undefined,
|
||||||
media_ids: attachments.map((media) => media.id),
|
media_ids: attachments.map((media) => media.id),
|
||||||
|
poll: showPoll
|
||||||
|
? {
|
||||||
|
options: completedPollOptions,
|
||||||
|
expires_in: pollExpiresIn,
|
||||||
|
multiple: pollMultiple,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
text = ''
|
text = ''
|
||||||
warning = ''
|
warning = ''
|
||||||
showWarning = false
|
showWarning = false
|
||||||
attachments = []
|
attachments = []
|
||||||
|
showPoll = false
|
||||||
|
pollOptions = ['', '']
|
||||||
|
pollMultiple = false
|
||||||
onposted?.(created)
|
onposted?.(created)
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error = cause instanceof Error ? cause.message : 'Could not post that.'
|
error = cause instanceof Error ? cause.message : 'Could not post that.'
|
||||||
@@ -140,6 +229,63 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showPoll}
|
||||||
|
<fieldset class="composer-poll">
|
||||||
|
<legend>Poll options</legend>
|
||||||
|
<div class="composer-poll-options">
|
||||||
|
{#each pollOptions as _, index (index)}
|
||||||
|
<div class="composer-poll-option">
|
||||||
|
<label class="visually-hidden" for={`composer-poll-option-${index}`}>
|
||||||
|
Poll option {index + 1}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`composer-poll-option-${index}`}
|
||||||
|
class="field-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={pollOptions[index]}
|
||||||
|
maxlength={maxPollOptionCharacters}
|
||||||
|
placeholder={`Option ${index + 1}`}
|
||||||
|
required={index < 2}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--small"
|
||||||
|
disabled={pollOptions.length <= 2}
|
||||||
|
aria-label={`Remove poll option ${index + 1}`}
|
||||||
|
onclick={() => removePollOption(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="composer-poll-settings">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--small"
|
||||||
|
disabled={pollOptions.length >= maxPollOptions}
|
||||||
|
onclick={addPollOption}
|
||||||
|
>
|
||||||
|
Add option
|
||||||
|
</button>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={pollMultiple} />
|
||||||
|
Allow multiple choices
|
||||||
|
</label>
|
||||||
|
<label for="composer-poll-duration">Keep open for</label>
|
||||||
|
<select id="composer-poll-duration" bind:value={pollExpiresIn}>
|
||||||
|
{#each pollDurations as seconds (seconds)}
|
||||||
|
<option value={seconds}>{durationLabel(seconds)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="field-hint">
|
||||||
|
At least two options are required; each can be up to
|
||||||
|
{maxPollOptionCharacters.toLocaleString()} characters.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="composer-toolbar">
|
<div class="composer-toolbar">
|
||||||
<label class="button button--small composer-upload">
|
<label class="button button--small composer-upload">
|
||||||
{uploading ? 'Uploading…' : 'Add photo'}
|
{uploading ? 'Uploading…' : 'Add photo'}
|
||||||
@@ -148,11 +294,22 @@
|
|||||||
type="file"
|
type="file"
|
||||||
accept="image/*,video/*,audio/*"
|
accept="image/*,video/*,audio/*"
|
||||||
multiple
|
multiple
|
||||||
disabled={uploading || attachments.length >= maxAttachments}
|
disabled={uploading || showPoll || attachments.length >= maxAttachments}
|
||||||
onchange={onFiles}
|
onchange={onFiles}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--small"
|
||||||
|
aria-pressed={showPoll ? 'true' : 'false'}
|
||||||
|
disabled={!showPoll && attachments.length > 0}
|
||||||
|
title={attachments.length > 0 ? 'Remove attachments before adding a poll' : 'Add a poll'}
|
||||||
|
onclick={togglePoll}
|
||||||
|
>
|
||||||
|
Poll
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button button--small"
|
class="button button--small"
|
||||||
|
|||||||
@@ -24,4 +24,59 @@ describe('Composer', () => {
|
|||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('composes a multiple-choice poll without a backend', async () => {
|
||||||
|
const postStatus = vi.fn().mockResolvedValue(status())
|
||||||
|
const services = testServices({
|
||||||
|
session: session({
|
||||||
|
token: 'token',
|
||||||
|
me: account(),
|
||||||
|
signedIn: true,
|
||||||
|
instance: {
|
||||||
|
title: 'Pleroma',
|
||||||
|
version: '2.9.0',
|
||||||
|
configuration: {
|
||||||
|
polls: {
|
||||||
|
max_options: 4,
|
||||||
|
max_characters_per_option: 30,
|
||||||
|
min_expiration: 300,
|
||||||
|
max_expiration: 604_800,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
endpoints: { postStatus },
|
||||||
|
})
|
||||||
|
const view = render(Composer, {
|
||||||
|
props: { initialText: 'Which old web feature should return?' },
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(view.getByRole('button', { name: 'Poll' }))
|
||||||
|
await fireEvent.input(view.getByRole('textbox', { name: 'Poll option 1' }), {
|
||||||
|
target: { value: 'Guestbooks' },
|
||||||
|
})
|
||||||
|
await fireEvent.input(view.getByRole('textbox', { name: 'Poll option 2' }), {
|
||||||
|
target: { value: 'Webrings' },
|
||||||
|
})
|
||||||
|
await fireEvent.click(view.getByRole('button', { name: 'Add option' }))
|
||||||
|
await fireEvent.input(view.getByRole('textbox', { name: 'Poll option 3' }), {
|
||||||
|
target: { value: 'Blink tags' },
|
||||||
|
})
|
||||||
|
await fireEvent.click(view.getByRole('checkbox', { name: 'Allow multiple choices' }))
|
||||||
|
await fireEvent.change(view.getByLabelText('Keep open for'), {
|
||||||
|
target: { value: '3600' },
|
||||||
|
})
|
||||||
|
await fireEvent.click(view.getByRole('button', { name: 'Post Entry' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(postStatus).toHaveBeenCalledOnce())
|
||||||
|
expect(postStatus.mock.calls[0][1]).toMatchObject({
|
||||||
|
status: 'Which old web feature should return?',
|
||||||
|
poll: {
|
||||||
|
options: ['Guestbooks', 'Webrings', 'Blink tags'],
|
||||||
|
expires_in: 3600,
|
||||||
|
multiple: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,42 +1,175 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/** Read-only poll results. Voting needs a write scope and a UI of its own. */
|
/** Mastodon/Pleroma poll choices, voting and results. */
|
||||||
|
import { untrack } from 'svelte'
|
||||||
import type { Poll } from '$lib/api/types'
|
import type { Poll } from '$lib/api/types'
|
||||||
import { relativeTime } from '$lib/util/time'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { formatCount } from '$lib/util/profile'
|
import { formatCount } from '$lib/util/profile'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
poll: Poll
|
poll: Poll
|
||||||
|
authorId?: string
|
||||||
|
onupdate?: (poll: Poll) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let { poll }: Props = $props()
|
let { poll, authorId, onupdate }: Props = $props()
|
||||||
|
const { endpoints, session } = useAppServices()
|
||||||
|
|
||||||
const total = $derived(poll.votes_count || 0)
|
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 {
|
function share(votes: number | null): number {
|
||||||
if (!total || votes === null) return 0
|
if (!total || votes === null) return 0
|
||||||
return Math.round((votes / total) * 100)
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="poll" data-expired={poll.expired ? 'true' : 'false'}>
|
<div
|
||||||
{#each poll.options as option, index (index)}
|
class="poll"
|
||||||
<div class="poll-option" data-own-vote={poll.own_votes?.includes(index) ? 'true' : 'false'}>
|
data-expired={currentPoll.expired ? 'true' : 'false'}
|
||||||
<div class="poll-option-label">
|
data-voted={currentPoll.voted ? 'true' : 'false'}
|
||||||
<span class="poll-option-title">{option.title}</span>
|
data-multiple={currentPoll.multiple ? 'true' : 'false'}
|
||||||
<span class="poll-option-share">{share(option.votes_count)}%</span>
|
>
|
||||||
|
{#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}
|
||||||
|
{option.title}
|
||||||
|
</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>
|
</div>
|
||||||
<div class="poll-option-bar">
|
{/each}
|
||||||
<span class="poll-option-fill" style="width: {share(option.votes_count)}%"></span>
|
{:else}
|
||||||
</div>
|
<form class="poll-vote-form" onsubmit={submitVote}>
|
||||||
</div>
|
<fieldset class="poll-choices" disabled={!canChoose}>
|
||||||
{/each}
|
<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}
|
||||||
|
<span>{option.title}</span>
|
||||||
|
</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">
|
<p class="poll-meta">
|
||||||
{formatCount(total)} vote{total === 1 ? '' : 's'}
|
{formatCount(total)} vote{total === 1 ? '' : 's'}
|
||||||
{#if poll.expired}
|
{#if currentPoll.expired}
|
||||||
· closed
|
· closed
|
||||||
{:else if poll.expires_at}
|
{:else if currentPoll.expires_at}
|
||||||
· closes {relativeTime(poll.expires_at).replace(' ago', ' from now')}
|
· closes in {closesIn(currentPoll.expires_at)}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { APP_SERVICES } from '$lib/app-services'
|
||||||
|
import type { ApiClient } from '$lib/api/client'
|
||||||
|
import type { Poll } from '$lib/api/types'
|
||||||
|
import { account, session, testServices } from '$test/fixtures'
|
||||||
|
import PollView from './PollView.svelte'
|
||||||
|
|
||||||
|
function poll(overrides: Partial<Poll> = {}): Poll {
|
||||||
|
return {
|
||||||
|
id: 'poll-1',
|
||||||
|
expires_at: '2099-01-01T00:00:00.000Z',
|
||||||
|
expired: false,
|
||||||
|
multiple: false,
|
||||||
|
votes_count: 0,
|
||||||
|
options: [
|
||||||
|
{ title: 'Cats', votes_count: null },
|
||||||
|
{ title: 'Dogs', votes_count: null },
|
||||||
|
{ title: 'Both', votes_count: null },
|
||||||
|
],
|
||||||
|
emojis: [],
|
||||||
|
voted: false,
|
||||||
|
own_votes: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedServices(
|
||||||
|
votePoll: (api: ApiClient, id: string, choices: number[]) => Promise<Poll>,
|
||||||
|
) {
|
||||||
|
return testServices({
|
||||||
|
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||||
|
endpoints: { votePoll },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PollView', () => {
|
||||||
|
it('submits one selected index and immediately shows returned results', async () => {
|
||||||
|
const updated = poll({
|
||||||
|
voted: true,
|
||||||
|
votes_count: 3,
|
||||||
|
own_votes: [1],
|
||||||
|
options: [
|
||||||
|
{ title: 'Cats', votes_count: 1 },
|
||||||
|
{ title: 'Dogs', votes_count: 2 },
|
||||||
|
{ title: 'Both', votes_count: 0 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const votePoll = vi.fn().mockResolvedValue(updated)
|
||||||
|
const onupdate = vi.fn()
|
||||||
|
const view = render(PollView, {
|
||||||
|
props: { poll: poll(), authorId: 'someone-else', onupdate },
|
||||||
|
context: new Map([[APP_SERVICES, signedServices(votePoll)]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(view.getByRole('radio', { name: 'Dogs' }))
|
||||||
|
await fireEvent.click(view.getByRole('button', { name: 'Vote' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(votePoll).toHaveBeenCalledOnce())
|
||||||
|
expect(votePoll.mock.calls[0][1]).toBe('poll-1')
|
||||||
|
expect(votePoll.mock.calls[0][2]).toEqual([1])
|
||||||
|
expect(onupdate).toHaveBeenCalledWith(updated)
|
||||||
|
expect(await view.findByText('67%')).toBeInTheDocument()
|
||||||
|
expect(view.getByLabelText('Your vote')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits every selected index for a multiple-choice poll', async () => {
|
||||||
|
const votePoll = vi.fn().mockResolvedValue(
|
||||||
|
poll({ multiple: true, voted: true, own_votes: [0, 2] }),
|
||||||
|
)
|
||||||
|
const view = render(PollView, {
|
||||||
|
props: { poll: poll({ multiple: true }), authorId: 'someone-else' },
|
||||||
|
context: new Map([[APP_SERVICES, signedServices(votePoll)]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(view.getByRole('checkbox', { name: 'Cats' }))
|
||||||
|
await fireEvent.click(view.getByRole('checkbox', { name: 'Both' }))
|
||||||
|
await fireEvent.click(view.getByRole('button', { name: 'Vote' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(votePoll).toHaveBeenCalledOnce())
|
||||||
|
expect(votePoll.mock.calls[0][2]).toEqual([0, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows open poll options to signed-out readers without enabling a vote', () => {
|
||||||
|
const view = render(PollView, {
|
||||||
|
props: { poll: poll(), authorId: 'someone-else' },
|
||||||
|
context: new Map([[APP_SERVICES, testServices()]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.getByRole('radio', { name: 'Cats' })).toBeDisabled()
|
||||||
|
expect(view.getByRole('link', { name: 'Sign in to vote' })).toHaveAttribute('href', '#/login')
|
||||||
|
expect(view.queryByRole('button', { name: 'Vote' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiClient } from './client'
|
import { ApiClient } from './client'
|
||||||
import { updateProfileFields } from './endpoints'
|
import { postStatus, updateProfileFields, votePoll } from './endpoints'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
@@ -38,3 +38,60 @@ describe('updateProfileFields', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('poll endpoints', () => {
|
||||||
|
it('submits selected poll option indexes as JSON', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
expect(init?.method).toBe('POST')
|
||||||
|
expect(JSON.parse(String(init?.body))).toEqual({ choices: [0, 2] })
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'poll-1',
|
||||||
|
expired: false,
|
||||||
|
multiple: true,
|
||||||
|
votes_count: 2,
|
||||||
|
options: [],
|
||||||
|
emojis: [],
|
||||||
|
voted: true,
|
||||||
|
own_votes: [0, 2],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
await votePoll(new ApiClient('example.test', 'token'), 'poll/one', [0, 2])
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'https://example.test/api/v1/polls/poll%2Fone/votes',
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes a standard Mastodon/Pleroma poll in a new status', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||||
|
status: 'Pick a snack',
|
||||||
|
poll: {
|
||||||
|
options: ['Cake', 'Fruit'],
|
||||||
|
expires_in: 3600,
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return new Response(JSON.stringify({ id: 'status-1' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
await postStatus(new ApiClient('example.test', 'token'), {
|
||||||
|
status: 'Pick a snack',
|
||||||
|
poll: {
|
||||||
|
options: ['Cake', 'Fruit'],
|
||||||
|
expires_in: 3600,
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
InstanceInfo,
|
InstanceInfo,
|
||||||
MediaAttachment,
|
MediaAttachment,
|
||||||
Notification,
|
Notification,
|
||||||
|
Poll,
|
||||||
Relationship,
|
Relationship,
|
||||||
SearchResults,
|
SearchResults,
|
||||||
Status,
|
Status,
|
||||||
@@ -229,6 +230,11 @@ export interface ComposeOptions {
|
|||||||
sensitive?: boolean
|
sensitive?: boolean
|
||||||
media_ids?: string[]
|
media_ids?: string[]
|
||||||
language?: string
|
language?: string
|
||||||
|
poll?: {
|
||||||
|
options: string[]
|
||||||
|
expires_in: number
|
||||||
|
multiple: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Status> {
|
export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Status> {
|
||||||
@@ -242,6 +248,7 @@ export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Sta
|
|||||||
if (options.sensitive) body.sensitive = true
|
if (options.sensitive) body.sensitive = true
|
||||||
if (options.media_ids?.length) body.media_ids = options.media_ids
|
if (options.media_ids?.length) body.media_ids = options.media_ids
|
||||||
if (options.language) body.language = options.language
|
if (options.language) body.language = options.language
|
||||||
|
if (options.poll) body.poll = options.poll
|
||||||
return api.post<Status>('/api/v1/statuses', body)
|
return api.post<Status>('/api/v1/statuses', body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +266,10 @@ export function reblogStatus(api: ApiClient, id: string, on: boolean): Promise<S
|
|||||||
return api.post<Status>(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`)
|
return api.post<Status>(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function votePoll(api: ApiClient, id: string, choices: number[]): Promise<Poll> {
|
||||||
|
return api.post<Poll>(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices })
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadMedia(api: ApiClient, file: File, description?: string): Promise<MediaAttachment> {
|
export async function uploadMedia(api: ApiClient, file: File, description?: string): Promise<MediaAttachment> {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.set('file', file)
|
form.set('file', file)
|
||||||
|
|||||||
@@ -266,6 +266,12 @@ export interface InstanceInfo {
|
|||||||
max_characters?: number
|
max_characters?: number
|
||||||
max_media_attachments?: number
|
max_media_attachments?: number
|
||||||
}
|
}
|
||||||
|
polls?: {
|
||||||
|
max_options?: number
|
||||||
|
max_characters_per_option?: number
|
||||||
|
min_expiration?: number
|
||||||
|
max_expiration?: number
|
||||||
|
}
|
||||||
accounts?: {
|
accounts?: {
|
||||||
max_profile_fields?: number
|
max_profile_fields?: number
|
||||||
/** Pleroma v2 names. */
|
/** Pleroma v2 names. */
|
||||||
|
|||||||
@@ -285,6 +285,46 @@
|
|||||||
background: var(--ms-nav-bg);
|
background: var(--ms-nav-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.poll-option[data-own-vote='true'] .poll-option-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-own-vote {
|
||||||
|
color: var(--ms-link-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-choices {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-choice {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-choice input {
|
||||||
|
flex: none;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-vote-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-sign-in,
|
||||||
|
.poll-notice {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: var(--ms-font-size-small);
|
||||||
|
}
|
||||||
|
|
||||||
.poll-meta {
|
.poll-meta {
|
||||||
font-size: var(--ms-font-size-small);
|
font-size: var(--ms-font-size-small);
|
||||||
color: var(--ms-muted-fg);
|
color: var(--ms-muted-fg);
|
||||||
|
|||||||
@@ -171,6 +171,41 @@ textarea {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-poll {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--ms-module-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-poll legend {
|
||||||
|
padding: 0 4px;
|
||||||
|
color: var(--ms-heading-fg);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-poll-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-poll-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-poll-option .field-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-poll-settings {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* The CSS editor in Settings wants to be a code surface, not prose. */
|
/* The CSS editor in Settings wants to be a code surface, not prose. */
|
||||||
.css-editor {
|
.css-editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user