Attic is a cozy space with lofty ambitions. attic.social
11
fork

Configure Feed

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

async lazy load bookmark pagination

+81 -17
+8
src/lib/types.ts
··· 1 1 import type { RequestEvent } from "@sveltejs/kit"; 2 + import type { BookmarkData } from "./valibot"; 2 3 3 4 export type AuthEvent = RequestEvent & { 4 5 platform: App.Platform; ··· 9 10 user: NonNullable<App.Locals["user"]>; 10 11 }; 11 12 }; 13 + 14 + export type BookmarkEntity = BookmarkData & { cid: string; uri: string }; 15 + 16 + export type BookmarkLoad = (cursor?: string) => Promise<{ 17 + bookmarks: Array<BookmarkEntity>; 18 + next?: BookmarkLoad; 19 + }>; 12 20 13 21 export const isAuthEvent = (event: RequestEvent): event is AuthEvent => { 14 22 return event.platform?.env !== undefined;
+46 -2
src/routes/bookmarks/[did=did]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { closeDialog, openDialog } from "$lib/dialog.svelte.js"; 3 + import type { BookmarkEntity, BookmarkLoad } from "$lib/types"; 4 + import { onMount } from "svelte"; 5 + import type { Attachment } from "svelte/attachments"; 3 6 import type { EventHandler } from "svelte/elements"; 4 7 import type { PageProps } from "./$types"; 5 8 let { data, form, params }: PageProps = $props(); 6 9 10 + let bookmarks: BookmarkEntity[] = $derived(data.bookmarks); 11 + let nextLoad: BookmarkLoad | undefined = $derived(data.next); 12 + let nextObserver: IntersectionObserver | undefined = $state(undefined); 13 + let nextDisabled = $state(true); 14 + 15 + const nextAttach: Attachment = (element) => { 16 + nextObserver?.observe(element); 17 + }; 18 + 19 + const onNextClick = () => { 20 + if (nextLoad === undefined) return; 21 + nextLoad().then(async (newData) => { 22 + bookmarks = bookmarks.concat(newData.bookmarks); 23 + nextLoad = newData.next; 24 + }); 25 + nextLoad = undefined; 26 + }; 27 + 28 + onMount(() => { 29 + nextDisabled = false; 30 + nextObserver = new IntersectionObserver((entries) => { 31 + for (const entry of entries) { 32 + if (entry.isIntersecting) { 33 + nextObserver?.unobserve(entry.target); 34 + requestAnimationFrame(() => onNextClick()); 35 + break; 36 + } 37 + } 38 + }); 39 + }); 40 + 7 41 const isSelf = $derived(data.user && params.did === data.user.did); 8 42 9 - let editData: null | (typeof data.bookmarks)[number] = $state(null); 43 + let editData: BookmarkEntity | null = $state(null); 10 44 let editDialog: HTMLDialogElement | null = $state(null); 11 45 let createDialog: HTMLDialogElement | null = $state(null); 12 46 let createURL: URL | null = $state(null); ··· 198 232 </button> 199 233 {/if} 200 234 </div> 201 - {#each data.bookmarks as entry (entry.cid)} 235 + {#each bookmarks as entry (entry.cid)} 202 236 <article id={entry.cid} class="Bookmark"> 203 237 <h3> 204 238 <a href={entry.url} rel="noopener noreferrer" target="_blank"> ··· 235 269 {/if} 236 270 </article> 237 271 {/each} 272 + {#if nextLoad} 273 + <button 274 + type="button" 275 + onclick={onNextClick} 276 + disabled={nextDisabled} 277 + {@attach nextAttach} 278 + > 279 + Load more... 280 + </button> 281 + {/if} 238 282 </div>
+27 -15
src/routes/bookmarks/[did=did]/+page.ts
··· 1 + import { dev } from "$app/environment"; 1 2 import { resolvePDS } from "$lib/atproto"; 3 + import type { BookmarkEntity, BookmarkLoad } from "$lib/types"; 2 4 import { 3 - type BookmarkData, 5 + type ActorProfileData, 4 6 parseActorProfile, 5 7 parseBookmark, 6 8 } from "$lib/valibot"; 7 9 import { Client, simpleFetchHandler } from "@atcute/client"; 8 10 import { error } from "@sveltejs/kit"; 9 11 import type { PageLoad } from "./$types"; 12 + 13 + const RECORD_LIMIT = dev ? 5 : 50; 10 14 11 15 export const load: PageLoad = async ({ params, data, fetch }) => { 12 16 const pds = await resolvePDS(params.did); ··· 26 30 if (response.ok === false) { 27 31 error(404); 28 32 } 29 - const bookmarks: Array<BookmarkData & { cid: string; uri: string }> = []; 30 - // [TODO] pagination? 31 - let cursor: string | undefined; 32 - do { 33 + let profile: ActorProfileData; 34 + try { 35 + profile = parseActorProfile(response.data.value); 36 + } catch { 37 + error(404); 38 + } 39 + // First set can be loaded server-side 40 + // Pagination is done client-side for older records 41 + const loadBookmarks: BookmarkLoad = async (cursor) => { 33 42 const response = await rpc.get("com.atproto.repo.listRecords", { 34 43 params: { 35 44 repo: params.did, 36 45 collection: "social.attic.bookmark.entity", 46 + limit: RECORD_LIMIT, 37 47 cursor, 38 48 }, 39 49 }); 40 50 if (response.ok === false) { 41 - break; 51 + return { 52 + bookmarks: [], 53 + }; 42 54 } 43 - cursor = response.data.cursor; 55 + const bookmarks: Array<BookmarkEntity> = []; 44 56 for (const entity of response.data.records) { 45 57 try { 46 58 bookmarks.push({ ··· 52 64 // [TODO] delete invalid data? 53 65 } 54 66 } 55 - } while (cursor); 56 - try { 57 - const profile = parseActorProfile(response.data.value); 67 + const next = response.data.cursor; 58 68 return { 59 - ...data, 60 - profile, 61 69 bookmarks, 70 + next: next ? () => loadBookmarks(next) : undefined, 62 71 }; 63 - } catch { 64 - error(404); 65 - } 72 + }; 73 + return { 74 + ...data, 75 + profile, 76 + ...await loadBookmarks(), 77 + }; 66 78 };