Something went wrong. Try again.
hardened bluesky app fork with microcosm
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332/** * A post thread assembled client-side from microcosm + PDS reads, independent of * Bluesky's AppView `getPostThreadV2`. * * A thread is an aggregation with no atproto primitive: * - ANCESTORS (above the anchor) come from walking the anchor record's * `reply.parent` chain upward, one record fetch per level. * - DESCENDANTS (below the anchor) come from Constellation: replies to a post * are `app.bsky.feed.post` records whose `reply.parent.uri` backlinks to it. * We fan out level by level, bounded by `below` (depth) and `branchingFactor` * (replies per level), then hydrate each post. * * The output mirrors `app.bsky.unspecced.getPostThreadV2`'s flat, DFS-ordered * `ThreadItem[]` (negative depths above the anchor, 0 for the anchor, positive * below) so the existing `usePostThread` pipeline consumes it unchanged. */import { type AppBskyFeedDefs, type AppBskyFeedPost, type AppBskyUnspeccedDefs, type AppBskyUnspeccedGetPostThreadV2,} from '@atproto/api'
import * as constellation from '#/lib/microcosm/constellation'import {hydratePost} from '#/lib/microcosm/hydrate'import {resolveMiniDoc} from '#/lib/microcosm/slingshot'
type Sort = 'newest' | 'oldest' | 'top' | (string & {})
/** Hard caps so a pathological thread can't fan out unbounded. */const MAX_ABOVE = 80const DEFAULT_BELOW = 6const DEFAULT_BRANCHING = 10
function parseAtUri(uri: string): {did: string; rkey: string} | undefined { // at://did/collection/rkey const m = uri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/) if (!m) return undefined return {did: m[1], rkey: m[2]}}
function refToUri(ref: constellation.RecordRef): string { return `at://${ref.did}/${ref.collection}/${ref.rkey}`}
function makeThreadItem( uri: string, depth: number, post: AppBskyFeedDefs.PostView, opts: {moreParents?: boolean; moreReplies?: number; opThread?: boolean} = {},): AppBskyUnspeccedGetPostThreadV2.ThreadItem { const value: AppBskyUnspeccedGetPostThreadV2.ThreadItem['value'] = { $type: 'app.bsky.unspecced.defs#threadItemPost', post, moreParents: opts.moreParents ?? false, moreReplies: opts.moreReplies ?? 0, opThread: opts.opThread ?? false, hiddenByThreadgate: false, mutedByViewer: false, } satisfies AppBskyUnspeccedDefs.ThreadItemPost & {$type: string} return { $type: 'app.bsky.unspecced.getPostThreadV2#threadItem', uri, depth, value, }}
/** Pull the `reply.parent.uri` from an already-hydrated post's record. */function parentUriOf(post: AppBskyFeedDefs.PostView): string | undefined { const record = post.record as AppBskyFeedPost.Record | undefined return record?.reply?.parent?.uri}
/** Walk up the reply chain from the anchor, nearest parent first. */async function collectAncestors( anchor: AppBskyFeedDefs.PostView, viewerDid: string | undefined, signal?: AbortSignal,): Promise<{posts: AppBskyFeedDefs.PostView[]; truncated: boolean}> { const posts: AppBskyFeedDefs.PostView[] = [] let currentParentUri = parentUriOf(anchor) let truncated = false while (currentParentUri) { if (posts.length >= MAX_ABOVE) { truncated = true break } const parent = await hydratePost( currentParentUri, viewerDid, signal, 1, ).catch(() => undefined) if (!parent) break posts.push(parent) currentParentUri = parentUriOf(parent) } return {posts, truncated}}
/** Direct replies to a post, hydrated, ordered by `sort`. */async function fetchReplies( postUri: string, branchingFactor: number, sort: Sort, viewerDid: string | undefined, signal?: AbortSignal,): Promise<{ replies: AppBskyFeedDefs.PostView[] total: number}> { const page = await constellation .getBacklinks( { subject: postUri, source: constellation.Sources.replies, // Pull a bit more than we render so sorting has something to choose // from; we cap to branchingFactor after sorting. limit: Math.min(Math.max(branchingFactor * 3, 30), 100), }, signal, ) .catch(() => undefined) if (!page) return {replies: [], total: 0}
const uris = page.records.map(refToUri) const hydrated = ( await Promise.all( // Full hydration (not lite): thread replies show their own like/reply/ // repost counts and viewer state, same as the AppView. Reply volume is // bounded by branchingFactor, so this stays affordable. uris.map(uri => hydratePost(uri, viewerDid, signal, 1).catch(() => undefined), ), ) ).filter((p): p is AppBskyFeedDefs.PostView => !!p)
sortReplies(hydrated, sort) return {replies: hydrated.slice(0, branchingFactor), total: page.total}}
function indexedAtMs(p: AppBskyFeedDefs.PostView): number { const record = p.record as AppBskyFeedPost.Record | undefined const t = record?.createdAt ?? p.indexedAt return t ? new Date(t).getTime() : 0}
function engagement(p: AppBskyFeedDefs.PostView): number { return (p.likeCount ?? 0) + (p.repostCount ?? 0) + (p.replyCount ?? 0)}
function sortReplies(posts: AppBskyFeedDefs.PostView[], sort: Sort): void { if (sort === 'oldest') { posts.sort((a, b) => indexedAtMs(a) - indexedAtMs(b)) } else if (sort === 'top') { // Lite hydration omits counts, so "top" degrades to newest-first unless // counts are present. Best-effort: engagement desc, then newest. posts.sort( (a, b) => engagement(b) - engagement(a) || indexedAtMs(b) - indexedAtMs(a), ) } else { // 'newest' (default) posts.sort((a, b) => indexedAtMs(b) - indexedAtMs(a)) }}
/** * Recursively expand replies below a post, appending flat ThreadItems in DFS * order. `rootAuthorDid` tracks the OP for `opThread`. */async function expandBelow( parentUri: string, parentDepth: number, below: number, branchingFactor: number, sort: Sort, rootAuthorDid: string | undefined, viewerDid: string | undefined, out: AppBskyUnspeccedGetPostThreadV2.ThreadItem[], signal?: AbortSignal,): Promise<void> { if (parentDepth >= below) return const {replies} = await fetchReplies( parentUri, branchingFactor, sort, viewerDid, signal, ) if (replies.length === 0) return
const depth = parentDepth + 1 const willExpandChildren = depth < below for (const reply of replies) { const opThread = !!rootAuthorDid && reply.author.did === rootAuthorDid
// `moreReplies` is about THIS reply's own unshown children, not its // siblings. If we won't recurse into it (depth limit), it's the reply's full // reply count; otherwise we recurse and the count is filled by that call, so // 0 here. (replyCount comes from the full hydration of the reply.) const ownReplyCount = reply.replyCount ?? 0 const moreReplies = willExpandChildren ? 0 : ownReplyCount
const before = out.length out.push(makeThreadItem(reply.uri, depth, reply, {opThread, moreReplies}))
if (willExpandChildren) { await expandBelow( reply.uri, depth, below, branchingFactor, sort, rootAuthorDid, viewerDid, out, signal, ) // If recursion rendered fewer children than this reply actually has // (capped by branchingFactor, or some failed to hydrate), report the // remainder so the UI can offer to load them. const rendered = out.length - before - 1 if (rendered < ownReplyCount) { const item = out[before] if ( item.value.$type === 'app.bsky.unspecced.defs#threadItemPost' ) { ;( item.value as AppBskyUnspeccedDefs.ThreadItemPost ).moreReplies = ownReplyCount - rendered } } } }}
/** * Build one `getPostThreadV2`-compatible thread around an anchor post. * Returns `undefined` if the anchor can't be hydrated (caller falls back to the * AppView). */export async function buildPostThread( anchorUri: string, opts: { viewerDid?: string above?: boolean below?: number branchingFactor?: number sort?: Sort } = {}, signal?: AbortSignal,): Promise<AppBskyUnspeccedGetPostThreadV2.OutputSchema | undefined> { const parsed = parseAtUri(anchorUri) if (!parsed) return undefined
const below = Math.min(opts.below ?? DEFAULT_BELOW, 12) const branchingFactor = opts.branchingFactor ?? DEFAULT_BRANCHING const sort = opts.sort ?? 'newest' const includeAbove = opts.above ?? true
// Constellation indexes by DID. The anchor uri often arrives with a handle // authority (route param), which would make every reply backlink lookup // return nothing - resolve it to a DID first. if (!parsed.did.startsWith('did:')) { const did = await resolveMiniDoc(parsed.did, signal) .then(m => m.did) .catch(() => undefined) if (did) anchorUri = `at://${did}/app.bsky.feed.post/${parsed.rkey}` }
// Hydrate the anchor with full counts/viewer state - it's the focal post. const anchor = await hydratePost( anchorUri, opts.viewerDid, signal, 1, ).catch(() => undefined) if (!anchor) return undefined
const items: AppBskyUnspeccedGetPostThreadV2.ThreadItem[] = []
// Ancestors: walk up, then emit deepest-first with negative depths so the // root is at the top and the anchor's direct parent is at depth -1. let rootAuthorDid = anchor.author.did if (includeAbove) { const {posts: ancestors, truncated} = await collectAncestors( anchor, opts.viewerDid, signal, ) if (ancestors.length) { rootAuthorDid = ancestors[ancestors.length - 1].author.did } // ancestors[0] is the nearest parent (depth -1); the last is the root. for (let i = 0; i < ancestors.length; i++) { const depth = -(i + 1) const isTop = i === ancestors.length - 1 items.unshift( makeThreadItem(ancestors[i].uri, depth, ancestors[i], { moreParents: isTop && truncated, opThread: ancestors[i].author.did === rootAuthorDid, }), ) } }
// Anchor at depth 0. items.push( makeThreadItem(anchorUri, 0, anchor, { opThread: anchor.author.did === rootAuthorDid, }), )
// Descendants below the anchor. await expandBelow( anchorUri, 0, below, branchingFactor, sort, rootAuthorDid, opts.viewerDid, items, signal, )
// `hasOtherReplies` would require knowing about replies we intentionally // dropped (e.g. by threadgate); we don't reproduce that, so report false. return {thread: items, hasOtherReplies: false}}