refactor timeline component into reply timeline and feed timeline components
12345<div class="flex justify-center py-4"> <p class="text-xl opacity-80"> all posts seen! <span class="text-2xl">:o</span> </p></div>123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182<script lang="ts"> import BskyPost from './BskyPost.svelte'; import { type State as PostComposerState } from './PostComposer.svelte'; import { AtpClient } from '$lib/at/client.svelte'; import { accounts } from '$lib/accounts'; import type { Did, RecordKey } from '@atcute/lexicons/syntax'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; import { allPosts, viewClient, accountPreferences, feedTimelines, feedCursors, fetchFeed, resetFeed, checkForNewPosts } from '$lib/state.svelte'; import Icon from '@iconify/svelte'; import NotLoggedIn from './NotLoggedIn.svelte'; import { fetchFeedGenerator } from '$lib/at/feeds'; import LoadingSpinner from './LoadingSpinner.svelte'; import EndOfList from './EndOfList.svelte'; import LoadError from './LoadError.svelte';
interface Props { client?: AtpClient | null; postComposerState: PostComposerState; class?: string; selectedFeed: string; }
let { client = null, postComposerState = $bindable(), selectedFeed, class: className = '' }: Props = $props();
const userDid = $derived(client?.user?.did);
let feedServiceDid = $state<string | null>(null); let newPostsAvailable = $state(false);
$effect(() => { selectedFeed; feedServiceDid = null; newPostsAvailable = false; loaderState.reset(); fetchFeedGenerator(client ?? viewClient, selectedFeed).then((meta) => { feedServiceDid = meta?.did ?? null; }); });
$effect(() => { if (!client || !feedServiceDid) return;
const check = async () => { if (!client || !feedServiceDid) return; newPostsAvailable = await checkForNewPosts(client, selectedFeed, feedServiceDid); };
check(); const interval = setInterval(check, 15000);
return () => clearInterval(interval); });
const loaderState = new LoaderState(); let scrollContainer = $state<HTMLDivElement>(); let loading = $state(false); let loadError = $state('');
export const clearFeed = () => { if (!userDid) return; scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }); newPostsAvailable = false; resetFeed(userDid, selectedFeed); loaderState.reset(); loadMore(); };
const feedPosts = $derived.by(() => { if (!userDid) return []; const uris = feedTimelines.get(userDid)?.get(selectedFeed) ?? []; return uris .map((uri) => { const did = uri.split('/')[2] as Did; return allPosts.get(did)?.get(uri); }) .filter((p): p is NonNullable<typeof p> => p !== undefined); });
const loadMore = async () => { if (loading || !client || !userDid || !feedServiceDid) return;
loading = true; loaderState.status = 'LOADING';
try { const result = await fetchFeed(client, selectedFeed, feedServiceDid); loaderState.loaded(); if (result?.end) loaderState.complete(); } catch (error) { loadError = `${error}`; loaderState.error(); loading = false; return; }
loading = false; };
$effect(() => { const isEmpty = feedPosts.length === 0; if (isEmpty && !loading && userDid && feedServiceDid) { const cursor = feedCursors.get(userDid)?.get(selectedFeed); if (!cursor?.end) loadMore(); } });</script>
{#snippet feedPostsView()} {#each feedPosts as post, i (post.uri)} {@const uriParts = post.uri.split('/')} {@const postDid = uriParts[2] as Did} {@const postRkey = uriParts[4] as RecordKey} <div class="mb-1.5"> <BskyPost client={client!} did={postDid} rkey={postRkey} data={post} onQuote={(p) => { postComposerState.focus = 'focused'; postComposerState.quoting = p; }} onReply={(p) => { postComposerState.focus = 'focused'; postComposerState.replying = p; }} /> </div> {#if i < feedPosts.length - 1} <div class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" ></div> {/if} {/each}{/snippet}
<div class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" bind:this={scrollContainer}> {#if newPostsAvailable} <div class="sticky top-2 z-20 mb-4 flex w-full justify-center"> <button class="flex action-button items-center gap-2 bg-(--nucleus-bg) hover:scale-115! enabled:hover:bg-(--nucleus-bg)!" onclick={clearFeed} > <Icon icon="heroicons:arrow-up-16-solid" width="20" /> load new posts </button> </div> {/if} {#if userDid || $accounts.length > 0} <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> {@render feedPostsView()} {#snippet noData()} <EndOfList /> {/snippet} {#snippet loading()} <LoadingSpinner /> {/snippet} {#snippet error()} <LoadError error={loadError} onRetry={loadMore} /> {/snippet} </InfiniteLoader> {:else} <NotLoggedIn /> {/if}</div>12345678910111213141516171819202122<script lang="ts"> import Icon from '@iconify/svelte';
interface Props { error: string; onRetry: () => void; }
let { error, onRetry }: Props = $props();</script>
<div class="flex flex-col gap-4 py-4"> <p class="text-xl opacity-80"> <span class="text-4xl">x_x</span> <br /> {error} </p> <div> <button class="flex action-button items-center gap-2" onclick={onRetry}> <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again </button> </div></div>123456<div class="flex justify-center"> <div class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" ></div></div>123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218<script lang="ts"> import BskyPost from './BskyPost.svelte'; import { type State as PostComposerState } from './PostComposer.svelte'; import { AtpClient } from '$lib/at/client.svelte'; import { accounts } from '$lib/accounts'; import { type ResourceUri } from '@atcute/lexicons'; import { SvelteSet } from 'svelte/reactivity'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; import { postCursors, fetchTimeline, allPosts, timelines, fetchInteractionsToTimelineEnd, accountPreferences } from '$lib/state.svelte'; import Icon from '@iconify/svelte'; import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; import type { Did } from '@atcute/lexicons/syntax'; import NotLoggedIn from './NotLoggedIn.svelte'; import LoadingSpinner from './LoadingSpinner.svelte'; import EndOfList from './EndOfList.svelte'; import LoadError from './LoadError.svelte';
interface Props { client?: AtpClient | null; targetDid?: Did; postComposerState: PostComposerState; class?: string; showReplies?: boolean; }
let { client = null, targetDid = undefined, showReplies = true, postComposerState = $bindable(), class: className = '' }: Props = $props();
let reverseChronological = $state(true); let viewOwnPosts = $state(true); const expandedThreads = new SvelteSet<ResourceUri>();
const userDid = $derived(client?.user?.did); const did = $derived(targetDid ?? userDid);
const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); const mutes = $derived(currentPrefs?.mutes ?? []);
const threads = $derived( filterThreads( did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts, mutes) : [], $accounts, { viewOwnPosts } ) );
const loaderState = new LoaderState(); let scrollContainer = $state<HTMLDivElement>(); let loading = $state(false); let loadError = $state('');
let fetchingInteractions = $state(false); let scheduledFetchInteractions = $state(false);
const loadMore = async () => { if (loading || !client || !userDid || !did) return;
loading = true; loaderState.status = 'LOADING';
try { await fetchTimeline(client, did, 7, showReplies, { downwards: userDid === did ? 'sameAuthor' : 'none' }); if (client.user && userDid) { if (!fetchingInteractions) { scheduledFetchInteractions = false; fetchingInteractions = true; await fetchInteractionsToTimelineEnd(client, userDid, did); fetchingInteractions = false; } else { scheduledFetchInteractions = true; } } loaderState.loaded(); const cursor = postCursors.get(did); if (cursor?.end) loaderState.complete(); } catch (error) { loadError = `${error}`; loaderState.error(); loading = false; return; }
loading = false; };
$effect(() => { const isEmpty = threads.length === 0; if (isEmpty && !loading && userDid && did) { const cursor = postCursors.get(did); if (!cursor?.end) loadMore(); } });
$effect(() => { did; loaderState.reset(); });
// we want to load interactions when changing logged in user // only on timelines that arent logged in users, because those are already // loaded by loadMore $effect(() => { if (client && scheduledFetchInteractions && userDid && did && did !== userDid) { if (!fetchingInteractions) { scheduledFetchInteractions = false; fetchingInteractions = true; fetchInteractionsToTimelineEnd(client, userDid, did).finally( () => (fetchingInteractions = false) ); } else { scheduledFetchInteractions = true; } } });</script>
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} <span class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" > <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> <BskyPost mini client={client!} {...post} /> </span>{/snippet}
{#snippet threadsView()} {#each threads as thread, i (thread.rootUri)} <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> {#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} <div class="mb-1.5"> <BskyPost client={client!} onQuote={(post) => { postComposerState.focus = 'focused'; postComposerState.quoting = post; }} onReply={(post) => { postComposerState.focus = 'focused'; postComposerState.replying = post; }} {...post} /> </div> {:else if mini} {#if idx === 1} {@render replyPost(post, !reverseChronological)} <button 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)" onclick={() => expandedThreads.add(thread.rootUri)} > <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> <Icon class="shrink-0" icon={reverseChronological ? 'heroicons:bars-arrow-up-solid' : 'heroicons:bars-arrow-down-solid'} width={32} /><span class="shrink-0 pb-1">view full chain</span> <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> </button> {:else if idx === thread.posts.length - 3} {@render replyPost(post)} {/if} {/if} {/each} </div> {#if i < threads.length - 1} <div class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" ></div> {/if} {/each}{/snippet}
<div class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" bind:this={scrollContainer}> {#if did || $accounts.length > 0} <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> {@render threadsView()} {#snippet noData()} <EndOfList /> {/snippet} {#snippet loading()} <LoadingSpinner /> {/snippet} {#snippet error()} <LoadError error={loadError} onRetry={loadMore} /> {/snippet} </InfiniteLoader> {:else} <NotLoggedIn /> {/if}</div>1223567891011121314151617181920212223242526274567891011 unmodified lines222324254748492627515253545556575859606162636465666768697071727374757677787980818228848586878889293091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418531321881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383334353637383940414243<script lang="ts"> import BskyPost from './BskyPost.svelte'; import { type State as PostComposerState } from './PostComposer.svelte'; import { AtpClient } from '$lib/at/client.svelte'; import { accounts } from '$lib/accounts'; import { type ResourceUri } from '@atcute/lexicons'; import { SvelteSet } from 'svelte/reactivity'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; import { postCursors, fetchTimeline, allPosts, timelines, viewClient, fetchInteractionsToTimelineEnd, accountPreferences, feedTimelines, feedCursors, fetchFeed, resetFeed, checkForNewPosts } from '$lib/state.svelte'; import Icon from '@iconify/svelte'; import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; import type { Did, RecordKey } from '@atcute/lexicons/syntax'; import NotLoggedIn from './NotLoggedIn.svelte'; import { fetchFeedGenerator } from '$lib/at/feeds'; import type { Did } from '@atcute/lexicons/syntax'; import FeedTimelineView from './FeedTimelineView.svelte'; import ReplyTimelineView from './ReplyTimelineView.svelte';
interface Props { client?: AtpClient | null; targetDid?: Did;11 unmodified lines selectedFeed = $bindable(null), class: className = '' }: Props = $props();
let reverseChronological = $state(true); let viewOwnPosts = $state(true); const expandedThreads = new SvelteSet<ResourceUri>(); let feedView = $state<{ clearFeed: () => void }>();
const userDid = $derived(client?.user?.did); const did = $derived(targetDid ?? userDid);
const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); const mutes = $derived(currentPrefs?.mutes ?? []);
let feedServiceDid = $state<string | null>(null); let newPostsAvailable = $state(false);
$effect(() => { if (selectedFeed) { fetchFeedGenerator(client ?? viewClient, selectedFeed).then((meta) => { feedServiceDid = meta?.did ?? null; }); } else { feedServiceDid = null; }
newPostsAvailable = false; if (!client || !selectedFeed) return;
const check = async () => { if (!client || !selectedFeed || !feedServiceDid) return; newPostsAvailable = await checkForNewPosts(client, selectedFeed, feedServiceDid); };
check(); const interval = setInterval(check, 15000);
return () => clearInterval(interval); });
export const clearFeed = () => { if (!selectedFeed || !userDid) return; scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }); newPostsAvailable = false; resetFeed(userDid, selectedFeed); loaderState.reset(); loadMore(); feedView?.clearFeed(); };
const threads = $derived( filterThreads( did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts, mutes) : [], $accounts, { viewOwnPosts } ) );
const feedPosts = $derived.by(() => { if (!selectedFeed || !userDid) return []; const uris = feedTimelines.get(userDid)?.get(selectedFeed) ?? []; return uris .map((uri) => { const did = uri.split('/')[2] as Did; return allPosts.get(did)?.get(uri); }) .filter((p): p is NonNullable<typeof p> => p !== undefined); });
const loaderState = new LoaderState(); let scrollContainer = $state<HTMLDivElement>(); let loading = $state(false); let loadError = $state('');
const loadMore = async () => { if (loading || !client || !userDid) return;
loading = true; loaderState.status = 'LOADING';
try { if (selectedFeed && feedServiceDid) { const result = await fetchFeed(client, selectedFeed, feedServiceDid); loaderState.loaded(); if (result?.end) loaderState.complete(); } else if (did) { await fetchTimeline(client, did, 7, showReplies, { downwards: userDid === did ? 'sameAuthor' : 'none' }); if (client.user && userDid) { if (!fetchingInteractions) { scheduledFetchInteractions = false; fetchingInteractions = true; await fetchInteractionsToTimelineEnd(client, userDid, did); fetchingInteractions = false; } else { scheduledFetchInteractions = true; } } loaderState.loaded(); const cursor = postCursors.get(did); if (cursor?.end) loaderState.complete(); } } catch (error) { loadError = `${error}`; loaderState.error(); loading = false; return; }
loading = false; };
$effect(() => { const isEmpty = selectedFeed ? feedPosts.length === 0 : threads.length === 0; if (isEmpty && !loading && userDid) { if (selectedFeed) { const cursor = feedCursors.get(userDid)?.get(selectedFeed); if (!cursor?.end) loadMore(); } else if (did) { const cursor = postCursors.get(did); if (!cursor?.end) loadMore(); } } });
let fetchingInteractions = $state(false); let scheduledFetchInteractions = $state(false); // we want to load interactions when changing logged in user // only on timelines that arent logged in users, because those are already // loaded by loadMore $effect(() => { if (client && scheduledFetchInteractions && userDid && did && did !== userDid) { if (!fetchingInteractions) { scheduledFetchInteractions = false; fetchingInteractions = true; fetchInteractionsToTimelineEnd(client, userDid, did).finally( () => (fetchingInteractions = false) ); } else { scheduledFetchInteractions = true; } } });</script>
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} <span class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" > <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> <BskyPost mini client={client!} {...post} /> </span>{/snippet}
{#snippet threadsView()} {#each threads as thread, i (thread.rootUri)} <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> {#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} <div class="mb-1.5"> <BskyPost client={client!} onQuote={(post) => { postComposerState.focus = 'focused'; postComposerState.quoting = post; }} onReply={(post) => { postComposerState.focus = 'focused'; postComposerState.replying = post; }} {...post} /> </div> {:else if mini} {#if idx === 1} {@render replyPost(post, !reverseChronological)} <button 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)" onclick={() => expandedThreads.add(thread.rootUri)} > <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> <Icon class="shrink-0" icon={reverseChronological ? 'heroicons:bars-arrow-up-solid' : 'heroicons:bars-arrow-down-solid'} width={32} /><span class="shrink-0 pb-1">view full chain</span> <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> </button> {:else if idx === thread.posts.length - 3} {@render replyPost(post)} {/if} {/if} {/each} </div> {#if i < threads.length - 1} <div class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" ></div> {/if} {/each}{/snippet}
{#snippet feedPostsView()} {#each feedPosts as post, i (post.uri)} {@const uriParts = post.uri.split('/')} {@const postDid = uriParts[2] as Did} {@const postRkey = uriParts[4] as RecordKey} <div class="mb-1.5"> <BskyPost client={client!} did={postDid} rkey={postRkey} data={post} onQuote={(p) => { postComposerState.focus = 'focused'; postComposerState.quoting = p; }} onReply={(p) => { postComposerState.focus = 'focused'; postComposerState.replying = p; }} /> </div> {#if i < feedPosts.length - 1} <div class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" ></div> {/if} {/each}{/snippet}
<div class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" bind:this={scrollContainer}> {#if newPostsAvailable} <div class="sticky top-2 z-20 mb-4 flex w-full justify-center"> <button class="flex action-button items-center gap-2 bg-(--nucleus-bg) hover:scale-115! enabled:hover:bg-(--nucleus-bg)!" onclick={clearFeed} > <Icon icon="heroicons:arrow-up-16-solid" width="20" /> load new posts </button> </div> {/if} {#if targetDid || $accounts.length > 0} <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> {#if selectedFeed} {@render feedPostsView()} {:else} {@render threadsView()} {/if} {#snippet noData()} <div class="flex justify-center py-4"> <p class="text-xl opacity-80"> all posts seen! <span class="text-2xl">:o</span> </p> </div> {/snippet} {#snippet loading()} <div class="flex justify-center"> <div class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" ></div> </div> {/snippet} {#snippet error()} <div class="flex flex-col gap-4 py-4"> <p class="text-xl opacity-80"> <span class="text-4xl">x_x</span> <br /> {loadError} </p> <div> <button class="flex action-button items-center gap-2" onclick={loadMore}> <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again </button> </div> </div> {/snippet} </InfiniteLoader> {:else} <NotLoggedIn /> {/if}</div>{#if selectedFeed} <FeedTimelineView {client} {selectedFeed} bind:postComposerState class={className} bind:this={feedView} />{:else} <ReplyTimelineView {client} {targetDid} {showReplies} bind:postComposerState class={className} />{/if}