// 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} */ 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`; }