From 5e834526fc9ccc446a8b3e2a02c1fe0e6cd0fee5 Mon Sep 17 00:00:00 2001 From: dawn <90008@gaze.systems> Date: Fri, 23 Jan 2026 10:24:26 +0300 Subject: [PATCH] virtual list timeline, kinda ass but works for now --- src/components/BskyPost.svelte | 26 +-- src/components/Dropdown.svelte | 9 +- src/components/FeedTimelineView.svelte | 152 ++++++++----- src/components/GenericTimelineView.svelte | 247 +++++++++++++++------- src/components/PhotoSwipeGallery.svelte | 2 +- src/components/ProfileInfo.svelte | 14 +- src/components/ProfilePicture.svelte | 27 +-- src/components/ProfileView.svelte | 2 +- src/lib/post-height.ts | 77 +++++++ src/lib/state.svelte.ts | 8 +- 10 files changed, 389 insertions(+), 175 deletions(-) create mode 100644 src/lib/post-height.ts diff --git a/src/components/BskyPost.svelte b/src/components/BskyPost.svelte index 833521f..07c6618 100644 --- a/src/components/BskyPost.svelte +++ b/src/components/BskyPost.svelte @@ -8,7 +8,7 @@ type RecordKey, type ResourceUri } from '@atcute/lexicons'; - import { expect, ok } from '$lib/result'; + import { err, expect, ok, type Result } from '$lib/result'; import { accounts, generateColorForDid } from '$lib/accounts'; import ProfilePicture from './ProfilePicture.svelte'; import BskyPost from './BskyPost.svelte'; @@ -87,25 +87,27 @@ ); const showAsMuted = $derived(isMuted && !expandDisallowed); - let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); + const handle = $derived(handles.get(did) ?? 'handle.invalid'); onMount(() => { resolveDidDoc(did).then((res) => { - if (res.ok) { - handle = res.value.handle; - handles.set(did, handle); - } + if (res.ok) handles.set(did, res.value.handle); return res; }); }); - const post = data - ? Promise.resolve(ok(data)) - : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); - let profile: AppBskyActorProfile.Main | null = $state(profiles.get(did) ?? null); + const profile = $derived(profiles.get(did)); onMount(async () => { const p = await client.getProfile(did); if (!p.ok) return; - profile = p.value; - profiles.set(did, profile); + profiles.set(did, p.value); + }); + + // svelte-ignore state_referenced_locally + let post: Result = $state(data ? ok(data) : err("post couldn't be loaded")); + $effect(() => { + client.getRecord(AppBskyFeedPost.mainSchema, did, rkey).then((res) => { + if (!res.ok) return; + post = res; + }); }); const postId = $derived( diff --git a/src/components/Dropdown.svelte b/src/components/Dropdown.svelte index cb8dc41..ae91146 100644 --- a/src/components/Dropdown.svelte +++ b/src/components/Dropdown.svelte @@ -48,13 +48,14 @@ let openTimer: ReturnType; const updatePosition = async () => { - const { x, y } = await computePosition(triggerRef!, contentRef!, { + if (!triggerRef || !contentRef) return; + const { x, y } = await computePosition(triggerRef, contentRef, { placement, middleware: [offset(offsetDistance), flip(), shift({ padding: 8 })], strategy: 'fixed' }); - Object.assign(contentRef!.style, { + Object.assign(contentRef.style, { left: `${x}px`, top: `${y}px` }); @@ -129,8 +130,8 @@ }); $effect(() => { - if (isOpen) { - cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition); + if (isOpen && triggerRef && contentRef) { + cleanup = autoUpdate(triggerRef, contentRef, updatePosition); } else if (cleanup) { cleanup(); cleanup = null; diff --git a/src/components/FeedTimelineView.svelte b/src/components/FeedTimelineView.svelte index b1845ad..f2e971d 100644 --- a/src/components/FeedTimelineView.svelte +++ b/src/components/FeedTimelineView.svelte @@ -2,9 +2,12 @@ import BskyPost from './BskyPost.svelte'; import { type State as PostComposerState } from './PostComposer.svelte'; import { AtpClient } from '$lib/at/client.svelte'; + import { estimatePostHeight } from '$lib/post-height'; + import { accounts } from '$lib/accounts'; import type { Did, RecordKey } from '@atcute/lexicons/syntax'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; + import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; import { allPosts, viewClient, @@ -41,13 +44,20 @@ let feedServiceDid = $state(null); let newPostsAvailable = $state(false); + let virtualList = $state(null); + let scrollToIndex = $state(undefined); + + const viewKey = $derived(`${userDid ?? 'anon'}-${selectedFeed}`); $effect(() => { - selectedFeed; + viewKey; // dependency feedServiceDid = null; newPostsAvailable = false; displayCount = 15; + measuredHeights = []; loaderState.reset(); + scrollToIndex = undefined; + fetchFeedGenerator(client ?? viewClient, selectedFeed).then((meta) => { feedServiceDid = meta?.did ?? null; }); @@ -68,7 +78,6 @@ }); const loaderState = new LoaderState(); - let scrollContainer = $state(); let loading = $state(false); let loadError = $state(''); @@ -77,9 +86,11 @@ export const clearFeed = () => { if (!userDid) return; - scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }); + scrollToIndex = 0; + setTimeout(() => (scrollToIndex = undefined), 100); newPostsAvailable = false; displayCount = 15; + measuredHeights = []; resetFeed(userDid, selectedFeed); loaderState.reset(); loadMore(); @@ -98,7 +109,20 @@ .filter((p): p is NonNullable => p !== undefined); }); - const renderedPosts = $derived(feedPosts.slice(0, displayCount)); + let measuredHeights: number[] = $state([]); + const itemHeights = $derived.by(() => { + const heights = measuredHeights.slice(0, feedPosts.length); + while (heights.length < feedPosts.length) { + heights.push(estimatePostHeight(feedPosts[heights.length])); + } + return heights; + }); + + const averageHeight = $derived.by(() => { + if (measuredHeights.length === 0) return 150; + const sum = measuredHeights.reduce((a, b) => a + b, 0); + return sum / measuredHeights.length; + }); const loadMore = async () => { if (loading || !client || !userDid || !feedServiceDid) return; @@ -144,55 +168,85 @@ if (!cursor?.end) loadMore(); } }); + + const renderItem = (index: number) => { + const post = feedPosts[index]; + if (!post) return { post: null, postDid: null, postRkey: null }; + const uriParts = post.uri.split('/'); + const postDid = uriParts[2] as Did; + const postRkey = uriParts[4] as RecordKey; + return { post, postDid, postRkey }; + }; -{#snippet feedPostsView()} - {#each renderedPosts as post, i (post.uri)} - {@const uriParts = post.uri.split('/')} - {@const postDid = uriParts[2] as Did} - {@const postRkey = uriParts[4] as RecordKey} -
- { - postComposerState.focus = 'focused'; - postComposerState.quoting = p; - }} - onReply={(p) => { - postComposerState.focus = 'focused'; - postComposerState.replying = p; - }} - /> -
- {#if i < renderedPosts.length - 1} -
- {/if} - {/each} -{/snippet} - -
+
{#if userDid || $accounts.length > 0} - - {@render feedPostsView()} - {#snippet noData()} - - {/snippet} - {#snippet loading()} - - {/snippet} - {#snippet error()} - - {/snippet} - + {#key viewKey} + 0 ? scrollToIndex : undefined} + > + {#snippet item({ index, style }: { index: number; style: string })} + {@const { post, postDid, postRkey } = renderItem(index)} +
{ + // we need to return this so the bind works + return measuredHeights[index] ?? estimatePostHeight(post); + }, + (h) => { + // update the height + if (measuredHeights[index] !== h) measuredHeights[index] = h; + } + } + > +
+ {#if post && postDid && postRkey} + { + postComposerState.focus = 'focused'; + postComposerState.quoting = p; + }} + onReply={(p) => { + postComposerState.focus = 'focused'; + postComposerState.replying = p; + }} + /> + {/if} +
+
+ {/snippet} + + {#snippet footer()} +
+ +
+ {#snippet noData()} + + {/snippet} + {#snippet loading()} + + {/snippet} + {#snippet error()} + + {/snippet} +
+
+ {/snippet} +
+ {/key} {:else} {/if} diff --git a/src/components/GenericTimelineView.svelte b/src/components/GenericTimelineView.svelte index 3c60a60..fe55ead 100644 --- a/src/components/GenericTimelineView.svelte +++ b/src/components/GenericTimelineView.svelte @@ -5,6 +5,7 @@ import { type ResourceUri } from '@atcute/lexicons'; import { SvelteSet } from 'svelte/reactivity'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; + import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; import Icon from '@iconify/svelte'; import { type ThreadPost, type Thread } from '$lib/thread'; import NotLoggedIn from './NotLoggedIn.svelte'; @@ -14,6 +15,7 @@ import LoadNewPosts from './LoadNewPosts.svelte'; import { onMount } from 'svelte'; import { initialDone } from '$lib/state.svelte'; + import { estimatePostHeight } from '$lib/post-height'; interface Props { client?: AtpClient | null; @@ -46,6 +48,8 @@ let isAtTop = $state(true); let boundaryTime = $state(null); + let virtualList = $state(null); + let scrollToIndex = $state(undefined); const visibleThreads = $derived.by(() => { if (boundaryTime === null) return threads; @@ -56,9 +60,11 @@ $effect(() => { timelineId; displayCount = 15; + measuredHeights = []; + scrollToIndex = undefined; }); - const renderedThreads = $derived(visibleThreads.slice(0, displayCount)); + // const renderedThreads = $derived(visibleThreads.slice(0, displayCount)); $effect(() => { if (threads.length > 0) { @@ -69,16 +75,64 @@ const showNewPosts = () => { boundaryTime = threads[0]?.newestTime ?? null; - window.scrollTo({ top: 0, behavior: 'instant' }); + // @ts-ignore + virtualList?.scrollToIndex(0); isAtTop = true; }; - const onScroll = () => (isAtTop = window.scrollY < 300); + const onScroll = (event: { event: Event; offset: number }) => { + const { offset } = event; + isAtTop = offset < 300; + }; const loaderState = new LoaderState(); let loading = $state(false); let loadError = $state(''); + // helper to estimate thread height + const estimateThreadHeight = (thread: Thread) => { + let height = 0; + if (thread.branchParentPost) height += 20; // approx height for mini parent + + const isExpanded = expandedThreads.has(thread.rootUri); + const len = thread.posts.length; + const isLong = len > 4; + + for (let i = 0; i < len; i++) { + const post = thread.posts[i]; + const mini = !isExpanded && isLong && i > 0 && i < len - 2; + + if (!mini) { + // normal post + height += estimatePostHeight(post.data); + if (i < len - 1) height += 6; // mb-1.5 + } else { + // mini / collapsed + if (i === 1) height += 88; // "view full chain" button + reply post + // other mini posts are hidden or collapsed + } + } + + height += 28; // for the thread spacer + + return height; + }; + + let measuredHeights: number[] = $state([]); + const itemHeights = $derived.by(() => { + const heights = measuredHeights.slice(0, visibleThreads.length); + while (heights.length < visibleThreads.length) { + heights.push(estimateThreadHeight(visibleThreads[heights.length])); + } + return heights; + }); + + const averageHeight = $derived.by(() => { + if (measuredHeights.length === 0) return 300; + const sum = measuredHeights.reduce((a, b) => a + b, 0); + return sum / measuredHeights.length; + }); + const loadMore = async () => { if (loading || !shouldLoad) return; @@ -116,9 +170,11 @@ loaderState.loaded(); } }); - - + const renderItem = (index: number) => { + return visibleThreads[index]; + }; + {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} {/snippet} -{#snippet threadsView()} - {#each renderedThreads as thread, i (thread.rootUri)} -
- {#if thread.branchParentPost} - {@render replyPost(thread.branchParentPost)} - {/if} - {#each thread.posts as post, idx (post.data.uri)} - {@const mini = - !expandedThreads.has(thread.rootUri) && - thread.posts.length > 4 && - idx > 0 && - idx < thread.posts.length - 2} - {#if !mini} -
- { - postComposerState.focus = 'focused'; - postComposerState.quoting = post; - }} - onReply={(post) => { - postComposerState.focus = 'focused'; - postComposerState.replying = post; - }} - {...post} - /> -
- {:else if mini} - {#if idx === 1} - {@render replyPost(post, !reverseChronological)} - - {:else if idx === thread.posts.length - 3} - {@render replyPost(post)} - {/if} - {/if} - {/each} -
- {#if i < renderedThreads.length - 1} -
- {/if} - {/each} -{/snippet} - -
+
0 && boundaryTime !== null && threads[0].newestTime > boundaryTime} onclick={showNewPosts} /> {#if isLoggedIn} - - {@render threadsView()} - {#snippet noData()} - - {/snippet} - {#snippet loading()} - - {/snippet} - {#snippet error()} - - {/snippet} - + {#key timelineId} + 0 ? scrollToIndex : undefined} + > + {#snippet item({ index, style }: { index: number; style: string })} + {@const thread = renderItem(index)} +
{ + // we need to return this so the bind works + return measuredHeights[index] ?? estimateThreadHeight(thread); + }, + (h) => { + // update the height + if (measuredHeights[index] !== h) measuredHeights[index] = h; + } + } + > + {#if thread} +
+ {#if thread.branchParentPost} + {@render replyPost(thread.branchParentPost)} + {/if} + {#each thread.posts as post, idx (post.data.uri)} + {@const mini = + !expandedThreads.has(thread.rootUri) && + thread.posts.length > 4 && + idx > 0 && + idx < thread.posts.length - 2} + {#if !mini} +
+ { + postComposerState.focus = 'focused'; + postComposerState.quoting = post; + }} + onReply={(post) => { + postComposerState.focus = 'focused'; + postComposerState.replying = post; + }} + {...post} + /> +
+ {:else if mini} + {#if idx === 1} + {@render replyPost(post, !reverseChronological)} + + {:else if idx === thread.posts.length - 3} + {@render replyPost(post)} + {/if} + {/if} + {/each} +
+ {#if index < visibleThreads.length - 1} +
+ {/if} + {/if} +
+ {/snippet} + + {#snippet footer()} +
+ +
+ {#snippet noData()} + + {/snippet} + {#snippet loading()} + + {/snippet} + {#snippet error()} + + {/snippet} +
+
+ {/snippet} +
+ {/key} {:else} {/if} diff --git a/src/components/PhotoSwipeGallery.svelte b/src/components/PhotoSwipeGallery.svelte index 046b6db..fed5f7b 100644 --- a/src/components/PhotoSwipeGallery.svelte +++ b/src/components/PhotoSwipeGallery.svelte @@ -62,7 +62,7 @@