import { type Did, type ResourceUri } from '@atcute/lexicons'; import type { Account } from './accounts'; import type { PostWithUri } from './at/fetch'; import { getBlockRelationship, postsByRootUri } from './state.svelte'; import { timestampFromCursor } from '$lib'; export type ThreadPost = { data: PostWithUri; account: Did; did: Did; rkey: string; parentUri: ResourceUri | null; depth: number; newestTime: number; blockRelationship?: { userBlocked: boolean; blockedByTarget: boolean }; isMuted?: boolean; }; export type Thread = { rootUri: ResourceUri; posts: ThreadPost[]; newestTime: number; branchParentPost?: ThreadPost; }; export const isOwnPost = (post: ThreadPost, accounts: Account[]) => accounts.some((account) => account.did === post.did); export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) => posts.some((post) => !isOwnPost(post, accounts)); export type FilterOptions = { viewOwnPosts: boolean; filterReplies?: boolean; filterRootsToDids?: Set; }; type ThreadGroup = { rootUri: ResourceUri; posts: ThreadPost[]; newestTime: number; isReply: boolean; rootDid: Did | null; }; export const buildThreadsFiltered = ( account: Did, timeline: Set, posts: Map, mutes: Set, accounts: Account[], opts: FilterOptions, limit?: number ): Thread[] => { const blockCache = new Map(); const getBlockRel = (target: Did) => { let rel = blockCache.get(target); if (!rel) { rel = getBlockRelationship(account, target); blockCache.set(target, rel); } return rel; }; // phase 1: find distinct root URIs from timeline and build groups using pre-computed index const rootUrisFromTimeline = new Set(); for (const uri of timeline) { const did = extractDidFromUri(uri); if (!did) continue; const data = posts.get(uri); if (!data) continue; const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; rootUrisFromTimeline.add(rootUri); } const groups = new Map(); for (const rootUri of rootUrisFromTimeline) { const postUris = postsByRootUri.get(rootUri); if (!postUris) continue; let group: ThreadGroup | undefined; for (const uri of postUris) { if (!timeline.has(uri)) continue; const did = extractDidFromUri(uri); if (!did) continue; const parts = uri.split('/'); if (parts.length < 5) continue; const rkey = parts[4]; const data = posts.get(uri); if (!data) continue; const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; const isReply = !!data.record.reply; const cursorTime = timestampFromCursor(rkey); const postTime = cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(); if (!group) { group = { rootUri, posts: [], newestTime: postTime, isReply, rootDid: extractDidFromUri(rootUri) }; groups.set(rootUri, group); } const blockRel = getBlockRel(did); group.posts.push({ data, account, did, rkey, parentUri, depth: 0, newestTime: postTime, blockRelationship: blockRel, isMuted: mutes.has(did) }); if (postTime > group.newestTime) group.newestTime = postTime; } } // phase 2: sort groups by newest time descending const sortedGroups = Array.from(groups.values()).sort((a, b) => b.newestTime - a.newestTime); // phase 3: process groups with pre-filtering and early exit const threads: Thread[] = []; const shouldIncludeGroup = (group: ThreadGroup): boolean => { if (opts.filterReplies && group.isReply) return false; if (opts.filterRootsToDids) { if ( group.rootDid && !opts.filterRootsToDids.has(group.rootDid) && !accounts.some((a) => a.did === group.rootDid) ) { return false; } } return true; }; const processGroup = (group: ThreadGroup): Thread[] => { const groupPosts = group.posts; const uriToPost = new Map(groupPosts.map((p) => [p.data.uri, p])); const childrenMap = new Map(); for (const post of groupPosts) { let depth = 0; let currentUri = post.parentUri; while (currentUri && uriToPost.has(currentUri)) { depth++; currentUri = uriToPost.get(currentUri)!.parentUri; } post.depth = depth; if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []); childrenMap.get(post.parentUri)!.push(post); } childrenMap.values().forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime)); const createThread = ( posts: ThreadPost[], rootUri: ResourceUri, branchParentUri?: ResourceUri ): Thread => ({ rootUri, posts, newestTime: Math.max(...posts.map((p) => p.newestTime)), branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined }); const collectSubtree = (startPost: ThreadPost): ThreadPost[] => { const result: ThreadPost[] = []; const addWithChildren = (post: ThreadPost) => { result.push(post); const children = childrenMap.get(post.data.uri) || []; children.forEach(addWithChildren); }; addWithChildren(startPost); return result; }; const branchingPoints = Array.from(childrenMap.entries()) .filter(([, children]) => children.length > 1) .map(([uri]) => uri); const result: Thread[] = []; if (branchingPoints.length === 0) { const roots = childrenMap.get(null) || []; const allPosts = roots.flatMap((root) => collectSubtree(root)); result.push(createThread(allPosts, group.rootUri)); } else { for (const branchParentUri of branchingPoints) { const branches = childrenMap.get(branchParentUri) || []; const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime); sortedBranches.forEach((branchRoot, index) => { const isOldestBranch = index === 0; const branchPosts: ThreadPost[] = []; if (isOldestBranch && branchParentUri !== null) { const parentChain: ThreadPost[] = []; let currentUri: ResourceUri | null = branchParentUri; while (currentUri && uriToPost.has(currentUri)) { parentChain.unshift(uriToPost.get(currentUri)!); currentUri = uriToPost.get(currentUri)!.parentUri; } branchPosts.push(...parentChain); } branchPosts.push(...collectSubtree(branchRoot)); const minDepth = Math.min(...branchPosts.map((p) => p.depth)); branchPosts.forEach((p) => (p.depth = p.depth - minDepth)); result.push( createThread( branchPosts, branchRoot.data.uri, isOldestBranch ? undefined : (branchParentUri ?? undefined) ) ); }); } } return result; }; const passesPostFilter = (thread: Thread): boolean => { if (thread.posts.length === 0) return false; if (!opts.viewOwnPosts && hasNonOwnPost(thread.posts, accounts)) return false; return true; }; for (const group of sortedGroups) { if (!shouldIncludeGroup(group)) continue; const groupThreads = processGroup(group); for (const thread of groupThreads) { if (!passesPostFilter(thread)) continue; threads.push(thread); if (limit && threads.length >= limit) return threads; } } return threads; }; const extractDidFromUri = (uri: ResourceUri): Did | null => { const match = uri.match(/^at:\/\/(did:plc:[a-z0-9]+)/); return match ? (match[1] as Did) : null; };