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
+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,
})
}