Paginate notifications

This commit is contained in:
dtonon 2026-09-23 10:42:26 +02:00
parent 8695d3e40e
commit 982c20d1fa
3 changed files with 104 additions and 18 deletions

View file

@ -7,12 +7,27 @@
import {
notificationsStore,
markNotificationsSeen,
loadMoreNotifications,
type Notification,
} from "$lib/notifications.svelte";
import { summarize } from "$lib/seo";
import * as nip19 from "@nostr/tools/nip19";
const PAGE = 5;
const items = $derived(notificationsStore.items);
// Visible slice; "Show more" reveals another page from what is loaded and
// fetches older ones once the loaded list runs out.
let shown = $state(PAGE);
const visible = $derived(items?.slice(0, shown) ?? null);
const hasMore = $derived(
!!items && (items.length > shown || !notificationsStore.done),
);
async function showMore() {
if (items && items.length <= shown) await loadMoreNotifications();
shown += PAGE;
}
// Unread threshold frozen when the list first shows, so new items keep their
// mark while on screen even though they are counted as seen right away.
@ -20,7 +35,13 @@
$effect(() => {
if (!items) return;
if (openedAt === null) openedAt = untrack(() => notificationsStore.seenAt);
if (openedAt === null) {
const seenAt = untrack(() => notificationsStore.seenAt);
openedAt = seenAt;
// Every new item is visible at once; the page size is only a floor
const unread = items.filter((n) => n.createdAt > seenAt).length;
shown = Math.max(PAGE, unread);
}
markNotificationsSeen();
});
@ -86,7 +107,7 @@
</p>
{:else}
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
{#each items as n (n.id)}
{#each visible ?? [] as n (n.id)}
{@const unread = n.createdAt > (openedAt ?? 0)}
{@const name = authorName(n.pubkey)}
{@const picture = authorPicture(n.pubkey)}
@ -163,5 +184,18 @@
</li>
{/each}
</ul>
{#if hasMore}
{@const busy = notificationsStore.loadingMore}
<div class="flex justify-center py-6">
<button
onclick={showMore}
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}
</section>

View file

@ -20,7 +20,13 @@ export type Notification = {
title?: string; // thread title, filled by fetchThreadTitles
};
export const NOTIFICATIONS_LIMIT = 50;
export const NOTIFICATIONS_PAGE = 20;
const CURSOR_HEADROOM = 10; // boundary events re-read at the inclusive cursor
export type NotificationPage = {
items: Notification[];
done: boolean; // no older notifications left
};
// Classify an event that p-tags the user. Own events and anything that is
// neither a discussion reply nor a chat message yield null.
@ -69,23 +75,32 @@ export async function fetchThreadTitles(
}
}
// Latest replies and mentions addressed to the user, newest first. Without
// `groupId` every group the relay serves the caller counts.
// One page of replies and mentions addressed to the user, newest first,
// older than the cursor. Without `groupId` every group the relay serves the
// caller counts.
export async function fetchNotifications(
q: Query,
me: string,
groupId?: string,
): Promise<Notification[]> {
req: { groupId?: string; until?: number; exclude?: Set<string> } = {},
): Promise<NotificationPage> {
const n = NOTIFICATIONS_PAGE;
const exclude = req.exclude ?? new Set<string>();
const events = await q({
kinds: [1111, 9],
"#p": [me],
...(groupId ? { "#h": [groupId] } : {}),
limit: NOTIFICATIONS_LIMIT,
...(req.groupId ? { "#h": [req.groupId] } : {}),
...(req.until ? { until: req.until } : {}),
limit: n + CURSOR_HEADROOM,
});
const items = events
const fresh = events
.filter((e) => !exclude.has(e.id))
.map((e) => parseNotification(e, me))
.filter((n): n is Notification => n !== null)
.filter((x): x is Notification => x !== null)
.sort((a, b) => b.createdAt - a.createdAt);
const items = 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;
await fetchThreadTitles(q, items);
return items;
return { items, done };
}

View file

@ -6,14 +6,18 @@ import {
fetchThreadTitles,
parseNotification,
type Notification,
type NotificationPage,
} from "$lib/forum/notifications";
export type { Notification };
const SEEN_KEY = "notifications_seen";
const scope = () => (MODE === "simple" ? GROUP_ID : undefined);
let pubkey = $state<string | null>(null);
let items = $state<Notification[] | null>(null);
let done = $state(true);
let loadingMore = $state(false);
let seenAt = $state(0);
let liveSub: { close(): void } | null = null;
let req = 0; // supersedes an in-flight load on login/logout
@ -33,6 +37,12 @@ export const notificationsStore = {
get items() {
return items;
},
get done() {
return done;
},
get loadingMore() {
return loadingMore;
},
get seenAt() {
return seenAt;
},
@ -55,23 +65,24 @@ export async function startNotifications(pk: string) {
pubkey = pk;
seenAt = readSeen(pk);
const current = ++req;
const scope = MODE === "simple" ? GROUP_ID : undefined;
const groupId = scope();
let result: Notification[] = [];
let result: NotificationPage = { items: [], done: true };
try {
result = await fetchNotifications(queryForum, pk, scope);
result = await fetchNotifications(queryForum, pk, { groupId });
} catch (e) {
console.error("[notifications] load failed", e);
}
if (current !== req) return;
items = result;
for (const n of result) ensureProfile(n.pubkey);
items = result.items;
done = result.done;
for (const n of result.items) ensureProfile(n.pubkey);
liveSub = subscribeForum(
{
kinds: [1111, 9],
"#p": [pk],
...(scope ? { "#h": [scope] } : {}),
...(groupId ? { "#h": [groupId] } : {}),
since: Math.floor(Date.now() / 1000),
},
{
@ -97,9 +108,35 @@ export function stopNotifications() {
liveSub = null;
pubkey = null;
items = null;
done = true;
loadingMore = false;
seenAt = 0;
}
// Append the next page of older notifications.
export async function loadMoreNotifications() {
const pk = pubkey;
const current = req;
if (!pk || !items || done || loadingMore) return;
loadingMore = true;
const oldest = items[items.length - 1];
try {
const next = await fetchNotifications(queryForum, pk, {
groupId: scope(),
until: oldest?.createdAt,
exclude: new Set(items.map((n) => n.id)),
});
if (current !== req || !items) return;
items = [...items, ...next.items];
done = next.done;
for (const n of next.items) ensureProfile(n.pubkey);
} catch (e) {
console.error("[notifications] failed to load more", e);
} finally {
if (current === req) loadingMore = false;
}
}
// Everything loaded so far counts as read.
export function markNotificationsSeen() {
if (!pubkey) return;