/** * 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 { // v2 is richer but Pleroma/Akkoma only reliably serve v1. try { return await api.get('/api/v2/instance') } catch (cause) { if (cause instanceof ApiError && cause.status !== 0) { return api.get('/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 { return api.get('/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 { 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('/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 { return updatePublicProfile(api, { fields }) } export function fetchAccount(api: ApiClient, id: string): Promise { return api.get(`/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 { const handle = acct.replace(/^@/, '') try { return await api.get('/api/v1/accounts/lookup', { acct: handle }) } catch (cause) { if (!(cause instanceof ApiError) || cause.status === 0) throw cause const results = await api.get('/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> { return api.page(`/api/v1/accounts/${encodeURIComponent(id)}/followers`, { ...cursor }) } export function fetchFollowing(api: ApiClient, id: string, cursor: Cursor = {}): Promise> { return api.page(`/api/v1/accounts/${encodeURIComponent(id)}/following`, { ...cursor }) } export async function fetchRelationship(api: ApiClient, id: string): Promise { if (!api.authenticated) return null // Array values are serialized as `id[]=…`, which is what Mastodon expects. const rows = await api.get('/api/v1/accounts/relationships', { id: [id] }) return rows[0] ?? null } export function followAccount(api: ApiClient, id: string): Promise { return api.post(`/api/v1/accounts/${encodeURIComponent(id)}/follow`) } export function unfollowAccount(api: ApiClient, id: string): Promise { return api.post(`/api/v1/accounts/${encodeURIComponent(id)}/unfollow`) } export function blockAccount(api: ApiClient, id: string): Promise { return api.post(`/api/v1/accounts/${encodeURIComponent(id)}/block`) } export function unblockAccount(api: ApiClient, id: string): Promise { return api.post(`/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 { return api.get('/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> { switch (kind) { case 'home': return api.page('/api/v1/timelines/home', { ...cursor }) case 'local': return api.page('/api/v1/timelines/public', { ...cursor, local: true }) case 'tag': return api.page(`/api/v1/timelines/tag/${encodeURIComponent(options.tag ?? '')}`, { ...cursor }) case 'public': default: return api.page('/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> { return api.page(`/api/v1/accounts/${encodeURIComponent(id)}/statuses`, { ...cursor, ...options }) } export function fetchStatus(api: ApiClient, id: string): Promise { return api.get(`/api/v1/statuses/${encodeURIComponent(id)}`) } export function fetchContext(api: ApiClient, id: string): Promise { return api.get(`/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 { const body: Record = { 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('/api/v1/statuses', body) } export function deleteStatus(api: ApiClient, id: string): Promise { return api.delete(`/api/v1/statuses/${encodeURIComponent(id)}`) } export function favouriteStatus(api: ApiClient, id: string, on: boolean): Promise { const action = on ? 'favourite' : 'unfavourite' return api.post(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`) } export function reblogStatus(api: ApiClient, id: string, on: boolean): Promise { const action = on ? 'reblog' : 'unreblog' return api.post(`/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 { const path = `/api/v1/pleroma/statuses/${encodeURIComponent(id)}` + `/reactions/${encodeURIComponent(emoji)}` const { data } = await api.raw(path, { method: on ? 'PUT' : 'DELETE' }) return data } export function votePoll(api: ApiClient, id: string, choices: number[]): Promise { return api.post(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices }) } export async function uploadMedia(api: ApiClient, file: File, description?: string): Promise { 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('/api/v1/media', { method: 'POST', form }) return data } /* ------------------------------------------------------------- mail centre */ export async function fetchNotifications( api: ApiClient, cursor: Cursor = {}, types?: string[], ): Promise> { const query: Query = { ...cursor } if (types?.length) query.types = types const page = await api.page('/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> { return api.page('/api/v1/follow_requests', { ...cursor }) } export function authorizeFollowRequest(api: ApiClient, id: string): Promise { return api.post(`/api/v1/follow_requests/${encodeURIComponent(id)}/authorize`) } export function rejectFollowRequest(api: ApiClient, id: string): Promise { return api.post(`/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 { return api.get('/api/v2/search', { q, type: options.type, limit: options.limit ?? 20, offset: options.offset, resolve: api.authenticated, }) }