Paginate notifications
This commit is contained in:
parent
8695d3e40e
commit
982c20d1fa
3 changed files with 104 additions and 18 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue