// Showing a commit author as an atproto identity, where one can be found. // // A commit names its author with a name and an address that the author's own // git client wrote. Nothing signs either, so the page shows an atproto // identity only for an author the hosting account has claimed in its // dev.pdsjs.git.identity record. Every other author reads as the plain name // the commit carries. // // A server that serves dev.pdsjs.git.getAuthors resolves the claims and the // profiles behind them in one answer. Otherwise the page reads the record // and asks the appview itself. Either way the answer is remembered for the // next visit, so a commit's author has a face on the first frame. import { matchIdent, parseIdentityRecord, splitIdent, } from '@pdsjs/git/authors'; import { account, serverClient } from '#/lib/git.js'; const APPVIEW = 'https://public.api.bsky.app'; const IDENTITY_COLLECTION = 'dev.pdsjs.git.identity'; /** The appview takes at most this many actors per call. */ const PROFILE_BATCH = 25; /** An appview slower than this costs the faces, not the page. */ const PROFILE_TIMEOUT_MS = 4000; /** Where a visit remembers the authors the last one resolved. */ const AUTHORS_KEY = 'git-ui:authors'; /** * The claims the hosting account makes, about itself and about others. * @type {Array} */ let claims = []; /** Profiles by DID, for every DID the claims name plus the account itself. */ const profiles = new Map(); /** * @param {string[]} dids * @returns {Promise} resolved once every profile the appview knows is * in the map. A DID it does not know is simply absent. */ async function loadProfiles(dids) { for (let at = 0; at < dids.length; at += PROFILE_BATCH) { const params = new URLSearchParams(); for (const did of dids.slice(at, at + PROFILE_BATCH)) { params.append('actors', did); } const body = await fetch( `${APPVIEW}/xrpc/app.bsky.actor.getProfiles?${params}`, { signal: AbortSignal.timeout(PROFILE_TIMEOUT_MS) }, ) .then((res) => (res.ok ? res.json() : null)) .catch(() => null); for (const profile of body?.profiles ?? []) { profiles.set(profile.did, { did: profile.did, handle: profile.handle, displayName: profile.displayName || '', avatar: profile.avatar || '', }); } } } /** * The claims and profiles as the server resolves them, where it serves * getAuthors. * @returns {Promise} whether the server answered */ async function loadFromServer() { const client = await serverClient(); if (!(await client.serves('getAuthors'))) return false; const { owner, claims: served } = await client.getAuthors(); claims = served.map((claim) => ({ name: claim.name, email: claim.email, did: claim.did, label: claim.label, avatarCid: '', avatarUrl: claim.avatar, })); profiles.clear(); if (owner) profiles.set(owner.did, owner); for (const claim of served) { if (claim.profile) profiles.set(claim.profile.did, claim.profile); } return true; } /** The claims from the record, and the profiles from the appview. */ async function loadFromRecord() { const params = new URLSearchParams({ repo: account.did, collection: IDENTITY_COLLECTION, rkey: 'self', }); const identity = await fetch( `${account.pds}/xrpc/com.atproto.repo.getRecord?${params}`, ) .then((res) => (res.ok ? res.json() : null)) .catch(() => null); claims = identity ? parseIdentityRecord(identity.value) : []; profiles.clear(); asked.clear(); const wanted = new Set([account.did]); for (const claim of claims) if (claim.did) wanted.add(claim.did); await loadProfiles([...wanted]); } /** * Read the account's identity claims and the profile of every identity * they name. Both are anonymous, and both are needed before any commit * renders, so this runs once alongside the repository list. */ export async function loadAuthors() { let served = false; try { served = await loadFromServer(); } catch { // A server that lists the method but cannot answer it reads as one // without it. } if (!served) await loadFromRecord(); remember(); return { claims: claims.length, profiles: profiles.size }; } function remember() { if (typeof localStorage === 'undefined') return; try { localStorage.setItem( AUTHORS_KEY, JSON.stringify({ host: location.hostname, claims, profiles: [...profiles], }), ); } catch { // A page that cannot write storage just resolves fresh next time. } } /** * Start from the authors the last visit resolved, so a commit's author has * a face on the first frame. loadAuthors() corrects them in place. * @returns {boolean} whether anything was in hand */ export function primeAuthors() { if (typeof localStorage === 'undefined') return false; try { const stored = JSON.parse(localStorage.getItem(AUTHORS_KEY) || 'null'); if (!stored || stored.host !== location.hostname) return false; claims = Array.isArray(stored.claims) ? stored.claims : []; profiles.clear(); for (const [did, profile] of stored.profiles ?? []) { profiles.set(did, profile); } return true; } catch { return false; } } export function owner() { return profiles.get(account.did) ?? null; } /** * The profile behind a DID, where one has been learned. * @param {string|undefined} did */ export function profileFor(did) { return (did && profiles.get(did)) || null; } /** DIDs the appview has answered or failed once already. */ const asked = new Set(); /** * Learn the profiles behind statement authors, so their statements show * their picture. A DID the appview does not know stays absent, and the * page draws a stand-in; each DID is asked about once per visit. * @param {string[]} dids */ export async function learnProfiles(dids) { const unknown = dids.filter((did) => did && !asked.has(did)); for (const did of unknown) asked.add(did); if (unknown.length > 0) await loadProfiles(unknown); } /** * The first letter to show while an avatar loads, or in place of one. * @param {string|undefined} text */ export function initial(text) { return (String(text || '?').trim()[0] || '?').toUpperCase(); } /** * How to show one commit author. * * A claim carrying no DID is the account speaking about itself. A claim * naming another DID is the account vouching for somebody else, which the * page shows the same way: the reader already decided to trust this account * by opening its repositories. * @param {string} ident - the commit's author ident */ export function describeAuthor(ident) { const { name, email } = splitIdent(ident); const claim = matchIdent(claims, ident); // Whose profile speaks for this author: the DID the claim names, or the // account itself where the claim names no one else. A label with no DID // names something that holds no account at all, so the hosting account // must not answer in its place. const named = claim?.did || (claim && !claim.label ? account.did : ''); const profile = named ? profiles.get(named) : null; // A claim may name a tool or a bot rather than an account. The account // says what to call it and gives it a face, and the blob it names is in // the account's own repo. if (!profile && claim?.label) { return { name: claim.label, handle: '', avatar: claim.avatarUrl || (claim.avatarCid ? `${account.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(account.did)}&cid=${encodeURIComponent(claim.avatarCid)}` : ''), atproto: false, title: `${ident} · named by @${owner()?.handle ?? account.handle}`, }; } // The DID rides out whether or not a profile is known, because it is what // a face with no picture draws its pattern from. Seeding that from the name // instead would give one person two faces on one page: an identicon beside // their words, where the DID is in hand, and something else on their // commits. if (named) { return { name: profile?.displayName || profile?.handle || name || email, handle: profile?.handle ?? '', did: named, avatar: profile?.avatar ?? '', atproto: true, title: `${ident} · claimed by @${owner()?.handle ?? account.handle}`, }; } return { name: name || email || 'unknown', handle: '', did: '', avatar: '', atproto: false, title: ident, }; }