appview-less bluesky client
24
fork

Configure Feed

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

at main 250 lines 7.2 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 { 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>