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, 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}