appview-less bluesky client
24
fork

Configure Feed

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

at main 304 lines 9.0 kB view raw
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.svelte'; 5 import { type ResourceUri } from '@atcute/lexicons'; 6 import { SvelteSet } from 'svelte/reactivity'; 7 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 8 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 9 import Icon from '@iconify/svelte'; 10 import { type ThreadPost, type Thread } from '$lib/thread'; 11 import NotLoggedIn from './NotLoggedIn.svelte'; 12 import LoadingSpinner from './LoadingSpinner.svelte'; 13 import EndOfList from './EndOfList.svelte'; 14 import LoadError from './LoadError.svelte'; 15 import LoadNewPosts from './LoadNewPosts.svelte'; 16 import { onMount } from 'svelte'; 17 import { initialDone } from '$lib/state.svelte'; 18 import { estimatePostHeight } from '$lib/post-height'; 19 20 interface Props { 21 client?: AtpClient | null; 22 threads: Thread[]; 23 timelineId?: string; 24 postComposerState: PostComposerState; 25 class?: string; 26 isLoggedIn?: boolean; 27 canLoad?: boolean; 28 onLoadMore: () => Promise<void>; 29 isComplete?: boolean; 30 displayCount?: number; 31 } 32 33 let { 34 client = null, 35 threads, 36 timelineId = undefined, 37 postComposerState = $bindable(), 38 class: className = '', 39 isLoggedIn = false, 40 canLoad = undefined, 41 onLoadMore, 42 isComplete = false, 43 displayCount = $bindable(15) 44 }: Props = $props(); 45 46 const shouldLoad = $derived(canLoad ?? isLoggedIn); 47 48 let reverseChronological = $state(true); 49 const expandedThreads = new SvelteSet<ResourceUri>(); 50 51 let isAtTop = $state(true); 52 let boundaryTime = $state<number | null>(null); 53 54 const visibleThreads = $derived.by(() => { 55 if (boundaryTime === null) return threads; 56 return threads.filter((t) => t.newestTime <= boundaryTime!); 57 }); 58 59 $effect(() => { 60 timelineId; 61 displayCount = 15; 62 measuredHeights = []; 63 }); 64 65 // const renderedThreads = $derived(visibleThreads.slice(0, displayCount)); 66 67 $effect(() => { 68 if (threads.length > 0 && boundaryTime === null) boundaryTime = threads[0].newestTime; 69 }); 70 71 const showNewPosts = () => { 72 boundaryTime = threads[0]?.newestTime ?? null; 73 window.scrollTo({ top: 0, behavior: 'instant' }); 74 isAtTop = true; 75 }; 76 77 const onScroll = (event: { event: Event; offset: number }) => { 78 const { offset } = event; 79 isAtTop = offset < 300; 80 }; 81 82 const loaderState = new LoaderState(); 83 let loading = $state(false); 84 let loadError = $state(''); 85 86 // helper to estimate thread height 87 const estimateThreadHeight = (thread: Thread) => { 88 let height = 0; 89 if (thread.branchParentPost) height += 20; // approx height for mini parent 90 91 const isExpanded = expandedThreads.has(thread.rootUri); 92 const len = thread.posts.length; 93 const isLong = len > 4; 94 95 for (let i = 0; i < len; i++) { 96 const post = thread.posts[i]; 97 const mini = !isExpanded && isLong && i > 0 && i < len - 2; 98 99 if (!mini) { 100 // normal post 101 height += estimatePostHeight(post.data); 102 if (i < len - 1) height += 6; // mb-1.5 103 } else { 104 // mini / collapsed 105 if (i === 1) height += 88; // "view full chain" button + reply post 106 // other mini posts are hidden or collapsed 107 } 108 } 109 110 height += 28; // for the thread spacer 111 112 return height; 113 }; 114 115 let measuredHeights: number[] = $state([]); 116 const itemHeights = $derived.by(() => { 117 const heights = measuredHeights.slice(0, visibleThreads.length); 118 while (heights.length < visibleThreads.length) { 119 heights.push(estimateThreadHeight(visibleThreads[heights.length])); 120 } 121 return heights; 122 }); 123 124 const averageHeight = $derived.by(() => { 125 if (measuredHeights.length === 0) return 300; 126 const sum = measuredHeights.reduce((a, b) => a + b, 0); 127 return sum / measuredHeights.length; 128 }); 129 130 const loadMore = async () => { 131 if (loading || !shouldLoad) return; 132 133 loading = true; 134 loaderState.status = 'LOADING'; 135 loadError = ''; 136 137 try { 138 displayCount += 10; 139 const bufferSize = visibleThreads.length - displayCount; 140 141 if (bufferSize < 5 && !isComplete) await onLoadMore(); 142 143 loaderState.loaded(); 144 if (isComplete && displayCount >= visibleThreads.length) loaderState.complete(); 145 } catch (error) { 146 loadError = `${error}`; 147 loaderState.error(); 148 } finally { 149 loading = false; 150 } 151 }; 152 153 $effect(() => { 154 const isEmpty = threads.length < 10; 155 if (isEmpty && !loading && shouldLoad && !isComplete) loadMore(); 156 }); 157 158 $effect(() => { 159 if (!initialDone.has(client?.user?.did ?? 'did:plc:invalid')) { 160 loading = true; 161 loaderState.status = 'LOADING'; 162 } else { 163 loading = false; 164 loaderState.loaded(); 165 } 166 }); 167 168 const renderItem = (index: number) => { 169 return visibleThreads[index]; 170 }; 171</script> 172 173{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 174 <span 175 class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 176 > 177 <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 178 <BskyPost mini client={client!} {...post} /> 179 </span> 180{/snippet} 181 182<div class="h-full [scrollbar-color:var(--nucleus-accent)_transparent] {className}"> 183 <LoadNewPosts 184 visible={threads.length > 0 && boundaryTime !== null && threads[0].newestTime > boundaryTime} 185 onclick={showNewPosts} 186 /> 187 {#if isLoggedIn} 188 {#key timelineId} 189 <VirtualList 190 height="100%" 191 itemCount={visibleThreads.length} 192 itemSize={itemHeights} 193 estimatedItemSize={averageHeight} 194 onAfterScroll={onScroll} 195 > 196 {#snippet item({ index, style }: { index: number; style: string })} 197 {@const thread = renderItem(index)} 198 <div 199 style="{style} height: auto;" 200 bind:clientHeight={ 201 () => { 202 // we need to return this so the bind works 203 return measuredHeights[index] ?? estimateThreadHeight(thread); 204 }, 205 (h) => { 206 // update the height 207 if (measuredHeights[index] !== h) measuredHeights[index] = h; 208 } 209 } 210 > 211 {#if thread} 212 <div 213 class="flex w-full shrink-0 {reverseChronological 214 ? 'flex-col' 215 : 'flex-col-reverse'}" 216 > 217 {#if thread.branchParentPost} 218 {@render replyPost(thread.branchParentPost)} 219 {/if} 220 {#each thread.posts as post, idx (post.data.uri)} 221 {@const mini = 222 !expandedThreads.has(thread.rootUri) && 223 thread.posts.length > 4 && 224 idx > 0 && 225 idx < thread.posts.length - 2} 226 {#if !mini} 227 <div class="mb-1.5"> 228 <BskyPost 229 client={client!} 230 onQuote={(post) => { 231 postComposerState.focus = 'focused'; 232 postComposerState.quoting = post; 233 }} 234 onReply={(post) => { 235 postComposerState.focus = 'focused'; 236 postComposerState.replying = post; 237 }} 238 {...post} 239 blockRelationship={post.blockRelationship} 240 /> 241 </div> 242 {:else if mini} 243 {#if idx === 1} 244 {@render replyPost(post, !reverseChronological)} 245 <button 246 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)" 247 onclick={() => expandedThreads.add(thread.rootUri)} 248 > 249 <div 250 class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50" 251 ></div> 252 <Icon 253 class="shrink-0" 254 icon={reverseChronological 255 ? 'heroicons:bars-arrow-up-solid' 256 : 'heroicons:bars-arrow-down-solid'} 257 width={32} 258 /><span class="shrink-0 pb-1">view full chain</span> 259 <div 260 class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50" 261 ></div> 262 </button> 263 {:else if idx === thread.posts.length - 3} 264 {@render replyPost(post)} 265 {/if} 266 {/if} 267 {/each} 268 </div> 269 {#if index < visibleThreads.length - 1} 270 <div 271 class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 272 ></div> 273 {/if} 274 {/if} 275 </div> 276 {/snippet} 277 278 {#snippet footer()} 279 <div class="pb-20"> 280 <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 281 <div class="h-px w-px opacity-0"></div> 282 {#snippet noData()} 283 <EndOfList /> 284 {/snippet} 285 {#snippet loading()} 286 <LoadingSpinner /> 287 {#if !shouldLoad} 288 <p class="text-center text-xl opacity-80"> 289 warming up... <span class="text-2xl">◔.◔</span> 290 </p> 291 {/if} 292 {/snippet} 293 {#snippet error()} 294 <LoadError error={loadError} onRetry={loadMore} /> 295 {/snippet} 296 </InfiniteLoader> 297 </div> 298 {/snippet} 299 </VirtualList> 300 {/key} 301 {:else} 302 <NotLoggedIn /> 303 {/if} 304</div>