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