/** * The public half of an account: the profile record every ATProto account * keeps, and the picture in it. * * Read straight from the account's own PDS, like everything else this site * only reads (pds.ts). Not from an appview: it only knows accounts it has * indexed, and its CDN sends no `Access-Control-Allow-Origin` at all — which * the camo editor cannot live with, because it draws the avatar into a canvas * and reads the pixels back out. A blob from the PDS does send the header, and * it is the account's own copy of the picture. */ import { blobUrl, resolvePds } from "./pds"; /** * The two fields naming an account that this module needs. * * A `Session` is one of these and so is identity.ts's `Actor`: the signed-in * account and a player whose page is being read want the same name and the * same letter, and neither of them wants the rest of a session. */ export interface Named { readonly did: string; readonly handle: string | null; } /** * What to call an account on screen. * * A null handle means the handle in the DID document did not resolve back to * this DID, so it is not a name we are willing to put in front of anyone. The * DID is not friendly, but it is the account, and it is always true. */ export function accountName(session: Named): string { return session.handle ? `@${session.handle}` : session.did; } /** The one letter an account stands behind until its picture has loaded. */ export function monogram(session: Named): string { const source = session.handle ?? session.did.replace(/^did:[a-z]+:/, ""); const letter = [...source].find((character) => /[a-z0-9]/i.test(character)); return (letter ?? "?").toUpperCase(); } /** The collection and rkey a profile is always at. */ const COLLECTION = "app.bsky.actor.profile"; const RKEY = "self"; /** * The avatar's CID, or null where the account has no picture. * * A modern blob carries `ref.$link`; ones written before the blob format * settled carry a bare `cid`. Both are still sitting in live repositories, so * both are read. Everything here comes off the network, so nothing is assumed * about its shape. */ export function avatarCid(value: unknown): string | null { if (typeof value !== "object" || value === null) return null; const avatar = (value as { avatar?: unknown }).avatar; if (typeof avatar !== "object" || avatar === null) return null; const ref = (avatar as { ref?: { $link?: unknown } }).ref; const cid = ref?.$link ?? (avatar as { cid?: unknown }).cid; return typeof cid === "string" && cid !== "" ? cid : null; } /** * Where to find an account's avatar, or null where there is no picture. * * The original upload, at whatever size it was uploaded — a PDS keeps no * resized copy, so this is a full-size image behind a 1.75rem masthead icon. * It is content-addressed and cached by the browser, so that costs the first * page load of a session and nothing after it. */ export async function avatarUrl(did: string): Promise { const pds = await resolvePds(did); const url = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); url.searchParams.set("repo", did); url.searchParams.set("collection", COLLECTION); url.searchParams.set("rkey", RKEY); const response = await fetch(url); // An account that has never written a profile is answered with // RecordNotFound, which is a 400. That is an ordinary account, not a fault. if (response.status === 400) return null; if (!response.ok) throw new Error("Your account's profile could not be read."); const { value } = (await response.json()) as { value?: unknown }; const cid = avatarCid(value); return cid ? blobUrl(pds, did, cid) : null; }