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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
203 lines · 5.9 KB Raw History
// Turning what the endpoints return into what a page shows.

import { tidToMs } from '@pdsjs/core/repo';
import { parseSpaceUri } from '@pdsjs/spaces/uri';
import {
  ACCESS_LABELS,
  accessKind,
  allowedOf,
  isPublic,
  POLICY_LABELS,
  policyKind,
} from '#/lib/policy.js';

/**
 * The two parts of a space URI a heading shows: the type it declares and the
 * key that names this one.
 * @param {string} uri
 * @returns {{spaceDid: string, spaceType: string, skey: string}}
 */
export function spaceParts(uri) {
  const { spaceDid, spaceType, skey } = parseSpaceUri(uri);
  return { spaceDid, spaceType, skey };
}

/**
 * The registrable-ish domain behind an NSID: the first two authority segments,
 * reversed. `my.bulletin.board` -> `bulletin.my`.
 *
 * Two segments rather than the whole authority, because that is the domain the
 * app answers on: `app.bsky.feed.post` belongs to `bsky.app`, not to
 * `feed.bsky.app`.
 *
 * @param {string} nsid
 * @returns {string}
 */
export function nsidDomain(nsid) {
  const parts = String(nsid).split('.');
  return parts.length < 2 ? String(nsid) : `${parts[1]}.${parts[0]}`;
}

/**
 * A DID short enough for a line of prose, keeping both ends so two DIDs from
 * one directory still read apart.
 * @param {string} did
 * @returns {string}
 */
export function shortDid(did) {
  if (did.length <= 24) return did;
  return `${did.slice(0, 16)}…${did.slice(-6)}`;
}

/**
 * When a record key says it was written, or null for a key that is not a TID.
 * @param {string} rkey
 * @returns {Date|null}
 */
export function rkeyTime(rkey) {
  const ms = tidToMs(rkey);
  return ms === null ? null : new Date(ms);
}

/** @type {Array<[string, number]>} */
const UNITS = [
  ['year', 31536000000],
  ['month', 2592000000],
  ['day', 86400000],
  ['hour', 3600000],
  ['minute', 60000],
];

/**
 * How long ago something happened, in the coarsest unit that still says
 * something. Anything under a minute reads as "just now".
 * @param {Date|string|number|null|undefined} when
 * @returns {string}
 */
export function ago(when) {
  if (!when) return '';
  const then = when instanceof Date ? when : new Date(when);
  const elapsed = Date.now() - then.getTime();
  if (Number.isNaN(elapsed)) return '';
  if (elapsed < 60000) return 'just now';
  for (const [unit, size] of UNITS) {
    if (elapsed >= size) {
      const count = Math.floor(elapsed / Number(size));
      return `${count} ${unit}${count === 1 ? '' : 's'} ago`;
    }
  }
  return 'just now';
}

/**
 * How long something has left, or that it has none. Seconds are a unit here and
 * not in `ago`, because a delegation token lives sixty of them.
 * @param {Date|string|number|null|undefined} when
 * @returns {string}
 */
export function until(when) {
  if (!when) return '';
  const then = when instanceof Date ? when : new Date(when);
  const left = then.getTime() - Date.now();
  if (Number.isNaN(left)) return '';
  if (left <= 0) return 'expired';
  for (const [unit, size] of [...UNITS, ['second', 1000]]) {
    if (left >= Number(size)) {
      const count = Math.floor(left / Number(size));
      return `in ${count} ${unit}${count === 1 ? '' : 's'}`;
    }
  }
  return 'in under a second';
}

/**
 * @param {number} bytes
 * @returns {string}
 */
export function fileSize(bytes) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

/**
 * Base64 bytes as hex, which is how a commit hash and a MAC are compared by
 * eye against another implementation's output.
 * @param {{$bytes?: string}|null|undefined} value
 * @returns {string}
 */
export function bytesToHex(value) {
  if (!value?.$bytes) return '';
  const padded = value.$bytes.replace(/-/g, '+').replace(/_/g, '/');
  try {
    const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '='));
    return Array.from(binary, (character) =>
      character.charCodeAt(0).toString(16).padStart(2, '0'),
    ).join('');
  } catch {
    return value.$bytes;
  }
}

/**
 * Records grouped by collection, each group counted and stamped with its most
 * recent write. This is the space's shape, which no endpoint reports directly.
 *
 * @param {Array<{collection: string, rkey: string}>} records
 * @returns {Array<{collection: string, count: number, lastWriteAt: number|null}>}
 */
export function collectionsOf(records) {
  /** @type {Map<string, {count: number, lastWriteAt: number|null}>} */
  const groups = new Map();
  for (const record of records) {
    const group = groups.get(record.collection) || {
      count: 0,
      lastWriteAt: null,
    };
    group.count += 1;
    const at = tidToMs(record.rkey);
    if (at !== null && at > (group.lastWriteAt || 0)) group.lastWriteAt = at;
    groups.set(record.collection, group);
  }
  return [...groups]
    .map(([collection, group]) => ({ collection, ...group }))
    .sort((a, b) => a.collection.localeCompare(b.collection));
}

/**
 * Which policy governs a space, as the page names it.
 * @param {any} config
 * @returns {string}
 */
export function policyLabel(config) {
  const kind = policyKind(config);
  return kind ? POLICY_LABELS[kind] : 'Unknown';
}

/**
 * Who the policy admits for one kind of access.
 * @param {any} config
 * @param {'read'|'write'} access
 * @returns {string}
 */
export function accessLabel(config, access) {
  const kind = policyKind(config);
  if (kind === 'memberspace') {
    if (isPublic(config, access)) return 'Anyone';
    return access === 'read' ? 'Members who may read' : 'Members who may write';
  }
  return kind ? POLICY_LABELS[kind] : 'Unknown';
}

/**
 * The clients a space admits, as one phrase.
 * @param {any} config
 * @returns {string}
 */
export function appAccessLabel(config) {
  if (accessKind(config) !== 'allowList') return ACCESS_LABELS.open;
  const count = allowedOf(config).length;
  return count === 1
    ? '1 client on an allow list'
    : `${count} clients on an allow list`;
}