Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
3.2 kB · 90 lines
TypeScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091/** * 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 `<img src>`, 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<string, Promise<ActorProfile>>();
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<ActorProfile> { 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<string | null> { return profileFor(actor).then((profile) => profile.avatar);}
/** * Wire an avatar `<img>` 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; } });}