/**
* 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;
}
});
}