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;
|
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[] {
|
function newestFirst(events: Event[]): Event[] {
|
||||||
return [...events].sort((a, b) => b.created_at - a.created_at);
|
return [...events].sort((a, b) => b.created_at - a.created_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A user's latest root posts (kind 11) and replies (kind 1111). `groupId`
|
// One page of a user's root posts (kind 11) or replies (kind 1111), newest
|
||||||
// scopes the lookup to one group (simple mode); otherwise every group the
|
// first, older than the cursor. Without `groupId` every group the relay
|
||||||
// relay serves the caller counts.
|
// 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(
|
export async function fetchUserPosts(
|
||||||
q: Query,
|
q: Query,
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
groupId?: string,
|
groupId?: string,
|
||||||
): Promise<UserPosts> {
|
): Promise<UserPosts> {
|
||||||
const scope = groupId ? { "#h": [groupId] } : {};
|
const [roots, replies] = await Promise.all([
|
||||||
const [ops, replies] = await Promise.all([
|
fetchUserPostPage(q, pubkey, "roots", { groupId }),
|
||||||
q({ kinds: [11], authors: [pubkey], ...scope, limit: USER_POSTS_LIMIT }),
|
fetchUserPostPage(q, pubkey, "replies", { groupId }),
|
||||||
q({ kinds: [1111], authors: [pubkey], ...scope, limit: USER_POSTS_LIMIT }),
|
|
||||||
]);
|
]);
|
||||||
|
return { roots, replies };
|
||||||
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 {
|
// Cursor for the page after `page`: the oldest timestamp shown, inclusive
|
||||||
roots: newestFirst(ops).map((e) => ({
|
// (the ids already shown are excluded on the next read).
|
||||||
id: e.id,
|
export function nextCursor(page: UserPostPage): number | undefined {
|
||||||
threadId: e.id,
|
const last = page.items[page.items.length - 1];
|
||||||
title: tag(e, "title") ?? "(untitled)",
|
return last?.createdAt;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,24 @@
|
||||||
import { GROUP_ID, MODE } from "$lib/config";
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
import { queryForum } from "$lib/relay";
|
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 pubkey = $state<string | null>(null);
|
||||||
let posts = $state<UserPosts | 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
|
// Posts of the profile last loaded. Consumers check `pubkey` against the
|
||||||
// page's, so a stale result never shows under another user's name.
|
// page's, so a stale result never shows under another user's name.
|
||||||
|
|
@ -16,20 +29,53 @@ export const userPostsStore = {
|
||||||
get posts() {
|
get posts() {
|
||||||
return posts;
|
return posts;
|
||||||
},
|
},
|
||||||
|
get loadingMore() {
|
||||||
|
return loadingMore;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadUserPosts(pk: string) {
|
export function loadUserPosts(pk: string): Promise<void> {
|
||||||
pubkey = pk;
|
pubkey = pk;
|
||||||
posts = null;
|
posts = null;
|
||||||
let result: UserPosts = { roots: [], replies: [] };
|
loadingMore = { roots: false, replies: false };
|
||||||
|
initial = (async () => {
|
||||||
|
let result: UserPosts = {
|
||||||
|
roots: { items: [], done: true },
|
||||||
|
replies: { items: [], done: true },
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
result = await fetchUserPosts(
|
result = await fetchUserPosts(queryForum, pk, scope());
|
||||||
queryForum,
|
|
||||||
pk,
|
|
||||||
MODE === "simple" ? GROUP_ID : undefined,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[profile] failed to load posts", e);
|
console.error("[profile] failed to load posts", e);
|
||||||
}
|
}
|
||||||
if (pubkey === pk) posts = result;
|
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,
|
entryFromUser,
|
||||||
type ProfileEntry,
|
type ProfileEntry,
|
||||||
} from "$lib/profiles.svelte";
|
} from "$lib/profiles.svelte";
|
||||||
import { userPostsStore, loadUserPosts } from "$lib/userPosts.svelte";
|
import {
|
||||||
import type { UserPost } from "$lib/forum/userPosts";
|
userPostsStore,
|
||||||
|
loadUserPosts,
|
||||||
|
loadMoreUserPosts,
|
||||||
|
} from "$lib/userPosts.svelte";
|
||||||
|
import type {
|
||||||
|
UserPost,
|
||||||
|
UserPostKind,
|
||||||
|
UserPostPage,
|
||||||
|
} from "$lib/forum/userPosts";
|
||||||
import Meta from "$lib/components/Meta.svelte";
|
import Meta from "$lib/components/Meta.svelte";
|
||||||
import { summarize } from "$lib/seo";
|
import { summarize } from "$lib/seo";
|
||||||
|
|
||||||
|
|
@ -85,12 +93,12 @@
|
||||||
{jsonLd}
|
{jsonLd}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#snippet postList(items: UserPost[], empty: string)}
|
{#snippet postList(kind: UserPostKind, list: UserPostPage, empty: string)}
|
||||||
{#if items.length === 0}
|
{#if list.items.length === 0}
|
||||||
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">{empty}</p>
|
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">{empty}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
|
<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)}
|
{@const text = summarize(p.content, 200)}
|
||||||
<li class="py-3">
|
<li class="py-3">
|
||||||
<a href={postHref(p)} class="group block">
|
<a href={postHref(p)} class="group block">
|
||||||
|
|
@ -125,6 +133,19 @@
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</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}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|
@ -212,7 +233,7 @@
|
||||||
Loading…
|
Loading…
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
{@render postList(posts.roots, "No discussions yet.")}
|
{@render postList("roots", posts.roots, "No discussions yet.")}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -228,7 +249,7 @@
|
||||||
Loading…
|
Loading…
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
{@render postList(posts.replies, "No replies yet.")}
|
{@render postList("replies", posts.replies, "No replies yet.")}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue