From 657d517503557930246ee43103b903acfe228fdd Mon Sep 17 00:00:00 2001 From: "Moon.eth" Date: Thu, 30 Jul 2026 19:45:40 +0900 Subject: [PATCH] quote posts --- src/App.svelte | 8 +- src/components/blog/BlogEntry.svelte | 40 ++++++- src/components/blog/BlogEntry.test.ts | 55 ++++++++++ src/components/blog/Composer.svelte | 38 ++++++- src/components/blog/Composer.test.ts | 25 +++++ src/components/blog/QuoteCard.svelte | 148 ++++++++++++++++++++++++++ src/components/blog/QuoteCard.test.ts | 31 ++++++ src/lib/api/endpoints.test.ts | 53 +++++++++ src/lib/api/endpoints.ts | 21 ++++ src/lib/api/types.ts | 40 +++++++ src/lib/notifications.ts | 2 + src/lib/router.svelte.ts | 1 + src/lib/router.test.ts | 7 ++ src/lib/util/status.test.ts | 62 +++++++++++ src/lib/util/status.ts | 57 ++++++++++ src/routes/Compose.svelte | 65 +++++++++-- src/routes/Compose.test.ts | 24 ++++- src/routes/Mail.svelte | 2 + src/routes/Quotes.svelte | 90 ++++++++++++++++ src/routes/Quotes.test.ts | 29 +++++ src/styles/blog.css | 65 +++++++++++ src/styles/forms.css | 20 ++++ 22 files changed, 869 insertions(+), 14 deletions(-) create mode 100644 src/components/blog/QuoteCard.svelte create mode 100644 src/components/blog/QuoteCard.test.ts create mode 100644 src/lib/util/status.test.ts create mode 100644 src/lib/util/status.ts create mode 100644 src/routes/Quotes.svelte create mode 100644 src/routes/Quotes.test.ts diff --git a/src/App.svelte b/src/App.svelte index 22cd97d..2516112 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,6 +12,7 @@ import Profile from '$routes/Profile.svelte' import Timeline from '$routes/Timeline.svelte' import StatusPage from '$routes/StatusPage.svelte' + import Quotes from '$routes/Quotes.svelte' import Mail from '$routes/Mail.svelte' import Browse from '$routes/Browse.svelte' import Search from '$routes/Search.svelte' @@ -89,6 +90,8 @@ {:else if route.name === 'blog.entry'} + {:else if route.name === 'blog.quotes'} + {:else if route.name === 'mail'} {:else if route.name === 'mail.folder'} @@ -98,7 +101,10 @@ {:else if route.name === 'search'} {:else if route.name === 'compose'} - + {:else if route.name === 'login'} {:else if route.name === 'settings'} diff --git a/src/components/blog/BlogEntry.svelte b/src/components/blog/BlogEntry.svelte index 7a0a93f..8f425d5 100644 --- a/src/components/blog/BlogEntry.svelte +++ b/src/components/blog/BlogEntry.svelte @@ -14,6 +14,8 @@ import { useAppServices } from '$lib/app-services' import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile' import { renderDisplayName } from '$lib/util/html' + import { routeTo } from '$lib/router.svelte' + import { quoteReferenceOf, quotesCountOf } from '$lib/util/status' import { isoDate, longDate, stampDate } from '$lib/util/time' import { extractYouTubeVideoIds } from '$lib/util/youtube' import Avatar from '../common/Avatar.svelte' @@ -23,6 +25,7 @@ import EmojiReactions from './EmojiReactions.svelte' import PollView from './PollView.svelte' import PreviewCardView from './PreviewCardView.svelte' + import QuoteCard from './QuoteCard.svelte' import YouTubeEmbeds from './YouTubeEmbeds.svelte' interface Props { @@ -50,6 +53,15 @@ const permalink = $derived(`#/blog/${entry.id}`) const isMine = $derived(session.me?.id === entry.account.id) const youtubeVideoIds = $derived(extractYouTubeVideoIds(entry.content)) + const quote = $derived(quoteReferenceOf(entry)) + const quotesCount = $derived(quotesCountOf(entry)) + const canQuote = $derived( + entry.visibility !== 'direct' && + !['denied', 'unknown'].includes(entry.quote_approval?.current_user ?? '') && + (entry.visibility !== 'private' || + session.me?.id === entry.account.id || + Boolean(entry.quote_approval)), + ) let busy = $state(false) let actionError = $state(null) @@ -189,6 +201,7 @@ {:else} {/if} + {#if quote} + + {/if} + {#if entry.poll} {/if} - {#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0} + {#if entry.card && !quote && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0} {/if} @@ -262,6 +280,26 @@ ({formatCount(entry.reblogs_count)}) + {#if canQuote} + + Quote + + {/if} + + {#if quotesCount > 0} + + Quotes ({formatCount(quotesCount)}) + + {/if} + {#if entry.url} Original diff --git a/src/components/blog/BlogEntry.test.ts b/src/components/blog/BlogEntry.test.ts index 53eb798..515ceda 100644 --- a/src/components/blog/BlogEntry.test.ts +++ b/src/components/blog/BlogEntry.test.ts @@ -67,6 +67,61 @@ describe('BlogEntry MFM rendering', () => { }) }) +describe('BlogEntry quote posts', () => { + it('renders a Pleroma quote and links to quote composition and the quote list', () => { + const quoted = status({ + id: 'quoted-entry', + content: '

Quoted words

', + account: account({ id: 'quoted-author', display_name: 'Quoted Author' }), + }) + const outer = status({ + id: 'outer-entry', + content: '

My commentary

', + quotes_count: 3, + pleroma: { quote: quoted, quote_id: quoted.id, quote_visible: true }, + }) + const services = testServices({ + session: session({ signedIn: true, token: 'token', me: account() }), + }) + const view = render(BlogEntry, { + props: { status: outer }, + context: new Map([[APP_SERVICES, services]]), + }) + + expect(view.getByText('Quoted words')).toBeInTheDocument() + expect(view.getByRole('link', { name: 'Quote' })).toHaveAttribute( + 'href', + '#/compose?quote=outer-entry', + ) + expect(view.getByRole('link', { name: 'Quotes (3)' })).toHaveAttribute( + 'href', + '#/blog/outer-entry/quotes', + ) + }) + + it('shows a Mastodon quote authorization placeholder without leaking content', () => { + const hidden = status({ id: 'hidden-quote', content: '

Do not display me

' }) + const view = renderEntry({ + quote: { state: 'blocked_account', quoted_status: hidden }, + }) + + expect(view.getByText('The quoted account is blocked.')).toBeInTheDocument() + expect(view.queryByText('Do not display me')).not.toBeInTheDocument() + }) + + it('does not offer quoting when Mastodon says the viewer is denied', () => { + const view = renderEntry({ + quote_approval: { + automatic: [], + manual: [], + current_user: 'denied', + }, + }) + + expect(view.queryByRole('link', { name: 'Quote' })).not.toBeInTheDocument() + }) +}) + describe('BlogEntry emoji reactions', () => { it('collapses the reaction bar when the entry has no reactions', () => { const view = renderEntry({ pleroma: { emoji_reactions: [] } }) diff --git a/src/components/blog/Composer.svelte b/src/components/blog/Composer.svelte index 3554afb..106a202 100644 --- a/src/components/blog/Composer.svelte +++ b/src/components/blog/Composer.svelte @@ -9,10 +9,13 @@ import { untrack } from 'svelte' import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types' import { useAppServices } from '$lib/app-services' + import QuoteCard from './QuoteCard.svelte' interface Props { /** Set to reply to an existing entry. */ inReplyTo?: Status | null + /** Status attached as a structured quote. */ + quote?: Status | null /** Prefilled body, e.g. the mentions of the entry being replied to. */ initialText?: string initialVisibility?: StatusVisibility @@ -23,6 +26,7 @@ let { inReplyTo = null, + quote = null, initialText = '', initialVisibility = 'public', placeholder = 'What are you up to?', @@ -33,6 +37,7 @@ const { endpoints, session } = useAppServices() // Seeded once from the prop; afterwards the textarea owns the value. let text = $state(untrack(() => initialText)) + let quotedStatus = $state(untrack(() => quote)) let warning = $state('') let showWarning = $state(false) let visibility = $state( @@ -73,7 +78,7 @@ remaining >= 0 && pollValid && !(showPoll && attachments.length > 0) && - (text.trim().length > 0 || attachments.length > 0), + (text.trim().length > 0 || attachments.length > 0 || quotedStatus !== null), ) const POLL_DURATION_PRESETS = [ @@ -201,6 +206,7 @@ multiple: pollMultiple, } : undefined, + quoted_status_id: quotedStatus?.id, }) text = '' warning = '' @@ -209,6 +215,7 @@ showPoll = false pollOptions = ['', ''] pollMultiple = false + quotedStatus = null onposted?.(created) } catch (cause) { error = cause instanceof Error ? cause.message : 'Could not post that.' @@ -250,6 +257,35 @@ {#if session.signedIn}
+ {#if quotedStatus} +
+

+ Quoting this entry + +

+ {#if quotedStatus.quote_approval?.current_user === 'manual'} +

+ The original author will need to approve this quote before it appears. +

+ {/if} + +
+ {/if} + {#if error} {/if} diff --git a/src/components/blog/Composer.test.ts b/src/components/blog/Composer.test.ts index 1c36f57..7341aad 100644 --- a/src/components/blog/Composer.test.ts +++ b/src/components/blog/Composer.test.ts @@ -25,6 +25,31 @@ describe('Composer', () => { }) }) + it('can submit a structured quote without commentary', async () => { + const quoted = status({ + id: 'quoted-entry', + content: '

The original thought

', + }) + const postStatus = vi.fn().mockResolvedValue(status({ id: 'new-quote' })) + const services = testServices({ + session: session({ token: 'token', me: account(), signedIn: true }), + endpoints: { postStatus }, + }) + const view = render(Composer, { + props: { quote: quoted, submitLabel: 'Post Quote' }, + context: new Map([[APP_SERVICES, services]]), + }) + + expect(view.getByText('The original thought')).toBeInTheDocument() + await fireEvent.click(view.getByRole('button', { name: 'Post Quote' })) + + await waitFor(() => expect(postStatus).toHaveBeenCalledOnce()) + expect(postStatus.mock.calls[0][1]).toMatchObject({ + status: '', + quoted_status_id: 'quoted-entry', + }) + }) + it('composes a multiple-choice poll without a backend', async () => { const postStatus = vi.fn().mockResolvedValue(status()) const services = testServices({ diff --git a/src/components/blog/QuoteCard.svelte b/src/components/blog/QuoteCard.svelte new file mode 100644 index 0000000..477dafc --- /dev/null +++ b/src/components/blog/QuoteCard.svelte @@ -0,0 +1,148 @@ + + +
diff --git a/src/components/blog/QuoteCard.test.ts b/src/components/blog/QuoteCard.test.ts new file mode 100644 index 0000000..b97f70a --- /dev/null +++ b/src/components/blog/QuoteCard.test.ts @@ -0,0 +1,31 @@ +import { render } from '@testing-library/svelte' +import { describe, expect, it, vi } from 'vitest' +import { APP_SERVICES } from '$lib/app-services' +import { session, status, testServices } from '$test/fixtures' +import QuoteCard from './QuoteCard.svelte' + +describe('QuoteCard', () => { + it('loads a Mastodon shallow quote by its status ID', async () => { + const fetchStatus = vi.fn().mockResolvedValue( + status({ id: 'shallow-target', content: '

Fetched shallow quote

' }), + ) + const services = testServices({ + session: session(), + endpoints: { fetchStatus }, + }) + const view = render(QuoteCard, { + props: { + quote: { + state: 'accepted', + status: null, + id: 'shallow-target', + url: null, + }, + }, + context: new Map([[APP_SERVICES, services]]), + }) + + expect(await view.findByText('Fetched shallow quote')).toBeInTheDocument() + expect(fetchStatus).toHaveBeenCalledWith(services.session.api, 'shallow-target') + }) +}) diff --git a/src/lib/api/endpoints.test.ts b/src/lib/api/endpoints.test.ts index 85e73b7..b985f54 100644 --- a/src/lib/api/endpoints.test.ts +++ b/src/lib/api/endpoints.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiClient } from './client' import { fetchNotifications, + fetchQuotes, postStatus, setEmojiReaction, updateProfileFields, @@ -181,6 +182,58 @@ describe('emoji reaction endpoints', () => { }) }) +describe('quote endpoints', () => { + it('sends both current and legacy quote parameters when composing', async () => { + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + expect(JSON.parse(String(init?.body))).toMatchObject({ + status: 'Commentary', + quoted_status_id: 'quoted/one', + quote_id: 'quoted/one', + }) + return new Response(JSON.stringify({ id: 'quote-1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + vi.stubGlobal('fetch', fetchMock) + + await postStatus(new ApiClient('example.test', 'token'), { + status: 'Commentary', + quoted_status_id: 'quoted/one', + }) + }) + + it('falls back to the legacy Pleroma quote-list endpoint', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([{ id: 'quote-1' }]), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const page = await fetchQuotes(new ApiClient('old-pleroma.example', 'token'), 'status/one', { + limit: 20, + }) + + expect(page.items).toEqual([{ id: 'quote-1' }]) + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://old-pleroma.example/api/v1/statuses/status%2Fone/quotes?limit=20', + ) + expect(fetchMock.mock.calls[1][0]).toBe( + 'https://old-pleroma.example/api/v1/pleroma/statuses/status%2Fone/quotes?limit=20', + ) + }) +}) + describe('fetchNotifications', () => { it('defensively applies requested types when a server ignores the filter', async () => { vi.stubGlobal( diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index 49b0c4a..ea0d94c 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -283,6 +283,7 @@ export interface ComposeOptions { expires_in: number multiple: boolean } + quoted_status_id?: string } export function postStatus(api: ApiClient, options: ComposeOptions): Promise { @@ -297,9 +298,29 @@ export function postStatus(api: ApiClient, options: ComposeOptions): Promise('/api/v1/statuses', body) } +export async function fetchQuotes( + api: ApiClient, + id: string, + cursor: Cursor = {}, +): Promise> { + const encoded = encodeURIComponent(id) + try { + return await api.page(`/api/v1/statuses/${encoded}/quotes`, { ...cursor }) + } catch (cause) { + if (!(cause instanceof ApiError) || (cause.status !== 404 && cause.status !== 405)) throw cause + return api.page(`/api/v1/pleroma/statuses/${encoded}/quotes`, { ...cursor }) + } +} + export function deleteStatus(api: ApiClient, id: string): Promise { return api.delete(`/api/v1/statuses/${encodeURIComponent(id)}`) } diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index a26c0a5..54d9a13 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -178,6 +178,32 @@ export interface EmojiReaction { accounts?: Account[] } +export type QuoteState = + | 'pending' + | 'accepted' + | 'rejected' + | 'revoked' + | 'deleted' + | 'unauthorized' + | 'blocked_account' + | 'blocked_domain' + | 'muted_account' + | string + +/** Mastodon 4.4+ quote envelope. Pleroma places the Status under `pleroma.quote`. */ +export interface StatusQuote { + state: QuoteState + quoted_status?: Status | null + /** Mastodon ShallowQuote form. */ + quoted_status_id?: string | null +} + +export interface QuoteApproval { + automatic: string[] + manual: string[] + current_user: 'automatic' | 'manual' | 'denied' | 'unknown' | string +} + export interface Status { id: string uri: string @@ -198,6 +224,7 @@ export interface Status { replies_count: number reblogs_count: number favourites_count: number + quotes_count?: number media_attachments: MediaAttachment[] mentions: StatusMention[] @@ -205,6 +232,12 @@ export interface Status { emojis: CustomEmoji[] card?: PreviewCard | null poll?: Poll | null + /** Mastodon quote envelope, or a raw quoted Status on a few compatible forks. */ + quote?: StatusQuote | Status | null + quote_approval?: QuoteApproval | null + /** Compatibility fields returned at top-level by some Pleroma-family forks. */ + quote_id?: string | null + quote_url?: string | null application?: { name: string; website?: string | null } | null reblog: Status | null @@ -222,6 +255,11 @@ export interface Status { spoiler_text?: Record /** Pleroma/Akkoma emoji reactions, including custom emoji URLs. */ emoji_reactions?: EmojiReaction[] + quote?: Status | null + quote_id?: string | null + quote_url?: string | null + quote_visible?: boolean + quotes_count?: number } } @@ -250,6 +288,8 @@ export type NotificationType = | 'favourite' | 'poll' | 'update' + | 'quote' + | 'quoted_update' | 'admin.sign_up' | 'admin.report' | 'pleroma:emoji_reaction' diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 06e5309..5011952 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -86,6 +86,8 @@ const MESSAGE: Record = { favourite: 'gave your entry kudos', poll: 'has a poll that just ended', update: 'edited an entry', + quote: 'quoted your entry', + quoted_update: 'edited an entry you quoted', 'pleroma:emoji_reaction': 'reacted to your entry', 'admin.sign_up': 'joined the server', 'admin.report': 'was included in a report', diff --git a/src/lib/router.svelte.ts b/src/lib/router.svelte.ts index cdfc608..6cdfe61 100644 --- a/src/lib/router.svelte.ts +++ b/src/lib/router.svelte.ts @@ -31,6 +31,7 @@ const ROUTES: RoutePattern[] = [ { name: 'mail.folder', pattern: '/mail/:folder' }, { name: 'timeline', pattern: '/timeline/:kind' }, { name: 'tag', pattern: '/tag/:tag' }, + { name: 'blog.quotes', pattern: '/blog/:id/quotes' }, { name: 'blog.entry', pattern: '/blog/:id' }, { name: 'compose', pattern: '/compose' }, // Account routes come last: `:acct` is greedy enough to shadow the others. diff --git a/src/lib/router.test.ts b/src/lib/router.test.ts index f597d82..6ad97ed 100644 --- a/src/lib/router.test.ts +++ b/src/lib/router.test.ts @@ -10,4 +10,11 @@ describe('parseHash', () => { expect(() => parseHash('#/@broken%ZZ')).not.toThrow() expect(parseHash('#/@broken%ZZ').name).toBe('notfound') }) + + it('matches the quote-list route before a blog entry', () => { + expect(parseHash('#/blog/status%2Fone/quotes')).toMatchObject({ + name: 'blog.quotes', + params: { id: 'status/one' }, + }) + }) }) diff --git a/src/lib/util/status.test.ts b/src/lib/util/status.test.ts new file mode 100644 index 0000000..0c624d8 --- /dev/null +++ b/src/lib/util/status.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { status } from '$test/fixtures' +import { quoteReferenceOf, quotesCountOf } from './status' + +describe('quote status normalization', () => { + it('reads Pleroma embedded quotes and counts', () => { + const quoted = status({ id: 'quoted-entry' }) + const outer = status({ + pleroma: { + quote: quoted, + quote_id: quoted.id, + quote_visible: true, + quotes_count: 4, + }, + }) + + expect(quoteReferenceOf(outer)).toMatchObject({ + state: 'accepted', + status: quoted, + id: 'quoted-entry', + }) + expect(quotesCountOf(outer)).toBe(4) + }) + + it('reads Mastodon quote envelopes but hides blocked quote content', () => { + const quoted = status({ id: 'quoted-entry' }) + + expect( + quoteReferenceOf( + status({ + quote: { state: 'accepted', quoted_status: quoted }, + quotes_count: 2, + }), + ), + ).toMatchObject({ state: 'accepted', status: quoted }) + + expect( + quoteReferenceOf( + status({ + quote: { state: 'blocked_account', quoted_status: quoted }, + }), + ), + ).toMatchObject({ state: 'blocked_account', status: null }) + }) + + it('retains the target ID from a Mastodon shallow quote', () => { + expect( + quoteReferenceOf( + status({ + quote: { + state: 'accepted', + quoted_status_id: 'shallow-target', + }, + }), + ), + ).toMatchObject({ + state: 'accepted', + status: null, + id: 'shallow-target', + }) + }) +}) diff --git a/src/lib/util/status.ts b/src/lib/util/status.ts new file mode 100644 index 0000000..5cd34ff --- /dev/null +++ b/src/lib/util/status.ts @@ -0,0 +1,57 @@ +import type { QuoteState, Status, StatusQuote } from '../api/types' + +export interface QuoteReference { + state: QuoteState + status: Status | null + id: string | null + url: string | null +} + +function isStatus(value: StatusQuote | Status): value is Status { + return 'id' in value && 'account' in value +} + +/** + * Normalize Mastodon's quote envelope and Pleroma/Akkoma's extension fields. + * Content is exposed only for accepted quotes; blocked and muted states retain + * a Status in Mastodon's API but clients are expected not to display it. + */ +export function quoteReferenceOf(status: Status): QuoteReference | null { + const raw = status.quote + if (raw) { + if (isStatus(raw)) { + return { state: 'accepted', status: raw, id: raw.id, url: raw.url ?? raw.uri } + } + const visible = raw.state === 'accepted' ? (raw.quoted_status ?? null) : null + return { + state: raw.state || 'unauthorized', + status: visible, + id: raw.quoted_status?.id ?? raw.quoted_status_id ?? null, + url: raw.quoted_status?.url ?? raw.quoted_status?.uri ?? null, + } + } + + const pleroma = status.pleroma + if (pleroma?.quote) { + return { + state: pleroma.quote_visible === false ? 'unauthorized' : 'accepted', + status: pleroma.quote_visible === false ? null : pleroma.quote, + id: pleroma.quote.id, + url: pleroma.quote.url ?? pleroma.quote.uri, + } + } + + const id = status.quote_id ?? pleroma?.quote_id ?? null + const url = status.quote_url ?? pleroma?.quote_url ?? null + if (!id && !url) return null + return { + state: pleroma?.quote_visible === false ? 'unauthorized' : 'pending', + status: null, + id, + url, + } +} + +export function quotesCountOf(status: Status): number { + return Math.max(0, status.quotes_count ?? 0, status.pleroma?.quotes_count ?? 0) +} diff --git a/src/routes/Compose.svelte b/src/routes/Compose.svelte index c8006de..4c423fe 100644 --- a/src/routes/Compose.svelte +++ b/src/routes/Compose.svelte @@ -1,5 +1,7 @@
-

{to ? 'Send a Message' : 'Post a Blog Entry'}

+

+ {quoteId ? 'Quote a Blog Entry' : to ? 'Send a Message' : 'Post a Blog Entry'} +

{#if to}

Addressed to @{to.replace(/^@/, '')}. Set the @@ -25,16 +64,26 @@ {/if}

- - {#key to} + + {#if quoteLoading} +

Loading quoted entry…

+ {:else if quoteError} + + {:else if !quoteId || quote} + {#key `${to ?? ''}:${quoteId ?? ''}`} router.go(`#/blog/${status.id}`)} /> {/key} + {/if}
{#if !session.signedIn} diff --git a/src/routes/Compose.test.ts b/src/routes/Compose.test.ts index 3dc7fd1..cbb56d7 100644 --- a/src/routes/Compose.test.ts +++ b/src/routes/Compose.test.ts @@ -1,7 +1,7 @@ -import { render } from '@testing-library/svelte' -import { describe, expect, it } from 'vitest' +import { render, waitFor } from '@testing-library/svelte' +import { describe, expect, it, vi } from 'vitest' import { APP_SERVICES } from '$lib/app-services' -import { account, session, testServices } from '$test/fixtures' +import { account, session, status, testServices } from '$test/fixtures' import Compose from './Compose.svelte' describe('Compose route', () => { @@ -22,4 +22,22 @@ describe('Compose route', () => { expect(view.getByRole('textbox', { name: 'Entry text' })).toHaveValue('@bob@example.test ') expect(view.getByRole('combobox', { name: 'Who can see this' })).toHaveValue('direct') }) + + it('loads and previews a quote target without a backend', async () => { + const fetchStatus = vi.fn().mockResolvedValue( + status({ id: 'quoted-entry', content: '

Loaded quote target

' }), + ) + const services = testServices({ + session: session({ token: 'token', me: account(), signedIn: true }), + endpoints: { fetchStatus }, + }) + const view = render(Compose, { + props: { quoteId: 'quoted-entry' }, + context: new Map([[APP_SERVICES, services]]), + }) + + await waitFor(() => expect(fetchStatus).toHaveBeenCalledOnce()) + expect(await view.findByText('Loaded quote target')).toBeInTheDocument() + expect(view.getByRole('button', { name: 'Post Quote' })).toBeEnabled() + }) }) diff --git a/src/routes/Mail.svelte b/src/routes/Mail.svelte index db42111..5cf0dd7 100644 --- a/src/routes/Mail.svelte +++ b/src/routes/Mail.svelte @@ -61,6 +61,8 @@ poll: 'closed a poll you voted in', status: 'posted a new entry', update: 'edited an entry you interacted with', + quote: 'quoted your entry', + quoted_update: 'edited an entry you quoted', 'pleroma:emoji_reaction': 'reacted to your entry', } diff --git a/src/routes/Quotes.svelte b/src/routes/Quotes.svelte new file mode 100644 index 0000000..d192b01 --- /dev/null +++ b/src/routes/Quotes.svelte @@ -0,0 +1,90 @@ + + +
+

Quotes of this Blog Entry

+ +
+ + {#if sourceLoading} +

Loading original entry…

+ {:else if sourceError} + + {:else if source} + (source = updated)} + /> + {/if} +
+ + + + +
+
diff --git a/src/routes/Quotes.test.ts b/src/routes/Quotes.test.ts new file mode 100644 index 0000000..aeabe79 --- /dev/null +++ b/src/routes/Quotes.test.ts @@ -0,0 +1,29 @@ +import { render } from '@testing-library/svelte' +import { describe, expect, it, vi } from 'vitest' +import { APP_SERVICES } from '$lib/app-services' +import { session, status, testServices } from '$test/fixtures' +import Quotes from './Quotes.svelte' + +describe('Quotes route', () => { + it('shows the source and paginated quoting entries', async () => { + const services = testServices({ + session: session(), + endpoints: { + fetchStatus: vi.fn().mockResolvedValue( + status({ id: 'source', content: '

Original entry

' }), + ), + fetchQuotes: vi.fn().mockResolvedValue({ + items: [status({ id: 'quote-1', content: '

A quoting entry

' })], + links: {}, + }), + }, + }) + const view = render(Quotes, { + props: { id: 'source' }, + context: new Map([[APP_SERVICES, services]]), + }) + + expect(await view.findByText('Original entry')).toBeInTheDocument() + expect(await view.findByText('A quoting entry')).toBeInTheDocument() + }) +}) diff --git a/src/styles/blog.css b/src/styles/blog.css index 13501a0..c755d65 100644 --- a/src/styles/blog.css +++ b/src/styles/blog.css @@ -432,6 +432,71 @@ margin-top: 5px; } +/* --------------------------------------------------------------- quote cards */ + +.quote-card { + margin-top: 8px; + border: 1px solid var(--ms-module-border); + background: var(--ms-page-bg); +} + +.quote-card-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + padding: 5px 7px; + border-bottom: 1px solid var(--ms-table-border); + background: var(--ms-table-stripe-bg); +} + +.quote-card-avatar { + width: 28px; + height: 28px; +} + +.quote-card-byline { + min-width: 0; + overflow-wrap: anywhere; +} + +.quote-card-author { + font-weight: 700; +} + +.quote-card-handle, +.quote-card-date { + margin-left: 5px; + color: var(--ms-muted-fg); + font-size: var(--ms-font-size-small); +} + +.quote-card-body { + padding: 7px; +} + +.quote-card .attachment-list, +.quote-card .preview-card, +.quote-card .poll { + margin-top: 6px; +} + +.quote-card-placeholder, +.quote-card-nested { + margin: 0; + padding: 7px; + color: var(--ms-muted-fg); +} + +.quote-card-placeholder a { + margin-left: 0.4em; +} + +/* Mastodon inserts this compatibility link when a structured quote is posted. */ +.quote-post-content .quote-inline { + display: none; +} + /* --------------------------------------------------------------- comments */ .comment-list { diff --git a/src/styles/forms.css b/src/styles/forms.css index b4d110d..2043498 100644 --- a/src/styles/forms.css +++ b/src/styles/forms.css @@ -131,6 +131,26 @@ textarea { margin-top: 6px; } +.composer-quote { + margin-bottom: 8px; +} + +.composer-quote-label { + margin: 0 0 4px; + font-weight: 700; +} + +.composer-quote-label .link-button { + margin-left: 0.4em; + font-weight: 400; +} + +.composer-quote-notice { + margin: 0 0 4px; + color: var(--ms-muted-fg); + font-size: var(--ms-font-size-small); +} + .composer-counter { margin-left: auto; color: var(--ms-muted-fg);