quote posts

This commit is contained in:
Moon.eth
2026-07-30 19:45:40 +09:00
parent 49c9c2ba57
commit 657d517503
22 changed files with 869 additions and 14 deletions
+7 -1
View File
@@ -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 @@
<Timeline kind="tag" tag={route.params.tag} />
{:else if route.name === 'blog.entry'}
<StatusPage id={route.params.id} />
{:else if route.name === 'blog.quotes'}
<Quotes id={route.params.id} />
{:else if route.name === 'mail'}
<Mail folder="inbox" />
{:else if route.name === 'mail.folder'}
@@ -98,7 +101,10 @@
{:else if route.name === 'search'}
<Search q={route.query.get('q') ?? ''} />
{:else if route.name === 'compose'}
<Compose to={route.query.get('to') ?? undefined} />
<Compose
to={route.query.get('to') ?? undefined}
quoteId={route.query.get('quote') ?? undefined}
/>
{:else if route.name === 'login'}
<Login />
{:else if route.name === 'settings'}
+39 -1
View File
@@ -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<string | null>(null)
@@ -189,6 +201,7 @@
<EmojiText text={entry.spoiler_text} emojis={entry.emojis} />
</summary>
<MfmContent
class={quote ? 'quote-post-content' : ''}
html={entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
@@ -202,6 +215,7 @@
</details>
{:else}
<MfmContent
class={quote ? 'quote-post-content' : ''}
html={entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
@@ -214,6 +228,10 @@
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
{/if}
{#if quote}
<QuoteCard {quote} />
{/if}
{#if entry.poll}
<PollView
poll={entry.poll}
@@ -223,7 +241,7 @@
/>
{/if}
{#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
{#if entry.card && !quote && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
<PreviewCardView card={entry.card} />
{/if}
@@ -262,6 +280,26 @@
<span class="blog-action-count">({formatCount(entry.reblogs_count)})</span>
</button>
{#if canQuote}
<a
class="blog-action blog-action--quote"
href={session.signedIn ? routeTo('/compose', { quote: entry.id }) : '#/login'}
title={session.signedIn ? 'Quote this entry' : 'Sign in to quote this entry'}
>
Quote
</a>
{/if}
{#if quotesCount > 0}
<a
class="blog-action blog-action--quotes"
href={session.signedIn ? `#/blog/${entry.id}/quotes` : '#/login'}
title={session.signedIn ? 'View entries quoting this' : 'Sign in to view quotes'}
>
Quotes <span class="blog-action-count">({formatCount(quotesCount)})</span>
</a>
{/if}
{#if entry.url}
<a class="blog-action blog-action--source" href={entry.url} target="_blank" rel="noopener noreferrer">
Original
+55
View File
@@ -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: '<p>Quoted words</p>',
account: account({ id: 'quoted-author', display_name: 'Quoted Author' }),
})
const outer = status({
id: 'outer-entry',
content: '<p>My commentary</p>',
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: '<p>Do not display me</p>' })
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: [] } })
+37 -1
View File
@@ -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<Status | null>(untrack(() => quote))
let warning = $state('')
let showWarning = $state(false)
let visibility = $state<StatusVisibility>(
@@ -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}
<form class="composer" onsubmit={submit} use:composerInteractions>
{#if quotedStatus}
<div class="composer-quote">
<p class="composer-quote-label">
Quoting this entry
<button
type="button"
class="link-button"
aria-label="Remove quoted entry"
onclick={() => (quotedStatus = null)}
>
remove
</button>
</p>
{#if quotedStatus.quote_approval?.current_user === 'manual'}
<p class="composer-quote-notice">
The original author will need to approve this quote before it appears.
</p>
{/if}
<QuoteCard
quote={{
state: 'accepted',
status: quotedStatus,
id: quotedStatus.id,
url: quotedStatus.url ?? quotedStatus.uri,
}}
/>
</div>
{/if}
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
+25
View File
@@ -25,6 +25,31 @@ describe('Composer', () => {
})
})
it('can submit a structured quote without commentary', async () => {
const quoted = status({
id: 'quoted-entry',
content: '<p>The original thought</p>',
})
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({
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts">
/** A non-recursive embedded quote, with Mastodon's authorization states. */
import { untrack } from 'svelte'
import type { Status } from '$lib/api/types'
import type { QuoteReference } from '$lib/util/status'
import { useAppServices } from '$lib/app-services'
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
import { stampDate } from '$lib/util/time'
import Avatar from '../common/Avatar.svelte'
import EmojiText from '../common/EmojiText.svelte'
import MfmContent from '../common/MfmContent.svelte'
import Attachments from './Attachments.svelte'
import PollView from './PollView.svelte'
import PreviewCardView from './PreviewCardView.svelte'
interface Props {
quote: QuoteReference
}
let { quote }: Props = $props()
const { endpoints, session } = useAppServices()
let loadedStatus = $state<Status | null>(untrack(() => quote.status))
let loading = $state(false)
let loadError = $state(false)
let loadGeneration = 0
const status = $derived((loadedStatus ?? quote.status)?.reblog ?? loadedStatus ?? quote.status)
$effect(() => {
const supplied = quote.status
const state = quote.state
const id = quote.id
const generation = ++loadGeneration
loadedStatus = supplied
loading = false
loadError = false
if (supplied || state !== 'accepted' || !id) return
loading = true
untrack(() => void loadShallowQuote(id, generation))
return () => {
if (generation === loadGeneration) loadGeneration += 1
}
})
async function loadShallowQuote(id: string, generation: number): Promise<void> {
try {
const found = await endpoints.fetchStatus(session.api, id)
if (generation === loadGeneration && quote.id === id) loadedStatus = found
} catch {
if (generation === loadGeneration) loadError = true
} finally {
if (generation === loadGeneration) loading = false
}
}
const STATE_MESSAGE: Record<string, string> = {
pending: 'This quote is awaiting approval.',
rejected: 'The author did not approve this quote.',
revoked: 'The author revoked this quote.',
deleted: 'The quoted entry was deleted.',
unauthorized: 'The quoted entry is not available to you.',
blocked_account: 'The quoted account is blocked.',
blocked_domain: 'The quoted accounts server is blocked.',
muted_account: 'The quoted account is muted.',
}
</script>
<aside class="quote-card" data-quote-state={quote.state}>
{#if loading}
<p class="quote-card-placeholder">Loading quoted entry&hellip;</p>
{:else if status}
<header class="quote-card-header">
<Avatar account={status.account} plain class="quote-card-avatar" />
<span class="quote-card-byline">
<a class="quote-card-author" href={profilePath(status.account)}>
<EmojiText text={displayNameOf(status.account)} emojis={status.account.emojis} />
</a>
<span class="quote-card-handle">{fullHandle(status.account, session.host)}</span>
</span>
<a class="quote-card-date" href={`#/blog/${status.id}`}>{stampDate(status.created_at)}</a>
</header>
<div class="quote-card-body">
{#if status.spoiler_text}
<details class="content-warning">
<summary class="content-warning-summary">
<EmojiText text={status.spoiler_text} emojis={status.emojis} />
</summary>
<MfmContent
html={status.content}
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
lang={status.language}
/>
{#if status.media_attachments.length > 0}
<Attachments
attachments={status.media_attachments}
emojis={status.emojis}
sensitive={status.sensitive}
/>
{/if}
</details>
{:else}
<MfmContent
html={status.content}
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
lang={status.language}
/>
{#if status.media_attachments.length > 0}
<Attachments
attachments={status.media_attachments}
emojis={status.emojis}
sensitive={status.sensitive}
/>
{/if}
{/if}
{#if status.poll}
<PollView
poll={status.poll}
emojis={status.poll.emojis?.length ? status.poll.emojis : status.emojis}
authorId={status.account.id}
/>
{/if}
{#if status.card && status.media_attachments.length === 0}
<PreviewCardView card={status.card} />
{/if}
{#if status.quote || status.pleroma?.quote}
<p class="quote-card-nested">
<a href={`#/blog/${status.id}`}>View the nested quote</a>
</p>
{/if}
</div>
{:else}
<p class="quote-card-placeholder">
{loadError
? 'The quoted entry could not be loaded.'
: STATE_MESSAGE[quote.state] ?? 'The quoted entry is unavailable.'}
{#if quote.url}
<a href={quote.url} target="_blank" rel="noopener noreferrer">View original reference</a>
{/if}
</p>
{/if}
</aside>
+31
View File
@@ -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: '<p>Fetched shallow quote</p>' }),
)
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')
})
})
+53
View File
@@ -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(
+21
View File
@@ -283,6 +283,7 @@ export interface ComposeOptions {
expires_in: number
multiple: boolean
}
quoted_status_id?: string
}
export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Status> {
@@ -297,9 +298,29 @@ export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Sta
if (options.media_ids?.length) body.media_ids = options.media_ids
if (options.language) body.language = options.language
if (options.poll) body.poll = options.poll
if (options.quoted_status_id) {
body.quoted_status_id = options.quoted_status_id
// Older Pleroma/Akkoma releases predate the standardized Mastodon name.
// Mastodon ignores unknown JSON keys, while those servers require this.
body.quote_id = options.quoted_status_id
}
return api.post<Status>('/api/v1/statuses', body)
}
export async function fetchQuotes(
api: ApiClient,
id: string,
cursor: Cursor = {},
): Promise<Page<Status>> {
const encoded = encodeURIComponent(id)
try {
return await api.page<Status>(`/api/v1/statuses/${encoded}/quotes`, { ...cursor })
} catch (cause) {
if (!(cause instanceof ApiError) || (cause.status !== 404 && cause.status !== 405)) throw cause
return api.page<Status>(`/api/v1/pleroma/statuses/${encoded}/quotes`, { ...cursor })
}
}
export function deleteStatus(api: ApiClient, id: string): Promise<Status> {
return api.delete<Status>(`/api/v1/statuses/${encodeURIComponent(id)}`)
}
+40
View File
@@ -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<string, string>
/** 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'
+2
View File
@@ -86,6 +86,8 @@ const MESSAGE: Record<string, string> = {
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',
+1
View File
@@ -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.
+7
View File
@@ -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' },
})
})
})
+62
View File
@@ -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',
})
})
})
+57
View File
@@ -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)
}
+57 -8
View File
@@ -1,5 +1,7 @@
<script lang="ts">
/** A full-page composer, reachable from the nav and from "Send Message". */
import { untrack } from 'svelte'
import type { Status } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import Module from '$components/common/Module.svelte'
import Composer from '$components/blog/Composer.svelte'
@@ -7,16 +9,53 @@
interface Props {
/** Handle to address the entry to, from `?to=`. */
to?: string
/** Local API ID of the entry being quoted, from `?quote=`. */
quoteId?: string
}
let { to }: Props = $props()
let { to, quoteId }: Props = $props()
const { router, session } = useAppServices()
const { endpoints, router, session } = useAppServices()
const prefill = $derived(to ? `@${to.replace(/^@/, '')} ` : '')
let quote = $state<Status | null>(null)
let quoteLoading = $state(false)
let quoteError = $state<string | null>(null)
let loadGeneration = 0
$effect(() => {
const id = quoteId
const generation = ++loadGeneration
quote = null
quoteError = null
if (!id) {
quoteLoading = false
return
}
quoteLoading = true
untrack(() => void loadQuote(id, generation))
return () => {
if (generation === loadGeneration) loadGeneration += 1
}
})
async function loadQuote(id: string, generation: number): Promise<void> {
try {
const found = await endpoints.fetchStatus(session.api, id)
if (generation === loadGeneration && quoteId === id) quote = found.reblog ?? found
} catch (cause) {
if (generation === loadGeneration) {
quoteError = cause instanceof Error ? cause.message : 'Could not load the quoted entry.'
}
} finally {
if (generation === loadGeneration) quoteLoading = false
}
}
</script>
<div class="page compose-page">
<h1 class="page-title">{to ? 'Send a Message' : 'Post a Blog Entry'}</h1>
<h1 class="page-title">
{quoteId ? 'Quote a Blog Entry' : to ? 'Send a Message' : 'Post a Blog Entry'}
</h1>
{#if to}
<p class="page-subtitle">
Addressed to <a href={`#/@${to.replace(/^@/, '')}`}>@{to.replace(/^@/, '')}</a>. Set the
@@ -25,16 +64,26 @@
{/if}
<div class="layout--single">
<Module title={to ? 'New message' : 'New entry'}>
{#key to}
<Module title={quoteId ? 'New quote' : to ? 'New message' : 'New entry'}>
{#if quoteLoading}
<p class="loading-note">Loading quoted entry&hellip;</p>
{:else if quoteError}
<p class="error-note" role="alert">
<strong class="error-note-title">The entry cant be quoted.</strong>
{quoteError}
</p>
{:else if !quoteId || quote}
{#key `${to ?? ''}:${quoteId ?? ''}`}
<Composer
{quote}
initialText={prefill}
initialVisibility={to ? 'direct' : 'public'}
placeholder={to ? 'Say something…' : 'What are you up to?'}
submitLabel={to ? 'Send' : 'Post Entry'}
initialVisibility={quote?.visibility === 'private' ? 'private' : to ? 'direct' : 'public'}
placeholder={quote ? 'Add a comment, or post the quote by itself…' : to ? 'Say something…' : 'What are you up to?'}
submitLabel={quote ? 'Post Quote' : to ? 'Send' : 'Post Entry'}
onposted={(status) => router.go(`#/blog/${status.id}`)}
/>
{/key}
{/if}
</Module>
{#if !session.signedIn}
+21 -3
View File
@@ -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: '<p>Loaded quote target</p>' }),
)
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()
})
})
+2
View File
@@ -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',
}
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
/** Paginated posts that quote one source entry. */
import { untrack } from 'svelte'
import type { Status } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import { Feed } from '$lib/stores/feed.svelte'
import { useTimelineRefresh } from '$lib/timeline-refresh'
import Module from '$components/common/Module.svelte'
import BlogEntry from '$components/blog/BlogEntry.svelte'
import BlogList from '$components/blog/BlogList.svelte'
interface Props {
id: string
}
let { id }: Props = $props()
const { endpoints, session } = useAppServices()
const timelineRefresh = useTimelineRefresh()
let source = $state<Status | null>(null)
let sourceError = $state<string | null>(null)
let sourceLoading = $state(true)
let loadGeneration = 0
let feed = $state<Feed<Status>>(
new Feed<Status>(async () => ({ items: [], links: {} })),
)
$effect(() => {
const currentId = id
const generation = ++loadGeneration
source = null
sourceError = null
sourceLoading = true
feed = new Feed<Status>((cursor) => endpoints.fetchQuotes(session.api, currentId, cursor))
untrack(() => {
void feed.reload()
void loadSource(currentId, generation)
})
return () => {
if (generation === loadGeneration) loadGeneration += 1
}
})
$effect(() => timelineRefresh?.register(() => void feed.refresh()))
async function loadSource(currentId: string, generation: number): Promise<void> {
try {
const found = await endpoints.fetchStatus(session.api, currentId)
if (generation === loadGeneration && id === currentId) {
source = found
document.title = 'Quotes | plspace'
}
} catch (cause) {
if (generation === loadGeneration) {
sourceError = cause instanceof Error ? cause.message : 'Could not load the original entry.'
}
} finally {
if (generation === loadGeneration) sourceLoading = false
}
}
</script>
<div class="page quotes-page">
<h1 class="page-title">Quotes of this Blog Entry</h1>
<div class="layout--single">
<Module title="Original Entry" variant="band">
{#if sourceLoading}
<p class="loading-note">Loading original entry&hellip;</p>
{:else if sourceError}
<p class="error-note" role="alert">{sourceError}</p>
{:else if source}
<BlogEntry
status={source}
compact
onupdate={(updated) => (source = updated)}
/>
{/if}
</Module>
<Module title="Entries quoting this" variant="band">
<BlogList
{feed}
emptyText="Nobody has quoted this entry yet."
label="View More Quotes"
longFormDate
/>
</Module>
</div>
</div>
+29
View File
@@ -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: '<p>Original entry</p>' }),
),
fetchQuotes: vi.fn().mockResolvedValue({
items: [status({ id: 'quote-1', content: '<p>A quoting entry</p>' })],
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()
})
})
+65
View File
@@ -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 {
+20
View File
@@ -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);