+134
src/components/FollowingItem.svelte
+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
+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
+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
+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
+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
+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