/** * ThreadBuilder * builds threaded conversations from a flat feed of posts */ /** * i initially based this off of @aylac.top's implementation for supercoolclient.pages.dev, but later * completely pivoted, thank you ayla for the inspiration :D * @see https://raw.githubusercontent.com/ayla6/supercoolclient/refs/heads/main/src/elements/ui/feed.ts */ import { AppBskyFeedDefs } from '@atcute/bluesky' import { is } from '@atcute/lexicons' export type FeedItem = AppBskyFeedDefs.FeedViewPost export type PostView = AppBskyFeedDefs.PostView export interface ThreadNode { data: FeedItem | PostView children: ThreadNode[] /** if the node was created from `reply.parent` and is not in the main feed */ isVirtual: boolean } export class ThreadBuilder { private nodeMap = new Map() private rootUris = new Set() constructor(private feed: FeedItem[]) {} public build(): ThreadNode[] { this.nodeMap.clear() this.rootUris.clear() this.createNodesFromFeed() this.linkRepliesToParents() return this.getSortedRoots() } // create nodes for each feed item private createNodesFromFeed() { for (const item of this.feed) { const uri = item.post.uri if (this.nodeMap.has(uri)) { const existing = this.nodeMap.get(uri)! existing.data = item existing.isVirtual = false } else { this.nodeMap.set(uri, { data: item, children: [], isVirtual: false, }) } this.rootUris.add(uri) } } /** link replies to parent nodes, will create virtual parents if missing */ private linkRepliesToParents() { for (const item of this.feed) { if (!item.reply?.parent) continue const parent = item.reply.parent // check that the parent is a PostView if (!is(AppBskyFeedDefs.postViewSchema, parent)) { continue } const currentUri = item.post.uri const parentUri = parent.uri const currentNode = this.nodeMap.get(currentUri)! if (this.nodeMap.has(parentUri)) { const parentNode = this.nodeMap.get(parentUri)! if (!parentNode.children.includes(currentNode)) parentNode.children.push(currentNode) this.rootUris.delete(currentUri) } else { // parent missing from feed; create virtual parent const virtualParentNode: ThreadNode = { data: parent, children: [currentNode], isVirtual: true, } this.nodeMap.set(parentUri, virtualParentNode) this.rootUris.add(parentUri) this.rootUris.delete(currentUri) } } } /** sort roots by newest activity */ private getSortedRoots(): ThreadNode[] { const roots = Array.from(this.rootUris).map((uri) => this.nodeMap.get(uri)!) return roots.sort((a, b) => { const dateA = getIndexedAt(a) const dateB = getIndexedAt(b) return new Date(dateB).getTime() - new Date(dateA).getTime() }) } } function getIndexedAt(node: ThreadNode): string { return isFeedItem(node.data) ? node.data.post.indexedAt : node.data.indexedAt } function isFeedItem(item: FeedItem | PostView): item is FeedItem { return 'post' in item }