appview-less bluesky client
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>