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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user