appview-less bluesky client
24
fork

Configure Feed

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

virtual list timeline, kinda ass but works for now

dawn 5e834526 47f3d73a

+388 -174
+14 -12
src/components/BskyPost.svelte
··· 8 8 type RecordKey, 9 9 type ResourceUri 10 10 } from '@atcute/lexicons'; 11 - import { expect, ok } from '$lib/result'; 11 + import { err, expect, ok, type Result } from '$lib/result'; 12 12 import { accounts, generateColorForDid } from '$lib/accounts'; 13 13 import ProfilePicture from './ProfilePicture.svelte'; 14 14 import BskyPost from './BskyPost.svelte'; ··· 87 87 ); 88 88 const showAsMuted = $derived(isMuted && !expandDisallowed); 89 89 90 - let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); 90 + const handle = $derived(handles.get(did) ?? 'handle.invalid'); 91 91 onMount(() => { 92 92 resolveDidDoc(did).then((res) => { 93 - if (res.ok) { 94 - handle = res.value.handle; 95 - handles.set(did, handle); 96 - } 93 + if (res.ok) handles.set(did, res.value.handle); 97 94 return res; 98 95 }); 99 96 }); 100 - const post = data 101 - ? Promise.resolve(ok(data)) 102 - : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); 103 - let profile: AppBskyActorProfile.Main | null = $state(profiles.get(did) ?? null); 97 + const profile = $derived(profiles.get(did)); 104 98 onMount(async () => { 105 99 const p = await client.getProfile(did); 106 100 if (!p.ok) return; 107 - profile = p.value; 108 - profiles.set(did, profile); 101 + profiles.set(did, p.value); 102 + }); 103 + 104 + // svelte-ignore state_referenced_locally 105 + let post: Result<PostWithUri, string> = $state(data ? ok(data) : err("post couldn't be loaded")); 106 + $effect(() => { 107 + client.getRecord(AppBskyFeedPost.mainSchema, did, rkey).then((res) => { 108 + if (!res.ok) return; 109 + post = res; 110 + }); 109 111 }); 110 112 111 113 const postId = $derived(
+5 -4
src/components/Dropdown.svelte
··· 48 48 let openTimer: ReturnType<typeof setTimeout>; 49 49 50 50 const updatePosition = async () => { 51 - const { x, y } = await computePosition(triggerRef!, contentRef!, { 51 + if (!triggerRef || !contentRef) return; 52 + const { x, y } = await computePosition(triggerRef, contentRef, { 52 53 placement, 53 54 middleware: [offset(offsetDistance), flip(), shift({ padding: 8 })], 54 55 strategy: 'fixed' 55 56 }); 56 57 57 - Object.assign(contentRef!.style, { 58 + Object.assign(contentRef.style, { 58 59 left: `${x}px`, 59 60 top: `${y}px` 60 61 }); ··· 129 130 }); 130 131 131 132 $effect(() => { 132 - if (isOpen) { 133 - cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition); 133 + if (isOpen && triggerRef && contentRef) { 134 + cleanup = autoUpdate(triggerRef, contentRef, updatePosition); 134 135 } else if (cleanup) { 135 136 cleanup(); 136 137 cleanup = null;
+103 -49
src/components/FeedTimelineView.svelte
··· 2 2 import BskyPost from './BskyPost.svelte'; 3 3 import { type State as PostComposerState } from './PostComposer.svelte'; 4 4 import { AtpClient } from '$lib/at/client.svelte'; 5 + import { estimatePostHeight } from '$lib/post-height'; 6 + 5 7 import { accounts } from '$lib/accounts'; 6 8 import type { Did, RecordKey } from '@atcute/lexicons/syntax'; 7 9 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 10 + import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 8 11 import { 9 12 allPosts, 10 13 viewClient, ··· 41 44 42 45 let feedServiceDid = $state<string | null>(null); 43 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}`); 44 51 45 52 $effect(() => { 46 - selectedFeed; 53 + viewKey; // dependency 47 54 feedServiceDid = null; 48 55 newPostsAvailable = false; 49 56 displayCount = 15; 57 + measuredHeights = []; 50 58 loaderState.reset(); 59 + scrollToIndex = undefined; 60 + 51 61 fetchFeedGenerator(client ?? viewClient, selectedFeed).then((meta) => { 52 62 feedServiceDid = meta?.did ?? null; 53 63 }); ··· 68 78 }); 69 79 70 80 const loaderState = new LoaderState(); 71 - let scrollContainer = $state<HTMLDivElement>(); 72 81 let loading = $state(false); 73 82 let loadError = $state(''); 74 83 ··· 77 86 78 87 export const clearFeed = () => { 79 88 if (!userDid) return; 80 - scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }); 89 + scrollToIndex = 0; 90 + setTimeout(() => (scrollToIndex = undefined), 100); 81 91 newPostsAvailable = false; 82 92 displayCount = 15; 93 + measuredHeights = []; 83 94 resetFeed(userDid, selectedFeed); 84 95 loaderState.reset(); 85 96 loadMore(); ··· 98 109 .filter((p): p is NonNullable<typeof p> => p !== undefined); 99 110 }); 100 111 101 - const renderedPosts = $derived(feedPosts.slice(0, displayCount)); 112 + let measuredHeights: number[] = $state([]); 113 + const itemHeights = $derived.by(() => { 114 + const heights = measuredHeights.slice(0, feedPosts.length); 115 + while (heights.length < feedPosts.length) { 116 + heights.push(estimatePostHeight(feedPosts[heights.length])); 117 + } 118 + return heights; 119 + }); 120 + 121 + const averageHeight = $derived.by(() => { 122 + if (measuredHeights.length === 0) return 150; 123 + const sum = measuredHeights.reduce((a, b) => a + b, 0); 124 + return sum / measuredHeights.length; 125 + }); 102 126 103 127 const loadMore = async () => { 104 128 if (loading || !client || !userDid || !feedServiceDid) return; ··· 144 168 if (!cursor?.end) loadMore(); 145 169 } 146 170 }); 147 - </script> 148 171 149 - {#snippet feedPostsView()} 150 - {#each renderedPosts as post, i (post.uri)} 151 - {@const uriParts = post.uri.split('/')} 152 - {@const postDid = uriParts[2] as Did} 153 - {@const postRkey = uriParts[4] as RecordKey} 154 - <div class="mb-1.5"> 155 - <BskyPost 156 - client={client!} 157 - did={postDid} 158 - rkey={postRkey} 159 - data={post} 160 - onQuote={(p) => { 161 - postComposerState.focus = 'focused'; 162 - postComposerState.quoting = p; 163 - }} 164 - onReply={(p) => { 165 - postComposerState.focus = 'focused'; 166 - postComposerState.replying = p; 167 - }} 168 - /> 169 - </div> 170 - {#if i < renderedPosts.length - 1} 171 - <div 172 - class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 173 - ></div> 174 - {/if} 175 - {/each} 176 - {/snippet} 172 + const renderItem = (index: number) => { 173 + const post = feedPosts[index]; 174 + if (!post) return { post: null, postDid: null, postRkey: null }; 175 + const uriParts = post.uri.split('/'); 176 + const postDid = uriParts[2] as Did; 177 + const postRkey = uriParts[4] as RecordKey; 178 + return { post, postDid, postRkey }; 179 + }; 180 + </script> 177 181 178 - <div 179 - class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 180 - bind:this={scrollContainer} 181 - > 182 + <div class="h-full [scrollbar-color:var(--nucleus-accent)_transparent] {className}"> 182 183 <LoadNewPosts visible={newPostsAvailable} onclick={clearFeed} /> 183 184 {#if userDid || $accounts.length > 0} 184 - <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 185 - {@render feedPostsView()} 186 - {#snippet noData()} 187 - <EndOfList /> 188 - {/snippet} 189 - {#snippet loading()} 190 - <LoadingSpinner /> 191 - {/snippet} 192 - {#snippet error()} 193 - <LoadError error={loadError} onRetry={loadMore} /> 194 - {/snippet} 195 - </InfiniteLoader> 185 + {#key viewKey} 186 + <VirtualList 187 + bind:this={virtualList} 188 + height="100%" 189 + itemCount={feedPosts.length} 190 + itemSize={itemHeights} 191 + estimatedItemSize={averageHeight} 192 + scrollToIndex={feedPosts.length > 0 ? scrollToIndex : undefined} 193 + > 194 + {#snippet item({ index, style }: { index: number; style: string })} 195 + {@const { post, postDid, postRkey } = renderItem(index)} 196 + <div 197 + style="{style} height: auto;" 198 + bind:clientHeight={ 199 + () => { 200 + // we need to return this so the bind works 201 + return measuredHeights[index] ?? estimatePostHeight(post); 202 + }, 203 + (h) => { 204 + // update the height 205 + if (measuredHeights[index] !== h) measuredHeights[index] = h; 206 + } 207 + } 208 + > 209 + <div 210 + 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" 211 + > 212 + {#if post && postDid && postRkey} 213 + <BskyPost 214 + client={client!} 215 + did={postDid} 216 + rkey={postRkey} 217 + data={post} 218 + onQuote={(p) => { 219 + postComposerState.focus = 'focused'; 220 + postComposerState.quoting = p; 221 + }} 222 + onReply={(p) => { 223 + postComposerState.focus = 'focused'; 224 + postComposerState.replying = p; 225 + }} 226 + /> 227 + {/if} 228 + </div> 229 + </div> 230 + {/snippet} 231 + 232 + {#snippet footer()} 233 + <div class="pb-20"> 234 + <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 235 + <div class="h-px w-px opacity-0"></div> 236 + {#snippet noData()} 237 + <EndOfList /> 238 + {/snippet} 239 + {#snippet loading()} 240 + <LoadingSpinner /> 241 + {/snippet} 242 + {#snippet error()} 243 + <LoadError error={loadError} onRetry={loadMore} /> 244 + {/snippet} 245 + </InfiniteLoader> 246 + </div> 247 + {/snippet} 248 + </VirtualList> 249 + {/key} 196 250 {:else} 197 251 <NotLoggedIn /> 198 252 {/if}
+171 -76
src/components/GenericTimelineView.svelte
··· 5 5 import { type ResourceUri } from '@atcute/lexicons'; 6 6 import { SvelteSet } from 'svelte/reactivity'; 7 7 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 8 + import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 8 9 import Icon from '@iconify/svelte'; 9 10 import { type ThreadPost, type Thread } from '$lib/thread'; 10 11 import NotLoggedIn from './NotLoggedIn.svelte'; ··· 14 15 import LoadNewPosts from './LoadNewPosts.svelte'; 15 16 import { onMount } from 'svelte'; 16 17 import { initialDone } from '$lib/state.svelte'; 18 + import { estimatePostHeight } from '$lib/post-height'; 17 19 18 20 interface Props { 19 21 client?: AtpClient | null; ··· 46 48 47 49 let isAtTop = $state(true); 48 50 let boundaryTime = $state<number | null>(null); 51 + let virtualList = $state<VirtualList | null>(null); 52 + let scrollToIndex = $state<number | undefined>(undefined); 49 53 50 54 const visibleThreads = $derived.by(() => { 51 55 if (boundaryTime === null) return threads; ··· 56 60 $effect(() => { 57 61 timelineId; 58 62 displayCount = 15; 63 + measuredHeights = []; 64 + scrollToIndex = undefined; 59 65 }); 60 66 61 - const renderedThreads = $derived(visibleThreads.slice(0, displayCount)); 67 + // const renderedThreads = $derived(visibleThreads.slice(0, displayCount)); 62 68 63 69 $effect(() => { 64 70 if (threads.length > 0) { ··· 69 75 70 76 const showNewPosts = () => { 71 77 boundaryTime = threads[0]?.newestTime ?? null; 72 - window.scrollTo({ top: 0, behavior: 'instant' }); 78 + // @ts-ignore 79 + virtualList?.scrollToIndex(0); 73 80 isAtTop = true; 74 81 }; 75 82 76 - const onScroll = () => (isAtTop = window.scrollY < 300); 83 + const onScroll = (event: { event: Event; offset: number }) => { 84 + const { offset } = event; 85 + isAtTop = offset < 300; 86 + }; 77 87 78 88 const loaderState = new LoaderState(); 79 89 let loading = $state(false); 80 90 let loadError = $state(''); 81 91 92 + // helper to estimate thread height 93 + const estimateThreadHeight = (thread: Thread) => { 94 + let height = 0; 95 + if (thread.branchParentPost) height += 20; // approx height for mini parent 96 + 97 + const isExpanded = expandedThreads.has(thread.rootUri); 98 + const len = thread.posts.length; 99 + const isLong = len > 4; 100 + 101 + for (let i = 0; i < len; i++) { 102 + const post = thread.posts[i]; 103 + const mini = !isExpanded && isLong && i > 0 && i < len - 2; 104 + 105 + if (!mini) { 106 + // normal post 107 + height += estimatePostHeight(post.data); 108 + if (i < len - 1) height += 6; // mb-1.5 109 + } else { 110 + // mini / collapsed 111 + if (i === 1) height += 88; // "view full chain" button + reply post 112 + // other mini posts are hidden or collapsed 113 + } 114 + } 115 + 116 + height += 28; // for the thread spacer 117 + 118 + return height; 119 + }; 120 + 121 + let measuredHeights: number[] = $state([]); 122 + const itemHeights = $derived.by(() => { 123 + const heights = measuredHeights.slice(0, visibleThreads.length); 124 + while (heights.length < visibleThreads.length) { 125 + heights.push(estimateThreadHeight(visibleThreads[heights.length])); 126 + } 127 + return heights; 128 + }); 129 + 130 + const averageHeight = $derived.by(() => { 131 + if (measuredHeights.length === 0) return 300; 132 + const sum = measuredHeights.reduce((a, b) => a + b, 0); 133 + return sum / measuredHeights.length; 134 + }); 135 + 82 136 const loadMore = async () => { 83 137 if (loading || !shouldLoad) return; 84 138 ··· 116 170 loaderState.loaded(); 117 171 } 118 172 }); 119 - </script> 120 173 121 - <svelte:window onscroll={onScroll} /> 174 + const renderItem = (index: number) => { 175 + return visibleThreads[index]; 176 + }; 177 + </script> 122 178 123 179 {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 124 180 <span ··· 129 185 </span> 130 186 {/snippet} 131 187 132 - {#snippet threadsView()} 133 - {#each renderedThreads as thread, i (thread.rootUri)} 134 - <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 135 - {#if thread.branchParentPost} 136 - {@render replyPost(thread.branchParentPost)} 137 - {/if} 138 - {#each thread.posts as post, idx (post.data.uri)} 139 - {@const mini = 140 - !expandedThreads.has(thread.rootUri) && 141 - thread.posts.length > 4 && 142 - idx > 0 && 143 - idx < thread.posts.length - 2} 144 - {#if !mini} 145 - <div class="mb-1.5"> 146 - <BskyPost 147 - client={client!} 148 - onQuote={(post) => { 149 - postComposerState.focus = 'focused'; 150 - postComposerState.quoting = post; 151 - }} 152 - onReply={(post) => { 153 - postComposerState.focus = 'focused'; 154 - postComposerState.replying = post; 155 - }} 156 - {...post} 157 - /> 158 - </div> 159 - {:else if mini} 160 - {#if idx === 1} 161 - {@render replyPost(post, !reverseChronological)} 162 - <button 163 - 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)" 164 - onclick={() => expandedThreads.add(thread.rootUri)} 165 - > 166 - <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 167 - <Icon 168 - class="shrink-0" 169 - icon={reverseChronological 170 - ? 'heroicons:bars-arrow-up-solid' 171 - : 'heroicons:bars-arrow-down-solid'} 172 - width={32} 173 - /><span class="shrink-0 pb-1">view full chain</span> 174 - <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 175 - </button> 176 - {:else if idx === thread.posts.length - 3} 177 - {@render replyPost(post)} 178 - {/if} 179 - {/if} 180 - {/each} 181 - </div> 182 - {#if i < renderedThreads.length - 1} 183 - <div 184 - class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 185 - ></div> 186 - {/if} 187 - {/each} 188 - {/snippet} 189 - 190 - <div class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}"> 188 + <div class="h-full [scrollbar-color:var(--nucleus-accent)_transparent] {className}"> 191 189 <LoadNewPosts 192 190 visible={threads.length > 0 && boundaryTime !== null && threads[0].newestTime > boundaryTime} 193 191 onclick={showNewPosts} 194 192 /> 195 193 {#if isLoggedIn} 196 - <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 197 - {@render threadsView()} 198 - {#snippet noData()} 199 - <EndOfList /> 200 - {/snippet} 201 - {#snippet loading()} 202 - <LoadingSpinner /> 203 - {/snippet} 204 - {#snippet error()} 205 - <LoadError error={loadError} onRetry={loadMore} /> 206 - {/snippet} 207 - </InfiniteLoader> 194 + {#key timelineId} 195 + <VirtualList 196 + bind:this={virtualList} 197 + height="100%" 198 + itemCount={visibleThreads.length} 199 + itemSize={itemHeights} 200 + estimatedItemSize={averageHeight} 201 + onAfterScroll={onScroll} 202 + scrollToIndex={visibleThreads.length > 0 ? scrollToIndex : undefined} 203 + > 204 + {#snippet item({ index, style }: { index: number; style: string })} 205 + {@const thread = renderItem(index)} 206 + <div 207 + style="{style} height: auto;" 208 + bind:clientHeight={ 209 + () => { 210 + // we need to return this so the bind works 211 + return measuredHeights[index] ?? estimateThreadHeight(thread); 212 + }, 213 + (h) => { 214 + // update the height 215 + if (measuredHeights[index] !== h) measuredHeights[index] = h; 216 + } 217 + } 218 + > 219 + {#if thread} 220 + <div 221 + class="flex w-full shrink-0 {reverseChronological 222 + ? 'flex-col' 223 + : 'flex-col-reverse'}" 224 + > 225 + {#if thread.branchParentPost} 226 + {@render replyPost(thread.branchParentPost)} 227 + {/if} 228 + {#each thread.posts as post, idx (post.data.uri)} 229 + {@const mini = 230 + !expandedThreads.has(thread.rootUri) && 231 + thread.posts.length > 4 && 232 + idx > 0 && 233 + idx < thread.posts.length - 2} 234 + {#if !mini} 235 + <div class="mb-1.5"> 236 + <BskyPost 237 + client={client!} 238 + onQuote={(post) => { 239 + postComposerState.focus = 'focused'; 240 + postComposerState.quoting = post; 241 + }} 242 + onReply={(post) => { 243 + postComposerState.focus = 'focused'; 244 + postComposerState.replying = post; 245 + }} 246 + {...post} 247 + /> 248 + </div> 249 + {:else if mini} 250 + {#if idx === 1} 251 + {@render replyPost(post, !reverseChronological)} 252 + <button 253 + 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)" 254 + onclick={() => expandedThreads.add(thread.rootUri)} 255 + > 256 + <div 257 + class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50" 258 + ></div> 259 + <Icon 260 + class="shrink-0" 261 + icon={reverseChronological 262 + ? 'heroicons:bars-arrow-up-solid' 263 + : 'heroicons:bars-arrow-down-solid'} 264 + width={32} 265 + /><span class="shrink-0 pb-1">view full chain</span> 266 + <div 267 + class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50" 268 + ></div> 269 + </button> 270 + {:else if idx === thread.posts.length - 3} 271 + {@render replyPost(post)} 272 + {/if} 273 + {/if} 274 + {/each} 275 + </div> 276 + {#if index < visibleThreads.length - 1} 277 + <div 278 + class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 279 + ></div> 280 + {/if} 281 + {/if} 282 + </div> 283 + {/snippet} 284 + 285 + {#snippet footer()} 286 + <div class="pb-20"> 287 + <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 288 + <div class="h-px w-px opacity-0"></div> 289 + {#snippet noData()} 290 + <EndOfList /> 291 + {/snippet} 292 + {#snippet loading()} 293 + <LoadingSpinner /> 294 + {/snippet} 295 + {#snippet error()} 296 + <LoadError error={loadError} onRetry={loadMore} /> 297 + {/snippet} 298 + </InfiniteLoader> 299 + </div> 300 + {/snippet} 301 + </VirtualList> 302 + {/key} 208 303 {:else} 209 304 <NotLoggedIn /> 210 305 {/if}
+1 -1
src/components/PhotoSwipeGallery.svelte
··· 62 62 </script> 63 63 64 64 <div class="gallery styling-twitter" data-total={images.length} bind:this={element}> 65 - {#each images as img, i (img.src)} 65 + {#each images as img, i (`${img.src}#${i}`)} 66 66 {@const thumb = img.thumbnail ?? img} 67 67 {@const isHidden = i > 3} 68 68 {@const isOverlay = i === 3 && images.length > 4}
+5 -9
src/components/ProfileInfo.svelte
··· 12 12 client: AtpClient; 13 13 did: Did; 14 14 handle?: Handle; 15 - profile?: AppBskyActorProfile.Main | null; 15 + profile?: AppBskyActorProfile.Main; 16 16 } 17 17 18 - let { 19 - client, 20 - did, 21 - handle = handles.get(did), 22 - profile = $bindable(profiles.get(did) ?? null) 23 - }: Props = $props(); 18 + let { client, did, handle: _handle, profile: _profile }: Props = $props(); 19 + 20 + const handle = $derived(_handle ?? handles.get(did)); 21 + const profile = $derived(_profile ?? profiles.get(did)); 24 22 25 23 const userDid = $derived(client.user?.did); 26 24 const blockRel = $derived( ··· 37 35 if (profile) return; 38 36 const res = await client.getProfile(did); 39 37 if (!res.ok) return; 40 - profile = res.value; 41 38 profiles.set(did, res.value); 42 39 })(), 43 40 (async () => { 44 41 if (handle) return; 45 42 const res = await resolveDidDoc(did); 46 43 if (!res.ok) return; 47 - handle = res.value.handle; 48 44 handles.set(did, res.value.handle); 49 45 })() 50 46 ]);
+7 -18
src/components/ProfilePicture.svelte
··· 15 15 16 16 let { client, did, size }: Props = $props(); 17 17 18 - // svelte-ignore state_referenced_locally 19 - let avatarBlob = $state(profiles.get(did)?.avatar); 18 + const avatarBlob = $derived(profiles.get(did)?.avatar); 20 19 const avatarUrl: string | null = $derived( 21 20 isBlob(avatarBlob) ? img('avatar_thumbnail', did, avatarBlob.ref.$link) : null 22 21 ); 23 22 24 23 const loadProfile = async (targetDid: Did) => { 25 - const cachedBlob = profiles.get(did)?.avatar; 26 - if (cachedBlob) { 27 - avatarBlob = cachedBlob; 28 - return; 29 - } 24 + if (avatarBlob) return; 30 25 31 - try { 32 - const profile = await client.getProfile(targetDid); 33 - if (profile.ok) { 34 - avatarBlob = profile.value.avatar; 35 - profiles.set(did, profile.value); 36 - } else avatarBlob = undefined; 37 - } catch (e) { 38 - console.error(`${targetDid}: failed to load pfp`, e); 39 - avatarBlob = undefined; 40 - } 26 + const profile = await client.getProfile(targetDid); 27 + if (profile.ok) profiles.set(did, profile.value); 28 + else console.error(`${targetDid}: failed to load pfp: ${profile.error}`); 41 29 }; 42 30 43 31 $effect(() => { 32 + client; 44 33 loadProfile(did); 45 34 }); 46 35 47 - let color = $derived(generateColorForDid(did)); 36 + const color = $derived(generateColorForDid(did)); 48 37 </script> 49 38 50 39 {#if avatarUrl}
+1 -1
src/components/ProfileView.svelte
··· 30 30 const profile = $derived(profiles.get(actor as Did)); 31 31 const displayName = $derived(profile?.displayName ?? ''); 32 32 const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did)); 33 + let did = $derived(isDid(actor) ? actor : null); 33 34 let loading = $state(true); 34 35 let error = $state<string | null>(null); 35 - let did = $state(isDid(actor) ? actor : null); 36 36 37 37 let userBlocked = $state(false); 38 38 let blockedByTarget = $state(false);
+77
src/lib/post-height.ts
··· 1 + import type { PostWithUri } from './at/fetch'; 2 + 3 + const LINE_HEIGHT = 20; // approx line height in px 4 + const CHARS_PER_LINE = 66; // approx chars per line 5 + const BASE_HEIGHT = 100; // header + footer + padding 6 + const IMAGE_HEIGHT = 300; // constrained height for single images 7 + const IMAGE_GRID_HEIGHT = 200; // height for grid of images 8 + const VIDEO_HEIGHT = 300; // default video height 9 + const LINK_CARD_HEIGHT = 100; // external link card 10 + const QUOTE_FALLBACK_HEIGHT = 100; // fallback for quote 11 + 12 + // idk this is dumb idk i hate virtual lists and shit bleh 13 + export const estimatePostHeight = (post: PostWithUri | undefined | null, depth = 0): number => { 14 + if (!post) return 150; // default fallback if post is missing 15 + if (depth > 1) return 0; // prevent infinite recursion 16 + 17 + const record = post.record; 18 + let height = BASE_HEIGHT; 19 + 20 + // 1. text height 21 + if (record.text) { 22 + const lines = Math.ceil(record.text.length / CHARS_PER_LINE) || 1; 23 + // add a bit more for newlines if present 24 + const newlines = (record.text.match(/\n/g) || []).length; 25 + height += (Math.max(lines, newlines + 1) * LINE_HEIGHT); 26 + } 27 + 28 + // 2. embeds 29 + if (record.embed) { 30 + const embed = record.embed; 31 + 32 + if (embed.$type === 'app.bsky.embed.images') { 33 + // images 34 + if (embed.images.length === 1) { 35 + const aspect = embed.images[0].aspectRatio; 36 + if (aspect) { 37 + // w / h = a => h = w / a 38 + // assuming max width of ~500px in feed 39 + const calculatedHeight = 500 / (aspect.width / aspect.height); 40 + height += Math.min(calculatedHeight, 500); // clamp max height 41 + } else { 42 + height += IMAGE_HEIGHT; 43 + } 44 + } else { 45 + height += IMAGE_GRID_HEIGHT; 46 + } 47 + } else if (embed.$type === 'app.bsky.embed.video') { 48 + // video 49 + const aspect = embed.aspectRatio; 50 + if (aspect) { 51 + const calculatedHeight = 500 / (aspect.width / aspect.height); 52 + height += Math.min(calculatedHeight, 500); 53 + } else { 54 + height += VIDEO_HEIGHT; 55 + } 56 + } else if (embed.$type === 'app.bsky.embed.record') { 57 + // quote post 58 + height += QUOTE_FALLBACK_HEIGHT; 59 + 60 + } else if (embed.$type === 'app.bsky.embed.recordWithMedia') { 61 + // recordWithMedia 62 + // media part 63 + const media = embed.media; 64 + if (media.$type === 'app.bsky.embed.images') { 65 + height += (media.images.length === 1 ? IMAGE_HEIGHT : IMAGE_GRID_HEIGHT); 66 + } else if (media.$type === 'app.bsky.embed.video') { 67 + height += VIDEO_HEIGHT; 68 + } 69 + // quote part 70 + height += QUOTE_FALLBACK_HEIGHT; 71 + } else if (embed.$type === 'app.bsky.embed.external') { 72 + height += LINK_CARD_HEIGHT; 73 + } 74 + } 75 + 76 + return Math.round(height); 77 + };
+4 -4
src/lib/state.svelte.ts
··· 919 919 const feed = followingFeed.get(userDid); 920 920 if (!feed || feed.size === 0) return; 921 921 922 - let minTimestamp = Date.now(); 922 + let minTimestamp = Date.now() * 1000; 923 923 let found = false; 924 924 925 925 for (const uri of feed) { 926 926 const post = getPostFromUri(uri); 927 927 if (post) { 928 - const ts = new Date(post.record.createdAt).getTime(); 928 + const ts = new Date(post.record.createdAt).getTime() * 1000; 929 929 if (ts < minTimestamp) minTimestamp = ts; 930 930 found = true; 931 931 } ··· 948 948 const posts = userFeedTimelines.get(feedUri); 949 949 if (!posts || posts.length === 0) return; 950 950 951 - let minTimestamp = Date.now(); 951 + let minTimestamp = Date.now() * 1000; 952 952 let found = false; 953 953 954 954 for (const uri of posts) { 955 955 const post = getPostFromUri(uri); 956 956 if (post) { 957 - const ts = new Date(post.record.createdAt).getTime(); 957 + const ts = new Date(post.record.createdAt).getTime() * 1000; 958 958 if (ts < minTimestamp) minTimestamp = ts; 959 959 found = true; 960 960 }