Something went wrong. Try again.
fork of ozone with nix stuff
Something went wrong. Try again.
TypeScript
at customized
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169import { TANGLED_API_URL, TANGLED_APP_URL } from './constants'import { resolveDidDocData } from './identity'
/** * The `sh.tangled.actor.profile` record, as returned inside the `value` of * `sh.tangled.actor.getProfile`. Mirrors the lexicon in tangled's * `web/src/lib/api/lexicons/types/sh/tangled/actor/profile.ts`; every field * except `bluesky` is optional there. */export type TangledProfileRecord = { $type?: string avatar?: { $type?: string ref?: { $link?: string } mimeType?: string size?: number } bluesky?: boolean description?: string links?: string[] location?: string pinnedRepositories?: string[] preferredHandle?: string pronouns?: string stats?: string[] createdAt?: string}
export type TangledProfile = { uri: string cid?: string /** Blob CID of the avatar, if the record carries one. */ avatarCid?: string description?: string links: string[] location?: string preferredHandle?: string pronouns?: string /** * Which counters the account wants shown on its tangled profile page. The * record only names them -- the values live behind separate appview queries -- * so the card does not render them. */ stats: string[] /** Whether the account opted into showing a Bluesky link. */ bluesky: boolean createdAt?: string}
/** * The appview happily round-trips the optional fields as empty strings (and * `links`/`stats` as arrays *of* empty strings) rather than omitting them, so * every value has to be treated as absent-when-blank or the card renders a row * of empty chips. */const text = (value: unknown): string | undefined => { if (typeof value !== 'string') return undefined const trimmed = value.trim() return trimmed.length ? trimmed : undefined}
const textList = (value: unknown): string[] => Array.isArray(value) ? value.map(text).filter((v): v is string => v != null) : []
export const profileUriForDid = (did: string) => `at://${did}/sh.tangled.actor.profile/self`
/** Public tangled page for an account, by handle when we have one. */export const tangledProfileUrl = (handleOrDid: string) => `${TANGLED_APP_URL}/${handleOrDid}`
/** * Why a profile is missing. Accounts that have never used tangled have no * `sh.tangled.actor.profile` record, and the appview reports that as a *502* * `UpstreamFailed` (it proxies the record fetch and surfaces the upstream's * 400/500 verbatim) rather than a 404. That is indistinguishable from the * upstream genuinely being down, so both land on `unavailable` and the card * words itself carefully instead of claiming the account has no profile. */export type TangledProfileMiss = 'absent' | 'unavailable'
export type TangledProfileResult = | { profile: TangledProfile; miss?: undefined } | { profile: null; miss: TangledProfileMiss }
export async function getTangledProfile( did: string, signal?: AbortSignal,): Promise<TangledProfileResult> { const url = new URL('/xrpc/sh.tangled.actor.getProfile', TANGLED_API_URL) url.searchParams.set('actor', profileUriForDid(did))
const res = await fetch(url, { signal, headers: { accept: 'application/json' }, })
if (!res.ok) { // Read the XRPC error name where there is one; a bare status is all we get // from an intermediary. const error = await res .json() .then((body) => (typeof body?.error === 'string' ? body.error : undefined)) .catch(() => undefined)
if (res.status === 400 || res.status === 404) { return { profile: null, miss: 'absent' } } if (error === 'UpstreamFailed' || error === 'RecordNotFound') { return { profile: null, miss: 'unavailable' } } throw new Error( `Could not load tangled profile (HTTP ${res.status}${ error ? `: ${error}` : '' })`, ) }
const body = (await res.json()) as { uri?: string cid?: string value?: TangledProfileRecord } const record = body?.value if (!record) return { profile: null, miss: 'absent' }
const profile: TangledProfile = { uri: body.uri || profileUriForDid(did), cid: body.cid, avatarCid: text(record.avatar?.ref?.$link), description: text(record.description), links: textList(record.links), location: text(record.location), preferredHandle: text(record.preferredHandle), pronouns: text(record.pronouns), stats: textList(record.stats), bluesky: record.bluesky === true, createdAt: text(record.createdAt), } return { profile }}
/** * Avatar blobs are served from the account's own PDS. tangled's web app instead * points at its resizing avatar service, but those URLs are HMAC-signed with a * server-side secret Ozone does not hold, so we read the blob directly and let * the layout constrain the rendered size. */export async function getTangledAvatarUrl( did: string, avatarCid: string, signal?: AbortSignal,): Promise<string | null> { const doc = await resolveDidDocData(did, signal) const pds = doc?.services?.['atproto_pds'] // did:plc `/data` spells this `endpoint`; a full did document uses // `serviceEndpoint`. resolveDidDocData can return either shape. const endpoint = pds?.serviceEndpoint || pds?.endpoint if (!endpoint) return null
const url = new URL('/xrpc/com.atproto.sync.getBlob', endpoint) url.searchParams.set('did', did) url.searchParams.set('cid', avatarCid) return url.toString()}