import { getContext, untrack } from "svelte";
import { SvelteMap } from "svelte/reactivity";
import { createBobbinClient } from "#lib/api/client.ts";
import { createDeliberiClient, type DeliberiContext } from "#lib/api/deliberi.ts";
import {
getUnreadCount,
hydrateNotifications,
listNotifications,
markAllNotificationsRead,
markEntityNotificationsRead,
updateNotificationSeen
} from "#lib/api/notifications.ts";
import type {
CategoryFilter,
NotificationSummary,
ReadFilter
} from "#lib/components/notifications/types.ts";
import type { Auth } from "#lib/auth.svelte.ts";
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<NotificationSummary[]>;
setRead(uri: string, read: boolean): Promise<void>;
markAllRead(): Promise<void>;
markEntityVisited(entityUri: string): Promise<void>;
}
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<string, boolean>();
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<DeliberiContext | null> => {
const did = auth.currentDid;
if (!did || !deliberiUrl) return null;
// the agent lands after the did and shouldn't load the list again
const agent = await untrack(
() => auth.agent ?? auth.agentFor(did, { relogin: false }).catch(() => null)
);
return agent ? createDeliberiClient({ deliberiUrl, agent }) : null;
};
// the bell and a page's own list load together and share one count
let counting: { did: string; count: Promise<{ count: number }> } | null = null;
const unreadCount = (deliberi: DeliberiContext) => {
const did = deliberi.agent.sub;
if (counting?.did !== did) {
const count = getUnreadCount(deliberi).finally(() => {
if (counting?.count === count) counting = null;
});
counting = { did, count };
}
return counting.count;
};
const list = async (query: NotificationQuery = {}): Promise<NotificationSummary[]> => {
const deliberi = await client();
if (!deliberi) return [];
const [count, page] = await Promise.all([
unreadCount(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<void> => {
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<void> => {
state.markAll();
const deliberi = await client();
if (!deliberi) return;
await markAllNotificationsRead(deliberi).catch(() => state.resync(deliberi));
};
const markEntityVisited = async (entityUri: string): Promise<void> => {
// 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>(NOTIFICATIONS_KEY);