wip bsky client for the web & android bbell.vt3e.cat
at main 112 lines 3.0 kB view raw
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}