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}
- {#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 @@
{/each}
+ {#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;