~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
263 lines · 8.4 KB Raw History
// 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<import('@pdsjs/git/authors').IdentClaim & {avatarUrl?: string}>}
 */
let claims = [];

/** Profiles by DID, for every DID the claims name plus the account itself. */
const profiles = new Map();

/**
 * @param {string[]} dids
 * @returns {Promise<void>} 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<boolean>} 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,
  };
}