replies timeline only, appview-less bluesky client
at main 16 kB view raw
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}