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 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'}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
/** 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…</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
|
||||
{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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- 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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user