mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
initial commit
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Hash router.
|
||||
*
|
||||
* Hash routing (rather than the History API) is what makes `dist/` a genuinely
|
||||
* static bundle: it can be served from a subdirectory, an S3 bucket or a GitHub
|
||||
* Pages path with no rewrite rules, and the OAuth redirect can land on the
|
||||
* document URL's query string without colliding with our own routes.
|
||||
*/
|
||||
|
||||
export interface RouteMatch {
|
||||
name: string
|
||||
params: Record<string, string>
|
||||
query: URLSearchParams
|
||||
/** The raw hash path, e.g. `/@alice@example.social/friends`. */
|
||||
path: string
|
||||
}
|
||||
|
||||
interface RoutePattern {
|
||||
name: string
|
||||
/** `/blog/:id` — `:param` captures one segment, `*rest` captures the remainder. */
|
||||
pattern: string
|
||||
}
|
||||
|
||||
const ROUTES: RoutePattern[] = [
|
||||
{ name: 'home', pattern: '/' },
|
||||
{ name: 'login', pattern: '/login' },
|
||||
{ name: 'settings', pattern: '/settings' },
|
||||
{ name: 'browse', pattern: '/browse' },
|
||||
{ name: 'search', pattern: '/search' },
|
||||
{ name: 'mail', pattern: '/mail' },
|
||||
{ name: 'mail.folder', pattern: '/mail/:folder' },
|
||||
{ name: 'timeline', pattern: '/timeline/:kind' },
|
||||
{ name: 'tag', pattern: '/tag/:tag' },
|
||||
{ name: 'blog.entry', pattern: '/blog/:id' },
|
||||
{ name: 'compose', pattern: '/compose' },
|
||||
// Account routes come last: `:acct` is greedy enough to shadow the others.
|
||||
{ name: 'profile.friends', pattern: '/@:acct/friends' },
|
||||
{ name: 'profile.blog', pattern: '/@:acct/blog' },
|
||||
{ name: 'profile.pics', pattern: '/@:acct/pics' },
|
||||
{ name: 'profile', pattern: '/@:acct' },
|
||||
]
|
||||
|
||||
function matchPattern(pattern: string, path: string): Record<string, string> | null {
|
||||
const patternParts = pattern.split('/').filter(Boolean)
|
||||
const pathParts = path.split('/').filter(Boolean)
|
||||
|
||||
if (patternParts.length !== pathParts.length) return null
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
for (let index = 0; index < patternParts.length; index += 1) {
|
||||
const expected = patternParts[index]
|
||||
const actual = pathParts[index]
|
||||
|
||||
// `@:acct` — a literal prefix followed by a capture, as in `/@alice@host`.
|
||||
const prefixed = /^(@?)(:[a-zA-Z]+)$/.exec(expected)
|
||||
if (prefixed) {
|
||||
const [, prefix, name] = prefixed
|
||||
if (prefix && !actual.startsWith(prefix)) return null
|
||||
const value = decodeURIComponent(actual.slice(prefix.length))
|
||||
if (!value) return null
|
||||
params[name.slice(1)] = value
|
||||
continue
|
||||
}
|
||||
|
||||
if (expected !== actual) return null
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
export function parseHash(hash: string): RouteMatch {
|
||||
const raw = hash.replace(/^#/, '') || '/'
|
||||
const [pathPart, queryPart = ''] = raw.split('?')
|
||||
const path = pathPart || '/'
|
||||
const query = new URLSearchParams(queryPart)
|
||||
|
||||
for (const route of ROUTES) {
|
||||
const params = matchPattern(route.pattern, path)
|
||||
if (params) return { name: route.name, params, query, path }
|
||||
}
|
||||
|
||||
return { name: 'notfound', params: {}, query, path }
|
||||
}
|
||||
|
||||
class Router {
|
||||
current = $state<RouteMatch>(parseHash(typeof location === 'undefined' ? '#/' : location.hash))
|
||||
|
||||
constructor() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.addEventListener('hashchange', () => {
|
||||
this.current = parseHash(location.hash)
|
||||
// Matches the old-web expectation that a new "page" starts at the top.
|
||||
window.scrollTo(0, 0)
|
||||
})
|
||||
}
|
||||
|
||||
/** Navigate, adding a history entry. */
|
||||
go(to: string): void {
|
||||
const target = to.startsWith('#') ? to : `#${to.startsWith('/') ? to : `/${to}`}`
|
||||
if (location.hash === target) {
|
||||
this.current = parseHash(target)
|
||||
return
|
||||
}
|
||||
location.hash = target
|
||||
}
|
||||
|
||||
/** Navigate without adding a history entry (search-as-you-type, tab switches). */
|
||||
replace(to: string): void {
|
||||
const target = to.startsWith('#') ? to : `#${to.startsWith('/') ? to : `/${to}`}`
|
||||
history.replaceState({}, '', target)
|
||||
this.current = parseHash(target)
|
||||
}
|
||||
}
|
||||
|
||||
export const router = new Router()
|
||||
|
||||
/** Build a route string with an encoded query. */
|
||||
export function routeTo(path: string, query?: Record<string, string | undefined>): string {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
if (value) params.set(key, value)
|
||||
}
|
||||
const serialized = params.toString()
|
||||
return `#${path}${serialized ? `?${serialized}` : ''}`
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Cursor-paginated list state.
|
||||
*
|
||||
* Every list in the app — timelines, friend lists, notifications, the directory
|
||||
* — is the same shape: fetch a page, remember the cursor, append on demand.
|
||||
* This wraps that with the two things that bite in practice: de-duplication
|
||||
* (federated timelines repeat statuses across pages when new posts arrive
|
||||
* mid-scroll) and out-of-order responses from an impatient "more" button.
|
||||
*/
|
||||
|
||||
import { ApiError, type Page } from '../api/client'
|
||||
import type { Cursor } from '../api/endpoints'
|
||||
|
||||
export interface Identified {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type Loader<T> = (cursor: Cursor) => Promise<Page<T>>
|
||||
|
||||
export class Feed<T extends Identified> {
|
||||
items = $state<T[]>([])
|
||||
loading = $state(false)
|
||||
/** Distinguishes the initial spinner from the "more entries" spinner. */
|
||||
initialized = $state(false)
|
||||
error = $state<string | null>(null)
|
||||
exhausted = $state(false)
|
||||
|
||||
private loader: Loader<T>
|
||||
private pageSize: number
|
||||
private nextCursor: string | undefined
|
||||
private seen = new Set<string>()
|
||||
private generation = 0
|
||||
|
||||
constructor(loader: Loader<T>, pageSize = 20) {
|
||||
this.loader = loader
|
||||
this.pageSize = pageSize
|
||||
}
|
||||
|
||||
/** Swap in a new loader and reload — used when a route param changes. */
|
||||
setLoader(loader: Loader<T>): void {
|
||||
this.loader = loader
|
||||
void this.reload()
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
this.generation += 1
|
||||
this.nextCursor = undefined
|
||||
this.seen = new Set()
|
||||
this.items = []
|
||||
this.exhausted = false
|
||||
this.initialized = false
|
||||
await this.run(this.generation, true)
|
||||
}
|
||||
|
||||
async loadMore(): Promise<void> {
|
||||
if (this.loading || this.exhausted) return
|
||||
await this.run(this.generation, false)
|
||||
}
|
||||
|
||||
private async run(generation: number, replace: boolean): Promise<void> {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const page = await this.loader({ max_id: replace ? undefined : this.nextCursor, limit: this.pageSize })
|
||||
|
||||
// A newer reload started while this request was in flight; drop it.
|
||||
if (generation !== this.generation) return
|
||||
|
||||
const fresh = page.items.filter((item) => item && !this.seen.has(item.id))
|
||||
for (const item of fresh) this.seen.add(item.id)
|
||||
|
||||
this.items = replace ? fresh : [...this.items, ...fresh]
|
||||
|
||||
const previousCursor = this.nextCursor
|
||||
this.nextCursor = page.links.maxId
|
||||
|
||||
// Stop when the server runs out, or when it hands back the same cursor
|
||||
// (some servers echo the cursor forever on an empty page).
|
||||
if (page.items.length === 0 || !this.nextCursor || this.nextCursor === previousCursor) {
|
||||
this.exhausted = true
|
||||
}
|
||||
} catch (cause) {
|
||||
if (generation !== this.generation) return
|
||||
this.error =
|
||||
cause instanceof ApiError
|
||||
? cause.message
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: 'Something went wrong.'
|
||||
this.exhausted = true
|
||||
} finally {
|
||||
if (generation === this.generation) {
|
||||
this.loading = false
|
||||
this.initialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace one item in place, e.g. after a favourite/repost toggle. */
|
||||
update(id: string, updater: (item: T) => T): void {
|
||||
this.items = this.items.map((item) => (item.id === id ? updater(item) : item))
|
||||
}
|
||||
|
||||
/** Drop an item, e.g. after deleting a post. */
|
||||
remove(id: string): void {
|
||||
this.items = this.items.filter((item) => item.id !== id)
|
||||
this.seen.delete(id)
|
||||
}
|
||||
|
||||
/** Insert at the top, e.g. after composing. */
|
||||
prepend(item: T): void {
|
||||
if (this.seen.has(item.id)) return
|
||||
this.seen.add(item.id)
|
||||
this.items = [item, ...this.items]
|
||||
}
|
||||
|
||||
get isEmpty(): boolean {
|
||||
return this.initialized && this.items.length === 0 && !this.error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* The signed-in session: which server we're pointed at, the token, and the
|
||||
* viewer's own account.
|
||||
*
|
||||
* Browsing logged-out is a first-class mode — you can point plspace at any
|
||||
* public instance and read its local timeline — so `host` is meaningful even
|
||||
* when `token` is null.
|
||||
*/
|
||||
|
||||
import { ApiClient, ApiError, normalizeHost } from '../api/client'
|
||||
import { fetchInstance, verifyCredentials } from '../api/endpoints'
|
||||
import * as oauth from '../api/oauth'
|
||||
import type { CredentialAccount, InstanceInfo } from '../api/types'
|
||||
|
||||
const STORAGE_KEY = 'plspace:session'
|
||||
|
||||
interface PersistedSession {
|
||||
host: string
|
||||
token: string | null
|
||||
}
|
||||
|
||||
function load(): PersistedSession {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { host: '', token: null }
|
||||
const parsed = JSON.parse(raw) as PersistedSession
|
||||
return { host: normalizeHost(parsed.host ?? ''), token: parsed.token ?? null }
|
||||
} catch {
|
||||
return { host: '', token: null }
|
||||
}
|
||||
}
|
||||
|
||||
class Session {
|
||||
host = $state('')
|
||||
token = $state<string | null>(null)
|
||||
me = $state<CredentialAccount | null>(null)
|
||||
instance = $state<InstanceInfo | null>(null)
|
||||
|
||||
/** True until the first `restore()` settles, so routes can hold off. */
|
||||
loading = $state(true)
|
||||
error = $state<string | null>(null)
|
||||
|
||||
/** A client bound to the current host and token. Recomputed on change. */
|
||||
readonly api = $derived(new ApiClient(this.host, this.token))
|
||||
|
||||
readonly signedIn = $derived(Boolean(this.token && this.me))
|
||||
readonly connected = $derived(Boolean(this.host))
|
||||
|
||||
/**
|
||||
* Rehydrate from storage and finish any OAuth redirect. Called once at boot.
|
||||
* Returns the route to land on, if the OAuth flow specified one.
|
||||
*/
|
||||
async restore(): Promise<string | null> {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
let landing: string | null = null
|
||||
|
||||
try {
|
||||
const completed = await oauth.completeLogin()
|
||||
if (completed) {
|
||||
this.host = completed.host
|
||||
this.token = completed.token
|
||||
this.persist()
|
||||
landing = completed.returnTo
|
||||
} else {
|
||||
const stored = load()
|
||||
this.host = stored.host
|
||||
this.token = stored.token
|
||||
}
|
||||
|
||||
if (!this.host) return landing
|
||||
|
||||
// The instance description is cosmetic; never let it block sign-in.
|
||||
void this.loadInstance()
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
this.me = await verifyCredentials(this.api)
|
||||
} catch (cause) {
|
||||
if (cause instanceof ApiError && cause.isAuthFailure) {
|
||||
// Token revoked server-side, or the instance was reinstalled.
|
||||
this.token = null
|
||||
this.me = null
|
||||
this.persist()
|
||||
this.error = 'Your sign-in expired. Please log in again.'
|
||||
} else {
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
this.error = cause instanceof Error ? cause.message : String(cause)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
return landing
|
||||
}
|
||||
|
||||
private async loadInstance(): Promise<void> {
|
||||
try {
|
||||
this.instance = await fetchInstance(new ApiClient(this.host))
|
||||
} catch {
|
||||
this.instance = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Point at a server without signing in. */
|
||||
async connect(host: string): Promise<void> {
|
||||
const normalized = normalizeHost(host)
|
||||
if (!normalized) throw new Error('Enter a server address, for example pleroma.soykaf.com')
|
||||
|
||||
// Probe before committing, so a typo surfaces here rather than on every page.
|
||||
const probe = new ApiClient(normalized)
|
||||
const instance = await fetchInstance(probe)
|
||||
|
||||
this.host = normalized
|
||||
this.token = null
|
||||
this.me = null
|
||||
this.instance = instance
|
||||
this.error = null
|
||||
this.persist()
|
||||
}
|
||||
|
||||
async login(host: string, returnTo = '#/'): Promise<void> {
|
||||
const normalized = normalizeHost(host)
|
||||
if (!normalized) throw new Error('Enter a server address, for example pleroma.soykaf.com')
|
||||
await oauth.beginLogin(normalized, returnTo)
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
const { host, token } = this
|
||||
this.token = null
|
||||
this.me = null
|
||||
this.persist()
|
||||
if (host && token) await oauth.revoke(host, token)
|
||||
}
|
||||
|
||||
/** Forget the server entirely and return to the login screen. */
|
||||
disconnect(): void {
|
||||
void this.logout()
|
||||
this.host = ''
|
||||
this.instance = null
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
const payload: PersistedSession = { host: this.host, token: this.token }
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
||||
}
|
||||
}
|
||||
|
||||
export const session = new Session()
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* User styling.
|
||||
*
|
||||
* The whole point of a MySpace profile was that you could wreck it with your
|
||||
* own CSS. Two independent layers exist here:
|
||||
*
|
||||
* 1. **Viewer CSS** (`#user-stylesheet`) — what *you* set in Settings. Applies
|
||||
* everywhere you browse and is stored locally.
|
||||
* 2. **Profile CSS** (`#profile-stylesheet`) — what the *account being viewed*
|
||||
* publishes, read from a profile field named `css` / `style` / `layout`.
|
||||
* Cleared on navigation so it can never leak onto another page.
|
||||
*
|
||||
* Profile CSS is untrusted third-party input, so it is filtered: no `@import`,
|
||||
* no `url()` pointing anywhere but https/data-images, no escaping the profile
|
||||
* subtree. It is CSS only — there is no path here by which a remote profile can
|
||||
* run script.
|
||||
*/
|
||||
|
||||
const VIEWER_STYLE_ID = 'user-stylesheet'
|
||||
const PROFILE_STYLE_ID = 'profile-stylesheet'
|
||||
const STORAGE_KEY = 'plspace:viewer-css'
|
||||
|
||||
/** Root class the profile page carries; all profile CSS is confined to it. */
|
||||
export const PROFILE_SCOPE = '.profile-page'
|
||||
|
||||
/** Field names checked, in order, for a profile's published stylesheet. */
|
||||
export const CSS_FIELD_NAMES = ['css', 'style', 'layout', 'stylesheet']
|
||||
|
||||
/**
|
||||
* Get (or create) a style element, always moving it to the end of `<head>`.
|
||||
*
|
||||
* The relocation is the important part. The app's own stylesheet is injected
|
||||
* into `<head>` when the bundle loads — after the `<style id="user-stylesheet">`
|
||||
* declared in index.html — so a user rule with the same specificity as a
|
||||
* shipped one would silently lose the tie. Re-appending puts user CSS last in
|
||||
* document order, which is what makes plain single-class overrides work without
|
||||
* anyone reaching for `!important`.
|
||||
*/
|
||||
function styleElement(id: string): HTMLStyleElement {
|
||||
let element = document.getElementById(id) as HTMLStyleElement | null
|
||||
if (!element) {
|
||||
element = document.createElement('style')
|
||||
element.id = id
|
||||
}
|
||||
document.head.append(element)
|
||||
return element
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip constructs a hostile profile could abuse.
|
||||
*
|
||||
* This is defence in depth rather than a sandbox: the browser's own CSS parser
|
||||
* is the real boundary, and CSS cannot execute script in any supported browser.
|
||||
* What it *can* do is phone home via background images and cover the page, so
|
||||
* remote resources and fixed positioning are what get removed.
|
||||
*/
|
||||
export function sanitizeCss(css: string): string {
|
||||
return (
|
||||
css
|
||||
// Comments first, so they can't hide the patterns below.
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
// `@import` would pull in an unbounded, unfiltered stylesheet.
|
||||
.replace(/@import[^;]*;?/gi, '')
|
||||
// Legacy IE vectors, still parsed by nothing but worth removing.
|
||||
.replace(/expression\s*\(/gi, 'void(')
|
||||
.replace(/behaviou?r\s*:/gi, '_behavior:')
|
||||
.replace(/-moz-binding\s*:/gi, '_binding:')
|
||||
// Only allow images from https or inline data URIs.
|
||||
.replace(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi, (whole, _quote: string, url: string) =>
|
||||
/^(https:\/\/|data:image\/)/i.test(url.trim()) ? whole : 'none',
|
||||
)
|
||||
// Keep the page navigable: no viewport-covering overlays.
|
||||
.replace(/position\s*:\s*fixed/gi, 'position: static')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix every selector so the rules cannot reach outside the profile subtree.
|
||||
*
|
||||
* A selector list is anything between a block boundary and the `{` that opens
|
||||
* its body. Matching after `{` as well as after `}` is what catches rules
|
||||
* *nested inside* an at-block — without it, `@media (…) { .site-nav { … } }`
|
||||
* would slip through unscoped and let a profile restyle the whole app. At-rule
|
||||
* preludes themselves are skipped, since `[^{}@]` cannot span the `@`.
|
||||
*/
|
||||
export function scopeCss(css: string, scope: string): string {
|
||||
// `@keyframes` steps are `from`/`to`/`50%`, not selectors — prefixing them
|
||||
// produces a block the parser discards, silently killing every animation.
|
||||
// Lift them out, scope everything else, then put them back.
|
||||
const keyframes: string[] = []
|
||||
const withoutKeyframes = css.replace(
|
||||
/@(?:-\w+-)?keyframes\s+[^{]+\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/gi,
|
||||
(block) => {
|
||||
keyframes.push(block)
|
||||
// The placeholder must end in `}` so the *next* rule still sits on a
|
||||
// block boundary the scoping regex recognises, and must contain `@` so
|
||||
// the placeholder itself is never mistaken for a selector.
|
||||
return `@plspace-keyframes-${keyframes.length - 1}{}`
|
||||
},
|
||||
)
|
||||
|
||||
const scoped = scopeSelectors(withoutKeyframes, scope)
|
||||
|
||||
return scoped.replace(
|
||||
/@plspace-keyframes-(\d+)\{\}/g,
|
||||
(_whole, index: string) => keyframes[Number(index)],
|
||||
)
|
||||
}
|
||||
|
||||
function scopeSelectors(css: string, scope: string): string {
|
||||
return css.replace(/(^|[{}])([^{}@]+)\{/g, (whole, close: string, selectors: string) => {
|
||||
const trimmed = selectors.trim()
|
||||
if (!trimmed) return whole
|
||||
|
||||
const scoped = trimmed
|
||||
.split(',')
|
||||
.map((selector) => {
|
||||
const one = selector.trim()
|
||||
if (!one) return ''
|
||||
// Let authors restyle the page background by writing `body`/`html`.
|
||||
if (/^(html|body)$/i.test(one)) return scope
|
||||
if (one.startsWith(scope)) return one
|
||||
return `${scope} ${one}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
|
||||
return `${close}${scoped}{`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* There is no built-in light/dark switch, on purpose.
|
||||
*
|
||||
* The 2005 palette is the design, and a dark variant is nothing more than a set
|
||||
* of token overrides — which is precisely what a preset already is. Shipping a
|
||||
* hardcoded toggle would have meant one dark theme nobody could edit, sitting
|
||||
* beside a styling system built for exactly this. Dark mode is the Midnight
|
||||
* preset in lib/themes.ts; users can edit it or write their own.
|
||||
*
|
||||
* A preset that wants native form controls to follow suit can say
|
||||
* `:root { color-scheme: dark }` in its own CSS — viewer CSS is unscoped.
|
||||
*/
|
||||
class Theme {
|
||||
/** CSS the viewer wrote for themselves. */
|
||||
viewerCss = $state('')
|
||||
/** Whether to honour CSS published by the profiles you visit. */
|
||||
allowProfileCss = $state(true)
|
||||
|
||||
constructor() {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
this.viewerCss = localStorage.getItem(STORAGE_KEY) ?? ''
|
||||
this.allowProfileCss = localStorage.getItem('plspace:allow-profile-css') !== 'false'
|
||||
if (this.viewerCss) this.applyViewerCss()
|
||||
}
|
||||
|
||||
setViewerCss(css: string): void {
|
||||
this.viewerCss = css
|
||||
localStorage.setItem(STORAGE_KEY, css)
|
||||
this.applyViewerCss()
|
||||
}
|
||||
|
||||
setAllowProfileCss(allow: boolean): void {
|
||||
this.allowProfileCss = allow
|
||||
localStorage.setItem('plspace:allow-profile-css', String(allow))
|
||||
if (!allow) this.clearProfileCss()
|
||||
}
|
||||
|
||||
private applyViewerCss(): void {
|
||||
// Not scoped: this is the viewer's own machine and their own choice.
|
||||
styleElement(VIEWER_STYLE_ID).textContent = sanitizeCss(this.viewerCss)
|
||||
}
|
||||
|
||||
/** Apply CSS published by the profile currently on screen. */
|
||||
applyProfileCss(css: string | null | undefined): void {
|
||||
if (!css || !this.allowProfileCss) {
|
||||
this.clearProfileCss()
|
||||
return
|
||||
}
|
||||
styleElement(PROFILE_STYLE_ID).textContent = scopeCss(sanitizeCss(css), PROFILE_SCOPE)
|
||||
// Keep the viewer's own sheet last: their machine, their final say.
|
||||
if (this.viewerCss) styleElement(VIEWER_STYLE_ID)
|
||||
}
|
||||
|
||||
clearProfileCss(): void {
|
||||
const element = document.getElementById(PROFILE_STYLE_ID)
|
||||
if (element) element.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
export const theme = new Theme()
|
||||
|
||||
/** Pull a published stylesheet out of an account's profile fields. */
|
||||
export function profileCssFromFields(
|
||||
fields: Array<{ name: string; value: string }> | undefined,
|
||||
): string | null {
|
||||
if (!fields?.length) return null
|
||||
for (const field of fields) {
|
||||
if (!CSS_FIELD_NAMES.includes(field.name.trim().toLowerCase())) continue
|
||||
// Field values arrive as HTML; take the text and undo entity escaping.
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = field.value
|
||||
const text = (container.textContent ?? '').trim()
|
||||
if (text.includes('{')) return text
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Starter layouts.
|
||||
*
|
||||
* Every preset is written only in terms of the tokens from styles/tokens.css —
|
||||
* no element selectors, no !important — which is both the point (they're
|
||||
* examples of the intended override style) and the reason they compose with
|
||||
* whatever else a user writes underneath.
|
||||
*/
|
||||
|
||||
export interface ThemePreset {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
css: string
|
||||
}
|
||||
|
||||
export const PRESETS: ThemePreset[] = [
|
||||
{
|
||||
id: 'classic',
|
||||
name: 'Classic Blue',
|
||||
description: 'The default. Navy chrome, cornflower boxes, peach bands.',
|
||||
css: '',
|
||||
},
|
||||
{
|
||||
id: 'midnight',
|
||||
name: 'Midnight',
|
||||
description: 'Dark background, cyan links, the 2 a.m. profile edit.',
|
||||
css: `:root {
|
||||
/* Native form controls and scrollbars follow the palette. */
|
||||
color-scheme: dark;
|
||||
|
||||
--ms-page-bg: #0e0e14;
|
||||
--ms-page-fg: #d8d8e0;
|
||||
--ms-canvas-bg: #06060a;
|
||||
--ms-link: #58d7ff;
|
||||
--ms-link-visited: #b39ddb;
|
||||
--ms-link-hover: #9beaff;
|
||||
--ms-chrome-bg: #12121c;
|
||||
--ms-nav-bg: #1c1c2c;
|
||||
--ms-nav-active-bg: #58d7ff;
|
||||
--ms-nav-active-fg: #06060a;
|
||||
--ms-module-bg: #14141e;
|
||||
--ms-module-border: #2e2e44;
|
||||
--ms-module-header-bg: #1c1c2c;
|
||||
--ms-module-header-fg: #58d7ff;
|
||||
--ms-band-bg: #1c1c2c;
|
||||
--ms-band-fg: #58d7ff;
|
||||
--ms-band-border: #2e2e44;
|
||||
--ms-heading-fg: #58d7ff;
|
||||
--ms-table-label-bg: #1a1a26;
|
||||
--ms-table-label-fg: #a9a9c0;
|
||||
--ms-table-value-bg: #14141e;
|
||||
--ms-table-value-fg: #d8d8e0;
|
||||
--ms-table-stripe-bg: #11111a;
|
||||
--ms-input-bg: #0a0a10;
|
||||
--ms-input-fg: #d8d8e0;
|
||||
--ms-input-border: #2e2e44;
|
||||
--ms-button-bg: linear-gradient(#24243a, #16161f);
|
||||
--ms-button-fg: #d8d8e0;
|
||||
--ms-button-border: #2e2e44;
|
||||
--ms-muted-fg: #7c7c96;
|
||||
--ms-hr-color: #24243a;
|
||||
--ms-avatar-border: #2e2e44;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: 'bubblegum',
|
||||
name: 'Bubblegum',
|
||||
description: 'Hot pink, Comic Sans, rounded corners. No apologies.',
|
||||
css: `:root {
|
||||
--ms-font-family: 'Comic Sans MS', 'Comic Neue', Verdana, sans-serif;
|
||||
--ms-font-size: 12px;
|
||||
--ms-font-size-content: 13px;
|
||||
--ms-page-bg: #fff4fb;
|
||||
--ms-canvas-bg: #ffd9f0;
|
||||
--ms-page-fg: #43102f;
|
||||
--ms-link: #d6006e;
|
||||
--ms-link-visited: #a3005c;
|
||||
--ms-link-hover: #ff2e9a;
|
||||
--ms-chrome-bg: #ff2e9a;
|
||||
--ms-nav-bg: #ff85c2;
|
||||
--ms-nav-active-bg: #ffd400;
|
||||
--ms-nav-active-fg: #43102f;
|
||||
--ms-module-bg: #fffafd;
|
||||
--ms-module-border: #ff85c2;
|
||||
--ms-module-header-bg: #ff2e9a;
|
||||
--ms-module-radius: 10px;
|
||||
--ms-band-bg: #ffe27a;
|
||||
--ms-band-fg: #b3005e;
|
||||
--ms-band-border: #ffb800;
|
||||
--ms-heading-fg: #d6006e;
|
||||
--ms-table-label-bg: #ffd9f0;
|
||||
--ms-table-value-bg: #fffafd;
|
||||
--ms-table-stripe-bg: #fff0f8;
|
||||
--ms-avatar-radius: 8px;
|
||||
--ms-avatar-border: #ff85c2;
|
||||
--ms-button-bg: linear-gradient(#fff, #ffd9f0);
|
||||
--ms-button-border: #ff85c2;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
name: 'Terminal',
|
||||
description: 'Green on black, monospace, no decoration.',
|
||||
css: `:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--ms-font-family: 'Courier New', Courier, monospace;
|
||||
--ms-font-family-heading: 'Courier New', Courier, monospace;
|
||||
--ms-font-size: 13px;
|
||||
--ms-font-size-content: 13px;
|
||||
--ms-page-bg: #000000;
|
||||
--ms-canvas-bg: #000000;
|
||||
--ms-page-fg: #33ff66;
|
||||
--ms-link: #99ff99;
|
||||
--ms-link-visited: #66cc66;
|
||||
--ms-link-hover: #ffffff;
|
||||
--ms-link-decoration: underline;
|
||||
--ms-chrome-bg: #001a00;
|
||||
--ms-chrome-fg: #33ff66;
|
||||
--ms-nav-bg: #002600;
|
||||
--ms-nav-active-bg: #33ff66;
|
||||
--ms-nav-active-fg: #000000;
|
||||
--ms-module-bg: #000000;
|
||||
--ms-module-border: #1f7a33;
|
||||
--ms-module-header-bg: #001a00;
|
||||
--ms-module-header-fg: #33ff66;
|
||||
--ms-band-bg: #001a00;
|
||||
--ms-band-fg: #99ff99;
|
||||
--ms-band-border: #1f7a33;
|
||||
--ms-heading-fg: #99ff99;
|
||||
--ms-table-label-bg: #001a00;
|
||||
--ms-table-label-fg: #33ff66;
|
||||
--ms-table-value-bg: #000000;
|
||||
--ms-table-value-fg: #33ff66;
|
||||
--ms-table-stripe-bg: #000d00;
|
||||
--ms-table-border: #1f7a33;
|
||||
--ms-input-bg: #000000;
|
||||
--ms-input-fg: #33ff66;
|
||||
--ms-input-border: #1f7a33;
|
||||
--ms-button-bg: #001a00;
|
||||
--ms-button-fg: #33ff66;
|
||||
--ms-button-border: #1f7a33;
|
||||
--ms-muted-fg: #1f7a33;
|
||||
--ms-avatar-border: #1f7a33;
|
||||
--ms-hr-color: #1f7a33;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
name: 'Sunset',
|
||||
description: 'Warm oranges and browns, wider text, gentler on the eyes.',
|
||||
css: `:root {
|
||||
--ms-font-size: 12px;
|
||||
--ms-font-size-content: 14px;
|
||||
--ms-line-height: 1.55;
|
||||
--ms-page-bg: #fffaf3;
|
||||
--ms-canvas-bg: #f0dcc4;
|
||||
--ms-page-fg: #3a2a1c;
|
||||
--ms-link: #b5451b;
|
||||
--ms-link-visited: #8a3714;
|
||||
--ms-link-hover: #e0642f;
|
||||
--ms-chrome-bg: #7a3410;
|
||||
--ms-nav-bg: #b5451b;
|
||||
--ms-nav-active-bg: #ffb845;
|
||||
--ms-nav-active-fg: #3a2a1c;
|
||||
--ms-module-bg: #fffaf3;
|
||||
--ms-module-border: #dcae7a;
|
||||
--ms-module-header-bg: #c96a2c;
|
||||
--ms-band-bg: #ffe0b8;
|
||||
--ms-band-fg: #a8410f;
|
||||
--ms-band-border: #dcae7a;
|
||||
--ms-heading-fg: #b5451b;
|
||||
--ms-table-label-bg: #f5e2cb;
|
||||
--ms-table-value-bg: #fffaf3;
|
||||
--ms-table-stripe-bg: #fdf2e5;
|
||||
--ms-table-border: #dcae7a;
|
||||
--ms-avatar-border: #dcae7a;
|
||||
--ms-hr-color: #e6cfae;
|
||||
--ms-page-width-wide: 900px;
|
||||
}`,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* A worked example, shown in Settings, that goes past recolouring: it shows
|
||||
* the class hooks and the data attributes rather than just the token layer.
|
||||
*/
|
||||
export const EXAMPLE_CSS = `/* Restyle just your own blog entries */
|
||||
.blog-entry[data-mine='true'] {
|
||||
background: #fffbe6;
|
||||
border-left: 4px solid #ffb845;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* Make private entries obvious */
|
||||
.blog-entry[data-visibility='private'] {
|
||||
background: #fff0f0;
|
||||
}
|
||||
|
||||
/* Round the friend-space photos */
|
||||
.friend-card-photo {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* A background image behind the whole page */
|
||||
.profile-page {
|
||||
background-image: url(https://example.com/stars.png);
|
||||
background-attachment: fixed;
|
||||
}`
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Turning server HTML into something safe to hand to `{@html}`.
|
||||
*
|
||||
* Status content, account bios and profile field values all arrive as HTML from
|
||||
* arbitrary federated servers, so every one of them goes through DOMPurify
|
||||
* before rendering. On top of sanitizing we rewrite links so mentions and
|
||||
* hashtags navigate inside the app instead of bouncing the user to the remote
|
||||
* web UI.
|
||||
*/
|
||||
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { CustomEmoji, StatusMention, StatusTag } from '../api/types'
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
'p',
|
||||
'br',
|
||||
'span',
|
||||
'a',
|
||||
'del',
|
||||
'pre',
|
||||
'code',
|
||||
'em',
|
||||
'strong',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
'sub',
|
||||
'sup',
|
||||
'blockquote',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'ruby',
|
||||
'rt',
|
||||
'rp',
|
||||
'img',
|
||||
]
|
||||
|
||||
const ALLOWED_ATTR = ['href', 'rel', 'class', 'title', 'lang', 'src', 'alt', 'draggable', 'data-plspace-to']
|
||||
|
||||
/**
|
||||
* Rewrite outbound anchors:
|
||||
* - mentions/hashtags we can resolve locally become in-app hash routes
|
||||
* - everything else keeps its href but gains `target`/`rel` hardening
|
||||
*/
|
||||
function installHooks(): void {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return
|
||||
|
||||
const classes = node.getAttribute('class') ?? ''
|
||||
const href = node.getAttribute('href') ?? ''
|
||||
|
||||
if (classes.includes('mention') || classes.includes('hashtag') || href.startsWith('#/')) {
|
||||
// Left as a same-document link; the router picks it up.
|
||||
node.removeAttribute('target')
|
||||
node.setAttribute('rel', 'nofollow noopener')
|
||||
return
|
||||
}
|
||||
|
||||
if (href) {
|
||||
node.setAttribute('target', '_blank')
|
||||
node.setAttribute('rel', 'nofollow noopener noreferrer')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
installHooks()
|
||||
|
||||
export interface RenderOptions {
|
||||
mentions?: StatusMention[]
|
||||
tags?: StatusTag[]
|
||||
emojis?: CustomEmoji[]
|
||||
/** Strip block structure down to a single line (used in previews). */
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
/** Escape for interpolation into an HTML string we build ourselves. */
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace `:shortcode:` with the instance's custom emoji images.
|
||||
*
|
||||
* Runs on the *sanitized* string and only injects `<img>` with a URL taken from
|
||||
* the emoji list, so it cannot reintroduce markup from the original content.
|
||||
*/
|
||||
function applyEmojis(html: string, emojis: CustomEmoji[] | undefined): string {
|
||||
if (!emojis?.length) return html
|
||||
const table = new Map(emojis.map((emoji) => [emoji.shortcode, emoji]))
|
||||
|
||||
// Skip anything inside a tag by only matching between '>' boundaries.
|
||||
return html.replace(/(^|>)([^<]*)/g, (_match, boundary: string, text: string) => {
|
||||
const replaced = text.replace(/:([a-zA-Z0-9_+-]+):/g, (whole, shortcode: string) => {
|
||||
const emoji = table.get(shortcode)
|
||||
if (!emoji) return whole
|
||||
return `<img class="custom-emoji" src="${escapeHtml(emoji.url)}" alt=":${escapeHtml(
|
||||
shortcode,
|
||||
)}:" title=":${escapeHtml(shortcode)}:" draggable="false" />`
|
||||
})
|
||||
return boundary + replaced
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Point mention/hashtag anchors at our own routes.
|
||||
*
|
||||
* Mastodon marks mentions with `class="u-url mention"` and gives the matching
|
||||
* `mentions[]` entry, but the anchor text is only `@user` (no domain), so the
|
||||
* href is matched against the mention list instead of the label.
|
||||
*/
|
||||
function rewriteLinks(html: string, options: RenderOptions): string {
|
||||
if (!options.mentions?.length && !options.tags?.length) return html
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = html
|
||||
|
||||
for (const anchor of Array.from(container.querySelectorAll('a'))) {
|
||||
const href = anchor.getAttribute('href') ?? ''
|
||||
const classes = anchor.getAttribute('class') ?? ''
|
||||
|
||||
const mention = options.mentions?.find((entry) => entry.url === href || href.endsWith(`/@${entry.username}`))
|
||||
if (mention) {
|
||||
anchor.setAttribute('href', `#/@${mention.acct}`)
|
||||
anchor.setAttribute('class', `${classes} mention`.trim())
|
||||
anchor.removeAttribute('target')
|
||||
continue
|
||||
}
|
||||
|
||||
if (classes.includes('hashtag') || /\/tags?\//.test(href)) {
|
||||
const name = (anchor.textContent ?? '').replace(/^#/, '').trim()
|
||||
if (name) {
|
||||
anchor.setAttribute('href', `#/tag/${encodeURIComponent(name)}`)
|
||||
anchor.setAttribute('class', `${classes} hashtag`.trim())
|
||||
anchor.removeAttribute('target')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return container.innerHTML
|
||||
}
|
||||
|
||||
/** Sanitize + emojify + relink server HTML. Always use this before `{@html}`. */
|
||||
export function renderHtml(source: string | null | undefined, options: RenderOptions = {}): string {
|
||||
if (!source) return ''
|
||||
|
||||
let html = DOMPurify.sanitize(source, {
|
||||
ALLOWED_TAGS: options.inline ? ALLOWED_TAGS.filter((tag) => tag !== 'img') : ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
// Blocks `javascript:` and friends without also killing `#/` routes.
|
||||
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|xmpp:|#|\/)/i,
|
||||
})
|
||||
|
||||
html = rewriteLinks(html, options)
|
||||
html = applyEmojis(html, options.emojis)
|
||||
|
||||
if (options.inline) {
|
||||
html = html
|
||||
.replace(/<\/p>\s*<p>/g, ' ')
|
||||
.replace(/<\/?p>/g, '')
|
||||
.replace(/<br\s*\/?>/g, ' ')
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text projection, for teasers, subjects and `document.title`.
|
||||
*
|
||||
* Block boundaries become spaces first: stripping tags outright would weld
|
||||
* `<p>RE: https://…</p><p>Congratulations…` into one unreadable run, which is
|
||||
* exactly the shape a Mastodon quote-post takes.
|
||||
*/
|
||||
export function toPlainText(source: string | null | undefined): string {
|
||||
if (!source) return ''
|
||||
const spaced = source.replace(/<(?:br|\/p|\/div|\/li|\/h[1-6]|\/blockquote|\/pre)\s*\/?>/gi, ' $& ')
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = DOMPurify.sanitize(spaced, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] })
|
||||
return (container.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Emoji-substituted display name, safe for `{@html}`. */
|
||||
export function renderDisplayName(name: string, emojis: CustomEmoji[] | undefined): string {
|
||||
return applyEmojis(escapeHtml(name), emojis)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Mapping ActivityPub accounts onto a 2005 profile page.
|
||||
*
|
||||
* A MySpace profile had a fixed vocabulary — a headline in quotes, a mood, an
|
||||
* "Interests" table with General/Music/Movies/Television/Books/Heroes rows, and
|
||||
* blurbs for "About me" and "Who I'd like to meet". Mastodon has none of that;
|
||||
* it has a bio and up to four free-form key/value fields.
|
||||
*
|
||||
* The mapping is: profile fields whose name matches a known MySpace row fill
|
||||
* that row, leftover fields land in a generic details table, and the bio is
|
||||
* split into the two blurbs on a "who I'd like to meet"-ish heading if the user
|
||||
* wrote one. Users opt in to the richer layout simply by naming their fields
|
||||
* `Music`, `Movies`, `Mood` and so on.
|
||||
*/
|
||||
|
||||
import type { Account } from '../api/types'
|
||||
import { renderHtml, toPlainText } from './html'
|
||||
|
||||
/** The interest rows a MySpace profile shipped with, in their original order. */
|
||||
export const INTEREST_ROWS = ['General', 'Music', 'Movies', 'Television', 'Books', 'Heroes'] as const
|
||||
|
||||
export type InterestRow = (typeof INTEREST_ROWS)[number]
|
||||
|
||||
/** Field names that feed the chrome rather than a table row. */
|
||||
const CHROME_FIELDS = new Set(['headline', 'mood', 'status', 'location', 'city', 'gender', 'pronouns', 'age'])
|
||||
|
||||
const ALIASES: Record<string, InterestRow> = {
|
||||
general: 'General',
|
||||
interests: 'General',
|
||||
about: 'General',
|
||||
music: 'Music',
|
||||
bands: 'Music',
|
||||
'now playing': 'Music',
|
||||
movies: 'Movies',
|
||||
film: 'Movies',
|
||||
films: 'Movies',
|
||||
television: 'Television',
|
||||
tv: 'Television',
|
||||
shows: 'Television',
|
||||
books: 'Books',
|
||||
reading: 'Books',
|
||||
heroes: 'Heroes',
|
||||
hero: 'Heroes',
|
||||
inspiration: 'Heroes',
|
||||
}
|
||||
|
||||
export interface ProfileField {
|
||||
name: string
|
||||
/** Sanitized HTML. */
|
||||
value: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export interface InterestEntry {
|
||||
row: InterestRow
|
||||
/** Sanitized HTML. */
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ProfileView {
|
||||
account: Account
|
||||
/** The quoted line beside the photo. */
|
||||
headline: string
|
||||
mood: string | null
|
||||
location: string | null
|
||||
gender: string | null
|
||||
/** Age in years, from the account creation date unless a field overrides it. */
|
||||
age: number | null
|
||||
/** "About me" blurb, sanitized HTML. */
|
||||
about: string
|
||||
/** "Who I'd like to meet" blurb, sanitized HTML. Empty when the user wrote none. */
|
||||
wantsToMeet: string
|
||||
interests: InterestEntry[]
|
||||
details: ProfileField[]
|
||||
}
|
||||
|
||||
/** Split a bio on a "who I'd like to meet" style heading, if one exists. */
|
||||
function splitBio(noteHtml: string): { about: string; meet: string } {
|
||||
const marker = /(?:^|\n|<br\s*\/?>|<\/p>\s*<p>)\s*(?:who\s+i(?:'|’)?d\s+like\s+to\s+meet|looking\s+for)\s*:?/i
|
||||
const match = marker.exec(noteHtml)
|
||||
if (!match || match.index === undefined) return { about: noteHtml, meet: '' }
|
||||
return {
|
||||
about: noteHtml.slice(0, match.index),
|
||||
meet: noteHtml.slice(match.index + match[0].length),
|
||||
}
|
||||
}
|
||||
|
||||
function findField(account: Account, names: string[]): string | null {
|
||||
for (const field of account.fields ?? []) {
|
||||
if (names.includes(field.name.trim().toLowerCase())) {
|
||||
const text = toPlainText(field.value)
|
||||
if (text) return text
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable, silly mood per account — MySpace always showed one, and an empty
|
||||
* "Mood:" line reads as a bug. Derived from the account id so it doesn't
|
||||
* flicker between renders.
|
||||
*/
|
||||
const MOODS = [
|
||||
'busy :-)',
|
||||
'productive :)',
|
||||
'awake',
|
||||
'jubilant',
|
||||
'chill',
|
||||
'working',
|
||||
'contemplative',
|
||||
'amused ;)',
|
||||
'nostalgic',
|
||||
'bouncy',
|
||||
'sleepy',
|
||||
'accomplished',
|
||||
]
|
||||
|
||||
export function fallbackMood(seed: string): string {
|
||||
let hash = 0
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0
|
||||
}
|
||||
return MOODS[hash % MOODS.length]
|
||||
}
|
||||
|
||||
export function buildProfileView(account: Account): ProfileView {
|
||||
const noteHtml = renderHtml(account.note, { emojis: account.emojis })
|
||||
const { about, meet } = splitBio(noteHtml)
|
||||
|
||||
const interests: InterestEntry[] = []
|
||||
const details: ProfileField[] = []
|
||||
|
||||
for (const field of account.fields ?? []) {
|
||||
const key = field.name.trim().toLowerCase()
|
||||
if (CHROME_FIELDS.has(key)) continue
|
||||
|
||||
const value = renderHtml(field.value, { emojis: account.emojis })
|
||||
const row = ALIASES[key]
|
||||
if (row) {
|
||||
interests.push({ row, value })
|
||||
} else {
|
||||
details.push({
|
||||
name: field.name,
|
||||
value,
|
||||
verified: Boolean(field.verified_at),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the canonical MySpace ordering rather than the user's field order.
|
||||
interests.sort((a, b) => INTEREST_ROWS.indexOf(a.row) - INTEREST_ROWS.indexOf(b.row))
|
||||
|
||||
const headlineField = findField(account, ['headline', 'status'])
|
||||
const headline = headlineField ?? firstSentence(toPlainText(account.note)) ?? '"..."'
|
||||
|
||||
const ageField = findField(account, ['age'])
|
||||
const parsedAge = ageField ? Number.parseInt(ageField, 10) : Number.NaN
|
||||
|
||||
return {
|
||||
account,
|
||||
headline,
|
||||
mood: findField(account, ['mood']) ?? fallbackMood(account.id || account.acct),
|
||||
location: findField(account, ['location', 'city']),
|
||||
gender: findField(account, ['gender', 'pronouns']),
|
||||
age: Number.isFinite(parsedAge) ? parsedAge : null,
|
||||
about,
|
||||
wantsToMeet: meet,
|
||||
interests,
|
||||
details,
|
||||
}
|
||||
}
|
||||
|
||||
function firstSentence(text: string): string | null {
|
||||
if (!text) return null
|
||||
const match = /^.{0,120}?[.!?](?:\s|$)/.exec(text)
|
||||
const sentence = (match?.[0] ?? text.slice(0, 120)).trim()
|
||||
return sentence || null
|
||||
}
|
||||
|
||||
/** `15,672,442` — friend counts were the whole point. */
|
||||
export function formatCount(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || value < 0) return '0'
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
/** `@user@host`, always with the domain so remote accounts are unambiguous. */
|
||||
export function fullHandle(account: Account, localHost: string): string {
|
||||
return account.acct.includes('@') ? `@${account.acct}` : `@${account.acct}@${localHost}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account's follower list is withheld.
|
||||
*
|
||||
* Mastodon signals this with `hide_collections`; Pleroma and Akkoma use
|
||||
* `pleroma.hide_followers` instead. Both then return an empty list, which is
|
||||
* indistinguishable from having no followers unless you check the flag.
|
||||
*/
|
||||
export function followersHidden(account: Account): boolean {
|
||||
return Boolean(account.hide_collections || account.pleroma?.hide_followers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the follower *count* is withheld.
|
||||
*
|
||||
* Pleroma reports `followers_count: 0` when `hide_followers_count` is set, so
|
||||
* rendering that zero would state a privacy setting as fact.
|
||||
*/
|
||||
export function followerCountHidden(account: Account): boolean {
|
||||
return Boolean(account.pleroma?.hide_followers_count)
|
||||
}
|
||||
|
||||
/** As above, for the accounts someone follows. */
|
||||
export function followingCountHidden(account: Account): boolean {
|
||||
return Boolean(account.pleroma?.hide_follows_count)
|
||||
}
|
||||
|
||||
/** The route this app uses for an account. */
|
||||
export function profilePath(account: Account): string {
|
||||
return `#/@${account.acct}`
|
||||
}
|
||||
|
||||
export function displayNameOf(account: Account): string {
|
||||
return account.display_name?.trim() || account.username
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Date formatting in the vernacular of a 2005 profile page.
|
||||
*
|
||||
* MySpace showed `Last Login: 05/10/2005` and posted entries as
|
||||
* `Wednesday, September 12, 2007` — no relative timestamps, no tooltips. The
|
||||
* relative helper exists anyway because a federated timeline is unreadable
|
||||
* without one.
|
||||
*/
|
||||
|
||||
const MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
]
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
function toDate(value: string | Date | null | undefined): Date | null {
|
||||
if (!value) return null
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** `05/10/2005` — the "Last Login" format. */
|
||||
export function shortDate(value: string | Date | null | undefined): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return '--/--/----'
|
||||
return `${pad(date.getMonth() + 1)}/${pad(date.getDate())}/${date.getFullYear()}`
|
||||
}
|
||||
|
||||
/** `Wednesday, September 12, 2007` — the blog-entry heading format. */
|
||||
export function longDate(value: string | Date | null | undefined): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return ''
|
||||
return `${DAYS[date.getDay()]}, ${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`
|
||||
}
|
||||
|
||||
/** `Sep 12, 2007 1:30 PM` — comment and bulletin stamps. */
|
||||
export function stampDate(value: string | Date | null | undefined): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return ''
|
||||
const hours = date.getHours()
|
||||
const hour12 = hours % 12 === 0 ? 12 : hours % 12
|
||||
const meridiem = hours < 12 ? 'AM' : 'PM'
|
||||
return `${MONTHS[date.getMonth()].slice(0, 3)} ${date.getDate()}, ${date.getFullYear()} ${hour12}:${pad(
|
||||
date.getMinutes(),
|
||||
)} ${meridiem}`
|
||||
}
|
||||
|
||||
/** `4 months ago` — used for "Last active". */
|
||||
export function relativeTime(value: string | Date | null | undefined, now: Date = new Date()): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return 'a while ago'
|
||||
|
||||
const seconds = Math.round((now.getTime() - date.getTime()) / 1000)
|
||||
if (seconds < 45) return 'just now'
|
||||
|
||||
const units: Array<[label: string, seconds: number]> = [
|
||||
['year', 31_536_000],
|
||||
['month', 2_592_000],
|
||||
['week', 604_800],
|
||||
['day', 86_400],
|
||||
['hour', 3_600],
|
||||
['minute', 60],
|
||||
]
|
||||
|
||||
for (const [label, size] of units) {
|
||||
const count = Math.floor(seconds / size)
|
||||
if (count >= 1) return `${count} ${label}${count === 1 ? '' : 's'} ago`
|
||||
}
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
/** Whole-year age from a date, or null when it isn't parseable. */
|
||||
export function yearsSince(value: string | Date | null | undefined, now: Date = new Date()): number | null {
|
||||
const date = toDate(value)
|
||||
if (!date) return null
|
||||
let years = now.getFullYear() - date.getFullYear()
|
||||
const monthDelta = now.getMonth() - date.getMonth()
|
||||
if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < date.getDate())) years -= 1
|
||||
return years < 0 ? 0 : years
|
||||
}
|
||||
|
||||
/** Machine-readable value for `<time datetime>`. */
|
||||
export function isoDate(value: string | Date | null | undefined): string {
|
||||
return toDate(value)?.toISOString() ?? ''
|
||||
}
|
||||
Reference in New Issue
Block a user