appview-less bluesky client
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

split timeline code into a separate view component

dawn be282819 2ad7bd0b

+236 -221
+174
src/components/TimelineView.svelte
··· 1 + <script lang="ts"> 2 + import BskyPost from './BskyPost.svelte'; 3 + import { type State as PostComposerState } from './PostComposer.svelte'; 4 + import { AtpClient } from '$lib/at/client'; 5 + import { accounts, type Account } from '$lib/accounts'; 6 + import { type ResourceUri } from '@atcute/lexicons'; 7 + import { SvelteSet } from 'svelte/reactivity'; 8 + import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 9 + import { cursors, fetchTimeline, posts, viewClient } from '$lib/state.svelte'; 10 + import Icon from '@iconify/svelte'; 11 + import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 12 + 13 + interface Props { 14 + client?: AtpClient | null; 15 + postComposerState: PostComposerState; 16 + class?: string; 17 + } 18 + 19 + let { client = null, postComposerState = $bindable(), class: className = '' }: Props = $props(); 20 + 21 + const effectiveClient = $derived(client ?? viewClient); 22 + 23 + let reverseChronological = $state(true); 24 + let viewOwnPosts = $state(true); 25 + const expandedThreads = new SvelteSet<ResourceUri>(); 26 + 27 + const threads = $derived( 28 + filterThreads( 29 + buildThreads( 30 + $accounts.map((account) => account.did), 31 + posts 32 + ), 33 + $accounts, 34 + { viewOwnPosts } 35 + ) 36 + ); 37 + 38 + const loaderState = new LoaderState(); 39 + let scrollContainer = $state<HTMLDivElement>(); 40 + let loading = $state(false); 41 + let loadError = $state(''); 42 + 43 + const fetchTimelines = (newAccounts: Account[]) => 44 + Promise.all(newAccounts.map((acc) => fetchTimeline(acc.did))); 45 + 46 + const loadMore = async () => { 47 + if (loading || $accounts.length === 0) return; 48 + 49 + loading = true; 50 + loaderState.status = 'LOADING'; 51 + 52 + try { 53 + await fetchTimelines($accounts); 54 + loaderState.loaded(); 55 + } catch (error) { 56 + loadError = `${error}`; 57 + loaderState.error(); 58 + loading = false; 59 + return; 60 + } 61 + 62 + loading = false; 63 + if (cursors.values().every((cursor) => cursor.end)) loaderState.complete(); 64 + }; 65 + </script> 66 + 67 + {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 68 + <span 69 + class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 70 + > 71 + <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 72 + <BskyPost mini client={effectiveClient} {...post} /> 73 + </span> 74 + {/snippet} 75 + 76 + {#snippet threadsView()} 77 + {#each threads as thread (thread.rootUri)} 78 + <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 79 + {#if thread.branchParentPost} 80 + {@render replyPost(thread.branchParentPost)} 81 + {/if} 82 + {#each thread.posts as post, idx (post.data.uri)} 83 + {@const mini = 84 + !expandedThreads.has(thread.rootUri) && 85 + thread.posts.length > 4 && 86 + idx > 0 && 87 + idx < thread.posts.length - 2} 88 + {#if !mini} 89 + <div class="mb-1.5"> 90 + <BskyPost 91 + client={effectiveClient} 92 + onQuote={(post) => (postComposerState = { type: 'focused', quoting: post })} 93 + onReply={(post) => (postComposerState = { type: 'focused', replying: post })} 94 + {...post} 95 + /> 96 + </div> 97 + {:else if mini} 98 + {#if idx === 1} 99 + {@render replyPost(post, !reverseChronological)} 100 + <button 101 + 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)" 102 + onclick={() => expandedThreads.add(thread.rootUri)} 103 + > 104 + <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 105 + <Icon 106 + class="shrink-0" 107 + icon={reverseChronological 108 + ? 'heroicons:bars-arrow-up-solid' 109 + : 'heroicons:bars-arrow-down-solid'} 110 + width={32} 111 + /><span class="shrink-0 pb-1">view full chain</span> 112 + <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 113 + </button> 114 + {:else if idx === thread.posts.length - 3} 115 + {@render replyPost(post)} 116 + {/if} 117 + {/if} 118 + {/each} 119 + </div> 120 + <div 121 + class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 122 + ></div> 123 + {/each} 124 + {/snippet} 125 + 126 + <div 127 + class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 128 + bind:this={scrollContainer} 129 + > 130 + {#if $accounts.length > 0} 131 + <InfiniteLoader 132 + {loaderState} 133 + triggerLoad={loadMore} 134 + loopDetectionTimeout={0} 135 + intersectionOptions={{ root: scrollContainer }} 136 + > 137 + {@render threadsView()} 138 + {#snippet noData()} 139 + <div class="flex justify-center py-4"> 140 + <p class="text-xl opacity-80"> 141 + all posts seen! <span class="text-2xl">:o</span> 142 + </p> 143 + </div> 144 + {/snippet} 145 + {#snippet loading()} 146 + <div class="flex justify-center"> 147 + <div 148 + class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 149 + style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 150 + ></div> 151 + </div> 152 + {/snippet} 153 + {#snippet error()} 154 + <div class="flex flex-col gap-4 py-4"> 155 + <p class="text-xl opacity-80"> 156 + <span class="text-4xl">x_x</span> <br /> 157 + {loadError} 158 + </p> 159 + <div> 160 + <button class="flex action-button items-center gap-2" onclick={loadMore}> 161 + <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again 162 + </button> 163 + </div> 164 + </div> 165 + {/snippet} 166 + </InfiniteLoader> 167 + {:else} 168 + <div class="flex justify-center py-4"> 169 + <p class="text-xl opacity-80"> 170 + <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 171 + </p> 172 + </div> 173 + {/if} 174 + </div>
+50 -4
src/lib/state.svelte.ts
··· 1 1 import { writable } from 'svelte/store'; 2 - import { AtpClient, newPublicClient, type NotificationsStream } from './at/client'; 2 + import { 3 + AtpClient, 4 + newPublicClient, 5 + type NotificationsStream, 6 + type NotificationsStreamEvent 7 + } from './at/client'; 3 8 import { SvelteMap, SvelteDate } from 'svelte/reactivity'; 4 9 import type { Did, InferOutput, ResourceUri } from '@atcute/lexicons'; 5 10 import type { Backlink } from './at/constellation'; 6 11 import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch'; 7 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 12 + import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 8 13 import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 9 14 import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 10 15 import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 16 + import { expect } from './result'; 11 17 12 18 export const notificationStream = writable<NotificationsStream | null>(null); 13 19 export const jetstream = writable<JetstreamSubscription | null>(null); ··· 134 140 } 135 141 }; 136 142 143 + export const handleNotification = async (event: NotificationsStreamEvent) => { 144 + if (event.type === 'message') { 145 + const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); 146 + const did = parsedSubjectUri.repo as AtprotoDid; 147 + const client = await getClient(did); 148 + const subjectPost = await client.getRecord( 149 + AppBskyFeedPost.mainSchema, 150 + did, 151 + parsedSubjectUri.rkey 152 + ); 153 + if (!subjectPost.ok) return; 154 + 155 + const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 156 + const hydrated = await hydratePosts(client, did, [ 157 + { 158 + record: subjectPost.value.record, 159 + uri: event.data.link.subject, 160 + cid: subjectPost.value.cid, 161 + replies: { 162 + cursor: null, 163 + total: 1, 164 + records: [ 165 + { 166 + did: parsedSourceUri.repo, 167 + collection: parsedSourceUri.collection, 168 + rkey: parsedSourceUri.rkey 169 + } 170 + ] 171 + } 172 + } 173 + ]); 174 + if (!hydrated.ok) { 175 + console.error(`cant hydrate posts ${did}: ${hydrated.error}`); 176 + return; 177 + } 178 + 179 + // console.log(hydrated); 180 + addPosts(did, hydrated.value); 181 + } 182 + }; 183 + 137 184 export const currentTime = new SvelteDate(); 138 185 139 - if (typeof window !== 'undefined') { 186 + if (typeof window !== 'undefined') 140 187 setInterval(() => { 141 188 currentTime.setTime(Date.now()); 142 189 }, 1000); 143 - }
+12 -217
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import BskyPost from '$components/BskyPost.svelte'; 3 2 import PostComposer, { type State as PostComposerState } from '$components/PostComposer.svelte'; 4 3 import AccountSelector from '$components/AccountSelector.svelte'; 5 4 import SettingsView from '$components/SettingsView.svelte'; 6 5 import NotificationsView from '$components/NotificationsView.svelte'; 7 6 import FollowingView from '$components/FollowingView.svelte'; 8 - import { AtpClient, streamNotifications, type NotificationsStreamEvent } from '$lib/at/client'; 7 + import TimelineView from '$components/TimelineView.svelte'; 8 + import { AtpClient, streamNotifications } from '$lib/at/client'; 9 9 import { accounts, type Account } from '$lib/accounts'; 10 - import { parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons'; 11 10 import { onMount, tick } from 'svelte'; 12 - import { hydratePosts } from '$lib/at/fetch'; 13 - import { expect } from '$lib/result'; 14 - import { AppBskyFeedPost } from '@atcute/bluesky'; 15 - import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 16 - import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 11 + import { SvelteMap } from 'svelte/reactivity'; 17 12 import { 18 - addPosts, 19 13 clients, 20 14 cursors, 21 15 fetchFollowPosts, 22 16 fetchFollows, 23 - fetchTimeline, 24 17 follows, 25 - getClient, 26 18 notificationStream, 27 19 posts, 28 20 viewClient, 29 21 jetstream, 30 - handleJetstreamEvent 22 + handleJetstreamEvent, 23 + handleNotification 31 24 } from '$lib/state.svelte'; 32 25 import { get } from 'svelte/store'; 33 26 import Icon from '@iconify/svelte'; 34 27 import { sessions } from '$lib/at/oauth'; 35 28 import type { AtprotoDid, Did } from '@atcute/lexicons/syntax'; 36 29 import type { PageProps } from './+page'; 37 - import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 38 30 import { JetstreamSubscription } from '@atcute/jetstream'; 39 31 import { settings } from '$lib/settings'; 40 32 ··· 43 35 // svelte-ignore state_referenced_locally 44 36 let errors = $state(loadData.client.ok ? [] : [loadData.client.error]); 45 37 let errorsOpen = $state(false); 38 + 46 39 let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null); 47 40 $effect(() => { 48 41 if (selectedDid) localStorage.setItem('selectedDid', selectedDid); ··· 66 59 if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 67 60 await loginAccount(account); 68 61 }; 69 - 70 62 const handleLogout = async (did: AtprotoDid) => { 71 63 await sessions.remove(did); 72 64 const newAccounts = $accounts.filter((acc) => acc.did !== did); ··· 101 93 102 94 window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' }); 103 95 }; 104 - let reverseChronological = $state(true); 105 - let viewOwnPosts = $state(true); 106 96 107 - const threads = $derived( 108 - filterThreads( 109 - buildThreads( 110 - $accounts.map((account) => account.did), 111 - posts 112 - ), 113 - $accounts, 114 - { viewOwnPosts } 115 - ) 116 - ); 117 97 let postComposerState = $state<PostComposerState>({ type: 'null' }); 118 - 119 - const expandedThreads = new SvelteSet<ResourceUri>(); 120 - 121 - const fetchTimelines = (newAccounts: Account[]) => 122 - Promise.all(newAccounts.map((acc) => fetchTimeline(acc.did))); 123 - 124 - const handleNotification = async (event: NotificationsStreamEvent) => { 125 - if (event.type === 'message') { 126 - const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); 127 - const did = parsedSubjectUri.repo as AtprotoDid; 128 - const client = await getClient(did); 129 - const subjectPost = await client.getRecord( 130 - AppBskyFeedPost.mainSchema, 131 - did, 132 - parsedSubjectUri.rkey 133 - ); 134 - if (!subjectPost.ok) return; 135 - 136 - const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 137 - const hydrated = await hydratePosts(client, did, [ 138 - { 139 - record: subjectPost.value.record, 140 - uri: event.data.link.subject, 141 - cid: subjectPost.value.cid, 142 - replies: { 143 - cursor: null, 144 - total: 1, 145 - records: [ 146 - { 147 - did: parsedSourceUri.repo, 148 - collection: parsedSourceUri.collection, 149 - rkey: parsedSourceUri.rkey 150 - } 151 - ] 152 - } 153 - } 154 - ]); 155 - if (!hydrated.ok) { 156 - errors.push(`cant hydrate posts ${did}: ${hydrated.error}`); 157 - return; 158 - } 159 - 160 - // console.log(hydrated); 161 - addPosts(did, hydrated.value); 162 - } 163 - }; 164 - 165 - const loaderState = new LoaderState(); 166 - let scrollContainer = $state<HTMLDivElement>(); 167 - 168 - let loading = $state(false); 169 - let loadError = $state(''); 170 98 let showScrollToTop = $state(false); 171 - 172 99 const handleScroll = () => { 173 100 if (currentView === 'timeline') showScrollToTop = window.scrollY > 300; 174 101 }; ··· 176 103 window.scrollTo({ top: 0, behavior: 'smooth' }); 177 104 }; 178 105 179 - const loadMore = async () => { 180 - if (loading || $accounts.length === 0) return; 181 - 182 - loading = true; 183 - loaderState.status = 'LOADING'; 184 - 185 - try { 186 - await fetchTimelines($accounts); 187 - loaderState.loaded(); 188 - } catch (error) { 189 - loadError = `${error}`; 190 - loaderState.error(); 191 - loading = false; 192 - return; 193 - } 194 - 195 - loading = false; 196 - if (cursors.values().every((cursor) => cursor.end)) loaderState.complete(); 197 - }; 198 - 199 106 onMount(() => { 200 107 window.addEventListener('scroll', handleScroll); 201 108 ··· 231 138 })(); 232 139 233 140 if ($accounts.length > 0) { 234 - loaderState.status = 'LOADING'; 235 141 if (loadData.client.ok && loadData.client.value) { 236 142 const loggedInDid = loadData.client.value.user!.did as AtprotoDid; 237 143 selectedDid = loggedInDid; ··· 247 153 ?.forEach((follow) => fetchFollowPosts(follow.subject as AtprotoDid)) 248 154 ) 249 155 ); 250 - loadMore(); 251 156 }); 252 157 } else { 253 158 selectedDid = null; ··· 291 196 <div class="mx-auto flex min-h-dvh max-w-2xl flex-col"> 292 197 <div class="flex-1"> 293 198 <!-- timeline --> 294 - <div 295 - id="app-thread-list" 296 - class=" 297 - min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] 298 - {currentView === 'timeline' ? `${animClass}` : 'hidden'} 299 - " 300 - bind:this={scrollContainer} 301 - > 302 - {#if $accounts.length > 0} 303 - {@render renderThreads()} 304 - {:else} 305 - <div class="flex justify-center py-4"> 306 - <p class="text-xl opacity-80"> 307 - <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 308 - </p> 309 - </div> 310 - {/if} 311 - </div> 199 + <TimelineView 200 + class={currentView === 'timeline' ? `${animClass}` : 'hidden'} 201 + client={selectedClient ?? viewClient} 202 + bind:postComposerState 203 + /> 204 + 312 205 {#if currentView === 'settings'} 313 206 <div class={animClass}> 314 207 <SettingsView /> ··· 436 329 </div> 437 330 </div> 438 331 </div> 439 - 440 - {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 441 - <span 442 - class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 443 - > 444 - <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 445 - <BskyPost mini client={selectedClient ?? viewClient} {...post} /> 446 - </span> 447 - {/snippet} 448 - 449 - {#snippet threadsView()} 450 - {#each threads as thread (thread.rootUri)} 451 - <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 452 - {#if thread.branchParentPost} 453 - {@render replyPost(thread.branchParentPost)} 454 - {/if} 455 - {#each thread.posts as post, idx (post.data.uri)} 456 - {@const mini = 457 - !expandedThreads.has(thread.rootUri) && 458 - thread.posts.length > 4 && 459 - idx > 0 && 460 - idx < thread.posts.length - 2} 461 - {#if !mini} 462 - <div class="mb-1.5"> 463 - <BskyPost 464 - client={selectedClient ?? viewClient} 465 - onQuote={(post) => (postComposerState = { type: 'focused', quoting: post })} 466 - onReply={(post) => (postComposerState = { type: 'focused', replying: post })} 467 - {...post} 468 - /> 469 - </div> 470 - {:else if mini} 471 - {#if idx === 1} 472 - {@render replyPost(post, !reverseChronological)} 473 - <button 474 - 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)" 475 - onclick={() => expandedThreads.add(thread.rootUri)} 476 - > 477 - <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 478 - <Icon 479 - class="shrink-0" 480 - icon={reverseChronological 481 - ? 'heroicons:bars-arrow-up-solid' 482 - : 'heroicons:bars-arrow-down-solid'} 483 - width={32} 484 - /><span class="shrink-0 pb-1">view full chain</span> 485 - <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 486 - </button> 487 - {:else if idx === thread.posts.length - 3} 488 - {@render replyPost(post)} 489 - {/if} 490 - {/if} 491 - {/each} 492 - </div> 493 - <div 494 - class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 495 - ></div> 496 - {/each} 497 - {/snippet} 498 - 499 - {#snippet renderThreads()} 500 - <InfiniteLoader 501 - {loaderState} 502 - triggerLoad={loadMore} 503 - loopDetectionTimeout={0} 504 - intersectionOptions={{ root: scrollContainer }} 505 - > 506 - {@render threadsView()} 507 - {#snippet noData()} 508 - <div class="flex justify-center py-4"> 509 - <p class="text-xl opacity-80"> 510 - all posts seen! <span class="text-2xl">:o</span> 511 - </p> 512 - </div> 513 - {/snippet} 514 - {#snippet loading()} 515 - <div class="flex justify-center"> 516 - <div 517 - class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 518 - style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 519 - ></div> 520 - </div> 521 - {/snippet} 522 - {#snippet error()} 523 - <div class="flex flex-col gap-4 py-4"> 524 - <p class="text-xl opacity-80"> 525 - <span class="text-4xl">x_x</span> <br /> 526 - {loadError} 527 - </p> 528 - <div> 529 - <button class="flex action-button items-center gap-2" onclick={loadMore}> 530 - <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again 531 - </button> 532 - </div> 533 - </div> 534 - {/snippet} 535 - </InfiniteLoader> 536 - {/snippet} 537 332 538 333 <style> 539 334 .footer-bg {