Paginate last post in user profile
This commit is contained in:
parent
f40680cbf9
commit
a9b0702c68
3 changed files with 169 additions and 65 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue