appview-less bluesky client
24
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 264 lines 7.4 kB view raw
1import { type Did, type ResourceUri } from '@atcute/lexicons'; 2import type { Account } from './accounts'; 3import type { PostWithUri } from './at/fetch'; 4import { getBlockRelationship, postsByRootUri } from './state.svelte'; 5import { timestampFromCursor } from '$lib'; 6 7export type ThreadPost = { 8 data: PostWithUri; 9 account: Did; 10 did: Did; 11 rkey: string; 12 parentUri: ResourceUri | null; 13 depth: number; 14 newestTime: number; 15 blockRelationship?: { userBlocked: boolean; blockedByTarget: boolean }; 16 isMuted?: boolean; 17}; 18 19export type Thread = { 20 rootUri: ResourceUri; 21 posts: ThreadPost[]; 22 newestTime: number; 23 branchParentPost?: ThreadPost; 24}; 25 26export const isOwnPost = (post: ThreadPost, accounts: Account[]) => 27 accounts.some((account) => account.did === post.did); 28export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) => 29 posts.some((post) => !isOwnPost(post, accounts)); 30 31export type FilterOptions = { 32 viewOwnPosts: boolean; 33 filterReplies?: boolean; 34 filterRootsToDids?: Set<Did>; 35}; 36 37type ThreadGroup = { 38 rootUri: ResourceUri; 39 posts: ThreadPost[]; 40 newestTime: number; 41 isReply: boolean; 42 rootDid: Did | null; 43}; 44 45export const buildThreadsFiltered = ( 46 account: Did, 47 timeline: Set<ResourceUri>, 48 posts: Map<ResourceUri, PostWithUri>, 49 mutes: Set<Did>, 50 accounts: Account[], 51 opts: FilterOptions, 52 limit?: number 53): Thread[] => { 54 const blockCache = new Map<Did, { userBlocked: boolean; blockedByTarget: boolean }>(); 55 const getBlockRel = (target: Did) => { 56 let rel = blockCache.get(target); 57 if (!rel) { 58 rel = getBlockRelationship(account, target); 59 blockCache.set(target, rel); 60 } 61 return rel; 62 }; 63 64 // phase 1: find distinct root URIs from timeline and build groups using pre-computed index 65 const rootUrisFromTimeline = new Set<ResourceUri>(); 66 for (const uri of timeline) { 67 const did = extractDidFromUri(uri); 68 if (!did) continue; 69 const data = posts.get(uri); 70 if (!data) continue; 71 const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 72 rootUrisFromTimeline.add(rootUri); 73 } 74 75 const groups = new Map<ResourceUri, ThreadGroup>(); 76 for (const rootUri of rootUrisFromTimeline) { 77 const postUris = postsByRootUri.get(rootUri); 78 if (!postUris) continue; 79 80 let group: ThreadGroup | undefined; 81 82 for (const uri of postUris) { 83 if (!timeline.has(uri)) continue; 84 const did = extractDidFromUri(uri); 85 if (!did) continue; 86 const parts = uri.split('/'); 87 if (parts.length < 5) continue; 88 const rkey = parts[4]; 89 90 const data = posts.get(uri); 91 if (!data) continue; 92 93 const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 94 const isReply = !!data.record.reply; 95 96 const cursorTime = timestampFromCursor(rkey); 97 const postTime = cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(); 98 99 if (!group) { 100 group = { 101 rootUri, 102 posts: [], 103 newestTime: postTime, 104 isReply, 105 rootDid: extractDidFromUri(rootUri) 106 }; 107 groups.set(rootUri, group); 108 } 109 110 const blockRel = getBlockRel(did); 111 group.posts.push({ 112 data, 113 account, 114 did, 115 rkey, 116 parentUri, 117 depth: 0, 118 newestTime: postTime, 119 blockRelationship: blockRel, 120 isMuted: mutes.has(did) 121 }); 122 123 if (postTime > group.newestTime) group.newestTime = postTime; 124 } 125 } 126 127 // phase 2: sort groups by newest time descending 128 const sortedGroups = Array.from(groups.values()).sort((a, b) => b.newestTime - a.newestTime); 129 130 // phase 3: process groups with pre-filtering and early exit 131 const threads: Thread[] = []; 132 133 const shouldIncludeGroup = (group: ThreadGroup): boolean => { 134 if (opts.filterReplies && group.isReply) return false; 135 136 if (opts.filterRootsToDids) { 137 if ( 138 group.rootDid && 139 !opts.filterRootsToDids.has(group.rootDid) && 140 !accounts.some((a) => a.did === group.rootDid) 141 ) { 142 return false; 143 } 144 } 145 146 return true; 147 }; 148 149 const processGroup = (group: ThreadGroup): Thread[] => { 150 const groupPosts = group.posts; 151 const uriToPost = new Map(groupPosts.map((p) => [p.data.uri, p])); 152 const childrenMap = new Map<ResourceUri | null, ThreadPost[]>(); 153 154 for (const post of groupPosts) { 155 let depth = 0; 156 let currentUri = post.parentUri; 157 while (currentUri && uriToPost.has(currentUri)) { 158 depth++; 159 currentUri = uriToPost.get(currentUri)!.parentUri; 160 } 161 post.depth = depth; 162 163 if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []); 164 childrenMap.get(post.parentUri)!.push(post); 165 } 166 167 childrenMap.values().forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime)); 168 169 const createThread = ( 170 posts: ThreadPost[], 171 rootUri: ResourceUri, 172 branchParentUri?: ResourceUri 173 ): Thread => ({ 174 rootUri, 175 posts, 176 newestTime: Math.max(...posts.map((p) => p.newestTime)), 177 branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined 178 }); 179 180 const collectSubtree = (startPost: ThreadPost): ThreadPost[] => { 181 const result: ThreadPost[] = []; 182 const addWithChildren = (post: ThreadPost) => { 183 result.push(post); 184 const children = childrenMap.get(post.data.uri) || []; 185 children.forEach(addWithChildren); 186 }; 187 addWithChildren(startPost); 188 return result; 189 }; 190 191 const branchingPoints = Array.from(childrenMap.entries()) 192 .filter(([, children]) => children.length > 1) 193 .map(([uri]) => uri); 194 195 const result: Thread[] = []; 196 197 if (branchingPoints.length === 0) { 198 const roots = childrenMap.get(null) || []; 199 const allPosts = roots.flatMap((root) => collectSubtree(root)); 200 result.push(createThread(allPosts, group.rootUri)); 201 } else { 202 for (const branchParentUri of branchingPoints) { 203 const branches = childrenMap.get(branchParentUri) || []; 204 const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime); 205 206 sortedBranches.forEach((branchRoot, index) => { 207 const isOldestBranch = index === 0; 208 const branchPosts: ThreadPost[] = []; 209 210 if (isOldestBranch && branchParentUri !== null) { 211 const parentChain: ThreadPost[] = []; 212 let currentUri: ResourceUri | null = branchParentUri; 213 while (currentUri && uriToPost.has(currentUri)) { 214 parentChain.unshift(uriToPost.get(currentUri)!); 215 currentUri = uriToPost.get(currentUri)!.parentUri; 216 } 217 branchPosts.push(...parentChain); 218 } 219 220 branchPosts.push(...collectSubtree(branchRoot)); 221 222 const minDepth = Math.min(...branchPosts.map((p) => p.depth)); 223 branchPosts.forEach((p) => (p.depth = p.depth - minDepth)); 224 225 result.push( 226 createThread( 227 branchPosts, 228 branchRoot.data.uri, 229 isOldestBranch ? undefined : (branchParentUri ?? undefined) 230 ) 231 ); 232 }); 233 } 234 } 235 236 return result; 237 }; 238 239 const passesPostFilter = (thread: Thread): boolean => { 240 if (thread.posts.length === 0) return false; 241 if (!opts.viewOwnPosts && hasNonOwnPost(thread.posts, accounts)) return false; 242 return true; 243 }; 244 245 for (const group of sortedGroups) { 246 if (!shouldIncludeGroup(group)) continue; 247 248 const groupThreads = processGroup(group); 249 250 for (const thread of groupThreads) { 251 if (!passesPostFilter(thread)) continue; 252 threads.push(thread); 253 if (limit && threads.length >= limit) return threads; 254 } 255 } 256 257 return threads; 258}; 259 260const extractDidFromUri = (uri: ResourceUri): Did | null => { 261 const match = uri.match(/^at:\/\/(did:plc:[a-z0-9]+)/); 262 return match ? (match[1] as Did) : null; 263}; 264