diff --git a/web/src/lib/components/notifications/NotificationBell.svelte b/web/src/lib/components/notifications/NotificationBell.svelte
index da70b37c5..eeec28a10 100644
--- a/web/src/lib/components/notifications/NotificationBell.svelte
+++ b/web/src/lib/components/notifications/NotificationBell.svelte
@@ -1,21 +1,16 @@
{#snippet count()}
- {#if unreadCount > 0}
+ {#if shared.unread > 0}
{@render count()}
@@ -150,7 +99,7 @@
buttonVariant="ghost"
size="small"
align="right"
- label="Notifications, {unreadCount} unread"
+ label="Notifications, {shared.unread} unread"
menuClass="w-[36rem]"
>
{#snippet trigger()}
@@ -161,11 +110,11 @@
0}
+ hasNotifications={preview.length > 0}
preferences={false}
onReadFilter={(filter) => (readFilter = filter)}
onCategoryFilter={(filter) => (categoryFilter = filter)}
- onMarkAllRead={markAllRead}
+ onMarkAllRead={shared.markAllRead}
/>
@@ -175,7 +124,7 @@
{readFilter}
border={false}
grouped={false}
- onToggleRead={toggleRead}
+ onToggleRead={shared.setRead}
/>
{
if (!fixtures) return undefined;
if (fixtures instanceof Promise) return fixtures;
- return {
- notifications: fixtures,
- totalUnread: fixtures.filter((n) => !n.read).length
- };
+ return { notifications: fixtures, totalUnread: fixtures.filter((n) => !n.read).length };
});
- // Use createLoad to reactively fetch preview in browser
- const previewLoad = createLoad(async (): Promise => {
+ const previewLoad = createLoad(async (): Promise => {
if (!browser || !signedIn || !context) return null;
- return await context.preview(PREVIEW_LIMIT);
+ return context.overlay(await context.list({ limit: PREVIEW_LIMIT }));
});
const loading = $derived(
@@ -95,7 +87,7 @@
{@render content(fixturePreview.notifications, fixturePreview.totalUnread)}
{/if}
{:else if previewLoad.data}
- {@render content(previewLoad.data.notifications, previewLoad.data.totalUnread)}
+ {@render content(previewLoad.data, context?.unread ?? null)}
{:else}
Open your inbox
diff --git a/web/src/lib/notifications.svelte.ts b/web/src/lib/notifications.svelte.ts
index 8ce3e0724..027e5b6ed 100644
--- a/web/src/lib/notifications.svelte.ts
+++ b/web/src/lib/notifications.svelte.ts
@@ -1,61 +1,163 @@
import { getContext } from "svelte";
+import { SvelteMap } from "svelte/reactivity";
import { createBobbinClient } from "$lib/api/client";
-import { createDeliberiClient } from "$lib/api/deliberi";
+import { createDeliberiClient, type DeliberiContext } from "$lib/api/deliberi";
import {
getUnreadCount,
hydrateNotifications,
listNotifications,
+ markAllNotificationsRead,
markEntityNotificationsRead,
- type NotificationItem
+ updateNotificationSeen
} from "$lib/api/notifications";
-import type { NotificationSummary } from "$lib/components/notifications/types";
+import type {
+ CategoryFilter,
+ NotificationSummary,
+ ReadFilter
+} from "$lib/components/notifications/types";
import type { Auth } from "$lib/auth.svelte";
export const NOTIFICATIONS_KEY = Symbol("notifications");
-export interface NotificationsPreview {
- notifications: NotificationSummary[];
- totalUnread: number;
+export interface NotificationQuery {
+ read?: ReadFilter;
+ category?: CategoryFilter;
+ limit?: number;
}
export interface Notifications {
+ readonly unread: number;
+ overlay(rows: NotificationSummary[]): NotificationSummary[];
+ /** rows as the server returned them, empty when signed out or deliberi is unconfigured */
+ list(query?: NotificationQuery): Promise;
+ setRead(uri: string, read: boolean): Promise;
+ markAllRead(): Promise;
markEntityVisited(entityUri: string): Promise;
- /** latest rows plus the account-wide unread count, null when signed out
- * or deliberi is unconfigured */
- preview(limit: number): Promise;
}
+const unreadOf = (rows: NotificationSummary[]) => rows.filter((row) => !row.read).length;
+
+// the account-wide count and the per-row read state move together here, so no
+// caller can advance one without the other
+const createReadState = (initial: number) => {
+ let unread = $state(initial);
+ const reads = new SvelteMap();
+
+ return {
+ get unread() {
+ return unread;
+ },
+ overlay: (rows: NotificationSummary[]): NotificationSummary[] =>
+ rows.map((row) => {
+ const read = reads.get(row.uri) ?? row.read;
+ return read === row.read ? row : { ...row, read };
+ }),
+ seed: (rows: NotificationSummary[], total: number) => {
+ unread = total;
+ for (const row of rows) reads.set(row.uri, row.read);
+ },
+ mark: (uri: string, read: boolean): boolean => {
+ if (reads.get(uri) === read) return false;
+ reads.set(uri, read);
+ unread = Math.max(0, unread + (read ? -1 : 1));
+ return true;
+ },
+ markAll: () => {
+ for (const uri of reads.keys()) reads.set(uri, true);
+ unread = 0;
+ },
+ resync: async (deliberi: DeliberiContext) => {
+ unread = await getUnreadCount(deliberi)
+ .then((r) => r.count)
+ .catch(() => unread);
+ }
+ };
+};
+
export const createNotifications = (
auth: Auth,
deliberiUrl: string | undefined,
bobbinUrl: string
): Notifications => {
- const markEntityVisited = async (entityUri: string): Promise => {
- // yield first so the call lands outside the $effect that invoked it
- await Promise.resolve();
- const agent = auth.agent;
- if (!agent || !deliberiUrl) return;
- const deliberi = createDeliberiClient({ deliberiUrl, agent });
- await markEntityNotificationsRead(deliberi, entityUri).catch(() => {});
- };
+ const state = createReadState(0);
- const preview = async (limit: number): Promise => {
+ const client = async (): Promise => {
const did = auth.currentDid;
if (!did || !deliberiUrl) return null;
const agent =
auth.agent ?? (await auth.agentFor(did, { relogin: false }).catch(() => null));
- if (!agent) return null;
- const deliberi = createDeliberiClient({ deliberiUrl, agent });
- const hydrate = (items: NotificationItem[]): Promise =>
- hydrateNotifications(createBobbinClient({ serviceUrl: bobbinUrl }), items);
- const [countResult, listResult] = await Promise.all([
+ return agent ? createDeliberiClient({ deliberiUrl, agent }) : null;
+ };
+
+ const list = async (query: NotificationQuery = {}): Promise => {
+ const deliberi = await client();
+ if (!deliberi) return [];
+ const [count, page] = await Promise.all([
getUnreadCount(deliberi),
- listNotifications(deliberi, { limit }).then((r) => hydrate(r.notifications))
+ listNotifications(deliberi, {
+ read: query.read,
+ category: query.category === "all" ? undefined : query.category,
+ limit: query.limit
+ })
]);
- return { notifications: listResult, totalUnread: countResult.count };
+ const rows = await hydrateNotifications(
+ createBobbinClient({ serviceUrl: bobbinUrl }),
+ page.notifications
+ );
+ state.seed(rows, count.count);
+ return rows;
+ };
+
+ const setRead = async (uri: string, read: boolean): Promise => {
+ if (!state.mark(uri, read)) return;
+ const deliberi = await client();
+ if (!deliberi) return;
+ await updateNotificationSeen(deliberi, { uri, read }).catch(() => state.resync(deliberi));
};
- return { markEntityVisited, preview };
+ const markAllRead = async (): Promise => {
+ state.markAll();
+ const deliberi = await client();
+ if (!deliberi) return;
+ await markAllNotificationsRead(deliberi).catch(() => state.resync(deliberi));
+ };
+
+ const markEntityVisited = async (entityUri: string): Promise => {
+ // yield first so the call lands outside the $effect that invoked it
+ await Promise.resolve();
+ const deliberi = await client();
+ if (!deliberi) return;
+ await markEntityNotificationsRead(deliberi, entityUri).catch(() => {});
+ await state.resync(deliberi);
+ };
+
+ return {
+ get unread() {
+ return state.unread;
+ },
+ overlay: state.overlay,
+ list,
+ setRead,
+ markAllRead,
+ markEntityVisited
+ };
+};
+
+/** storybook and tests: same surface, no network */
+export const createFixtureNotifications = (rows: NotificationSummary[]): Notifications => {
+ const state = createReadState(unreadOf(rows));
+ state.seed(rows, unreadOf(rows));
+
+ return {
+ get unread() {
+ return state.unread;
+ },
+ overlay: state.overlay,
+ list: async () => rows,
+ setRead: async (uri, read) => void state.mark(uri, read),
+ markAllRead: async () => state.markAll(),
+ markEntityVisited: async () => {}
+ };
};
export const getNotifications = () => getContext(NOTIFICATIONS_KEY);
diff --git a/web/src/lib/notifications.test.ts b/web/src/lib/notifications.test.ts
new file mode 100644
index 000000000..0bb884599
--- /dev/null
+++ b/web/src/lib/notifications.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import { createFixtureNotifications } from "$lib/notifications.svelte";
+import { notifications } from "$lib/components/notifications/mock";
+import type { NotificationSummary } from "$lib/components/notifications/types";
+
+const rows: NotificationSummary[] = notifications.map((notification, index) => ({
+ ...notification,
+ uri: `at://mock/notif/${index}`,
+ read: index % 2 === 1
+}));
+
+const unreadRow = rows.find((row) => !row.read)!;
+const readRow = rows.find((row) => row.read)!;
+const baseline = rows.filter((row) => !row.read).length;
+
+const readOf = (
+ service: { overlay(rows: NotificationSummary[]): NotificationSummary[] },
+ uri: string
+) => service.overlay(rows).find((row) => row.uri === uri)!.read;
+
+describe("notification read state", () => {
+ it("moves the count with the row it marked", async () => {
+ const service = createFixtureNotifications(rows);
+ expect(service.unread).toBe(baseline);
+
+ await service.setRead(unreadRow.uri, true);
+ expect(service.unread).toBe(baseline - 1);
+ expect(readOf(service, unreadRow.uri)).toBe(true);
+
+ await service.setRead(unreadRow.uri, false);
+ expect(service.unread).toBe(baseline);
+ expect(readOf(service, unreadRow.uri)).toBe(false);
+ });
+
+ it("ignores a mark that changes nothing", async () => {
+ const service = createFixtureNotifications(rows);
+
+ await service.setRead(unreadRow.uri, true);
+ await service.setRead(unreadRow.uri, true);
+ await service.setRead(readRow.uri, true);
+
+ expect(service.unread).toBe(baseline - 1);
+ });
+
+ it("empties the count and every known row on mark all read", async () => {
+ const service = createFixtureNotifications(rows);
+
+ await service.markAllRead();
+
+ expect(service.unread).toBe(0);
+ expect(service.overlay(rows).every((row) => row.read)).toBe(true);
+ });
+});
diff --git a/web/src/routes/notifications/+page.svelte b/web/src/routes/notifications/+page.svelte
index 07cc3b087..49e669e9d 100644
--- a/web/src/routes/notifications/+page.svelte
+++ b/web/src/routes/notifications/+page.svelte
@@ -1,55 +1,27 @@
@@ -104,7 +54,7 @@
0}
- onMarkAllRead={markAllRead}
+ onMarkAllRead={shared.markAllRead}
/>
@@ -114,7 +64,7 @@
{loading}
notifications={mobile}
readFilter={data.readFilter}
- onToggleRead={toggleRead}
+ onToggleRead={shared.setRead}
/>
@@ -123,18 +73,18 @@
{loading}
notifications={work}
title="work"
- unreadCount={workUnread}
+ unreadCount={unread(ofCategory(all, "work"))}
readFilter={data.readFilter}
- onToggleRead={toggleRead}
+ onToggleRead={shared.setRead}
/>
{#if showSocial}
{/if}