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, type Account } from '$lib/accounts';
8 import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons';
9 import { onMount } from 'svelte';
10 import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch';
11 import { expect } from '$lib/result';
12 import { AppBskyFeedPost } from '@atcute/bluesky';
13 import { SvelteMap, SvelteSet } from 'svelte/reactivity';
14 import { InfiniteLoader, LoaderState } from 'svelte-infinite';
15 import { clients, cursors, notificationStream, posts, viewClient } from '$lib/state.svelte';
16 import { get } from 'svelte/store';
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';
22 import NotificationsPopup from '$components/NotificationsPopup.svelte';
23
24 const { data: loadData }: PageProps = $props();
25
26 let errors = $state(loadData.client.ok ? [] : [loadData.client.error]);
27 let errorsOpen = $state(false);
28
29 let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null);
30 $effect(() => {
31 if (selectedDid) {
32 localStorage.setItem('selectedDid', selectedDid);
33 } else {
34 localStorage.removeItem('selectedDid');
35 }
36 });
37
38 const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
39
40 const loginAccount = async (account: Account) => {
41 if (clients.has(account.did)) return;
42 const client = new AtpClient();
43 const result = await client.login(await sessions.get(account.did));
44 if (!result.ok) {
45 errors.push(`failed to login into @${account.handle ?? account.did}: ${result.error}`);
46 return;
47 }
48 clients.set(account.did, client);
49 };
50
51 const handleAccountSelected = async (did: AtprotoDid) => {
52 selectedDid = did;
53 const account = $accounts.find((acc) => acc.did === did);
54 if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
55 await loginAccount(account);
56 };
57
58 const handleLogout = async (did: AtprotoDid) => {
59 await sessions.remove(did);
60 const newAccounts = $accounts.filter((acc) => acc.did !== did);
61 $accounts = newAccounts;
62 clients.delete(did);
63 posts.delete(did);
64 cursors.delete(did);
65 handleAccountSelected(newAccounts[0]?.did);
66 };
67
68 let isSettingsOpen = $state(false);
69 let isNotificationsOpen = $state(false);
70 let reverseChronological = $state(true);
71 let viewOwnPosts = $state(true);
72
73 const threads = $derived(filterThreads(buildThreads(posts), $accounts, { viewOwnPosts }));
74
75 let quoting = $state<PostWithUri | undefined>(undefined);
76 let replying = $state<PostWithUri | undefined>(undefined);
77
78 const expandedThreads = new SvelteSet<ResourceUri>();
79
80 const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => {
81 if (!posts.has(did)) {
82 posts.set(did, new SvelteMap(accTimeline));
83 return;
84 }
85 const map = posts.get(did)!;
86 for (const [uri, record] of accTimeline) map.set(uri, record);
87 };
88
89 const fetchTimeline = async (account: Account) => {
90 const client = clients.get(account.did);
91 if (!client) return;
92
93 const cursor = cursors.get(account.did);
94 if (cursor && cursor.end) return;
95
96 const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6);
97 if (!accPosts.ok) throw `cant fetch posts @${account.handle}: ${accPosts.error}`;
98
99 // if the cursor is undefined, we've reached the end of the timeline
100 if (!accPosts.value.cursor) {
101 cursors.set(account.did, { ...cursor, end: true });
102 return;
103 }
104
105 cursors.set(account.did, { value: accPosts.value.cursor, end: false });
106 const hydrated = await hydratePosts(client, account.did, accPosts.value.posts);
107 if (!hydrated.ok) throw `cant hydrate posts @${account.handle}: ${hydrated.error}`;
108
109 addPosts(account.did, hydrated.value);
110 };
111
112 const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
113
114 const handleNotification = async (event: NotificationsStreamEvent) => {
115 if (event.type === 'message') {
116 // console.log(event.data);
117 const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
118 const subjectPost = await viewClient.getRecord(
119 AppBskyFeedPost.mainSchema,
120 parsedSubjectUri.repo,
121 parsedSubjectUri.rkey
122 );
123 if (!subjectPost.ok) return;
124
125 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
126 const hydrated = await hydratePosts(viewClient, parsedSubjectUri.repo as AtprotoDid, [
127 {
128 record: subjectPost.value.record,
129 uri: event.data.link.subject,
130 cid: subjectPost.value.cid,
131 replies: {
132 cursor: null,
133 total: 1,
134 records: [
135 {
136 did: parsedSourceUri.repo,
137 collection: parsedSourceUri.collection,
138 rkey: parsedSourceUri.rkey
139 }
140 ]
141 }
142 }
143 ]);
144
145 if (!hydrated.ok) {
146 errors.push(`cant hydrate posts @${parsedSubjectUri.repo}: ${hydrated.error}`);
147 return;
148 }
149
150 // console.log(hydrated);
151 addPosts(parsedSubjectUri.repo, hydrated.value);
152 }
153 };
154
155 // const handleJetstream = async (subscription: JetstreamSubscription) => {
156 // for await (const event of subscription) {
157 // if (event.kind !== 'commit') continue;
158 // const commit = event.commit;
159 // if (commit.operation === 'delete') {
160 // continue;
161 // }
162 // const record = commit.record as AppBskyFeedPost.Main;
163 // addPosts(
164 // event.did,
165 // new Map([[`at://${event.did}/${commit.collection}/${commit.rkey}` as ResourceUri, record]])
166 // );
167 // }
168 // };
169
170 const loaderState = new LoaderState();
171 let scrollContainer = $state<HTMLDivElement>();
172
173 let loading = $state(false);
174 let loadError = $state('');
175 let showScrollToTop = $state(false);
176
177 const handleScroll = () => {
178 showScrollToTop = window.scrollY > 300;
179 };
180
181 const scrollToTop = () => {
182 window.scrollTo({ top: 0, behavior: 'smooth' });
183 };
184
185 const loadMore = async () => {
186 if (loading || $accounts.length === 0) return;
187
188 loading = true;
189 loaderState.status = 'LOADING';
190
191 try {
192 await fetchTimelines($accounts);
193 loaderState.loaded();
194 } catch (error) {
195 loadError = `${error}`;
196 loaderState.error();
197 loading = false;
198 return;
199 }
200
201 loading = false;
202 if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
203 };
204
205 onMount(() => {
206 window.addEventListener('scroll', handleScroll);
207
208 accounts.subscribe((newAccounts) => {
209 get(notificationStream)?.stop();
210 // jetstream.set(null);
211 if (newAccounts.length === 0) return;
212 notificationStream.set(
213 viewClient.streamNotifications(
214 newAccounts.map((account) => account.did),
215 'app.bsky.feed.post:reply.parent.uri'
216 )
217 );
218 // jetstream.set(
219 // viewClient.streamJetstream(
220 // newAccounts.map((account) => account.did),
221 // 'app.bsky.feed.post'
222 // )
223 // );
224 });
225 notificationStream.subscribe((stream) => {
226 if (!stream) return;
227 stream.listen(handleNotification);
228 });
229 // jetstream.subscribe((stream) => {
230 // if (!stream) return;
231 // handleJetstream(stream);
232 // });
233 if ($accounts.length > 0) {
234 loaderState.status = 'LOADING';
235 if (loadData.client.ok && loadData.client.value) {
236 const loggedInDid = loadData.client.value.user!.did as AtprotoDid;
237 selectedDid = loggedInDid;
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 loadMore();
244 });
245 } else {
246 selectedDid = null;
247 }
248
249 return () => {
250 window.removeEventListener('scroll', handleScroll);
251 };
252 });
253</script>
254
255{#snippet appButton(onClick: () => void, icon: string, ariaLabel: string, iconHover?: string)}
256 <button
257 onclick={onClick}
258 class="group rounded-sm bg-(--nucleus-accent)/15 p-2 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg"
259 aria-label={ariaLabel}
260 >
261 <Icon class="group-hover:hidden" {icon} width={28} />
262 <Icon class="hidden group-hover:block" icon={iconHover ?? icon} width={28} />
263 </button>
264{/snippet}
265
266<div class="mx-auto max-w-2xl">
267 <!-- thread list (page scrolls as a whole) -->
268 <div
269 id="app-thread-list"
270 class="mb-4 min-h-screen p-2 [scrollbar-color:var(--nucleus-accent)_transparent]"
271 bind:this={scrollContainer}
272 >
273 {#if $accounts.length > 0}
274 {@render renderThreads()}
275 {:else}
276 <div class="flex justify-center py-4">
277 <p class="text-xl opacity-80">
278 <span class="text-4xl">x_x</span> <br /> no accounts are logged in!
279 </p>
280 </div>
281 {/if}
282 </div>
283
284 <!-- header -->
285 <div class="sticky bottom-0 z-10">
286 {#if errors.length > 0}
287 <div class="relative m-3 mb-1 error-disclaimer">
288 <div class="flex items-center gap-2 text-red-500">
289 <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
290 there are ({errors.length}) errors
291 <div class="grow"></div>
292 <button onclick={() => (errorsOpen = !errorsOpen)} class="action-button p-1 px-1.5"
293 >{errorsOpen ? 'hide details' : 'see details'}</button
294 >
295 </div>
296 {#if errorsOpen}
297 <div
298 class="absolute right-0 bottom-full left-0 z-10 mb-2 flex animate-fade-in-scale-fast flex-col gap-1 error-disclaimer shadow-lg transition-all"
299 >
300 {#each errors as error, idx (idx)}
301 <p>• {error}</p>
302 {/each}
303 </div>
304 {/if}
305 </div>
306 {/if}
307
308 <div
309 class="rounded-t-sm px-0.5 pt-0.5"
310 style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
311 >
312 <div
313 class="rounded-t-sm"
314 style="
315 background: linear-gradient(to right, color-mix(in srgb, var(--nucleus-accent) 18%, var(--nucleus-bg)), color-mix(in srgb, var(--nucleus-accent2) 13%, var(--nucleus-bg)));
316 "
317 >
318 <!-- composer and error disclaimer (above thread list, not scrollable) -->
319 <div class="flex gap-2 px-2 pt-2 pb-1">
320 <AccountSelector
321 client={viewClient}
322 accounts={$accounts}
323 bind:selectedDid
324 onAccountSelected={handleAccountSelected}
325 onLogout={handleLogout}
326 />
327
328 {#if selectedClient}
329 <div class="flex-1">
330 <PostComposer
331 client={selectedClient}
332 onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)}
333 bind:quoting
334 bind:replying
335 />
336 </div>
337 {:else}
338 <div
339 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"
340 >
341 <p class="text-sm opacity-80">select or add an account to post</p>
342 </div>
343 {/if}
344
345 {#if showScrollToTop}
346 {@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top')}
347 {/if}
348 </div>
349
350 <div
351 class="mt-1 h-px w-full opacity-50"
352 style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
353 ></div>
354
355 <div class="flex items-center gap-1.5 px-2 py-1">
356 <div class="mb-2">
357 <h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
358 <div class="mt-1 flex gap-2">
359 <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div>
360 <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div>
361 </div>
362 </div>
363 <div class="grow"></div>
364 {@render appButton(
365 () => (isNotificationsOpen = true),
366 'heroicons:bell',
367 'notifications',
368 'heroicons:bell-solid'
369 )}
370 {@render appButton(
371 () => (isSettingsOpen = true),
372 'heroicons:cog-6-tooth',
373 'settings',
374 'heroicons:cog-6-tooth-solid'
375 )}
376 </div>
377
378 <!-- <hr
379 class="h-[4px] w-full rounded-full border-0"
380 style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
381 /> -->
382 </div>
383 </div>
384 </div>
385</div>
386
387<SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} />
388<NotificationsPopup
389 bind:isOpen={isNotificationsOpen}
390 onClose={() => (isNotificationsOpen = false)}
391/>
392
393{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
394 <span
395 class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis"
396 >
397 <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span>
398 <BskyPost mini client={selectedClient ?? viewClient} {...post} />
399 </span>
400{/snippet}
401
402{#snippet threadsView()}
403 {#each threads as thread (thread.rootUri)}
404 <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}">
405 {#if thread.branchParentPost}
406 {@render replyPost(thread.branchParentPost)}
407 {/if}
408 {#each thread.posts as post, idx (post.data.uri)}
409 {@const mini =
410 !expandedThreads.has(thread.rootUri) &&
411 thread.posts.length > 4 &&
412 idx > 0 &&
413 idx < thread.posts.length - 2}
414 {#if !mini}
415 <div class="mb-1.5">
416 <BskyPost
417 client={selectedClient ?? viewClient}
418 onQuote={(post) => (quoting = post)}
419 onReply={(post) => (replying = post)}
420 {...post}
421 />
422 </div>
423 {:else if mini}
424 {#if idx === 1}
425 {@render replyPost(post, !reverseChronological)}
426 <button
427 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)"
428 onclick={() => expandedThreads.add(thread.rootUri)}
429 >
430 <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
431 <Icon
432 class="shrink-0"
433 icon={reverseChronological
434 ? 'heroicons:bars-arrow-up-solid'
435 : 'heroicons:bars-arrow-down-solid'}
436 width={32}
437 /><span class="shrink-0 pb-1">view full chain</span>
438 <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div>
439 </button>
440 {:else if idx === thread.posts.length - 3}
441 {@render replyPost(post)}
442 {/if}
443 {/if}
444 {/each}
445 </div>
446 <div
447 class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
448 ></div>
449 {/each}
450{/snippet}
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 flex-col gap-4 py-4">
477 <p class="text-xl opacity-80">
478 <span class="text-4xl">x_x</span> <br />
479 {loadError}
480 </p>
481 <div>
482 <button class="flex action-button items-center gap-2" onclick={loadMore}>
483 <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again
484 </button>
485 </div>
486 </div>
487 {/snippet}
488 </InfiniteLoader>
489{/snippet}