replies timeline only, appview-less bluesky client

optimize following list by a ton

ptr.pet a006a151 8c5c8eeb

verified
+134
src/components/FollowingItem.svelte
··· 1 + <script lang="ts" module> 2 + // Cache for synchronous access during component recycling 3 + const profileCache = new SvelteMap<string, { displayName?: string; handle: string }>(); 4 + </script> 5 + 6 + <script lang="ts"> 7 + import ProfilePicture from './ProfilePicture.svelte'; 8 + import { getRelativeTime } from '$lib/date'; 9 + import { generateColorForDid } from '$lib/accounts'; 10 + import type { Did } from '@atcute/lexicons'; 11 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 12 + import type { calculateFollowedUserStats, Sort } from '$lib/following'; 13 + import type { AtpClient } from '$lib/at/client'; 14 + import { SvelteMap } from 'svelte/reactivity'; 15 + import { clients, getClient } from '$lib/state.svelte'; 16 + 17 + interface Props { 18 + style: string; 19 + did: Did; 20 + stats: NonNullable<ReturnType<typeof calculateFollowedUserStats>>; 21 + client: AtpClient; 22 + sort: Sort; 23 + currentTime: Date; 24 + } 25 + 26 + let { style, did, stats, client, sort, currentTime }: Props = $props(); 27 + 28 + // svelte-ignore state_referenced_locally 29 + const cached = profileCache.get(did); 30 + let displayName = $state<string | undefined>(cached?.displayName); 31 + let handle = $state<string>(cached?.handle ?? 'handle.invalid'); 32 + 33 + const loadProfile = async (targetDid: Did) => { 34 + if (profileCache.has(targetDid)) { 35 + const c = profileCache.get(targetDid)!; 36 + displayName = c.displayName; 37 + handle = c.handle; 38 + } else { 39 + const existingClient = clients.get(targetDid as AtprotoDid); 40 + if (existingClient?.user?.handle) { 41 + handle = existingClient.user.handle; 42 + } else { 43 + handle = 'handle.invalid'; 44 + displayName = undefined; 45 + } 46 + } 47 + 48 + try { 49 + // Optimization: Check clients map first to avoid async overhead if possible 50 + // but we need to ensure we have the profile data, not just client existence. 51 + const userClient = await getClient(targetDid as AtprotoDid); 52 + 53 + // Check if the component has been recycled for a different user while we were awaiting 54 + if (did !== targetDid) return; 55 + 56 + let newHandle = handle; 57 + let newDisplayName = displayName; 58 + 59 + if (userClient.user?.handle) { 60 + newHandle = userClient.user.handle; 61 + handle = newHandle; 62 + } else { 63 + newHandle = targetDid; 64 + handle = newHandle; 65 + } 66 + 67 + const profileRes = await userClient.getProfile(); 68 + 69 + if (did !== targetDid) return; 70 + 71 + if (profileRes.ok) { 72 + newDisplayName = profileRes.value.displayName; 73 + displayName = newDisplayName; 74 + } 75 + 76 + // Update cache 77 + profileCache.set(targetDid, { 78 + handle: newHandle, 79 + displayName: newDisplayName 80 + }); 81 + } catch (e) { 82 + if (did !== targetDid) return; 83 + console.error(`failed to load profile for ${targetDid}`, e); 84 + handle = 'error'; 85 + } 86 + }; 87 + 88 + // Re-run whenever `did` changes 89 + $effect(() => { 90 + loadProfile(did); 91 + }); 92 + 93 + const lastPostAt = $derived(stats?.lastPostAt ?? new Date(0)); 94 + const relTime = $derived(getRelativeTime(lastPostAt, currentTime)); 95 + const color = $derived(generateColorForDid(did)); 96 + </script> 97 + 98 + <div {style} class="box-border w-full pb-2"> 99 + <div 100 + class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 101 + style={`--post-color: ${color};`} 102 + > 103 + <ProfilePicture {client} {did} size={10} /> 104 + <div class="min-w-0 flex-1 space-y-1"> 105 + <div 106 + class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)" 107 + style={`--post-color: ${color};`} 108 + > 109 + <span class="truncate">{displayName || handle}</span> 110 + <span class="truncate text-sm opacity-60">@{handle}</span> 111 + </div> 112 + <div class="flex gap-2 text-xs opacity-70"> 113 + <span 114 + class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 115 + ? 'text-(--nucleus-accent)' 116 + : ''} 117 + > 118 + posted {relTime} 119 + {relTime !== 'now' ? 'ago' : ''} 120 + </span> 121 + {#if stats?.recentPostCount && stats.recentPostCount > 0} 122 + <span class="text-(--nucleus-accent2)"> 123 + {stats.recentPostCount} posts / 6h 124 + </span> 125 + {/if} 126 + {#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0} 127 + <span class="ml-auto font-bold text-(--nucleus-accent)"> 128 + ★ {stats.conversationalScore.toFixed(1)} 129 + </span> 130 + {/if} 131 + </div> 132 + </div> 133 + </div> 134 + </div>
+74 -89
src/components/FollowingView.svelte
··· 1 1 <script lang="ts"> 2 - import { follows, getClient, allPosts, allBacklinks, currentTime } from '$lib/state.svelte'; 2 + import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte'; 3 3 import type { Did } from '@atcute/lexicons'; 4 - import ProfilePicture from './ProfilePicture.svelte'; 5 - import { type AtpClient, resolveDidDoc } from '$lib/at/client'; 6 - import { getRelativeTime } from '$lib/date'; 7 - import { generateColorForDid } from '$lib/accounts'; 8 - import { type AtprotoDid } from '@atcute/lexicons/syntax'; 4 + import { type AtpClient } from '$lib/at/client'; 9 5 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 10 6 import { 11 7 calculateFollowedUserStats, ··· 13 9 sortFollowedUser, 14 10 type Sort 15 11 } from '$lib/following'; 12 + import FollowingItem from './FollowingItem.svelte'; 16 13 17 14 interface Props { 18 15 selectedDid: Did; ··· 24 21 let followingSort: Sort = $state('active' as Sort); 25 22 const followsMap = $derived(follows.get(selectedDid)); 26 23 27 - const interactionScores = $derived.by(() => { 28 - if (followingSort !== 'conversational') return null; 29 - return calculateInteractionScores( 30 - selectedDid, 31 - followsMap ?? new Map(), 32 - allPosts, 33 - allBacklinks, 34 - currentTime.getTime() 35 - ); 36 - }); 24 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 + let sortedFollowing = $state<{ did: Did; data: any }[]>([]); 37 26 38 - class FollowedUserStats { 39 - did: Did; 40 - profile: Promise<string | null | undefined>; 41 - handle: Promise<string>; 27 + let isLongCalculation = $state(false); 28 + let calculationTimer: ReturnType<typeof setTimeout> | undefined; 42 29 43 - constructor(did: Did) { 44 - this.did = did; 45 - this.profile = getClient(did as AtprotoDid) 46 - .then((client) => client.getProfile()) 47 - .then((profile) => { 48 - if (profile.ok) return profile.value.displayName; 49 - return null; 50 - }); 51 - this.handle = resolveDidDoc(did).then((doc) => { 52 - if (doc.ok) return doc.value.handle; 53 - return 'handle.invalid'; 54 - }); 30 + // Optimization: Use a static timestamp for calculation to avoid re-sorting every second. 31 + // Only update this when the sort mode changes. 32 + let staticNow = $state(Date.now()); 33 + 34 + const updateList = async () => { 35 + // Reset timer and loading state at start 36 + if (calculationTimer) clearTimeout(calculationTimer); 37 + isLongCalculation = false; 38 + 39 + if (!followsMap) { 40 + sortedFollowing = []; 41 + return; 55 42 } 56 43 57 - data = $derived.by(() => 58 - calculateFollowedUserStats( 44 + // schedule spinner to appear only if calculation takes > 200ms 45 + calculationTimer = setTimeout(() => { 46 + isLongCalculation = true; 47 + }, 200); 48 + // yield to main thread to allow UI to show spinner/update 49 + await new Promise((resolve) => setTimeout(resolve, 0)); 50 + 51 + const interactionScores = 52 + followingSort === 'conversational' 53 + ? calculateInteractionScores( 54 + selectedDid, 55 + followsMap, 56 + allPosts, 57 + allBacklinks, 58 + replyIndex, 59 + staticNow 60 + ) 61 + : null; 62 + 63 + const userStatsList = Array.from(followsMap.values()).map((f) => ({ 64 + did: f.subject, 65 + data: calculateFollowedUserStats( 59 66 followingSort, 60 - this.did, 67 + f.subject, 61 68 allPosts, 62 69 interactionScores, 63 - currentTime.getTime() 70 + staticNow 64 71 ) 65 - ); 66 - } 72 + })); 73 + 74 + const following = userStatsList.filter((u) => u.data !== null); 75 + const sorted = [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!)); 67 76 68 - const userStatsList = $derived( 69 - followsMap ? Array.from(followsMap.values()).map((f) => new FollowedUserStats(f.subject)) : [] 70 - ); 71 - const following = $derived(userStatsList.filter((u) => u.data !== null)); 72 - const sortedFollowing = $derived( 73 - [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!)) 74 - ); 77 + sortedFollowing = sorted; 78 + 79 + // Clear timer and remove loading state immediately after done 80 + if (calculationTimer) clearTimeout(calculationTimer); 81 + isLongCalculation = false; 82 + }; 83 + 84 + $effect(() => { 85 + // Dependencies that trigger a re-sort 86 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 87 + const _s = followingSort; 88 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 89 + const _f = followsMap; 90 + // Update time when sort changes 91 + staticNow = Date.now(); 92 + 93 + updateList(); 94 + }); 75 95 76 96 let listHeight = $state(0); 77 97 let listContainer: HTMLDivElement | undefined = $state(); ··· 119 139 </div> 120 140 121 141 <div class="min-h-0 flex-1" bind:this={listContainer}> 122 - {#if sortedFollowing.length === 0} 142 + {#if sortedFollowing.length === 0 || isLongCalculation} 123 143 <div class="flex justify-center py-8"> 124 144 <div 125 145 class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" ··· 130 150 <VirtualList height={listHeight} itemCount={sortedFollowing.length} itemSize={76}> 131 151 {#snippet item({ index, style }: { index: number; style: string })} 132 152 {@const user = sortedFollowing[index]} 133 - {@const stats = user.data!} 134 - {@const lastPostAt = stats.lastPostAt} 135 - {@const relTime = getRelativeTime(lastPostAt, currentTime)} 136 - {@const color = generateColorForDid(user.did)} 137 - <div {style} class="box-border w-full pb-2"> 138 - <div 139 - class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 140 - style={`--post-color: ${color};`} 141 - > 142 - <ProfilePicture client={selectedClient} did={user.did} size={10} /> 143 - <div class="min-w-0 flex-1 space-y-1"> 144 - <div 145 - class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)" 146 - style={`--post-color: ${color};`} 147 - > 148 - {#await Promise.all([user.profile, user.handle]) then [displayName, handle]} 149 - <span class="truncate">{displayName || handle}</span> 150 - <span class="truncate text-sm opacity-60">@{handle}</span> 151 - {/await} 152 - </div> 153 - <div class="flex gap-2 text-xs opacity-70"> 154 - <span 155 - class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 156 - ? 'text-(--nucleus-accent)' 157 - : ''} 158 - > 159 - posted {relTime} 160 - {relTime !== 'now' ? 'ago' : ''} 161 - </span> 162 - {#if stats.recentPostCount > 0} 163 - <span class="text-(--nucleus-accent2)"> 164 - {stats.recentPostCount} posts / 6h 165 - </span> 166 - {/if} 167 - {#if followingSort === 'conversational' && stats.conversationalScore > 0} 168 - <span class="ml-auto font-bold text-(--nucleus-accent)"> 169 - ★ {stats.conversationalScore.toFixed(1)} 170 - </span> 171 - {/if} 172 - </div> 173 - </div> 174 - </div> 175 - </div> 153 + <FollowingItem 154 + {style} 155 + did={user.did} 156 + stats={user.data!} 157 + client={selectedClient} 158 + sort={followingSort} 159 + {currentTime} 160 + /> 176 161 {/snippet} 177 162 </VirtualList> 178 163 {/if}
+57 -23
src/components/ProfilePicture.svelte
··· 1 + <script lang="ts" module> 2 + // Module-level cache for synchronous access during component recycling 3 + const avatarCache = new SvelteMap<string, string | null>(); 4 + </script> 5 + 1 6 <script lang="ts"> 2 7 import { generateColorForDid } from '$lib/accounts'; 3 8 import type { AtpClient } from '$lib/at/client'; ··· 5 10 import PfpPlaceholder from './PfpPlaceholder.svelte'; 6 11 import { img } from '$lib/cdn'; 7 12 import type { Did } from '@atcute/lexicons'; 13 + import { SvelteMap } from 'svelte/reactivity'; 8 14 9 15 interface Props { 10 16 client: AtpClient; ··· 14 20 15 21 let { client, did, size }: Props = $props(); 16 22 23 + // svelte-ignore state_referenced_locally 24 + let avatarUrl = $state<string | null>(avatarCache.get(did) ?? null); 25 + 26 + const loadProfile = async (targetDid: Did) => { 27 + // If we already have it in cache, we might want to re-validate eventually, 28 + // but for UI stability, using the cache is priority. 29 + // However, we still need to handle the case where we don't have it. 30 + if (avatarCache.has(targetDid)) avatarUrl = avatarCache.get(targetDid) ?? null; 31 + else avatarUrl = null; 32 + 33 + try { 34 + const profile = await client.getProfile(targetDid); 35 + 36 + if (did !== targetDid) return; 37 + 38 + if (profile.ok) { 39 + const record = profile.value; 40 + if (isBlob(record.avatar)) { 41 + const url = img('avatar_thumbnail', targetDid, record.avatar.ref.$link); 42 + avatarUrl = url; 43 + avatarCache.set(targetDid, url); 44 + } else { 45 + avatarUrl = null; 46 + avatarCache.set(targetDid, null); 47 + } 48 + } else { 49 + // Don't cache errors aggressively, or maybe cache 'null' to stop retrying? 50 + // For now, just set local state. 51 + avatarUrl = null; 52 + } 53 + } catch (e) { 54 + if (did !== targetDid) return; 55 + console.error(`${targetDid}: failed to load pfp`, e); 56 + avatarUrl = null; 57 + } 58 + }; 59 + 60 + $effect(() => { 61 + loadProfile(did); 62 + }); 63 + 17 64 let color = $derived(generateColorForDid(did)); 18 65 </script> 19 66 20 - {#snippet missingPfp()} 67 + {#if avatarUrl} 68 + <img 69 + class="rounded-sm bg-(--nucleus-accent)/10" 70 + loading="lazy" 71 + style="width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});" 72 + alt="avatar for {did}" 73 + src={avatarUrl} 74 + /> 75 + {:else} 21 76 <PfpPlaceholder {color} {size} /> 22 - {/snippet} 23 - 24 - {#await client.getProfile(did)} 25 - {@render missingPfp()} 26 - {:then profile} 27 - {#if profile.ok} 28 - {@const record = profile.value} 29 - {#if isBlob(record.avatar)} 30 - <img 31 - class="rounded-sm" 32 - loading="lazy" 33 - style="width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});" 34 - alt="avatar for {did}" 35 - src={img('avatar_thumbnail', did, record.avatar.ref.$link)} 36 - /> 37 - {:else} 38 - {@render missingPfp()} 39 - {/if} 40 - {:else} 41 - {@render missingPfp()} 42 - {/if} 43 - {/await} 77 + {/if}
+139 -110
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 3 import type { Backlink, BacklinksSource } from './at/constellation'; 4 - import { repostSource } from '$lib'; 4 + import { extractDidFromUri, repostSource } from '$lib'; 5 5 import type { AppBskyGraphFollow } from '@atcute/bluesky'; 6 6 7 7 export type Sort = 'recent' | 'active' | 'conversational'; ··· 12 12 statsB: NonNullable<ReturnType<typeof calculateFollowedUserStats>> 13 13 ) => { 14 14 if (sort === 'conversational') { 15 - if (Math.abs(statsB.conversationalScore - statsA.conversationalScore) > 0.1) 15 + if (Math.abs(statsB.conversationalScore! - statsA.conversationalScore!) > 0.1) 16 16 // sort based on conversational score 17 - return statsB.conversationalScore - statsA.conversationalScore; 17 + return statsB.conversationalScore! - statsA.conversationalScore!; 18 18 } else { 19 19 if (sort === 'active') 20 - if (Math.abs(statsB.activeScore - statsA.activeScore) > 0.0001) 20 + if (Math.abs(statsB.activeScore! - statsA.activeScore!) > 0.0001) 21 21 // sort based on activity 22 - return statsB.activeScore - statsA.activeScore; 22 + return statsB.activeScore! - statsA.activeScore!; 23 23 } 24 24 // use recent if scores are similar / we are using recent mode 25 - return statsB.lastPostAt.getTime() - statsA.lastPostAt.getTime(); 25 + return statsB.lastPostAt!.getTime() - statsA.lastPostAt!.getTime(); 26 26 }; 27 27 28 + // Caching to prevent re-calculating stats for every render frame if data is stable 29 + const userStatsCache = new Map< 30 + Did, 31 + { timestamp: number; stats: ReturnType<typeof _calculateStats> } 32 + >(); 33 + const STATS_CACHE_TTL = 60 * 1000; // 1 minute 34 + 28 35 export const calculateFollowedUserStats = ( 29 36 sort: Sort, 30 37 did: Did, ··· 32 39 interactionScores: Map<ActorIdentifier, number> | null, 33 40 now: number 34 41 ) => { 42 + // For 'active' sort which is computationally heavy, use cache 43 + if (sort === 'active') { 44 + const cached = userStatsCache.get(did); 45 + if (cached && now - cached.timestamp < STATS_CACHE_TTL) { 46 + 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 + if (postsMap && postsMap.size > 0) return { ...cached.stats, did }; 51 + } 52 + } 53 + 54 + const stats = _calculateStats(sort, did, posts, interactionScores, now); 55 + 56 + if (stats && sort === 'active') userStatsCache.set(did, { timestamp: now, stats }); 57 + 58 + return stats; 59 + }; 60 + 61 + const _calculateStats = ( 62 + sort: Sort, 63 + did: Did, 64 + posts: Map<Did, Map<ResourceUri, PostWithUri>>, 65 + interactionScores: Map<ActorIdentifier, number> | null, 66 + now: number 67 + ) => { 35 68 const postsMap = posts.get(did); 36 69 if (!postsMap || postsMap.size === 0) return null; 37 70 ··· 71 104 const replyWeight = 6; 72 105 const repostWeight = 2; 73 106 74 - // interactions decay over time to prioritize recent conversations. 75 - // half-life of 3 days ensures that inactivity (>1 days) results in a noticeable score drop. 76 107 const oneDay = 24 * 60 * 60 * 1000; 77 108 const halfLifeMs = 3 * oneDay; 78 109 const decayLambda = 0.693 / halfLifeMs; ··· 80 111 // normalization constants 81 112 const rateBaseline = 1; 82 113 const ratePower = 0.5; 83 - // consider the last 7 days for rate calculation 84 114 const windowSize = 7 * oneDay; 85 115 116 + // Cache for post rates to avoid iterating every user's timeline every time 117 + const rateCache = new Map<Did, { rate: number; calculatedAt: number; postCount: number }>(); 118 + 119 + const getPostRate = (did: Did, posts: Map<ResourceUri, PostWithUri>, now: number): number => { 120 + const cached = rateCache.get(did); 121 + // If cached and number of posts hasn't changed, return cached rate 122 + if (cached && cached.postCount === posts.size && now - cached.calculatedAt < 5 * 60 * 1000) 123 + return cached.rate; 124 + 125 + let volume = 0; 126 + let minTime = now; 127 + let maxTime = 0; 128 + let hasRecentPosts = false; 129 + 130 + for (const [, post] of posts) { 131 + const t = new Date(post.record.createdAt).getTime(); 132 + if (now - t < windowSize) { 133 + volume += 1; 134 + if (t < minTime) minTime = t; 135 + if (t > maxTime) maxTime = t; 136 + hasRecentPosts = true; 137 + } 138 + } 139 + 140 + let rate = 0; 141 + if (hasRecentPosts) { 142 + const days = Math.max((maxTime - minTime) / oneDay, 1); 143 + rate = volume / days; 144 + } 145 + 146 + rateCache.set(did, { rate, calculatedAt: now, postCount: posts.size }); 147 + return rate; 148 + }; 149 + 86 150 export const calculateInteractionScores = ( 87 151 user: Did, 88 152 followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>, 89 153 allPosts: Map<Did, Map<ResourceUri, PostWithUri>>, 90 154 backlinks_: Map<ResourceUri, Map<BacklinksSource, Set<Backlink>>>, 155 + replyIndex: Map<Did, Set<ResourceUri>>, // NEW: Inverted Index 91 156 now: number 92 157 ) => { 93 158 const scores = new Map<Did, number>(); ··· 97 162 return Math.exp(-decayLambda * age); 98 163 }; 99 164 100 - const postRates = new Map<Did, number>(); 101 - 102 - const processPosts = (did: Did, posts: Map<ResourceUri, PostWithUri>) => { 103 - let volume = 0; 104 - let minTime = now; 105 - let maxTime = 0; 106 - let hasRecentPosts = false; 165 + // Helper to add score 166 + const addScore = (did: Did, weight: number, time: number) => { 167 + const current = scores.get(did) ?? 0; 168 + scores.set(did, current + weight * decay(time)); 169 + }; 107 170 171 + // 1. Process MY posts (Me -> Others) 172 + // This is relatively cheap as "my posts" are few compared to "everyone's posts" 173 + const myPosts = allPosts.get(user); 174 + if (myPosts) { 108 175 const seenRoots = new Set<ResourceUri>(); 109 - 110 - for (const [, post] of posts) { 176 + for (const post of myPosts.values()) { 111 177 const t = new Date(post.record.createdAt).getTime(); 112 - const dec = decay(t); 113 178 114 - // Calculate rate based on raw volume over time frame 115 - // We only care about posts within the relevant window to determine "current" activity rate 116 - if (now - t < windowSize) { 117 - volume += 1; 118 - if (t < minTime) minTime = t; 119 - if (t > maxTime) maxTime = t; 120 - hasRecentPosts = true; 121 - } 122 - 123 - const processPostUri = (uri: ResourceUri, weight: number) => { 124 - // only try to extract the DID 125 - const match = uri.match(/^at:\/\/([^/]+)/); 126 - if (!match) return; 127 - const targetDid = match[1] as Did; 128 - let subjectDid = targetDid; 129 - // if we are processing posts of the user 130 - if (did === user) { 131 - // then only process posts where the user is replying to others 132 - if (targetDid === user) return; 133 - } else { 134 - // otherwise only process posts that are replies to the user 135 - if (targetDid !== user) return; 136 - subjectDid = did; 137 - } 138 - // console.log(`${subjectDid} -> ${targetDid}`); 139 - const s = scores.get(subjectDid) ?? 0; 140 - scores.set(subjectDid, s + weight * dec); 141 - }; 179 + // If I replied to someone 142 180 if (post.record.reply) { 143 181 const parentUri = post.record.reply.parent.uri; 144 182 const rootUri = post.record.reply.root.uri; 145 - processPostUri(parentUri, replyWeight); 146 - // prevent duplicates 183 + 184 + const targetDid = extractDidFromUri(parentUri); 185 + if (targetDid && targetDid !== user) addScore(targetDid, replyWeight, t); 186 + 147 187 if (parentUri !== rootUri && !seenRoots.has(rootUri)) { 148 - processPostUri(rootUri, replyWeight); 188 + const rootDid = extractDidFromUri(rootUri); 189 + if (rootDid && rootDid !== user) addScore(rootDid, replyWeight, t); 149 190 seenRoots.add(rootUri); 150 191 } 151 192 } 152 - if (post.record.embed?.$type === 'app.bsky.embed.record') 153 - processPostUri(post.record.embed.record.uri, quoteWeight); 154 - if (post.record.embed?.$type === 'app.bsky.embed.recordWithMedia') 155 - processPostUri(post.record.embed.record.record.uri, quoteWeight); 156 - } 157 193 158 - let rate = 0; 159 - if (hasRecentPosts) { 160 - // Rate = Posts / Days 161 - // Use at least 1 day to avoid skewing bursts of <24h too high 162 - const days = Math.max((maxTime - minTime) / oneDay, 1); 163 - rate = volume / days; 194 + // If I quoted someone 195 + if (post.record.embed?.$type === 'app.bsky.embed.record') { 196 + const targetDid = extractDidFromUri(post.record.embed.record.uri); 197 + if (targetDid && targetDid !== user) addScore(targetDid, quoteWeight, t); 198 + } 164 199 } 165 - postRates.set(did, rate); 166 - }; 200 + } 201 + 202 + // 2. Process OTHERS -> ME (using Index) 203 + // Optimized: Use replyIndex instead of iterating all follows 204 + const repliesToMe = replyIndex.get(user); 205 + if (repliesToMe) { 206 + for (const uri of repliesToMe) { 207 + const authorDid = extractDidFromUri(uri); 208 + if (!authorDid || authorDid === user) continue; // Self-reply 209 + 210 + const postsMap = allPosts.get(authorDid); 211 + const post = postsMap?.get(uri); 212 + if (!post) continue; // Post data not loaded? 167 213 168 - // process self 169 - const myPosts = allPosts.get(user); 170 - if (myPosts) processPosts(user, myPosts); 171 - // process following 172 - for (const follow of followsMap.values()) { 173 - const posts = allPosts.get(follow.subject); 174 - if (!posts) continue; 175 - processPosts(follow.subject, posts); 214 + const t = new Date(post.record.createdAt).getTime(); 215 + addScore(authorDid, replyWeight, t); 216 + } 176 217 } 177 218 178 - const followsSet = new Set(followsMap.values().map((follow) => follow.subject)); 179 - // interactions with others 180 219 for (const [uri, backlinks] of backlinks_) { 181 - const match = uri.match(/^at:\/\/([^/]+)/); 182 - if (!match) continue; 183 - const targetDid = match[1] as Did; 184 - // only process backlinks that target the user 185 - const isSelf = targetDid === user; 186 - // and are from users the user follows 187 - const isFollowing = followsSet.has(targetDid); 188 - if (!isSelf && !isFollowing) continue; 189 - // check if the post exists 190 - const post = allPosts.get(targetDid)?.get(uri); 191 - if (!post) continue; 192 - const reposts = backlinks.get(repostSource) ?? new Set(); 193 - const adds = new Map<Did, { score: number; repostCount: number }>(); 194 - for (const repost of reposts) { 195 - // we dont count "self interactions" 196 - if (isSelf && repost.did === user) continue; 197 - // we dont count interactions that arent the user's 198 - if (isFollowing && repost.did !== user) continue; 199 - // use targetDid for following (because it will be the following did) 200 - // use repost.did for self interactions (because it will be the following did) 201 - const did = isFollowing ? targetDid : repost.did; 202 - const add = adds.get(did) ?? { score: 0, repostCount: 0 }; 203 - // diminish the weight as the number of reposts increases 204 - const diminishFactor = 9; 205 - const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor)); 206 - adds.set(did, { 207 - score: add.score + weight, 208 - repostCount: add.repostCount + 1 209 - }); 210 - } 211 - for (const [did, add] of adds.entries()) { 212 - if (add.score === 0) continue; 213 - const time = new Date(post.record.createdAt).getTime(); 214 - scores.set(did, (scores.get(did) ?? 0) + add.score * decay(time)); 220 + const targetDid = extractDidFromUri(uri); 221 + if (!targetDid || targetDid !== user) continue; // Only care about interactions on MY posts 222 + 223 + const reposts = backlinks.get(repostSource); 224 + if (reposts) { 225 + 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 }; 229 + 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 + 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); 244 + } 215 245 } 216 246 } 217 247 218 248 // Apply normalization 219 249 for (const [did, score] of scores) { 220 - const rate = postRates.get(did) ?? 0; 221 - // NormalizedScore = DecayScore / (PostRate + Baseline)^alpha 222 - // This penalizes spammers (high rate) and inactivity (score decay vs constant rate) 250 + const posts = allPosts.get(did); 251 + const rate = posts ? getPostRate(did, posts, now) : 0; 223 252 scores.set(did, score / Math.pow(rate + rateBaseline, ratePower)); 224 253 } 225 254
+8
src/lib/index.ts
··· 1 1 import type { 2 2 CanonicalResourceUri, 3 + Did, 3 4 ParsedCanonicalResourceUri, 4 5 ParsedResourceUri, 5 6 ResourceUri ··· 12 13 }; 13 14 export const toCanonicalUri = (parsed: ParsedCanonicalResourceUri): CanonicalResourceUri => { 14 15 return `at://${parsed.repo}/${parsed.collection}/${parsed.rkey}${parsed.fragment ? `#${parsed.fragment}` : ''}`; 16 + }; 17 + 18 + export const extractDidFromUri = (uri: string): Did | null => { 19 + if (!uri.startsWith('at://')) return null; 20 + const idx = uri.indexOf('/', 5); 21 + if (idx === -1) return uri.slice(5) as Did; 22 + return uri.slice(5, idx) as Did; 15 23 }; 16 24 17 25 export const likeSource: BacklinksSource = 'app.bsky.feed.like:subject.uri';
+23 -2
src/lib/state.svelte.ts
··· 16 16 import type { Backlink, BacklinksSource } from './at/constellation'; 17 17 import { now as tidNow } from '@atcute/tid'; 18 18 import type { Records } from '@atcute/lexicons/ambient'; 19 - import { likeSource, replySource, repostSource, timestampFromCursor } from '$lib'; 19 + import { 20 + extractDidFromUri, 21 + likeSource, 22 + replySource, 23 + repostSource, 24 + timestampFromCursor 25 + } from '$lib'; 20 26 21 27 export const notificationStream = writable<NotificationsStream | null>(null); 22 28 export const jetstream = writable<JetstreamSubscription | null>(null); ··· 231 237 }; 232 238 233 239 export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 240 + // did -> post uris that are replies to that did 241 + export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 234 242 235 243 export const addPostsRaw = ( 236 244 did: AtprotoDid, ··· 258 266 collection: parsedUri.collection, 259 267 rkey: parsedUri.rkey 260 268 }; 261 - if (post.record.reply) addBacklinks(post.record.reply.parent.uri, replySource, [link]); 269 + if (post.record.reply) { 270 + addBacklinks(post.record.reply.parent.uri, replySource, [link]); 271 + 272 + // update reply index 273 + const parentDid = extractDidFromUri(post.record.reply.parent.uri); 274 + if (parentDid) { 275 + let set = replyIndex.get(parentDid); 276 + if (!set) { 277 + set = new SvelteSet(); 278 + replyIndex.set(parentDid, set); 279 + } 280 + set.add(uri); 281 + } 282 + } 262 283 } 263 284 }; 264 285