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 { estimatePostHeight } from '$lib/post-height';
6
7 import { accounts } from '$lib/accounts';
8 import type { Did, RecordKey } from '@atcute/lexicons/syntax';
9 import { InfiniteLoader, LoaderState } from 'svelte-infinite';
10 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list';
11 import {
12 allPosts,
13 viewClient,
14 accountPreferences,
15 feedTimelines,
16 feedCursors,
17 fetchFeed,
18 resetFeed,
19 checkForNewPosts,
20 fetchInteractionsToFeedTimelineEnd
21 } from '$lib/state.svelte';
22 import NotLoggedIn from './NotLoggedIn.svelte';
23 import { fetchFeedGenerator } from '$lib/at/feeds';
24 import LoadingSpinner from './LoadingSpinner.svelte';
25 import EndOfList from './EndOfList.svelte';
26 import LoadError from './LoadError.svelte';
27 import LoadNewPosts from './LoadNewPosts.svelte';
28
29 interface Props {
30 client?: AtpClient | null;
31 postComposerState: PostComposerState;
32 class?: string;
33 selectedFeed: string;
34 }
35
36 let {
37 client = null,
38 postComposerState = $bindable(),
39 selectedFeed,
40 class: className = ''
41 }: Props = $props();
42
43 const userDid = $derived(client?.user?.did);
44
45 let feedServiceDid = $state<string | null>(null);
46 let newPostsAvailable = $state(false);
47 let virtualList = $state<VirtualList | null>(null);
48 let scrollToIndex = $state<number | undefined>(undefined);
49
50 const viewKey = $derived(`${userDid ?? 'anon'}-${selectedFeed}`);
51
52 $effect(() => {
53 viewKey; // dependency
54 feedServiceDid = null;
55 newPostsAvailable = false;
56 displayCount = 15;
57 measuredHeights = [];
58 loaderState.reset();
59 scrollToIndex = undefined;
60
61 fetchFeedGenerator(client ?? viewClient, selectedFeed).then((meta) => {
62 feedServiceDid = meta?.did ?? null;
63 });
64 });
65
66 $effect(() => {
67 if (!client || !feedServiceDid) return;
68
69 const check = async () => {
70 if (!client || !feedServiceDid) return;
71 newPostsAvailable = await checkForNewPosts(client, selectedFeed, feedServiceDid);
72 };
73
74 check();
75 const interval = setInterval(check, 15000);
76
77 return () => clearInterval(interval);
78 });
79
80 const loaderState = new LoaderState();
81 let loading = $state(false);
82 let loadError = $state('');
83
84 let fetchingInteractions = $state(false);
85 let scheduledFetchInteractions = $state(false);
86
87 export const clearFeed = () => {
88 if (!userDid) return;
89 scrollToIndex = 0;
90 setTimeout(() => (scrollToIndex = undefined), 100);
91 newPostsAvailable = false;
92 displayCount = 15;
93 measuredHeights = [];
94 resetFeed(userDid, selectedFeed);
95 loaderState.reset();
96 loadMore();
97 };
98
99 let displayCount = $state(15);
100
101 const feedPosts = $derived.by(() => {
102 if (!userDid) return [];
103 const uris = feedTimelines.get(userDid)?.get(selectedFeed) ?? [];
104 return uris
105 .map((uri) => allPosts.get(uri))
106 .filter((p): p is NonNullable<typeof p> => p !== undefined);
107 });
108
109 let measuredHeights: number[] = $state([]);
110 const itemHeights = $derived.by(() => {
111 const heights = measuredHeights.slice(0, feedPosts.length);
112 while (heights.length < feedPosts.length) {
113 heights.push(estimatePostHeight(feedPosts[heights.length]));
114 }
115 return heights;
116 });
117
118 const averageHeight = $derived.by(() => {
119 if (measuredHeights.length === 0) return 150;
120 const sum = measuredHeights.reduce((a, b) => a + b, 0);
121 return sum / measuredHeights.length;
122 });
123
124 const loadMore = async () => {
125 if (loading || !client || !userDid || !feedServiceDid) return;
126
127 loading = true;
128 loaderState.status = 'LOADING';
129
130 try {
131 displayCount += 10;
132 const bufferSize = feedPosts.length - displayCount;
133 const cursor = feedCursors.get(userDid)?.get(selectedFeed);
134
135 if (bufferSize < 5 && !cursor?.end) {
136 const result = await fetchFeed(client, selectedFeed, feedServiceDid);
137 if (client.user && userDid) {
138 if (!fetchingInteractions) {
139 scheduledFetchInteractions = false;
140 fetchingInteractions = true;
141 await fetchInteractionsToFeedTimelineEnd(client, userDid, selectedFeed);
142 fetchingInteractions = false;
143 } else {
144 scheduledFetchInteractions = true;
145 }
146 }
147 console.log('feed loaded', result?.end);
148 if (result?.end) loaderState.complete();
149 } else {
150 if (cursor?.end && displayCount >= feedPosts.length) loaderState.complete();
151 }
152 loaderState.loaded();
153 } catch (error) {
154 loadError = `${error}`;
155 loaderState.error();
156 } finally {
157 loading = false;
158 }
159 };
160
161 $effect(() => {
162 const isEmpty = feedPosts.length === 0;
163 if (isEmpty && !loading && userDid && feedServiceDid) {
164 const cursor = feedCursors.get(userDid)?.get(selectedFeed);
165 if (!cursor?.end) loadMore();
166 }
167 });
168
169 const renderItem = (index: number) => {
170 const post = feedPosts[index];
171 if (!post) return { post: null, postDid: null, postRkey: null };
172 const uriParts = post.uri.split('/');
173 const postDid = uriParts[2] as Did;
174 const postRkey = uriParts[4] as RecordKey;
175 return { post, postDid, postRkey };
176 };
177</script>
178
179<div class="h-full [scrollbar-color:var(--nucleus-accent)_transparent] {className}">
180 <LoadNewPosts visible={newPostsAvailable} onclick={clearFeed} />
181 {#if userDid || $accounts.length > 0}
182 {#key viewKey}
183 <VirtualList
184 bind:this={virtualList}
185 height="100%"
186 itemCount={feedPosts.length}
187 itemSize={itemHeights}
188 estimatedItemSize={averageHeight}
189 scrollToIndex={feedPosts.length > 0 ? scrollToIndex : undefined}
190 >
191 {#snippet item({ index, style }: { index: number; style: string })}
192 {@const { post, postDid, postRkey } = renderItem(index)}
193 <div
194 style="{style} height: auto;"
195 bind:clientHeight={
196 () => {
197 // we need to return this so the bind works
198 return measuredHeights[index] ?? estimatePostHeight(post);
199 },
200 (h) => {
201 // update the height
202 if (measuredHeights[index] !== h) measuredHeights[index] = h;
203 }
204 }
205 >
206 <div
207 class="mx-2 mb-1.5 border-b border-dashed border-[color-mix(in_srgb,var(--nucleus-accent)_30%,transparent)] pb-3 last:border-0"
208 >
209 {#if post && postDid && postRkey}
210 <BskyPost
211 client={client!}
212 did={postDid}
213 rkey={postRkey}
214 data={post}
215 onQuote={(p) => {
216 postComposerState.focus = 'focused';
217 postComposerState.quoting = p;
218 }}
219 onReply={(p) => {
220 postComposerState.focus = 'focused';
221 postComposerState.replying = p;
222 }}
223 />
224 {/if}
225 </div>
226 </div>
227 {/snippet}
228
229 {#snippet footer()}
230 <div class="pb-20">
231 <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}>
232 <div class="h-px w-px opacity-0"></div>
233 {#snippet noData()}
234 <EndOfList />
235 {/snippet}
236 {#snippet loading()}
237 <LoadingSpinner />
238 {/snippet}
239 {#snippet error()}
240 <LoadError error={loadError} onRetry={loadMore} />
241 {/snippet}
242 </InfiniteLoader>
243 </div>
244 {/snippet}
245 </VirtualList>
246 {/key}
247 {:else}
248 <NotLoggedIn />
249 {/if}
250</div>