-
-
{option.title}
-
{share(option.votes_count)}%
+
+ {#if showResults}
+ {#each currentPoll.options as option, index (index)}
+
+
+
+ {#if currentPoll.own_votes?.includes(index)}
+ ✓
+ {/if}
+ {option.title}
+
+
+ {option.votes_count === null ? '—' : `${share(option.votes_count)}%`}
+
+
+
+
+
-
-
-
-
- {/each}
+ {/each}
+ {:else}
+
+ {/if}
+
+ {#if isAuthor && !currentPoll.expired}
+
You created this poll.
+ {/if}
+ {#if error}
+
{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%;