replies timeline only, appview-less bluesky client

refactor how backlinks are stored

ptr.pet bded7c3e 1ce50124

verified
Changed files
+96 -81
src
+4 -4
src/components/BskyPost.svelte
··· 17 17 allPosts, 18 18 pulsingPostId, 19 19 currentTime, 20 - findBacklinksBy, 21 20 deletePostBacklink, 22 21 createPostBacklink, 23 22 router, 24 23 profiles, 25 - handles 24 + handles, 25 + hasBacklink 26 26 } from '$lib/state.svelte'; 27 27 import type { PostWithUri } from '$lib/at/fetch'; 28 28 import { onMount, type Snippet } from 'svelte'; ··· 315 315 {/snippet} 316 316 317 317 {#snippet postControls(post: PostWithUri)} 318 - {@const myRepost = findBacklinksBy(post.uri, repostSource, selectedDid!).length > 0} 319 - {@const myLike = findBacklinksBy(post.uri, likeSource, selectedDid!).length > 0} 318 + {@const myRepost = hasBacklink(post.uri, repostSource, selectedDid!)} 319 + {@const myLike = hasBacklink(post.uri, likeSource, selectedDid!)} 320 320 {#snippet control({ 321 321 name, 322 322 icon,
+32 -48
src/lib/following.ts
··· 1 1 import { type ActorIdentifier, type Did, type ResourceUri } from '@atcute/lexicons'; 2 2 import type { PostWithUri } from './at/fetch'; 3 - import type { Backlink, BacklinksSource } from './at/constellation'; 3 + import type { BacklinksSource } from './at/constellation'; 4 4 import { extractDidFromUri, repostSource } from '$lib'; 5 5 import type { AppBskyGraphFollow } from '@atcute/bluesky'; 6 6 ··· 13 13 ) => { 14 14 if (sort === 'conversational') { 15 15 if (Math.abs(statsB.conversationalScore! - statsA.conversationalScore!) > 0.1) 16 - // sort based on conversational score 17 16 return statsB.conversationalScore! - statsA.conversationalScore!; 18 17 } else { 19 18 if (sort === 'active') 20 19 if (Math.abs(statsB.activeScore! - statsA.activeScore!) > 0.0001) 21 - // sort based on activity 22 20 return statsB.activeScore! - statsA.activeScore!; 23 21 } 24 - // use recent if scores are similar / we are using recent mode 25 22 return statsB.lastPostAt!.getTime() - statsA.lastPostAt!.getTime(); 26 23 }; 27 24 28 - // Caching to prevent re-calculating stats for every render frame if data is stable 29 25 const userStatsCache = new Map< 30 26 Did, 31 27 { timestamp: number; stats: ReturnType<typeof _calculateStats> } 32 28 >(); 33 - const STATS_CACHE_TTL = 60 * 1000; // 1 minute 29 + const STATS_CACHE_TTL = 60 * 1000; 34 30 35 31 export const calculateFollowedUserStats = ( 36 32 sort: Sort, ··· 39 35 interactionScores: Map<ActorIdentifier, number> | null, 40 36 now: number 41 37 ) => { 42 - // For 'active' sort which is computationally heavy, use cache 43 38 if (sort === 'active') { 44 39 const cached = userStatsCache.get(did); 45 40 if (cached && now - cached.timestamp < STATS_CACHE_TTL) { 46 41 const postsMap = posts.get(did); 47 - // Simple invalidation check: if post count matches, assume cache is valid enough 48 - // This avoids iterating the map just to check contents. 49 - // Ideally we'd have a version/hash on the map. 50 42 if (postsMap && postsMap.size > 0) return { ...cached.stats, did }; 51 43 } 52 44 } ··· 81 73 if (ageMs < quarterPosts) recentPostCount++; 82 74 if (sort === 'active') { 83 75 const ageHours = ageMs / (1000 * 60 * 60); 84 - // score = 1 / t^G 85 76 activeScore += 1 / Math.pow(ageHours + 1, gravity); 86 77 } 87 78 } ··· 99 90 }; 100 91 }; 101 92 102 - // weights 103 93 const quoteWeight = 4; 104 94 const replyWeight = 6; 105 95 const repostWeight = 2; ··· 108 98 const halfLifeMs = 3 * oneDay; 109 99 const decayLambda = 0.693 / halfLifeMs; 110 100 111 - // normalization constants 112 101 const rateBaseline = 1; 113 102 const ratePower = 0.5; 114 103 const windowSize = 7 * oneDay; 115 104 116 - // Cache for post rates to avoid iterating every user's timeline every time 117 105 const rateCache = new Map<Did, { rate: number; calculatedAt: number; postCount: number }>(); 118 106 119 107 const getPostRate = (did: Did, posts: Map<ResourceUri, PostWithUri>, now: number): number => { 120 108 const cached = rateCache.get(did); 121 - // If cached and number of posts hasn't changed, return cached rate 122 109 if (cached && cached.postCount === posts.size && now - cached.calculatedAt < 5 * 60 * 1000) 123 110 return cached.rate; 124 111 ··· 151 138 user: Did, 152 139 followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>, 153 140 allPosts: Map<Did, Map<ResourceUri, PostWithUri>>, 154 - backlinks_: Map<ResourceUri, Map<BacklinksSource, Set<Backlink>>>, 155 - replyIndex: Map<Did, Set<ResourceUri>>, // NEW: Inverted Index 141 + allBacklinks: Map<BacklinksSource, Map<ResourceUri, Map<Did, Set<string>>>>, 142 + replyIndex: Map<Did, Set<ResourceUri>>, 156 143 now: number 157 144 ) => { 158 145 const scores = new Map<Did, number>(); ··· 162 149 return Math.exp(-decayLambda * age); 163 150 }; 164 151 165 - // Helper to add score 166 152 const addScore = (did: Did, weight: number, time: number) => { 167 153 const current = scores.get(did) ?? 0; 168 154 scores.set(did, current + weight * decay(time)); 169 155 }; 170 156 171 - // 1. Process MY posts (Me -> Others) 172 - // This is relatively cheap as "my posts" are few compared to "everyone's posts" 157 + // 1. process my posts (me -> others) 173 158 const myPosts = allPosts.get(user); 174 159 if (myPosts) { 175 160 const seenRoots = new Set<ResourceUri>(); 176 161 for (const post of myPosts.values()) { 177 162 const t = new Date(post.record.createdAt).getTime(); 178 163 179 - // If I replied to someone 180 164 if (post.record.reply) { 181 165 const parentUri = post.record.reply.parent.uri; 182 166 const rootUri = post.record.reply.root.uri; ··· 191 175 } 192 176 } 193 177 194 - // If I quoted someone 195 178 if (post.record.embed?.$type === 'app.bsky.embed.record') { 196 179 const targetDid = extractDidFromUri(post.record.embed.record.uri); 197 180 if (targetDid && targetDid !== user) addScore(targetDid, quoteWeight, t); ··· 199 182 } 200 183 } 201 184 202 - // 2. Process OTHERS -> ME (using Index) 203 - // Optimized: Use replyIndex instead of iterating all follows 185 + // 2. process others -> me (using reply index) 204 186 const repliesToMe = replyIndex.get(user); 205 187 if (repliesToMe) { 206 188 for (const uri of repliesToMe) { 207 189 const authorDid = extractDidFromUri(uri); 208 - if (!authorDid || authorDid === user) continue; // Self-reply 190 + if (!authorDid || authorDid === user) continue; 209 191 210 192 const postsMap = allPosts.get(authorDid); 211 193 const post = postsMap?.get(uri); 212 - if (!post) continue; // Post data not loaded? 194 + if (!post) continue; 213 195 214 196 const t = new Date(post.record.createdAt).getTime(); 215 197 addScore(authorDid, replyWeight, t); 216 198 } 217 199 } 218 200 219 - for (const [uri, backlinks] of backlinks_) { 220 - const targetDid = extractDidFromUri(uri); 221 - if (!targetDid || targetDid !== user) continue; // Only care about interactions on MY posts 201 + // 3. process reposts on my posts 202 + const repostBacklinks = allBacklinks.get(repostSource); 203 + if (repostBacklinks && myPosts) { 204 + for (const [uri, myPost] of myPosts) { 205 + const didMap = repostBacklinks.get(uri); 206 + if (!didMap) continue; 222 207 223 - const reposts = backlinks.get(repostSource); 224 - if (reposts) { 208 + const t = new Date(myPost.record.createdAt).getTime(); 225 209 const adds = new Map<Did, { score: number; repostCount: number }>(); 226 - for (const repost of reposts) { 227 - if (repost.did === user) continue; 228 - const add = adds.get(repost.did) ?? { score: 0, repostCount: 0 }; 210 + 211 + for (const [did, rkeys] of didMap) { 212 + if (did === user) continue; 213 + 214 + let add = adds.get(did) ?? { score: 0, repostCount: 0 }; 229 215 const diminishFactor = 9; 230 - const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor)); 231 - adds.set(repost.did, { 232 - score: add.score + weight, 233 - repostCount: add.repostCount + 1 234 - }); 235 - } 236 216 237 - // Get the timestamp of the post being reposted to calculate decay 238 - // (Interaction timestamp is unknown for backlinks usually, so we use post timestamp as proxy or 'now'? 239 - // Original code used `post.record.createdAt`. 240 - const myPost = myPosts?.get(uri); 241 - if (myPost) { 242 - const t = new Date(myPost.record.createdAt).getTime(); 243 - for (const [did, add] of adds.entries()) addScore(did, add.score, t); 217 + // each rkey is a separate repost record, apply diminishing returns 218 + for (let i = 0; i < rkeys.size; i++) { 219 + const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor)); 220 + add = { 221 + score: add.score + weight, 222 + repostCount: add.repostCount + 1 223 + }; 224 + } 225 + adds.set(did, add); 244 226 } 227 + 228 + for (const [did, add] of adds.entries()) addScore(did, add.score, t); 245 229 } 246 230 } 247 231 248 - // Apply normalization 232 + // normalize by posting rate 249 233 for (const [did, score] of scores) { 250 234 const posts = allPosts.get(did); 251 235 const rate = posts ? getPostRate(did, posts, now) : 0;
+60 -29
src/lib/state.svelte.ts
··· 32 32 export const profiles = new SvelteMap<Did, AppBskyActorProfile.Main>(); 33 33 export const handles = new SvelteMap<Did, Handle>(); 34 34 35 - export type BacklinksMap = SvelteMap<BacklinksSource, SvelteSet<Backlink>>; 36 - export const allBacklinks = new SvelteMap<ResourceUri, BacklinksMap>(); 35 + // source -> subject -> did (who did the interaction) -> rkey 36 + export type BacklinksMap = SvelteMap< 37 + BacklinksSource, 38 + SvelteMap<ResourceUri, SvelteMap<Did, SvelteSet<RecordKey>>> 39 + >; 40 + export const allBacklinks: BacklinksMap = new SvelteMap(); 37 41 38 42 export const addBacklinks = ( 39 43 subject: ResourceUri, 40 44 source: BacklinksSource, 41 45 links: Iterable<Backlink> 42 46 ) => { 43 - let postsMap = allBacklinks.get(subject); 44 - if (!postsMap) { 45 - postsMap = new SvelteMap(); 46 - allBacklinks.set(subject, postsMap); 47 + let subjectMap = allBacklinks.get(source); 48 + if (!subjectMap) { 49 + subjectMap = new SvelteMap(); 50 + allBacklinks.set(source, subjectMap); 47 51 } 48 - let backlinksSet = postsMap.get(source); 49 - if (!backlinksSet) { 50 - backlinksSet = new SvelteSet(); 51 - postsMap.set(source, backlinksSet); 52 + 53 + let didMap = subjectMap.get(subject); 54 + if (!didMap) { 55 + didMap = new SvelteMap(); 56 + subjectMap.set(subject, didMap); 52 57 } 58 + 53 59 for (const link of links) { 54 - backlinksSet.add(link); 55 - // console.log( 56 - // `added backlink at://${link.did}/${link.collection}/${link.rkey} to ${subject} from ${source}` 57 - // ); 60 + let rkeys = didMap.get(link.did); 61 + if (!rkeys) { 62 + rkeys = new SvelteSet(); 63 + didMap.set(link.did, rkeys); 64 + } 65 + rkeys.add(link.rkey); 58 66 } 59 67 }; 60 68 ··· 63 71 source: BacklinksSource, 64 72 links: Iterable<Backlink> 65 73 ) => { 66 - const postsMap = allBacklinks.get(subject); 67 - if (!postsMap) return; 68 - const backlinksSet = postsMap.get(source); 69 - if (!backlinksSet) return; 70 - for (const link of links) backlinksSet.delete(link); 74 + const didMap = allBacklinks.get(source)?.get(subject); 75 + if (!didMap) return; 76 + 77 + for (const link of links) { 78 + const rkeys = didMap.get(link.did); 79 + if (!rkeys) continue; 80 + rkeys.delete(link.rkey); 81 + if (rkeys.size === 0) didMap.delete(link.did); 82 + } 71 83 }; 72 84 73 - export const findBacklinksBy = ( 74 - subject: ResourceUri, 75 - source: BacklinksSource, 76 - did: Did 77 - ): Backlink[] => { 78 - const postsMap = allBacklinks.get(subject); 79 - if (!postsMap) return []; 80 - const backlinksSet = postsMap.get(source); 81 - if (!backlinksSet) return []; 82 - return Array.from(backlinksSet.values().filter((link) => link.did === did)); 85 + export const findBacklinksBy = (subject: ResourceUri, source: BacklinksSource, did: Did) => { 86 + const rkeys = allBacklinks.get(source)?.get(subject)?.get(did) ?? []; 87 + // reconstruct the collection from the source 88 + const collection = source.split(':')[0] as Nsid; 89 + return rkeys.values().map((rkey) => ({ did, collection, rkey })); 90 + }; 91 + 92 + export const hasBacklink = (subject: ResourceUri, source: BacklinksSource, did: Did): boolean => { 93 + return allBacklinks.get(source)?.get(subject)?.has(did) ?? false; 94 + }; 95 + 96 + export const getAllBacklinksFor = (subject: ResourceUri, source: BacklinksSource): Backlink[] => { 97 + const subjectMap = allBacklinks.get(source); 98 + if (!subjectMap) return []; 99 + 100 + const didMap = subjectMap.get(subject); 101 + if (!didMap) return []; 102 + 103 + const collection = source.split(':')[0] as Nsid; 104 + const result: Backlink[] = []; 105 + 106 + for (const [did, rkeys] of didMap) 107 + for (const rkey of rkeys) result.push({ did, collection, rkey }); 108 + 109 + return result; 110 + }; 111 + 112 + export const isBlockedBy = (subject: Did, blocker: Did): boolean => { 113 + return hasBacklink(`at://${subject}`, 'app.bsky.graph.block:subject', blocker); 83 114 }; 84 115 85 116 // eslint-disable-next-line @typescript-eslint/no-explicit-any