flatten allPosts to make it easier to work with
163 unmodified linesExpand all164165166167168168169170171172345 unmodified linesExpand all163 unmodified linesExpand all } }) .then((result) => { if (!result.ok) return; allPosts.get(did)?.delete(aturi); allPosts.delete(aturi); deleteState = 'deleted'; }); actionsOpen = false; };345 unmodified linesExpand all100 unmodified lines101102103104105106107108105106107108109141 unmodified linesExpand all100 unmodified lines const feedPosts = $derived.by(() => { if (!userDid) return []; const uris = feedTimelines.get(userDid)?.get(selectedFeed) ?? []; return uris .map((uri) => { const did = uri.split('/')[2] as Did; return allPosts.get(did)?.get(uri); }) .map((uri) => allPosts.get(uri)) .filter((p): p is NonNullable<typeof p> => p !== undefined); });
let measuredHeights: number[] = $state([]);141 unmodified linesExpand all12234567891011121341 unmodified lines55565758525960616263646566676869705771727374757677787980818293 unmodified lines<script lang="ts"> import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte'; import { follows, allPosts, postsByDid, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte'; import type { Did } from '@atcute/lexicons'; import { type AtpClient } from '$lib/at/client.svelte'; import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; import {41 unmodified lines await new Promise((resolve) => setTimeout(resolve, 0));
const interactionScores = followingSort === 'conversational' ? calculateInteractionScores(selectedDid, allPosts, allBacklinks, replyIndex, staticNow) ? calculateInteractionScores( selectedDid, allPosts, postsByDid, allBacklinks, replyIndex, staticNow ) : null;
const userStatsList = followsMap.keys().map((did) => ({ did, data: calculateFollowedUserStats(followingSort, did, allPosts, interactionScores, staticNow) data: calculateFollowedUserStats( followingSort, did, allPosts, postsByDid, interactionScores, staticNow ) }));
const following = userStatsList.filter((u) => u.data !== null); const sorted = [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!));93 unmodified lines29 unmodified lines30313233343435363738394041414242434445464647484950511 unmodified line535455565657585960616061626364656667686970697172737475767761 unmodified lines1391401411421391431441451461471489 unmodified lines15815916016115715816216316416016516616716816917017122 unmodified lines1941951961971911921981992002012021 unmodified line20420520620720220320820921021121221321421521 unmodified lines2372382392402332342412422432442452462472482492502512522532541 unmodified line29 unmodified lines
export const calculateFollowedUserStats = ( sort: Sort, did: Did, posts: Map<Did, Map<ResourceUri, PostWithUri>>, posts: Map<ResourceUri, PostWithUri>, postsByDid: Map<Did, Set<ResourceUri>>, interactionScores: Map<ActorIdentifier, number> | null, now: number) => { if (sort === 'active') { const cached = userStatsCache.get(did); if (cached && now - cached.timestamp < STATS_CACHE_TTL) { const postsMap = posts.get(did); if (postsMap && postsMap.size > 0) return { ...cached.stats, did }; const userPostUris = postsByDid.get(did); if (userPostUris && userPostUris.size > 0) return { ...cached.stats, did }; } }
const stats = _calculateStats(sort, did, posts, interactionScores, now); const stats = _calculateStats(sort, did, posts, postsByDid, interactionScores, now);
if (stats && sort === 'active') userStatsCache.set(did, { timestamp: now, stats });
return stats;1 unmodified line
const _calculateStats = ( sort: Sort, did: Did, posts: Map<Did, Map<ResourceUri, PostWithUri>>, posts: Map<ResourceUri, PostWithUri>, postsByDid: Map<Did, Set<ResourceUri>>, interactionScores: Map<ActorIdentifier, number> | null, now: number) => { const postsMap = posts.get(did); if (!postsMap || postsMap.size === 0) return null; const userPostUris = postsByDid.get(did); if (!userPostUris || userPostUris.size === 0) return null;
let lastPostAtTime = 0; let activeScore = 0; let recentPostCount = 0; const quarterPosts = 6 * 60 * 60 * 1000; const gravity = 2.0;
for (const post of postsMap.values()) { for (const uri of userPostUris) { const post = posts.get(uri); if (!post) continue; const t = new Date(post.record.createdAt).getTime(); if (t > lastPostAtTime) lastPostAtTime = t; const ageMs = Math.max(0, now - t); if (ageMs < quarterPosts) recentPostCount++;61 unmodified lines};
export const calculateInteractionScores = ( user: Did, allPosts: Map<Did, Map<ResourceUri, PostWithUri>>, allPosts: Map<ResourceUri, PostWithUri>, postsByDid: Map<Did, Set<ResourceUri>>, allBacklinks: Map<BacklinksSource, Map<ResourceUri, Map<Did, Set<string>>>>, replyIndex: Map<Did, Set<ResourceUri>>, now: number) => {9 unmodified lines scores.set(did, current + weight * decay(time)); };
// 1. process my posts (me -> others) const myPosts = allPosts.get(user); if (myPosts) { const myPostUris = postsByDid.get(user); if (myPostUris) { const seenRoots = new Set<ResourceUri>(); for (const post of myPosts.values()) { for (const uri of myPostUris) { const post = allPosts.get(uri); if (!post) continue; const t = new Date(post.record.createdAt).getTime();
if (post.record.reply) { const parentUri = post.record.reply.parent.uri;22 unmodified lines for (const uri of repliesToMe) { const authorDid = extractDidFromUri(uri); if (!authorDid || authorDid === user) continue;
const postsMap = allPosts.get(authorDid); const post = postsMap?.get(uri); const post = allPosts.get(uri); if (!post) continue;
const t = new Date(post.record.createdAt).getTime(); addScore(authorDid, replyWeight, t);1 unmodified line }
// 3. process reposts on my posts const repostBacklinks = allBacklinks.get(repostSource); if (repostBacklinks && myPosts) { for (const [uri, myPost] of myPosts) { if (repostBacklinks && myPostUris) { for (const uri of myPostUris) { const myPost = allPosts.get(uri); if (!myPost) continue; const didMap = repostBacklinks.get(uri); if (!didMap) continue;
const t = new Date(myPost.record.createdAt).getTime();21 unmodified lines }
// normalize by posting rate for (const [did, score] of scores) { const posts = allPosts.get(did); const rate = posts ? getPostRate(did, posts, now) : 0; const userPostUris = postsByDid.get(did); let rate = 0; if (userPostUris) { const userPosts = new Map<ResourceUri, PostWithUri>(); for (const uri of userPostUris) { const post = allPosts.get(uri); if (post) userPosts.set(uri, post); } rate = getPostRate(did, userPosts, now); } scores.set(did, score / Math.pow(rate + rateBaseline, ratePower)); }
return scores;1 unmodified line675 unmodified linesExpand all6766776786796806806816826836846856866876886896906876916926896906916926936946956966971 unmodified line69970070170270270370470570370470570670770870971070771171271371471571671771871972072172272372472517 unmodified lines74374474574673374774873574975075175275375496 unmodified lines8518528538548408558568578588593 unmodified lines863864865866852867868869870871246 unmodified linesExpand all675 unmodified linesExpand all blockedByTarget: isBlockedBy(userDid, targetDid) };};
export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();export const allPosts = new SvelteMap<ResourceUri, PostWithUri>();export const postsByDid = new SvelteMap<Did, SvelteSet<ResourceUri>>();export type DeletedPostInfo = { reply?: PostWithUri['record']['reply'] };export const deletedPosts = new SvelteMap<ResourceUri, DeletedPostInfo>();
// posts grouped by root uri for efficient thread buildingexport const postsByRootUri = new SvelteMap<ResourceUri, SvelteSet<ResourceUri>>();// did -> post uris that are replies to that didexport const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>();
export const getPost = (did: Did, rkey: RecordKey) => allPosts.get(did)?.get(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); allPosts.get(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey }));
export const getPostFromUri = (uri: ResourceUri) => { const did = extractDidFromUri(uri); return did ? allPosts.get(did)?.get(uri) : undefined;};export const getPostFromUri = (uri: ResourceUri) => allPosts.get(uri);
const hydrateCacheFn: Parameters<typeof hydratePosts>[3] = (did, rkey) => { const cached = getPost(did, rkey); return cached ? ok(cached) : undefined;1 unmodified line
export const addPosts = (newPosts: Iterable<PostWithUri>) => { for (const post of newPosts) { const parsedUri = expect(parseCanonicalResourceUri(post.uri)); let posts = allPosts.get(parsedUri.repo); if (!posts) { posts = new SvelteMap(); allPosts.set(parsedUri.repo, posts); allPosts.set(post.uri, post);
// update postsByDid index let didPosts = postsByDid.get(parsedUri.repo); if (!didPosts) { didPosts = new SvelteSet(); postsByDid.set(parsedUri.repo, didPosts); } posts.set(post.uri, post); didPosts.add(post.uri);
// update postsByRootUri grouping const rootUri = (post.record.reply?.root.uri as ResourceUri) || post.uri; let rootGroup = postsByRootUri.get(rootUri); if (!rootGroup) { rootGroup = new SvelteSet(); postsByRootUri.set(rootUri, rootGroup); } rootGroup.add(post.uri);
if (post.record.reply) { const link = { did: parsedUri.repo, collection: parsedUri.collection,17 unmodified lines};
export const deletePost = (uri: ResourceUri) => { const did = extractDidFromUri(uri)!; const post = allPosts.get(did)?.get(uri); const post = allPosts.get(uri); if (!post) return; allPosts.get(did)?.delete(uri); allPosts.delete(uri); postsByDid.get(did)?.delete(uri); // remove reply from index const subjectDid = extractDidFromUri(post.record.reply?.parent.uri ?? ''); if (subjectDid) replyIndex.get(subjectDid)?.delete(uri); deletedPosts.set(uri, { reply: post.record.reply });96 unmodified linesconst traversePostChain = (post: PostWithUri) => { const result = [post.uri]; const parentUri = post.record.reply?.parent.uri; if (parentUri) { const parentPost = allPosts.get(extractDidFromUri(parentUri)!)?.get(parentUri); const parentPost = allPosts.get(parentUri as ResourceUri); if (parentPost) result.push(...traversePostChain(parentPost)); } return result;};3 unmodified lines timeline = new SvelteSet(); timelines.set(did, timeline); } for (const uri of uris) { const post = allPosts.get(did)?.get(uri); const post = allPosts.get(uri); // we need to traverse the post chain to add all posts in the chain to the timeline // because the parent posts might not be in the timeline yet const chain = post ? traversePostChain(post) : [uri]; for (const uri of chain) timeline.add(uri);246 unmodified linesExpand all123125374567813 unmodified lines222324252930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184262728293 unmodified lines33343536196197198199200201202203204205206207208209210211212213214215216373839403 unmodified lines4445464722848495051527 unmodified lines60616263244646566676869707172737475767778792472482492502518081253254828384858687888925625725890919226026193949526326426526626726826927027127227396979827527627727827928028128228328428528699100101102103104105106107108109110111112113114115116117118119120121122288123124125126127128136 unmodified linesExpand all// updated src/lib/thread.ts
import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons';import { type Did, type ResourceUri } from '@atcute/lexicons';import type { Account } from './accounts';import { expect } from './result';import type { PostWithUri } from './at/fetch';import { isBlockedBy, getBlockRelationship } from './state.svelte';import { getBlockRelationship, postsByRootUri } from './state.svelte';import { timestampFromCursor } from '$lib';
export type ThreadPost = { data: PostWithUri;13 unmodified lines newestTime: number; branchParentPost?: ThreadPost;};
export const buildThreads = ( account: Did, timeline: Set<ResourceUri>, posts: Map<Did, Map<ResourceUri, PostWithUri>>, 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) { // 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(rkey); const blockRel = getBlockRel(repo); const post: ThreadPost = { data, account, did: repo, rkey, parentUri, depth: 0, newestTime: cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(), blockRelationship: blockRel, isMuted: mutes.has(repo) };
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
threadMap.get(rootUri)!.push(post); }
const threads: Thread[] = [];
for (const [rootUri, posts] of threadMap) { const uriToPost = new Map(posts.map((p) => [p.data.uri, p])); const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
// calculate depths for (const post of posts) { 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 => { return { 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; };
// find posts with >2 children to split them into separate chains const branchingPoints = Array.from(childrenMap.entries()) .filter(([, children]) => children.length > 1) .map(([uri]) => uri);
if (branchingPoints.length === 0) { const roots = childrenMap.get(null) || []; const allPosts = roots.flatMap((root) => collectSubtree(root)); threads.push(createThread(allPosts, 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[] = [];
// the oldest branch has the full context // todo: consider letting the user decide this..? 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));
threads.push( createThread( branchPosts, branchRoot.data.uri, isOldestBranch ? undefined : (branchParentUri ?? undefined) ) ); }); } } }
threads.sort((a, b) => b.newestTime - a.newestTime);
return threads;};
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));3 unmodified lines filterReplies?: boolean; filterRootsToDids?: Set<Did>;};
export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) => threads.filter((thread) => { if (thread.posts.length === 0) return false; if (opts.filterReplies && thread.posts[0].data.record.reply) return false;
if (!opts.viewOwnPosts) if (hasNonOwnPost(thread.posts, accounts)) return false;
if (opts.filterRootsToDids) { const rootDid = extractDidFromUri(thread.rootUri); if ( rootDid && !opts.filterRootsToDids.has(rootDid) && !accounts.some((a) => a.did === rootDid) ) { return false; } }
return true; });
type ThreadGroup = { rootUri: ResourceUri; posts: ThreadPost[]; newestTime: number;3 unmodified lines
export const buildThreadsFiltered = ( account: Did, timeline: Set<ResourceUri>, posts: Map<Did, Map<ResourceUri, PostWithUri>>, posts: Map<ResourceUri, PostWithUri>, mutes: Set<Did>, accounts: Account[], opts: FilterOptions, limit?: number7 unmodified lines } return rel; };
// phase 1: group posts by root uri, track metadata for pre-filtering // 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;
for (const uri of timeline) { const parts = uri.split('/'); if (parts.length < 5) continue; const repo = parts[2] as Did; const rkey = parts[4]; let group: ThreadGroup | undefined;
const data = posts.get(repo)?.get(uri); if (!data) continue; 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 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 data = posts.get(uri); if (!data) continue;
const cursorTime = timestampFromCursor(rkey); const postTime = cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(); const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; const isReply = !!data.record.reply;
let group = groups.get(rootUri); if (!group) { group = { rootUri, posts: [], newestTime: postTime, isReply, rootDid: extractDidFromUri(rootUri) }; groups.set(rootUri, group); } const cursorTime = timestampFromCursor(rkey); const postTime = cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime();
const blockRel = getBlockRel(repo); group.posts.push({ data, account, did: repo, rkey, parentUri, depth: 0, newestTime: postTime, blockRelationship: blockRel, isMuted: mutes.has(repo) }); 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; 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);136 unmodified linesExpand all