mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
/**
|
|
* Typed wrappers around the endpoints this frontend actually uses.
|
|
*
|
|
* Grouped by the MySpace-era concept they back, because that is how the UI
|
|
* thinks about them: friends (follows), blog entries (statuses), comments
|
|
* (replies), the mail centre (notifications).
|
|
*/
|
|
|
|
import type { ApiClient, Page, Query } from './client'
|
|
import { ApiError } from './client'
|
|
import type {
|
|
Account,
|
|
Context,
|
|
CredentialAccount,
|
|
InstanceInfo,
|
|
MediaAttachment,
|
|
Notification,
|
|
Poll,
|
|
Relationship,
|
|
SearchResults,
|
|
Status,
|
|
StatusVisibility,
|
|
} from './types'
|
|
|
|
export interface Cursor {
|
|
max_id?: string
|
|
min_id?: string
|
|
since_id?: string
|
|
limit?: number
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ server */
|
|
|
|
export async function fetchInstance(api: ApiClient): Promise<InstanceInfo> {
|
|
// v2 is richer but Pleroma/Akkoma only reliably serve v1.
|
|
try {
|
|
return await api.get<InstanceInfo>('/api/v2/instance')
|
|
} catch (cause) {
|
|
if (cause instanceof ApiError && cause.status !== 0) {
|
|
return api.get<InstanceInfo>('/api/v1/instance')
|
|
}
|
|
throw cause
|
|
}
|
|
}
|
|
|
|
export function instanceDomain(instance: InstanceInfo | null, fallback: string): string {
|
|
return instance?.domain ?? instance?.uri ?? fallback
|
|
}
|
|
|
|
export interface InstanceStat {
|
|
label: string
|
|
value: number
|
|
}
|
|
|
|
/**
|
|
* Normalize the two instance shapes into a stat list.
|
|
*
|
|
* v1 published user/status/domain counts; v2 dropped all of it except monthly
|
|
* actives, so the right rail shows whichever the server actually returned
|
|
* rather than a row of zeroes.
|
|
*/
|
|
export function instanceStats(instance: InstanceInfo | null): InstanceStat[] {
|
|
if (!instance) return []
|
|
|
|
if (instance.stats) {
|
|
return [
|
|
{ label: 'Members', value: instance.stats.user_count },
|
|
{ label: 'Entries', value: instance.stats.status_count },
|
|
{ label: 'Known servers', value: instance.stats.domain_count },
|
|
]
|
|
}
|
|
|
|
const activeMonth = instance.usage?.users?.active_month
|
|
return typeof activeMonth === 'number' ? [{ label: 'Active this month', value: activeMonth }] : []
|
|
}
|
|
|
|
export function instanceThumbnail(instance: InstanceInfo | null): string | null {
|
|
const thumbnail = instance?.thumbnail
|
|
if (!thumbnail) return null
|
|
return typeof thumbnail === 'string' ? thumbnail : (thumbnail.url ?? null)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ people */
|
|
|
|
export function verifyCredentials(api: ApiClient): Promise<CredentialAccount> {
|
|
return api.get<CredentialAccount>('/api/v1/accounts/verify_credentials')
|
|
}
|
|
|
|
export interface PublicProfileUpdate {
|
|
displayName?: string
|
|
note?: string
|
|
fields?: Array<{ name: string; value: string }>
|
|
avatar?: File | '' | undefined
|
|
header?: File | '' | undefined
|
|
background?: File | '' | undefined
|
|
avatarDescription?: string
|
|
headerDescription?: string
|
|
bot?: boolean
|
|
actorType?: 'Person' | 'Service' | 'Group'
|
|
birthday?: string
|
|
showBirthday?: boolean
|
|
}
|
|
|
|
/** Update only public-facing account profile data through the shared API. */
|
|
export async function updatePublicProfile(
|
|
api: ApiClient,
|
|
update: PublicProfileUpdate,
|
|
): Promise<CredentialAccount> {
|
|
const form = new FormData()
|
|
if (update.displayName !== undefined) form.set('display_name', update.displayName)
|
|
if (update.note !== undefined) form.set('note', update.note)
|
|
if (update.avatar !== undefined) form.set('avatar', update.avatar)
|
|
if (update.header !== undefined) form.set('header', update.header)
|
|
if (update.background !== undefined) form.set('pleroma_background_image', update.background)
|
|
if (update.avatarDescription !== undefined) {
|
|
form.set('avatar_description', update.avatarDescription)
|
|
}
|
|
if (update.headerDescription !== undefined) {
|
|
form.set('header_description', update.headerDescription)
|
|
}
|
|
if (update.bot !== undefined) form.set('bot', String(update.bot))
|
|
if (update.actorType !== undefined) form.set('actor_type', update.actorType)
|
|
if (update.birthday !== undefined) form.set('birthday', update.birthday)
|
|
if (update.showBirthday !== undefined) form.set('show_birthday', String(update.showBirthday))
|
|
// An omitted collection means "leave fields unchanged". To explicitly clear
|
|
// every field, submit one empty row; Pleroma and Mastodon both discard it
|
|
// while still recognizing that fields_attributes was present.
|
|
const submittedFields =
|
|
update.fields === undefined
|
|
? undefined
|
|
: update.fields.length > 0
|
|
? update.fields
|
|
: [{ name: '', value: '' }]
|
|
submittedFields?.forEach((field, index) => {
|
|
form.append(`fields_attributes[${index}][name]`, field.name)
|
|
form.append(`fields_attributes[${index}][value]`, field.value)
|
|
})
|
|
|
|
const { data } = await api.raw<CredentialAccount>('/api/v1/accounts/update_credentials', {
|
|
method: 'PATCH',
|
|
form,
|
|
})
|
|
return data
|
|
}
|
|
|
|
/**
|
|
* Replace the signed-in account's profile fields while leaving every other
|
|
* credential untouched. Mastodon and Pleroma both accept this indexed
|
|
* multipart shape; Pleroma-FE uses the same representation.
|
|
*/
|
|
export async function updateProfileFields(
|
|
api: ApiClient,
|
|
fields: Array<{ name: string; value: string }>,
|
|
): Promise<CredentialAccount> {
|
|
return updatePublicProfile(api, { fields })
|
|
}
|
|
|
|
export function fetchAccount(api: ApiClient, id: string): Promise<Account> {
|
|
return api.get<Account>(`/api/v1/accounts/${encodeURIComponent(id)}`)
|
|
}
|
|
|
|
/**
|
|
* Resolve `user` or `user@host` to an account.
|
|
*
|
|
* `/api/v1/accounts/lookup` is the fast path but predates Pleroma's
|
|
* compatibility work, so fall back to search with `resolve=1`, which also
|
|
* pulls in accounts the instance has never seen before.
|
|
*/
|
|
export async function lookupAccount(api: ApiClient, acct: string): Promise<Account> {
|
|
const handle = acct.replace(/^@/, '')
|
|
try {
|
|
return await api.get<Account>('/api/v1/accounts/lookup', { acct: handle })
|
|
} catch (cause) {
|
|
if (!(cause instanceof ApiError) || cause.status === 0) throw cause
|
|
|
|
const results = await api.get<SearchResults>('/api/v2/search', {
|
|
q: handle,
|
|
type: 'accounts',
|
|
resolve: api.authenticated,
|
|
limit: 5,
|
|
})
|
|
const exact = results.accounts.find((account) => account.acct.toLowerCase() === handle.toLowerCase())
|
|
if (exact) return exact
|
|
if (results.accounts.length > 0) return results.accounts[0]
|
|
throw cause
|
|
}
|
|
}
|
|
|
|
export function fetchFollowers(api: ApiClient, id: string, cursor: Cursor = {}): Promise<Page<Account>> {
|
|
return api.page<Account>(`/api/v1/accounts/${encodeURIComponent(id)}/followers`, { ...cursor })
|
|
}
|
|
|
|
export function fetchFollowing(api: ApiClient, id: string, cursor: Cursor = {}): Promise<Page<Account>> {
|
|
return api.page<Account>(`/api/v1/accounts/${encodeURIComponent(id)}/following`, { ...cursor })
|
|
}
|
|
|
|
export async function fetchRelationship(api: ApiClient, id: string): Promise<Relationship | null> {
|
|
if (!api.authenticated) return null
|
|
// Array values are serialized as `id[]=…`, which is what Mastodon expects.
|
|
const rows = await api.get<Relationship[]>('/api/v1/accounts/relationships', { id: [id] })
|
|
return rows[0] ?? null
|
|
}
|
|
|
|
export function followAccount(api: ApiClient, id: string): Promise<Relationship> {
|
|
return api.post<Relationship>(`/api/v1/accounts/${encodeURIComponent(id)}/follow`)
|
|
}
|
|
|
|
export function unfollowAccount(api: ApiClient, id: string): Promise<Relationship> {
|
|
return api.post<Relationship>(`/api/v1/accounts/${encodeURIComponent(id)}/unfollow`)
|
|
}
|
|
|
|
export function blockAccount(api: ApiClient, id: string): Promise<Relationship> {
|
|
return api.post<Relationship>(`/api/v1/accounts/${encodeURIComponent(id)}/block`)
|
|
}
|
|
|
|
export function unblockAccount(api: ApiClient, id: string): Promise<Relationship> {
|
|
return api.post<Relationship>(`/api/v1/accounts/${encodeURIComponent(id)}/unblock`)
|
|
}
|
|
|
|
/** The instance's opt-in profile directory — the "Browse" page's source. */
|
|
export function fetchDirectory(
|
|
api: ApiClient,
|
|
options: { offset?: number; limit?: number; order?: 'active' | 'new'; local?: boolean } = {},
|
|
): Promise<Account[]> {
|
|
return api.get<Account[]>('/api/v1/directory', {
|
|
offset: options.offset ?? 0,
|
|
limit: options.limit ?? 20,
|
|
order: options.order ?? 'active',
|
|
local: options.local ?? true,
|
|
})
|
|
}
|
|
|
|
/* ------------------------------------------------------------ blog entries */
|
|
|
|
export type TimelineKind = 'home' | 'public' | 'local' | 'tag'
|
|
|
|
export function fetchTimeline(
|
|
api: ApiClient,
|
|
kind: TimelineKind,
|
|
cursor: Cursor = {},
|
|
options: { tag?: string } = {},
|
|
): Promise<Page<Status>> {
|
|
switch (kind) {
|
|
case 'home':
|
|
return api.page<Status>('/api/v1/timelines/home', { ...cursor })
|
|
case 'local':
|
|
return api.page<Status>('/api/v1/timelines/public', { ...cursor, local: true })
|
|
case 'tag':
|
|
return api.page<Status>(`/api/v1/timelines/tag/${encodeURIComponent(options.tag ?? '')}`, { ...cursor })
|
|
case 'public':
|
|
default:
|
|
return api.page<Status>('/api/v1/timelines/public', { ...cursor })
|
|
}
|
|
}
|
|
|
|
export function fetchAccountStatuses(
|
|
api: ApiClient,
|
|
id: string,
|
|
cursor: Cursor = {},
|
|
options: { exclude_replies?: boolean; exclude_reblogs?: boolean; only_media?: boolean; pinned?: boolean } = {},
|
|
): Promise<Page<Status>> {
|
|
return api.page<Status>(`/api/v1/accounts/${encodeURIComponent(id)}/statuses`, { ...cursor, ...options })
|
|
}
|
|
|
|
export function fetchStatus(api: ApiClient, id: string): Promise<Status> {
|
|
return api.get<Status>(`/api/v1/statuses/${encodeURIComponent(id)}`)
|
|
}
|
|
|
|
export function fetchContext(api: ApiClient, id: string): Promise<Context> {
|
|
return api.get<Context>(`/api/v1/statuses/${encodeURIComponent(id)}/context`)
|
|
}
|
|
|
|
export interface ComposeOptions {
|
|
status: string
|
|
in_reply_to_id?: string | null
|
|
visibility?: StatusVisibility
|
|
spoiler_text?: string
|
|
sensitive?: boolean
|
|
media_ids?: string[]
|
|
language?: string
|
|
poll?: {
|
|
options: string[]
|
|
expires_in: number
|
|
multiple: boolean
|
|
}
|
|
}
|
|
|
|
export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Status> {
|
|
const body: Record<string, unknown> = { status: options.status }
|
|
if (options.in_reply_to_id) body.in_reply_to_id = options.in_reply_to_id
|
|
if (options.visibility) body.visibility = options.visibility
|
|
if (options.spoiler_text) {
|
|
body.spoiler_text = options.spoiler_text
|
|
body.sensitive = true
|
|
}
|
|
if (options.sensitive) body.sensitive = true
|
|
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
|
|
return api.post<Status>('/api/v1/statuses', body)
|
|
}
|
|
|
|
export function deleteStatus(api: ApiClient, id: string): Promise<Status> {
|
|
return api.delete<Status>(`/api/v1/statuses/${encodeURIComponent(id)}`)
|
|
}
|
|
|
|
export function favouriteStatus(api: ApiClient, id: string, on: boolean): Promise<Status> {
|
|
const action = on ? 'favourite' : 'unfavourite'
|
|
return api.post<Status>(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`)
|
|
}
|
|
|
|
export function reblogStatus(api: ApiClient, id: string, on: boolean): Promise<Status> {
|
|
const action = on ? 'reblog' : 'unreblog'
|
|
return api.post<Status>(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`)
|
|
}
|
|
|
|
/**
|
|
* Add or remove one of the reactions already present on a status.
|
|
*
|
|
* Pleroma and Akkoma share this endpoint. `emoji` can be a Unicode emoji, a
|
|
* local custom shortcode, or the server-qualified `shortcode@host` returned in
|
|
* `pleroma.emoji_reactions`.
|
|
*/
|
|
export async function setEmojiReaction(
|
|
api: ApiClient,
|
|
id: string,
|
|
emoji: string,
|
|
on: boolean,
|
|
): Promise<Status> {
|
|
const path =
|
|
`/api/v1/pleroma/statuses/${encodeURIComponent(id)}` +
|
|
`/reactions/${encodeURIComponent(emoji)}`
|
|
const { data } = await api.raw<Status>(path, { method: on ? 'PUT' : 'DELETE' })
|
|
return data
|
|
}
|
|
|
|
export function votePoll(api: ApiClient, id: string, choices: number[]): Promise<Poll> {
|
|
return api.post<Poll>(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices })
|
|
}
|
|
|
|
export async function uploadMedia(api: ApiClient, file: File, description?: string): Promise<MediaAttachment> {
|
|
const form = new FormData()
|
|
form.set('file', file)
|
|
if (description) form.set('description', description)
|
|
// v2 returns 202 while transcoding; v1 blocks until ready, which is simpler
|
|
// for a client with no job-polling loop.
|
|
const { data } = await api.raw<MediaAttachment>('/api/v1/media', { method: 'POST', form })
|
|
return data
|
|
}
|
|
|
|
/* ------------------------------------------------------------- mail centre */
|
|
|
|
export async function fetchNotifications(
|
|
api: ApiClient,
|
|
cursor: Cursor = {},
|
|
types?: string[],
|
|
): Promise<Page<Notification>> {
|
|
const query: Query = { ...cursor }
|
|
if (types?.length) query.types = types
|
|
const page = await api.page<Notification>('/api/v1/notifications', query)
|
|
if (!types?.length) return page
|
|
|
|
// Egregoros currently accepts but ignores `types[]`. Apply the same filter
|
|
// locally so Mail folders remain correct; this is harmless when the server
|
|
// already filtered the page.
|
|
const allowed = new Set(types)
|
|
return { ...page, items: page.items.filter((notification) => allowed.has(notification.type)) }
|
|
}
|
|
|
|
export function fetchFollowRequests(api: ApiClient, cursor: Cursor = {}): Promise<Page<Account>> {
|
|
return api.page<Account>('/api/v1/follow_requests', { ...cursor })
|
|
}
|
|
|
|
export function authorizeFollowRequest(api: ApiClient, id: string): Promise<Relationship> {
|
|
return api.post<Relationship>(`/api/v1/follow_requests/${encodeURIComponent(id)}/authorize`)
|
|
}
|
|
|
|
export function rejectFollowRequest(api: ApiClient, id: string): Promise<Relationship> {
|
|
return api.post<Relationship>(`/api/v1/follow_requests/${encodeURIComponent(id)}/reject`)
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- search */
|
|
|
|
export function search(
|
|
api: ApiClient,
|
|
q: string,
|
|
options: { type?: 'accounts' | 'statuses' | 'hashtags'; limit?: number; offset?: number } = {},
|
|
): Promise<SearchResults> {
|
|
return api.get<SearchResults>('/api/v2/search', {
|
|
q,
|
|
type: options.type,
|
|
limit: options.limit ?? 20,
|
|
offset: options.offset,
|
|
resolve: api.authenticated,
|
|
})
|
|
}
|