From a9b0702c683a950bb5858b1b6154640b61fadbc1 Mon Sep 17 00:00:00 2001 From: dtonon Date: Wed, 26 Aug 2026 15:08:13 +0100 Subject: [PATCH] Paginate last post in user profile --- src/lib/forum/userPosts.ts | 125 +++++++++++++++++---------- src/lib/userPosts.svelte.ts | 74 +++++++++++++--- src/routes/profile/[id]/+page.svelte | 35 ++++++-- 3 files changed, 169 insertions(+), 65 deletions(-) diff --git a/src/lib/forum/userPosts.ts b/src/lib/forum/userPosts.ts index bbb2aac..bce4392 100644 --- a/src/lib/forum/userPosts.ts +++ b/src/lib/forum/userPosts.ts @@ -10,60 +10,97 @@ export type UserPost = { content: string; }; -export type UserPosts = { roots: UserPost[]; replies: UserPost[] }; +export type UserPostKind = "roots" | "replies"; -export const USER_POSTS_LIMIT = 50; +export type UserPostPage = { + items: UserPost[]; + done: boolean; // no older posts left +}; + +export type UserPosts = Record; + +// Posts per "Show more" step +export const USER_POSTS_PAGE = 20; +const CURSOR_HEADROOM = 10; // boundary events re-read at the inclusive cursor + +export type UserPostPageRequest = { + groupId?: string; // scope to one group (simple mode) + until?: number; // oldest loaded timestamp, inclusive + exclude?: Set; // ids already shown + n?: number; +}; function newestFirst(events: Event[]): Event[] { return [...events].sort((a, b) => b.created_at - a.created_at); } -// A user's latest root posts (kind 11) and replies (kind 1111). `groupId` -// scopes the lookup to one group (simple mode); otherwise every group the -// relay serves the caller counts. +// One page of a user's root posts (kind 11) or replies (kind 1111), newest +// first, older than the cursor. Without `groupId` every group the relay +// serves the caller counts. +export async function fetchUserPostPage( + q: Query, + pubkey: string, + kind: UserPostKind, + req: UserPostPageRequest = {}, +): Promise { + const n = req.n ?? USER_POSTS_PAGE; + const exclude = req.exclude ?? new Set(); + const events = await q({ + kinds: [kind === "roots" ? 11 : 1111], + authors: [pubkey], + ...(req.groupId ? { "#h": [req.groupId] } : {}), + ...(req.until ? { until: req.until } : {}), + limit: n + CURSOR_HEADROOM, + }); + const fresh = newestFirst(events).filter( + (e) => !exclude.has(e.id) && (kind === "roots" || tag(e, "E")), + ); + const slice = fresh.slice(0, n); + // Finished only when everything fresh fits the page and the relay had + // nothing beyond the requested window + const done = fresh.length <= n && events.length < n + CURSOR_HEADROOM; + + // Replies take their title from the root thread + const titles = new Map(); + if (kind === "replies" && slice.length > 0) { + const rootIds = [...new Set(slice.map((e) => tag(e, "E")!))]; + const roots = await q({ kinds: [11], ids: rootIds }); + for (const e of roots) titles.set(e.id, tag(e, "title") ?? "(untitled)"); + } + + const items = slice.map((e) => { + const threadId = kind === "roots" ? e.id : tag(e, "E")!; + return { + id: e.id, + threadId, + title: + kind === "roots" + ? (tag(e, "title") ?? "(untitled)") + : (titles.get(threadId) ?? ""), + groupId: tag(e, "h") ?? req.groupId ?? "", + createdAt: e.created_at, + content: e.content, + }; + }); + return { items, done }; +} + +// The first page of both columns. export async function fetchUserPosts( q: Query, pubkey: string, groupId?: string, ): Promise { - const scope = groupId ? { "#h": [groupId] } : {}; - const [ops, replies] = await Promise.all([ - q({ kinds: [11], authors: [pubkey], ...scope, limit: USER_POSTS_LIMIT }), - q({ kinds: [1111], authors: [pubkey], ...scope, limit: USER_POSTS_LIMIT }), + const [roots, replies] = await Promise.all([ + fetchUserPostPage(q, pubkey, "roots", { groupId }), + fetchUserPostPage(q, pubkey, "replies", { groupId }), ]); - - const rootIds = [ - ...new Set( - replies.map((r) => tag(r, "E")).filter((id): id is string => !!id), - ), - ]; - const titles = new Map(); - if (rootIds.length > 0) { - const roots = await q({ kinds: [11], ids: rootIds }); - for (const e of roots) titles.set(e.id, tag(e, "title") ?? "(untitled)"); - } - - return { - roots: newestFirst(ops).map((e) => ({ - id: e.id, - threadId: e.id, - title: tag(e, "title") ?? "(untitled)", - groupId: tag(e, "h") ?? groupId ?? "", - createdAt: e.created_at, - content: e.content, - })), - replies: newestFirst(replies) - .filter((e) => tag(e, "E")) - .map((e) => { - const threadId = tag(e, "E")!; - return { - id: e.id, - threadId, - title: titles.get(threadId) ?? "", - groupId: tag(e, "h") ?? groupId ?? "", - createdAt: e.created_at, - content: e.content, - }; - }), - }; + return { roots, replies }; +} + +// Cursor for the page after `page`: the oldest timestamp shown, inclusive +// (the ids already shown are excluded on the next read). +export function nextCursor(page: UserPostPage): number | undefined { + const last = page.items[page.items.length - 1]; + return last?.createdAt; } diff --git a/src/lib/userPosts.svelte.ts b/src/lib/userPosts.svelte.ts index 9cdfc7a..9f105bd 100644 --- a/src/lib/userPosts.svelte.ts +++ b/src/lib/userPosts.svelte.ts @@ -1,11 +1,24 @@ import { GROUP_ID, MODE } from "$lib/config"; import { queryForum } from "$lib/relay"; -import { fetchUserPosts, type UserPosts } from "$lib/forum/userPosts"; +import { + fetchUserPosts, + fetchUserPostPage, + nextCursor, + type UserPostKind, + type UserPosts, +} from "$lib/forum/userPosts"; -export type { UserPosts }; +export type { UserPosts, UserPostKind }; + +const scope = () => (MODE === "simple" ? GROUP_ID : undefined); let pubkey = $state(null); let posts = $state(null); +let loadingMore = $state>({ + roots: false, + replies: false, +}); +let initial: Promise | null = null; // Posts of the profile last loaded. Consumers check `pubkey` against the // page's, so a stale result never shows under another user's name. @@ -16,20 +29,53 @@ export const userPostsStore = { get posts() { return posts; }, + get loadingMore() { + return loadingMore; + }, }; -export async function loadUserPosts(pk: string) { +export function loadUserPosts(pk: string): Promise { pubkey = pk; posts = null; - let result: UserPosts = { roots: [], replies: [] }; - try { - result = await fetchUserPosts( - queryForum, - pk, - MODE === "simple" ? GROUP_ID : undefined, - ); - } catch (e) { - console.error("[profile] failed to load posts", e); - } - if (pubkey === pk) posts = result; + loadingMore = { roots: false, replies: false }; + initial = (async () => { + let result: UserPosts = { + roots: { items: [], done: true }, + replies: { items: [], done: true }, + }; + try { + result = await fetchUserPosts(queryForum, pk, scope()); + } catch (e) { + console.error("[profile] failed to load posts", e); + } + if (pubkey === pk) posts = result; + })(); + return initial; +} + +// Append the next page of one column. Waits for the initial load when the +// page still shows the server snapshot. +export async function loadMoreUserPosts(kind: UserPostKind): Promise { + const pk = pubkey; + if (!pk || loadingMore[kind]) return; + loadingMore[kind] = true; + try { + await initial; + const current = posts?.[kind]; + if (!posts || !current || current.done || pubkey !== pk) return; + const next = await fetchUserPostPage(queryForum, pk, kind, { + groupId: scope(), + until: nextCursor(current), + exclude: new Set(current.items.map((p) => p.id)), + }); + if (pubkey !== pk) return; + posts[kind] = { + items: [...current.items, ...next.items], + done: next.done, + }; + } catch (e) { + console.error("[profile] failed to load more posts", e); + } finally { + loadingMore[kind] = false; + } } diff --git a/src/routes/profile/[id]/+page.svelte b/src/routes/profile/[id]/+page.svelte index df07c79..1a1b4fb 100644 --- a/src/routes/profile/[id]/+page.svelte +++ b/src/routes/profile/[id]/+page.svelte @@ -8,8 +8,16 @@ entryFromUser, type ProfileEntry, } from "$lib/profiles.svelte"; - import { userPostsStore, loadUserPosts } from "$lib/userPosts.svelte"; - import type { UserPost } from "$lib/forum/userPosts"; + import { + userPostsStore, + loadUserPosts, + loadMoreUserPosts, + } from "$lib/userPosts.svelte"; + import type { + UserPost, + UserPostKind, + UserPostPage, + } from "$lib/forum/userPosts"; import Meta from "$lib/components/Meta.svelte"; import { summarize } from "$lib/seo"; @@ -85,12 +93,12 @@ {jsonLd} /> -{#snippet postList(items: UserPost[], empty: string)} - {#if items.length === 0} +{#snippet postList(kind: UserPostKind, list: UserPostPage, empty: string)} + {#if list.items.length === 0}

{empty}

{:else} + {#if !list.done} + {@const busy = userPostsStore.loadingMore[kind]} +
+ +
+ {/if} {/if} {/snippet} @@ -212,7 +233,7 @@ Loading…

{:else} - {@render postList(posts.roots, "No discussions yet.")} + {@render postList("roots", posts.roots, "No discussions yet.")} {/if} @@ -228,7 +249,7 @@ Loading…

{:else} - {@render postList(posts.replies, "No replies yet.")} + {@render postList("replies", posts.replies, "No replies yet.")} {/if}