+60
-1
deno.lock
+60
-1
deno.lock
···
8
8
"npm:@atcute/client@^4.1.1": "4.1.1",
9
9
"npm:@atcute/identity-resolver@^1.2.1": "1.2.1_@atcute+identity@1.1.3",
10
10
"npm:@atcute/identity@^1.1.3": "1.1.3",
11
+
"npm:@atcute/jetstream@^1.1.2": "1.1.2",
11
12
"npm:@atcute/lexicons@^1.2.5": "1.2.5",
12
13
"npm:@atcute/oauth-browser-client@^2.0.3": "2.0.3_@atcute+identity@1.1.3",
13
14
"npm:@atcute/tid@^1.0.3": "1.0.3",
···
23
24
"npm:@tailwindcss/vite@^4.1.18": "4.1.18_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3",
24
25
"npm:@types/node@^25.0.3": "25.0.3",
25
26
"npm:@wora/cache-persist@^2.2.1": "2.2.1",
27
+
"npm:async-cache-dedupe@^3.4.0": "3.4.0",
26
28
"npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.2",
27
29
"npm:eslint-plugin-svelte@^3.13.1": "3.13.1_eslint@9.39.2_svelte@5.46.1__acorn@8.15.0_postcss@8.5.6",
28
30
"npm:eslint@^9.39.2": "9.39.2",
···
92
94
"dependencies": [
93
95
"@atcute/lexicons",
94
96
"@badrap/valita"
97
+
]
98
+
},
99
+
"@atcute/jetstream@1.1.2": {
100
+
"integrity": "sha512-u6p/h2xppp7LE6W/9xErAJ6frfN60s8adZuCKtfAaaBBiiYbb1CfpzN8Uc+2qtJZNorqGvuuDb5572Jmh7yHBQ==",
101
+
"dependencies": [
102
+
"@atcute/lexicons",
103
+
"@badrap/valita",
104
+
"@mary-ext/event-iterator",
105
+
"@mary-ext/simple-event-emitter",
106
+
"partysocket",
107
+
"type-fest",
108
+
"yocto-queue@1.2.2"
95
109
]
96
110
},
97
111
"@atcute/lexicons@1.2.5": {
···
405
419
"@jridgewell/sourcemap-codec"
406
420
]
407
421
},
422
+
"@mary-ext/event-iterator@1.0.0": {
423
+
"integrity": "sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==",
424
+
"dependencies": [
425
+
"yocto-queue@1.2.2"
426
+
]
427
+
},
428
+
"@mary-ext/simple-event-emitter@1.0.0": {
429
+
"integrity": "sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg=="
430
+
},
408
431
"@polka/url@1.0.0-next.29": {
409
432
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
410
433
},
···
843
866
"aria-query@5.3.2": {
844
867
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="
845
868
},
869
+
"async-cache-dedupe@3.4.0": {
870
+
"integrity": "sha512-RkQr21CpltqMpbYpRaEAmF1BdUO5jnnS/scZkectmLiuWQ81w8u4lYraipbQf8zQ0yYvb3U0N1ozNAYmI4jQ3g==",
871
+
"dependencies": [
872
+
"mnemonist",
873
+
"safe-stable-stringify"
874
+
]
875
+
},
846
876
"axobject-query@4.1.0": {
847
877
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
848
878
},
···
1087
1117
"esutils@2.0.3": {
1088
1118
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
1089
1119
},
1120
+
"event-target-polyfill@0.0.4": {
1121
+
"integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="
1122
+
},
1090
1123
"fast-deep-equal@3.1.3": {
1091
1124
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
1092
1125
},
···
1344
1377
"brace-expansion@2.0.2"
1345
1378
]
1346
1379
},
1380
+
"mnemonist@0.40.3": {
1381
+
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
1382
+
"dependencies": [
1383
+
"obliterator"
1384
+
]
1385
+
},
1347
1386
"mri@1.2.0": {
1348
1387
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
1349
1388
},
···
1364
1403
"natural-compare@1.4.0": {
1365
1404
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
1366
1405
},
1406
+
"obliterator@2.0.5": {
1407
+
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="
1408
+
},
1367
1409
"optionator@0.9.4": {
1368
1410
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
1369
1411
"dependencies": [
···
1378
1420
"p-limit@3.1.0": {
1379
1421
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
1380
1422
"dependencies": [
1381
-
"yocto-queue"
1423
+
"yocto-queue@0.1.0"
1382
1424
]
1383
1425
},
1384
1426
"p-locate@5.0.0": {
···
1393
1435
"callsites"
1394
1436
]
1395
1437
},
1438
+
"partysocket@1.1.10": {
1439
+
"integrity": "sha512-ACfn0P6lQuj8/AqB4L5ZDFcIEbpnIteNNObrlxqV1Ge80GTGhjuJ2sNKwNQlFzhGi4kI7fP/C1Eqh8TR78HjDQ==",
1440
+
"dependencies": [
1441
+
"event-target-polyfill"
1442
+
]
1443
+
},
1396
1444
"path-exists@4.0.0": {
1397
1445
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
1398
1446
},
···
1513
1561
"dependencies": [
1514
1562
"mri"
1515
1563
]
1564
+
},
1565
+
"safe-stable-stringify@2.5.0": {
1566
+
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
1516
1567
},
1517
1568
"semver@7.7.3": {
1518
1569
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
···
1658
1709
"prelude-ls"
1659
1710
]
1660
1711
},
1712
+
"type-fest@4.41.0": {
1713
+
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="
1714
+
},
1661
1715
"typescript-eslint@8.50.1_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.50.1__eslint@9.39.2__typescript@5.9.3": {
1662
1716
"integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==",
1663
1717
"dependencies": [
···
1729
1783
"yocto-queue@0.1.0": {
1730
1784
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
1731
1785
},
1786
+
"yocto-queue@1.2.2": {
1787
+
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="
1788
+
},
1732
1789
"zimmerframe@1.1.4": {
1733
1790
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
1734
1791
}
···
1743
1800
"npm:@atcute/client@^4.1.1",
1744
1801
"npm:@atcute/identity-resolver@^1.2.1",
1745
1802
"npm:@atcute/identity@^1.1.3",
1803
+
"npm:@atcute/jetstream@^1.1.2",
1746
1804
"npm:@atcute/lexicons@^1.2.5",
1747
1805
"npm:@atcute/oauth-browser-client@^2.0.3",
1748
1806
"npm:@atcute/tid@^1.0.3",
···
1758
1816
"npm:@tailwindcss/vite@^4.1.18",
1759
1817
"npm:@types/node@^25.0.3",
1760
1818
"npm:@wora/cache-persist@^2.2.1",
1819
+
"npm:async-cache-dedupe@^3.4.0",
1761
1820
"npm:eslint-config-prettier@^10.1.8",
1762
1821
"npm:eslint-plugin-svelte@^3.13.1",
1763
1822
"npm:eslint@^9.39.2",
+2
package.json
+2
package.json
···
21
21
"@atcute/client": "^4.1.1",
22
22
"@atcute/identity": "^1.1.3",
23
23
"@atcute/identity-resolver": "^1.2.1",
24
+
"@atcute/jetstream": "^1.1.2",
24
25
"@atcute/lexicons": "^1.2.5",
25
26
"@atcute/oauth-browser-client": "^2.0.3",
26
27
"@atcute/tid": "^1.0.3",
27
28
"@floating-ui/dom": "^1.7.4",
28
29
"@soffinal/websocket": "^0.2.1",
29
30
"@wora/cache-persist": "^2.2.1",
31
+
"async-cache-dedupe": "^3.4.0",
30
32
"hash-wasm": "^4.12.0",
31
33
"lru-cache": "^11.2.4",
32
34
"svelte-device-info": "^1.0.6",
+2
-2
src/components/AccountSelector.svelte
+2
-2
src/components/AccountSelector.svelte
···
1
1
<script lang="ts">
2
2
import { generateColorForDid, loggingIn, type Account } from '$lib/accounts';
3
-
import { AtpClient } from '$lib/at/client';
3
+
import { AtpClient, resolveHandle } from '$lib/at/client';
4
4
import type { Handle } from '@atcute/lexicons';
5
5
import ProfilePicture from './ProfilePicture.svelte';
6
6
import PfpPlaceholder from './PfpPlaceholder.svelte';
···
67
67
if (isHandle(loginHandle)) handle = loginHandle;
68
68
else throw 'handle is invalid';
69
69
70
-
let did = await client.resolveHandle(handle);
70
+
let did = await resolveHandle(handle);
71
71
if (!did.ok) throw did.error;
72
72
73
73
await initiateLogin(did.value, handle);
+5
-23
src/components/BskyPost.svelte
+5
-23
src/components/BskyPost.svelte
···
1
1
<script lang="ts">
2
-
import { type AtpClient } from '$lib/at/client';
2
+
import { resolveDidDoc, type AtpClient } from '$lib/at/client';
3
3
import {
4
4
AppBskyActorProfile,
5
5
AppBskyEmbedExternal,
···
35
35
import { type AppBskyEmbeds } from '$lib/at/types';
36
36
import { settings } from '$lib/settings';
37
37
import RichText from './RichText.svelte';
38
+
import { getRelativeTime } from '$lib/date';
38
39
39
40
interface Props {
40
41
client: AtpClient;
···
69
70
const color = generateColorForDid(did);
70
71
71
72
let handle: ActorIdentifier = $state(did);
72
-
const didDoc = client.resolveDidDoc(did).then((res) => {
73
+
const didDoc = resolveDidDoc(did).then((res) => {
73
74
if (res.ok) handle = res.value.handle;
74
75
return res;
75
76
});
···
131
132
}
132
133
};
133
134
134
-
const getRelativeTime = (date: Date) => {
135
-
const now = new Date();
136
-
const diff = now.getTime() - date.getTime();
137
-
const seconds = Math.floor(diff / 1000);
138
-
const minutes = Math.floor(seconds / 60);
139
-
const hours = Math.floor(minutes / 60);
140
-
const days = Math.floor(hours / 24);
141
-
const months = Math.floor(days / 30);
142
-
const years = Math.floor(months / 12);
143
-
144
-
if (years > 0) return `${years}y`;
145
-
if (months > 0) return `${months}m`;
146
-
if (days > 0) return `${days}d`;
147
-
if (hours > 0) return `${hours}h`;
148
-
if (minutes > 0) return `${minutes}m`;
149
-
if (seconds > 0) return `${seconds}s`;
150
-
return 'now';
151
-
};
152
-
153
135
const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => {
154
136
const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source);
155
137
if (!backlinks.ok) return null;
···
348
330
349
331
{#if profileDesc.length > 0}
350
332
<p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
351
-
<RichText text={profileDesc} {client} />
333
+
<RichText text={profileDesc} />
352
334
</p>
353
335
{/if}
354
336
</Dropdown>
···
427
409
</span>
428
410
</div>
429
411
<p class="leading-normal text-wrap wrap-break-word">
430
-
<RichText text={record.text} facets={record.facets ?? []} {client} />
412
+
<RichText text={record.text} facets={record.facets ?? []} />
431
413
{#if isOnPostComposer && record.embed}
432
414
{@render embedBadge(record.embed)}
433
415
{/if}
+171
src/components/FollowingView.svelte
+171
src/components/FollowingView.svelte
···
1
+
<script lang="ts">
2
+
import { follows, getClient, posts } 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
+
10
+
interface Props {
11
+
selectedDid: Did;
12
+
selectedClient: AtpClient;
13
+
}
14
+
15
+
const { selectedDid, selectedClient }: Props = $props();
16
+
17
+
const burstTimeframeMs = 1000 * 60 * 60; // 1 hour
18
+
19
+
type FollowedAccount = {
20
+
did: Did;
21
+
lastPostAt: Date;
22
+
postsInBurst: number;
23
+
};
24
+
25
+
class FollowedUserStats {
26
+
did: Did;
27
+
constructor(did: Did) {
28
+
this.did = did;
29
+
}
30
+
31
+
data = $derived.by(() => {
32
+
const postsMap = posts.get(this.did);
33
+
if (!postsMap || postsMap.size === 0) return null;
34
+
35
+
let lastPostAtTime = 0;
36
+
let postsInBurst = 0;
37
+
const now = Date.now();
38
+
const timeframe = now - burstTimeframeMs;
39
+
40
+
for (const post of postsMap.values()) {
41
+
const t = new Date(post.record.createdAt).getTime();
42
+
if (t > lastPostAtTime) lastPostAtTime = t;
43
+
if (t > timeframe) postsInBurst++;
44
+
}
45
+
46
+
return {
47
+
did: this.did,
48
+
lastPostAt: new Date(lastPostAtTime),
49
+
postsInBurst
50
+
};
51
+
});
52
+
}
53
+
54
+
type Sort = 'recent' | 'active';
55
+
let followingSort: Sort = $state('active' as Sort);
56
+
57
+
const followsMap = $derived(follows.get(selectedDid));
58
+
59
+
const userStatsList = $derived(
60
+
followsMap ? Array.from(followsMap.values()).map((f) => new FollowedUserStats(f.subject)) : []
61
+
);
62
+
63
+
const following: FollowedAccount[] = $derived(
64
+
userStatsList.map((u) => u.data).filter((d): d is FollowedAccount => d !== null)
65
+
);
66
+
67
+
const sortedFollowing = $derived(
68
+
[...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;
75
+
} 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();
79
+
}
80
+
})
81
+
);
82
+
83
+
let highlightedDid: Did | undefined = $state(undefined);
84
+
</script>
85
+
86
+
<div class="p-2">
87
+
<div class="mb-4 flex items-center justify-between px-2">
88
+
<div>
89
+
<h2 class="text-3xl font-bold">following</h2>
90
+
<div class="mt-2 flex gap-2">
91
+
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div>
92
+
<div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div>
93
+
</div>
94
+
</div>
95
+
<div class="flex gap-2 text-sm">
96
+
{#each ['recent', 'active'] as Sort[] as type (type)}
97
+
<button
98
+
class="rounded-sm px-2 py-1 transition-colors {followingSort === type
99
+
? 'bg-(--nucleus-accent) text-(--nucleus-bg)'
100
+
: 'bg-(--nucleus-accent)/10 hover:bg-(--nucleus-accent)/20'}"
101
+
onclick={() => (followingSort = type)}
102
+
>
103
+
{type}
104
+
</button>
105
+
{/each}
106
+
</div>
107
+
</div>
108
+
109
+
<div class="flex flex-col gap-2">
110
+
{#if sortedFollowing.length === 0}
111
+
<div class="flex justify-center py-8">
112
+
<div
113
+
class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
114
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
115
+
></div>
116
+
</div>
117
+
{: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}
169
+
{/if}
170
+
</div>
171
+
</div>
+1
-1
src/components/PostComposer.svelte
+1
-1
src/components/PostComposer.svelte
+1
src/components/ProfilePicture.svelte
+1
src/components/ProfilePicture.svelte
+2
-4
src/components/RichText.svelte
+2
-4
src/components/RichText.svelte
···
1
1
<script lang="ts">
2
-
import type { AtpClient } from '$lib/at/client';
3
2
import { parseToRichText } from '$lib/richtext';
4
3
import { settings } from '$lib/settings';
5
4
import type { BakedRichtext } from '@atcute/bluesky-richtext-builder';
···
8
7
interface Props {
9
8
text: string;
10
9
facets?: Facet[];
11
-
client: AtpClient;
12
10
}
13
11
14
-
const { text, facets, client }: Props = $props();
12
+
const { text, facets }: Props = $props();
15
13
16
14
const richtext: Promise<BakedRichtext> = $derived(
17
-
facets ? Promise.resolve({ text, facets }) : parseToRichText(client, text)
15
+
facets ? Promise.resolve({ text, facets }) : parseToRichText(text)
18
16
);
19
17
</script>
20
18
+6
-9
src/components/SettingsView.svelte
+6
-9
src/components/SettingsView.svelte
···
1
1
<script lang="ts">
2
2
import { defaultSettings, needsReload, settings } from '$lib/settings';
3
-
import { handleCache, didDocCache, recordCache } from '$lib/at/client';
4
3
import { get } from 'svelte/store';
5
4
import ColorPicker from 'svelte-awesome-color-picker';
6
5
import Tabs from './Tabs.svelte';
7
6
import { portal } from 'svelte-portal';
7
+
import { cache } from '$lib/cache';
8
8
9
9
type Tab = 'style' | 'moderation' | 'advanced';
10
10
let activeTab = $state<Tab>('advanced');
···
29
29
};
30
30
31
31
const handleClearCache = () => {
32
-
handleCache.clear();
33
-
didDocCache.clear();
34
-
recordCache.clear();
32
+
cache.clear();
35
33
alert('cache cleared!');
36
34
};
37
35
</script>
38
36
39
-
{#snippet divider()}
40
-
<div class="h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
41
-
{/snippet}
42
-
43
37
{#snippet advancedTab()}
44
38
<div class="space-y-3 p-4">
45
39
<div>
···
62
56
{@render _input('slingshot', 'slingshot url (for fetching records & resolving identity)')}
63
57
{@render _input('spacedust', 'spacedust url (for notifications)')}
64
58
{@render _input('constellation', 'constellation url (for backlinks)')}
59
+
{@render _input('jetstream', 'jetstream url (for real-time updates)')}
65
60
</div>
66
61
</div>
67
62
···
161
156
162
157
<div
163
158
use:portal={'#app-footer'}
164
-
class="fixed bottom-[5dvh] z-20 w-full max-w-2xl p-4 pt-2 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)]"
159
+
class="
160
+
fixed bottom-[5dvh] z-20 w-full max-w-2xl p-4 pt-2 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)]
161
+
"
165
162
>
166
163
<Tabs
167
164
tabs={['style', 'moderation', 'advanced']}
+1
-1
src/components/Tabs.svelte
+1
-1
src/components/Tabs.svelte
+137
-123
src/lib/at/client.ts
+137
-123
src/lib/at/client.ts
···
4
4
ComAtprotoRepoGetRecord,
5
5
ComAtprotoRepoListRecords
6
6
} from '@atcute/atproto';
7
-
import { Client as AtcuteClient } from '@atcute/client';
7
+
import { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client';
8
8
import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons';
9
9
import {
10
10
isDid,
···
30
30
import { MiniDocQuery, type MiniDoc } from './slingshot';
31
31
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
32
32
import type { Records } from '@atcute/lexicons/ambient';
33
-
import { PersistedLRU } from '$lib/cache';
33
+
import { cache as rawCache } from '$lib/cache';
34
34
import { AppBskyActorProfile } from '@atcute/bluesky';
35
35
import { WebSocket } from '@soffinal/websocket';
36
36
import type { Notification } from './stardust';
37
37
import { get } from 'svelte/store';
38
38
import { settings } from '$lib/settings';
39
39
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
40
-
// import { JetstreamSubscription } from '@atcute/jetstream';
41
-
42
-
const cacheTtl = 1000 * 60 * 60 * 24;
43
-
export const handleCache = new PersistedLRU<Handle, AtprotoDid>({
44
-
max: 1000,
45
-
ttl: cacheTtl,
46
-
prefix: 'handle'
47
-
});
48
-
export const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({
49
-
max: 1000,
50
-
ttl: cacheTtl,
51
-
prefix: 'didDoc'
52
-
});
53
-
export const recordCache = new PersistedLRU<
54
-
string,
55
-
InferOutput<typeof ComAtprotoRepoGetRecord.mainSchema.output.schema>
56
-
>({
57
-
max: 5000,
58
-
ttl: cacheTtl,
59
-
prefix: 'record'
60
-
});
61
40
62
41
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);
63
42
export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);
64
43
export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
65
44
66
-
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;
67
-
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
68
-
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
45
+
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
46
+
47
+
const cacheWithHandles = rawCache.define(
48
+
'resolveHandle',
49
+
async (handle: Handle): Promise<AtprotoDid> => {
50
+
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, {
51
+
handle
52
+
});
53
+
if (!res.ok) throw new Error(res.error);
54
+
return res.value.did as AtprotoDid;
55
+
}
56
+
);
69
57
70
-
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
58
+
const cacheWithDidDocs = cacheWithHandles.define(
59
+
'resolveDidDoc',
60
+
async (identifier: ActorIdentifier): Promise<MiniDoc> => {
61
+
const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, {
62
+
identifier
63
+
});
64
+
if (!res.ok) throw new Error(res.error);
65
+
return res.value;
66
+
}
67
+
);
68
+
69
+
const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => {
70
+
const parsedUri = parseResourceUri(uri);
71
+
if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`);
72
+
const { repo, collection, rkey } = parsedUri.value;
73
+
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
74
+
repo,
75
+
collection: collection!,
76
+
rkey: rkey!
77
+
});
78
+
if (!res.ok) throw new Error(res.error);
79
+
return res.value;
80
+
});
81
+
82
+
const cache = cacheWithRecords;
71
83
72
84
export class AtpClient {
73
85
public atcute: AtcuteClient | null = null;
···
117
129
rkey: RecordKey
118
130
): Promise<Result<RecordOutput<Output>, string>> {
119
131
const collection = schema.object.shape.$type.expected;
120
-
const cacheKey = `${repo}:${collection}:${rkey}`;
121
132
122
-
const cached = recordCache.get(cacheKey);
123
-
if (cached) return ok({ uri: cached.uri, cid: cached.cid, record: cached.value as Output });
124
-
const cachedSignal = recordCache.getSignal(cacheKey);
125
-
126
-
const result = await Promise.race([
127
-
fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
128
-
repo,
129
-
collection,
130
-
rkey
131
-
}).then((result): Result<RecordOutput<Output>, string> => {
132
-
if (!result.ok) return result;
133
-
134
-
const parsed = safeParse(schema, result.value.value);
135
-
if (!parsed.ok) return err(parsed.message);
133
+
try {
134
+
// Call the cached function
135
+
const rawValue = await cache.fetchRecord(`at://${repo}/${collection}/${rkey}`);
136
136
137
-
recordCache.set(cacheKey, result.value);
137
+
const parsed = safeParse(schema, rawValue.value);
138
+
if (!parsed.ok) return err(parsed.message);
138
139
139
-
return ok({
140
-
uri: result.value.uri,
141
-
cid: result.value.cid,
142
-
record: parsed.value as Output
143
-
});
144
-
}),
145
-
cachedSignal.then(
146
-
(d): Result<RecordOutput<Output>, string> =>
147
-
ok({ uri: d.uri, cid: d.cid, record: d.value as Output })
148
-
)
149
-
]);
150
-
151
-
if (!result.ok) return result;
152
-
153
-
return ok(result.value);
140
+
return ok({
141
+
uri: rawValue.uri,
142
+
cid: rawValue.cid,
143
+
record: parsed.value as Output
144
+
});
145
+
} catch (e) {
146
+
return err(String(e));
147
+
}
154
148
}
155
149
156
150
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
···
161
155
162
156
async listRecords<Collection extends keyof Records>(
163
157
collection: Collection,
164
-
repo: ActorIdentifier,
165
158
cursor?: string,
166
-
limit?: number
159
+
limit: number = 100
167
160
): Promise<
168
161
Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string>
169
162
> {
170
-
if (!this.atcute) return err('not authenticated');
163
+
if (!this.atcute || !this.user) return err('not authenticated');
171
164
const res = await this.atcute.get('com.atproto.repo.listRecords', {
172
165
params: {
173
-
repo,
166
+
repo: this.user.did,
174
167
collection,
175
168
cursor,
176
169
limit
177
170
}
178
171
});
179
172
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
180
-
return ok(res.data);
181
-
}
182
-
183
-
async resolveHandle(identifier: ActorIdentifier): Promise<Result<AtprotoDid, string>> {
184
-
if (isDid(identifier)) return ok(identifier as AtprotoDid);
185
173
186
-
const cached = handleCache.get(identifier);
187
-
if (cached) return ok(cached);
188
-
const cachedSignal = handleCache.getSignal(identifier);
189
-
190
-
const res = await Promise.race([
191
-
fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, {
192
-
handle: identifier
193
-
}),
194
-
cachedSignal.then((d): Result<{ did: Did }, string> => ok({ did: d }))
195
-
]);
196
-
197
-
const mapped = map(res, (data) => data.did as AtprotoDid);
198
-
199
-
if (mapped.ok) handleCache.set(identifier, mapped.value);
200
-
201
-
return mapped;
174
+
for (const record of res.data.records) {
175
+
await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24);
176
+
}
177
+
return ok(res.data);
202
178
}
203
179
204
-
async resolveDidDoc(handleOrDid: ActorIdentifier): Promise<Result<MiniDoc, string>> {
205
-
const cached = didDocCache.get(handleOrDid);
206
-
if (cached) return ok(cached);
207
-
const cachedSignal = didDocCache.getSignal(handleOrDid);
180
+
async listRecordsAll<Collection extends keyof Records>(
181
+
collection: Collection
182
+
): Promise<ReturnType<typeof this.listRecords>> {
183
+
const data: InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']> = {
184
+
records: []
185
+
};
208
186
209
-
const result = await Promise.race([
210
-
fetchMicrocosm(slingshotUrl, MiniDocQuery, {
211
-
identifier: handleOrDid
212
-
}),
213
-
cachedSignal.then((d): Result<MiniDoc, string> => ok(d))
214
-
]);
187
+
let end = false;
188
+
while (!end) {
189
+
const res = await this.listRecords(collection, data.cursor);
190
+
if (!res.ok) return res;
191
+
data.cursor = res.value.cursor;
192
+
data.records.push(...res.value.records);
193
+
end = !res.value.cursor;
194
+
}
215
195
216
-
if (result.ok) didDocCache.set(handleOrDid, result.value);
217
-
218
-
return result;
196
+
return ok(data);
219
197
}
220
198
221
199
async getBacklinksUri(
···
235
213
repo: ActorIdentifier,
236
214
collection: Nsid,
237
215
rkey: RecordKey,
238
-
source: BacklinksSource
216
+
source: BacklinksSource,
217
+
limit?: number
239
218
): Promise<Result<Backlinks, string>> {
240
-
const did = await this.resolveHandle(repo);
219
+
const did = await resolveHandle(repo);
241
220
if (!did.ok) return err(`cant resolve handle: ${did.error}`);
242
221
243
222
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000));
244
223
const query = fetchMicrocosm(constellationUrl, BacklinksQuery, {
245
224
subject: `at://${did.value}/${collection}/${rkey}`,
246
225
source,
247
-
limit: 100
226
+
limit: limit || 100
248
227
});
249
228
250
229
const results = await Promise.race([query, timeout]);
···
252
231
253
232
return results;
254
233
}
234
+
}
255
235
256
-
streamNotifications(subjects: Did[], ...sources: BacklinksSource[]): NotificationsStream {
257
-
const url = new URL(spacedustUrl);
258
-
url.protocol = 'wss:';
259
-
url.pathname = '/subscribe';
260
-
const searchParams = [];
261
-
sources.every((source) => searchParams.push(['wantedSources', source]));
262
-
subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject]));
263
-
subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`]));
264
-
searchParams.push(['instant', 'true']);
265
-
url.search = `?${new URLSearchParams(searchParams)}`;
266
-
// console.log(`streaming notifications: ${url}`);
267
-
const encoder = WebSocket.getDefaultEncoder<undefined, Notification>();
268
-
const ws = new WebSocket<typeof encoder>(url.toString(), {
269
-
encoder
270
-
});
271
-
return ws;
236
+
export const newPublicClient = async (ident: ActorIdentifier): Promise<AtpClient> => {
237
+
const atp = new AtpClient();
238
+
const didDoc = await resolveDidDoc(ident);
239
+
if (!didDoc.ok) {
240
+
console.error('failed to resolve did doc', didDoc.error);
241
+
return atp;
272
242
}
243
+
atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) });
244
+
atp.user = { did: didDoc.value.did, handle: didDoc.value.handle };
245
+
return atp;
246
+
};
247
+
248
+
// Wrappers that use the cache
249
+
250
+
export const resolveHandle = async (
251
+
identifier: ActorIdentifier
252
+
): Promise<Result<AtprotoDid, string>> => {
253
+
if (isDid(identifier)) return ok(identifier as AtprotoDid);
254
+
255
+
try {
256
+
const did = await cache.resolveHandle(identifier);
257
+
return ok(did);
258
+
} catch (e) {
259
+
return err(String(e));
260
+
}
261
+
};
262
+
263
+
export const resolveDidDoc = async (ident: ActorIdentifier): Promise<Result<MiniDoc, string>> => {
264
+
try {
265
+
const doc = await cache.resolveDidDoc(ident);
266
+
return ok(doc);
267
+
} catch (e) {
268
+
return err(String(e));
269
+
}
270
+
};
273
271
274
-
// streamJetstream(subjects: Did[], ...collections: Nsid[]) {
275
-
// return new JetstreamSubscription({
276
-
// url: 'wss://jetstream2.fr.hose.cam',
277
-
// wantedCollections: collections,
278
-
// wantedDids: subjects
279
-
// });
280
-
// }
281
-
}
272
+
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;
273
+
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
274
+
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
275
+
276
+
export const streamNotifications = (
277
+
subjects: Did[],
278
+
...sources: BacklinksSource[]
279
+
): NotificationsStream => {
280
+
const url = new URL(spacedustUrl);
281
+
url.protocol = 'wss:';
282
+
url.pathname = '/subscribe';
283
+
const searchParams = [];
284
+
sources.every((source) => searchParams.push(['wantedSources', source]));
285
+
subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject]));
286
+
subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`]));
287
+
searchParams.push(['instant', 'true']);
288
+
url.search = `?${new URLSearchParams(searchParams)}`;
289
+
// console.log(`streaming notifications: ${url}`);
290
+
const encoder = WebSocket.getDefaultEncoder<undefined, Notification>();
291
+
const ws = new WebSocket<typeof encoder>(url.toString(), {
292
+
encoder
293
+
});
294
+
return ws;
295
+
};
282
296
283
297
const fetchMicrocosm = async <
284
298
Schema extends XRPCQueryMetadata,
+2
-4
src/lib/at/fetch.ts
+2
-4
src/lib/at/fetch.ts
···
4
4
type Cid,
5
5
type ResourceUri
6
6
} from '@atcute/lexicons';
7
-
import { recordCache, type AtpClient } from './client';
7
+
import { type AtpClient } from './client';
8
8
import { err, expect, ok, type Result } from '$lib/result';
9
9
import type { Backlinks } from './constellation';
10
10
import { AppBskyFeedPost } from '@atcute/bluesky';
···
20
20
21
21
export const fetchPostsWithBacklinks = async (
22
22
client: AtpClient,
23
-
repo: AtprotoDid,
24
23
cursor?: string,
25
24
limit?: number
26
25
): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => {
27
-
const recordsList = await client.listRecords('app.bsky.feed.post', repo, cursor, limit);
26
+
const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit);
28
27
if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`);
29
28
cursor = recordsList.value.cursor;
30
29
const records = recordsList.value.records;
···
32
31
try {
33
32
const allBacklinks = await Promise.all(
34
33
records.map(async (r): Promise<PostWithBacklinks> => {
35
-
recordCache.set(r.uri, r);
36
34
const replies = await client.getBacklinksUri(r.uri, replySource);
37
35
if (!replies.ok) throw `cant fetch replies: ${replies.error}`;
38
36
return {
+193
-88
src/lib/cache.ts
+193
-88
src/lib/cache.ts
···
1
-
import { Cache, type CacheOptions } from '@wora/cache-persist';
2
-
import { LRUCache } from 'lru-cache';
1
+
import { createCache } from 'async-cache-dedupe';
2
+
3
+
const DB_NAME = 'nucleus-cache';
4
+
const STORE_NAME = 'keyvalue';
5
+
const DB_VERSION = 1;
6
+
7
+
type WriteOp =
8
+
| {
9
+
type: 'put';
10
+
key: string;
11
+
value: { value: unknown; expires: number };
12
+
resolve: () => void;
13
+
reject: (err: unknown) => void;
14
+
}
15
+
| { type: 'delete'; key: string; resolve: () => void; reject: (err: unknown) => void };
16
+
type ReadOp = {
17
+
key: string;
18
+
resolve: (val: unknown) => void;
19
+
reject: (err: unknown) => void;
20
+
};
21
+
22
+
class IDBStorage {
23
+
private dbPromise: Promise<IDBDatabase> | null = null;
24
+
25
+
private getBatch: ReadOp[] = [];
26
+
private writeBatch: WriteOp[] = [];
27
+
28
+
private getFlushScheduled = false;
29
+
private writeFlushScheduled = false;
30
+
31
+
constructor() {
32
+
if (typeof indexedDB === 'undefined') {
33
+
return;
34
+
}
35
+
36
+
this.dbPromise = new Promise((resolve, reject) => {
37
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
3
38
4
-
export interface PersistedLRUOptions {
5
-
prefix?: string;
6
-
max: number;
7
-
ttl?: number;
8
-
persistOptions?: CacheOptions;
9
-
}
39
+
request.onerror = () => {
40
+
console.error('IDB open error:', request.error);
41
+
reject(request.error);
42
+
};
10
43
11
-
interface PersistedEntry<V> {
12
-
value: V;
13
-
addedAt: number;
14
-
}
44
+
request.onsuccess = () => resolve(request.result);
45
+
46
+
request.onupgradeneeded = (event) => {
47
+
const db = (event.target as IDBOpenDBRequest).result;
48
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
49
+
db.createObjectStore(STORE_NAME);
50
+
}
51
+
};
52
+
});
53
+
}
15
54
16
-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
17
-
export class PersistedLRU<K extends string, V extends {}> {
18
-
private memory: LRUCache<K, V>;
19
-
private storage: Cache;
20
-
private signals: Map<K, ((data: V) => void)[]>;
55
+
async get(key: string): Promise<unknown> {
56
+
// checking in-flight writes
57
+
for (let i = this.writeBatch.length - 1; i >= 0; i--) {
58
+
const op = this.writeBatch[i];
59
+
if (op.key === key) {
60
+
if (op.type === 'delete') return undefined;
61
+
if (op.type === 'put') {
62
+
// if expired we dont want it
63
+
if (op.value.expires < Date.now()) return undefined;
64
+
return op.value.value;
65
+
}
66
+
}
67
+
}
21
68
22
-
private prefix = '';
69
+
if (!this.dbPromise) return undefined;
23
70
24
-
constructor(opts: PersistedLRUOptions) {
25
-
this.memory = new LRUCache<K, V>({
26
-
max: opts.max,
27
-
ttl: opts.ttl
71
+
return new Promise((resolve, reject) => {
72
+
this.getBatch.push({ key, resolve, reject });
73
+
this.scheduleGetFlush();
28
74
});
29
-
this.storage = new Cache(opts.persistOptions);
30
-
this.prefix = opts.prefix ? `${opts.prefix}%` : '';
31
-
this.signals = new Map();
75
+
}
32
76
33
-
this.init();
77
+
private scheduleGetFlush() {
78
+
if (this.getFlushScheduled) return;
79
+
this.getFlushScheduled = true;
80
+
queueMicrotask(() => this.flushGetBatch());
34
81
}
35
82
36
-
async init(): Promise<void> {
37
-
await this.storage.restore();
83
+
private async flushGetBatch() {
84
+
this.getFlushScheduled = false;
85
+
const batch = this.getBatch;
86
+
this.getBatch = [];
38
87
39
-
const state = this.storage.getState();
40
-
for (const [key, val] of Object.entries(state)) {
41
-
try {
42
-
const k = this.unprefix(key) as unknown as K;
88
+
if (batch.length === 0) return;
89
+
90
+
try {
91
+
const db = await this.dbPromise;
92
+
if (!db) throw new Error('DB not available');
43
93
44
-
if (this.isPersistedEntry(val)) {
45
-
const entry = val as PersistedEntry<V>;
46
-
this.memory.set(k, entry.value, { start: entry.addedAt });
47
-
} else {
48
-
// Handle legacy data (before this update)
49
-
this.memory.set(k, val as V);
94
+
const transaction = db.transaction(STORE_NAME, 'readonly');
95
+
const store = transaction.objectStore(STORE_NAME);
96
+
97
+
batch.forEach(({ key, resolve }) => {
98
+
try {
99
+
const request = store.get(key);
100
+
request.onsuccess = () => {
101
+
const result = request.result;
102
+
if (!result) {
103
+
resolve(undefined);
104
+
return;
105
+
}
106
+
if (result.expires < Date.now()) {
107
+
// Fire-and-forget removal for expired items
108
+
this.remove(key).catch(() => {});
109
+
resolve(undefined);
110
+
return;
111
+
}
112
+
resolve(result.value);
113
+
};
114
+
request.onerror = () => resolve(undefined);
115
+
} catch {
116
+
resolve(undefined);
50
117
}
51
-
} catch (err) {
52
-
console.warn('skipping invalid persisted entry', key, err);
53
-
}
118
+
});
119
+
} catch (error) {
120
+
batch.forEach(({ reject }) => reject(error));
54
121
}
55
122
}
56
123
57
-
get(key: K): V | undefined {
58
-
return this.memory.get(key);
124
+
async set(key: string, value: unknown, ttl: number): Promise<void> {
125
+
if (!this.dbPromise) return;
126
+
127
+
const expires = Date.now() + ttl * 1000;
128
+
const storageValue = { value, expires };
129
+
130
+
return new Promise((resolve, reject) => {
131
+
this.writeBatch.push({ type: 'put', key, value: storageValue, resolve, reject });
132
+
this.scheduleWriteFlush();
133
+
});
59
134
}
60
135
61
-
getSignal(key: K): Promise<V> {
62
-
return new Promise<V>((resolve) => {
63
-
if (!this.signals.has(key)) {
64
-
this.signals.set(key, [resolve]);
65
-
return;
66
-
}
67
-
const signals = this.signals.get(key)!;
68
-
signals.push(resolve);
69
-
this.signals.set(key, signals);
136
+
async remove(key: string): Promise<void> {
137
+
if (!this.dbPromise) return;
138
+
139
+
return new Promise((resolve, reject) => {
140
+
this.writeBatch.push({ type: 'delete', key, resolve, reject });
141
+
this.scheduleWriteFlush();
70
142
});
71
143
}
72
144
73
-
set(key: K, value: V): void {
74
-
const addedAt = performance.now();
75
-
this.memory.set(key, value, { start: addedAt });
145
+
private scheduleWriteFlush() {
146
+
if (this.writeFlushScheduled) return;
147
+
this.writeFlushScheduled = true;
148
+
queueMicrotask(() => this.flushWriteBatch());
149
+
}
150
+
151
+
private async flushWriteBatch() {
152
+
this.writeFlushScheduled = false;
153
+
const batch = this.writeBatch;
154
+
this.writeBatch = [];
155
+
156
+
if (batch.length === 0) return;
157
+
158
+
try {
159
+
const db = await this.dbPromise;
160
+
if (!db) throw new Error('DB not available');
76
161
77
-
const entry: PersistedEntry<V> = { value, addedAt };
78
-
this.storage.set(this.prefixed(key), entry);
162
+
const transaction = db.transaction(STORE_NAME, 'readwrite');
163
+
const store = transaction.objectStore(STORE_NAME);
79
164
80
-
const signals = this.signals.get(key);
81
-
let signal = signals?.pop();
82
-
while (signal) {
83
-
signal(value);
84
-
signal = signals?.pop();
165
+
batch.forEach((op) => {
166
+
try {
167
+
let request: IDBRequest;
168
+
if (op.type === 'put') {
169
+
request = store.put(op.value, op.key);
170
+
} else {
171
+
request = store.delete(op.key);
172
+
}
173
+
174
+
request.onsuccess = () => op.resolve();
175
+
request.onerror = () => op.reject(request.error);
176
+
} catch (err) {
177
+
op.reject(err);
178
+
}
179
+
});
180
+
} catch (error) {
181
+
batch.forEach(({ reject }) => reject(error));
85
182
}
86
-
this.storage.flush();
87
183
}
88
184
89
-
has(key: K): boolean {
90
-
return this.memory.has(key);
91
-
}
185
+
async clear(): Promise<void> {
186
+
if (!this.dbPromise) return;
187
+
try {
188
+
const db = await this.dbPromise;
189
+
return new Promise<void>((resolve, reject) => {
190
+
const transaction = db.transaction(STORE_NAME, 'readwrite');
191
+
const store = transaction.objectStore(STORE_NAME);
192
+
const request = store.clear();
92
193
93
-
delete(key: K): void {
94
-
this.memory.delete(key);
95
-
this.storage.delete(this.prefixed(key));
96
-
this.storage.flush();
194
+
request.onerror = () => reject(request.error);
195
+
request.onsuccess = () => resolve();
196
+
});
197
+
} catch (e) {
198
+
console.error('IDB clear error', e);
199
+
}
97
200
}
98
201
99
-
clear(): void {
100
-
this.memory.clear();
101
-
this.storage.purge();
102
-
this.storage.flush();
202
+
async exists(key: string): Promise<boolean> {
203
+
return (await this.get(key)) !== undefined;
103
204
}
104
205
105
-
private prefixed(key: K): string {
106
-
return this.prefix + key;
206
+
async invalidate(key: string): Promise<void> {
207
+
return this.remove(key);
107
208
}
108
209
109
-
private unprefix(prefixed: string): string {
110
-
return prefixed.slice(this.prefix.length);
210
+
// noops
211
+
async getTTL(key: string): Promise<void> {
212
+
return;
111
213
}
112
-
113
-
// Type guard to check if data is our new PersistedEntry format
114
-
private isPersistedEntry(data: unknown): data is PersistedEntry<V> {
115
-
return (
116
-
data !== null &&
117
-
typeof data === 'object' &&
118
-
'value' in data &&
119
-
'addedAt' in data &&
120
-
typeof data.addedAt === 'number'
121
-
);
214
+
async refresh(): Promise<void> {
215
+
return;
122
216
}
123
217
}
218
+
219
+
export const cache = createCache({
220
+
storage: {
221
+
type: 'custom',
222
+
options: {
223
+
storage: new IDBStorage()
224
+
}
225
+
},
226
+
ttl: 60 * 60 * 24, // 24 hours
227
+
onError: (err) => console.error(err)
228
+
});
+18
src/lib/date.ts
+18
src/lib/date.ts
···
1
+
export const getRelativeTime = (date: Date) => {
2
+
const now = new Date();
3
+
const diff = now.getTime() - date.getTime();
4
+
const seconds = Math.floor(diff / 1000);
5
+
const minutes = Math.floor(seconds / 60);
6
+
const hours = Math.floor(minutes / 60);
7
+
const days = Math.floor(hours / 24);
8
+
const months = Math.floor(days / 30);
9
+
const years = Math.floor(months / 12);
10
+
11
+
if (years > 0) return `${years}y`;
12
+
if (months > 0) return `${months}m`;
13
+
if (days > 0) return `${days}d`;
14
+
if (hours > 0) return `${hours}h`;
15
+
if (minutes > 0) return `${minutes}m`;
16
+
if (seconds > 0) return `${seconds}s`;
17
+
return 'now';
18
+
};
+5
-10
src/lib/richtext/index.ts
+5
-10
src/lib/richtext/index.ts
···
1
1
import RichtextBuilder, { type BakedRichtext } from '@atcute/bluesky-richtext-builder';
2
2
import { tokenize, type Token } from '$lib/richtext/parser';
3
3
import type { Did, GenericUri, Handle } from '@atcute/lexicons';
4
-
import type { AtpClient } from '$lib/at/client';
4
+
import { resolveHandle } from '$lib/at/client';
5
5
6
-
export const parseToRichText = (
7
-
client: AtpClient,
8
-
text: string
9
-
): ReturnType<typeof processTokens> => {
10
-
const tokens = tokenize(text);
11
-
return processTokens(client, tokens);
12
-
};
6
+
export const parseToRichText = (text: string): ReturnType<typeof processTokens> =>
7
+
processTokens(tokenize(text));
13
8
14
-
const processTokens = async (client: AtpClient, tokens: Token[]): Promise<BakedRichtext> => {
9
+
const processTokens = async (tokens: Token[]): Promise<BakedRichtext> => {
15
10
const rt = new RichtextBuilder();
16
11
17
12
for (const token of tokens) {
···
23
18
let did: Did | undefined = token.did as Did | undefined;
24
19
if (!did) {
25
20
const handle = token.handle as Handle;
26
-
const result = await client.resolveHandle(handle);
21
+
const result = await resolveHandle(handle);
27
22
if (result.ok) did = result.value;
28
23
}
29
24
if (did) rt.addMention(token.raw, did);
+7
-4
src/lib/settings.ts
+7
-4
src/lib/settings.ts
···
5
5
slingshot: string;
6
6
spacedust: string;
7
7
constellation: string;
8
+
jetstream: string;
8
9
};
9
10
export type Settings = {
10
11
endpoints: ApiEndpoints;
···
16
17
endpoints: {
17
18
slingshot: 'https://slingshot.microcosm.blue',
18
19
spacedust: 'https://spacedust.microcosm.blue',
19
-
constellation: 'https://constellation.microcosm.blue'
20
+
constellation: 'https://constellation.microcosm.blue',
21
+
jetstream: 'wss://jetstream2.fr.hose.cam'
20
22
},
21
23
theme: defaultTheme,
22
24
socialAppUrl: 'https://bsky.app'
···
26
28
const stored = localStorage.getItem('settings');
27
29
28
30
const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings;
29
-
initial.endpoints = initial.endpoints ?? defaultSettings.endpoints;
30
-
initial.theme = initial.theme ?? defaultSettings.theme;
31
+
initial.endpoints = { ...defaultSettings.endpoints, ...initial.endpoints };
32
+
initial.theme = { ...defaultSettings.theme, ...initial.theme };
31
33
initial.socialAppUrl = initial.socialAppUrl ?? defaultSettings.socialAppUrl;
32
34
33
35
const { subscribe, set, update } = writable<Settings>(initial as Settings);
···
66
68
return (
67
69
current.endpoints.slingshot !== other.endpoints.slingshot ||
68
70
current.endpoints.spacedust !== other.endpoints.spacedust ||
69
-
current.endpoints.constellation !== other.endpoints.constellation
71
+
current.endpoints.constellation !== other.endpoints.constellation ||
72
+
current.endpoints.jetstream !== other.endpoints.jetstream
70
73
);
71
74
};
+113
-5
src/lib/state.svelte.ts
+113
-5
src/lib/state.svelte.ts
···
1
1
import { writable } from 'svelte/store';
2
-
import { AtpClient, type NotificationsStream } from './at/client';
2
+
import { AtpClient, newPublicClient, type NotificationsStream } from './at/client';
3
3
import { SvelteMap } from 'svelte/reactivity';
4
-
import type { Did, ResourceUri } from '@atcute/lexicons';
4
+
import type { Did, InferOutput, ResourceUri } from '@atcute/lexicons';
5
5
import type { Backlink } from './at/constellation';
6
-
import type { PostWithUri } from './at/fetch';
6
+
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch';
7
7
import type { AtprotoDid } from '@atcute/lexicons/syntax';
8
-
// import type { JetstreamSubscription } from '@atcute/jetstream';
8
+
import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky';
9
+
import type { ComAtprotoRepoListRecords } from '@atcute/atproto';
10
+
import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream';
9
11
10
12
export const notificationStream = writable<NotificationsStream | null>(null);
11
-
// export const jetstream = writable<JetstreamSubscription | null>(null);
13
+
export const jetstream = writable<JetstreamSubscription | null>(null);
12
14
13
15
export type PostActions = {
14
16
like: Backlink | null;
···
22
24
23
25
export const viewClient = new AtpClient();
24
26
export const clients = new SvelteMap<AtprotoDid, AtpClient>();
27
+
export const getClient = async (did: AtprotoDid): Promise<AtpClient> => {
28
+
if (!clients.has(did)) clients.set(did, await newPublicClient(did));
29
+
return clients.get(did)!;
30
+
};
31
+
32
+
export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>();
33
+
34
+
export const addFollows = (
35
+
did: Did,
36
+
followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]>
37
+
) => {
38
+
if (!follows.has(did)) {
39
+
follows.set(did, new SvelteMap(followMap));
40
+
return;
41
+
}
42
+
const map = follows.get(did)!;
43
+
for (const [uri, record] of followMap) map.set(uri, record);
44
+
};
45
+
46
+
export const fetchFollows = async (did: AtprotoDid) => {
47
+
const client = await getClient(did);
48
+
const res = await client.listRecordsAll('app.bsky.graph.follow');
49
+
if (!res.ok) return;
50
+
addFollows(
51
+
did,
52
+
res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main])
53
+
);
54
+
};
55
+
56
+
export const fetchFollowPosts = async (did: AtprotoDid) => {
57
+
const client = await getClient(did);
58
+
const res = await client.listRecords('app.bsky.feed.post');
59
+
if (!res.ok) return;
60
+
addPostsRaw(did, res.value);
61
+
};
25
62
26
63
export const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
27
64
export const cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
65
+
66
+
export const addPostsRaw = (
67
+
did: Did,
68
+
_posts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']>
69
+
) => {
70
+
const postsWithUri = new SvelteMap(
71
+
_posts.records.map((post) => [
72
+
post.uri,
73
+
{ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri
74
+
])
75
+
);
76
+
addPosts(did, postsWithUri);
77
+
cursors.set(did, { value: _posts.cursor, end: _posts.cursor === undefined });
78
+
};
79
+
80
+
export const addPosts = (did: Did, _posts: Iterable<[ResourceUri, PostWithUri]>) => {
81
+
if (!posts.has(did)) {
82
+
posts.set(did, new SvelteMap(_posts));
83
+
return;
84
+
}
85
+
const map = posts.get(did)!;
86
+
for (const [uri, record] of _posts) map.set(uri, record);
87
+
};
88
+
89
+
export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => {
90
+
const client = await getClient(did);
91
+
92
+
const cursor = cursors.get(did);
93
+
if (cursor && cursor.end) return;
94
+
95
+
const accPosts = await fetchPostsWithBacklinks(client, cursor?.value, limit);
96
+
if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`;
97
+
98
+
// if the cursor is undefined, we've reached the end of the timeline
99
+
if (!accPosts.value.cursor) {
100
+
cursors.set(did, { ...cursor, end: true });
101
+
return;
102
+
}
103
+
104
+
cursors.set(did, { value: accPosts.value.cursor, end: false });
105
+
const hydrated = await hydratePosts(client, did, accPosts.value.posts);
106
+
if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`;
107
+
108
+
addPosts(did, hydrated.value);
109
+
};
110
+
111
+
export const handleJetstreamEvent = (event: JetstreamEvent) => {
112
+
if (event.kind !== 'commit') return;
113
+
114
+
const { did, commit } = event;
115
+
if (commit.collection !== 'app.bsky.feed.post') return;
116
+
117
+
const uri: ResourceUri = `at://${did}/${commit.collection}/${commit.rkey}`;
118
+
119
+
if (commit.operation === 'create') {
120
+
const { cid, record } = commit;
121
+
122
+
const post: PostWithUri = {
123
+
uri,
124
+
cid,
125
+
// assume record is valid, we trust the jetstream
126
+
record: record as AppBskyFeedPost.Main
127
+
};
128
+
129
+
addPosts(did, [[uri, post]]);
130
+
} else if (commit.operation === 'delete') {
131
+
if (posts.has(did)) {
132
+
posts.get(did)?.delete(uri);
133
+
}
134
+
}
135
+
};
+7
-2
src/lib/thread.ts
+7
-2
src/lib/thread.ts
···
20
20
branchParentPost?: ThreadPost;
21
21
};
22
22
23
-
export const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => {
23
+
export const buildThreads = (
24
+
accounts: Did[],
25
+
posts: Map<Did, Map<ResourceUri, PostWithUri>>
26
+
): Thread[] => {
24
27
const threadMap = new Map<ResourceUri, ThreadPost[]>();
25
28
26
29
// group posts by root uri into "thread" chains
27
-
for (const [account, timeline] of timelines) {
30
+
for (const account of accounts) {
31
+
const timeline = posts.get(account);
32
+
if (!timeline) continue;
28
33
for (const [uri, data] of timeline) {
29
34
const parsedUri = expect(parseCanonicalResourceUri(uri));
30
35
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
+107
-79
src/routes/+page.svelte
+107
-79
src/routes/+page.svelte
···
4
4
import AccountSelector from '$components/AccountSelector.svelte';
5
5
import SettingsView from '$components/SettingsView.svelte';
6
6
import NotificationsView from '$components/NotificationsView.svelte';
7
-
import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client';
8
-
import { accounts, generateColorForDid, type Account } from '$lib/accounts';
9
-
import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons';
7
+
import FollowingView from '$components/FollowingView.svelte';
8
+
import { AtpClient, streamNotifications, type NotificationsStreamEvent } from '$lib/at/client';
9
+
import { accounts, type Account } from '$lib/accounts';
10
+
import { parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons';
10
11
import { onMount, tick } from 'svelte';
11
-
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch';
12
+
import { hydratePosts } from '$lib/at/fetch';
12
13
import { expect } from '$lib/result';
13
14
import { AppBskyFeedPost } from '@atcute/bluesky';
14
15
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
15
16
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
16
-
import { clients, cursors, notificationStream, posts, viewClient } from '$lib/state.svelte';
17
+
import {
18
+
addPosts,
19
+
clients,
20
+
cursors,
21
+
fetchFollowPosts,
22
+
fetchFollows,
23
+
fetchTimeline,
24
+
follows,
25
+
getClient,
26
+
notificationStream,
27
+
posts,
28
+
viewClient,
29
+
jetstream,
30
+
handleJetstreamEvent
31
+
} from '$lib/state.svelte';
17
32
import { get } from 'svelte/store';
18
33
import Icon from '@iconify/svelte';
19
34
import { sessions } from '$lib/at/oauth';
20
-
import type { AtprotoDid } from '@atcute/lexicons/syntax';
35
+
import type { AtprotoDid, Did } from '@atcute/lexicons/syntax';
21
36
import type { PageProps } from './+page';
22
37
import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread';
38
+
import { JetstreamSubscription } from '@atcute/jetstream';
39
+
import { settings } from '$lib/settings';
23
40
24
41
const { data: loadData }: PageProps = $props();
25
42
26
43
// svelte-ignore state_referenced_locally
27
44
let errors = $state(loadData.client.ok ? [] : [loadData.client.error]);
28
45
let errorsOpen = $state(false);
29
-
30
46
let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null);
31
47
$effect(() => {
32
-
if (selectedDid) {
33
-
localStorage.setItem('selectedDid', selectedDid);
34
-
} else {
35
-
localStorage.removeItem('selectedDid');
36
-
}
48
+
if (selectedDid) localStorage.setItem('selectedDid', selectedDid);
49
+
else localStorage.removeItem('selectedDid');
37
50
});
38
-
39
51
const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
40
52
41
53
const loginAccount = async (account: Account) => {
···
48
60
}
49
61
clients.set(account.did, client);
50
62
};
51
-
52
63
const handleAccountSelected = async (did: AtprotoDid) => {
53
64
selectedDid = did;
54
65
const account = $accounts.find((acc) => acc.did === did);
···
66
77
handleAccountSelected(newAccounts[0]?.did);
67
78
};
68
79
69
-
type View = 'timeline' | 'notifications' | 'settings';
80
+
type View = 'timeline' | 'notifications' | 'following' | 'settings';
70
81
let currentView = $state<View>('timeline');
71
82
let animClass = $state('animate-fade-in-scale');
72
-
let timelineScrollPosition = $state(0);
83
+
let scrollPositions = new SvelteMap<View, number>();
73
84
74
85
const viewOrder: Record<View, number> = {
75
86
timeline: 0,
76
-
notifications: 1,
77
-
settings: 2
87
+
following: 1,
88
+
notifications: 2,
89
+
settings: 3
78
90
};
79
91
80
92
const switchView = async (newView: View) => {
81
93
if (currentView === newView) return;
82
-
if (currentView === 'timeline') timelineScrollPosition = window.scrollY;
94
+
scrollPositions.set(currentView, window.scrollY);
83
95
84
96
const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left';
85
97
animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left';
···
87
99
88
100
await tick();
89
101
90
-
if (newView !== 'timeline') window.scrollTo({ top: 0, behavior: 'instant' });
91
-
else window.scrollTo({ top: timelineScrollPosition, behavior: 'instant' });
102
+
window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' });
92
103
};
93
-
94
104
let reverseChronological = $state(true);
95
105
let viewOwnPosts = $state(true);
96
106
97
-
const threads = $derived(filterThreads(buildThreads(posts), $accounts, { viewOwnPosts }));
98
-
107
+
const threads = $derived(
108
+
filterThreads(
109
+
buildThreads(
110
+
$accounts.map((account) => account.did),
111
+
posts
112
+
),
113
+
$accounts,
114
+
{ viewOwnPosts }
115
+
)
116
+
);
99
117
let postComposerState = $state<PostComposerState>({ type: 'null' });
100
118
101
119
const expandedThreads = new SvelteSet<ResourceUri>();
102
120
103
-
const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => {
104
-
if (!posts.has(did)) {
105
-
posts.set(did, new SvelteMap(accTimeline));
106
-
return;
107
-
}
108
-
const map = posts.get(did)!;
109
-
for (const [uri, record] of accTimeline) map.set(uri, record);
110
-
};
111
-
112
-
const fetchTimeline = async (account: Account) => {
113
-
const client = clients.get(account.did);
114
-
if (!client) return;
115
-
116
-
const cursor = cursors.get(account.did);
117
-
if (cursor && cursor.end) return;
118
-
119
-
const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6);
120
-
if (!accPosts.ok) throw `cant fetch posts @${account.handle}: ${accPosts.error}`;
121
-
122
-
// if the cursor is undefined, we've reached the end of the timeline
123
-
if (!accPosts.value.cursor) {
124
-
cursors.set(account.did, { ...cursor, end: true });
125
-
return;
126
-
}
127
-
128
-
cursors.set(account.did, { value: accPosts.value.cursor, end: false });
129
-
const hydrated = await hydratePosts(client, account.did, accPosts.value.posts);
130
-
if (!hydrated.ok) throw `cant hydrate posts @${account.handle}: ${hydrated.error}`;
131
-
132
-
addPosts(account.did, hydrated.value);
133
-
};
134
-
135
-
const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
121
+
const fetchTimelines = (newAccounts: Account[]) =>
122
+
Promise.all(newAccounts.map((acc) => fetchTimeline(acc.did)));
136
123
137
124
const handleNotification = async (event: NotificationsStreamEvent) => {
138
125
if (event.type === 'message') {
139
-
// console.log(event.data);
140
126
const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
141
-
const subjectPost = await viewClient.getRecord(
127
+
const did = parsedSubjectUri.repo as AtprotoDid;
128
+
const client = await getClient(did);
129
+
const subjectPost = await client.getRecord(
142
130
AppBskyFeedPost.mainSchema,
143
-
parsedSubjectUri.repo,
131
+
did,
144
132
parsedSubjectUri.rkey
145
133
);
146
134
if (!subjectPost.ok) return;
147
135
148
136
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
149
-
const hydrated = await hydratePosts(viewClient, parsedSubjectUri.repo as AtprotoDid, [
137
+
const hydrated = await hydratePosts(client, did, [
150
138
{
151
139
record: subjectPost.value.record,
152
140
uri: event.data.link.subject,
···
164
152
}
165
153
}
166
154
]);
167
-
168
155
if (!hydrated.ok) {
169
-
errors.push(`cant hydrate posts @${parsedSubjectUri.repo}: ${hydrated.error}`);
156
+
errors.push(`cant hydrate posts ${did}: ${hydrated.error}`);
170
157
return;
171
158
}
172
159
173
160
// console.log(hydrated);
174
-
addPosts(parsedSubjectUri.repo, hydrated.value);
161
+
addPosts(did, hydrated.value);
175
162
}
176
163
};
177
164
···
185
172
const handleScroll = () => {
186
173
if (currentView === 'timeline') showScrollToTop = window.scrollY > 300;
187
174
};
188
-
189
175
const scrollToTop = () => {
190
176
window.scrollTo({ top: 0, behavior: 'smooth' });
191
177
};
···
218
204
// jetstream.set(null);
219
205
if (newAccounts.length === 0) return;
220
206
notificationStream.set(
221
-
viewClient.streamNotifications(
207
+
streamNotifications(
222
208
newAccounts.map((account) => account.did),
223
-
'app.bsky.feed.post:reply.parent.uri'
209
+
'app.bsky.feed.post:reply.parent.uri',
210
+
'app.bsky.feed.post:embed.record.record.uri',
211
+
'app.bsky.feed.post:embed.record.uri'
224
212
)
225
213
);
226
214
});
···
228
216
if (!stream) return;
229
217
stream.listen(handleNotification);
230
218
});
219
+
220
+
console.log(`creating jetstream subscription to ${$settings.endpoints.jetstream}`);
221
+
const jetstreamSub = new JetstreamSubscription({
222
+
url: $settings.endpoints.jetstream,
223
+
wantedCollections: ['app.bsky.feed.post'],
224
+
wantedDids: ['did:web:guestbook.gaze.systems'] // initially contain sentinel
225
+
});
226
+
jetstream.set(jetstreamSub);
227
+
228
+
(async () => {
229
+
console.log('polling for jetstream...');
230
+
for await (const event of jetstreamSub) handleJetstreamEvent(event);
231
+
})();
232
+
231
233
if ($accounts.length > 0) {
232
234
loaderState.status = 'LOADING';
233
235
if (loadData.client.ok && loadData.client.value) {
···
236
238
clients.set(loggedInDid, loadData.client.value);
237
239
}
238
240
if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did;
239
-
console.log('onMount selectedDid', selectedDid);
241
+
// console.log('onMount selectedDid', selectedDid);
240
242
Promise.all($accounts.map(loginAccount)).then(() => {
243
+
$accounts.forEach((account) =>
244
+
fetchFollows(account.did).then(() =>
245
+
follows
246
+
.get(account.did)
247
+
?.forEach((follow) => fetchFollowPosts(follow.subject as AtprotoDid))
248
+
)
249
+
);
241
250
loadMore();
242
251
});
243
252
} else {
244
253
selectedDid = null;
245
254
}
246
255
247
-
return () => {
248
-
window.removeEventListener('scroll', handleScroll);
249
-
};
256
+
return () => window.removeEventListener('scroll', handleScroll);
257
+
});
258
+
259
+
$effect(() => {
260
+
const wantedDids: Did[] = ['did:web:guestbook.gaze.systems'];
261
+
262
+
for (const followMap of follows.values())
263
+
for (const follow of followMap.values()) wantedDids.push(follow.subject);
264
+
for (const account of $accounts) wantedDids.push(account.did);
265
+
266
+
console.log('updating jetstream options:', wantedDids);
267
+
$jetstream?.updateOptions({ wantedDids });
250
268
});
251
269
</script>
252
270
···
271
289
{/snippet}
272
290
273
291
<div class="mx-auto flex min-h-dvh max-w-2xl flex-col">
274
-
<!-- Views Container -->
275
292
<div class="flex-1">
276
293
<!-- timeline -->
277
294
<div
278
295
id="app-thread-list"
279
-
class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {currentView ===
280
-
'timeline'
281
-
? `block ${animClass}`
282
-
: 'hidden'}"
296
+
class="
297
+
min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent]
298
+
{currentView === 'timeline' ? `${animClass}` : 'hidden'}
299
+
"
283
300
bind:this={scrollContainer}
284
301
>
285
302
{#if $accounts.length > 0}
···
292
309
</div>
293
310
{/if}
294
311
</div>
295
-
296
-
<!-- other views -->
297
312
{#if currentView === 'settings'}
298
313
<div class={animClass}>
299
314
<SettingsView />
300
315
</div>
301
-
{:else if currentView === 'notifications'}
316
+
{/if}
317
+
{#if currentView === 'notifications'}
302
318
<div class={animClass}>
303
319
<NotificationsView />
320
+
</div>
321
+
{/if}
322
+
{#if currentView === 'following'}
323
+
<div class={animClass}>
324
+
<FollowingView selectedClient={selectedClient!} selectedDid={selectedDid!} />
304
325
</div>
305
326
{/if}
306
327
</div>
···
386
407
'timeline',
387
408
currentView === 'timeline',
388
409
'heroicons:home-solid'
410
+
)}
411
+
{@render appButton(
412
+
() => switchView('following'),
413
+
'heroicons:users',
414
+
'following',
415
+
currentView === 'following',
416
+
'heroicons:users-solid'
389
417
)}
390
418
{@render appButton(
391
419
() => switchView('notifications'),