/** * Whether the signed-in player follows somebody, on Bluesky. * * One request to the public appview, which answers this unauthenticated: * `app.bsky.graph.getRelationships` takes an actor and a list of others and * says, per other, which way the follows run. No route of ours is involved * and nothing here carries a token — a follow is public, and this is the same * appview avatars.ts already talks to. * * Deliberately not `/api/opponents/suggested`, which exists and answers a * different question: that one is "who do I follow, who has played here, and * who have I not faced yet", scoped to a list worth suggesting. This is one * yes or no about one account. * * The answer is a nicety and never a gate. Anything that goes wrong — a slow * appview, an account it has never indexed, a network that is not there — is * "we do not know", and what a caller does with that is show nothing. */ const APPVIEW = "https://public.api.bsky.app"; /** What the appview knows about how two accounts are connected. */ export interface Relationship { /** Whether the viewer follows the other account. */ readonly following: boolean; /** Whether the other account follows the viewer back. */ readonly followedBy: boolean; } const NEITHER: Relationship = { following: false, followedBy: false }; /** One answer per pair for the life of the page. */ const cache = new Map>(); /** * How `viewer` is connected to `other`, both named by DID. * * Both directions are read though only one is shown today: they arrive in the * same answer, and "they follow you back" is the other half of the mutual the * front page's own copy is about. */ export function relationship( viewer: string, other: string, ): Promise { const key = `${viewer} ${other}`; const known = cache.get(key); if (known) return known; const pending = lookup(viewer, other).catch((error: unknown) => { console.warn("relationships: the appview did not answer", error); // Not remembered: one flaky lookup should not settle the question for the // life of the page. cache.delete(key); return NEITHER; }); cache.set(key, pending); return pending; } async function lookup(viewer: string, other: string): Promise { const url = `${APPVIEW}/xrpc/app.bsky.graph.getRelationships` + `?actor=${encodeURIComponent(viewer)}&others=${encodeURIComponent(other)}`; const response = await fetch(url); if (!response.ok) { throw new Error(`getRelationships answered ${response.status}`); } const body = (await response.json()) as { relationships?: { following?: unknown; followedBy?: unknown }[]; }; // A follow is an at:// URI where there is one and absent where there is // not. Everything here comes off the network, so nothing is assumed about // its shape. const found = body.relationships?.[0]; return { following: typeof found?.following === "string", followedBy: typeof found?.followedBy === "string", }; }