wip bsky client for the web & android
bbell.vt3e.cat
1/**
2 * ThreadBuilder
3 * builds threaded conversations from a flat feed of posts
4 */
5
6/**
7 * i initially based this off of @aylac.top's implementation for supercoolclient.pages.dev, but later
8 * completely pivoted, thank you ayla for the inspiration :D
9 * @see https://raw.githubusercontent.com/ayla6/supercoolclient/refs/heads/main/src/elements/ui/feed.ts
10 */
11
12import { AppBskyFeedDefs } from '@atcute/bluesky'
13import { is } from '@atcute/lexicons'
14
15export type FeedItem = AppBskyFeedDefs.FeedViewPost
16export type PostView = AppBskyFeedDefs.PostView
17
18export interface ThreadNode {
19 data: FeedItem | PostView
20 children: ThreadNode[]
21 /** if the node was created from `reply.parent` and is not in the main feed */
22 isVirtual: boolean
23}
24
25export class ThreadBuilder {
26 private nodeMap = new Map<string, ThreadNode>()
27 private rootUris = new Set<string>()
28
29 constructor(private feed: FeedItem[]) {}
30
31 public build(): ThreadNode[] {
32 this.nodeMap.clear()
33 this.rootUris.clear()
34
35 this.createNodesFromFeed()
36 this.linkRepliesToParents()
37 return this.getSortedRoots()
38 }
39
40 // create nodes for each feed item
41 private createNodesFromFeed() {
42 for (const item of this.feed) {
43 const uri = item.post.uri
44
45 if (this.nodeMap.has(uri)) {
46 const existing = this.nodeMap.get(uri)!
47 existing.data = item
48 existing.isVirtual = false
49 } else {
50 this.nodeMap.set(uri, {
51 data: item,
52 children: [],
53 isVirtual: false,
54 })
55 }
56 this.rootUris.add(uri)
57 }
58 }
59
60 /** link replies to parent nodes, will create virtual parents if missing */
61 private linkRepliesToParents() {
62 for (const item of this.feed) {
63 if (!item.reply?.parent) continue
64
65 const parent = item.reply.parent
66
67 // check that the parent is a PostView
68 if (!is(AppBskyFeedDefs.postViewSchema, parent)) {
69 continue
70 }
71
72 const currentUri = item.post.uri
73 const parentUri = parent.uri
74 const currentNode = this.nodeMap.get(currentUri)!
75
76 if (this.nodeMap.has(parentUri)) {
77 const parentNode = this.nodeMap.get(parentUri)!
78 if (!parentNode.children.includes(currentNode)) parentNode.children.push(currentNode)
79 this.rootUris.delete(currentUri)
80 } else {
81 // parent missing from feed; create virtual parent
82 const virtualParentNode: ThreadNode = {
83 data: parent,
84 children: [currentNode],
85 isVirtual: true,
86 }
87 this.nodeMap.set(parentUri, virtualParentNode)
88 this.rootUris.add(parentUri)
89 this.rootUris.delete(currentUri)
90 }
91 }
92 }
93
94 /** sort roots by newest activity */
95 private getSortedRoots(): ThreadNode[] {
96 const roots = Array.from(this.rootUris).map((uri) => this.nodeMap.get(uri)!)
97
98 return roots.sort((a, b) => {
99 const dateA = getIndexedAt(a)
100 const dateB = getIndexedAt(b)
101 return new Date(dateB).getTime() - new Date(dateA).getTime()
102 })
103 }
104}
105
106function getIndexedAt(node: ThreadNode): string {
107 return isFeedItem(node.data) ? node.data.post.indexedAt : node.data.indexedAt
108}
109
110function isFeedItem(item: FeedItem | PostView): item is FeedItem {
111 return 'post' in item
112}