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 {
|
import {
|
||||||
notificationsStore,
|
notificationsStore,
|
||||||
markNotificationsSeen,
|
markNotificationsSeen,
|
||||||
|
loadMoreNotifications,
|
||||||
type Notification,
|
type Notification,
|
||||||
} from "$lib/notifications.svelte";
|
} from "$lib/notifications.svelte";
|
||||||
import { summarize } from "$lib/seo";
|
import { summarize } from "$lib/seo";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
|
||||||
|
const PAGE = 5;
|
||||||
|
|
||||||
const items = $derived(notificationsStore.items);
|
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
|
// 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.
|
// mark while on screen even though they are counted as seen right away.
|
||||||
|
|
@ -20,7 +35,13 @@
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!items) return;
|
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();
|
markNotificationsSeen();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -86,7 +107,7 @@
|
||||||
</p>
|
</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 n (n.id)}
|
{#each visible ?? [] as n (n.id)}
|
||||||
{@const unread = n.createdAt > (openedAt ?? 0)}
|
{@const unread = n.createdAt > (openedAt ?? 0)}
|
||||||
{@const name = authorName(n.pubkey)}
|
{@const name = authorName(n.pubkey)}
|
||||||
{@const picture = authorPicture(n.pubkey)}
|
{@const picture = authorPicture(n.pubkey)}
|
||||||
|
|
@ -163,5 +184,18 @@
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,13 @@ export type Notification = {
|
||||||
title?: string; // thread title, filled by fetchThreadTitles
|
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
|
// Classify an event that p-tags the user. Own events and anything that is
|
||||||
// neither a discussion reply nor a chat message yield null.
|
// 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
|
// One page of replies and mentions addressed to the user, newest first,
|
||||||
// `groupId` every group the relay serves the caller counts.
|
// older than the cursor. Without `groupId` every group the relay serves the
|
||||||
|
// caller counts.
|
||||||
export async function fetchNotifications(
|
export async function fetchNotifications(
|
||||||
q: Query,
|
q: Query,
|
||||||
me: string,
|
me: string,
|
||||||
groupId?: string,
|
req: { groupId?: string; until?: number; exclude?: Set<string> } = {},
|
||||||
): Promise<Notification[]> {
|
): Promise<NotificationPage> {
|
||||||
|
const n = NOTIFICATIONS_PAGE;
|
||||||
|
const exclude = req.exclude ?? new Set<string>();
|
||||||
const events = await q({
|
const events = await q({
|
||||||
kinds: [1111, 9],
|
kinds: [1111, 9],
|
||||||
"#p": [me],
|
"#p": [me],
|
||||||
...(groupId ? { "#h": [groupId] } : {}),
|
...(req.groupId ? { "#h": [req.groupId] } : {}),
|
||||||
limit: NOTIFICATIONS_LIMIT,
|
...(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))
|
.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);
|
.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);
|
await fetchThreadTitles(q, items);
|
||||||
return items;
|
return { items, done };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,18 @@ import {
|
||||||
fetchThreadTitles,
|
fetchThreadTitles,
|
||||||
parseNotification,
|
parseNotification,
|
||||||
type Notification,
|
type Notification,
|
||||||
|
type NotificationPage,
|
||||||
} from "$lib/forum/notifications";
|
} from "$lib/forum/notifications";
|
||||||
|
|
||||||
export type { Notification };
|
export type { Notification };
|
||||||
|
|
||||||
const SEEN_KEY = "notifications_seen";
|
const SEEN_KEY = "notifications_seen";
|
||||||
|
const scope = () => (MODE === "simple" ? GROUP_ID : undefined);
|
||||||
|
|
||||||
let pubkey = $state<string | null>(null);
|
let pubkey = $state<string | null>(null);
|
||||||
let items = $state<Notification[] | null>(null);
|
let items = $state<Notification[] | null>(null);
|
||||||
|
let done = $state(true);
|
||||||
|
let loadingMore = $state(false);
|
||||||
let seenAt = $state(0);
|
let seenAt = $state(0);
|
||||||
let liveSub: { close(): void } | null = null;
|
let liveSub: { close(): void } | null = null;
|
||||||
let req = 0; // supersedes an in-flight load on login/logout
|
let req = 0; // supersedes an in-flight load on login/logout
|
||||||
|
|
@ -33,6 +37,12 @@ export const notificationsStore = {
|
||||||
get items() {
|
get items() {
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
get done() {
|
||||||
|
return done;
|
||||||
|
},
|
||||||
|
get loadingMore() {
|
||||||
|
return loadingMore;
|
||||||
|
},
|
||||||
get seenAt() {
|
get seenAt() {
|
||||||
return seenAt;
|
return seenAt;
|
||||||
},
|
},
|
||||||
|
|
@ -55,23 +65,24 @@ export async function startNotifications(pk: string) {
|
||||||
pubkey = pk;
|
pubkey = pk;
|
||||||
seenAt = readSeen(pk);
|
seenAt = readSeen(pk);
|
||||||
const current = ++req;
|
const current = ++req;
|
||||||
const scope = MODE === "simple" ? GROUP_ID : undefined;
|
const groupId = scope();
|
||||||
|
|
||||||
let result: Notification[] = [];
|
let result: NotificationPage = { items: [], done: true };
|
||||||
try {
|
try {
|
||||||
result = await fetchNotifications(queryForum, pk, scope);
|
result = await fetchNotifications(queryForum, pk, { groupId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[notifications] load failed", e);
|
console.error("[notifications] load failed", e);
|
||||||
}
|
}
|
||||||
if (current !== req) return;
|
if (current !== req) return;
|
||||||
items = result;
|
items = result.items;
|
||||||
for (const n of result) ensureProfile(n.pubkey);
|
done = result.done;
|
||||||
|
for (const n of result.items) ensureProfile(n.pubkey);
|
||||||
|
|
||||||
liveSub = subscribeForum(
|
liveSub = subscribeForum(
|
||||||
{
|
{
|
||||||
kinds: [1111, 9],
|
kinds: [1111, 9],
|
||||||
"#p": [pk],
|
"#p": [pk],
|
||||||
...(scope ? { "#h": [scope] } : {}),
|
...(groupId ? { "#h": [groupId] } : {}),
|
||||||
since: Math.floor(Date.now() / 1000),
|
since: Math.floor(Date.now() / 1000),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -97,9 +108,35 @@ export function stopNotifications() {
|
||||||
liveSub = null;
|
liveSub = null;
|
||||||
pubkey = null;
|
pubkey = null;
|
||||||
items = null;
|
items = null;
|
||||||
|
done = true;
|
||||||
|
loadingMore = false;
|
||||||
seenAt = 0;
|
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.
|
// Everything loaded so far counts as read.
|
||||||
export function markNotificationsSeen() {
|
export function markNotificationsSeen() {
|
||||||
if (!pubkey) return;
|
if (!pubkey) return;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue