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, ok } 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 { notificationStream } 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
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 });
33
34 const clients = new SvelteMap<AtprotoDid, AtpClient>();
35 const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
36
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 };
43
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 }>();
65
66 let isSettingsOpen = $state(false);
67 let reverseChronological = $state(true);
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>();
76
77 const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => {
78 if (!posts.has(did)) {
79 posts.set(did, new SvelteMap(accTimeline));
80 return;
81 }
82 const map = posts.get(did)!;
83 for (const [uri, record] of accTimeline) map.set(uri, record);
84 };
85
86 const fetchTimeline = async (account: Account) => {
87 const client = clients.get(account.did);
88 if (!client) return;
89
90 const cursor = cursors.get(account.did);
91 if (cursor && cursor.end) return;
92
93 const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6);
94 if (!accPosts.ok)
95 throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`;
96
97 // if the cursor is undefined, we've reached the end of the timeline
98 if (!accPosts.value.cursor) {
99 cursors.set(account.did, { ...cursor, end: true });
100 return;
101 }
102
103 cursors.set(account.did, { value: accPosts.value.cursor, end: false });
104 addPosts(account.did, await hydratePosts(client, accPosts.value.posts));
105 };
106
107 const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
108
109 const handleNotification = async (event: NotificationsStreamEvent) => {
110 if (event.type === 'message') {
111 // console.log(event.data);
112 const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
113 const subjectPost = await viewClient.getRecord(
114 AppBskyFeedPost.mainSchema,
115 parsedSubjectUri.repo,
116 parsedSubjectUri.rkey
117 );
118 if (!subjectPost.ok) return;
119
120 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
121 const hydrated = await hydratePosts(viewClient, [
122 {
123 record: subjectPost.value.record,
124 uri: event.data.link.subject,
125 cid: subjectPost.value.cid,
126 replies: ok({
127 cursor: null,
128 total: 1,
129 records: [
130 {
131 did: parsedSourceUri.repo,
132 collection: parsedSourceUri.collection,
133 rkey: parsedSourceUri.rkey
134 }
135 ]
136 })
137 }
138 ]);
139
140 // console.log(hydrated);
141 addPosts(parsedSubjectUri.repo, hydrated);
142 }
143 };
144
145 // const handleJetstream = async (subscription: JetstreamSubscription) => {
146 // for await (const event of subscription) {
147 // if (event.kind !== 'commit') continue;
148 // const commit = event.commit;
149 // if (commit.operation === 'delete') {
150 // continue;
151 // }
152 // const record = commit.record as AppBskyFeedPost.Main;
153 // addPosts(
154 // event.did,
155 // new Map([[`at://${event.did}/${commit.collection}/${commit.rkey}` as ResourceUri, record]])
156 // );
157 // }
158 // };
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
181 onMount(async () => {
182 accounts.subscribe((newAccounts) => {
183 get(notificationStream)?.stop();
184 // jetstream.set(null);
185 if (newAccounts.length === 0) return;
186 notificationStream.set(
187 viewClient.streamNotifications(
188 newAccounts.map((account) => account.did),
189 'app.bsky.feed.post:reply.parent.uri'
190 )
191 );
192 // jetstream.set(
193 // viewClient.streamJetstream(
194 // newAccounts.map((account) => account.did),
195 // 'app.bsky.feed.post'
196 // )
197 // );
198 });
199 notificationStream.subscribe((stream) => {
200 if (!stream) return;
201 stream.listen(handleNotification);
202 });
203 // jetstream.subscribe((stream) => {
204 // if (!stream) return;
205 // handleJetstream(stream);
206 // });
207 if ($accounts.length > 0) {
208 loaderState.status = 'LOADING';
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);
216 Promise.all($accounts.map(loginAccount)).then(() => {
217 loadMore();
218 });
219 } else {
220 selectedDid = null;
221 }
222 });
223</script>
224
225<div class="mx-auto flex h-screen max-w-2xl flex-col p-4">
226 <div class="mb-6 flex shrink-0 items-center justify-between">
227 <div>
228 <h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
229 <div class="mt-1 flex gap-2">
230 <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div>
231 <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div>
232 </div>
233 </div>
234 <button
235 onclick={() => (isSettingsOpen = true)}
236 class="group rounded-sm bg-(--nucleus-accent)/7 p-2 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg"
237 aria-label="settings"
238 >
239 <Icon class="group-hover:hidden" icon="heroicons:cog-6-tooth" width={28} />
240 <Icon class="hidden group-hover:block" icon="heroicons:cog-6-tooth-solid" width={28} />
241 </button>
242 </div>
243
244 <div class="shrink-0 space-y-4">
245 <div class="flex min-h-16 items-stretch gap-2">
246 <AccountSelector
247 client={viewClient}
248 accounts={$accounts}
249 bind:selectedDid
250 onAccountSelected={handleAccountSelected}
251 onLogout={handleLogout}
252 />
253
254 {#if selectedClient}
255 <div class="flex-1">
256 <PostComposer
257 client={selectedClient}
258 onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)}
259 bind:quoting
260 bind:replying
261 />
262 </div>
263 {:else}
264 <div
265 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"
266 >
267 <p class="text-sm opacity-80">select or add an account to post</p>
268 </div>
269 {/if}
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}
280
281 <!-- <hr
282 class="h-[4px] w-full rounded-full border-0"
283 style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
284 /> -->
285 </div>
286
287 <div
288 class="mt-4 overflow-y-scroll [scrollbar-color:var(--nucleus-accent)_transparent]"
289 bind:this={scrollContainer}
290 >
291 {#if $accounts.length > 0}
292 {@render renderThreads()}
293 {:else}
294 <div class="flex justify-center py-4">
295 <p class="text-xl opacity-80">
296 <span class="text-4xl">x_x</span> <br /> no accounts are logged in!
297 </p>
298 </div>
299 {/if}
300 </div>
301</div>
302
303<SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} />
304
305{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
306 <span
307 class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis"
308 >
309 <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span>
310 <BskyPost mini client={selectedClient ?? viewClient} {...post} />
311 </span>
312{/snippet}
313
314{#snippet threadsView()}
315 {#each threads as thread (thread.rootUri)}
316 <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}">
317 {#if thread.branchParentPost}
318 {@render replyPost(thread.branchParentPost)}
319 {/if}
320 {#each thread.posts as post, idx (post.data.uri)}
321 {@const mini =
322 !expandedThreads.has(thread.rootUri) &&
323 thread.posts.length > 4 &&
324 idx > 0 &&
325 idx < thread.posts.length - 2}
326 {#if !mini}
327 <div class="mb-1.5">
328 <BskyPost
329 client={selectedClient ?? viewClient}
330 onQuote={(post) => (quoting = post)}
331 onReply={(post) => (replying = post)}
332 {...post}
333 />
334 </div>
335 {:else if mini}
336 {#if idx === 1}
337 {@render replyPost(post, !reverseChronological)}
338 <button
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)"
340 onclick={() => expandedThreads.add(thread.rootUri)}
341 >
342 <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
343 <Icon
344 class="shrink-0"
345 icon={reverseChronological
346 ? 'heroicons:bars-arrow-up-solid'
347 : 'heroicons:bars-arrow-down-solid'}
348 width={32}
349 /><span class="shrink-0 pb-1">view full chain</span>
350 <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div>
351 </button>
352 {:else if idx === thread.posts.length - 3}
353 {@render replyPost(post)}
354 {/if}
355 {/if}
356 {/each}
357 </div>
358 <div
359 class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
360 ></div>
361 {/each}
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}