+45
-1
deno.lock
+45
-1
deno.lock
···
4
4
"npm:@atcute/atproto@^3.1.7": "3.1.8",
5
5
"npm:@atcute/bluesky@^3.2.7": "3.2.9",
6
6
"npm:@atcute/client@^4.0.5": "4.0.5",
7
+
"npm:@atcute/identity-resolver@^1.1.4": "1.1.4_@atcute+identity@1.1.1",
7
8
"npm:@atcute/identity@^1.1.1": "1.1.1",
8
9
"npm:@atcute/lexicons@^1.2.2": "1.2.2",
10
+
"npm:@atcute/oauth-browser-client@^2.0.1": "2.0.1_@atcute+identity@1.1.1",
9
11
"npm:@atcute/tid@^1.0.3": "1.0.3",
10
12
"npm:@eslint/compat@^1.4.0": "1.4.1_eslint@9.38.0",
11
13
"npm:@eslint/js@^9.36.0": "9.38.0",
···
57
59
"@atcute/lexicons"
58
60
]
59
61
},
62
+
"@atcute/identity-resolver@1.1.4_@atcute+identity@1.1.1": {
63
+
"integrity": "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA==",
64
+
"dependencies": [
65
+
"@atcute/identity",
66
+
"@atcute/lexicons",
67
+
"@atcute/util-fetch",
68
+
"@badrap/valita"
69
+
]
70
+
},
60
71
"@atcute/identity@1.1.1": {
61
72
"integrity": "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q==",
62
73
"dependencies": [
···
71
82
"esm-env"
72
83
]
73
84
},
85
+
"@atcute/multibase@1.1.6": {
86
+
"integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==",
87
+
"dependencies": [
88
+
"@atcute/uint8array"
89
+
]
90
+
},
91
+
"@atcute/oauth-browser-client@2.0.1_@atcute+identity@1.1.1": {
92
+
"integrity": "sha512-lG021GkeORG06zfFf4bH85egObjBEKHNgAWHvbtY/E2dX4wxo88hf370pJDx8acdnuUJLJ2VKPikJtZwo4Heeg==",
93
+
"dependencies": [
94
+
"@atcute/client",
95
+
"@atcute/identity",
96
+
"@atcute/identity-resolver",
97
+
"@atcute/lexicons",
98
+
"@atcute/multibase",
99
+
"@atcute/uint8array",
100
+
"nanoid@5.1.6"
101
+
]
102
+
},
74
103
"@atcute/tid@1.0.3": {
75
104
"integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w=="
105
+
},
106
+
"@atcute/uint8array@1.0.5": {
107
+
"integrity": "sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q=="
108
+
},
109
+
"@atcute/util-fetch@1.0.3": {
110
+
"integrity": "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ==",
111
+
"dependencies": [
112
+
"@badrap/valita"
113
+
]
76
114
},
77
115
"@badrap/valita@0.4.6": {
78
116
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
···
1354
1392
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1355
1393
"bin": true
1356
1394
},
1395
+
"nanoid@5.1.6": {
1396
+
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
1397
+
"bin": true
1398
+
},
1357
1399
"natural-compare@1.4.0": {
1358
1400
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
1359
1401
},
···
1434
1476
"postcss@8.5.6": {
1435
1477
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
1436
1478
"dependencies": [
1437
-
"nanoid",
1479
+
"nanoid@3.3.11",
1438
1480
"picocolors",
1439
1481
"source-map-js"
1440
1482
]
···
1740
1782
"npm:@atcute/atproto@^3.1.7",
1741
1783
"npm:@atcute/bluesky@^3.2.7",
1742
1784
"npm:@atcute/client@^4.0.5",
1785
+
"npm:@atcute/identity-resolver@^1.1.4",
1743
1786
"npm:@atcute/identity@^1.1.1",
1744
1787
"npm:@atcute/lexicons@^1.2.2",
1788
+
"npm:@atcute/oauth-browser-client@^2.0.1",
1745
1789
"npm:@atcute/tid@^1.0.3",
1746
1790
"npm:@eslint/compat@^1.4.0",
1747
1791
"npm:@eslint/js@^9.36.0",
+3
-4
flake.lock
+3
-4
flake.lock
···
17
17
},
18
18
"nixpkgs": {
19
19
"locked": {
20
-
"lastModified": 1761656231,
21
-
"narHash": "sha256-EiED5k6gXTWoAIS8yQqi5mAX6ojnzpHwAQTS3ykeYMg=",
20
+
"lastModified": 1761850514,
21
+
"narHash": "sha256-qmg1yC6ybzH0/w4Bupx1hpgTS5MTl2qBMoD+DFx3hWM=",
22
22
"owner": "nixos",
23
23
"repo": "nixpkgs",
24
-
"rev": "e99366c665bdd53b7b500ccdc5226675cfc51f45",
24
+
"rev": "1c3d5f4e01f0b18b508be644d9d6a196fb7ed1f5",
25
25
"type": "github"
26
26
},
27
27
"original": {
28
28
"owner": "nixos",
29
-
"ref": "nixpkgs-unstable",
30
29
"repo": "nixpkgs",
31
30
"type": "github"
32
31
}
+2
-2
flake.nix
+2
-2
flake.nix
···
1
1
{
2
2
inputs.parts.url = "github:hercules-ci/flake-parts";
3
-
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
3
+
inputs.nixpkgs.url = "github:nixos/nixpkgs";
4
4
inputs.naked-shell.url = "github:90-008/mk-naked-shell";
5
5
6
6
outputs = inp:
···
17
17
devShells.default = config.mk-naked-shell.lib.mkNakedShell {
18
18
name = "nucleus-devshell";
19
19
packages = with pkgs; [
20
-
nodejs-slim_latest deno
20
+
nodejs-slim_latest deno biome
21
21
];
22
22
shellHook = ''
23
23
export PATH="$PATH:$PWD/node_modules/.bin"
+2
package.json
+2
package.json
···
18
18
"@atcute/bluesky": "^3.2.7",
19
19
"@atcute/client": "^4.0.5",
20
20
"@atcute/identity": "^1.1.1",
21
+
"@atcute/identity-resolver": "^1.1.4",
21
22
"@atcute/lexicons": "^1.2.2",
23
+
"@atcute/oauth-browser-client": "^2.0.1",
22
24
"@atcute/tid": "^1.0.3",
23
25
"@soffinal/websocket": "^0.2.1",
24
26
"@wora/cache-persist": "^2.2.1",
+7
src/app.css
+7
src/app.css
···
30
30
@apply rounded-sm border-2 border-(--nucleus-accent) px-3 py-2 font-semibold text-(--nucleus-accent) transition-all hover:scale-105 hover:bg-(--nucleus-accent)/20;
31
31
}
32
32
33
+
@utility error-disclaimer {
34
+
@apply rounded-sm border-2 border-red-500 bg-red-500/8 p-2;
35
+
p {
36
+
@apply text-base text-wrap wrap-break-word text-red-500;
37
+
}
38
+
}
39
+
33
40
:root {
34
41
scrollbar-width: thin;
35
42
scrollbar-color: var(--nucleus-accent) var(--nucleus-bg);
+29
-56
src/components/AccountSelector.svelte
+29
-56
src/components/AccountSelector.svelte
···
1
1
<script lang="ts">
2
-
import { generateColorForDid, type Account } from '$lib/accounts';
2
+
import { generateColorForDid, loggingIn, type Account } from '$lib/accounts';
3
3
import { AtpClient } from '$lib/at/client';
4
-
import type { Did, Handle } from '@atcute/lexicons';
4
+
import type { Handle } from '@atcute/lexicons';
5
5
import ProfilePicture from './ProfilePicture.svelte';
6
6
import PfpPlaceholder from './PfpPlaceholder.svelte';
7
+
import { flow } from '$lib/at/oauth';
8
+
import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax';
9
+
import Icon from '@iconify/svelte';
7
10
8
11
interface Props {
9
12
client: AtpClient;
10
13
accounts: Array<Account>;
11
-
selectedDid?: Did | null;
12
-
onAccountSelected: (did: Did) => void;
13
-
onLoginSucceed: (did: Did, handle: Handle, password: string) => void;
14
-
onLogout: (did: Did) => void;
14
+
selectedDid?: AtprotoDid | null;
15
+
onAccountSelected: (did: AtprotoDid) => void;
16
+
onLogout: (did: AtprotoDid) => void;
15
17
}
16
18
17
19
let {
···
19
21
accounts = [],
20
22
selectedDid = $bindable(null),
21
23
onAccountSelected,
22
-
onLoginSucceed,
23
24
onLogout
24
25
}: Props = $props();
25
26
26
27
let isDropdownOpen = $state(false);
27
28
let isLoginModalOpen = $state(false);
28
29
let loginHandle = $state('');
29
-
let loginPassword = $state('');
30
30
let loginError = $state('');
31
31
let isLoggingIn = $state(false);
32
32
···
35
35
isDropdownOpen = !isDropdownOpen;
36
36
};
37
37
38
-
const selectAccount = (did: Did) => {
38
+
const selectAccount = (did: AtprotoDid) => {
39
39
onAccountSelected(did);
40
40
isDropdownOpen = false;
41
41
};
···
44
44
isLoginModalOpen = true;
45
45
isDropdownOpen = false;
46
46
loginHandle = '';
47
-
loginPassword = '';
48
47
loginError = '';
49
48
};
50
49
51
50
const closeLoginModal = () => {
52
51
isLoginModalOpen = false;
53
52
loginHandle = '';
54
-
loginPassword = '';
55
53
loginError = '';
56
54
};
57
55
58
56
const handleLogin = async () => {
59
-
if (!loginHandle || !loginPassword) {
60
-
loginError = 'please enter both handle and password';
61
-
return;
62
-
}
57
+
try {
58
+
if (!loginHandle) throw 'please enter handle';
63
59
64
-
isLoggingIn = true;
65
-
loginError = '';
60
+
isLoggingIn = true;
61
+
loginError = '';
66
62
67
-
try {
68
-
const client = new AtpClient();
69
-
const result = await client.login(loginHandle as Handle, loginPassword);
63
+
let handle: Handle;
64
+
if (isHandle(loginHandle)) handle = loginHandle;
65
+
else throw 'handle is invalid';
70
66
71
-
if (!result.ok) {
72
-
loginError = result.error;
73
-
isLoggingIn = false;
74
-
return;
75
-
}
67
+
let did = await client.resolveHandle(handle);
68
+
if (!did.ok) throw did.error;
76
69
77
-
if (!client.didDoc) {
78
-
loginError = 'failed to get did document';
79
-
isLoggingIn = false;
80
-
return;
81
-
}
82
-
83
-
onLoginSucceed(client.didDoc.did, loginHandle as Handle, loginPassword);
84
-
closeLoginModal();
70
+
loggingIn.set({ did: did.value, handle });
71
+
const result = await flow.start(handle);
72
+
if (!result.ok) throw result.error;
85
73
} catch (error) {
86
74
loginError = `login failed: ${error}`;
75
+
loggingIn.set(null);
87
76
} finally {
88
77
isLoggingIn = false;
89
78
}
···
141
130
<svg
142
131
xmlns="http://www.w3.org/2000/svg"
143
132
onclick={() => onLogout(account.did)}
144
-
class="ml-auto hidden h-5 w-5 text-(--nucleus-accent) transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md"
133
+
class="ml-auto hidden h-5 w-5 text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md"
145
134
width="24"
146
135
height="24"
147
136
viewBox="0 0 20 20"
···
173
162
</button>
174
163
{/each}
175
164
</div>
176
-
<div
177
-
class="mx-2 h-px bg-gradient-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"
178
-
></div>
165
+
<div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
179
166
{/if}
180
167
<button
181
168
onclick={openLoginModal}
···
249
236
/>
250
237
</div>
251
238
252
-
<div>
253
-
<label for="password" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
254
-
app password
255
-
</label>
256
-
<input
257
-
id="password"
258
-
type="password"
259
-
bind:value={loginPassword}
260
-
placeholder="xxxx-xxxx-xxxx-xxxx"
261
-
class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
262
-
disabled={isLoggingIn}
263
-
/>
264
-
</div>
265
-
266
239
{#if loginError}
267
-
<div
268
-
class="rounded-sm border-2 p-4"
269
-
style="background: #ef444422; border-color: #ef4444;"
270
-
>
271
-
<p class="text-sm font-medium" style="color: #fca5a5;">{loginError}</p>
240
+
<div class="error-disclaimer">
241
+
<p>
242
+
<Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
243
+
{loginError}
244
+
</p>
272
245
</div>
273
246
{/if}
274
247
+22
-21
src/components/BskyPost.svelte
+22
-21
src/components/BskyPost.svelte
···
11
11
type ResourceUri
12
12
} from '@atcute/lexicons';
13
13
import { expect, ok } from '$lib/result';
14
-
import { generateColorForDid } from '$lib/accounts';
14
+
import { accounts, generateColorForDid } from '$lib/accounts';
15
15
import ProfilePicture from './ProfilePicture.svelte';
16
16
import { isBlob } from '@atcute/lexicons/interfaces';
17
17
import { blob, img } from '$lib/cdn';
18
18
import BskyPost from './BskyPost.svelte';
19
19
import Icon from '@iconify/svelte';
20
20
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
21
-
import { postActions, type PostActions } from '$lib';
21
+
import { postActions, type PostActions } from '$lib/state.svelte';
22
22
import * as TID from '@atcute/tid';
23
23
import type { PostWithUri } from '$lib/at/fetch';
24
-
import type { Writable } from 'svelte/store';
25
24
import { onMount } from 'svelte';
25
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
26
26
27
27
interface Props {
28
28
client: AtpClient;
29
-
selectedDid: Writable<Did | null>;
30
29
// post
31
30
did: Did;
32
31
rkey: RecordKey;
···
40
39
41
40
const {
42
41
client,
43
-
selectedDid,
44
42
did,
45
43
rkey,
46
44
data,
···
49
47
onReply,
50
48
isOnPostComposer = false /* replyBacklinks */
51
49
}: Props = $props();
50
+
51
+
const selectedDid = $derived(client.didDoc?.did ?? null);
52
52
53
53
const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
54
54
const color = generateColorForDid(did);
···
106
106
return 'now';
107
107
};
108
108
109
-
const findBacklink = async (source: BacklinksSource) => {
109
+
const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => {
110
110
const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source);
111
111
if (!backlinks.ok) return null;
112
-
return backlinks.value.records.find((r) => r.did === $selectedDid) ?? null;
113
-
};
112
+
return backlinks.value.records.find((r) => r.did === toDid) ?? null;
113
+
});
114
114
115
-
let findAllBacklinks = async (did: Did | null) => {
115
+
let findAllBacklinks = async (did: AtprotoDid | null) => {
116
116
if (!did) return;
117
117
if (postActions.has(`${did}:${aturi}`)) return;
118
118
const backlinks = await Promise.all([
119
-
findBacklink('app.bsky.feed.like:subject.uri'),
120
-
findBacklink('app.bsky.feed.repost:subject.uri')
119
+
findBacklink(did, 'app.bsky.feed.like:subject.uri'),
120
+
findBacklink(did, 'app.bsky.feed.repost:subject.uri')
121
121
// findBacklink('app.bsky.feed.post:reply.parent.uri'),
122
122
// findBacklink('app.bsky.feed.post:embed.record.uri')
123
123
]);
···
132
132
};
133
133
onMount(() => {
134
134
// findAllBacklinks($selectedDid);
135
-
selectedDid.subscribe(findAllBacklinks);
135
+
accounts.subscribe((accs) => {
136
+
accs.map((acc) => acc.did).forEach((did) => findAllBacklinks(did));
137
+
});
136
138
});
137
139
138
140
const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => {
139
141
// console.log('toggleLink', selectedDid, link, collection);
140
-
if (!$selectedDid) return null;
142
+
if (!selectedDid) return null;
141
143
const _post = await post;
142
144
if (!_post.ok) return null;
143
145
if (!link) {
···
154
156
// todo: handle errors
155
157
client.atcute?.post('com.atproto.repo.createRecord', {
156
158
input: {
157
-
repo: $selectedDid,
159
+
repo: selectedDid,
158
160
collection,
159
161
record,
160
162
rkey
···
162
164
});
163
165
return {
164
166
collection,
165
-
did: $selectedDid,
167
+
did: selectedDid,
166
168
rkey
167
169
};
168
170
}
···
215
217
style="background: {color}18; border-color: {color}66;"
216
218
>
217
219
<div
218
-
class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) [border-left-color:transparent]"
220
+
class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) border-l-transparent"
219
221
></div>
220
222
<p class="mt-3 text-sm font-medium opacity-60">loading post...</p>
221
223
</div>
···
253
255
>{getRelativeTime(new Date(record.createdAt))}</span
254
256
>
255
257
</div>
256
-
<p class="leading-relaxed text-wrap break-words">
258
+
<p class="leading-relaxed text-wrap wrap-break-word">
257
259
{record.text}
258
260
{#if isOnPostComposer}
259
261
{@render embedBadge(record)}
···
267
269
<!-- reject recursive quotes -->
268
270
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
269
271
<BskyPost
270
-
{selectedDid}
271
272
{client}
272
273
did={parsedUri.repo}
273
274
rkey={parsedUri.rkey}
···
312
313
</div>
313
314
{/if}
314
315
{#if !isOnPostComposer}
315
-
{@const backlinks = postActions.get(`${$selectedDid!}:${post.value.uri}`)}
316
+
{@const backlinks = postActions.get(`${selectedDid!}:${post.value.uri}`)}
316
317
{@render postControls(post.value, backlinks)}
317
318
{/if}
318
319
</div>
···
353
354
'heroicons:arrow-path-rounded-square-20-solid',
354
355
async (link) => {
355
356
if (link === undefined) return;
356
-
postActions.set(`${$selectedDid!}:${aturi}`, {
357
+
postActions.set(`${selectedDid!}:${aturi}`, {
357
358
...backlinks!,
358
359
repost: await toggleLink(link, 'app.bsky.feed.repost')
359
360
});
···
368
369
'heroicons:star',
369
370
async (link) => {
370
371
if (link === undefined) return;
371
-
postActions.set(`${$selectedDid!}:${aturi}`, {
372
+
postActions.set(`${selectedDid!}:${aturi}`, {
372
373
...backlinks!,
373
374
like: await toggleLink(link, 'app.bsky.feed.like')
374
375
});
+14
-23
src/components/PostComposer.svelte
+14
-23
src/components/PostComposer.svelte
···
5
5
import { generateColorForDid } from '$lib/accounts';
6
6
import type { PostWithUri } from '$lib/at/fetch';
7
7
import BskyPost from './BskyPost.svelte';
8
-
import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons';
8
+
import { parseCanonicalResourceUri } from '@atcute/lexicons';
9
9
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
10
-
import type { Writable } from 'svelte/store';
11
10
12
11
interface Props {
13
12
client: AtpClient;
14
-
selectedDid: Writable<Did | null>;
15
13
onPostSent: (post: PostWithUri) => void;
16
14
quoting?: PostWithUri;
17
15
replying?: PostWithUri;
···
19
17
20
18
let {
21
19
client,
22
-
selectedDid,
23
20
onPostSent,
24
21
quoting = $bindable(undefined),
25
22
replying = $bindable(undefined)
···
147
144
</div>
148
145
{:else}
149
146
<div class="flex flex-col gap-2">
147
+
{#snippet renderPost(post: PostWithUri)}
148
+
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
149
+
<BskyPost
150
+
{client}
151
+
did={parsedUri.repo}
152
+
rkey={parsedUri.rkey}
153
+
data={post}
154
+
isOnPostComposer={true}
155
+
/>
156
+
{/snippet}
150
157
{#if isFocused}
151
158
{#if replying}
152
-
{@const parsedUri = expect(parseCanonicalResourceUri(replying.uri))}
153
-
<BskyPost
154
-
{client}
155
-
{selectedDid}
156
-
did={parsedUri.repo}
157
-
rkey={parsedUri.rkey}
158
-
data={replying}
159
-
isOnPostComposer={true}
160
-
/>
159
+
{@render renderPost(replying)}
161
160
{/if}
162
161
<textarea
163
162
bind:this={textareaEl}
···
170
169
}}
171
170
placeholder="what's on your mind?"
172
171
rows="4"
173
-
class="[field-sizing:content] single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
172
+
class="field-sizing-content single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
174
173
style="border-color: color-mix(in srgb, {color} 27%, transparent);"
175
174
></textarea>
176
175
{#if quoting}
177
-
{@const parsedUri = expect(parseCanonicalResourceUri(quoting.uri))}
178
-
<BskyPost
179
-
{client}
180
-
{selectedDid}
181
-
did={parsedUri.repo}
182
-
rkey={parsedUri.rkey}
183
-
data={quoting}
184
-
isOnPostComposer={true}
185
-
/>
176
+
{@render renderPost(quoting)}
186
177
{/if}
187
178
<div class="flex items-center gap-2">
188
179
<div class="grow"></div>
+1
-1
src/components/SettingsPopup.svelte
+1
-1
src/components/SettingsPopup.svelte
···
56
56
</script>
57
57
58
58
{#snippet divider()}
59
-
<div class="h-px bg-gradient-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
59
+
<div class="h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
60
60
{/snippet}
61
61
62
62
{#snippet settingHeader(name: string, desc: string)}
+19
-5
src/lib/accounts.ts
+19
-5
src/lib/accounts.ts
···
1
-
import type { Did, Handle } from '@atcute/lexicons';
1
+
import type { Handle } from '@atcute/lexicons';
2
2
import { writable } from 'svelte/store';
3
-
import { hashColor } from './theme.svelte';
3
+
import { hashColor } from './theme';
4
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
4
5
5
6
export type Account = {
6
-
did: Did;
7
-
handle: Handle;
8
-
password: string;
7
+
did: AtprotoDid;
8
+
handle: Handle | null;
9
9
};
10
10
11
11
let _accounts: Account[] = [];
···
22
22
23
23
export const addAccount = (account: Account): void => {
24
24
accounts.update((accounts) => [...accounts, account]);
25
+
};
26
+
27
+
export const loggingIn = {
28
+
set: (account: Account | null) => {
29
+
if (!account) {
30
+
localStorage.removeItem('loggingIn');
31
+
} else {
32
+
localStorage.setItem('loggingIn', JSON.stringify(account));
33
+
}
34
+
},
35
+
get: (): Account | null => {
36
+
const raw = localStorage.getItem('loggingIn');
37
+
return raw ? JSON.parse(raw) : null;
38
+
}
25
39
};
26
40
27
41
export const generateColorForDid = (did: string) => hashColor(did);
+5
-7
src/lib/at/client.ts
+5
-7
src/lib/at/client.ts
···
4
4
ComAtprotoRepoGetRecord,
5
5
ComAtprotoRepoListRecords
6
6
} from '@atcute/atproto';
7
-
import { Client as AtcuteClient, CredentialManager } from '@atcute/client';
7
+
import { Client as AtcuteClient } from '@atcute/client';
8
8
import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons';
9
9
import {
10
10
isDid,
···
37
37
import type { Notification } from './stardust';
38
38
import { get } from 'svelte/store';
39
39
import { settings } from '$lib/settings';
40
+
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
40
41
// import { JetstreamSubscription } from '@atcute/jetstream';
41
42
42
43
const cacheTtl = 1000 * 60 * 60 * 24;
···
73
74
public atcute: AtcuteClient | null = null;
74
75
public didDoc: MiniDoc | null = null;
75
76
76
-
async login(handle: Handle, password: string): Promise<Result<null, string>> {
77
-
const didDoc = await this.resolveDidDoc(handle);
77
+
async login(identifier: ActorIdentifier, agent: OAuthUserAgent): Promise<Result<null, string>> {
78
+
const didDoc = await this.resolveDidDoc(identifier);
78
79
if (!didDoc.ok) return err(didDoc.error);
79
80
this.didDoc = didDoc.value;
80
81
81
82
try {
82
-
const handler = new CredentialManager({ service: didDoc.value.pds });
83
-
const rpc = new AtcuteClient({ handler });
84
-
await handler.login({ identifier: didDoc.value.did, password });
85
-
83
+
const rpc = new AtcuteClient({ handler: agent });
86
84
this.atcute = rpc;
87
85
} catch (error) {
88
86
return err(`failed to login: ${error}`);
+91
src/lib/at/oauth.ts
+91
src/lib/at/oauth.ts
···
1
+
import {
2
+
configureOAuth,
3
+
defaultIdentityResolver,
4
+
createAuthorizationUrl,
5
+
finalizeAuthorization,
6
+
OAuthUserAgent,
7
+
getSession,
8
+
deleteStoredSession
9
+
} from '@atcute/oauth-browser-client';
10
+
11
+
import {
12
+
CompositeDidDocumentResolver,
13
+
PlcDidDocumentResolver,
14
+
WebDidDocumentResolver,
15
+
XrpcHandleResolver
16
+
} from '@atcute/identity-resolver';
17
+
import { slingshotUrl } from './client';
18
+
import type { ActorIdentifier } from '@atcute/lexicons';
19
+
import { err, ok, type Result } from '$lib/result';
20
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
21
+
import { clientId, redirectUri } from '$lib/oauth';
22
+
23
+
configureOAuth({
24
+
metadata: {
25
+
client_id: clientId,
26
+
redirect_uri: redirectUri
27
+
},
28
+
identityResolver: defaultIdentityResolver({
29
+
handleResolver: new XrpcHandleResolver({ serviceUrl: slingshotUrl.href }),
30
+
31
+
didDocumentResolver: new CompositeDidDocumentResolver({
32
+
methods: {
33
+
plc: new PlcDidDocumentResolver(),
34
+
web: new WebDidDocumentResolver()
35
+
}
36
+
})
37
+
})
38
+
});
39
+
40
+
export const sessions = {
41
+
get: async (did: AtprotoDid) => {
42
+
const session = await getSession(did, { allowStale: true });
43
+
return new OAuthUserAgent(session);
44
+
},
45
+
remove: async (did: AtprotoDid) => {
46
+
try {
47
+
const agent = await sessions.get(did);
48
+
await agent.signOut();
49
+
} catch {
50
+
deleteStoredSession(did);
51
+
}
52
+
}
53
+
};
54
+
55
+
export const flow = {
56
+
start: async (identifier: ActorIdentifier): Promise<Result<null, string>> => {
57
+
try {
58
+
const authUrl = await createAuthorizationUrl({
59
+
target: { type: 'account', identifier },
60
+
scope: 'atproto transition:generic'
61
+
});
62
+
// recommended to wait for the browser to persist local storage before proceeding
63
+
await new Promise((resolve) => setTimeout(resolve, 200));
64
+
// redirect the user to sign in and authorize the app
65
+
window.location.assign(authUrl);
66
+
// if this is on an async function, ideally the function should never ever resolve.
67
+
// the only way it should resolve at this point is if the user aborted the authorization
68
+
// by returning back to this page (thanks to back-forward page caching)
69
+
await new Promise((_resolve, reject) => {
70
+
const listener = () => {
71
+
reject(new Error(`user aborted the login request`));
72
+
};
73
+
window.addEventListener('pageshow', listener, { once: true });
74
+
});
75
+
return ok(null);
76
+
} catch (error) {
77
+
return err(`login error: ${error}`);
78
+
}
79
+
},
80
+
finalize: async (url: URL): Promise<Result<OAuthUserAgent | null, string>> => {
81
+
try {
82
+
// createAuthorizationUrl asks server to put the params in the hash
83
+
const params = new URLSearchParams(url.hash.slice(1));
84
+
if (!params.has('code')) return ok(null);
85
+
const { session } = await finalizeAuthorization(params);
86
+
return ok(new OAuthUserAgent(session));
87
+
} catch (error) {
88
+
return err(`login error: ${error}`);
89
+
}
90
+
}
91
+
};
+6
src/lib/domain.ts
+6
src/lib/domain.ts
-19
src/lib/index.ts
-19
src/lib/index.ts
···
1
-
import { writable } from 'svelte/store';
2
-
import { 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 { JetstreamSubscription } from '@atcute/jetstream';
7
-
8
-
export const selectedDid = writable<Did | null>(null);
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;
15
-
repost: Backlink | null;
16
-
// reply: Backlink | null;
17
-
// quote: Backlink | null;
18
-
};
19
-
export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
+23
src/lib/oauth.ts
+23
src/lib/oauth.ts
···
1
+
import domain from '$lib/domain';
2
+
import { dev } from '$app/environment';
3
+
4
+
export const oauthMetadata = {
5
+
client_id: `${domain}/oauth-client-metadata.json`,
6
+
client_name: 'nucleus',
7
+
client_uri: domain,
8
+
logo_uri: `${domain}/favicon.png`,
9
+
redirect_uris: [`${domain}/`],
10
+
scope: 'atproto transition:generic',
11
+
grant_types: ['authorization_code', 'refresh_token'],
12
+
response_types: ['code'],
13
+
token_endpoint_auth_method: 'none',
14
+
application_type: 'web',
15
+
dpop_bound_access_tokens: true
16
+
};
17
+
18
+
export const redirectUri = domain;
19
+
export const clientId = dev
20
+
? `http://localhost` +
21
+
`?redirect_uri=${encodeURIComponent(redirectUri)}` +
22
+
`&scope=${encodeURIComponent(oauthMetadata.scope)}`
23
+
: oauthMetadata.client_id;
+1
-1
src/lib/settings.ts
+1
-1
src/lib/settings.ts
+17
src/lib/state.svelte.ts
+17
src/lib/state.svelte.ts
···
1
+
import { writable } from 'svelte/store';
2
+
import { 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 { JetstreamSubscription } from '@atcute/jetstream';
7
+
8
+
export const notificationStream = writable<NotificationsStream | null>(null);
9
+
// export const jetstream = writable<JetstreamSubscription | null>(null);
10
+
11
+
export type PostActions = {
12
+
like: Backlink | null;
13
+
repost: Backlink | null;
14
+
// reply: Backlink | null;
15
+
// quote: Backlink | null;
16
+
};
17
+
export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
+1
-1
src/lib/theme.svelte.ts
src/lib/theme.ts
+1
-1
src/lib/theme.svelte.ts
src/lib/theme.ts
···
31
31
32
32
const hue = hash % 360;
33
33
const saturation = 0.8 + ((hash >>> 10) % 20) * 0.01; // 80-100%
34
-
const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 50-75%
34
+
const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 45-80%
35
35
36
36
const rgb = hslToRgb(hue, saturation, lightness);
37
37
const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join('');
+166
src/lib/thread.ts
+166
src/lib/thread.ts
···
1
+
import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons';
2
+
import type { Account } from './accounts';
3
+
import { expect } from './result';
4
+
import type { PostWithUri } from './at/fetch';
5
+
6
+
export type ThreadPost = {
7
+
data: PostWithUri;
8
+
did: Did;
9
+
rkey: string;
10
+
parentUri: ResourceUri | null;
11
+
depth: number;
12
+
newestTime: number;
13
+
};
14
+
15
+
export type Thread = {
16
+
rootUri: ResourceUri;
17
+
posts: ThreadPost[];
18
+
newestTime: number;
19
+
branchParentPost?: ThreadPost;
20
+
};
21
+
22
+
export const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => {
23
+
const threadMap = new Map<ResourceUri, ThreadPost[]>();
24
+
25
+
// group posts by root uri into "thread" chains
26
+
for (const [, timeline] of timelines) {
27
+
for (const [uri, data] of timeline) {
28
+
const parsedUri = expect(parseCanonicalResourceUri(uri));
29
+
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
30
+
const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
31
+
32
+
const post: ThreadPost = {
33
+
data,
34
+
did: parsedUri.repo,
35
+
rkey: parsedUri.rkey,
36
+
parentUri,
37
+
depth: 0,
38
+
newestTime: new Date(data.record.createdAt).getTime()
39
+
};
40
+
41
+
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
42
+
43
+
threadMap.get(rootUri)!.push(post);
44
+
}
45
+
}
46
+
47
+
const threads: Thread[] = [];
48
+
49
+
for (const [rootUri, posts] of threadMap) {
50
+
const uriToPost = new Map(posts.map((p) => [p.data.uri, p]));
51
+
const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
52
+
53
+
// calculate depths
54
+
for (const post of posts) {
55
+
let depth = 0;
56
+
let currentUri = post.parentUri;
57
+
58
+
while (currentUri && uriToPost.has(currentUri)) {
59
+
depth++;
60
+
currentUri = uriToPost.get(currentUri)!.parentUri;
61
+
}
62
+
63
+
post.depth = depth;
64
+
65
+
if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []);
66
+
childrenMap.get(post.parentUri)!.push(post);
67
+
}
68
+
69
+
childrenMap
70
+
.values()
71
+
.forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime));
72
+
73
+
const createThread = (
74
+
posts: ThreadPost[],
75
+
rootUri: ResourceUri,
76
+
branchParentUri?: ResourceUri
77
+
): Thread => {
78
+
return {
79
+
rootUri,
80
+
posts,
81
+
newestTime: Math.max(...posts.map((p) => p.newestTime)),
82
+
branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
83
+
};
84
+
};
85
+
86
+
const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
87
+
const result: ThreadPost[] = [];
88
+
const addWithChildren = (post: ThreadPost) => {
89
+
result.push(post);
90
+
const children = childrenMap.get(post.data.uri) || [];
91
+
children.forEach(addWithChildren);
92
+
};
93
+
addWithChildren(startPost);
94
+
return result;
95
+
};
96
+
97
+
// find posts with >2 children to split them into separate chains
98
+
const branchingPoints = Array.from(childrenMap.entries())
99
+
.filter(([, children]) => children.length > 1)
100
+
.map(([uri]) => uri);
101
+
102
+
if (branchingPoints.length === 0) {
103
+
const roots = childrenMap.get(null) || [];
104
+
const allPosts = roots.flatMap((root) => collectSubtree(root));
105
+
threads.push(createThread(allPosts, rootUri));
106
+
} else {
107
+
for (const branchParentUri of branchingPoints) {
108
+
const branches = childrenMap.get(branchParentUri) || [];
109
+
110
+
const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
111
+
112
+
sortedBranches.forEach((branchRoot, index) => {
113
+
const isOldestBranch = index === 0;
114
+
const branchPosts: ThreadPost[] = [];
115
+
116
+
// the oldest branch has the full context
117
+
// todo: consider letting the user decide this..?
118
+
if (isOldestBranch && branchParentUri !== null) {
119
+
const parentChain: ThreadPost[] = [];
120
+
let currentUri: ResourceUri | null = branchParentUri;
121
+
while (currentUri && uriToPost.has(currentUri)) {
122
+
parentChain.unshift(uriToPost.get(currentUri)!);
123
+
currentUri = uriToPost.get(currentUri)!.parentUri;
124
+
}
125
+
branchPosts.push(...parentChain);
126
+
}
127
+
128
+
branchPosts.push(...collectSubtree(branchRoot));
129
+
130
+
const minDepth = Math.min(...branchPosts.map((p) => p.depth));
131
+
branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
132
+
133
+
threads.push(
134
+
createThread(
135
+
branchPosts,
136
+
branchRoot.data.uri,
137
+
isOldestBranch ? undefined : (branchParentUri ?? undefined)
138
+
)
139
+
);
140
+
});
141
+
}
142
+
}
143
+
}
144
+
145
+
threads.sort((a, b) => b.newestTime - a.newestTime);
146
+
147
+
// console.log(threads);
148
+
149
+
return threads;
150
+
};
151
+
152
+
export const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
153
+
accounts.some((account) => account.did === post.did);
154
+
export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
155
+
posts.some((post) => !isOwnPost(post, accounts));
156
+
157
+
// todo: add more filtering options
158
+
export type FilterOptions = {
159
+
viewOwnPosts: boolean;
160
+
};
161
+
162
+
export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) =>
163
+
threads.filter((thread) => {
164
+
if (!opts.viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
165
+
return true;
166
+
});
+133
-273
src/routes/+page.svelte
+133
-273
src/routes/+page.svelte
···
4
4
import AccountSelector from '$components/AccountSelector.svelte';
5
5
import SettingsPopup from '$components/SettingsPopup.svelte';
6
6
import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client';
7
-
import { accounts, addAccount, type Account } from '$lib/accounts';
8
-
import {
9
-
type Did,
10
-
type Handle,
11
-
parseCanonicalResourceUri,
12
-
type ResourceUri
13
-
} from '@atcute/lexicons';
7
+
import { accounts, type Account } from '$lib/accounts';
8
+
import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons';
14
9
import { onMount } from 'svelte';
15
10
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch';
16
11
import { expect, ok } from '$lib/result';
17
12
import { AppBskyFeedPost } from '@atcute/bluesky';
18
13
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
19
14
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
20
-
import { notificationStream, selectedDid } from '$lib';
15
+
import { notificationStream } from '$lib/state.svelte';
21
16
import { get } from 'svelte/store';
22
17
import Icon from '@iconify/svelte';
18
+
import { sessions } from '$lib/at/oauth';
19
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
20
+
import type { PageProps } from './+page';
21
+
import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread';
23
22
24
-
let loaderState = new LoaderState();
25
-
let scrollContainer = $state<HTMLDivElement>();
23
+
const { data: loadData }: PageProps = $props();
24
+
25
+
let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null);
26
+
$effect(() => {
27
+
if (selectedDid) {
28
+
localStorage.setItem('selectedDid', selectedDid);
29
+
} else {
30
+
localStorage.removeItem('selectedDid');
31
+
}
32
+
});
26
33
27
-
let clients = new SvelteMap<Did, AtpClient>();
28
-
let selectedClient = $derived($selectedDid ? clients.get($selectedDid) : null);
34
+
const clients = new SvelteMap<AtprotoDid, AtpClient>();
35
+
const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
29
36
30
-
let viewClient = $state<AtpClient>(new AtpClient());
37
+
const loginAccount = async (account: Account) => {
38
+
if (clients.has(account.did)) return;
39
+
const client = new AtpClient();
40
+
const result = await client.login(account.did, await sessions.get(account.did));
41
+
if (result.ok) clients.set(account.did, client);
42
+
};
31
43
32
-
let posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
33
-
let cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
44
+
const handleAccountSelected = async (did: AtprotoDid) => {
45
+
selectedDid = did;
46
+
const account = $accounts.find((acc) => acc.did === did);
47
+
if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
48
+
await loginAccount(account);
49
+
};
50
+
51
+
const handleLogout = async (did: AtprotoDid) => {
52
+
await sessions.remove(did);
53
+
const newAccounts = $accounts.filter((acc) => acc.did !== did);
54
+
$accounts = newAccounts;
55
+
clients.delete(did);
56
+
posts.delete(did);
57
+
cursors.delete(did);
58
+
handleAccountSelected(newAccounts[0]?.did);
59
+
};
60
+
61
+
const viewClient = new AtpClient();
62
+
63
+
const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
64
+
const cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
34
65
35
66
let isSettingsOpen = $state(false);
36
67
let reverseChronological = $state(true);
37
68
let viewOwnPosts = $state(true);
69
+
70
+
const threads = $derived(filterThreads(buildThreads(posts), $accounts, { viewOwnPosts }));
71
+
72
+
let quoting = $state<PostWithUri | undefined>(undefined);
73
+
let replying = $state<PostWithUri | undefined>(undefined);
74
+
75
+
const expandedThreads = new SvelteSet<ResourceUri>();
38
76
39
77
const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => {
40
78
if (!posts.has(did)) {
···
119
157
// }
120
158
// };
121
159
160
+
const loaderState = new LoaderState();
161
+
let scrollContainer = $state<HTMLDivElement>();
162
+
163
+
let loading = $state(false);
164
+
let loadError = $state('');
165
+
const loadMore = async () => {
166
+
if (loading || $accounts.length === 0) return;
167
+
168
+
loading = true;
169
+
try {
170
+
await fetchTimelines($accounts);
171
+
loaderState.loaded();
172
+
} catch (error) {
173
+
loadError = `${error}`;
174
+
loaderState.error();
175
+
} finally {
176
+
loading = false;
177
+
if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
178
+
}
179
+
};
180
+
122
181
onMount(async () => {
123
182
accounts.subscribe((newAccounts) => {
124
183
get(notificationStream)?.stop();
···
147
206
// });
148
207
if ($accounts.length > 0) {
149
208
loaderState.status = 'LOADING';
150
-
$selectedDid = $accounts[0].did;
209
+
if (loadData.client.ok && loadData.client.value) {
210
+
const loggedInDid = loadData.client.value.didDoc!.did as AtprotoDid;
211
+
selectedDid = loggedInDid;
212
+
clients.set(loggedInDid, loadData.client.value);
213
+
}
214
+
if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did;
215
+
console.log('onMount selectedDid', selectedDid);
151
216
Promise.all($accounts.map(loginAccount)).then(() => {
152
217
loadMore();
153
218
});
219
+
} else {
220
+
selectedDid = null;
154
221
}
155
222
});
156
-
157
-
const loginAccount = async (account: Account) => {
158
-
const client = new AtpClient();
159
-
const result = await client.login(account.handle, account.password);
160
-
if (result.ok) clients.set(account.did, client);
161
-
};
162
-
163
-
const handleAccountSelected = async (did: Did) => {
164
-
$selectedDid = did;
165
-
const account = $accounts.find((acc) => acc.did === did);
166
-
if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
167
-
await loginAccount(account);
168
-
};
169
-
170
-
const handleLogout = async (did: Did) => {
171
-
const newAccounts = $accounts.filter((acc) => acc.did !== did);
172
-
$accounts = newAccounts;
173
-
clients.delete(did);
174
-
posts.delete(did);
175
-
cursors.delete(did);
176
-
handleAccountSelected(newAccounts[0]?.did);
177
-
};
178
-
179
-
const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => {
180
-
const newAccount: Account = { did, handle, password };
181
-
addAccount(newAccount);
182
-
$selectedDid = did;
183
-
loginAccount(newAccount).then(() => fetchTimeline(newAccount));
184
-
};
185
-
186
-
let loading = $state(false);
187
-
let loadError = $state('');
188
-
const loadMore = async () => {
189
-
if (loading || $accounts.length === 0) return;
190
-
191
-
loading = true;
192
-
try {
193
-
await fetchTimelines($accounts);
194
-
loaderState.loaded();
195
-
} catch (error) {
196
-
loadError = `${error}`;
197
-
loaderState.error();
198
-
} finally {
199
-
loading = false;
200
-
if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
201
-
}
202
-
};
203
-
204
-
type ThreadPost = {
205
-
data: PostWithUri;
206
-
did: Did;
207
-
rkey: string;
208
-
parentUri: ResourceUri | null;
209
-
depth: number;
210
-
newestTime: number;
211
-
};
212
-
213
-
type Thread = {
214
-
rootUri: ResourceUri;
215
-
posts: ThreadPost[];
216
-
newestTime: number;
217
-
branchParentPost?: ThreadPost;
218
-
};
219
-
220
-
const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => {
221
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
222
-
const threadMap = new Map<ResourceUri, ThreadPost[]>();
223
-
224
-
// group posts by root uri into "thread" chains
225
-
for (const [, timeline] of timelines) {
226
-
for (const [uri, data] of timeline) {
227
-
const parsedUri = expect(parseCanonicalResourceUri(uri));
228
-
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
229
-
const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
230
-
231
-
const post: ThreadPost = {
232
-
data,
233
-
did: parsedUri.repo,
234
-
rkey: parsedUri.rkey,
235
-
parentUri,
236
-
depth: 0,
237
-
newestTime: new Date(data.record.createdAt).getTime()
238
-
};
239
-
240
-
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
241
-
242
-
threadMap.get(rootUri)!.push(post);
243
-
}
244
-
}
245
-
246
-
const threads: Thread[] = [];
247
-
248
-
for (const [rootUri, posts] of threadMap) {
249
-
const uriToPost = new Map(posts.map((p) => [p.data.uri, p]));
250
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
251
-
const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
252
-
253
-
// calculate depths
254
-
for (const post of posts) {
255
-
let depth = 0;
256
-
let currentUri = post.parentUri;
257
-
258
-
while (currentUri && uriToPost.has(currentUri)) {
259
-
depth++;
260
-
currentUri = uriToPost.get(currentUri)!.parentUri;
261
-
}
262
-
263
-
post.depth = depth;
264
-
265
-
if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []);
266
-
childrenMap.get(post.parentUri)!.push(post);
267
-
}
268
-
269
-
childrenMap
270
-
.values()
271
-
.forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime));
272
-
273
-
const createThread = (
274
-
posts: ThreadPost[],
275
-
rootUri: ResourceUri,
276
-
branchParentUri?: ResourceUri
277
-
): Thread => {
278
-
return {
279
-
rootUri,
280
-
posts,
281
-
newestTime: Math.max(...posts.map((p) => p.newestTime)),
282
-
branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
283
-
};
284
-
};
285
-
286
-
const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
287
-
const result: ThreadPost[] = [];
288
-
const addWithChildren = (post: ThreadPost) => {
289
-
result.push(post);
290
-
const children = childrenMap.get(post.data.uri) || [];
291
-
children.forEach(addWithChildren);
292
-
};
293
-
addWithChildren(startPost);
294
-
return result;
295
-
};
296
-
297
-
// find posts with >2 children to split them into separate chains
298
-
const branchingPoints = Array.from(childrenMap.entries())
299
-
.filter(([, children]) => children.length > 1)
300
-
.map(([uri]) => uri);
301
-
302
-
if (branchingPoints.length === 0) {
303
-
const roots = childrenMap.get(null) || [];
304
-
const allPosts = roots.flatMap((root) => collectSubtree(root));
305
-
threads.push(createThread(allPosts, rootUri));
306
-
} else {
307
-
for (const branchParentUri of branchingPoints) {
308
-
const branches = childrenMap.get(branchParentUri) || [];
309
-
310
-
const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
311
-
312
-
sortedBranches.forEach((branchRoot, index) => {
313
-
const isOldestBranch = index === 0;
314
-
const branchPosts: ThreadPost[] = [];
315
-
316
-
// the oldest branch has the full context
317
-
// todo: consider letting the user decide this..?
318
-
if (isOldestBranch && branchParentUri !== null) {
319
-
const parentChain: ThreadPost[] = [];
320
-
let currentUri: ResourceUri | null = branchParentUri;
321
-
while (currentUri && uriToPost.has(currentUri)) {
322
-
parentChain.unshift(uriToPost.get(currentUri)!);
323
-
currentUri = uriToPost.get(currentUri)!.parentUri;
324
-
}
325
-
branchPosts.push(...parentChain);
326
-
}
327
-
328
-
branchPosts.push(...collectSubtree(branchRoot));
329
-
330
-
const minDepth = Math.min(...branchPosts.map((p) => p.depth));
331
-
branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
332
-
333
-
threads.push(
334
-
createThread(
335
-
branchPosts,
336
-
branchRoot.data.uri,
337
-
isOldestBranch ? undefined : (branchParentUri ?? undefined)
338
-
)
339
-
);
340
-
});
341
-
}
342
-
}
343
-
}
344
-
345
-
threads.sort((a, b) => b.newestTime - a.newestTime);
346
-
347
-
// console.log(threads);
348
-
349
-
return threads;
350
-
};
351
-
352
-
// todo: add more filtering options
353
-
const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
354
-
accounts.some((account) => account.did === post.did);
355
-
const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
356
-
posts.some((post) => !isOwnPost(post, accounts));
357
-
const filterThreads = (threads: Thread[], accounts: Account[]) =>
358
-
threads.filter((thread) => {
359
-
if (!viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
360
-
return true;
361
-
});
362
-
363
-
let threads = $derived(filterThreads(buildThreads(posts), $accounts));
364
-
365
-
let quoting = $state<PostWithUri | undefined>(undefined);
366
-
let replying = $state<PostWithUri | undefined>(undefined);
367
-
368
-
let expandedThreads = new SvelteSet<ResourceUri>();
369
223
</script>
370
224
371
225
<div class="mx-auto flex h-screen max-w-2xl flex-col p-4">
372
-
<div class="mb-6 flex flex-shrink-0 items-center justify-between">
226
+
<div class="mb-6 flex shrink-0 items-center justify-between">
373
227
<div>
374
228
<h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
375
229
<div class="mt-1 flex gap-2">
···
387
241
</button>
388
242
</div>
389
243
390
-
<div class="flex-shrink-0 space-y-4">
244
+
<div class="shrink-0 space-y-4">
391
245
<div class="flex min-h-16 items-stretch gap-2">
392
246
<AccountSelector
393
247
client={viewClient}
394
248
accounts={$accounts}
395
-
bind:selectedDid={$selectedDid}
249
+
bind:selectedDid
396
250
onAccountSelected={handleAccountSelected}
397
-
onLoginSucceed={handleLoginSucceed}
398
251
onLogout={handleLogout}
399
252
/>
400
253
···
402
255
<div class="flex-1">
403
256
<PostComposer
404
257
client={selectedClient}
405
-
{selectedDid}
406
-
onPostSent={(post) => posts.get($selectedDid!)?.set(post.uri, post)}
258
+
onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)}
407
259
bind:quoting
408
260
bind:replying
409
261
/>
···
416
268
</div>
417
269
{/if}
418
270
</div>
271
+
272
+
{#if !loadData.client.ok}
273
+
<div class="error-disclaimer">
274
+
<p>
275
+
<Icon class="inline h-12 w-12" icon="heroicons:exclamation-triangle-16-solid" />
276
+
{loadData.client.error}
277
+
</p>
278
+
</div>
279
+
{/if}
419
280
420
281
<!-- <hr
421
282
class="h-[4px] w-full rounded-full border-0"
···
441
302
442
303
<SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} />
443
304
444
-
{#snippet renderThreads()}
445
-
<InfiniteLoader
446
-
{loaderState}
447
-
triggerLoad={loadMore}
448
-
loopDetectionTimeout={0}
449
-
intersectionOptions={{ root: scrollContainer }}
450
-
>
451
-
{@render threadsView()}
452
-
{#snippet noData()}
453
-
<div class="flex justify-center py-4">
454
-
<p class="text-xl opacity-80">
455
-
all posts seen! <span class="text-2xl">:o</span>
456
-
</p>
457
-
</div>
458
-
{/snippet}
459
-
{#snippet loading()}
460
-
<div class="flex justify-center">
461
-
<div
462
-
class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent"
463
-
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
464
-
></div>
465
-
</div>
466
-
{/snippet}
467
-
{#snippet error()}
468
-
<div class="flex justify-center py-4">
469
-
<p class="text-xl opacity-80">
470
-
<span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError}
471
-
</p>
472
-
</div>
473
-
{/snippet}
474
-
</InfiniteLoader>
475
-
{/snippet}
476
-
477
305
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
478
306
<span
479
-
class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap break-words overflow-ellipsis"
307
+
class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis"
480
308
>
481
309
<span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span>
482
-
<BskyPost mini {selectedDid} client={selectedClient ?? viewClient} {...post} />
310
+
<BskyPost mini client={selectedClient ?? viewClient} {...post} />
483
311
</span>
484
312
{/snippet}
485
313
···
498
326
{#if !mini}
499
327
<div class="mb-1.5">
500
328
<BskyPost
501
-
{selectedDid}
502
329
client={selectedClient ?? viewClient}
503
330
onQuote={(post) => (quoting = post)}
504
331
onReply={(post) => (replying = post)}
···
509
336
{#if idx === 1}
510
337
{@render replyPost(post, !reverseChronological)}
511
338
<button
512
-
class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,_var(--nucleus-fg)_50%,_var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)"
339
+
class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,var(--nucleus-fg)_50%,var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)"
513
340
onclick={() => expandedThreads.add(thread.rootUri)}
514
341
>
515
342
<div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
···
529
356
{/each}
530
357
</div>
531
358
<div
532
-
class="mx-8 mt-3 mb-4 h-px bg-gradient-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
359
+
class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
533
360
></div>
534
361
{/each}
535
362
{/snippet}
363
+
364
+
{#snippet renderThreads()}
365
+
<InfiniteLoader
366
+
{loaderState}
367
+
triggerLoad={loadMore}
368
+
loopDetectionTimeout={0}
369
+
intersectionOptions={{ root: scrollContainer }}
370
+
>
371
+
{@render threadsView()}
372
+
{#snippet noData()}
373
+
<div class="flex justify-center py-4">
374
+
<p class="text-xl opacity-80">
375
+
all posts seen! <span class="text-2xl">:o</span>
376
+
</p>
377
+
</div>
378
+
{/snippet}
379
+
{#snippet loading()}
380
+
<div class="flex justify-center">
381
+
<div
382
+
class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent"
383
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
384
+
></div>
385
+
</div>
386
+
{/snippet}
387
+
{#snippet error()}
388
+
<div class="flex justify-center py-4">
389
+
<p class="text-xl opacity-80">
390
+
<span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError}
391
+
</p>
392
+
</div>
393
+
{/snippet}
394
+
</InfiniteLoader>
395
+
{/snippet}
+48
src/routes/+page.ts
+48
src/routes/+page.ts
···
1
+
import { replaceState } from '$app/navigation';
2
+
import { addAccount, loggingIn } from '$lib/accounts';
3
+
import { AtpClient } from '$lib/at/client';
4
+
import { flow } from '$lib/at/oauth';
5
+
import { err, ok, type Result } from '$lib/result';
6
+
import type { PageLoad } from './$types';
7
+
8
+
export type PageProps = {
9
+
data: {
10
+
client: Result<AtpClient | null, string>;
11
+
};
12
+
};
13
+
14
+
export const load: PageLoad = async (): Promise<PageProps['data']> => {
15
+
return { client: await handleLogin() };
16
+
};
17
+
18
+
const handleLogin = async (): Promise<Result<AtpClient | null, string>> => {
19
+
const account = loggingIn.get();
20
+
if (!account) return ok(null);
21
+
22
+
const currentUrl = new URL(window.location.href);
23
+
// scrub history so auth state cant be replayed
24
+
try {
25
+
replaceState('', '/');
26
+
} catch {
27
+
// if router was unitialized then we probably dont need to scrub anyway
28
+
// so its fine
29
+
}
30
+
31
+
loggingIn.set(null);
32
+
const agent = await flow.finalize(currentUrl);
33
+
if (!agent.ok || !agent.value) {
34
+
if (!agent.ok) {
35
+
return err(agent.error);
36
+
}
37
+
return err('no session was logged into?!');
38
+
}
39
+
40
+
const client = new AtpClient();
41
+
const result = await client.login(account.did, agent.value);
42
+
if (!result.ok) {
43
+
return err(result.error);
44
+
}
45
+
46
+
addAccount(account);
47
+
return ok(client);
48
+
};
+11
src/routes/oauth-client-metadata.json/+server.ts
+11
src/routes/oauth-client-metadata.json/+server.ts
+1
-7
tsconfig.json
+1
-7
tsconfig.json
···
10
10
"sourceMap": true,
11
11
"strict": true,
12
12
"moduleResolution": "bundler",
13
-
"jsx": "react-jsx",
14
-
"paths": {
15
-
"$components": ["./src/components"],
16
-
"$components/*": ["./src/components/*"],
17
-
"$lib": ["./src/lib"],
18
-
"$lib/*": ["./src/lib/*"]
19
-
}
13
+
"jsx": "react-jsx"
20
14
}
21
15
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
22
16
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files