From 982c20d1fa273734a84aca2f9a8d1ebd1aeca9ac Mon Sep 17 00:00:00 2001 From: dtonon Date: Wed, 23 Sep 2026 10:42:26 +0200 Subject: [PATCH] Paginate notifications --- src/lib/components/Notifications.svelte | 38 ++++++++++++++++++- src/lib/forum/notifications.ts | 35 +++++++++++++----- src/lib/notifications.svelte.ts | 49 ++++++++++++++++++++++--- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 6b84efc..490021e 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -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 @@

{:else} + {#if hasMore} + {@const busy = notificationsStore.loadingMore} +
+ +
+ {/if} {/if} diff --git a/src/lib/forum/notifications.ts b/src/lib/forum/notifications.ts index 02cca1d..bcc29a3 100644 --- a/src/lib/forum/notifications.ts +++ b/src/lib/forum/notifications.ts @@ -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 { + req: { groupId?: string; until?: number; exclude?: Set } = {}, +): Promise { + const n = NOTIFICATIONS_PAGE; + const exclude = req.exclude ?? new Set(); 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 }; } diff --git a/src/lib/notifications.svelte.ts b/src/lib/notifications.svelte.ts index b50923b..d02b47c 100644 --- a/src/lib/notifications.svelte.ts +++ b/src/lib/notifications.svelte.ts @@ -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(null); let items = $state(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;