diff --git a/src/components/blog/BlogEntry.svelte b/src/components/blog/BlogEntry.svelte index 36cdeff..5c9fcb9 100644 --- a/src/components/blog/BlogEntry.svelte +++ b/src/components/blog/BlogEntry.svelte @@ -208,7 +208,11 @@ {/if} {#if entry.poll} - + onupdate?.(applyLocal(status, { poll }))} + /> {/if} {#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0} diff --git a/src/components/blog/Composer.svelte b/src/components/blog/Composer.svelte index 5d8d97b..bebae44 100644 --- a/src/components/blog/Composer.svelte +++ b/src/components/blog/Composer.svelte @@ -39,17 +39,96 @@ untrack(() => inReplyTo?.visibility ?? initialVisibility), ) let attachments = $state([]) + let showPoll = $state(false) + let pollOptions = $state(['', '']) + let pollMultiple = $state(false) + let pollExpiresIn = $state(86_400) let busy = $state(false) let uploading = $state(false) let error = $state(null) const maxCharacters = $derived(session.instance?.configuration?.statuses?.max_characters ?? 500) 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 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 { const input = event.currentTarget as HTMLInputElement const files = Array.from(input.files ?? []) @@ -87,11 +166,21 @@ visibility, spoiler_text: showWarning ? warning : undefined, media_ids: attachments.map((media) => media.id), + poll: showPoll + ? { + options: completedPollOptions, + expires_in: pollExpiresIn, + multiple: pollMultiple, + } + : undefined, }) text = '' warning = '' showWarning = false attachments = [] + showPoll = false + pollOptions = ['', ''] + pollMultiple = false onposted?.(created) } catch (cause) { error = cause instanceof Error ? cause.message : 'Could not post that.' @@ -140,6 +229,63 @@ {/if} + {#if showPoll} +
+ Poll options +
+ {#each pollOptions as _, index (index)} +
+ + + +
+ {/each} +
+
+ + + + +
+

+ At least two options are required; each can be up to + {maxPollOptionCharacters.toLocaleString()} characters. +

+
+ {/if} +
+ + + {#if currentPoll.multiple} + Choose one or more. + {/if} +
+ {:else} + + {/if} + + {/if} + + {#if isAuthor && !currentPoll.expired} +

You created this poll.

+ {/if} + {#if error} + + {/if}

{formatCount(total)} vote{total === 1 ? '' : 's'} - {#if poll.expired} + {#if currentPoll.expired} · closed - {:else if poll.expires_at} - · closes {relativeTime(poll.expires_at).replace(' ago', ' from now')} + {:else if currentPoll.expires_at} + · closes in {closesIn(currentPoll.expires_at)} {/if}

diff --git a/src/components/blog/PollView.test.ts b/src/components/blog/PollView.test.ts new file mode 100644 index 0000000..853e626 --- /dev/null +++ b/src/components/blog/PollView.test.ts @@ -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 { + 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, +) { + 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() + }) +}) diff --git a/src/lib/api/endpoints.test.ts b/src/lib/api/endpoints.test.ts index f1631e2..9be3665 100644 --- a/src/lib/api/endpoints.test.ts +++ b/src/lib/api/endpoints.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiClient } from './client' -import { updateProfileFields } from './endpoints' +import { postStatus, updateProfileFields, votePoll } from './endpoints' afterEach(() => { 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, + }, + }) + }) +}) diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index 570d01c..d63bc2e 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -15,6 +15,7 @@ import type { InstanceInfo, MediaAttachment, Notification, + Poll, Relationship, SearchResults, Status, @@ -229,6 +230,11 @@ export interface ComposeOptions { sensitive?: boolean media_ids?: string[] language?: string + poll?: { + options: string[] + expires_in: number + multiple: boolean + } } export function postStatus(api: ApiClient, options: ComposeOptions): Promise { @@ -242,6 +248,7 @@ export function postStatus(api: ApiClient, options: ComposeOptions): Promise('/api/v1/statuses', body) } @@ -259,6 +266,10 @@ export function reblogStatus(api: ApiClient, id: string, on: boolean): Promise(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`) } +export function votePoll(api: ApiClient, id: string, choices: number[]): Promise { + return api.post(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices }) +} + export async function uploadMedia(api: ApiClient, file: File, description?: string): Promise { const form = new FormData() form.set('file', file) diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 39f2893..64add8f 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -266,6 +266,12 @@ export interface InstanceInfo { max_characters?: number max_media_attachments?: number } + polls?: { + max_options?: number + max_characters_per_option?: number + min_expiration?: number + max_expiration?: number + } accounts?: { max_profile_fields?: number /** Pleroma v2 names. */ diff --git a/src/styles/blog.css b/src/styles/blog.css index 5c1ed9e..bac77a9 100644 --- a/src/styles/blog.css +++ b/src/styles/blog.css @@ -285,6 +285,46 @@ 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 { font-size: var(--ms-font-size-small); color: var(--ms-muted-fg); diff --git a/src/styles/forms.css b/src/styles/forms.css index ac1c104..3636c74 100644 --- a/src/styles/forms.css +++ b/src/styles/forms.css @@ -171,6 +171,41 @@ textarea { 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. */ .css-editor { width: 100%;