mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
quote posts
This commit is contained in:
+7
-1
@@ -12,6 +12,7 @@
|
|||||||
import Profile from '$routes/Profile.svelte'
|
import Profile from '$routes/Profile.svelte'
|
||||||
import Timeline from '$routes/Timeline.svelte'
|
import Timeline from '$routes/Timeline.svelte'
|
||||||
import StatusPage from '$routes/StatusPage.svelte'
|
import StatusPage from '$routes/StatusPage.svelte'
|
||||||
|
import Quotes from '$routes/Quotes.svelte'
|
||||||
import Mail from '$routes/Mail.svelte'
|
import Mail from '$routes/Mail.svelte'
|
||||||
import Browse from '$routes/Browse.svelte'
|
import Browse from '$routes/Browse.svelte'
|
||||||
import Search from '$routes/Search.svelte'
|
import Search from '$routes/Search.svelte'
|
||||||
@@ -89,6 +90,8 @@
|
|||||||
<Timeline kind="tag" tag={route.params.tag} />
|
<Timeline kind="tag" tag={route.params.tag} />
|
||||||
{:else if route.name === 'blog.entry'}
|
{:else if route.name === 'blog.entry'}
|
||||||
<StatusPage id={route.params.id} />
|
<StatusPage id={route.params.id} />
|
||||||
|
{:else if route.name === 'blog.quotes'}
|
||||||
|
<Quotes id={route.params.id} />
|
||||||
{:else if route.name === 'mail'}
|
{:else if route.name === 'mail'}
|
||||||
<Mail folder="inbox" />
|
<Mail folder="inbox" />
|
||||||
{:else if route.name === 'mail.folder'}
|
{:else if route.name === 'mail.folder'}
|
||||||
@@ -98,7 +101,10 @@
|
|||||||
{:else if route.name === 'search'}
|
{:else if route.name === 'search'}
|
||||||
<Search q={route.query.get('q') ?? ''} />
|
<Search q={route.query.get('q') ?? ''} />
|
||||||
{:else if route.name === 'compose'}
|
{: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'}
|
{:else if route.name === 'login'}
|
||||||
<Login />
|
<Login />
|
||||||
{:else if route.name === 'settings'}
|
{:else if route.name === 'settings'}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
import { useAppServices } from '$lib/app-services'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||||
import { renderDisplayName } from '$lib/util/html'
|
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 { isoDate, longDate, stampDate } from '$lib/util/time'
|
||||||
import { extractYouTubeVideoIds } from '$lib/util/youtube'
|
import { extractYouTubeVideoIds } from '$lib/util/youtube'
|
||||||
import Avatar from '../common/Avatar.svelte'
|
import Avatar from '../common/Avatar.svelte'
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
import EmojiReactions from './EmojiReactions.svelte'
|
import EmojiReactions from './EmojiReactions.svelte'
|
||||||
import PollView from './PollView.svelte'
|
import PollView from './PollView.svelte'
|
||||||
import PreviewCardView from './PreviewCardView.svelte'
|
import PreviewCardView from './PreviewCardView.svelte'
|
||||||
|
import QuoteCard from './QuoteCard.svelte'
|
||||||
import YouTubeEmbeds from './YouTubeEmbeds.svelte'
|
import YouTubeEmbeds from './YouTubeEmbeds.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -50,6 +53,15 @@
|
|||||||
const permalink = $derived(`#/blog/${entry.id}`)
|
const permalink = $derived(`#/blog/${entry.id}`)
|
||||||
const isMine = $derived(session.me?.id === entry.account.id)
|
const isMine = $derived(session.me?.id === entry.account.id)
|
||||||
const youtubeVideoIds = $derived(extractYouTubeVideoIds(entry.content))
|
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 busy = $state(false)
|
||||||
let actionError = $state<string | null>(null)
|
let actionError = $state<string | null>(null)
|
||||||
@@ -189,6 +201,7 @@
|
|||||||
<EmojiText text={entry.spoiler_text} emojis={entry.emojis} />
|
<EmojiText text={entry.spoiler_text} emojis={entry.emojis} />
|
||||||
</summary>
|
</summary>
|
||||||
<MfmContent
|
<MfmContent
|
||||||
|
class={quote ? 'quote-post-content' : ''}
|
||||||
html={entry.content}
|
html={entry.content}
|
||||||
emojis={entry.emojis}
|
emojis={entry.emojis}
|
||||||
mentions={entry.mentions}
|
mentions={entry.mentions}
|
||||||
@@ -202,6 +215,7 @@
|
|||||||
</details>
|
</details>
|
||||||
{:else}
|
{:else}
|
||||||
<MfmContent
|
<MfmContent
|
||||||
|
class={quote ? 'quote-post-content' : ''}
|
||||||
html={entry.content}
|
html={entry.content}
|
||||||
emojis={entry.emojis}
|
emojis={entry.emojis}
|
||||||
mentions={entry.mentions}
|
mentions={entry.mentions}
|
||||||
@@ -214,6 +228,10 @@
|
|||||||
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if quote}
|
||||||
|
<QuoteCard {quote} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if entry.poll}
|
{#if entry.poll}
|
||||||
<PollView
|
<PollView
|
||||||
poll={entry.poll}
|
poll={entry.poll}
|
||||||
@@ -223,7 +241,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/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} />
|
<PreviewCardView card={entry.card} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -262,6 +280,26 @@
|
|||||||
<span class="blog-action-count">({formatCount(entry.reblogs_count)})</span>
|
<span class="blog-action-count">({formatCount(entry.reblogs_count)})</span>
|
||||||
</button>
|
</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}
|
{#if entry.url}
|
||||||
<a class="blog-action blog-action--source" href={entry.url} target="_blank" rel="noopener noreferrer">
|
<a class="blog-action blog-action--source" href={entry.url} target="_blank" rel="noopener noreferrer">
|
||||||
Original
|
Original
|
||||||
|
|||||||
@@ -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', () => {
|
describe('BlogEntry emoji reactions', () => {
|
||||||
it('collapses the reaction bar when the entry has no reactions', () => {
|
it('collapses the reaction bar when the entry has no reactions', () => {
|
||||||
const view = renderEntry({ pleroma: { emoji_reactions: [] } })
|
const view = renderEntry({ pleroma: { emoji_reactions: [] } })
|
||||||
|
|||||||
@@ -9,10 +9,13 @@
|
|||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
|
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
|
||||||
import { useAppServices } from '$lib/app-services'
|
import { useAppServices } from '$lib/app-services'
|
||||||
|
import QuoteCard from './QuoteCard.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Set to reply to an existing entry. */
|
/** Set to reply to an existing entry. */
|
||||||
inReplyTo?: Status | null
|
inReplyTo?: Status | null
|
||||||
|
/** Status attached as a structured quote. */
|
||||||
|
quote?: Status | null
|
||||||
/** Prefilled body, e.g. the mentions of the entry being replied to. */
|
/** Prefilled body, e.g. the mentions of the entry being replied to. */
|
||||||
initialText?: string
|
initialText?: string
|
||||||
initialVisibility?: StatusVisibility
|
initialVisibility?: StatusVisibility
|
||||||
@@ -23,6 +26,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
inReplyTo = null,
|
inReplyTo = null,
|
||||||
|
quote = null,
|
||||||
initialText = '',
|
initialText = '',
|
||||||
initialVisibility = 'public',
|
initialVisibility = 'public',
|
||||||
placeholder = 'What are you up to?',
|
placeholder = 'What are you up to?',
|
||||||
@@ -33,6 +37,7 @@
|
|||||||
const { endpoints, session } = useAppServices()
|
const { endpoints, session } = useAppServices()
|
||||||
// Seeded once from the prop; afterwards the textarea owns the value.
|
// Seeded once from the prop; afterwards the textarea owns the value.
|
||||||
let text = $state(untrack(() => initialText))
|
let text = $state(untrack(() => initialText))
|
||||||
|
let quotedStatus = $state<Status | null>(untrack(() => quote))
|
||||||
let warning = $state('')
|
let warning = $state('')
|
||||||
let showWarning = $state(false)
|
let showWarning = $state(false)
|
||||||
let visibility = $state<StatusVisibility>(
|
let visibility = $state<StatusVisibility>(
|
||||||
@@ -73,7 +78,7 @@
|
|||||||
remaining >= 0 &&
|
remaining >= 0 &&
|
||||||
pollValid &&
|
pollValid &&
|
||||||
!(showPoll && attachments.length > 0) &&
|
!(showPoll && attachments.length > 0) &&
|
||||||
(text.trim().length > 0 || attachments.length > 0),
|
(text.trim().length > 0 || attachments.length > 0 || quotedStatus !== null),
|
||||||
)
|
)
|
||||||
|
|
||||||
const POLL_DURATION_PRESETS = [
|
const POLL_DURATION_PRESETS = [
|
||||||
@@ -201,6 +206,7 @@
|
|||||||
multiple: pollMultiple,
|
multiple: pollMultiple,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
quoted_status_id: quotedStatus?.id,
|
||||||
})
|
})
|
||||||
text = ''
|
text = ''
|
||||||
warning = ''
|
warning = ''
|
||||||
@@ -209,6 +215,7 @@
|
|||||||
showPoll = false
|
showPoll = false
|
||||||
pollOptions = ['', '']
|
pollOptions = ['', '']
|
||||||
pollMultiple = false
|
pollMultiple = false
|
||||||
|
quotedStatus = null
|
||||||
onposted?.(created)
|
onposted?.(created)
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error = cause instanceof Error ? cause.message : 'Could not post that.'
|
error = cause instanceof Error ? cause.message : 'Could not post that.'
|
||||||
@@ -250,6 +257,35 @@
|
|||||||
|
|
||||||
{#if session.signedIn}
|
{#if session.signedIn}
|
||||||
<form class="composer" onsubmit={submit} use:composerInteractions>
|
<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}
|
{#if error}
|
||||||
<p class="error-note" role="alert">{error}</p>
|
<p class="error-note" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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 () => {
|
it('composes a multiple-choice poll without a backend', async () => {
|
||||||
const postStatus = vi.fn().mockResolvedValue(status())
|
const postStatus = vi.fn().mockResolvedValue(status())
|
||||||
const services = testServices({
|
const services = testServices({
|
||||||
|
|||||||
@@ -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 account’s 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…</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>
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { ApiClient } from './client'
|
import { ApiClient } from './client'
|
||||||
import {
|
import {
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
|
fetchQuotes,
|
||||||
postStatus,
|
postStatus,
|
||||||
setEmojiReaction,
|
setEmojiReaction,
|
||||||
updateProfileFields,
|
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', () => {
|
describe('fetchNotifications', () => {
|
||||||
it('defensively applies requested types when a server ignores the filter', async () => {
|
it('defensively applies requested types when a server ignores the filter', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ export interface ComposeOptions {
|
|||||||
expires_in: number
|
expires_in: number
|
||||||
multiple: boolean
|
multiple: boolean
|
||||||
}
|
}
|
||||||
|
quoted_status_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Status> {
|
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.media_ids?.length) body.media_ids = options.media_ids
|
||||||
if (options.language) body.language = options.language
|
if (options.language) body.language = options.language
|
||||||
if (options.poll) body.poll = options.poll
|
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)
|
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> {
|
export function deleteStatus(api: ApiClient, id: string): Promise<Status> {
|
||||||
return api.delete<Status>(`/api/v1/statuses/${encodeURIComponent(id)}`)
|
return api.delete<Status>(`/api/v1/statuses/${encodeURIComponent(id)}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,32 @@ export interface EmojiReaction {
|
|||||||
accounts?: Account[]
|
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 {
|
export interface Status {
|
||||||
id: string
|
id: string
|
||||||
uri: string
|
uri: string
|
||||||
@@ -198,6 +224,7 @@ export interface Status {
|
|||||||
replies_count: number
|
replies_count: number
|
||||||
reblogs_count: number
|
reblogs_count: number
|
||||||
favourites_count: number
|
favourites_count: number
|
||||||
|
quotes_count?: number
|
||||||
|
|
||||||
media_attachments: MediaAttachment[]
|
media_attachments: MediaAttachment[]
|
||||||
mentions: StatusMention[]
|
mentions: StatusMention[]
|
||||||
@@ -205,6 +232,12 @@ export interface Status {
|
|||||||
emojis: CustomEmoji[]
|
emojis: CustomEmoji[]
|
||||||
card?: PreviewCard | null
|
card?: PreviewCard | null
|
||||||
poll?: Poll | 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
|
application?: { name: string; website?: string | null } | null
|
||||||
|
|
||||||
reblog: Status | null
|
reblog: Status | null
|
||||||
@@ -222,6 +255,11 @@ export interface Status {
|
|||||||
spoiler_text?: Record<string, string>
|
spoiler_text?: Record<string, string>
|
||||||
/** Pleroma/Akkoma emoji reactions, including custom emoji URLs. */
|
/** Pleroma/Akkoma emoji reactions, including custom emoji URLs. */
|
||||||
emoji_reactions?: EmojiReaction[]
|
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'
|
| 'favourite'
|
||||||
| 'poll'
|
| 'poll'
|
||||||
| 'update'
|
| 'update'
|
||||||
|
| 'quote'
|
||||||
|
| 'quoted_update'
|
||||||
| 'admin.sign_up'
|
| 'admin.sign_up'
|
||||||
| 'admin.report'
|
| 'admin.report'
|
||||||
| 'pleroma:emoji_reaction'
|
| 'pleroma:emoji_reaction'
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ const MESSAGE: Record<string, string> = {
|
|||||||
favourite: 'gave your entry kudos',
|
favourite: 'gave your entry kudos',
|
||||||
poll: 'has a poll that just ended',
|
poll: 'has a poll that just ended',
|
||||||
update: 'edited an entry',
|
update: 'edited an entry',
|
||||||
|
quote: 'quoted your entry',
|
||||||
|
quoted_update: 'edited an entry you quoted',
|
||||||
'pleroma:emoji_reaction': 'reacted to your entry',
|
'pleroma:emoji_reaction': 'reacted to your entry',
|
||||||
'admin.sign_up': 'joined the server',
|
'admin.sign_up': 'joined the server',
|
||||||
'admin.report': 'was included in a report',
|
'admin.report': 'was included in a report',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const ROUTES: RoutePattern[] = [
|
|||||||
{ name: 'mail.folder', pattern: '/mail/:folder' },
|
{ name: 'mail.folder', pattern: '/mail/:folder' },
|
||||||
{ name: 'timeline', pattern: '/timeline/:kind' },
|
{ name: 'timeline', pattern: '/timeline/:kind' },
|
||||||
{ name: 'tag', pattern: '/tag/:tag' },
|
{ name: 'tag', pattern: '/tag/:tag' },
|
||||||
|
{ name: 'blog.quotes', pattern: '/blog/:id/quotes' },
|
||||||
{ name: 'blog.entry', pattern: '/blog/:id' },
|
{ name: 'blog.entry', pattern: '/blog/:id' },
|
||||||
{ name: 'compose', pattern: '/compose' },
|
{ name: 'compose', pattern: '/compose' },
|
||||||
// Account routes come last: `:acct` is greedy enough to shadow the others.
|
// Account routes come last: `:acct` is greedy enough to shadow the others.
|
||||||
|
|||||||
@@ -10,4 +10,11 @@ describe('parseHash', () => {
|
|||||||
expect(() => parseHash('#/@broken%ZZ')).not.toThrow()
|
expect(() => parseHash('#/@broken%ZZ')).not.toThrow()
|
||||||
expect(parseHash('#/@broken%ZZ').name).toBe('notfound')
|
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' },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/** A full-page composer, reachable from the nav and from "Send Message". */
|
/** 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 { useAppServices } from '$lib/app-services'
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import Composer from '$components/blog/Composer.svelte'
|
import Composer from '$components/blog/Composer.svelte'
|
||||||
@@ -7,16 +9,53 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
/** Handle to address the entry to, from `?to=`. */
|
/** Handle to address the entry to, from `?to=`. */
|
||||||
to?: string
|
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(/^@/, '')} ` : '')
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="page compose-page">
|
<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}
|
{#if to}
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
Addressed to <a href={`#/@${to.replace(/^@/, '')}`}>@{to.replace(/^@/, '')}</a>. Set the
|
Addressed to <a href={`#/@${to.replace(/^@/, '')}`}>@{to.replace(/^@/, '')}</a>. Set the
|
||||||
@@ -25,16 +64,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="layout--single">
|
<div class="layout--single">
|
||||||
<Module title={to ? 'New message' : 'New entry'}>
|
<Module title={quoteId ? 'New quote' : to ? 'New message' : 'New entry'}>
|
||||||
{#key to}
|
{#if quoteLoading}
|
||||||
|
<p class="loading-note">Loading quoted entry…</p>
|
||||||
|
{:else if quoteError}
|
||||||
|
<p class="error-note" role="alert">
|
||||||
|
<strong class="error-note-title">The entry can’t be quoted.</strong>
|
||||||
|
{quoteError}
|
||||||
|
</p>
|
||||||
|
{:else if !quoteId || quote}
|
||||||
|
{#key `${to ?? ''}:${quoteId ?? ''}`}
|
||||||
<Composer
|
<Composer
|
||||||
|
{quote}
|
||||||
initialText={prefill}
|
initialText={prefill}
|
||||||
initialVisibility={to ? 'direct' : 'public'}
|
initialVisibility={quote?.visibility === 'private' ? 'private' : to ? 'direct' : 'public'}
|
||||||
placeholder={to ? 'Say something…' : 'What are you up to?'}
|
placeholder={quote ? 'Add a comment, or post the quote by itself…' : to ? 'Say something…' : 'What are you up to?'}
|
||||||
submitLabel={to ? 'Send' : 'Post Entry'}
|
submitLabel={quote ? 'Post Quote' : to ? 'Send' : 'Post Entry'}
|
||||||
onposted={(status) => router.go(`#/blog/${status.id}`)}
|
onposted={(status) => router.go(`#/blog/${status.id}`)}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
|
{/if}
|
||||||
</Module>
|
</Module>
|
||||||
|
|
||||||
{#if !session.signedIn}
|
{#if !session.signedIn}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render } from '@testing-library/svelte'
|
import { render, waitFor } from '@testing-library/svelte'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { APP_SERVICES } from '$lib/app-services'
|
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'
|
import Compose from './Compose.svelte'
|
||||||
|
|
||||||
describe('Compose route', () => {
|
describe('Compose route', () => {
|
||||||
@@ -22,4 +22,22 @@ describe('Compose route', () => {
|
|||||||
expect(view.getByRole('textbox', { name: 'Entry text' })).toHaveValue('@bob@example.test ')
|
expect(view.getByRole('textbox', { name: 'Entry text' })).toHaveValue('@bob@example.test ')
|
||||||
expect(view.getByRole('combobox', { name: 'Who can see this' })).toHaveValue('direct')
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
poll: 'closed a poll you voted in',
|
poll: 'closed a poll you voted in',
|
||||||
status: 'posted a new entry',
|
status: 'posted a new entry',
|
||||||
update: 'edited an entry you interacted with',
|
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',
|
'pleroma:emoji_reaction': 'reacted to your entry',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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…</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>
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -432,6 +432,71 @@
|
|||||||
margin-top: 5px;
|
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 */
|
/* --------------------------------------------------------------- comments */
|
||||||
|
|
||||||
.comment-list {
|
.comment-list {
|
||||||
|
|||||||
@@ -131,6 +131,26 @@ textarea {
|
|||||||
margin-top: 6px;
|
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 {
|
.composer-counter {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--ms-muted-fg);
|
color: var(--ms-muted-fg);
|
||||||
|
|||||||
Reference in New Issue
Block a user