Paginate last post in user profile

This commit is contained in:
dtonon 2026-08-26 15:08:13 +01:00
parent f40680cbf9
commit a9b0702c68
3 changed files with 169 additions and 65 deletions

View file

@ -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<UserPostKind, UserPostPage>;
// 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<string>; // 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<UserPostPage> {
const n = req.n ?? USER_POSTS_PAGE;
const exclude = req.exclude ?? new Set<string>();
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<string, string>();
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<UserPosts> {
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<string, string>();
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;
}

View file

@ -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<string | null>(null);
let posts = $state<UserPosts | null>(null);
let loadingMore = $state<Record<UserPostKind, boolean>>({
roots: false,
replies: false,
});
let initial: Promise<void> | 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<void> {
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<void> {
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;
}
}

View file

@ -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}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">{empty}</p>
{:else}
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
{#each items as p (p.id)}
{#each list.items as p (p.id)}
{@const text = summarize(p.content, 200)}
<li class="py-3">
<a href={postHref(p)} class="group block">
@ -125,6 +133,19 @@
</li>
{/each}
</ul>
{#if !list.done}
{@const busy = userPostsStore.loadingMore[kind]}
<div class="flex justify-center py-6">
<button
onclick={() => loadMoreUserPosts(kind)}
disabled={busy}
aria-busy={busy}
class="rounded border border-neutral-200 px-6 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
{busy ? "Loading…" : "Show more"}
</button>
</div>
{/if}
{/if}
{/snippet}
@ -212,7 +233,7 @@
Loading…
</p>
{:else}
{@render postList(posts.roots, "No discussions yet.")}
{@render postList("roots", posts.roots, "No discussions yet.")}
{/if}
</section>
@ -228,7 +249,7 @@
Loading…
</p>
{:else}
{@render postList(posts.replies, "No replies yet.")}
{@render postList("replies", posts.replies, "No replies yet.")}
{/if}
</section>
</div>