optimize thread building a bit more
1233456742 unmodified lines505152535454555657586 unmodified lines656667686969707172734 unmodified lines78798081828283848586868788899091919293949495969899100101979899100101102103104105412 unmodified linesExpand all<script lang="ts"> import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte'; import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; import { AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; import { parseCanonicalResourceUri, type Did, type Handle,42 unmodified lines isOnPostComposer?: boolean; onQuote?: (quote: PostWithUri) => void; onReply?: (reply: PostWithUri) => void; cornerFragment?: Snippet; isBlocked?: boolean; blockRelationship?: { userBlocked: boolean; blockedByTarget: boolean }; isMuted?: boolean; }
const {6 unmodified lines onQuote, onReply, isOnPostComposer = false /* replyBacklinks */, cornerFragment, isBlocked = false, blockRelationship = undefined, isMuted = false }: Props = $props();
const user = $derived(client.user);4 unmodified lines
let expandDisallowed = $state(false); const blockRel = $derived( user && !isOnPostComposer ? getBlockRelationship(user.did, did) ? (blockRelationship ?? getBlockRelationship(user.did, did)) : { userBlocked: false, blockedByTarget: false } ); const showAsBlocked = $derived( (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandDisallowed (blockRel.userBlocked || blockRel.blockedByTarget) && !expandDisallowed ); const showAsMuted = $derived(isMuted && !expandDisallowed);
const handle = $derived(handles.get(did) ?? 'handle.invalid'); onMount(() => { $effect(() => { resolveDidDoc(did).then((res) => { if (res.ok) handles.set(did, res.value.handle); return res; }); }); const profile = $derived(profiles.get(did)); onMount(async () => { const p = await client.getProfile(did); if (!p.ok) return; profiles.set(did, p.value); $effect(() => { client.getProfile(did).then((res) => { if (!res.ok) return; profiles.set(did, res.value); }); });
// svelte-ignore state_referenced_locally let post: Result<PostWithUri, string> = $state(data ? ok(data) : err("post couldn't be loaded"));412 unmodified linesExpand all11 unmodified lines1213141516161718192010 unmodified lines3132333435363738393940414243441 unmodified line46474849495051525354555657585950515253545556575859606162636456 unmodified lines1211221231241251261271281292 unmodified lines11 unmodified lines follows, followingCursors, initialDone } from '$lib/state.svelte'; import { buildThreads, filterThreads } from '$lib/thread'; import { buildThreadsFiltered } from '$lib/thread'; import type { Did } from '@atcute/lexicons/syntax'; import GenericTimelineView from './GenericTimelineView.svelte';
interface Props {10 unmodified lines targetDid = undefined }: Props = $props();
let viewOwnPosts = $state(true); let displayCount = $state(10);
const userDid = $derived(targetDid ?? client?.user?.did);
const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); const mutes = $derived(currentPrefs?.mutes ?? []); const mutes = $derived(new Set(currentPrefs?.mutes ?? []));
const followedDids = $derived.by(() => { if (!userDid) return new Set<Did>(); const map = follows.get(userDid);1 unmodified line return new Set(map.keys()); });
const threads = $derived( filterThreads( userDid ? buildThreads(userDid, followingFeed.get(userDid) ?? new SvelteSet(), allPosts, mutes) : [], $accounts, { viewOwnPosts, filterReplies: true, filterRootsToDids: followedDids } ) userDid ? buildThreadsFiltered( userDid, followingFeed.get(userDid) ?? new SvelteSet(), allPosts, mutes, $accounts, { viewOwnPosts, filterReplies: true, filterRootsToDids: followedDids }, displayCount ) : [] );
const isComplete = $derived.by(() => { if (!userDid) return false;56 unmodified lines {client} {threads} timelineId={`following:${userDid}`} bind:postComposerState bind:displayCount class={className} isLoggedIn={!!(userDid || $accounts.length > 0)} canLoad={!!(client && userDid && initialDone.has(userDid))} onLoadMore={loadMore}2 unmodified lines21 unmodified lines2223242526272627282930313233343 unmodified lines38394041414243444546479 unmodified lines57585960596162636492 unmodified lines15715815916016016116216316416578 unmodified lines24424524624724824925025125256 unmodified lines21 unmodified lines threads: Thread[]; timelineId?: string; postComposerState: PostComposerState; class?: string; isLoggedIn?: boolean; // Controls rendering of the list vs NotLoggedIn canLoad?: boolean; // Controls whether we can actually load more data isLoggedIn?: boolean; canLoad?: boolean; onLoadMore: () => Promise<void>; isComplete?: boolean; displayCount?: number; }
let { client = null,3 unmodified lines class: className = '', isLoggedIn = false, canLoad = undefined, onLoadMore, isComplete = false isComplete = false, displayCount = $bindable(15) }: Props = $props();
const shouldLoad = $derived(canLoad ?? isLoggedIn);
9 unmodified lines if (boundaryTime === null) return threads; return threads.filter((t) => t.newestTime <= boundaryTime!); });
let displayCount = $state(15); $effect(() => { timelineId; displayCount = 15; measuredHeights = [];92 unmodified lines } };
$effect(() => { const isEmpty = threads.length < 15; const isEmpty = threads.length < 10; if (isEmpty && !loading && shouldLoad && !isComplete) loadMore(); });
$effect(() => {78 unmodified lines postComposerState.focus = 'focused'; postComposerState.replying = post; }} {...post} blockRelationship={post.blockRelationship} /> </div> {:else if mini} {#if idx === 1}56 unmodified lines623 unmodified linesExpand all6246256266276286296306316326 unmodified lines639640641642643644645646647112 unmodified linesExpand all623 unmodified linesExpand all {:else} <!-- svelte-ignore a11y_no_static_element_interactions --> <div class="composer relative flex cursor-text items-center gap-0 py-0! transition-all hover:brightness-110" style="--acc-color: {color};" onmousedown={(e) => { if (e.defaultPrevented) return; _state.focus = 'focused'; }}6 unmodified lines onfocus={() => (_state.focus = 'focused')} type="text" placeholder="what's on your mind?" class="min-w-0 flex-1 border-none bg-transparent outline-none placeholder:text-(--nucleus-fg)/45 focus:ring-0" style="--acc-color: {color};" /> {#if _state.quoting} {@render attachmentIndicator(_state.quoting, 'quoting')} {/if}112 unmodified linesExpand all27 unmodified lines2829303132323334353613 unmodified lines27 unmodified lines else console.error(`${targetDid}: failed to load pfp: ${profile.error}`); };
$effect(() => { client; if (!client.user) return; loadProfile(did); });
const color = $derived(generateColorForDid(did));13 unmodified lines8 unmodified lines910111213131415161712 unmodified lines3031323334353637383939404142424344454643444546474849505152535455565735 unmodified lines939495969798991001012 unmodified lines8 unmodified lines timelines, fetchInteractionsToTimelineEnd, accountPreferences } from '$lib/state.svelte'; import { buildThreads, filterThreads } from '$lib/thread'; import { buildThreadsFiltered } from '$lib/thread'; import type { Did } from '@atcute/lexicons/syntax'; import GenericTimelineView from './GenericTimelineView.svelte';
interface Props {12 unmodified lines class: className = '' }: Props = $props();
let viewOwnPosts = $state(true); let displayCount = $state(10);
const userDid = $derived(client?.user?.did); const did = $derived(targetDid ?? userDid);
const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); const mutes = $derived(currentPrefs?.mutes ?? []); const mutes = $derived(new Set(currentPrefs?.mutes ?? []));
const threads = $derived( filterThreads( did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts, mutes) : [], $accounts, { viewOwnPosts } ) did && timelines.has(did) ? buildThreadsFiltered( did, timelines.get(did)!, allPosts, mutes, $accounts, { viewOwnPosts }, displayCount ) : [] );
let fetchingInteractions = $state(false); let scheduledFetchInteractions = $state(false);35 unmodified lines {client} {threads} timelineId={`replies:${did}`} bind:postComposerState bind:displayCount class={className} isLoggedIn={!!(did || $accounts.length > 0)} canLoad={!!(client && userDid && did)} onLoadMore={loadMore}2 unmodified lines517 unmodified linesExpand all518519520521522523522523524525526527528529530531532532533534535536537538539540535541542537538539540541542543544545546543544545546418 unmodified linesExpand all965966967968973969970971972977973974975976977122 unmodified linesExpand all517 unmodified linesExpand all return { did, res: res.value }; }) );
// 4. Update state const newPosts: ResourceUri[] = []; // 4. Update state - use records directly from listRecords instead of re-fetching const validPosts: PostWithUri[] = []; for (const result of results) { if (!result) continue; const { did, res } = result;
// update cursor if (res.cursor) userCursors!.set(did, res.cursor); else userCursors!.set(did, null); // null = exhausted
for (const record of res.records) newPosts.push(record.uri); for (const record of res.records) { validPosts.push({ uri: record.uri, cid: record.cid, record: record.value as AppBskyFeedPost.Main }); } }
if (newPosts.length === 0) return; if (validPosts.length === 0) return;
// fetch each post record const posts = await Promise.all( newPosts.map(async (uri) => { const result = await client.getRecordUri(AppBskyFeedPost.mainSchema, uri); if (!result.ok) return null; return { uri: result.value.uri, cid: result.value.cid, record: result.value.record }; }) );
const validPosts = posts.filter((p): p is PostWithUri => p !== null); addPosts(validPosts);
for (const post of validPosts) userFeed.add(post.uri);
418 unmodified linesExpand all
export const initialDone = new SvelteSet<Did>();export const fetchInitial = async (account: Account) => { const client = clients.get(account.did)!; await Promise.all([ await Promise.allSettled([ fetchBlocks(account), fetchForInteractions(client, account.did), fetchFollows(account).then((follows) => Promise.all(follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? []) Promise.allSettled(follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? []) ) ]); initialDone.add(account.did);};122 unmodified linesExpand all2 unmodified lines3456778910112 unmodified lines141516171818192021226 unmodified lines29303132333334353637383940414243444546474849503940515253545556575859606162466364656667505168697071725556737475767778134 unmodified linesExpand all2132142152161992172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274282 unmodified linesimport { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons';import type { Account } from './accounts';import { expect } from './result';import type { PostWithUri } from './at/fetch';import { isBlockedBy } from './state.svelte';import { isBlockedBy, getBlockRelationship } from './state.svelte';import { timestampFromCursor } from '$lib';
export type ThreadPost = { data: PostWithUri;2 unmodified lines rkey: string; parentUri: ResourceUri | null; depth: number; newestTime: number; isBlocked?: boolean; blockRelationship?: { userBlocked: boolean; blockedByTarget: boolean }; isMuted?: boolean;};
export type Thread = {6 unmodified linesexport const buildThreads = ( account: Did, timeline: Set<ResourceUri>, posts: Map<Did, Map<ResourceUri, PostWithUri>>, mutes: Did[], mutes: Set<Did>): Thread[] => { const threadMap = new Map<ResourceUri, ThreadPost[]>();
// cache block relationships for this build cycle to avoid re-computation // (isBlockedBy uses string ops and map lookups) 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; };
// group posts by root uri into "thread" chains for (const uri of timeline) { const parsedUri = expect(parseCanonicalResourceUri(uri)); const data = posts.get(parsedUri.repo)?.get(uri); // fast parse to avoid overhead of regex/validator in tight loop const parts = uri.split('/'); if (parts.length < 5) continue; const repo = parts[2] as Did; const rkey = parts[4];
const data = posts.get(repo)?.get(uri); if (!data) continue;
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
const cursorTime = timestampFromCursor(parsedUri.rkey); const cursorTime = timestampFromCursor(rkey); const blockRel = getBlockRel(repo); const post: ThreadPost = { data, account, did: parsedUri.repo, rkey: parsedUri.rkey, did: repo, rkey, parentUri, depth: 0, newestTime: cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(), isBlocked: isBlockedBy(parsedUri.repo, account), isMuted: mutes.includes(parsedUri.repo), blockRelationship: blockRel, isMuted: mutes.has(repo) };
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
134 unmodified linesExpand all
return true; });
// Helper to extract DID from URI if not already imported from elsewheretype ThreadGroup = { rootUri: ResourceUri; posts: ThreadPost[]; newestTime: number; isReply: boolean; rootDid: Did | null;};
export const buildThreadsFiltered = ( account: Did, timeline: Set<ResourceUri>, posts: Map<Did, 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: group posts by root uri, track metadata for pre-filtering const groups = new Map<ResourceUri, ThreadGroup>();
for (const uri of timeline) { const parts = uri.split('/'); if (parts.length < 5) continue; const repo = parts[2] as Did; const rkey = parts[4];
const data = posts.get(repo)?.get(uri); if (!data) continue;
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 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();
let group = groups.get(rootUri); if (!group) { group = { rootUri, posts: [], newestTime: postTime, isReply, rootDid: extractDidFromUri(rootUri) }; groups.set(rootUri, group); }
const blockRel = getBlockRel(repo); group.posts.push({ data, account, did: repo, rkey, parentUri, depth: 0, newestTime: postTime, blockRelationship: blockRel, isMuted: mutes.has(repo) });
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;};