quote posts

This commit is contained in:
Moon.eth
2026-07-30 19:45:40 +09:00
parent 49c9c2ba57
commit 657d517503
22 changed files with 869 additions and 14 deletions
+53
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiClient } from './client'
import {
fetchNotifications,
fetchQuotes,
postStatus,
setEmojiReaction,
updateProfileFields,
@@ -181,6 +182,58 @@ describe('emoji reaction endpoints', () => {
})
})
describe('quote endpoints', () => {
it('sends both current and legacy quote parameters when composing', async () => {
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
expect(JSON.parse(String(init?.body))).toMatchObject({
status: 'Commentary',
quoted_status_id: 'quoted/one',
quote_id: 'quoted/one',
})
return new Response(JSON.stringify({ id: 'quote-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
vi.stubGlobal('fetch', fetchMock)
await postStatus(new ApiClient('example.test', 'token'), {
status: 'Commentary',
quoted_status_id: 'quoted/one',
})
})
it('falls back to the legacy Pleroma quote-list endpoint', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify([{ id: 'quote-1' }]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
const page = await fetchQuotes(new ApiClient('old-pleroma.example', 'token'), 'status/one', {
limit: 20,
})
expect(page.items).toEqual([{ id: 'quote-1' }])
expect(fetchMock.mock.calls[0][0]).toBe(
'https://old-pleroma.example/api/v1/statuses/status%2Fone/quotes?limit=20',
)
expect(fetchMock.mock.calls[1][0]).toBe(
'https://old-pleroma.example/api/v1/pleroma/statuses/status%2Fone/quotes?limit=20',
)
})
})
describe('fetchNotifications', () => {
it('defensively applies requested types when a server ignores the filter', async () => {
vi.stubGlobal(
+21
View File
@@ -283,6 +283,7 @@ export interface ComposeOptions {
expires_in: number
multiple: boolean
}
quoted_status_id?: string
}
export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Status> {
@@ -297,9 +298,29 @@ export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Sta
if (options.media_ids?.length) body.media_ids = options.media_ids
if (options.language) body.language = options.language
if (options.poll) body.poll = options.poll
if (options.quoted_status_id) {
body.quoted_status_id = options.quoted_status_id
// Older Pleroma/Akkoma releases predate the standardized Mastodon name.
// Mastodon ignores unknown JSON keys, while those servers require this.
body.quote_id = options.quoted_status_id
}
return api.post<Status>('/api/v1/statuses', body)
}
export async function fetchQuotes(
api: ApiClient,
id: string,
cursor: Cursor = {},
): Promise<Page<Status>> {
const encoded = encodeURIComponent(id)
try {
return await api.page<Status>(`/api/v1/statuses/${encoded}/quotes`, { ...cursor })
} catch (cause) {
if (!(cause instanceof ApiError) || (cause.status !== 404 && cause.status !== 405)) throw cause
return api.page<Status>(`/api/v1/pleroma/statuses/${encoded}/quotes`, { ...cursor })
}
}
export function deleteStatus(api: ApiClient, id: string): Promise<Status> {
return api.delete<Status>(`/api/v1/statuses/${encodeURIComponent(id)}`)
}
+40
View File
@@ -178,6 +178,32 @@ export interface EmojiReaction {
accounts?: Account[]
}
export type QuoteState =
| 'pending'
| 'accepted'
| 'rejected'
| 'revoked'
| 'deleted'
| 'unauthorized'
| 'blocked_account'
| 'blocked_domain'
| 'muted_account'
| string
/** Mastodon 4.4+ quote envelope. Pleroma places the Status under `pleroma.quote`. */
export interface StatusQuote {
state: QuoteState
quoted_status?: Status | null
/** Mastodon ShallowQuote form. */
quoted_status_id?: string | null
}
export interface QuoteApproval {
automatic: string[]
manual: string[]
current_user: 'automatic' | 'manual' | 'denied' | 'unknown' | string
}
export interface Status {
id: string
uri: string
@@ -198,6 +224,7 @@ export interface Status {
replies_count: number
reblogs_count: number
favourites_count: number
quotes_count?: number
media_attachments: MediaAttachment[]
mentions: StatusMention[]
@@ -205,6 +232,12 @@ export interface Status {
emojis: CustomEmoji[]
card?: PreviewCard | null
poll?: Poll | null
/** Mastodon quote envelope, or a raw quoted Status on a few compatible forks. */
quote?: StatusQuote | Status | null
quote_approval?: QuoteApproval | null
/** Compatibility fields returned at top-level by some Pleroma-family forks. */
quote_id?: string | null
quote_url?: string | null
application?: { name: string; website?: string | null } | null
reblog: Status | null
@@ -222,6 +255,11 @@ export interface Status {
spoiler_text?: Record<string, string>
/** Pleroma/Akkoma emoji reactions, including custom emoji URLs. */
emoji_reactions?: EmojiReaction[]
quote?: Status | null
quote_id?: string | null
quote_url?: string | null
quote_visible?: boolean
quotes_count?: number
}
}
@@ -250,6 +288,8 @@ export type NotificationType =
| 'favourite'
| 'poll'
| 'update'
| 'quote'
| 'quoted_update'
| 'admin.sign_up'
| 'admin.report'
| 'pleroma:emoji_reaction'
+2
View File
@@ -86,6 +86,8 @@ const MESSAGE: Record<string, string> = {
favourite: 'gave your entry kudos',
poll: 'has a poll that just ended',
update: 'edited an entry',
quote: 'quoted your entry',
quoted_update: 'edited an entry you quoted',
'pleroma:emoji_reaction': 'reacted to your entry',
'admin.sign_up': 'joined the server',
'admin.report': 'was included in a report',
+1
View File
@@ -31,6 +31,7 @@ const ROUTES: RoutePattern[] = [
{ name: 'mail.folder', pattern: '/mail/:folder' },
{ name: 'timeline', pattern: '/timeline/:kind' },
{ name: 'tag', pattern: '/tag/:tag' },
{ name: 'blog.quotes', pattern: '/blog/:id/quotes' },
{ name: 'blog.entry', pattern: '/blog/:id' },
{ name: 'compose', pattern: '/compose' },
// Account routes come last: `:acct` is greedy enough to shadow the others.
+7
View File
@@ -10,4 +10,11 @@ describe('parseHash', () => {
expect(() => parseHash('#/@broken%ZZ')).not.toThrow()
expect(parseHash('#/@broken%ZZ').name).toBe('notfound')
})
it('matches the quote-list route before a blog entry', () => {
expect(parseHash('#/blog/status%2Fone/quotes')).toMatchObject({
name: 'blog.quotes',
params: { id: 'status/one' },
})
})
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { status } from '$test/fixtures'
import { quoteReferenceOf, quotesCountOf } from './status'
describe('quote status normalization', () => {
it('reads Pleroma embedded quotes and counts', () => {
const quoted = status({ id: 'quoted-entry' })
const outer = status({
pleroma: {
quote: quoted,
quote_id: quoted.id,
quote_visible: true,
quotes_count: 4,
},
})
expect(quoteReferenceOf(outer)).toMatchObject({
state: 'accepted',
status: quoted,
id: 'quoted-entry',
})
expect(quotesCountOf(outer)).toBe(4)
})
it('reads Mastodon quote envelopes but hides blocked quote content', () => {
const quoted = status({ id: 'quoted-entry' })
expect(
quoteReferenceOf(
status({
quote: { state: 'accepted', quoted_status: quoted },
quotes_count: 2,
}),
),
).toMatchObject({ state: 'accepted', status: quoted })
expect(
quoteReferenceOf(
status({
quote: { state: 'blocked_account', quoted_status: quoted },
}),
),
).toMatchObject({ state: 'blocked_account', status: null })
})
it('retains the target ID from a Mastodon shallow quote', () => {
expect(
quoteReferenceOf(
status({
quote: {
state: 'accepted',
quoted_status_id: 'shallow-target',
},
}),
),
).toMatchObject({
state: 'accepted',
status: null,
id: 'shallow-target',
})
})
})
+57
View File
@@ -0,0 +1,57 @@
import type { QuoteState, Status, StatusQuote } from '../api/types'
export interface QuoteReference {
state: QuoteState
status: Status | null
id: string | null
url: string | null
}
function isStatus(value: StatusQuote | Status): value is Status {
return 'id' in value && 'account' in value
}
/**
* Normalize Mastodon's quote envelope and Pleroma/Akkoma's extension fields.
* Content is exposed only for accepted quotes; blocked and muted states retain
* a Status in Mastodon's API but clients are expected not to display it.
*/
export function quoteReferenceOf(status: Status): QuoteReference | null {
const raw = status.quote
if (raw) {
if (isStatus(raw)) {
return { state: 'accepted', status: raw, id: raw.id, url: raw.url ?? raw.uri }
}
const visible = raw.state === 'accepted' ? (raw.quoted_status ?? null) : null
return {
state: raw.state || 'unauthorized',
status: visible,
id: raw.quoted_status?.id ?? raw.quoted_status_id ?? null,
url: raw.quoted_status?.url ?? raw.quoted_status?.uri ?? null,
}
}
const pleroma = status.pleroma
if (pleroma?.quote) {
return {
state: pleroma.quote_visible === false ? 'unauthorized' : 'accepted',
status: pleroma.quote_visible === false ? null : pleroma.quote,
id: pleroma.quote.id,
url: pleroma.quote.url ?? pleroma.quote.uri,
}
}
const id = status.quote_id ?? pleroma?.quote_id ?? null
const url = status.quote_url ?? pleroma?.quote_url ?? null
if (!id && !url) return null
return {
state: pleroma?.quote_visible === false ? 'unauthorized' : 'pending',
status: null,
id,
url,
}
}
export function quotesCountOf(status: Status): number {
return Math.max(0, status.quotes_count ?? 0, status.pleroma?.quotes_count ?? 0)
}