/** * Display avatars, from the public appview. * * The deliberate opposite of profile.ts, and the two must not be conflated: * that module reads the picture's bytes from the account's own PDS because * the camo editor draws them to a canvas and reads pixels back, and the * appview's CDN sends no CORS header at all. Here the picture is only ever * an ``, which CORS does not gate, so the appview's resized, * CDN-cached copy is the better fetch — and one lookup answers handle or * DID alike, without a PDS resolution round trip per face. * * The cost is coverage: the appview only knows accounts it has indexed. An * unknown account answers null, and every caller treats null as "show no * picture", never as an error. */ const APPVIEW = "https://public.api.bsky.app"; /** * What the appview knows about an actor that is worth showing next to their * name. Every field is optional in the answer and null here when it is * missing, an unindexed account included — a caller shows what there is. */ export interface ActorProfile { avatar: string | null; displayName: string | null; } /** One answer per actor for the life of the page, misses included. */ const cache = new Map>(); const NOBODY: ActorProfile = { avatar: null, displayName: null }; /** * The appview's profile for a handle or DID. One lookup per actor per page, * shared by every caller: the avatar and the display name come from the * same `getProfile`, and asking twice for two fields of one answer is a * round trip nobody needs. */ export function profileFor(actor: string): Promise { let kept = cache.get(actor); if (!kept) { kept = (async () => { const url = new URL(`${APPVIEW}/xrpc/app.bsky.actor.getProfile`); url.searchParams.set("actor", actor); const response = await fetch(url); if (!response.ok) return NOBODY; const body = (await response.json()) as { avatar?: unknown; displayName?: unknown; }; return { avatar: text(body.avatar), displayName: text(body.displayName), }; })().catch(() => NOBODY); cache.set(actor, kept); } return kept; } /** A non-empty string, or null. An absent field and an empty one mean the * same thing to a caller: there is nothing to show. */ function text(value: unknown): string | null { return typeof value === "string" && value !== "" ? value : null; } /** The avatar URL for a handle or DID, or null where there is no picture. */ export function avatarFor(actor: string): Promise { return profileFor(actor).then((profile) => profile.avatar); } /** * Wire an avatar `` to an actor, hidden until a picture arrives. * * The dataset token is the race guard: a slot select flipped twice in quick * succession resolves two lookups, and only the one still current when it * lands may touch the element. */ export function showAvatar(img: HTMLImageElement, actor: string): void { img.dataset.actor = actor; img.hidden = true; void avatarFor(actor).then((url) => { if (img.dataset.actor !== actor) return; if (url) { img.src = url; img.hidden = false; } }); }