import { br, em, h, link, p, paragraphs, strong, text, type Block, type Doc, type Inline } from './doc.ts'; import { postLink, profileLink, type PostView } from './refs.ts'; import { stripNumbering } from './unroll.ts'; import type { Facet } from './richtext.ts'; // Bluesky things as documents, for --markdown and --html. // // A command's result is recognised by its shape — a feed, a thread, posts, // profiles, notifications, lists, conversations, an unrolled thread — so every // command gets a proper rendering without saying so, and anything else falls // back to a plain rendering of its data. type Json = Record; const encoder = new TextEncoder(); const decoder = new TextDecoder(); /** Post text as inlines: its facets as links, its line breaks as breaks. */ export function richText(value: string, facets: Facet[] | undefined): Inline[] { const bytes = encoder.encode(value); const out: Inline[] = []; const plain = (from: number, to: number) => { decoder.decode(bytes.slice(from, to)).split('\n').forEach((line, at) => { if (at) out.push(br); if (line) out.push(text(line)); }); }; let at = 0; for (const facet of [...(facets ?? [])].sort((a, b) => a.index.byteStart - b.index.byteStart)) { const { byteStart, byteEnd } = facet.index; if (byteStart < at || byteEnd > bytes.length || byteStart >= byteEnd) continue; const feature = facet.features[0]; const shown = decoder.decode(bytes.slice(byteStart, byteEnd)); const href = feature?.$type === 'app.bsky.richtext.facet#link' ? feature.uri : feature?.$type === 'app.bsky.richtext.facet#mention' ? profileLink(feature.did) : feature?.$type === 'app.bsky.richtext.facet#tag' ? `https://bsky.app/hashtag/${encodeURIComponent(feature.tag)}` : undefined; if (!href) continue; plain(at, byteStart); out.push(link(href, shown)); at = byteEnd; } plain(at, bytes.length); return out; } /** Text as paragraphs of rich text: blank lines split paragraphs, facets keep their offsets. */ function richParagraphs(value: string, facets: Facet[] | undefined): Block[] { const inlines = richText(value, facets); const blocks: Block[] = []; let current: Inline[] = []; for (let at = 0; at < inlines.length; at++) { const node = inlines[at]!; if (node.t === 'break' && inlines[at + 1]?.t === 'break') { while (inlines[at + 1]?.t === 'break') at++; if (current.length) blocks.push({ t: 'paragraph', children: current }); current = []; continue; } current.push(node); } if (current.length) blocks.push({ t: 'paragraph', children: current }); return blocks; } function date(value: unknown): string { if (!value) return ''; const parsed = new Date(String(value)); return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString().replace('T', ' ').slice(0, 16); } function author(profile: Json | undefined): Inline[] { if (!profile) return [text('(unknown)')]; const name = String(profile['displayName'] ?? '').trim(); const handle = link(profileLink(String(profile['handle'] ?? profile['did'])), `@${profile['handle'] ?? profile['did']}`); return name ? [strong(name), text(' '), handle] : [handle]; } // ----------------------------------------------------------------- embeds --- export function embedBlocks(embed: Json | undefined, post?: PostView): Block[] { if (!embed) return []; const type = String(embed['$type'] ?? ''); if (type.startsWith('app.bsky.embed.images')) { return (embed['images'] as Json[] ?? []).map((image) => ({ t: 'image', src: String(image['fullsize'] ?? image['thumb'] ?? ''), alt: String(image['alt'] ?? ''), href: image['fullsize'] })); } if (type.startsWith('app.bsky.embed.video')) { const target = post ? postLink(post.uri, post.author?.handle) : String(embed['playlist'] ?? ''); const blocks: Block[] = []; if (embed['thumbnail']) blocks.push({ t: 'image', src: String(embed['thumbnail']), alt: String(embed['alt'] ?? 'video'), href: target }); blocks.push(p(em('Video'), embed['alt'] ? `: ${embed['alt']}` : '', ' — ', link(target, 'watch'))); return blocks; } if (type.startsWith('app.bsky.embed.external')) { const external = embed['external'] as Json ?? {}; const inner: Block[] = [p(link(String(external['uri']), strong(String(external['title'] || external['uri']))))]; if (external['description']) inner.push(p(String(external['description']))); if (external['thumb']) inner.push({ t: 'image', src: String(external['thumb']), alt: '', href: String(external['uri']) }); return [{ t: 'quote', children: inner }]; } if (type.startsWith('app.bsky.embed.recordWithMedia')) { return [...embedBlocks(embed['media'] as Json, post), ...embedBlocks(embed['record'] as Json, post)]; } if (type.startsWith('app.bsky.embed.record')) { const record = (embed['record'] as Json) ?? {}; const inner = record['record'] && record['$type'] === undefined ? record['record'] as Json : record; return [{ t: 'quote', children: quotedBlocks(inner) }]; } return []; } function quotedBlocks(record: Json): Block[] { const type = String(record['$type'] ?? ''); if (type.endsWith('#viewNotFound')) return [p(em('The quoted post was deleted.'))]; if (type.endsWith('#viewBlocked')) return [p(em('The quoted post is from a blocked account.'))]; if (type.endsWith('#viewDetached')) return [p(em('The quote was removed by its author.'))]; if (type === 'app.bsky.feed.defs#generatorView') return [p(strong('Feed: '), link(feedLink(record), String(record['displayName'])), ` by @${record['creator']?.handle}`)]; if (type === 'app.bsky.graph.defs#listView') return [p(strong('List: '), String(record['name']), ` by @${record['creator']?.handle}`)]; if (record['author'] && record['value']) { const value = record['value'] as Json; const view = { uri: record['uri'], author: record['author'] } as PostView; return [ p(...author(record['author']), text(' · '), link(postLink(String(record['uri']), record['author'].handle), date(value['createdAt']))), ...richParagraphs(String(value['text'] ?? ''), value['facets']), ...((record['embeds'] as Json[] | undefined) ?? []).flatMap((each) => embedBlocks(each, view)), ]; } return []; } function feedLink(view: Json): string { const [, , did, , rkey] = String(view['uri']).split('/'); return `https://bsky.app/profile/${did}/feed/${rkey}`; } // ------------------------------------------------------------------ posts --- function counts(post: PostView): Inline { const parts = [`♡ ${post.likeCount ?? 0}`, `⟲ ${post.repostCount ?? 0}`, `↩ ${post.replyCount ?? 0}`]; if (post.quoteCount) parts.push(`❝ ${post.quoteCount}`); return em(parts.join(' · ')); } export function postBlocks(view: PostView, { note }: { note?: Inline[] } = {}): Block { const record = view.record ?? {}; const children: Block[] = []; if (note?.length) children.push(p(em(...note))); children.push(p(...author(view.author as Json), text(' · '), link(postLink(view.uri, view.author?.handle), date(record.createdAt ?? view.indexedAt)))); children.push(...richParagraphs(String(record.text ?? ''), record['facets'] as Facet[] | undefined)); children.push(...embedBlocks(view.embed, view)); children.push(p(counts(view))); return { t: 'section', children }; } function feedItemBlocks(item: Json): Block { const note: Inline[] = []; const reason = item['reason'] as Json | undefined; if (reason?.['$type'] === 'app.bsky.feed.defs#reasonRepost') note.push(text(`Reposted by ${reason['by']?.displayName || `@${reason['by']?.handle}`}`)); if (reason?.['$type'] === 'app.bsky.feed.defs#reasonPin') note.push(text('Pinned')); const parent = item['reply']?.parent as Json | undefined; if (parent?.['author'] && parent['uri'] !== item['post']?.uri) { if (note.length) note.push(text(' · ')); note.push(text('Replying to '), link(postLink(String(parent['uri']), parent['author'].handle), `@${parent['author'].handle}`)); } return postBlocks(item['post'] as PostView, { note }); } function threadBlocks(node: Json): Block[] { const blocks: Block[] = []; const ancestors: Json[] = []; for (let parent = node['parent'] as Json | undefined; parent; parent = parent['parent'] as Json | undefined) ancestors.unshift(parent); for (const ancestor of ancestors) if (ancestor['post']) blocks.push(postBlocks(ancestor['post'])); if (node['post']) blocks.push(postBlocks(node['post'])); const replies = (list: Json[] | undefined): Block[] => { const items = (list ?? []).filter((reply) => reply['post']).map((reply) => [postBlocks(reply['post']), ...replies(reply['replies'])]); return items.length ? [{ t: 'list', items }] : []; }; const below = replies(node['replies']); if (below.length) blocks.push(h(2, 'Replies'), ...below); return blocks; } /** An unrolled thread, as one piece: the author once, the text, the media where it falls. */ export function unrolledDoc(data: Json, { links = false, keepNumbering = false }: { links?: boolean; keepNumbering?: boolean } = {}): Doc { const posts = data['posts'] as PostView[]; const first = posts[0]!; const who = data['author'] as Json; const name = String(who['displayName'] ?? '').trim() || `@${who['handle']}`; const blocks: Block[] = [ h(1, `Thread by ${name}`), p(...author(who), text(' · '), link(postLink(first.uri, who['handle']), date(first.record?.createdAt ?? first.indexedAt)), text(` · ${posts.length} posts`)), ]; if (data['inReplyTo']) blocks.push(p(em('In reply to ', link(postLink(String(data['inReplyTo'].uri), data['inReplyTo'].author), `@${data['inReplyTo'].author}`)))); for (const post of posts) { const raw = String(post.record?.text ?? ''); // Numbering comes off before the facets are applied, so their offsets are moved with it. const kept = keepNumbering ? raw : stripNumbering(raw); const shift = encoder.encode(raw.slice(0, Math.max(0, raw.indexOf(kept)))).length; const facets = (post.record?.['facets'] as Facet[] | undefined) ?.map((facet) => ({ ...facet, index: { byteStart: facet.index.byteStart - shift, byteEnd: facet.index.byteEnd - shift } })) .filter((facet) => facet.index.byteStart >= 0 && facet.index.byteEnd <= encoder.encode(kept).length); blocks.push(...richParagraphs(kept, facets)); blocks.push(...embedBlocks(post.embed, post)); if (links) blocks.push(p(em(link(postLink(post.uri, who['handle']), 'post')))); } return { title: `Thread by ${name}`, blocks }; } // ----------------------------------------------------------------- people --- function profileBlocks(view: Json): Block[] { const name = String(view['displayName'] ?? '').trim() || `@${view['handle']}`; const blocks: Block[] = [h(2, name), p(link(profileLink(String(view['handle'])), `@${view['handle']}`), text(` · ${view['did']}`))]; if (view['description']) blocks.push(...paragraphs(String(view['description']))); if (view['website']) blocks.push(p(link(String(view['website']), String(view['website'])))); blocks.push(p(em(`${view['followersCount'] ?? 0} followers · ${view['followsCount'] ?? 0} following · ${view['postsCount'] ?? 0} posts`))); return [{ t: 'section', children: blocks }]; } function actorItems(list: Json[]): Block[] { if (!list.length) return [p(em('Nobody.'))]; return [{ t: 'list', items: list.map((each) => { const line: Inline[] = [...author(each)]; if (each['description']) line.push(text(` — ${String(each['description']).replace(/\s+/g, ' ').trim()}`)); return [p(...line)]; }), }]; } const REASONS: Record = { like: 'liked your post', repost: 'reposted your post', follow: 'followed you', mention: 'mentioned you', reply: 'replied to you', quote: 'quoted your post', 'starterpack-joined': 'joined via your starter pack', verified: 'verified you', unverified: 'removed your verification', 'like-via-repost': 'liked your repost', 'repost-via-repost': 'reposted your repost', 'subscribed-post': 'posted', }; function notificationItems(list: Json[]): Block[] { if (!list.length) return [p(em('No notifications.'))]; return [{ t: 'list', items: list.map((each) => { const blocks: Block[] = [p(...author(each['author']), text(` ${REASONS[String(each['reason'])] ?? each['reason']} · ${date(each['indexedAt'])}`), ...(each['isRead'] ? [] : [text(' '), strong('new')]))]; const record = each['record'] as Json | undefined; if (['reply', 'mention', 'quote', 'subscribed-post'].includes(String(each['reason'])) && record?.['text']) { blocks.push({ t: 'quote', children: richParagraphs(String(record['text']), record['facets']) }); blocks.push(p(link(postLink(String(each['uri']), each['author']?.handle), 'open'))); } return blocks; }), }]; } function generatorItems(list: Json[]): Block[] { if (!list.length) return [p(em('No feeds.'))]; return [{ t: 'list', items: list.map((each) => [p(link(feedLink(each), strong(String(each['displayName']))), text(` by @${each['creator']?.handle}`)), ...(each['description'] ? paragraphs(String(each['description'])) : [])]) }]; } function listItems(list: Json[]): Block[] { if (!list.length) return [p(em('No lists.'))]; return [{ t: 'list', items: list.map((each) => [p(strong(String(each['name'])), text(` by @${each['creator']?.handle}${each['listItemCount'] !== undefined ? ` · ${each['listItemCount']} members` : ''}`)), ...(each['description'] ? paragraphs(String(each['description'])) : [])]) }]; } function convoItems(list: Json[]): Block[] { if (!list.length) return [p(em('No conversations.'))]; return [{ t: 'list', items: list.map((convo) => { const members = (convo['members'] as Json[] ?? []).map((member) => `@${member['handle']}`).join(', '); const last = convo['lastMessage'] as Json | undefined; return [p(strong(String(convo['name'] ?? members)), text(` · ${convo['unreadCount'] ?? 0} unread · ${date(last?.['sentAt'])}`)), ...(last?.['text'] ? [p(String(last['text']))] : [])]; }), }]; } function messageBlocks(list: Json[], convo: Json | undefined): Block[] { const members = new Map(((convo?.['members'] as Json[] | undefined) ?? []).map((member) => [member['did'], member])); return [...list].reverse().filter((message) => message['text'] !== undefined).map((message) => ({ t: 'section' as const, children: [ p(...author(members.get(message['sender']?.did) ?? { handle: message['sender']?.did }), text(` · ${date(message['sentAt'])}`)), ...richParagraphs(String(message['text']), message['facets']), ], })); } // ---------------------------------------------------------------- generic --- function generic(value: unknown): Block[] { if (value === null || value === undefined) return []; if (typeof value !== 'object') return [p(String(value))]; const flat = (item: unknown) => item === null || ['string', 'number', 'boolean'].includes(typeof item); if (Array.isArray(value)) { if (value.every(flat)) return [{ t: 'list', items: value.map((item) => [p(String(item))]) }]; return [{ t: 'code', lang: 'json', text: JSON.stringify(value, null, 2) }]; } const entries = Object.entries(value as Json).filter(([key, item]) => key !== '$type' && item !== undefined); if (entries.every(([, item]) => flat(item))) { return [{ t: 'table', head: ['', ''], rows: entries.map(([key, item]) => [[strong(key)], [/^(https?:)/.test(String(item)) ? link(String(item), String(item)) : text(String(item))]]) }]; } return [{ t: 'code', lang: 'json', text: JSON.stringify(value, null, 2) }]; } // ------------------------------------------------------------ recognising --- /** The document a result's data makes, recognised by its shape. */ export function autoDoc(data: unknown, title: string): Doc { const blocks = (() => { if (!data || typeof data !== 'object' || Array.isArray(data)) return generic(data); const d = data as Json; if (Array.isArray(d['posts']) && d['author'] && typeof d['text'] === 'string') return unrolledDoc(d).blocks; if (d['thread'] && !Array.isArray(d['thread']) && d['thread'].post) return threadBlocks(d['thread']); if (Array.isArray(d['feed'])) return d['feed'].length ? d['feed'].map(feedItemBlocks) : [p(em('Nothing here.'))]; if (Array.isArray(d['posts']) && d['posts'].every((post: Json) => post?.record)) return d['posts'].length ? d['posts'].map((post: PostView) => postBlocks(post)) : [p(em('No posts.'))]; if (Array.isArray(d['bookmarks'])) return d['bookmarks'].filter((each: Json) => each.item?.uri).map((each: Json) => postBlocks(each.item)); if (Array.isArray(d['notifications'])) return notificationItems(d['notifications']); if (Array.isArray(d['likes'])) return actorItems(d['likes'].map((like: Json) => like['actor'])); for (const key of ['actors', 'followers', 'follows', 'repostedBy', 'mutes', 'blocks', 'suggestions']) { if (Array.isArray(d[key])) return actorItems(d[key]); } if (Array.isArray(d['profiles'])) return d['profiles'].flatMap(profileBlocks); if (Array.isArray(d['feeds'])) return generatorItems(d['feeds']); if (d['list'] && Array.isArray(d['items'])) return [...listItems([d['list']]), h(2, 'Members'), ...actorItems(d['items'].map((item: Json) => item['subject']))]; if (Array.isArray(d['lists'])) return listItems(d['lists']); if (Array.isArray(d['convos'])) return convoItems(d['convos']); if (Array.isArray(d['messages'])) return messageBlocks(d['messages'], d['convo']); if (d['did'] && d['handle'] && 'followersCount' in d) return profileBlocks(d); if (d['view'] && d['view'].creator) return generatorItems([d['view']]); return generic(data); })(); const named = blocks[0]?.t === 'heading' && blocks[0].level === 1; return { title, blocks: named ? blocks : [h(1, title), ...blocks] }; }