Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164import { 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<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 otherconst 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; const agent = auth.agent ?? (await auth.agentFor(did, { relogin: false }).catch(() => null)); return agent ? createDeliberiClient({ deliberiUrl, agent }) : null; };
const list = async (query: NotificationQuery = {}): Promise<NotificationSummary[]> => { 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<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);