appview-less bluesky client
24
fork

Configure Feed

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

flatten allPosts to make it easier to work with

dawn 2ef992c3 ff4752b3

+146 -261
+1 -1
src/components/BskyPost.svelte
··· 165 165 }) 166 166 .then((result) => { 167 167 if (!result.ok) return; 168 - allPosts.get(did)?.delete(aturi); 168 + allPosts.delete(aturi); 169 169 deleteState = 'deleted'; 170 170 }); 171 171 actionsOpen = false;
+1 -4
src/components/FeedTimelineView.svelte
··· 102 102 if (!userDid) return []; 103 103 const uris = feedTimelines.get(userDid)?.get(selectedFeed) ?? []; 104 104 return uris 105 - .map((uri) => { 106 - const did = uri.split('/')[2] as Did; 107 - return allPosts.get(did)?.get(uri); 108 - }) 105 + .map((uri) => allPosts.get(uri)) 109 106 .filter((p): p is NonNullable<typeof p> => p !== undefined); 110 107 }); 111 108
+24 -3
src/components/FollowingView.svelte
··· 1 1 <script lang="ts"> 2 - import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte'; 2 + import { 3 + follows, 4 + allPosts, 5 + postsByDid, 6 + allBacklinks, 7 + currentTime, 8 + replyIndex 9 + } from '$lib/state.svelte'; 3 10 import type { Did } from '@atcute/lexicons'; 4 11 import { type AtpClient } from '$lib/at/client.svelte'; 5 12 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; ··· 49 56 50 57 const interactionScores = 51 58 followingSort === 'conversational' 52 - ? calculateInteractionScores(selectedDid, allPosts, allBacklinks, replyIndex, staticNow) 59 + ? calculateInteractionScores( 60 + selectedDid, 61 + allPosts, 62 + postsByDid, 63 + allBacklinks, 64 + replyIndex, 65 + staticNow 66 + ) 53 67 : null; 54 68 55 69 const userStatsList = followsMap.keys().map((did) => ({ 56 70 did, 57 - data: calculateFollowedUserStats(followingSort, did, allPosts, interactionScores, staticNow) 71 + data: calculateFollowedUserStats( 72 + followingSort, 73 + did, 74 + allPosts, 75 + postsByDid, 76 + interactionScores, 77 + staticNow 78 + ) 58 79 })); 59 80 60 81 const following = userStatsList.filter((u) => u.data !== null);
+34 -18
src/lib/following.ts
··· 31 31 export const calculateFollowedUserStats = ( 32 32 sort: Sort, 33 33 did: Did, 34 - posts: Map<Did, Map<ResourceUri, PostWithUri>>, 34 + posts: Map<ResourceUri, PostWithUri>, 35 + postsByDid: Map<Did, Set<ResourceUri>>, 35 36 interactionScores: Map<ActorIdentifier, number> | null, 36 37 now: number 37 38 ) => { 38 39 if (sort === 'active') { 39 40 const cached = userStatsCache.get(did); 40 41 if (cached && now - cached.timestamp < STATS_CACHE_TTL) { 41 - const postsMap = posts.get(did); 42 - if (postsMap && postsMap.size > 0) return { ...cached.stats, did }; 42 + const userPostUris = postsByDid.get(did); 43 + if (userPostUris && userPostUris.size > 0) return { ...cached.stats, did }; 43 44 } 44 45 } 45 46 46 - const stats = _calculateStats(sort, did, posts, interactionScores, now); 47 + const stats = _calculateStats(sort, did, posts, postsByDid, interactionScores, now); 47 48 48 49 if (stats && sort === 'active') userStatsCache.set(did, { timestamp: now, stats }); 49 50 ··· 53 54 const _calculateStats = ( 54 55 sort: Sort, 55 56 did: Did, 56 - posts: Map<Did, Map<ResourceUri, PostWithUri>>, 57 + posts: Map<ResourceUri, PostWithUri>, 58 + postsByDid: Map<Did, Set<ResourceUri>>, 57 59 interactionScores: Map<ActorIdentifier, number> | null, 58 60 now: number 59 61 ) => { 60 - const postsMap = posts.get(did); 61 - if (!postsMap || postsMap.size === 0) return null; 62 + const userPostUris = postsByDid.get(did); 63 + if (!userPostUris || userPostUris.size === 0) return null; 62 64 63 65 let lastPostAtTime = 0; 64 66 let activeScore = 0; ··· 66 68 const quarterPosts = 6 * 60 * 60 * 1000; 67 69 const gravity = 2.0; 68 70 69 - for (const post of postsMap.values()) { 71 + for (const uri of userPostUris) { 72 + const post = posts.get(uri); 73 + if (!post) continue; 70 74 const t = new Date(post.record.createdAt).getTime(); 71 75 if (t > lastPostAtTime) lastPostAtTime = t; 72 76 const ageMs = Math.max(0, now - t); ··· 136 140 137 141 export const calculateInteractionScores = ( 138 142 user: Did, 139 - allPosts: Map<Did, Map<ResourceUri, PostWithUri>>, 143 + allPosts: Map<ResourceUri, PostWithUri>, 144 + postsByDid: Map<Did, Set<ResourceUri>>, 140 145 allBacklinks: Map<BacklinksSource, Map<ResourceUri, Map<Did, Set<string>>>>, 141 146 replyIndex: Map<Did, Set<ResourceUri>>, 142 147 now: number ··· 154 159 }; 155 160 156 161 // 1. process my posts (me -> others) 157 - const myPosts = allPosts.get(user); 158 - if (myPosts) { 162 + const myPostUris = postsByDid.get(user); 163 + if (myPostUris) { 159 164 const seenRoots = new Set<ResourceUri>(); 160 - for (const post of myPosts.values()) { 165 + for (const uri of myPostUris) { 166 + const post = allPosts.get(uri); 167 + if (!post) continue; 161 168 const t = new Date(post.record.createdAt).getTime(); 162 169 163 170 if (post.record.reply) { ··· 188 195 const authorDid = extractDidFromUri(uri); 189 196 if (!authorDid || authorDid === user) continue; 190 197 191 - const postsMap = allPosts.get(authorDid); 192 - const post = postsMap?.get(uri); 198 + const post = allPosts.get(uri); 193 199 if (!post) continue; 194 200 195 201 const t = new Date(post.record.createdAt).getTime(); ··· 199 205 200 206 // 3. process reposts on my posts 201 207 const repostBacklinks = allBacklinks.get(repostSource); 202 - if (repostBacklinks && myPosts) { 203 - for (const [uri, myPost] of myPosts) { 208 + if (repostBacklinks && myPostUris) { 209 + for (const uri of myPostUris) { 210 + const myPost = allPosts.get(uri); 211 + if (!myPost) continue; 204 212 const didMap = repostBacklinks.get(uri); 205 213 if (!didMap) continue; 206 214 ··· 230 238 231 239 // normalize by posting rate 232 240 for (const [did, score] of scores) { 233 - const posts = allPosts.get(did); 234 - const rate = posts ? getPostRate(did, posts, now) : 0; 241 + const userPostUris = postsByDid.get(did); 242 + let rate = 0; 243 + if (userPostUris) { 244 + const userPosts = new Map<ResourceUri, PostWithUri>(); 245 + for (const uri of userPostUris) { 246 + const post = allPosts.get(uri); 247 + if (post) userPosts.set(uri, post); 248 + } 249 + rate = getPostRate(did, userPosts, now); 250 + } 235 251 scores.set(did, score / Math.pow(rate + rateBaseline, ratePower)); 236 252 } 237 253
+30 -15
src/lib/state.svelte.ts
··· 677 677 }; 678 678 }; 679 679 680 - export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 680 + export const allPosts = new SvelteMap<ResourceUri, PostWithUri>(); 681 + export const postsByDid = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 681 682 export type DeletedPostInfo = { reply?: PostWithUri['record']['reply'] }; 682 683 export const deletedPosts = new SvelteMap<ResourceUri, DeletedPostInfo>(); 684 + 685 + // posts grouped by root uri for efficient thread building 686 + export const postsByRootUri = new SvelteMap<ResourceUri, SvelteSet<ResourceUri>>(); 683 687 // did -> post uris that are replies to that did 684 688 export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 685 689 686 690 export const getPost = (did: Did, rkey: RecordKey) => 687 - allPosts.get(did)?.get(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 691 + allPosts.get(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 688 692 689 - export const getPostFromUri = (uri: ResourceUri) => { 690 - const did = extractDidFromUri(uri); 691 - return did ? allPosts.get(did)?.get(uri) : undefined; 692 - }; 693 + export const getPostFromUri = (uri: ResourceUri) => allPosts.get(uri); 693 694 694 695 const hydrateCacheFn: Parameters<typeof hydratePosts>[3] = (did, rkey) => { 695 696 const cached = getPost(did, rkey); ··· 699 700 export const addPosts = (newPosts: Iterable<PostWithUri>) => { 700 701 for (const post of newPosts) { 701 702 const parsedUri = expect(parseCanonicalResourceUri(post.uri)); 702 - let posts = allPosts.get(parsedUri.repo); 703 - if (!posts) { 704 - posts = new SvelteMap(); 705 - allPosts.set(parsedUri.repo, posts); 703 + allPosts.set(post.uri, post); 704 + 705 + // update postsByDid index 706 + let didPosts = postsByDid.get(parsedUri.repo); 707 + if (!didPosts) { 708 + didPosts = new SvelteSet(); 709 + postsByDid.set(parsedUri.repo, didPosts); 710 + } 711 + didPosts.add(post.uri); 712 + 713 + // update postsByRootUri grouping 714 + const rootUri = (post.record.reply?.root.uri as ResourceUri) || post.uri; 715 + let rootGroup = postsByRootUri.get(rootUri); 716 + if (!rootGroup) { 717 + rootGroup = new SvelteSet(); 718 + postsByRootUri.set(rootUri, rootGroup); 706 719 } 707 - posts.set(post.uri, post); 720 + rootGroup.add(post.uri); 721 + 708 722 if (post.record.reply) { 709 723 const link = { 710 724 did: parsedUri.repo, ··· 730 744 731 745 export const deletePost = (uri: ResourceUri) => { 732 746 const did = extractDidFromUri(uri)!; 733 - const post = allPosts.get(did)?.get(uri); 747 + const post = allPosts.get(uri); 734 748 if (!post) return; 735 - allPosts.get(did)?.delete(uri); 749 + allPosts.delete(uri); 750 + postsByDid.get(did)?.delete(uri); 736 751 // remove reply from index 737 752 const subjectDid = extractDidFromUri(post.record.reply?.parent.uri ?? ''); 738 753 if (subjectDid) replyIndex.get(subjectDid)?.delete(uri); ··· 837 852 const result = [post.uri]; 838 853 const parentUri = post.record.reply?.parent.uri; 839 854 if (parentUri) { 840 - const parentPost = allPosts.get(extractDidFromUri(parentUri)!)?.get(parentUri); 855 + const parentPost = allPosts.get(parentUri as ResourceUri); 841 856 if (parentPost) result.push(...traversePostChain(parentPost)); 842 857 } 843 858 return result; ··· 849 864 timelines.set(did, timeline); 850 865 } 851 866 for (const uri of uris) { 852 - const post = allPosts.get(did)?.get(uri); 867 + const post = allPosts.get(uri); 853 868 // we need to traverse the post chain to add all posts in the chain to the timeline 854 869 // because the parent posts might not be in the timeline yet 855 870 const chain = post ? traversePostChain(post) : [uri];
+56 -220
src/lib/thread.ts
··· 1 - // updated src/lib/thread.ts 2 - 3 - import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons'; 1 + import { type Did, type ResourceUri } from '@atcute/lexicons'; 4 2 import type { Account } from './accounts'; 5 - import { expect } from './result'; 6 3 import type { PostWithUri } from './at/fetch'; 7 - import { isBlockedBy, getBlockRelationship } from './state.svelte'; 4 + import { getBlockRelationship, postsByRootUri } from './state.svelte'; 8 5 import { timestampFromCursor } from '$lib'; 9 6 10 7 export type ThreadPost = { ··· 26 23 branchParentPost?: ThreadPost; 27 24 }; 28 25 29 - export const buildThreads = ( 30 - account: Did, 31 - timeline: Set<ResourceUri>, 32 - posts: Map<Did, Map<ResourceUri, PostWithUri>>, 33 - mutes: Set<Did> 34 - ): Thread[] => { 35 - const threadMap = new Map<ResourceUri, ThreadPost[]>(); 36 - 37 - // cache block relationships for this build cycle to avoid re-computation 38 - // (isBlockedBy uses string ops and map lookups) 39 - const blockCache = new Map<Did, { userBlocked: boolean; blockedByTarget: boolean }>(); 40 - const getBlockRel = (target: Did) => { 41 - let rel = blockCache.get(target); 42 - if (!rel) { 43 - rel = getBlockRelationship(account, target); 44 - blockCache.set(target, rel); 45 - } 46 - return rel; 47 - }; 48 - 49 - // group posts by root uri into "thread" chains 50 - for (const uri of timeline) { 51 - // fast parse to avoid overhead of regex/validator in tight loop 52 - const parts = uri.split('/'); 53 - if (parts.length < 5) continue; 54 - const repo = parts[2] as Did; 55 - const rkey = parts[4]; 56 - 57 - const data = posts.get(repo)?.get(uri); 58 - if (!data) continue; 59 - 60 - const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 61 - const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 62 - 63 - const cursorTime = timestampFromCursor(rkey); 64 - const blockRel = getBlockRel(repo); 65 - const post: ThreadPost = { 66 - data, 67 - account, 68 - did: repo, 69 - rkey, 70 - parentUri, 71 - depth: 0, 72 - newestTime: cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(), 73 - blockRelationship: blockRel, 74 - isMuted: mutes.has(repo) 75 - }; 76 - 77 - if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); 78 - 79 - threadMap.get(rootUri)!.push(post); 80 - } 81 - 82 - const threads: Thread[] = []; 83 - 84 - for (const [rootUri, posts] of threadMap) { 85 - const uriToPost = new Map(posts.map((p) => [p.data.uri, p])); 86 - const childrenMap = new Map<ResourceUri | null, ThreadPost[]>(); 87 - 88 - // calculate depths 89 - for (const post of posts) { 90 - let depth = 0; 91 - let currentUri = post.parentUri; 92 - 93 - while (currentUri && uriToPost.has(currentUri)) { 94 - depth++; 95 - currentUri = uriToPost.get(currentUri)!.parentUri; 96 - } 97 - 98 - post.depth = depth; 99 - 100 - if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []); 101 - childrenMap.get(post.parentUri)!.push(post); 102 - } 103 - 104 - childrenMap 105 - .values() 106 - .forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime)); 107 - 108 - const createThread = ( 109 - posts: ThreadPost[], 110 - rootUri: ResourceUri, 111 - branchParentUri?: ResourceUri 112 - ): Thread => { 113 - return { 114 - rootUri, 115 - posts, 116 - newestTime: Math.max(...posts.map((p) => p.newestTime)), 117 - branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined 118 - }; 119 - }; 120 - 121 - const collectSubtree = (startPost: ThreadPost): ThreadPost[] => { 122 - const result: ThreadPost[] = []; 123 - const addWithChildren = (post: ThreadPost) => { 124 - result.push(post); 125 - const children = childrenMap.get(post.data.uri) || []; 126 - children.forEach(addWithChildren); 127 - }; 128 - addWithChildren(startPost); 129 - return result; 130 - }; 131 - 132 - // find posts with >2 children to split them into separate chains 133 - const branchingPoints = Array.from(childrenMap.entries()) 134 - .filter(([, children]) => children.length > 1) 135 - .map(([uri]) => uri); 136 - 137 - if (branchingPoints.length === 0) { 138 - const roots = childrenMap.get(null) || []; 139 - const allPosts = roots.flatMap((root) => collectSubtree(root)); 140 - threads.push(createThread(allPosts, rootUri)); 141 - } else { 142 - for (const branchParentUri of branchingPoints) { 143 - const branches = childrenMap.get(branchParentUri) || []; 144 - 145 - const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime); 146 - 147 - sortedBranches.forEach((branchRoot, index) => { 148 - const isOldestBranch = index === 0; 149 - const branchPosts: ThreadPost[] = []; 150 - 151 - // the oldest branch has the full context 152 - // todo: consider letting the user decide this..? 153 - if (isOldestBranch && branchParentUri !== null) { 154 - const parentChain: ThreadPost[] = []; 155 - let currentUri: ResourceUri | null = branchParentUri; 156 - while (currentUri && uriToPost.has(currentUri)) { 157 - parentChain.unshift(uriToPost.get(currentUri)!); 158 - currentUri = uriToPost.get(currentUri)!.parentUri; 159 - } 160 - branchPosts.push(...parentChain); 161 - } 162 - 163 - branchPosts.push(...collectSubtree(branchRoot)); 164 - 165 - const minDepth = Math.min(...branchPosts.map((p) => p.depth)); 166 - branchPosts.forEach((p) => (p.depth = p.depth - minDepth)); 167 - 168 - threads.push( 169 - createThread( 170 - branchPosts, 171 - branchRoot.data.uri, 172 - isOldestBranch ? undefined : (branchParentUri ?? undefined) 173 - ) 174 - ); 175 - }); 176 - } 177 - } 178 - } 179 - 180 - threads.sort((a, b) => b.newestTime - a.newestTime); 181 - 182 - return threads; 183 - }; 184 - 185 26 export const isOwnPost = (post: ThreadPost, accounts: Account[]) => 186 27 accounts.some((account) => account.did === post.did); 187 28 export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) => ··· 193 34 filterRootsToDids?: Set<Did>; 194 35 }; 195 36 196 - export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) => 197 - threads.filter((thread) => { 198 - if (thread.posts.length === 0) return false; 199 - if (opts.filterReplies && thread.posts[0].data.record.reply) return false; 200 - 201 - if (!opts.viewOwnPosts) if (hasNonOwnPost(thread.posts, accounts)) return false; 202 - 203 - if (opts.filterRootsToDids) { 204 - const rootDid = extractDidFromUri(thread.rootUri); 205 - if ( 206 - rootDid && 207 - !opts.filterRootsToDids.has(rootDid) && 208 - !accounts.some((a) => a.did === rootDid) 209 - ) { 210 - return false; 211 - } 212 - } 213 - 214 - return true; 215 - }); 216 - 217 37 type ThreadGroup = { 218 38 rootUri: ResourceUri; 219 39 posts: ThreadPost[]; ··· 225 45 export const buildThreadsFiltered = ( 226 46 account: Did, 227 47 timeline: Set<ResourceUri>, 228 - posts: Map<Did, Map<ResourceUri, PostWithUri>>, 48 + posts: Map<ResourceUri, PostWithUri>, 229 49 mutes: Set<Did>, 230 50 accounts: Account[], 231 51 opts: FilterOptions, ··· 241 61 return rel; 242 62 }; 243 63 244 - // phase 1: group posts by root uri, track metadata for pre-filtering 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 + 245 75 const groups = new Map<ResourceUri, ThreadGroup>(); 76 + for (const rootUri of rootUrisFromTimeline) { 77 + const postUris = postsByRootUri.get(rootUri); 78 + if (!postUris) continue; 246 79 247 - for (const uri of timeline) { 248 - const parts = uri.split('/'); 249 - if (parts.length < 5) continue; 250 - const repo = parts[2] as Did; 251 - const rkey = parts[4]; 80 + let group: ThreadGroup | undefined; 252 81 253 - const data = posts.get(repo)?.get(uri); 254 - if (!data) continue; 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]; 255 89 256 - const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 257 - const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 258 - const isReply = !!data.record.reply; 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(); 259 98 260 - const cursorTime = timestampFromCursor(rkey); 261 - const postTime = cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(); 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 + } 262 109 263 - let group = groups.get(rootUri); 264 - if (!group) { 265 - group = { 266 - rootUri, 267 - posts: [], 110 + const blockRel = getBlockRel(did); 111 + group.posts.push({ 112 + data, 113 + account, 114 + did, 115 + rkey, 116 + parentUri, 117 + depth: 0, 268 118 newestTime: postTime, 269 - isReply, 270 - rootDid: extractDidFromUri(rootUri) 271 - }; 272 - groups.set(rootUri, group); 273 - } 274 - 275 - const blockRel = getBlockRel(repo); 276 - group.posts.push({ 277 - data, 278 - account, 279 - did: repo, 280 - rkey, 281 - parentUri, 282 - depth: 0, 283 - newestTime: postTime, 284 - blockRelationship: blockRel, 285 - isMuted: mutes.has(repo) 286 - }); 119 + blockRelationship: blockRel, 120 + isMuted: mutes.has(did) 121 + }); 287 122 288 - if (postTime > group.newestTime) group.newestTime = postTime; 123 + if (postTime > group.newestTime) group.newestTime = postTime; 124 + } 289 125 } 290 126 291 127 // phase 2: sort groups by newest time descending