initial commit

This commit is contained in:
Moon.eth
2026-07-29 09:16:38 +09:00
commit 586b599d4c
67 changed files with 9906 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
/**
* Thin fetch wrapper for Mastodon-compatible REST APIs.
*
* Deliberately dependency-free and stateless apart from the host/token it is
* constructed with, so it can be reused for logged-out browsing of an arbitrary
* instance as well as for the signed-in session.
*/
export class ApiError extends Error {
readonly status: number
readonly url: string
readonly body: unknown
constructor(status: number, url: string, body: unknown, message?: string) {
super(message ?? `${status} from ${url}`)
this.name = 'ApiError'
this.status = status
this.url = url
this.body = body
}
/** True when re-authenticating is likely to fix it. */
get isAuthFailure(): boolean {
return this.status === 401 || this.status === 403
}
}
/** Cursor links parsed out of the RFC 5988 `Link` response header. */
export interface PageLinks {
/** Older results (`?max_id=…`). */
next?: string
/** Newer results (`?min_id=…`). */
prev?: string
maxId?: string
minId?: string
sinceId?: string
}
export interface Page<T> {
items: T[]
links: PageLinks
}
export type QueryValue = string | number | boolean | undefined | null | string[]
export type Query = Record<string, QueryValue>
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
query?: Query
body?: unknown
/** Send as multipart instead of JSON (media uploads). */
form?: FormData
signal?: AbortSignal
/** Override the instance token for this call. */
token?: string | null
headers?: Record<string, string>
}
/**
* Mastodon paginates with a `Link` header rather than in the body. Pleroma
* emits the same header but occasionally omits `rel="prev"`, and some servers
* behind a CORS proxy strip it entirely — callers must cope with an empty
* result here by falling back to the last item's id.
*/
export function parseLinkHeader(header: string | null): PageLinks {
const links: PageLinks = {}
if (!header) return links
for (const part of header.split(/,\s*(?=<)/)) {
const match = /^<([^>]+)>\s*;\s*(.+)$/.exec(part.trim())
if (!match) continue
const [, url, params] = match
const rel = /rel\s*=\s*"?([^";]+)"?/.exec(params)?.[1]
if (rel !== 'next' && rel !== 'prev') continue
links[rel] = url
let parsed: URL
try {
parsed = new URL(url)
} catch {
continue
}
const maxId = parsed.searchParams.get('max_id')
const minId = parsed.searchParams.get('min_id')
const sinceId = parsed.searchParams.get('since_id')
if (rel === 'next' && maxId) links.maxId = maxId
if (rel === 'prev' && minId) links.minId = minId
if (rel === 'prev' && sinceId) links.sinceId = sinceId
}
return links
}
/** `https://example.social/` / `Example.Social` / `@user@example.social` -> `example.social`. */
export function normalizeHost(input: string): string {
let value = input.trim().toLowerCase()
if (!value) return ''
// Accept a full webfinger handle and keep only the domain part.
if (value.includes('@')) value = value.slice(value.lastIndexOf('@') + 1)
value = value.replace(/^https?:\/\//, '')
value = value.replace(/\/.*$/, '')
return value
}
function buildQuery(query: Query | undefined): string {
if (!query) return ''
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue
if (Array.isArray(value)) {
for (const entry of value) params.append(`${key}[]`, entry)
} else {
params.set(key, String(value))
}
}
const serialized = params.toString()
return serialized ? `?${serialized}` : ''
}
export class ApiClient {
readonly host: string
private token: string | null
constructor(host: string, token: string | null = null) {
this.host = normalizeHost(host)
this.token = token
}
get origin(): string {
return `https://${this.host}`
}
get authenticated(): boolean {
return Boolean(this.token)
}
setToken(token: string | null): void {
this.token = token
}
withToken(token: string | null): ApiClient {
return new ApiClient(this.host, token)
}
private url(path: string, query?: Query): string {
const suffix = path.startsWith('/') ? path : `/${path}`
return `${this.origin}${suffix}${buildQuery(query)}`
}
private headers(options: RequestOptions): Headers {
const headers = new Headers(options.headers)
headers.set('Accept', 'application/json')
const token = options.token !== undefined ? options.token : this.token
if (token) headers.set('Authorization', `Bearer ${token}`)
if (options.body !== undefined && !options.form) {
headers.set('Content-Type', 'application/json')
}
return headers
}
/** Perform a request and return the parsed body plus the raw response. */
async raw<T>(path: string, options: RequestOptions = {}): Promise<{ data: T; response: Response }> {
const url = this.url(path, options.query)
let response: Response
try {
response = await fetch(url, {
method: options.method ?? 'GET',
headers: this.headers(options),
body: options.form ?? (options.body !== undefined ? JSON.stringify(options.body) : undefined),
signal: options.signal,
// Mastodon tokens ride in the Authorization header; never send cookies
// cross-origin, which would also trip CORS preflight on most servers.
credentials: 'omit',
mode: 'cors',
})
} catch (cause) {
// A CORS rejection and an offline browser are indistinguishable here.
throw new ApiError(
0,
url,
null,
`Could not reach ${this.host}. It may be offline, or it may not allow browser apps to connect (CORS).`,
)
}
const text = await response.text()
let data: unknown = null
if (text) {
try {
data = JSON.parse(text)
} catch {
data = text
}
}
if (!response.ok) {
const message =
(data && typeof data === 'object' && 'error' in data && typeof data.error === 'string'
? data.error
: undefined) ?? `${response.status} ${response.statusText}`
throw new ApiError(response.status, url, data, message)
}
return { data: data as T, response }
}
async get<T>(path: string, query?: Query, options: RequestOptions = {}): Promise<T> {
const { data } = await this.raw<T>(path, { ...options, method: 'GET', query })
return data
}
async post<T>(path: string, body?: unknown, options: RequestOptions = {}): Promise<T> {
const { data } = await this.raw<T>(path, { ...options, method: 'POST', body })
return data
}
async patch<T>(path: string, body?: unknown, options: RequestOptions = {}): Promise<T> {
const { data } = await this.raw<T>(path, { ...options, method: 'PATCH', body })
return data
}
async delete<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { data } = await this.raw<T>(path, { ...options, method: 'DELETE' })
return data
}
/** GET a collection endpoint, returning both items and cursor links. */
async page<T>(path: string, query?: Query, options: RequestOptions = {}): Promise<Page<T>> {
const { data, response } = await this.raw<T[]>(path, { ...options, method: 'GET', query })
const items = Array.isArray(data) ? data : []
const links = parseLinkHeader(response.headers.get('Link'))
// Fallback for servers that drop the Link header: derive `max_id` from the
// last item so "see more" keeps working.
if (!links.maxId && items.length > 0) {
const last = items[items.length - 1] as { id?: string }
if (last && typeof last.id === 'string') links.maxId = last.id
}
return { items, links }
}
}
+289
View File
@@ -0,0 +1,289 @@
/**
* 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,
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 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
}
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
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}`)
}
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 function fetchNotifications(
api: ApiClient,
cursor: Cursor = {},
types?: string[],
): Promise<Page<Notification>> {
const query: Query = { ...cursor }
if (types?.length) query.types = types
return api.page<Notification>('/api/v1/notifications', query)
}
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,
})
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Browser-side OAuth 2.0 for Mastodon-compatible servers.
*
* A static frontend has no backend to keep a client secret, so it registers a
* throwaway app per instance (`POST /api/v1/apps`) and uses the authorization
* code flow, upgrading to PKCE when the server advertises support (Mastodon
* 4.3+). Pleroma and older Mastodon ignore the PKCE params, hence the retry
* without them rather than a hard requirement.
*
* The registered app credentials and the resulting token live in localStorage;
* they are per-origin and per-instance, and are exactly as sensitive as being
* logged in on this browser.
*/
import { ApiClient, ApiError, normalizeHost } from './client'
import type { OAuthApp, OAuthToken } from './types'
export const APP_NAME = 'plspace'
export const APP_WEBSITE = 'https://github.com/plspace'
export const SCOPES = 'read write follow'
const APP_KEY = 'plspace:oauth:apps'
const PENDING_KEY = 'plspace:oauth:pending'
interface PendingAuth {
host: string
verifier?: string
state: string
/** Route to land on after a successful exchange. */
returnTo: string
}
/**
* The redirect target must match what was registered byte-for-byte. The app
* uses hash routing, so the OAuth code comes back on the query string of the
* document URL and the hash stays free for our own router.
*/
export function redirectUri(): string {
return `${window.location.origin}${window.location.pathname}`
}
function readApps(): Record<string, OAuthApp> {
try {
return JSON.parse(localStorage.getItem(APP_KEY) ?? '{}') as Record<string, OAuthApp>
} catch {
return {}
}
}
function writeApp(host: string, app: OAuthApp): void {
const apps = readApps()
apps[host] = app
localStorage.setItem(APP_KEY, JSON.stringify(apps))
}
/** Register (or reuse) an OAuth app on `host`. */
export async function ensureApp(host: string): Promise<OAuthApp> {
const key = normalizeHost(host)
const cached = readApps()[key]
// Re-register if the deployment moved: a stale redirect_uri fails at /oauth/authorize
// with an opaque error page, which is miserable to debug.
if (cached && cached.redirect_uri === redirectUri()) return cached
const client = new ApiClient(key)
const app = await client.post<OAuthApp>('/api/v1/apps', {
client_name: APP_NAME,
redirect_uris: redirectUri(),
scopes: SCOPES,
website: APP_WEBSITE,
})
writeApp(key, app)
return app
}
function randomString(bytes = 48): string {
const buffer = new Uint8Array(bytes)
crypto.getRandomValues(buffer)
return base64url(buffer)
}
function base64url(buffer: ArrayBuffer | Uint8Array): string {
const view = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
let binary = ''
for (const byte of view) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function challengeFor(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
return base64url(digest)
}
/** Kick off the redirect to the instance's consent screen. */
export async function beginLogin(host: string, returnTo = '#/'): Promise<void> {
const key = normalizeHost(host)
const app = await ensureApp(key)
const state = randomString(16)
// crypto.subtle is unavailable on insecure origins; fall back to a plain
// authorization-code flow there rather than failing to log in at all.
const canPkce = Boolean(crypto.subtle)
const verifier = canPkce ? randomString(48) : undefined
const pending: PendingAuth = { host: key, verifier, state, returnTo }
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending))
const params = new URLSearchParams({
client_id: app.client_id,
redirect_uri: redirectUri(),
response_type: 'code',
scope: SCOPES,
state,
})
if (verifier) {
params.set('code_challenge', await challengeFor(verifier))
params.set('code_challenge_method', 'S256')
}
window.location.assign(`https://${key}/oauth/authorize?${params.toString()}`)
}
export interface CompletedLogin {
host: string
token: string
returnTo: string
}
/**
* Complete the flow if the current URL carries an authorization code.
* Returns null when this is an ordinary page load.
*/
export async function completeLogin(): Promise<CompletedLogin | null> {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const error = url.searchParams.get('error')
const state = url.searchParams.get('state')
if (!code && !error) return null
const rawPending = sessionStorage.getItem(PENDING_KEY)
sessionStorage.removeItem(PENDING_KEY)
clearOAuthParams()
if (error) {
throw new Error(url.searchParams.get('error_description') ?? `Authorization failed: ${error}`)
}
if (!rawPending) {
throw new Error('No sign-in was in progress in this tab. Please start again.')
}
const pending = JSON.parse(rawPending) as PendingAuth
if (pending.state !== state) {
throw new Error('Sign-in state did not match. Please start again.')
}
const app = readApps()[pending.host]
if (!app) throw new Error('Lost the app registration for this server. Please start again.')
const client = new ApiClient(pending.host)
const body: Record<string, string> = {
grant_type: 'authorization_code',
client_id: app.client_id,
client_secret: app.client_secret,
redirect_uri: redirectUri(),
scope: SCOPES,
code: code!,
}
if (pending.verifier) body.code_verifier = pending.verifier
let token: OAuthToken
try {
token = await client.post<OAuthToken>('/oauth/token', body)
} catch (cause) {
// Servers that don't implement PKCE reject the unexpected code_verifier.
if (pending.verifier && cause instanceof ApiError && cause.status === 400) {
delete body.code_verifier
token = await client.post<OAuthToken>('/oauth/token', body)
} else {
throw cause
}
}
return { host: pending.host, token: token.access_token, returnTo: pending.returnTo || '#/' }
}
/** Strip `?code=…&state=…` so a refresh doesn't try to redeem a spent code. */
function clearOAuthParams(): void {
const url = new URL(window.location.href)
for (const key of ['code', 'state', 'error', 'error_description', 'iss']) {
url.searchParams.delete(key)
}
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
}
/** Best-effort token revocation; failure is not worth blocking sign-out. */
export async function revoke(host: string, token: string): Promise<void> {
const app = readApps()[normalizeHost(host)]
if (!app) return
try {
await new ApiClient(host).post('/oauth/revoke', {
client_id: app.client_id,
client_secret: app.client_secret,
token,
})
} catch {
/* the local token is dropped regardless */
}
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Mastodon REST API entities (v1), typed conservatively.
*
* Everything here is written to survive Pleroma/Akkoma/GoToSocial/Iceshrimp as
* well as Mastodon proper, so anything that is not universally present is
* optional. Two rules of thumb learned the hard way:
*
* - Field presence differs per server *and* per authentication state. A
* logged-out `GET /api/v1/timelines/public` omits `favourited`/`reblogged`
* everywhere, and Pleroma omits several Mastodon-4.x additions entirely.
* - Counters are sometimes hidden (`-1` or `0`) rather than absent when a user
* opts out of showing collections, so never treat 0 as "definitely none".
*/
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct'
export interface CustomEmoji {
shortcode: string
url: string
static_url: string
visible_in_picker: boolean
category?: string | null
}
export interface AccountField {
name: string
/** HTML */
value: string
verified_at?: string | null
}
export interface AccountRole {
id: string
name: string
color: string
}
export interface Account {
id: string
username: string
/** `user` for local accounts, `user@host` for remote ones. */
acct: string
display_name: string
/** HTML bio. */
note: string
url: string
uri?: string
avatar: string
avatar_static: string
header: string
header_static: string
locked: boolean
bot?: boolean
group?: boolean
discoverable?: boolean | null
created_at: string
last_status_at?: string | null
statuses_count: number
followers_count: number
following_count: number
fields: AccountField[]
emojis: CustomEmoji[]
roles?: AccountRole[]
moved?: Account | null
suspended?: boolean
limited?: boolean
hide_collections?: boolean | null
/** Pleroma/Akkoma extension bag. Present only on those servers. */
pleroma?: {
background_image?: string | null
is_admin?: boolean
is_moderator?: boolean
hide_favorites?: boolean
/** Pleroma's equivalent of Mastodon's `hide_collections`. */
hide_followers?: boolean
hide_follows?: boolean
/**
* When set, `followers_count` is reported as 0 rather than withheld — so a
* zero here means "not telling you", not "nobody".
*/
hide_followers_count?: boolean
hide_follows_count?: boolean
relationship?: Relationship
/** Some deployments expose the user's own profile CSS here. */
background_color?: string | null
}
}
export interface CredentialAccount extends Account {
source?: {
note: string
fields: AccountField[]
privacy: StatusVisibility
sensitive: boolean
language: string | null
follow_requests_count?: number
}
}
export interface MediaAttachment {
id: string
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
url: string
preview_url: string | null
remote_url?: string | null
description?: string | null
blurhash?: string | null
meta?: {
original?: { width?: number; height?: number; aspect?: number }
small?: { width?: number; height?: number; aspect?: number }
[key: string]: unknown
}
}
export interface StatusMention {
id: string
username: string
url: string
acct: string
}
export interface StatusTag {
name: string
url: string
}
export interface PreviewCard {
url: string
title: string
description: string
type: 'link' | 'photo' | 'video' | 'rich'
image?: string | null
provider_name?: string
author_name?: string
}
export interface PollOption {
title: string
votes_count: number | null
}
export interface Poll {
id: string
expires_at: string | null
expired: boolean
multiple: boolean
votes_count: number
voters_count?: number | null
options: PollOption[]
emojis: CustomEmoji[]
voted?: boolean
own_votes?: number[]
}
export interface Status {
id: string
uri: string
created_at: string
account: Account
/** HTML. Must be sanitized before it goes anywhere near {@html}. */
content: string
visibility: StatusVisibility
sensitive: boolean
spoiler_text: string
language?: string | null
url?: string | null
edited_at?: string | null
in_reply_to_id: string | null
in_reply_to_account_id: string | null
replies_count: number
reblogs_count: number
favourites_count: number
media_attachments: MediaAttachment[]
mentions: StatusMention[]
tags: StatusTag[]
emojis: CustomEmoji[]
card?: PreviewCard | null
poll?: Poll | null
application?: { name: string; website?: string | null } | null
reblog: Status | null
favourited?: boolean
reblogged?: boolean
muted?: boolean
bookmarked?: boolean
pinned?: boolean
pleroma?: {
local?: boolean
conversation_id?: number
content?: Record<string, string>
spoiler_text?: Record<string, string>
}
}
export interface Relationship {
id: string
following: boolean
followed_by: boolean
requested: boolean
blocking: boolean
blocked_by?: boolean
muting: boolean
muting_notifications?: boolean
domain_blocking?: boolean
endorsed?: boolean
note?: string
showing_reblogs?: boolean
notifying?: boolean
}
export type NotificationType =
| 'mention'
| 'status'
| 'reblog'
| 'follow'
| 'follow_request'
| 'favourite'
| 'poll'
| 'update'
| 'admin.sign_up'
| 'admin.report'
| 'pleroma:emoji_reaction'
| 'pleroma:report'
export interface Notification {
id: string
type: NotificationType
created_at: string
account: Account
status?: Status | null
}
export interface Context {
ancestors: Status[]
descendants: Status[]
}
export interface InstanceInfo {
/** v1 shape */
uri?: string
/** v2 shape */
domain?: string
title: string
description?: string
short_description?: string
version: string
thumbnail?: string | { url: string } | null
/** v1 shape. */
stats?: {
user_count: number
status_count: number
domain_count: number
}
/** v2 shape; only monthly actives are published. */
usage?: {
users?: { active_month?: number }
}
configuration?: {
statuses?: {
max_characters?: number
max_media_attachments?: number
}
}
registrations?: boolean | { enabled?: boolean }
contact_account?: Account | null
/** Pleroma reports its "real" upstream here. */
pleroma?: unknown
}
export interface SearchResults {
accounts: Account[]
statuses: Status[]
hashtags: StatusTag[]
}
export interface OAuthApp {
id: string
name: string
website?: string | null
redirect_uri: string
client_id: string
client_secret: string
vapid_key?: string
}
export interface OAuthToken {
access_token: string
token_type: string
scope: string
created_at: number
}