1<script lang="ts">
2 import BskyPost from '$components/BskyPost.svelte';
3 import PostComposer from '$components/PostComposer.svelte';
4 import AccountSelector from '$components/AccountSelector.svelte';
5 import SettingsPopup from '$components/SettingsPopup.svelte';
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';
14 import { onMount } from 'svelte';
15 import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch';
16 import { expect, ok } from '$lib/result';
17 import { AppBskyFeedPost } from '@atcute/bluesky';
18 import { SvelteMap } from 'svelte/reactivity';
19 import { InfiniteLoader, LoaderState } from 'svelte-infinite';
20 import { notificationStream } from '$lib';
21 import { get } from 'svelte/store';
22
23 let loaderState = new LoaderState();
24 let scrollContainer = $state<HTMLDivElement>();
25
26 let selectedDid = $state<Did | null>(null);
27 let clients = new SvelteMap<Did, AtpClient>();
28 let selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
29
30 let viewClient = $state<AtpClient>(new AtpClient());
31
32 let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>();
33 let cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
34
35 let isSettingsOpen = $state(false);
36
37 const addPosts = (did: Did, accTimeline: Map<ResourceUri, AppBskyFeedPost.Main>) => {
38 if (!posts.has(did)) {
39 posts.set(did, new SvelteMap(accTimeline));
40 return;
41 }
42 const map = posts.get(did)!;
43 for (const [uri, record] of accTimeline) map.set(uri, record);
44 };
45
46 const fetchTimeline = async (account: Account) => {
47 const client = clients.get(account.did);
48 if (!client) return;
49
50 const cursor = cursors.get(account.did);
51 if (cursor && cursor.end) return;
52
53 const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 12);
54 if (!accPosts.ok)
55 throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`;
56
57 // if the cursor is undefined, we've reached the end of the timeline
58 if (!accPosts.value.cursor) {
59 cursors.set(account.did, { ...cursor, end: true });
60 return;
61 }
62
63 cursors.set(account.did, { value: accPosts.value.cursor, end: false });
64 addPosts(account.did, await hydratePosts(client, accPosts.value.posts));
65 };
66
67 const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
68
69 const handleNotification = async (event: NotificationsStreamEvent) => {
70 if (event.type === 'message') {
71 // console.log(event.data);
72 const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
73 const subjectPost = await viewClient.getRecord(
74 AppBskyFeedPost.mainSchema,
75 parsedSubjectUri.repo,
76 parsedSubjectUri.rkey
77 );
78 if (!subjectPost.ok) return;
79
80 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
81 const hydrated = await hydratePosts(viewClient, [
82 {
83 record: subjectPost.value,
84 uri: event.data.link.subject,
85 replies: ok({
86 cursor: null,
87 total: 1,
88 records: [
89 {
90 did: parsedSourceUri.repo,
91 collection: parsedSourceUri.collection,
92 rkey: parsedSourceUri.rkey
93 }
94 ]
95 })
96 }
97 ]);
98
99 // console.log(hydrated);
100 addPosts(parsedSubjectUri.repo, hydrated);
101 }
102 };
103
104 // const handleJetstream = async (subscription: JetstreamSubscription) => {
105 // for await (const event of subscription) {
106 // if (event.kind !== 'commit') continue;
107 // const commit = event.commit;
108 // if (commit.operation === 'delete') {
109 // continue;
110 // }
111 // const record = commit.record as AppBskyFeedPost.Main;
112 // addPosts(
113 // event.did,
114 // new Map([[`at://${event.did}/${commit.collection}/${commit.rkey}` as ResourceUri, record]])
115 // );
116 // }
117 // };
118
119 onMount(async () => {
120 accounts.subscribe((newAccounts) => {
121 get(notificationStream)?.stop();
122 // jetstream.set(null);
123 if (newAccounts.length === 0) return;
124 notificationStream.set(
125 viewClient.streamNotifications(
126 newAccounts.map((account) => account.did),
127 'app.bsky.feed.post:reply.parent.uri'
128 )
129 );
130 // jetstream.set(
131 // viewClient.streamJetstream(
132 // newAccounts.map((account) => account.did),
133 // 'app.bsky.feed.post'
134 // )
135 // );
136 });
137 notificationStream.subscribe((stream) => {
138 if (!stream) return;
139 stream.listen(handleNotification);
140 });
141 // jetstream.subscribe((stream) => {
142 // if (!stream) return;
143 // handleJetstream(stream);
144 // });
145 if ($accounts.length > 0) {
146 loaderState.status = 'LOADING';
147 selectedDid = $accounts[0].did;
148 Promise.all($accounts.map(loginAccount)).then(() => {
149 loadMore();
150 });
151 }
152 });
153
154 const loginAccount = async (account: Account) => {
155 const client = new AtpClient();
156 const result = await client.login(account.handle, account.password);
157 if (result.ok) clients.set(account.did, client);
158 };
159
160 const handleAccountSelected = async (did: Did) => {
161 selectedDid = did;
162 const account = $accounts.find((acc) => acc.did === did);
163 if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
164 await loginAccount(account);
165 };
166
167 const handleLogout = async (did: Did) => {
168 const newAccounts = $accounts.filter((acc) => acc.did !== did);
169 $accounts = newAccounts;
170 clients.delete(did);
171 posts.delete(did);
172 cursors.delete(did);
173 handleAccountSelected(newAccounts[0]?.did);
174 };
175
176 const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => {
177 const newAccount: Account = { did, handle, password };
178 addAccount(newAccount);
179 selectedDid = did;
180 loginAccount(newAccount).then(() => fetchTimeline(newAccount));
181 };
182
183 let loading = $state(false);
184 let loadError = $state('');
185 const loadMore = async () => {
186 if (loading || $accounts.length === 0) return;
187
188 loading = true;
189 try {
190 await fetchTimelines($accounts);
191 loaderState.loaded();
192 } catch (error) {
193 loadError = `${error}`;
194 loaderState.error();
195 } finally {
196 loading = false;
197 if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
198 }
199 };
200
201 let reverseChronological = $state(true);
202 let viewOwnPosts = $state(true);
203
204 type ThreadPost = {
205 uri: ResourceUri;
206 did: Did;
207 rkey: string;
208 record: AppBskyFeedPost.Main;
209 parentUri: ResourceUri | null;
210 depth: number;
211 newestTime: number;
212 };
213
214 type Thread = {
215 rootUri: ResourceUri;
216 posts: ThreadPost[];
217 newestTime: number;
218 branchParentPost?: ThreadPost;
219 };
220
221 const buildThreads = (timelines: Map<Did, Map<ResourceUri, AppBskyFeedPost.Main>>): Thread[] => {
222 // eslint-disable-next-line svelte/prefer-svelte-reactivity
223 const threadMap = new Map<ResourceUri, ThreadPost[]>();
224
225 // Single pass: create posts and group by thread
226 for (const [, timeline] of timelines) {
227 for (const [uri, record] of timeline) {
228 const parsedUri = expect(parseCanonicalResourceUri(uri));
229 const rootUri = (record.reply?.root.uri as ResourceUri) || uri;
230 const parentUri = (record.reply?.parent.uri as ResourceUri) || null;
231
232 const post: ThreadPost = {
233 uri,
234 did: parsedUri.repo,
235 rkey: parsedUri.rkey,
236 record,
237 parentUri,
238 depth: 0,
239 newestTime: new Date(record.createdAt).getTime()
240 };
241
242 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
243
244 threadMap.get(rootUri)!.push(post);
245 }
246 }
247
248 const threads: Thread[] = [];
249
250 for (const [rootUri, posts] of threadMap) {
251 const uriToPost = new Map(posts.map((p) => [p.uri, p]));
252 // eslint-disable-next-line svelte/prefer-svelte-reactivity
253 const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
254
255 // Calculate depth and group by parent
256 for (const post of posts) {
257 let depth = 0;
258 let currentUri = post.parentUri;
259
260 while (currentUri && uriToPost.has(currentUri)) {
261 depth++;
262 currentUri = uriToPost.get(currentUri)!.parentUri;
263 }
264
265 post.depth = depth;
266
267 if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []);
268 childrenMap.get(post.parentUri)!.push(post);
269 }
270
271 // Sort children by time (newest first)
272 childrenMap
273 .values()
274 .forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime));
275
276 // Helper to create a thread from posts
277 const createThread = (
278 posts: ThreadPost[],
279 rootUri: ResourceUri,
280 branchParentUri?: ResourceUri
281 ): Thread => {
282 return {
283 rootUri,
284 posts,
285 newestTime: Math.max(...posts.map((p) => p.newestTime)),
286 branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
287 };
288 };
289
290 // Helper to collect all posts in a subtree
291 const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
292 const result: ThreadPost[] = [];
293 const addWithChildren = (post: ThreadPost) => {
294 result.push(post);
295 const children = childrenMap.get(post.uri) || [];
296 children.forEach(addWithChildren);
297 };
298 addWithChildren(startPost);
299 return result;
300 };
301
302 // Find branching points (posts with 2+ children)
303 const branchingPoints = Array.from(childrenMap.entries())
304 .filter(([, children]) => children.length > 1)
305 .map(([uri]) => uri);
306
307 if (branchingPoints.length === 0) {
308 // No branches - single thread
309 const roots = childrenMap.get(null) || [];
310 const allPosts = roots.flatMap((root) => collectSubtree(root));
311 threads.push(createThread(allPosts, rootUri));
312 } else {
313 // Has branches - split into separate threads
314 for (const branchParentUri of branchingPoints) {
315 const branches = childrenMap.get(branchParentUri) || [];
316
317 // Sort branches oldest to newest for processing
318 const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
319
320 sortedBranches.forEach((branchRoot, index) => {
321 const isOldestBranch = index === 0;
322 const branchPosts: ThreadPost[] = [];
323
324 // If oldest branch, include parent chain
325 if (isOldestBranch && branchParentUri !== null) {
326 const parentChain: ThreadPost[] = [];
327 let currentUri: ResourceUri | null = branchParentUri;
328 while (currentUri && uriToPost.has(currentUri)) {
329 parentChain.unshift(uriToPost.get(currentUri)!);
330 currentUri = uriToPost.get(currentUri)!.parentUri;
331 }
332 branchPosts.push(...parentChain);
333 }
334
335 // Add branch posts
336 branchPosts.push(...collectSubtree(branchRoot));
337
338 // Recalculate depths for display
339 const minDepth = Math.min(...branchPosts.map((p) => p.depth));
340 branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
341
342 threads.push(
343 createThread(
344 branchPosts,
345 branchRoot.uri,
346 isOldestBranch ? undefined : (branchParentUri ?? undefined)
347 )
348 );
349 });
350 }
351 }
352 }
353
354 // Sort threads by newest time (descending) so older branches appear first
355 threads.sort((a, b) => b.newestTime - a.newestTime);
356
357 // console.log(threads);
358
359 return threads;
360 };
361
362 // Filtering functions
363 const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
364 accounts.some((account) => account.did === post.did);
365 const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
366 posts.some((post) => !isOwnPost(post, accounts));
367 const filterThreads = (threads: Thread[], accounts: Account[]) =>
368 threads.filter((thread) => {
369 if (!viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
370 return true;
371 });
372
373 let threads = $derived(filterThreads(buildThreads(posts), $accounts));
374</script>
375
376<div class="mx-auto flex h-screen max-w-2xl flex-col p-4">
377 <div class="mb-6 flex flex-shrink-0 items-center justify-between">
378 <div>
379 <h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
380 <div class="mt-1 flex gap-2">
381 <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div>
382 <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div>
383 </div>
384 </div>
385 <button
386 onclick={() => (isSettingsOpen = true)}
387 class="rounded-sm bg-(--nucleus-accent)/7 p-2.5 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg"
388 aria-label="Settings"
389 >
390 <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
391 <path
392 stroke-linecap="round"
393 stroke-linejoin="round"
394 stroke-width="2"
395 d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
396 />
397 <path
398 stroke-linecap="round"
399 stroke-linejoin="round"
400 stroke-width="2"
401 d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
402 />
403 </svg>
404 </button>
405 </div>
406
407 <div class="flex-shrink-0 space-y-4">
408 <div class="flex min-h-16 items-stretch gap-2">
409 <AccountSelector
410 client={viewClient}
411 accounts={$accounts}
412 bind:selectedDid
413 onAccountSelected={handleAccountSelected}
414 onLoginSucceed={handleLoginSucceed}
415 onLogout={handleLogout}
416 />
417
418 {#if selectedClient}
419 <div class="flex-1">
420 <PostComposer
421 client={selectedClient}
422 onPostSent={(uri, record) => posts.get(selectedDid!)?.set(uri, record)}
423 />
424 </div>
425 {:else}
426 <div
427 class="flex flex-1 items-center justify-center rounded-sm border-2 border-(--nucleus-accent)/20 bg-(--nucleus-accent)/4 px-4 py-2.5 backdrop-blur-sm"
428 >
429 <p class="text-sm opacity-80">select or add an account to post</p>
430 </div>
431 {/if}
432 </div>
433
434 <hr
435 class="h-[4px] w-full rounded-full border-0"
436 style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
437 />
438 </div>
439
440 <div
441 class="mt-4 overflow-y-scroll [scrollbar-color:var(--nucleus-accent)_transparent]"
442 bind:this={scrollContainer}
443 >
444 {#if $accounts.length > 0}
445 {@render renderThreads()}
446 {:else}
447 <div class="flex justify-center py-4">
448 <p class="text-xl opacity-80">
449 <span class="text-4xl">x_x</span> <br /> no accounts are logged in!
450 </p>
451 </div>
452 {/if}
453 </div>
454</div>
455
456<SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} />
457
458{#snippet renderThreads()}
459 <InfiniteLoader
460 {loaderState}
461 triggerLoad={loadMore}
462 loopDetectionTimeout={0}
463 intersectionOptions={{ root: scrollContainer }}
464 >
465 {@render threadsView()}
466 {#snippet noData()}
467 <div class="flex justify-center py-4">
468 <p class="text-xl opacity-80">
469 all posts seen! <span class="text-2xl">:o</span>
470 </p>
471 </div>
472 {/snippet}
473 {#snippet loading()}
474 <div class="flex justify-center">
475 <div
476 class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent"
477 style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
478 ></div>
479 </div>
480 {/snippet}
481 {#snippet error()}
482 <div class="flex justify-center py-4">
483 <p class="text-xl opacity-80">
484 <span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError}
485 </p>
486 </div>
487 {/snippet}
488 </InfiniteLoader>
489{/snippet}
490
491{#snippet threadsView()}
492 {#each threads as thread ([thread.rootUri, thread.branchParentPost, ...thread.posts.map((post) => post.uri)])}
493 <div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5">
494 {#if thread.branchParentPost}
495 {@const post = thread.branchParentPost}
496 <div class="mb-1.5 flex items-center gap-1.5">
497 <span class="text-sm text-nowrap opacity-60">{reverseChronological ? '↱' : '↳'}</span>
498 <BskyPost mini client={viewClient} {...post} />
499 </div>
500 {/if}
501 {#each thread.posts as post (post.uri)}
502 <div class="mb-1.5">
503 <BskyPost client={viewClient} {...post} />
504 </div>
505 {/each}
506 </div>
507 {/each}
508{/snippet}