+9
-2
src/components/BskyPost.svelte
+9
-2
src/components/BskyPost.svelte
···
24
24
import BskyPost from './BskyPost.svelte';
25
25
import Icon from '@iconify/svelte';
26
26
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
27
-
import { clients, postActions, posts, pulsingPostId, type PostActions } from '$lib/state.svelte';
27
+
import {
28
+
clients,
29
+
postActions,
30
+
posts,
31
+
pulsingPostId,
32
+
type PostActions,
33
+
currentTime
34
+
} from '$lib/state.svelte';
28
35
import * as TID from '@atcute/tid';
29
36
import type { PostWithUri } from '$lib/at/fetch';
30
37
import { onMount } from 'svelte';
···
405
412
title={new Date(record.createdAt).toLocaleString()}
406
413
class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
407
414
>
408
-
{getRelativeTime(new Date(record.createdAt))}
415
+
{getRelativeTime(new Date(record.createdAt), currentTime)}
409
416
</span>
410
417
</div>
411
418
<p class="leading-normal text-wrap wrap-break-word">
+194
-85
src/components/FollowingView.svelte
+194
-85
src/components/FollowingView.svelte
···
1
1
<script lang="ts">
2
-
import { follows, getClient, posts } from '$lib/state.svelte';
3
-
import type { Did } from '@atcute/lexicons';
2
+
import { follows, getClient, posts, postActions, currentTime } from '$lib/state.svelte';
3
+
import type { ActorIdentifier, Did, ResourceUri } from '@atcute/lexicons';
4
4
import ProfilePicture from './ProfilePicture.svelte';
5
5
import { type AtpClient, resolveDidDoc } from '$lib/at/client';
6
6
import { getRelativeTime } from '$lib/date';
7
7
import { generateColorForDid } from '$lib/accounts';
8
8
import { type AtprotoDid } from '@atcute/lexicons/syntax';
9
+
import { flip } from 'svelte/animate';
10
+
import { cubicOut } from 'svelte/easing';
9
11
10
12
interface Props {
11
13
selectedDid: Did;
···
14
16
15
17
const { selectedDid, selectedClient }: Props = $props();
16
18
17
-
const burstTimeframeMs = 1000 * 60 * 60; // 1 hour
19
+
type Sort = 'recent' | 'active' | 'conversational';
20
+
let followingSort: Sort = $state('active' as Sort);
21
+
22
+
const interactionScores = $derived.by(() => {
23
+
if (followingSort !== 'conversational') return null;
24
+
25
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
26
+
const scores = new Map<ActorIdentifier, number>();
27
+
const now = currentTime.getTime();
28
+
29
+
// Interactions are full weight for the first 3 days, then start decreasing linearly
30
+
// until 2 weeks, after which they decrease exponentially.
31
+
// Keep the same overall exponential timescale as before (half-life ~30 days).
32
+
const oneDay = 24 * 60 * 60 * 1000;
33
+
const halfLifeMs = 30 * oneDay;
34
+
const decayLambda = 0.693 / halfLifeMs;
35
+
const threeDays = 3 * oneDay;
36
+
const twoWeeks = 14 * oneDay;
37
+
38
+
const decay = (time: number) => {
39
+
const age = Math.max(0, now - time);
18
40
19
-
type FollowedAccount = {
20
-
did: Did;
21
-
lastPostAt: Date;
22
-
postsInBurst: number;
23
-
};
41
+
// Full weight for recent interactions within 3 days
42
+
if (age <= threeDays) return 1;
43
+
44
+
// Between 3 days and 2 weeks, linearly interpolate down to the value
45
+
// that the exponential would have at 2 weeks to keep continuity.
46
+
if (age <= twoWeeks) {
47
+
const expAtTwoWeeks = Math.exp(-decayLambda * twoWeeks);
48
+
const t = (age - threeDays) / (twoWeeks - threeDays); // 0..1
49
+
// linear ramp from 1 -> expAtTwoWeeks
50
+
return 1 - t * (1 - expAtTwoWeeks);
51
+
}
52
+
53
+
// After 2 weeks, exponential decay based on the chosen lambda
54
+
return Math.exp(-decayLambda * age);
55
+
};
56
+
57
+
const replyWeight = 4;
58
+
const repostWeight = 2;
59
+
const likeWeight = 1;
60
+
61
+
const myPosts = posts.get(selectedDid);
62
+
if (myPosts) {
63
+
for (const post of myPosts.values()) {
64
+
if (post.record.reply) {
65
+
const parentUri = post.record.reply.parent.uri;
66
+
// only try to extract the DID
67
+
const match = parentUri.match(/^at:\/\/([^/]+)/);
68
+
if (match) {
69
+
const targetDid = match[1] as Did;
70
+
if (targetDid === selectedDid) continue;
71
+
const s = scores.get(targetDid) || 0;
72
+
scores.set(
73
+
targetDid,
74
+
s + replyWeight * decay(new Date(post.record.createdAt).getTime())
75
+
);
76
+
}
77
+
}
78
+
}
79
+
}
80
+
81
+
// interactions with others
82
+
for (const [key, actions] of postActions) {
83
+
const sepIndex = key.indexOf(':');
84
+
if (sepIndex === -1) continue;
85
+
const did = key.slice(0, sepIndex) as Did;
86
+
const uri = key.slice(sepIndex + 1) as ResourceUri;
87
+
88
+
// only try to extract the DID
89
+
const match = uri.match(/^at:\/\/([^/]+)/);
90
+
if (!match) continue;
91
+
const targetDid = match[1] as Did;
92
+
93
+
if (did === targetDid) continue;
94
+
95
+
let add = 0;
96
+
if (actions.like) add += likeWeight;
97
+
if (actions.repost) add += repostWeight;
98
+
99
+
if (add > 0) {
100
+
const targetPosts = posts.get(targetDid);
101
+
const post = targetPosts?.get(uri);
102
+
if (post) {
103
+
const time = new Date(post.record.createdAt).getTime();
104
+
add *= decay(time);
105
+
}
106
+
scores.set(targetDid, (scores.get(targetDid) || 0) + add);
107
+
}
108
+
}
109
+
110
+
return scores;
111
+
});
24
112
25
113
class FollowedUserStats {
26
114
did: Did;
115
+
profile: Promise<string | null | undefined>;
116
+
handle: Promise<string>;
117
+
27
118
constructor(did: Did) {
28
119
this.did = did;
120
+
this.profile = getClient(did as AtprotoDid)
121
+
.then((client) => client.getProfile())
122
+
.then((profile) => {
123
+
if (profile.ok) return profile.value.displayName;
124
+
return null;
125
+
});
126
+
this.handle = resolveDidDoc(did).then((doc) => {
127
+
if (doc.ok) return doc.value.handle;
128
+
return 'handle.invalid';
129
+
});
29
130
}
30
131
31
132
data = $derived.by(() => {
···
33
134
if (!postsMap || postsMap.size === 0) return null;
34
135
35
136
let lastPostAtTime = 0;
36
-
let postsInBurst = 0;
37
-
const now = Date.now();
38
-
const timeframe = now - burstTimeframeMs;
137
+
let activeScore = 0;
138
+
let recentPostCount = 0;
139
+
const now = currentTime.getTime();
140
+
const quarterPosts = 6 * 60 * 60 * 1000;
141
+
const gravity = 2.0;
39
142
40
143
for (const post of postsMap.values()) {
41
144
const t = new Date(post.record.createdAt).getTime();
42
145
if (t > lastPostAtTime) lastPostAtTime = t;
43
-
if (t > timeframe) postsInBurst++;
146
+
const ageMs = Math.max(0, now - t);
147
+
if (ageMs < quarterPosts) recentPostCount++;
148
+
if (followingSort === 'active') {
149
+
const ageHours = ageMs / (1000 * 60 * 60);
150
+
// score = 1 / t^G
151
+
activeScore += 1 / Math.pow(ageHours + 1, gravity);
152
+
}
44
153
}
45
154
155
+
let conversationalScore = 0;
156
+
if (followingSort === 'conversational' && interactionScores)
157
+
conversationalScore = interactionScores.get(this.did) || 0;
158
+
46
159
return {
47
160
did: this.did,
48
161
lastPostAt: new Date(lastPostAtTime),
49
-
postsInBurst
162
+
activeScore,
163
+
conversationalScore,
164
+
recentPostCount
50
165
};
51
166
});
52
167
}
53
-
54
-
type Sort = 'recent' | 'active';
55
-
let followingSort: Sort = $state('active' as Sort);
56
168
57
169
const followsMap = $derived(follows.get(selectedDid));
58
170
···
60
172
followsMap ? Array.from(followsMap.values()).map((f) => new FollowedUserStats(f.subject)) : []
61
173
);
62
174
63
-
const following: FollowedAccount[] = $derived(
64
-
userStatsList.map((u) => u.data).filter((d): d is FollowedAccount => d !== null)
65
-
);
175
+
const following = $derived(userStatsList.filter((u) => u.data !== null));
66
176
67
177
const sortedFollowing = $derived(
68
178
[...following].sort((a, b) => {
69
-
if (followingSort === 'recent') {
70
-
// Sort by last post time descending, then burst descending
71
-
const timeA = a.lastPostAt.getTime();
72
-
const timeB = b.lastPostAt.getTime();
73
-
if (timeA !== timeB) return timeB - timeA;
74
-
return b.postsInBurst - a.postsInBurst;
179
+
const statsA = a.data!;
180
+
const statsB = b.data!;
181
+
if (followingSort === 'conversational') {
182
+
if (Math.abs(statsB.conversationalScore - statsA.conversationalScore) > 0.1)
183
+
// sort based on conversational score
184
+
return statsB.conversationalScore - statsA.conversationalScore;
75
185
} else {
76
-
// Sort by burst descending, then last post time descending
77
-
if (b.postsInBurst !== a.postsInBurst) return b.postsInBurst - a.postsInBurst;
78
-
return b.lastPostAt.getTime() - a.lastPostAt.getTime();
186
+
if (followingSort === 'active')
187
+
if (Math.abs(statsB.activeScore - statsA.activeScore) > 0.0001)
188
+
// sort based on activity
189
+
return statsB.activeScore - statsA.activeScore;
79
190
}
191
+
// use recent if scores are similar / we are using recent mode
192
+
return statsB.lastPostAt.getTime() - statsA.lastPostAt.getTime();
80
193
})
81
194
);
195
+
</script>
82
196
83
-
let highlightedDid: Did | undefined = $state(undefined);
84
-
</script>
197
+
{#snippet followingItems()}
198
+
{#each sortedFollowing as user (user.did)}
199
+
{@const stats = user.data!}
200
+
{@const lastPostAt = stats.lastPostAt}
201
+
{@const relTime = getRelativeTime(lastPostAt, currentTime)}
202
+
{@const color = generateColorForDid(user.did)}
203
+
<div animate:flip={{ duration: 350, easing: cubicOut }}>
204
+
<div
205
+
class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20"
206
+
style={`--post-color: ${color};`}
207
+
>
208
+
<ProfilePicture client={selectedClient} did={user.did} size={10} />
209
+
<div class="min-w-0 flex-1 space-y-1">
210
+
<div
211
+
class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)"
212
+
style={`--post-color: ${color};`}
213
+
>
214
+
{#await Promise.all([user.profile, user.handle]) then [displayName, handle]}
215
+
<span class="truncate">{displayName || handle}</span>
216
+
<span class="truncate text-sm opacity-60">@{handle}</span>
217
+
{/await}
218
+
</div>
219
+
<div class="flex gap-2 text-xs opacity-70">
220
+
<span
221
+
class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2
222
+
? 'text-(--nucleus-accent)'
223
+
: ''}
224
+
>
225
+
posted {relTime}
226
+
{relTime !== 'now' ? 'ago' : ''}
227
+
</span>
228
+
{#if stats.recentPostCount > 0}
229
+
<span class="text-(--nucleus-accent2)">
230
+
{stats.recentPostCount} posts / 6h
231
+
</span>
232
+
{/if}
233
+
{#if followingSort === 'conversational' && stats.conversationalScore > 0}
234
+
<span class="ml-auto font-bold text-(--nucleus-accent)">
235
+
★ {stats.conversationalScore.toFixed(1)}
236
+
</span>
237
+
{/if}
238
+
</div>
239
+
</div>
240
+
</div>
241
+
</div>
242
+
{/each}
243
+
{/snippet}
85
244
86
245
<div class="p-2">
87
-
<div class="mb-4 flex items-center justify-between px-2">
246
+
<div class="mb-4 flex flex-col justify-between gap-4 px-2 sm:flex-row sm:items-center">
88
247
<div>
89
248
<h2 class="text-3xl font-bold">following</h2>
90
249
<div class="mt-2 flex gap-2">
···
92
251
<div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div>
93
252
</div>
94
253
</div>
95
-
<div class="flex gap-2 text-sm">
96
-
{#each ['recent', 'active'] as Sort[] as type (type)}
254
+
<div class="flex flex-wrap gap-2 text-sm">
255
+
{#each ['recent', 'active', 'conversational'] as type (type)}
97
256
<button
98
257
class="rounded-sm px-2 py-1 transition-colors {followingSort === type
99
258
? 'bg-(--nucleus-accent) text-(--nucleus-bg)'
100
259
: 'bg-(--nucleus-accent)/10 hover:bg-(--nucleus-accent)/20'}"
101
-
onclick={() => (followingSort = type)}
260
+
onclick={() => (followingSort = type as Sort)}
102
261
>
103
262
{type}
104
263
</button>
···
115
274
></div>
116
275
</div>
117
276
{:else}
118
-
{#each sortedFollowing as user (user.did)}
119
-
{@const lastPostAt = user.lastPostAt}
120
-
{@const relTime = getRelativeTime(lastPostAt)}
121
-
{@const color = generateColorForDid(user.did)}
122
-
{@const isHighlighted = highlightedDid === user.did}
123
-
{@const displayName = getClient(user.did as AtprotoDid)
124
-
.then((client) => client.getProfile())
125
-
.then((profile) => {
126
-
if (profile.ok) return profile.value.displayName;
127
-
return null;
128
-
})}
129
-
{@const handle = resolveDidDoc(user.did).then((doc) => {
130
-
if (doc.ok) return doc.value.handle;
131
-
return 'handle.invalid';
132
-
})}
133
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
134
-
<div
135
-
class="flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors"
136
-
style={`background-color: ${isHighlighted ? `color-mix(in srgb, ${color} 20%, transparent)` : 'color-mix(in srgb, var(--nucleus-accent) 7%, transparent)'};`}
137
-
onmouseenter={() => (highlightedDid = user.did)}
138
-
onmouseleave={() => (highlightedDid = undefined)}
139
-
>
140
-
<ProfilePicture client={selectedClient} did={user.did} size={10} />
141
-
<div class="min-w-0 flex-1">
142
-
<div
143
-
class="flex items-baseline gap-2 font-bold transition-colors"
144
-
style={`${isHighlighted ? `color: ${color};` : ''}`}
145
-
>
146
-
{#await Promise.all([displayName, handle]) then [displayName, handle]}
147
-
<span class="truncate">{displayName || handle}</span>
148
-
<span class="truncate text-sm opacity-60">@{handle}</span>
149
-
{/await}
150
-
</div>
151
-
<div class="flex gap-2 text-xs opacity-70">
152
-
<span
153
-
class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2
154
-
? 'text-(--nucleus-accent)'
155
-
: ''}
156
-
>
157
-
posted {relTime}
158
-
{relTime !== 'now' ? 'ago' : ''}
159
-
</span>
160
-
{#if user.postsInBurst > 0}
161
-
<span class="font-bold text-(--nucleus-accent2)">
162
-
{user.postsInBurst} posts / 1h
163
-
</span>
164
-
{/if}
165
-
</div>
166
-
</div>
167
-
</div>
168
-
{/each}
277
+
{@render followingItems()}
169
278
{/if}
170
279
</div>
171
280
</div>
+2
-3
src/lib/date.ts
+2
-3
src/lib/date.ts
···
1
-
export const getRelativeTime = (date: Date) => {
2
-
const now = new Date();
1
+
export const getRelativeTime = (date: Date, now: Date = new Date()) => {
3
2
const diff = now.getTime() - date.getTime();
4
3
const seconds = Math.floor(diff / 1000);
5
4
const minutes = Math.floor(seconds / 60);
···
9
8
const years = Math.floor(months / 12);
10
9
11
10
if (years > 0) return `${years}y`;
12
-
if (months > 0) return `${months}m`;
11
+
if (months > 0) return `${months}mo`;
13
12
if (days > 0) return `${days}d`;
14
13
if (hours > 0) return `${hours}h`;
15
14
if (minutes > 0) return `${minutes}m`;
+9
-1
src/lib/state.svelte.ts
+9
-1
src/lib/state.svelte.ts
···
1
1
import { writable } from 'svelte/store';
2
2
import { AtpClient, newPublicClient, type NotificationsStream } from './at/client';
3
-
import { SvelteMap } from 'svelte/reactivity';
3
+
import { SvelteMap, SvelteDate } from 'svelte/reactivity';
4
4
import type { Did, InferOutput, ResourceUri } from '@atcute/lexicons';
5
5
import type { Backlink } from './at/constellation';
6
6
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch';
···
133
133
}
134
134
}
135
135
};
136
+
137
+
export const currentTime = new SvelteDate();
138
+
139
+
if (typeof window !== 'undefined') {
140
+
setInterval(() => {
141
+
currentTime.setTime(Date.now());
142
+
}, 1000);
143
+
}