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