Something went wrong. Try again.
appview-less bluesky client
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267import { 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<Did>;};
type ThreadGroup = { rootUri: ResourceUri; posts: ThreadPost[]; newestTime: number; isReply: boolean; rootDid: Did | null;};
export const buildThreadsFiltered = ( account: Did, timeline: Set<ResourceUri>, posts: Map<ResourceUri, PostWithUri>, mutes: Set<Did>, accounts: Account[], opts: FilterOptions, limit?: number): Thread[] => { const blockCache = new Map<Did, { userBlocked: boolean; blockedByTarget: boolean }>(); 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<ResourceUri>(); 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<ResourceUri, ThreadGroup>(); 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<ResourceUri | null, ThreadPost[]>();
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;};