import { getContext } from "svelte"; import { SvelteMap } from "svelte/reactivity"; import { createBobbinClient } from "$lib/api/client"; import { createDeliberiClient, type DeliberiContext } from "$lib/api/deliberi"; import { getUnreadCount, hydrateNotifications, listNotifications, markAllNotificationsRead, markEntityNotificationsRead, updateNotificationSeen } from "$lib/api/notifications"; 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 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; } 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 state = createReadState(0); 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)); 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, { read: query.read, category: query.category === "all" ? undefined : query.category, limit: query.limit }) ]); 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)); }; 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);