replies timeline only, appview-less bluesky client
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}