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