+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
<script lang="ts">
2
-
import { follows, getClient, allPosts, allBacklinks, currentTime } from '$lib/state.svelte';
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';
9
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list';
10
import {
11
calculateFollowedUserStats,
···
13
sortFollowedUser,
14
type Sort
15
} from '$lib/following';
16
17
interface Props {
18
selectedDid: Did;
···
24
let followingSort: Sort = $state('active' as Sort);
25
const followsMap = $derived(follows.get(selectedDid));
26
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
-
});
37
38
-
class FollowedUserStats {
39
-
did: Did;
40
-
profile: Promise<string | null | undefined>;
41
-
handle: Promise<string>;
42
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
-
});
55
}
56
57
-
data = $derived.by(() =>
58
-
calculateFollowedUserStats(
59
followingSort,
60
-
this.did,
61
allPosts,
62
interactionScores,
63
-
currentTime.getTime()
64
)
65
-
);
66
-
}
67
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
-
);
75
76
let listHeight = $state(0);
77
let listContainer: HTMLDivElement | undefined = $state();
···
119
</div>
120
121
<div class="min-h-0 flex-1" bind:this={listContainer}>
122
-
{#if sortedFollowing.length === 0}
123
<div class="flex justify-center py-8">
124
<div
125
class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
···
130
<VirtualList height={listHeight} itemCount={sortedFollowing.length} itemSize={76}>
131
{#snippet item({ index, style }: { index: number; style: string })}
132
{@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>
176
{/snippet}
177
</VirtualList>
178
{/if}
···
1
<script lang="ts">
2
+
import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte';
3
import type { Did } from '@atcute/lexicons';
4
+
import { type AtpClient } from '$lib/at/client';
5
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list';
6
import {
7
calculateFollowedUserStats,
···
9
sortFollowedUser,
10
type Sort
11
} from '$lib/following';
12
+
import FollowingItem from './FollowingItem.svelte';
13
14
interface Props {
15
selectedDid: Did;
···
21
let followingSort: Sort = $state('active' as Sort);
22
const followsMap = $derived(follows.get(selectedDid));
23
24
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+
let sortedFollowing = $state<{ did: Did; data: any }[]>([]);
26
27
+
let isLongCalculation = $state(false);
28
+
let calculationTimer: ReturnType<typeof setTimeout> | undefined;
29
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;
42
}
43
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(
66
followingSort,
67
+
f.subject,
68
allPosts,
69
interactionScores,
70
+
staticNow
71
)
72
+
}));
73
+
74
+
const following = userStatsList.filter((u) => u.data !== null);
75
+
const sorted = [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!));
76
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
+
});
95
96
let listHeight = $state(0);
97
let listContainer: HTMLDivElement | undefined = $state();
···
139
</div>
140
141
<div class="min-h-0 flex-1" bind:this={listContainer}>
142
+
{#if sortedFollowing.length === 0 || isLongCalculation}
143
<div class="flex justify-center py-8">
144
<div
145
class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
···
150
<VirtualList height={listHeight} itemCount={sortedFollowing.length} itemSize={76}>
151
{#snippet item({ index, style }: { index: number; style: string })}
152
{@const user = sortedFollowing[index]}
153
+
<FollowingItem
154
+
{style}
155
+
did={user.did}
156
+
stats={user.data!}
157
+
client={selectedClient}
158
+
sort={followingSort}
159
+
{currentTime}
160
+
/>
161
{/snippet}
162
</VirtualList>
163
{/if}
+57
-23
src/components/ProfilePicture.svelte
+57
-23
src/components/ProfilePicture.svelte
···
1
<script lang="ts">
2
import { generateColorForDid } from '$lib/accounts';
3
import type { AtpClient } from '$lib/at/client';
···
5
import PfpPlaceholder from './PfpPlaceholder.svelte';
6
import { img } from '$lib/cdn';
7
import type { Did } from '@atcute/lexicons';
8
9
interface Props {
10
client: AtpClient;
···
14
15
let { client, did, size }: Props = $props();
16
17
let color = $derived(generateColorForDid(did));
18
</script>
19
20
-
{#snippet missingPfp()}
21
<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}
···
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
+
6
<script lang="ts">
7
import { generateColorForDid } from '$lib/accounts';
8
import type { AtpClient } from '$lib/at/client';
···
10
import PfpPlaceholder from './PfpPlaceholder.svelte';
11
import { img } from '$lib/cdn';
12
import type { Did } from '@atcute/lexicons';
13
+
import { SvelteMap } from 'svelte/reactivity';
14
15
interface Props {
16
client: AtpClient;
···
20
21
let { client, did, size }: Props = $props();
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
+
64
let color = $derived(generateColorForDid(did));
65
</script>
66
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}
76
<PfpPlaceholder {color} {size} />
77
+
{/if}
+139
-110
src/lib/following.ts
+139
-110
src/lib/following.ts
···
1
import { type ActorIdentifier, type Did, type ResourceUri } from '@atcute/lexicons';
2
import type { PostWithUri } from './at/fetch';
3
import type { Backlink, BacklinksSource } from './at/constellation';
4
-
import { repostSource } from '$lib';
5
import type { AppBskyGraphFollow } from '@atcute/bluesky';
6
7
export type Sort = 'recent' | 'active' | 'conversational';
···
12
statsB: NonNullable<ReturnType<typeof calculateFollowedUserStats>>
13
) => {
14
if (sort === 'conversational') {
15
-
if (Math.abs(statsB.conversationalScore - statsA.conversationalScore) > 0.1)
16
// sort based on conversational score
17
-
return statsB.conversationalScore - statsA.conversationalScore;
18
} else {
19
if (sort === 'active')
20
-
if (Math.abs(statsB.activeScore - statsA.activeScore) > 0.0001)
21
// sort based on activity
22
-
return statsB.activeScore - statsA.activeScore;
23
}
24
// use recent if scores are similar / we are using recent mode
25
-
return statsB.lastPostAt.getTime() - statsA.lastPostAt.getTime();
26
};
27
28
export const calculateFollowedUserStats = (
29
sort: Sort,
30
did: Did,
···
32
interactionScores: Map<ActorIdentifier, number> | null,
33
now: number
34
) => {
35
const postsMap = posts.get(did);
36
if (!postsMap || postsMap.size === 0) return null;
37
···
71
const replyWeight = 6;
72
const repostWeight = 2;
73
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
const oneDay = 24 * 60 * 60 * 1000;
77
const halfLifeMs = 3 * oneDay;
78
const decayLambda = 0.693 / halfLifeMs;
···
80
// normalization constants
81
const rateBaseline = 1;
82
const ratePower = 0.5;
83
-
// consider the last 7 days for rate calculation
84
const windowSize = 7 * oneDay;
85
86
export const calculateInteractionScores = (
87
user: Did,
88
followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>,
89
allPosts: Map<Did, Map<ResourceUri, PostWithUri>>,
90
backlinks_: Map<ResourceUri, Map<BacklinksSource, Set<Backlink>>>,
91
now: number
92
) => {
93
const scores = new Map<Did, number>();
···
97
return Math.exp(-decayLambda * age);
98
};
99
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;
107
108
const seenRoots = new Set<ResourceUri>();
109
-
110
-
for (const [, post] of posts) {
111
const t = new Date(post.record.createdAt).getTime();
112
-
const dec = decay(t);
113
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
-
};
142
if (post.record.reply) {
143
const parentUri = post.record.reply.parent.uri;
144
const rootUri = post.record.reply.root.uri;
145
-
processPostUri(parentUri, replyWeight);
146
-
// prevent duplicates
147
if (parentUri !== rootUri && !seenRoots.has(rootUri)) {
148
-
processPostUri(rootUri, replyWeight);
149
seenRoots.add(rootUri);
150
}
151
}
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
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;
164
}
165
-
postRates.set(did, rate);
166
-
};
167
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);
176
}
177
178
-
const followsSet = new Set(followsMap.values().map((follow) => follow.subject));
179
-
// interactions with others
180
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));
215
}
216
}
217
218
// Apply normalization
219
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)
223
scores.set(did, score / Math.pow(rate + rateBaseline, ratePower));
224
}
225
···
1
import { type ActorIdentifier, type Did, type ResourceUri } from '@atcute/lexicons';
2
import type { PostWithUri } from './at/fetch';
3
import type { Backlink, BacklinksSource } from './at/constellation';
4
+
import { extractDidFromUri, repostSource } from '$lib';
5
import type { AppBskyGraphFollow } from '@atcute/bluesky';
6
7
export type Sort = 'recent' | 'active' | 'conversational';
···
12
statsB: NonNullable<ReturnType<typeof calculateFollowedUserStats>>
13
) => {
14
if (sort === 'conversational') {
15
+
if (Math.abs(statsB.conversationalScore! - statsA.conversationalScore!) > 0.1)
16
// sort based on conversational score
17
+
return statsB.conversationalScore! - statsA.conversationalScore!;
18
} else {
19
if (sort === 'active')
20
+
if (Math.abs(statsB.activeScore! - statsA.activeScore!) > 0.0001)
21
// sort based on activity
22
+
return statsB.activeScore! - statsA.activeScore!;
23
}
24
// use recent if scores are similar / we are using recent mode
25
+
return statsB.lastPostAt!.getTime() - statsA.lastPostAt!.getTime();
26
};
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
+
35
export const calculateFollowedUserStats = (
36
sort: Sort,
37
did: Did,
···
39
interactionScores: Map<ActorIdentifier, number> | null,
40
now: number
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
+
) => {
68
const postsMap = posts.get(did);
69
if (!postsMap || postsMap.size === 0) return null;
70
···
104
const replyWeight = 6;
105
const repostWeight = 2;
106
107
const oneDay = 24 * 60 * 60 * 1000;
108
const halfLifeMs = 3 * oneDay;
109
const decayLambda = 0.693 / halfLifeMs;
···
111
// normalization constants
112
const rateBaseline = 1;
113
const ratePower = 0.5;
114
const windowSize = 7 * oneDay;
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
+
150
export const calculateInteractionScores = (
151
user: Did,
152
followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>,
153
allPosts: Map<Did, Map<ResourceUri, PostWithUri>>,
154
backlinks_: Map<ResourceUri, Map<BacklinksSource, Set<Backlink>>>,
155
+
replyIndex: Map<Did, Set<ResourceUri>>, // NEW: Inverted Index
156
now: number
157
) => {
158
const scores = new Map<Did, number>();
···
162
return Math.exp(-decayLambda * age);
163
};
164
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
+
};
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) {
175
const seenRoots = new Set<ResourceUri>();
176
+
for (const post of myPosts.values()) {
177
const t = new Date(post.record.createdAt).getTime();
178
179
+
// If I replied to someone
180
if (post.record.reply) {
181
const parentUri = post.record.reply.parent.uri;
182
const rootUri = post.record.reply.root.uri;
183
+
184
+
const targetDid = extractDidFromUri(parentUri);
185
+
if (targetDid && targetDid !== user) addScore(targetDid, replyWeight, t);
186
+
187
if (parentUri !== rootUri && !seenRoots.has(rootUri)) {
188
+
const rootDid = extractDidFromUri(rootUri);
189
+
if (rootDid && rootDid !== user) addScore(rootDid, replyWeight, t);
190
seenRoots.add(rootUri);
191
}
192
}
193
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
+
}
199
}
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?
213
214
+
const t = new Date(post.record.createdAt).getTime();
215
+
addScore(authorDid, replyWeight, t);
216
+
}
217
}
218
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
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
+
}
245
}
246
}
247
248
// Apply normalization
249
for (const [did, score] of scores) {
250
+
const posts = allPosts.get(did);
251
+
const rate = posts ? getPostRate(did, posts, now) : 0;
252
scores.set(did, score / Math.pow(rate + rateBaseline, ratePower));
253
}
254
+8
src/lib/index.ts
+8
src/lib/index.ts
···
1
import type {
2
CanonicalResourceUri,
3
ParsedCanonicalResourceUri,
4
ParsedResourceUri,
5
ResourceUri
···
12
};
13
export const toCanonicalUri = (parsed: ParsedCanonicalResourceUri): CanonicalResourceUri => {
14
return `at://${parsed.repo}/${parsed.collection}/${parsed.rkey}${parsed.fragment ? `#${parsed.fragment}` : ''}`;
15
};
16
17
export const likeSource: BacklinksSource = 'app.bsky.feed.like:subject.uri';
···
1
import type {
2
CanonicalResourceUri,
3
+
Did,
4
ParsedCanonicalResourceUri,
5
ParsedResourceUri,
6
ResourceUri
···
13
};
14
export const toCanonicalUri = (parsed: ParsedCanonicalResourceUri): CanonicalResourceUri => {
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;
23
};
24
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
import type { Backlink, BacklinksSource } from './at/constellation';
17
import { now as tidNow } from '@atcute/tid';
18
import type { Records } from '@atcute/lexicons/ambient';
19
-
import { likeSource, replySource, repostSource, timestampFromCursor } from '$lib';
20
21
export const notificationStream = writable<NotificationsStream | null>(null);
22
export const jetstream = writable<JetstreamSubscription | null>(null);
···
231
};
232
233
export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
234
235
export const addPostsRaw = (
236
did: AtprotoDid,
···
258
collection: parsedUri.collection,
259
rkey: parsedUri.rkey
260
};
261
-
if (post.record.reply) addBacklinks(post.record.reply.parent.uri, replySource, [link]);
262
}
263
};
264
···
16
import type { Backlink, BacklinksSource } from './at/constellation';
17
import { now as tidNow } from '@atcute/tid';
18
import type { Records } from '@atcute/lexicons/ambient';
19
+
import {
20
+
extractDidFromUri,
21
+
likeSource,
22
+
replySource,
23
+
repostSource,
24
+
timestampFromCursor
25
+
} from '$lib';
26
27
export const notificationStream = writable<NotificationsStream | null>(null);
28
export const jetstream = writable<JetstreamSubscription | null>(null);
···
237
};
238
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>>();
242
243
export const addPostsRaw = (
244
did: AtprotoDid,
···
266
collection: parsedUri.collection,
267
rkey: parsedUri.rkey
268
};
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
+
}
283
}
284
};
285