replies timeline only, appview-less bluesky client
at main 5.0 kB view raw
1import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons'; 2import type { Account } from './accounts'; 3import { expect } from './result'; 4import type { PostWithUri } from './at/fetch'; 5 6export type ThreadPost = { 7 data: PostWithUri; 8 account: Did; 9 did: Did; 10 rkey: string; 11 parentUri: ResourceUri | null; 12 depth: number; 13 newestTime: number; 14}; 15 16export type Thread = { 17 rootUri: ResourceUri; 18 posts: ThreadPost[]; 19 newestTime: number; 20 branchParentPost?: ThreadPost; 21}; 22 23export const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => { 24 const threadMap = new Map<ResourceUri, ThreadPost[]>(); 25 26 // group posts by root uri into "thread" chains 27 for (const [account, timeline] of timelines) { 28 for (const [uri, data] of timeline) { 29 const parsedUri = expect(parseCanonicalResourceUri(uri)); 30 const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 31 const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 32 33 const post: ThreadPost = { 34 data, 35 account, 36 did: parsedUri.repo, 37 rkey: parsedUri.rkey, 38 parentUri, 39 depth: 0, 40 newestTime: new Date(data.record.createdAt).getTime() 41 }; 42 43 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); 44 45 threadMap.get(rootUri)!.push(post); 46 } 47 } 48 49 const threads: Thread[] = []; 50 51 for (const [rootUri, posts] of threadMap) { 52 const uriToPost = new Map(posts.map((p) => [p.data.uri, p])); 53 const childrenMap = new Map<ResourceUri | null, ThreadPost[]>(); 54 55 // calculate depths 56 for (const post of posts) { 57 let depth = 0; 58 let currentUri = post.parentUri; 59 60 while (currentUri && uriToPost.has(currentUri)) { 61 depth++; 62 currentUri = uriToPost.get(currentUri)!.parentUri; 63 } 64 65 post.depth = depth; 66 67 if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []); 68 childrenMap.get(post.parentUri)!.push(post); 69 } 70 71 childrenMap 72 .values() 73 .forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime)); 74 75 const createThread = ( 76 posts: ThreadPost[], 77 rootUri: ResourceUri, 78 branchParentUri?: ResourceUri 79 ): Thread => { 80 return { 81 rootUri, 82 posts, 83 newestTime: Math.max(...posts.map((p) => p.newestTime)), 84 branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined 85 }; 86 }; 87 88 const collectSubtree = (startPost: ThreadPost): ThreadPost[] => { 89 const result: ThreadPost[] = []; 90 const addWithChildren = (post: ThreadPost) => { 91 result.push(post); 92 const children = childrenMap.get(post.data.uri) || []; 93 children.forEach(addWithChildren); 94 }; 95 addWithChildren(startPost); 96 return result; 97 }; 98 99 // find posts with >2 children to split them into separate chains 100 const branchingPoints = Array.from(childrenMap.entries()) 101 .filter(([, children]) => children.length > 1) 102 .map(([uri]) => uri); 103 104 if (branchingPoints.length === 0) { 105 const roots = childrenMap.get(null) || []; 106 const allPosts = roots.flatMap((root) => collectSubtree(root)); 107 threads.push(createThread(allPosts, rootUri)); 108 } else { 109 for (const branchParentUri of branchingPoints) { 110 const branches = childrenMap.get(branchParentUri) || []; 111 112 const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime); 113 114 sortedBranches.forEach((branchRoot, index) => { 115 const isOldestBranch = index === 0; 116 const branchPosts: ThreadPost[] = []; 117 118 // the oldest branch has the full context 119 // todo: consider letting the user decide this..? 120 if (isOldestBranch && branchParentUri !== null) { 121 const parentChain: ThreadPost[] = []; 122 let currentUri: ResourceUri | null = branchParentUri; 123 while (currentUri && uriToPost.has(currentUri)) { 124 parentChain.unshift(uriToPost.get(currentUri)!); 125 currentUri = uriToPost.get(currentUri)!.parentUri; 126 } 127 branchPosts.push(...parentChain); 128 } 129 130 branchPosts.push(...collectSubtree(branchRoot)); 131 132 const minDepth = Math.min(...branchPosts.map((p) => p.depth)); 133 branchPosts.forEach((p) => (p.depth = p.depth - minDepth)); 134 135 threads.push( 136 createThread( 137 branchPosts, 138 branchRoot.data.uri, 139 isOldestBranch ? undefined : (branchParentUri ?? undefined) 140 ) 141 ); 142 }); 143 } 144 } 145 } 146 147 threads.sort((a, b) => b.newestTime - a.newestTime); 148 149 // console.log(threads); 150 151 return threads; 152}; 153 154export const isOwnPost = (post: ThreadPost, accounts: Account[]) => 155 accounts.some((account) => account.did === post.did); 156export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) => 157 posts.some((post) => !isOwnPost(post, accounts)); 158 159// todo: add more filtering options 160export type FilterOptions = { 161 viewOwnPosts: boolean; 162}; 163 164export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) => 165 threads.filter((thread) => { 166 if (!opts.viewOwnPosts) return hasNonOwnPost(thread.posts, accounts); 167 return true; 168 });