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
+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')
})
})