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 } from '$lib/at/fetch'; 16 import { expect, ok } from '$lib/result'; 17 import { AppBskyFeedPost } from '@atcute/bluesky'; 18 import { SvelteMap } from 'svelte/reactivity'; 19 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 20 import { notificationStream } from '$lib'; 21 import { get } from 'svelte/store'; 22 23 let loaderState = new LoaderState(); 24 let scrollContainer = $state<HTMLDivElement>(); 25 26 let selectedDid = $state<Did | null>(null); 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, AppBskyFeedPost.Main>>(); 33 let cursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 34 35 let isSettingsOpen = $state(false); 36 37 const addPosts = (did: Did, accTimeline: Map<ResourceUri, AppBskyFeedPost.Main>) => { 38 if (!posts.has(did)) { 39 posts.set(did, new SvelteMap(accTimeline)); 40 return; 41 } 42 const map = posts.get(did)!; 43 for (const [uri, record] of accTimeline) map.set(uri, record); 44 }; 45 46 const fetchTimeline = async (account: Account) => { 47 const client = clients.get(account.did); 48 if (!client) return; 49 50 const cursor = cursors.get(account.did); 51 if (cursor && cursor.end) return; 52 53 const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 12); 54 if (!accPosts.ok) 55 throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`; 56 57 // if the cursor is undefined, we've reached the end of the timeline 58 if (!accPosts.value.cursor) { 59 cursors.set(account.did, { ...cursor, end: true }); 60 return; 61 } 62 63 cursors.set(account.did, { value: accPosts.value.cursor, end: false }); 64 addPosts(account.did, await hydratePosts(client, accPosts.value.posts)); 65 }; 66 67 const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline)); 68 69 const handleNotification = async (event: NotificationsStreamEvent) => { 70 if (event.type === 'message') { 71 // console.log(event.data); 72 const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); 73 const subjectPost = await viewClient.getRecord( 74 AppBskyFeedPost.mainSchema, 75 parsedSubjectUri.repo, 76 parsedSubjectUri.rkey 77 ); 78 if (!subjectPost.ok) return; 79 80 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 81 const hydrated = await hydratePosts(viewClient, [ 82 { 83 record: subjectPost.value, 84 uri: event.data.link.subject, 85 replies: ok({ 86 cursor: null, 87 total: 1, 88 records: [ 89 { 90 did: parsedSourceUri.repo, 91 collection: parsedSourceUri.collection, 92 rkey: parsedSourceUri.rkey 93 } 94 ] 95 }) 96 } 97 ]); 98 99 // console.log(hydrated); 100 addPosts(parsedSubjectUri.repo, hydrated); 101 } 102 }; 103 104 // const handleJetstream = async (subscription: JetstreamSubscription) => { 105 // for await (const event of subscription) { 106 // if (event.kind !== 'commit') continue; 107 // const commit = event.commit; 108 // if (commit.operation === 'delete') { 109 // continue; 110 // } 111 // const record = commit.record as AppBskyFeedPost.Main; 112 // addPosts( 113 // event.did, 114 // new Map([[`at://${event.did}/${commit.collection}/${commit.rkey}` as ResourceUri, record]]) 115 // ); 116 // } 117 // }; 118 119 onMount(async () => { 120 accounts.subscribe((newAccounts) => { 121 get(notificationStream)?.stop(); 122 // jetstream.set(null); 123 if (newAccounts.length === 0) return; 124 notificationStream.set( 125 viewClient.streamNotifications( 126 newAccounts.map((account) => account.did), 127 'app.bsky.feed.post:reply.parent.uri' 128 ) 129 ); 130 // jetstream.set( 131 // viewClient.streamJetstream( 132 // newAccounts.map((account) => account.did), 133 // 'app.bsky.feed.post' 134 // ) 135 // ); 136 }); 137 notificationStream.subscribe((stream) => { 138 if (!stream) return; 139 stream.listen(handleNotification); 140 }); 141 // jetstream.subscribe((stream) => { 142 // if (!stream) return; 143 // handleJetstream(stream); 144 // }); 145 if ($accounts.length > 0) { 146 loaderState.status = 'LOADING'; 147 selectedDid = $accounts[0].did; 148 Promise.all($accounts.map(loginAccount)).then(() => { 149 loadMore(); 150 }); 151 } 152 }); 153 154 const loginAccount = async (account: Account) => { 155 const client = new AtpClient(); 156 const result = await client.login(account.handle, account.password); 157 if (result.ok) clients.set(account.did, client); 158 }; 159 160 const handleAccountSelected = async (did: Did) => { 161 selectedDid = did; 162 const account = $accounts.find((acc) => acc.did === did); 163 if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 164 await loginAccount(account); 165 }; 166 167 const handleLogout = async (did: Did) => { 168 const newAccounts = $accounts.filter((acc) => acc.did !== did); 169 $accounts = newAccounts; 170 clients.delete(did); 171 posts.delete(did); 172 cursors.delete(did); 173 handleAccountSelected(newAccounts[0]?.did); 174 }; 175 176 const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => { 177 const newAccount: Account = { did, handle, password }; 178 addAccount(newAccount); 179 selectedDid = did; 180 loginAccount(newAccount).then(() => fetchTimeline(newAccount)); 181 }; 182 183 let loading = $state(false); 184 let loadError = $state(''); 185 const loadMore = async () => { 186 if (loading || $accounts.length === 0) return; 187 188 loading = true; 189 try { 190 await fetchTimelines($accounts); 191 loaderState.loaded(); 192 } catch (error) { 193 loadError = `${error}`; 194 loaderState.error(); 195 } finally { 196 loading = false; 197 if (cursors.values().every((cursor) => cursor.end)) loaderState.complete(); 198 } 199 }; 200 201 let reverseChronological = $state(true); 202 let viewOwnPosts = $state(true); 203 204 type ThreadPost = { 205 uri: ResourceUri; 206 did: Did; 207 rkey: string; 208 record: AppBskyFeedPost.Main; 209 parentUri: ResourceUri | null; 210 depth: number; 211 newestTime: number; 212 }; 213 214 type Thread = { 215 rootUri: ResourceUri; 216 posts: ThreadPost[]; 217 newestTime: number; 218 branchParentPost?: ThreadPost; 219 }; 220 221 const buildThreads = (timelines: Map<Did, Map<ResourceUri, AppBskyFeedPost.Main>>): Thread[] => { 222 // eslint-disable-next-line svelte/prefer-svelte-reactivity 223 const threadMap = new Map<ResourceUri, ThreadPost[]>(); 224 225 // Single pass: create posts and group by thread 226 for (const [, timeline] of timelines) { 227 for (const [uri, record] of timeline) { 228 const parsedUri = expect(parseCanonicalResourceUri(uri)); 229 const rootUri = (record.reply?.root.uri as ResourceUri) || uri; 230 const parentUri = (record.reply?.parent.uri as ResourceUri) || null; 231 232 const post: ThreadPost = { 233 uri, 234 did: parsedUri.repo, 235 rkey: parsedUri.rkey, 236 record, 237 parentUri, 238 depth: 0, 239 newestTime: new Date(record.createdAt).getTime() 240 }; 241 242 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); 243 244 threadMap.get(rootUri)!.push(post); 245 } 246 } 247 248 const threads: Thread[] = []; 249 250 for (const [rootUri, posts] of threadMap) { 251 const uriToPost = new Map(posts.map((p) => [p.uri, p])); 252 // eslint-disable-next-line svelte/prefer-svelte-reactivity 253 const childrenMap = new Map<ResourceUri | null, ThreadPost[]>(); 254 255 // Calculate depth and group by parent 256 for (const post of posts) { 257 let depth = 0; 258 let currentUri = post.parentUri; 259 260 while (currentUri && uriToPost.has(currentUri)) { 261 depth++; 262 currentUri = uriToPost.get(currentUri)!.parentUri; 263 } 264 265 post.depth = depth; 266 267 if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []); 268 childrenMap.get(post.parentUri)!.push(post); 269 } 270 271 // Sort children by time (newest first) 272 childrenMap 273 .values() 274 .forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime)); 275 276 // Helper to create a thread from posts 277 const createThread = ( 278 posts: ThreadPost[], 279 rootUri: ResourceUri, 280 branchParentUri?: ResourceUri 281 ): Thread => { 282 return { 283 rootUri, 284 posts, 285 newestTime: Math.max(...posts.map((p) => p.newestTime)), 286 branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined 287 }; 288 }; 289 290 // Helper to collect all posts in a subtree 291 const collectSubtree = (startPost: ThreadPost): ThreadPost[] => { 292 const result: ThreadPost[] = []; 293 const addWithChildren = (post: ThreadPost) => { 294 result.push(post); 295 const children = childrenMap.get(post.uri) || []; 296 children.forEach(addWithChildren); 297 }; 298 addWithChildren(startPost); 299 return result; 300 }; 301 302 // Find branching points (posts with 2+ children) 303 const branchingPoints = Array.from(childrenMap.entries()) 304 .filter(([, children]) => children.length > 1) 305 .map(([uri]) => uri); 306 307 if (branchingPoints.length === 0) { 308 // No branches - single thread 309 const roots = childrenMap.get(null) || []; 310 const allPosts = roots.flatMap((root) => collectSubtree(root)); 311 threads.push(createThread(allPosts, rootUri)); 312 } else { 313 // Has branches - split into separate threads 314 for (const branchParentUri of branchingPoints) { 315 const branches = childrenMap.get(branchParentUri) || []; 316 317 // Sort branches oldest to newest for processing 318 const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime); 319 320 sortedBranches.forEach((branchRoot, index) => { 321 const isOldestBranch = index === 0; 322 const branchPosts: ThreadPost[] = []; 323 324 // If oldest branch, include parent chain 325 if (isOldestBranch && branchParentUri !== null) { 326 const parentChain: ThreadPost[] = []; 327 let currentUri: ResourceUri | null = branchParentUri; 328 while (currentUri && uriToPost.has(currentUri)) { 329 parentChain.unshift(uriToPost.get(currentUri)!); 330 currentUri = uriToPost.get(currentUri)!.parentUri; 331 } 332 branchPosts.push(...parentChain); 333 } 334 335 // Add branch posts 336 branchPosts.push(...collectSubtree(branchRoot)); 337 338 // Recalculate depths for display 339 const minDepth = Math.min(...branchPosts.map((p) => p.depth)); 340 branchPosts.forEach((p) => (p.depth = p.depth - minDepth)); 341 342 threads.push( 343 createThread( 344 branchPosts, 345 branchRoot.uri, 346 isOldestBranch ? undefined : (branchParentUri ?? undefined) 347 ) 348 ); 349 }); 350 } 351 } 352 } 353 354 // Sort threads by newest time (descending) so older branches appear first 355 threads.sort((a, b) => b.newestTime - a.newestTime); 356 357 // console.log(threads); 358 359 return threads; 360 }; 361 362 // Filtering functions 363 const isOwnPost = (post: ThreadPost, accounts: Account[]) => 364 accounts.some((account) => account.did === post.did); 365 const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) => 366 posts.some((post) => !isOwnPost(post, accounts)); 367 const filterThreads = (threads: Thread[], accounts: Account[]) => 368 threads.filter((thread) => { 369 if (!viewOwnPosts) return hasNonOwnPost(thread.posts, accounts); 370 return true; 371 }); 372 373 let threads = $derived(filterThreads(buildThreads(posts), $accounts)); 374</script> 375 376<div class="mx-auto flex h-screen max-w-2xl flex-col p-4"> 377 <div class="mb-6 flex flex-shrink-0 items-center justify-between"> 378 <div> 379 <h1 class="text-3xl font-bold tracking-tight">nucleus</h1> 380 <div class="mt-1 flex gap-2"> 381 <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div> 382 <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div> 383 </div> 384 </div> 385 <button 386 onclick={() => (isSettingsOpen = true)} 387 class="rounded-sm bg-(--nucleus-accent)/7 p-2.5 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg" 388 aria-label="Settings" 389 > 390 <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 391 <path 392 stroke-linecap="round" 393 stroke-linejoin="round" 394 stroke-width="2" 395 d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" 396 /> 397 <path 398 stroke-linecap="round" 399 stroke-linejoin="round" 400 stroke-width="2" 401 d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" 402 /> 403 </svg> 404 </button> 405 </div> 406 407 <div class="flex-shrink-0 space-y-4"> 408 <div class="flex min-h-16 items-stretch gap-2"> 409 <AccountSelector 410 client={viewClient} 411 accounts={$accounts} 412 bind:selectedDid 413 onAccountSelected={handleAccountSelected} 414 onLoginSucceed={handleLoginSucceed} 415 onLogout={handleLogout} 416 /> 417 418 {#if selectedClient} 419 <div class="flex-1"> 420 <PostComposer 421 client={selectedClient} 422 onPostSent={(uri, record) => posts.get(selectedDid!)?.set(uri, record)} 423 /> 424 </div> 425 {:else} 426 <div 427 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" 428 > 429 <p class="text-sm opacity-80">select or add an account to post</p> 430 </div> 431 {/if} 432 </div> 433 434 <hr 435 class="h-[4px] w-full rounded-full border-0" 436 style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));" 437 /> 438 </div> 439 440 <div 441 class="mt-4 overflow-y-scroll [scrollbar-color:var(--nucleus-accent)_transparent]" 442 bind:this={scrollContainer} 443 > 444 {#if $accounts.length > 0} 445 {@render renderThreads()} 446 {:else} 447 <div class="flex justify-center py-4"> 448 <p class="text-xl opacity-80"> 449 <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 450 </p> 451 </div> 452 {/if} 453 </div> 454</div> 455 456<SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} /> 457 458{#snippet renderThreads()} 459 <InfiniteLoader 460 {loaderState} 461 triggerLoad={loadMore} 462 loopDetectionTimeout={0} 463 intersectionOptions={{ root: scrollContainer }} 464 > 465 {@render threadsView()} 466 {#snippet noData()} 467 <div class="flex justify-center py-4"> 468 <p class="text-xl opacity-80"> 469 all posts seen! <span class="text-2xl">:o</span> 470 </p> 471 </div> 472 {/snippet} 473 {#snippet loading()} 474 <div class="flex justify-center"> 475 <div 476 class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 477 style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 478 ></div> 479 </div> 480 {/snippet} 481 {#snippet error()} 482 <div class="flex justify-center py-4"> 483 <p class="text-xl opacity-80"> 484 <span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError} 485 </p> 486 </div> 487 {/snippet} 488 </InfiniteLoader> 489{/snippet} 490 491{#snippet threadsView()} 492 {#each threads as thread ([thread.rootUri, thread.branchParentPost, ...thread.posts.map((post) => post.uri)])} 493 <div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5"> 494 {#if thread.branchParentPost} 495 {@const post = thread.branchParentPost} 496 <div class="mb-1.5 flex items-center gap-1.5"> 497 <span class="text-sm text-nowrap opacity-60">{reverseChronological ? '↱' : '↳'}</span> 498 <BskyPost mini client={viewClient} {...post} /> 499 </div> 500 {/if} 501 {#each thread.posts as post (post.uri)} 502 <div class="mb-1.5"> 503 <BskyPost client={viewClient} {...post} /> 504 </div> 505 {/each} 506 </div> 507 {/each} 508{/snippet}