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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
128 lines · 4.2 KB Raw History
import { coAuthorsOf } from '@pdsjs/git/authors';
import { Avatar } from '#/components/atoms/avatar.jsx';
import { describeAuthor, initial } from '#/lib/authors.js';
import { identicon } from '#/lib/avatars.js';
import { cn } from '#/lib/utils.js';

/** How many faces a pile shows before it counts the rest instead. */
const FACES = 3;

/**
 * The author stands in front and everyone credited beside them tucks in
 * behind, so the face a reader looks for first is the one that is whole.
 * These are classes rather than a style, because the avatar takes a class
 * name and nothing else. The pile isolates them, so a face never rises
 * above a menu that opens over it.
 */
const DEPTH = ['z-30', 'z-20', 'z-10'];

/**
 * Everyone a commit credits, in the order a reader meets them: the author it
 * names first, then anyone its message credits in a `Co-authored-by` trailer.
 * @param {{ident: unknown, message: unknown}} commit
 */
function creditedBy(ident, message) {
  const idents = [ident, ...coAuthorsOf(message)].filter(Boolean);
  const people = [];
  const seen = new Set();
  for (const one of idents) {
    const author = describeAuthor(one);
    const key = author.name.toLowerCase();
    if (seen.has(key)) continue;
    seen.add(key);
    people.push(author);
  }
  return people;
}

/**
 * The faces alone, for a row that has no width for the names: a commit on a
 * timeline, where the message is what the reader came for and the face says
 * whose it is.
 *
 * An author with no atproto account keeps whatever the account calls them,
 * which is how a tool that writes commits gets a face without having a repo
 * of its own.
 */
export function AuthorFaces({ ident, message, className }) {
  const people = creditedBy(ident, message);
  if (people.length === 0) return null;
  return (
    <span
      className={cn('isolate flex shrink-0 items-center', className)}
      title={people.map((person) => person.title).join('\n')}
    >
      {people.slice(0, FACES).map((person, index) => (
        <Face key={person.name} person={person} index={index} />
      ))}
    </span>
  );
}

/**
 * One face in a pile, drawn over the one behind it.
 *
 * An author the account has claimed draws the pattern their DID draws
 * everywhere else, so one person wears one face. An author claimed by nobody
 * has no identity to draw from and keeps their initial.
 */
function Face({ person, index }) {
  return (
    <Avatar
      src={person.avatar || (person.did ? identicon(person.did) : undefined)}
      fallback={
        <span className="text-[9px] text-faint">{initial(person.name)}</span>
      }
      // The face in front already carries a border, and that is the edge
      // between it and the one behind. A ring drawn here would have to guess
      // the colour behind the pile, which differs from one band to the next.
      className={cn(
        'relative size-4 shrink-0',
        DEPTH[index],
        index > 0 && '-ml-1.5',
      )}
    />
  );
}

/**
 * Everyone a commit credits, faces and names.
 *
 * The faces overlap, and the names read as a sentence, because a commit two
 * people wrote is one piece of work rather than two rows.
 */
export function AuthorFacepile({ ident, message, className }) {
  const people = creditedBy(ident, message);
  if (people.length === 0) return null;

  const shown = people.slice(0, FACES);
  const named = people.map((person) => person.name);
  // "Ada", "Ada and Bo", "Ada, Bo and Cy": the last two join with "and", the
  // rest with commas, which is how the names would be read aloud.
  const sentence =
    named.length > 1
      ? `${named.slice(0, -1).join(', ')} and ${named[named.length - 1]}`
      : named[0];

  return (
    <span
      className={cn('inline-flex min-w-0 items-center gap-2', className)}
      title={people.map((person) => person.title).join('\n')}
    >
      <span className="isolate flex shrink-0 items-center">
        {shown.map((person, index) => (
          <Face key={person.name} person={person} index={index} />
        ))}
      </span>
      <span className="truncate">
        {sentence}
        {people.length > shown.length && (
          <span className="text-faint">
            {' '}
            and {people.length - shown.length} more
          </span>
        )}
      </span>
    </span>
  );
}