replies timeline only, appview-less bluesky client
at main 7.1 kB view raw
1import { type ActorIdentifier, type Did, type ResourceUri } from '@atcute/lexicons'; 2import type { PostWithUri } from './at/fetch'; 3import type { BacklinksSource } from './at/constellation'; 4import { extractDidFromUri, repostSource } from '$lib'; 5import type { AppBskyGraphFollow } from '@atcute/bluesky'; 6 7export type Sort = 'recent' | 'active' | 'conversational'; 8 9export const sortFollowedUser = ( 10 sort: Sort, 11 statsA: NonNullable<ReturnType<typeof calculateFollowedUserStats>>, 12 statsB: NonNullable<ReturnType<typeof calculateFollowedUserStats>> 13) => { 14 if (sort === 'conversational') { 15 if (Math.abs(statsB.conversationalScore! - statsA.conversationalScore!) > 0.1) 16 return statsB.conversationalScore! - statsA.conversationalScore!; 17 } else { 18 if (sort === 'active') 19 if (Math.abs(statsB.activeScore! - statsA.activeScore!) > 0.0001) 20 return statsB.activeScore! - statsA.activeScore!; 21 } 22 return statsB.lastPostAt!.getTime() - statsA.lastPostAt!.getTime(); 23}; 24 25const userStatsCache = new Map< 26 Did, 27 { timestamp: number; stats: ReturnType<typeof _calculateStats> } 28>(); 29const STATS_CACHE_TTL = 60 * 1000; 30 31export const calculateFollowedUserStats = ( 32 sort: Sort, 33 did: Did, 34 posts: Map<Did, Map<ResourceUri, PostWithUri>>, 35 interactionScores: Map<ActorIdentifier, number> | null, 36 now: number 37) => { 38 if (sort === 'active') { 39 const cached = userStatsCache.get(did); 40 if (cached && now - cached.timestamp < STATS_CACHE_TTL) { 41 const postsMap = posts.get(did); 42 if (postsMap && postsMap.size > 0) return { ...cached.stats, did }; 43 } 44 } 45 46 const stats = _calculateStats(sort, did, posts, interactionScores, now); 47 48 if (stats && sort === 'active') userStatsCache.set(did, { timestamp: now, stats }); 49 50 return stats; 51}; 52 53const _calculateStats = ( 54 sort: Sort, 55 did: Did, 56 posts: Map<Did, Map<ResourceUri, PostWithUri>>, 57 interactionScores: Map<ActorIdentifier, number> | null, 58 now: number 59) => { 60 const postsMap = posts.get(did); 61 if (!postsMap || postsMap.size === 0) return null; 62 63 let lastPostAtTime = 0; 64 let activeScore = 0; 65 let recentPostCount = 0; 66 const quarterPosts = 6 * 60 * 60 * 1000; 67 const gravity = 2.0; 68 69 for (const post of postsMap.values()) { 70 const t = new Date(post.record.createdAt).getTime(); 71 if (t > lastPostAtTime) lastPostAtTime = t; 72 const ageMs = Math.max(0, now - t); 73 if (ageMs < quarterPosts) recentPostCount++; 74 if (sort === 'active') { 75 const ageHours = ageMs / (1000 * 60 * 60); 76 activeScore += 1 / Math.pow(ageHours + 1, gravity); 77 } 78 } 79 80 let conversationalScore = 0; 81 if (sort === 'conversational' && interactionScores) 82 conversationalScore = interactionScores.get(did) || 0; 83 84 return { 85 did, 86 lastPostAt: new Date(lastPostAtTime), 87 activeScore, 88 conversationalScore, 89 recentPostCount 90 }; 91}; 92 93const quoteWeight = 4; 94const replyWeight = 6; 95const repostWeight = 2; 96 97const oneDay = 24 * 60 * 60 * 1000; 98const halfLifeMs = 3 * oneDay; 99const decayLambda = 0.693 / halfLifeMs; 100 101const rateBaseline = 1; 102const ratePower = 0.5; 103const windowSize = 7 * oneDay; 104 105const rateCache = new Map<Did, { rate: number; calculatedAt: number; postCount: number }>(); 106 107const getPostRate = (did: Did, posts: Map<ResourceUri, PostWithUri>, now: number): number => { 108 const cached = rateCache.get(did); 109 if (cached && cached.postCount === posts.size && now - cached.calculatedAt < 5 * 60 * 1000) 110 return cached.rate; 111 112 let volume = 0; 113 let minTime = now; 114 let maxTime = 0; 115 let hasRecentPosts = false; 116 117 for (const [, post] of posts) { 118 const t = new Date(post.record.createdAt).getTime(); 119 if (now - t < windowSize) { 120 volume += 1; 121 if (t < minTime) minTime = t; 122 if (t > maxTime) maxTime = t; 123 hasRecentPosts = true; 124 } 125 } 126 127 let rate = 0; 128 if (hasRecentPosts) { 129 const days = Math.max((maxTime - minTime) / oneDay, 1); 130 rate = volume / days; 131 } 132 133 rateCache.set(did, { rate, calculatedAt: now, postCount: posts.size }); 134 return rate; 135}; 136 137export const calculateInteractionScores = ( 138 user: Did, 139 followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>, 140 allPosts: Map<Did, Map<ResourceUri, PostWithUri>>, 141 allBacklinks: Map<BacklinksSource, Map<ResourceUri, Map<Did, Set<string>>>>, 142 replyIndex: Map<Did, Set<ResourceUri>>, 143 now: number 144) => { 145 const scores = new Map<Did, number>(); 146 147 const decay = (time: number) => { 148 const age = Math.max(0, now - time); 149 return Math.exp(-decayLambda * age); 150 }; 151 152 const addScore = (did: Did, weight: number, time: number) => { 153 const current = scores.get(did) ?? 0; 154 scores.set(did, current + weight * decay(time)); 155 }; 156 157 // 1. process my posts (me -> others) 158 const myPosts = allPosts.get(user); 159 if (myPosts) { 160 const seenRoots = new Set<ResourceUri>(); 161 for (const post of myPosts.values()) { 162 const t = new Date(post.record.createdAt).getTime(); 163 164 if (post.record.reply) { 165 const parentUri = post.record.reply.parent.uri; 166 const rootUri = post.record.reply.root.uri; 167 168 const targetDid = extractDidFromUri(parentUri); 169 if (targetDid && targetDid !== user) addScore(targetDid, replyWeight, t); 170 171 if (parentUri !== rootUri && !seenRoots.has(rootUri)) { 172 const rootDid = extractDidFromUri(rootUri); 173 if (rootDid && rootDid !== user) addScore(rootDid, replyWeight, t); 174 seenRoots.add(rootUri); 175 } 176 } 177 178 if (post.record.embed?.$type === 'app.bsky.embed.record') { 179 const targetDid = extractDidFromUri(post.record.embed.record.uri); 180 if (targetDid && targetDid !== user) addScore(targetDid, quoteWeight, t); 181 } 182 } 183 } 184 185 // 2. process others -> me (using reply index) 186 const repliesToMe = replyIndex.get(user); 187 if (repliesToMe) { 188 for (const uri of repliesToMe) { 189 const authorDid = extractDidFromUri(uri); 190 if (!authorDid || authorDid === user) continue; 191 192 const postsMap = allPosts.get(authorDid); 193 const post = postsMap?.get(uri); 194 if (!post) continue; 195 196 const t = new Date(post.record.createdAt).getTime(); 197 addScore(authorDid, replyWeight, t); 198 } 199 } 200 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; 207 208 const t = new Date(myPost.record.createdAt).getTime(); 209 const adds = new Map<Did, { score: number; repostCount: number }>(); 210 211 for (const [did, rkeys] of didMap) { 212 if (did === user) continue; 213 214 let add = adds.get(did) ?? { score: 0, repostCount: 0 }; 215 const diminishFactor = 9; 216 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); 226 } 227 228 for (const [did, add] of adds.entries()) addScore(did, add.score, t); 229 } 230 } 231 232 // normalize by posting rate 233 for (const [did, score] of scores) { 234 const posts = allPosts.get(did); 235 const rate = posts ? getPostRate(did, posts, now) : 0; 236 scores.set(did, score / Math.pow(rate + rateBaseline, ratePower)); 237 } 238 239 return scores; 240};