// The public web page that displays a record, when one is known. The mapping // from an AT-URI to an app's web URL is app-specific and hand-kept, so only the // collections with a known page get a link; everything else gets none, and the // card simply shows no link. Apps are added here one at a time as their public // URL schemes are pinned down. const BSKY = 'https://bsky.app'; const GRAIN = 'https://grain.social'; const TANGLED = 'https://tangled.org'; /** * `at:////` addresses one record. Split it into its * parts, or null if it isn't a full record URI. * @param {string} uri * @returns {{ did: string, collection: string, rkey: string } | null} */ export function parseAtUri(uri) { if (typeof uri !== 'string' || !uri.startsWith('at://')) return null; const [did, collection, rkey] = uri.slice('at://'.length).split('/'); return did && collection && rkey ? { did, collection, rkey } : null; } // A post/list/feed AT-URI -> its bsky.app page. Likes, reposts, and the like // point *at* one of these rather than having a page of their own. /** @param {string} uri */ function atUriToBsky(uri) { const target = parseAtUri(uri); if (!target) return null; const { did, collection, rkey } = target; switch (collection) { case 'app.bsky.feed.post': return `${BSKY}/profile/${did}/post/${rkey}`; case 'app.bsky.graph.list': return `${BSKY}/profile/${did}/lists/${rkey}`; case 'app.bsky.feed.generator': return `${BSKY}/profile/${did}/feed/${rkey}`; default: return null; } } // A person's profile page, from the DID a follow/block/listitem points at. /** @param {unknown} did */ function bskyProfile(did) { return typeof did === 'string' && did.startsWith('did:') ? `${BSKY}/profile/${did}` : null; } // A gallery or profile Grain AT-URI -> its grain.social page. A favorite has no // page of its own; it points at the gallery it favorited. /** @param {string} uri */ function atUriToGrain(uri) { const target = parseAtUri(uri); if (!target) return null; const { did, collection, rkey } = target; switch (collection) { case 'social.grain.gallery': return `${GRAIN}/profile/${did}/gallery/${rkey}`; case 'social.grain.actor.profile': return `${GRAIN}/profile/${did}`; default: return null; } } /** * The public web page for a record, or null when none is known. Records that * have a page of their own link to it; a like or repost links the post it * points at (its `subject`), and a follow/block/listitem links that person's * profile. * @param {string} uri the record's own AT-URI * @param {any} value the record body, for the subject-pointing types * @returns {string | null} */ export function recordWebLink(uri, value) { const target = parseAtUri(uri); if (!target) return null; const { did, collection, rkey } = target; switch (collection) { case 'app.bsky.feed.post': case 'app.bsky.graph.list': case 'app.bsky.feed.generator': return atUriToBsky(uri); case 'app.bsky.actor.profile': return bskyProfile(did); case 'app.bsky.graph.starterpack': return `${BSKY}/starter-pack/${did}/${rkey}`; // A like or repost has no page; link the post it points at. case 'app.bsky.feed.like': case 'app.bsky.feed.repost': return atUriToBsky(value?.subject?.uri); // These point at a person; link their profile. case 'app.bsky.graph.follow': case 'app.bsky.graph.block': case 'app.bsky.graph.listitem': return bskyProfile(value?.subject); // Grain — a photo app. Galleries and profiles have their own page. case 'social.grain.gallery': case 'social.grain.actor.profile': return atUriToGrain(uri); // A favorite has no page; link the gallery it points at. The subject is a // plain at-uri string here, not a strongRef object. case 'social.grain.favorite': return atUriToGrain(value?.subject); // Tangled — git on atproto; tangled.org takes a DID in the path. A repo's // URL is keyed by its cosmetic `name`, not its rkey, so the repo record // itself links (it carries the name) but a star can't — a star only // references the repo by knot/DID/at-uri, never the name. case 'sh.tangled.repo': return value?.name ? `${TANGLED}/${did}/${value.name}` : null; case 'sh.tangled.graph.follow': return typeof value?.subject === 'string' && value.subject.startsWith('did:') ? `${TANGLED}/${value.subject}` : null; default: return null; } }