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};