import { useQuery } from '@tanstack/react-query'; import { ChevronDown, ChevronRight } from 'lucide-react'; import { useState } from 'react'; import { ago, fileSize } from '#/lib/format.js'; import { identityOf } from '#/lib/identity.js'; import { cn } from '#/lib/utils.js'; // A record arrives as the JSON form of DAG-CBOR, so three of its leaves are // objects rather than scalars: a CID link, a byte string, and a blob reference. // Each reads as one thing on the page instead of as a nested object. /** * @param {unknown} value * @returns {value is {$link: string}} */ function isLink(value) { return Boolean(value && typeof value === 'object' && '$link' in value); } /** * @param {unknown} value * @returns {value is {$bytes: string}} */ function isBytes(value) { return Boolean(value && typeof value === 'object' && '$bytes' in value); } /** * @param {any} value * @returns {boolean} */ function isBlob(value) { return value?.$type === 'blob' && isLink(value.ref); } const ISO_DATE = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z?$/; /** * Whoever a DID in a record belongs to, added beside it. A record names an * account by DID and nothing else, so without this a reader has to copy the * string somewhere to find out who wrote what. */ function IdentityHint({ did }) { const { data } = useQuery({ queryKey: ['identity', did], queryFn: () => identityOf(did), retry: false, staleTime: Number.POSITIVE_INFINITY, }); if (!data?.handle) return null; return ( {data.handle} {data.verified ? '' : '?'} ); } /** One scalar, coloured by what it is. */ function Leaf({ value, onBlob }) { if (value === null) { return null; } if (typeof value === 'boolean' || typeof value === 'number') { return {String(value)}; } if (isBlob(value)) { return ( ); } if (isLink(value)) { return ( {value.$link} ); } if (isBytes(value)) { // The decoded length rather than the base64 text: a hash, a signature and a // MAC are all opaque here, and their size is the part worth reading. const bytes = Math.floor((value.$bytes.length * 3) / 4); return {bytes} bytes; } const text = String(value); // The DID is the value, so it stays whole. The handle is added beside it // rather than in place of it, because a record says one and a reader knows // the other. if (text.startsWith('did:')) { return ( "{text}" ); } if (text.startsWith('at://')) { return "{text}"; } return ( "{text}" {ISO_DATE.test(text) && ( {ago(text)} )} ); } /** One key and its value, with a disclosure when the value has parts. */ function Entry({ name, value, depth, onBlob }) { const branching = value !== null && typeof value === 'object' && !isLink(value) && !isBytes(value) && !isBlob(value); // Two levels open is enough to see a record's shape without scrolling past // the parts of it that are lists of their own. const [open, setOpen] = useState(depth < 2); if (!branching) { return (
{name !== undefined && ( {name}: )}
); } const entries = Array.isArray(value) ? value.map((item, index) => [String(index), item]) : Object.entries(value); const Chevron = open ? ChevronDown : ChevronRight; const summary = Array.isArray(value) ? `${entries.length} item${entries.length === 1 ? '' : 's'}` : `${entries.length} field${entries.length === 1 ? '' : 's'}`; return (
{open && (
{entries.map(([key, item]) => ( ))} {Array.isArray(value) ? ']' : '}'}
)}
); } /** * A record value, read rather than pretty-printed. Pass `onBlob` to make a blob * reference open. */ export function JsonView({ value, onBlob, className }) { return (
); }