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:
@@ -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
|
||||
|
||||
@@ -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: [] } })
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user