Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295import { bold, cyan, dim, gray, green, indent, magenta, red, relativeTime, table, truncate, yellow, columns, plural } from './output.ts';import { expandLinks, type Facet } from './richtext.ts';import { postLink, type PostView } from './refs.ts';
// How Bluesky things look in a terminal. JSON output never passes through// here; this is only for people.
type Json = Record<string, any>;
export interface ProfileBasic { did: string; handle: string; displayName?: string; description?: string; avatar?: string; viewer?: Json; labels?: { val: string }[]; [key: string]: unknown;}
export function who(profile: ProfileBasic | undefined): string { if (!profile) return dim('(unknown)'); const name = profile.displayName?.trim(); const handle = cyan(`@${profile.handle}`); return name ? `${bold(name)} ${handle}` : handle;}
function counts(post: PostView): string { const parts: string[] = []; const viewer = post.viewer ?? {}; parts.push(`${viewer.like ? red('♥') : '♡'} ${post.likeCount ?? 0}`); parts.push(`${viewer.repost ? green('⟲') : '⟲'} ${post.repostCount ?? 0}`); parts.push(`↩ ${post.replyCount ?? 0}`); if (post.quoteCount) parts.push(`❝ ${post.quoteCount}`); if (viewer.bookmarked) parts.push(yellow('★')); return dim(parts.join(' '));}
function labels(list: { val: string }[] | undefined): string { const values = (list ?? []).map((label) => label.val).filter((val) => !val.startsWith('!no-')); return values.length ? ` ${yellow(`[${values.join(', ')}]`)}` : '';}
/** One post, as a block of lines. */export function post(view: PostView, { prefix, compact = false, parentShown = false }: { prefix?: string; compact?: boolean; parentShown?: boolean } = {}): string { const lines: string[] = []; const record = view.record ?? {}; const when = relativeTime(record.createdAt ?? view.indexedAt); lines.push(`${prefix ?? ''}${who(view.author as ProfileBasic)} ${dim(`· ${when}`)}${labels(view.labels)}`); if (record.reply && !compact && !parentShown) lines.push(dim(`↳ reply to ${postLink(record.reply.parent.uri)}`)); const text = expandLinks(String(record.text ?? ''), record['facets'] as Facet[] | undefined); if (text.trim()) lines.push(compact ? truncate(text, columns() - 4) : text); const embed = embedLines(view.embed); if (embed.length) lines.push(...embed.map((line) => indent(line, ' '))); lines.push(`${counts(view)} ${gray(postLink(view.uri, view.author?.handle))}`); return lines.join('\n');}
/** What a post carries, summarised. */export function embedLines(embed: Json | undefined): string[] { if (!embed) return []; const type = String(embed['$type'] ?? ''); if (type.startsWith('app.bsky.embed.images')) { return (embed['images'] as Json[] ?? []).map((image) => `${magenta('[image]')} ${image['alt'] ? image['alt'] : dim('(no alt text)')}${image['fullsize'] ? dim(` ${image['fullsize']}`) : ''}`); } if (type.startsWith('app.bsky.embed.video')) { return [`${magenta('[video]')} ${embed['alt'] ? embed['alt'] : dim('(no alt text)')}${embed['playlist'] ? dim(` ${embed['playlist']}`) : ''}`]; } if (type.startsWith('app.bsky.embed.external')) { const external = embed['external'] as Json ?? {}; const lines = [`${magenta('[link]')} ${bold(String(external['title'] || external['uri']))}`]; if (external['description']) lines.push(dim(truncate(String(external['description']), columns() - 6))); lines.push(gray(String(external['uri']))); return lines; } if (type.startsWith('app.bsky.embed.recordWithMedia')) { return [...embedLines(embed['media'] as Json), ...embedLines(embed['record'] as Json)]; } 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 quoted(inner); } return [dim(`[${type || 'embed'}]`)];}
function quoted(record: Json): string[] { const type = String(record['$type'] ?? ''); if (type.endsWith('#viewNotFound')) return [dim('┃ (quoted post deleted)')]; if (type.endsWith('#viewBlocked')) return [dim('┃ (quoted post from a blocked account)')]; if (type.endsWith('#viewDetached')) return [dim('┃ (quote removed by its author)')]; if (type === 'app.bsky.feed.defs#generatorView') return [`${magenta('[feed]')} ${bold(String(record['displayName']))} ${dim(`by @${record['creator']?.handle}`)}`]; if (type === 'app.bsky.graph.defs#listView') return [`${magenta('[list]')} ${bold(String(record['name']))} ${dim(`by @${record['creator']?.handle}`)}`]; if (type === 'app.bsky.graph.defs#starterPackViewBasic') return [`${magenta('[starter pack]')} ${bold(String(record['record']?.name ?? ''))}`]; if (record['author'] && record['value']) { const value = record['value'] as Json; const text = expandLinks(String(value['text'] ?? ''), value['facets'] as Facet[] | undefined); const lines = [`${who(record['author'] as ProfileBasic)} ${dim(`· ${relativeTime(value['createdAt'])}`)}`]; if (text.trim()) lines.push(...text.split('\n')); for (const embed of (record['embeds'] as Json[] | undefined) ?? []) lines.push(...embedLines(embed)); lines.push(gray(postLink(String(record['uri']), (record['author'] as ProfileBasic).handle))); return lines.map((line) => `${dim('┃')} ${line}`); } if (record['uri']) return [dim(`┃ ${record['uri']}`)]; return [];}
/** A feed item: a post, perhaps a repost, perhaps with the post it replies to. */export function feedItem(item: Json): string { const lines: string[] = []; const reason = item['reason'] as Json | undefined; if (reason?.['$type'] === 'app.bsky.feed.defs#reasonRepost') lines.push(green(`⟲ reposted by ${reason['by']?.displayName || `@${reason['by']?.handle}`}`)); if (reason?.['$type'] === 'app.bsky.feed.defs#reasonPin') lines.push(yellow('📌 pinned')); const parent = item['reply']?.parent as Json | undefined; const parentShown = Boolean(parent?.['author'] && parent['uri'] !== item['post']?.uri); if (parentShown) lines.push(dim(`↳ replying to @${parent!['author'].handle}: ${truncate(String(parent!['record']?.text ?? ''), columns() - 30)}`)); lines.push(post(item['post'] as PostView, { parentShown })); return lines.join('\n');}
export function feed(items: Json[]): string { if (!items.length) return dim('(nothing here)'); return items.map(feedItem).join('\n\n');}
export function posts(views: PostView[]): string { if (!views.length) return dim('(no posts)'); return views.map((view) => post(view)).join('\n\n');}
/** A thread: ancestors above the post, replies indented below it. */export function thread(node: Json): string { const lines: string[] = []; const ancestors: Json[] = []; let parent = node['parent'] as Json | undefined; while (parent) { ancestors.unshift(parent); parent = parent['parent'] as Json | undefined; } for (const ancestor of ancestors) lines.push(threadNode(ancestor, dim('│ ')), dim('│')); lines.push(threadNode(node, '', true)); const walk = (replies: Json[] | undefined, depth: number) => { for (const reply of replies ?? []) { lines.push('', threadNode(reply, ' '.repeat(depth) + dim('└─ '))); walk(reply['replies'] as Json[] | undefined, depth + 1); } }; walk(node['replies'] as Json[] | undefined, 1); return lines.join('\n');}
function threadNode(node: Json, prefix: string, focus = false): string { const type = String(node['$type'] ?? ''); if (type.endsWith('#notFoundPost')) return `${prefix}${dim('(deleted post)')}`; if (type.endsWith('#blockedPost')) return `${prefix}${dim('(post from a blocked account)')}`; const view = node['post'] as PostView | undefined; if (!view) return `${prefix}${dim('(unavailable)')}`; const block = post(view, { parentShown: true }); const pad = prefix.replace(/\u001b\[[0-9;]*m/g, '').replace(/└─ /, ' ').replace(/│ /, '│ '); const [first, ...rest] = block.split('\n'); const head = `${prefix}${focus ? bold('▶ ') : ''}${first}`; return [head, ...rest.map((line) => `${pad.startsWith('│') ? dim('│ ') : pad}${focus ? ' ' : ''}${line}`)].join('\n');}
export function profile(view: Json): string { const lines: string[] = []; lines.push(`${who(view as ProfileBasic)}${labels(view['labels'] as { val: string }[] | undefined)}`); lines.push(gray(String(view['did']))); const viewer = view['viewer'] as Json | undefined; const relation: string[] = []; if (viewer?.['following']) relation.push(green('you follow them')); if (viewer?.['followedBy']) relation.push(green('follows you')); if (viewer?.['blocking']) relation.push(red('blocked')); if (viewer?.['blockedBy']) relation.push(red('blocks you')); if (viewer?.['muted']) relation.push(yellow('muted')); if (relation.length) lines.push(relation.join(dim(' · '))); if (view['pronouns']) lines.push(dim(String(view['pronouns']))); if (view['description']) lines.push('', String(view['description'])); if (view['website']) lines.push('', cyan(String(view['website']))); lines.push(''); lines.push([ `${bold(String(view['followersCount'] ?? 0))} followers`, `${bold(String(view['followsCount'] ?? 0))} following`, `${bold(String(view['postsCount'] ?? 0))} posts`, ].join(' ')); if (view['createdAt']) lines.push(dim(`joined ${new Date(String(view['createdAt'])).toISOString().slice(0, 10)}`)); lines.push(gray(`https://bsky.app/profile/${view['handle']}`)); return lines.join('\n');}
/** People, one per line. */export function actors(list: Json[]): string { if (!list.length) return dim('(nobody)'); return table(list.map((each) => { const viewer = each['viewer'] as Json | undefined; const marks = [viewer?.['following'] ? 'following' : '', viewer?.['followedBy'] ? 'follows you' : '', viewer?.['muted'] ? 'muted' : '', viewer?.['blocking'] ? 'blocked' : ''].filter(Boolean).join(', '); return [cyan(`@${each['handle']}`), String(each['displayName'] ?? ''), dim(marks), dim(String(each['description'] ?? '').replace(/\s+/g, ' '))]; }), { flex: 3 });}
const REASONS: Record<string, string> = { 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',};
export function notifications(list: Json[], subjects: Map<string, PostView>): string { if (!list.length) return dim('(no notifications)'); return list.map((each) => { const reason = String(each['reason']); const fresh = each['isRead'] ? ' ' : `${yellow('●')} `; const head = `${fresh}${who(each['author'] as ProfileBasic)} ${REASONS[reason] ?? reason} ${dim(`· ${relativeTime(each['indexedAt'])}`)}`; const lines = [head]; if (['reply', 'mention', 'quote', 'subscribed-post'].includes(reason)) { const record = each['record'] as Json; const text = expandLinks(String(record?.['text'] ?? ''), record?.['facets'] as Facet[] | undefined); if (text) lines.push(indent(text, ' ')); lines.push(` ${gray(postLink(String(each['uri']), (each['author'] as ProfileBasic).handle))}`); } else if (each['reasonSubject']) { const subject = subjects.get(String(each['reasonSubject'])); const text = subject?.record?.text; if (text) lines.push(dim(` “${truncate(String(text), columns() - 8)}”`)); } return lines.join('\n'); }).join('\n');}
export function feedGenerators(list: Json[]): string { if (!list.length) return dim('(no feeds)'); return list.map((each) => [ `${bold(String(each['displayName']))} ${dim(`by @${each['creator']?.handle}`)}${each['likeCount'] ? dim(` · ♥ ${each['likeCount']}`) : ''}`, ...(each['description'] ? [indent(String(each['description']).trim(), ' ')] : []), ` ${gray(String(each['uri']))}`, ].join('\n')).join('\n\n');}
export function lists(list: Json[]): string { if (!list.length) return dim('(no lists)'); const purposes: Record<string, string> = { 'app.bsky.graph.defs#curatelist': 'curation', 'app.bsky.graph.defs#modlist': 'moderation', 'app.bsky.graph.defs#referencelist': 'reference', }; return list.map((each) => [ `${bold(String(each['name']))} ${dim(`(${purposes[String(each['purpose'])] ?? each['purpose']}) by @${each['creator']?.handle}${each['listItemCount'] !== undefined ? ` · ${plural(Number(each['listItemCount']), 'member')}` : ''}`)}`, ...(each['description'] ? [indent(String(each['description']).trim(), ' ')] : []), ` ${gray(String(each['uri']))}`, ].join('\n')).join('\n\n');}
export function convos(list: Json[], self: string | undefined): string { if (!list.length) return dim('(no conversations)'); return list.map((convo) => { const others = (convo['members'] as ProfileBasic[] ?? []).filter((member) => member.did !== self); const name = convo['name'] ?? others.map((member) => member.displayName || `@${member.handle}`).join(', '); const unread = Number(convo['unreadCount'] ?? 0); const last = convo['lastMessage'] as Json | undefined; const preview = last?.['text'] ? truncate(String(last['text']), columns() - 10) : dim('(no messages)'); const status = convo['status'] === 'request' ? yellow(' [request]') : ''; const muted = convo['muted'] ? dim(' [muted]') : ''; return [ `${unread ? yellow(`● ${unread} `) : ' '}${bold(String(name))}${status}${muted} ${dim(`· ${relativeTime(last?.['sentAt'])}`)}`, ` ${preview}`, ` ${gray(`id ${convo['id']}`)}`, ].join('\n'); }).join('\n');}
export function messages(list: Json[], members: Map<string, ProfileBasic>, self: string | undefined): string { if (!list.length) return dim('(no messages)'); // Messages come newest first; a conversation reads oldest first. return [...list].reverse().map((message) => { if (String(message['$type']).endsWith('#deletedMessageView')) return dim(` (deleted message · ${relativeTime(message['sentAt'])})`); if (String(message['$type']).includes('systemMessage')) return dim(` · ${String(message['data']?.['$type'] ?? 'system message').split('#').pop()}`); const sender = String(message['sender']?.did ?? ''); const author = members.get(sender); const name = sender === self ? green('you') : cyan(author ? `@${author.handle}` : sender); const text = expandLinks(String(message['text'] ?? ''), message['facets'] as Facet[] | undefined); const reactions = (message['reactions'] as Json[] | undefined)?.map((reaction) => reaction['value']).join('') ?? ''; const lines = [`${name} ${dim(`· ${relativeTime(message['sentAt'])}`)}${reactions ? ` ${reactions}` : ''}`, indent(text, ' ')]; if (message['embed']) lines.push(...embedLines(message['embed'] as Json).map((line) => indent(line, ' '))); lines.push(` ${gray(`id ${message['id']}`)}`); return lines.join('\n'); }).join('\n');}