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, type PostWithUri } from '$lib/at/fetch';
16 import { expect, ok } from '$lib/result';
17 import { AppBskyFeedPost } from '@atcute/bluesky';
18 import { SvelteMap, SvelteSet } from 'svelte/reactivity';
19 import { InfiniteLoader, LoaderState } from 'svelte-infinite';
20 import { notificationStream, selectedDid } from '$lib';
21 import { get } from 'svelte/store';
22 import Icon from '@iconify/svelte';
23
24 let loaderState = new LoaderState();
25 let scrollContainer = $state<HTMLDivElement>();
26
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, PostWithUri>>();
33 let cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
34
35 let isSettingsOpen = $state(false);
36 let reverseChronological = $state(true);
37 let viewOwnPosts = $state(true);
38
39 const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => {
40 if (!posts.has(did)) {
41 posts.set(did, new SvelteMap(accTimeline));
42 return;
43 }
44 const map = posts.get(did)!;
45 for (const [uri, record] of accTimeline) map.set(uri, record);
46 };
47
48 const fetchTimeline = async (account: Account) => {
49 const client = clients.get(account.did);
50 if (!client) return;
51
52 const cursor = cursors.get(account.did);
53 if (cursor && cursor.end) return;
54
55 const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6);
56 if (!accPosts.ok)
57 throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`;
58
59 // if the cursor is undefined, we've reached the end of the timeline
60 if (!accPosts.value.cursor) {
61 cursors.set(account.did, { ...cursor, end: true });
62 return;
63 }
64
65 cursors.set(account.did, { value: accPosts.value.cursor, end: false });
66 addPosts(account.did, await hydratePosts(client, accPosts.value.posts));
67 };
68
69 const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
70
71 const handleNotification = async (event: NotificationsStreamEvent) => {
72 if (event.type === 'message') {
73 // console.log(event.data);
74 const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
75 const subjectPost = await viewClient.getRecord(
76 AppBskyFeedPost.mainSchema,
77 parsedSubjectUri.repo,
78 parsedSubjectUri.rkey
79 );
80 if (!subjectPost.ok) return;
81
82 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
83 const hydrated = await hydratePosts(viewClient, [
84 {
85 record: subjectPost.value.record,
86 uri: event.data.link.subject,
87 cid: subjectPost.value.cid,
88 replies: ok({
89 cursor: null,
90 total: 1,
91 records: [
92 {
93 did: parsedSourceUri.repo,
94 collection: parsedSourceUri.collection,
95 rkey: parsedSourceUri.rkey
96 }
97 ]
98 })
99 }
100 ]);
101
102 // console.log(hydrated);
103 addPosts(parsedSubjectUri.repo, hydrated);
104 }
105 };
106
107 // const handleJetstream = async (subscription: JetstreamSubscription) => {
108 // for await (const event of subscription) {
109 // if (event.kind !== 'commit') continue;
110 // const commit = event.commit;
111 // if (commit.operation === 'delete') {
112 // continue;
113 // }
114 // const record = commit.record as AppBskyFeedPost.Main;
115 // addPosts(
116 // event.did,
117 // new Map([[`at://${event.did}/${commit.collection}/${commit.rkey}` as ResourceUri, record]])
118 // );
119 // }
120 // };
121
122 onMount(async () => {
123 accounts.subscribe((newAccounts) => {
124 get(notificationStream)?.stop();
125 // jetstream.set(null);
126 if (newAccounts.length === 0) return;
127 notificationStream.set(
128 viewClient.streamNotifications(
129 newAccounts.map((account) => account.did),
130 'app.bsky.feed.post:reply.parent.uri'
131 )
132 );
133 // jetstream.set(
134 // viewClient.streamJetstream(
135 // newAccounts.map((account) => account.did),
136 // 'app.bsky.feed.post'
137 // )
138 // );
139 });
140 notificationStream.subscribe((stream) => {
141 if (!stream) return;
142 stream.listen(handleNotification);
143 });
144 // jetstream.subscribe((stream) => {
145 // if (!stream) return;
146 // handleJetstream(stream);
147 // });
148 if ($accounts.length > 0) {
149 loaderState.status = 'LOADING';
150 $selectedDid = $accounts[0].did;
151 Promise.all($accounts.map(loginAccount)).then(() => {
152 loadMore();
153 });
154 }
155 });
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 // Single pass: create posts and group by thread
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 depth and group by parent
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 // Sort children by time (newest first)
270 childrenMap
271 .values()
272 .forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime));
273
274 // Helper to create a thread from posts
275 const createThread = (
276 posts: ThreadPost[],
277 rootUri: ResourceUri,
278 branchParentUri?: ResourceUri
279 ): Thread => {
280 return {
281 rootUri,
282 posts,
283 newestTime: Math.max(...posts.map((p) => p.newestTime)),
284 branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
285 };
286 };
287
288 // Helper to collect all posts in a subtree
289 const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
290 const result: ThreadPost[] = [];
291 const addWithChildren = (post: ThreadPost) => {
292 result.push(post);
293 const children = childrenMap.get(post.data.uri) || [];
294 children.forEach(addWithChildren);
295 };
296 addWithChildren(startPost);
297 return result;
298 };
299
300 // Find branching points (posts with 2+ children)
301 const branchingPoints = Array.from(childrenMap.entries())
302 .filter(([, children]) => children.length > 1)
303 .map(([uri]) => uri);
304
305 if (branchingPoints.length === 0) {
306 // No branches - single thread
307 const roots = childrenMap.get(null) || [];
308 const allPosts = roots.flatMap((root) => collectSubtree(root));
309 threads.push(createThread(allPosts, rootUri));
310 } else {
311 // Has branches - split into separate threads
312 for (const branchParentUri of branchingPoints) {
313 const branches = childrenMap.get(branchParentUri) || [];
314
315 // Sort branches oldest to newest for processing
316 const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
317
318 sortedBranches.forEach((branchRoot, index) => {
319 const isOldestBranch = index === 0;
320 const branchPosts: ThreadPost[] = [];
321
322 // If oldest branch, include parent chain
323 if (isOldestBranch && branchParentUri !== null) {
324 const parentChain: ThreadPost[] = [];
325 let currentUri: ResourceUri | null = branchParentUri;
326 while (currentUri && uriToPost.has(currentUri)) {
327 parentChain.unshift(uriToPost.get(currentUri)!);
328 currentUri = uriToPost.get(currentUri)!.parentUri;
329 }
330 branchPosts.push(...parentChain);
331 }
332
333 // Add branch posts
334 branchPosts.push(...collectSubtree(branchRoot));
335
336 // Recalculate depths for display
337 const minDepth = Math.min(...branchPosts.map((p) => p.depth));
338 branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
339
340 threads.push(
341 createThread(
342 branchPosts,
343 branchRoot.data.uri,
344 isOldestBranch ? undefined : (branchParentUri ?? undefined)
345 )
346 );
347 });
348 }
349 }
350 }
351
352 // Sort threads by newest time (descending) so older branches appear first
353 threads.sort((a, b) => b.newestTime - a.newestTime);
354
355 // console.log(threads);
356
357 return threads;
358 };
359
360 // Filtering functions
361 const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
362 accounts.some((account) => account.did === post.did);
363 const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
364 posts.some((post) => !isOwnPost(post, accounts));
365 const filterThreads = (threads: Thread[], accounts: Account[]) =>
366 threads.filter((thread) => {
367 if (!viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
368 return true;
369 });
370
371 let threads = $derived(filterThreads(buildThreads(posts), $accounts));
372
373 let quoting = $state<PostWithUri | undefined>(undefined);
374 let replying = $state<PostWithUri | undefined>(undefined);
375
376 let expandedThreads = new SvelteSet<ResourceUri>();
377</script>
378
379<div class="mx-auto flex h-screen max-w-2xl flex-col p-4">
380 <div class="mb-6 flex flex-shrink-0 items-center justify-between">
381 <div>
382 <h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
383 <div class="mt-1 flex gap-2">
384 <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div>
385 <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div>
386 </div>
387 </div>
388 <button
389 onclick={() => (isSettingsOpen = true)}
390 class="group rounded-sm bg-(--nucleus-accent)/7 p-2 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg"
391 aria-label="settings"
392 >
393 <Icon class="group-hover:hidden" icon="heroicons:cog-6-tooth" width={28} />
394 <Icon class="hidden group-hover:block" icon="heroicons:cog-6-tooth-solid" width={28} />
395 </button>
396 </div>
397
398 <div class="flex-shrink-0 space-y-4">
399 <div class="flex min-h-16 items-stretch gap-2">
400 <AccountSelector
401 client={viewClient}
402 accounts={$accounts}
403 bind:selectedDid={$selectedDid}
404 onAccountSelected={handleAccountSelected}
405 onLoginSucceed={handleLoginSucceed}
406 onLogout={handleLogout}
407 />
408
409 {#if selectedClient}
410 <div class="flex-1">
411 <PostComposer
412 client={selectedClient}
413 {selectedDid}
414 onPostSent={(post) => posts.get($selectedDid!)?.set(post.uri, post)}
415 bind:quoting
416 bind:replying
417 />
418 </div>
419 {:else}
420 <div
421 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"
422 >
423 <p class="text-sm opacity-80">select or add an account to post</p>
424 </div>
425 {/if}
426 </div>
427
428 <!-- <hr
429 class="h-[4px] w-full rounded-full border-0"
430 style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
431 /> -->
432 </div>
433
434 <div
435 class="mt-4 overflow-y-scroll [scrollbar-color:var(--nucleus-accent)_transparent]"
436 bind:this={scrollContainer}
437 >
438 {#if $accounts.length > 0}
439 {@render renderThreads()}
440 {:else}
441 <div class="flex justify-center py-4">
442 <p class="text-xl opacity-80">
443 <span class="text-4xl">x_x</span> <br /> no accounts are logged in!
444 </p>
445 </div>
446 {/if}
447 </div>
448</div>
449
450<SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} />
451
452{#snippet renderThreads()}
453 <InfiniteLoader
454 {loaderState}
455 triggerLoad={loadMore}
456 loopDetectionTimeout={0}
457 intersectionOptions={{ root: scrollContainer }}
458 >
459 {@render threadsView()}
460 {#snippet noData()}
461 <div class="flex justify-center py-4">
462 <p class="text-xl opacity-80">
463 all posts seen! <span class="text-2xl">:o</span>
464 </p>
465 </div>
466 {/snippet}
467 {#snippet loading()}
468 <div class="flex justify-center">
469 <div
470 class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent"
471 style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
472 ></div>
473 </div>
474 {/snippet}
475 {#snippet error()}
476 <div class="flex justify-center py-4">
477 <p class="text-xl opacity-80">
478 <span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError}
479 </p>
480 </div>
481 {/snippet}
482 </InfiniteLoader>
483{/snippet}
484
485{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
486 <span
487 class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap break-words overflow-ellipsis"
488 >
489 <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span>
490 <BskyPost mini {selectedDid} client={selectedClient ?? viewClient} {...post} />
491 </span>
492{/snippet}
493
494{#snippet threadsView()}
495 {#each threads as thread (thread.rootUri)}
496 <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}">
497 {#if thread.branchParentPost}
498 {@render replyPost(thread.branchParentPost)}
499 {/if}
500 {#each thread.posts as post, idx (post.data.uri)}
501 {@const mini =
502 !expandedThreads.has(thread.rootUri) &&
503 thread.posts.length > 4 &&
504 idx > 0 &&
505 idx < thread.posts.length - 2}
506 {#if !mini}
507 <div class="mb-1.5">
508 <BskyPost
509 {selectedDid}
510 client={selectedClient ?? viewClient}
511 onQuote={(post) => (quoting = post)}
512 onReply={(post) => (replying = post)}
513 {...post}
514 />
515 </div>
516 {:else if mini}
517 {#if idx === 1}
518 {@render replyPost(post, !reverseChronological)}
519 <button
520 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)"
521 onclick={() => expandedThreads.add(thread.rootUri)}
522 >
523 <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
524 <Icon
525 class="shrink-0"
526 icon={reverseChronological
527 ? 'heroicons:bars-arrow-up-solid'
528 : 'heroicons:bars-arrow-down-solid'}
529 width={32}
530 /><span class="shrink-0 pb-1">view full chain</span>
531 <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div>
532 </button>
533 {:else if idx === thread.posts.length - 3}
534 {@render replyPost(post)}
535 {/if}
536 {/if}
537 {/each}
538 </div>
539 <div
540 class="mx-8 mt-3 mb-4 h-px bg-gradient-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
541 ></div>
542 {/each}
543{/snippet}