+4
-4
src/components/BskyPost.svelte
+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
+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
+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