replies timeline only, appview-less bluesky client

feat: infinite loading

Changed files
+111 -42
src
+8
deno.lock
···
"npm:prettier-plugin-tailwindcss@~0.6.14": "0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.40.1___acorn@8.15.0_svelte@5.40.1__acorn@8.15.0",
"npm:prettier@^3.6.2": "3.6.2",
"npm:svelte-check@^4.3.2": "4.3.3_svelte@5.40.1__acorn@8.15.0_typescript@5.9.3",
+
"npm:svelte-infinite@0.5": "0.5.0_svelte@5.40.1__acorn@8.15.0",
"npm:svelte@^5.39.5": "5.40.1_acorn@8.15.0",
"npm:tailwindcss@^4.1.13": "4.1.14",
"npm:typescript-eslint@^8.44.1": "8.46.1_eslint@9.37.0_typescript@5.9.3_@typescript-eslint+parser@8.46.1__eslint@9.37.0__typescript@5.9.3",
···
"svelte"
},
+
"svelte-infinite@0.5.0_svelte@5.40.1__acorn@8.15.0": {
+
"integrity": "sha512-3ZomRQcQzg8VWtnqO4MvPC0Jt3hvh1wmC47t64BcI+8UXTl0FJYVfB7ky4d1NJ3mf/KZZa+hcIZJPnV9cOt8gQ==",
+
"dependencies": [
+
"svelte"
+
]
+
},
"svelte@5.40.1_acorn@8.15.0": {
"integrity": "sha512-0R3t2oiLxJNJb2buz61MNfPdkjeyj2qTCM7TtIv/4ZfF12zD7Ig8iIo+C8febroy+9S4QJ7qfijtearSdO/1ww==",
"dependencies": [
···
"npm:prettier-plugin-tailwindcss@~0.6.14",
"npm:prettier@^3.6.2",
"npm:svelte-check@^4.3.2",
+
"npm:svelte-infinite@0.5",
"npm:svelte@^5.39.5",
"npm:tailwindcss@^4.1.13",
"npm:typescript-eslint@^8.44.1",
+2 -1
package.json
···
"@atcute/lexicons": "^1.2.2",
"@wora/cache-persist": "^2.2.1",
"hash-wasm": "^4.12.0",
-
"lru-cache": "^11.2.2"
+
"lru-cache": "^11.2.2",
+
"svelte-infinite": "^0.5.0"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
+1 -1
src/components/BskyPost.svelte
···
{#if post.ok}
{@const record = post.value}
<div
-
class="rounded-sm border-2 p-3 shadow-lg backdrop-blur-sm transition-all hover:scale-[1.01]"
+
class="rounded-sm border-2 p-3 shadow-lg backdrop-blur-sm transition-all"
style="background: {color}18; border-color: {color}66;"
>
<div class="mb-3 flex items-center gap-1.5">
+3
src/routes/+layout.ts
···
+
export const ssr = false;
+
export const prerender = false;
+
export const csr = true;
+97 -40
src/routes/+page.svelte
···
import { expect } from '$lib/result';
import type { AppBskyFeedPost } from '@atcute/bluesky';
import { SvelteMap } from 'svelte/reactivity';
+
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
+
+
let loaderState = new LoaderState();
+
let scrollContainer = $state<HTMLDivElement>();
let selectedDid = $state<Did | null>(null);
let clients = new SvelteMap<Did, AtpClient>();
···
onMount(async () => {
if ($accounts.length > 0) {
selectedDid = $accounts[0].did;
-
Promise.all($accounts.map(loginAccount)).then(() => fetchTimelines($accounts));
+
Promise.all($accounts.map(loginAccount)).then(() => {
+
loaderState.isFirstLoad = true;
+
loadMore();
+
});
}
});
···
$accounts = $accounts.filter((acc) => acc.did !== did);
clients.delete(did);
posts.delete(did);
+
cursors.delete(did);
selectedDid = $accounts[0]?.did;
};
···
};
let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>();
+
let cursors = new SvelteMap<Did, string | undefined>();
+
const fetchTimeline = async (account: Account) => {
const client = clients.get(account.did);
if (!client) return;
-
const accPosts = await fetchPostsWithBacklinks(client, account.did, undefined, 20);
+
+
const cursor = cursors.get(account.did);
+
const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor, 6);
if (!accPosts.ok) {
-
console.error(`failed to fetch posts for account ${account.handle}: ${accPosts.error}`);
-
return;
+
throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`;
}
+
+
// Update cursor for next fetch
+
cursors.set(account.did, accPosts.value.cursor);
+
const accTimeline = await hydratePosts(client, accPosts.value.posts);
if (!posts.has(account.did)) {
posts.set(account.did, new SvelteMap(accTimeline));
···
const map = posts.get(account.did)!;
for (const [uri, record] of accTimeline) map.set(uri, record);
};
+
const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
+
let loading = $state(false);
+
let loadError = $state('');
+
const loadMore = async () => {
+
if (loading || $accounts.length === 0) return;
+
+
loading = true;
+
try {
+
await fetchTimelines($accounts);
+
loaderState.loaded();
+
} catch (error) {
+
loadError = `${error}`;
+
loaderState.error();
+
} finally {
+
loading = false;
+
}
+
};
+
let reverseChronological = $state(true);
-
let viewOwnPosts = $state(true);
+
let viewOwnPosts = $state(false);
type ThreadPost = {
uri: ResourceUri;
···
return threads;
};
-
// Filtering functions (now much simpler!)
+
// Filtering functions
const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
accounts.some((account) => account.did === post.did);
const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
···
return true;
});
-
// Usage
let threads = $derived(filterThreads(buildThreads(posts), $accounts));
</script>
-
<div class="mx-auto max-w-2xl p-4">
-
<div class="mb-6">
+
<div class="mx-auto flex h-screen max-w-2xl flex-col p-4">
+
<div class="mb-6 flex-shrink-0">
<h1 class="text-3xl font-bold tracking-tight" style="color: {theme.fg};">nucleus</h1>
<div class="mt-1 flex gap-2">
<div class="h-1 w-11 rounded-full" style="background: {theme.accent};"></div>
···
</div>
</div>
-
<div class="space-y-4">
+
<div class="flex-shrink-0 space-y-4">
<div class="flex min-h-16 items-stretch gap-2">
<AccountSelector
accounts={$accounts}
···
class="h-[4px] w-full rounded-full border-0"
style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});"
/>
+
</div>
-
<div class="flex flex-col">
-
{#each threads as thread (thread.rootUri)}
-
<div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5">
-
{#if thread.branchParentPost}
-
{@const post = thread.branchParentPost}
-
<div class="mb-1.5 flex items-center gap-1.5">
-
<span class="text-sm opacity-60" style="color: {theme.fg};"
-
>{reverseChronological ? '↱' : '↳'}</span
-
>
-
<BskyPost
-
mini
-
client={viewClient}
-
identifier={post.did}
-
rkey={post.rkey}
-
record={post.record}
-
/>
-
</div>
-
{/if}
-
{#each thread.posts as post (post.uri)}
-
<div class="mb-1.5">
-
<BskyPost
-
client={viewClient}
-
identifier={post.did}
-
rkey={post.rkey}
-
record={post.record}
-
/>
-
</div>
-
{/each}
+
<div class="mt-4 overflow-y-scroll [scrollbar-width:none]" bind:this={scrollContainer}>
+
<InfiniteLoader
+
{loaderState}
+
triggerLoad={loadMore}
+
intersectionOptions={{ root: scrollContainer }}
+
>
+
{@render threadsView()}
+
{#snippet loading()}
+
<div class="flex justify-center py-4">
+
<div
+
class="h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"
+
style="border-color: {theme.accent} {theme.accent} {theme.accent} transparent;"
+
></div>
+
</div>
+
{/snippet}
+
{#snippet error()}
+
<div class="flex justify-center py-4">
+
<p class="text-sm opacity-80" style="color: {theme.fg};">
+
an error occurred while loading posts: {loadError}
+
</p>
</div>
-
{/each}
-
</div>
+
{/snippet}
+
</InfiniteLoader>
</div>
</div>
+
+
{#snippet threadsView()}
+
{#each threads as thread (thread.rootUri)}
+
<div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5">
+
{#if thread.branchParentPost}
+
{@const post = thread.branchParentPost}
+
<div class="mb-1.5 flex items-center gap-1.5">
+
<span class="text-sm opacity-60" style="color: {theme.fg};"
+
>{reverseChronological ? '↱' : '↳'}</span
+
>
+
<BskyPost
+
mini
+
client={viewClient}
+
identifier={post.did}
+
rkey={post.rkey}
+
record={post.record}
+
/>
+
</div>
+
{/if}
+
{#each thread.posts as post (post.uri)}
+
<div class="mb-1.5">
+
<BskyPost
+
client={viewClient}
+
identifier={post.did}
+
rkey={post.rkey}
+
record={post.record}
+
/>
+
</div>
+
{/each}
+
</div>
+
{/each}
+
{/snippet}