Skip to content
704 linesCodeBlameRaw
1/**
2 * Live notifications in the browser: one socket per tab to the person's
3 * feed (`/-/live`, services/notify), a small store the page reads (counts
4 * for the rail, the Chat sidebar and the tab title; toasts; whether the
5 * socket is up), and delivery through a `NotificationSink`: the web's own
6 * (toasts, a soft sound, the app badge) or, inside the desktop app, its
7 * native bridge.
8 *
9 * Nothing here polls. The feed sends counts on connect and after every
10 * change; while the socket is down, `connected` is false and the pages
11 * that used to poll may fall back to a slow refresh.
12 *
13 * Presence rides the same socket: the tab says when it goes idle (no input
14 * for `IDLE_MS`) or comes back, the feed sends how everyone in the
15 * workspace shows (`presence`) and your own (`me`), and `setPresence`
16 * changes yours (lib/presence.ts, components/presence.tsx).
17 */
18import { useSyncExternalStore } from "react";
19
20import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts";
21
22import { isIdle, mergePeople } from "./presence";
23
24import {
25 HEARTBEAT_MS,
26 addToast,
27 attentionCount,
28 badgesOf,
29 dismissToast,
30 markReadLocally,
31 offerPush,
32 reconnectDelay,
33 titleWith,
34 type LiveBadges,
35 type PushChoice,
36 type Toast,
37} from "./notify-store";
38
39// ── The desktop bridge ───────────────────────────────────────────────────
40
41/**
42 * What the desktop app (an Electron shell loading this web app; see
43 * docs/WORKSPACE.md, "Desktop app") exposes from its preload script with
44 * `contextBridge.exposeInMainWorld("g1tDesktop", …)`. When it is there,
45 * notifications go to it instead of the browser:
46 *
47 * ```ts
48 * window.g1tDesktop = {
49 * version: "1.0.0",
50 * // A native notification. Clicking it brings the window forward and
51 * // navigates to `href` (calling the `onNavigate` listeners).
52 * notify({ id, title, body, href, tag, icon }) {},
53 * // The dock or taskbar badge and the tray's count; 0 clears it.
54 * setBadge(count) {},
55 * // Opens a URL outside the app, in the system browser.
56 * openUrl(url) {},
57 * // g1t:// deep links and notification clicks, as site paths ("/acme/-/chat/dm/chn_1").
58 * onNavigate(listener) { return () => {} },
59 * };
60 * ```
61 */
62export type DesktopBridge = {
63 version?: string;
64 notify(notification: { id: string; title: string; body: string; href: string; tag: string; icon?: string | null }): void;
65 setBadge(count: number): void;
66 openUrl(url: string): void;
67 onNavigate?(listener: (href: string) => void): () => void;
68};
69
70declare global {
71 interface Window {
72 g1tDesktop?: DesktopBridge;
73 }
74}
75
76export function desktopBridge(): DesktopBridge | null {
77 if (typeof window === "undefined") return null;
78 const bridge = window.g1tDesktop;
79 return bridge && typeof bridge.notify === "function" && typeof bridge.setBadge === "function" ? bridge : null;
80}
81
82// ── Sinks ────────────────────────────────────────────────────────────────
83
84/** Where a live notification goes once it arrives. */
85export interface NotificationSink {
86 readonly kind: "web" | "desktop";
87 /** A notification arrived; `toast` says the preferences want it shown, `focused` whether this tab is in front. */
88 deliver(notification: FeedNotification, options: { toast: boolean; focused: boolean }): void;
89 /** What needs the person, for the app icon. */
90 setBadge(count: number): void;
91 /** Goes to a page on the site. */
92 open(href: string): void;
93}
94
95let navigator_: ((href: string) => void) | null = null;
96
97/** The router's navigate, so a toast or a notification click moves without a reload. */
98export function setNavigator(navigate: ((href: string) => void) | null): void {
99 navigator_ = navigate;
100}
101
102function openHref(href: string): void {
103 if (navigator_) navigator_(href);
104 else if (typeof location !== "undefined") location.assign(href);
105}
106
107/** The web's: a toast in the tab, a soft sound if wanted, the installed app's badge. */
108export const webSink: NotificationSink = {
109 kind: "web",
110 deliver(notification, { toast }) {
111 if (!toast) return;
112 showToast(notification);
113 if (soundOn()) chime();
114 },
115 setBadge(count) {
116 const nav = typeof navigator !== "undefined" ? (navigator as Navigator & { setAppBadge?(n: number): Promise<void>; clearAppBadge?(): Promise<void> }) : null;
117 try {
118 if (count > 0) void nav?.setAppBadge?.(count)?.catch(() => {});
119 else void nav?.clearAppBadge?.()?.catch(() => {});
120 } catch {
121 // Not installed, or not allowed: the tab title says it anyway.
122 }
123 },
124 open: openHref,
125};
126
127/** The desktop app's: toasts while its window is in front, native notifications while it is not. */
128export function desktopSink(bridge: DesktopBridge): NotificationSink {
129 return {
130 kind: "desktop",
131 deliver(notification, { toast, focused }) {
132 if (!toast) return;
133 if (focused) {
134 showToast(notification);
135 if (soundOn()) chime();
136 return;
137 }
138 bridge.notify({
139 id: notification.id,
140 title: notification.title,
141 body: notification.body,
142 href: notification.href,
143 tag: notification.channel_id ? `chat:${notification.channel_id}` : `${notification.kind}:${notification.id}`,
144 icon: null,
145 });
146 },
147 setBadge(count) {
148 bridge.setBadge(count);
149 },
150 open: openHref,
151 };
152}
153
154/** The desktop bridge's sink when there is one, else the web's. */
155export function currentSink(): NotificationSink {
156 const bridge = desktopBridge();
157 return bridge ? desktopSink(bridge) : webSink;
158}
159
160// ── The store ────────────────────────────────────────────────────────────
161
162export type NotifyState = {
163 /** Whether the feed socket is up. */
164 connected: boolean;
165 /** Counts by workspace, as the feed last sent them. */
166 counts: Record<string, FeedCounts>;
167 /** The inbox's unread, the person's whole inbox. */
168 inbox: number | null;
169 toasts: Toast[];
170 preferences: NotifyPreferences | null;
171 vapidKey: string | null;
172 /** Whether to show the offer to turn on browser notifications. */
173 offer: boolean;
174 /** How the people who share a workspace with you show, by user id. */
175 people: Record<string, PresenceEntry>;
176 /** Your own presence, status and Do Not Disturb, once the feed has said. */
177 me: OwnPresence | null;
178};
179
180let state: NotifyState = { connected: false, counts: {}, inbox: null, toasts: [], preferences: null, vapidKey: null, offer: false, people: {}, me: null };
181const listeners = new Set<() => void>();
182const SERVER_STATE = state;
183
184function set(next: Partial<NotifyState>): void {
185 state = { ...state, ...next };
186 for (const listener of listeners) listener();
187}
188
189function subscribeStore(listener: () => void): () => void {
190 listeners.add(listener);
191 return () => listeners.delete(listener);
192}
193
194export function useNotifyState(): NotifyState {
195 return useSyncExternalStore(subscribeStore, () => state, () => SERVER_STATE);
196}
197
198/** The rail's numbers for a workspace from the feed, or null while it cannot say. */
199export function useLiveBadges(workspace: string | null | undefined): Partial<LiveBadges> | null {
200 const s = useNotifyState();
201 return badgesOf(workspace ? s.counts[workspace.toLowerCase()] : null, s.inbox);
202}
203
204/** A workspace's counts from the feed, for the Chat sidebar. */
205export function useLiveCounts(workspace: string | null | undefined): FeedCounts | null {
206 const s = useNotifyState();
207 return workspace ? (s.counts[workspace.toLowerCase()] ?? null) : null;
208}
209
210/** People's presence by user id, with an index by username (lowercased). */
211let peopleIndex: { from: Record<string, PresenceEntry>; byName: Map<string, PresenceEntry> } = { from: {}, byName: new Map() };
212
213function byName(people: Record<string, PresenceEntry>): Map<string, PresenceEntry> {
214 if (peopleIndex.from !== people) {
215 peopleIndex = { from: people, byName: new Map(Object.values(people).map((entry) => [entry.username.toLowerCase(), entry])) };
216 }
217 return peopleIndex.byName;
218}
219
220/**
221 * How a person shows, by id or username, or null while the feed has not
222 * said (they share no workspace with you, or the socket is not up yet).
223 * Your own comes from `me`, so it moves the moment you change it.
224 */
225export function usePresenceOf(person: { id?: string | null; username?: string | null } | null | undefined): PresenceEntry | null {
226 const s = useNotifyState();
227 if (!person) return null;
228 const mine = s.me && ((person.id && person.id === s.me.user_id) || (person.username && person.username.toLowerCase() === s.me.username.toLowerCase()));
229 if (mine) return s.me;
230 if (person.id && s.people[person.id]) return s.people[person.id]!;
231 return person.username ? (byName(s.people).get(person.username.toLowerCase()) ?? null) : null;
232}
233
234export function useOwnPresence(): OwnPresence | null {
235 return useNotifyState().me;
236}
237
238/** Whether the feed is up: pages fall back to a slow refresh only while it is not. */
239export function feedConnected(): boolean {
240 return state.connected;
241}
242
243export function useFeedConnected(): boolean {
244 return useNotifyState().connected;
245}
246
247let viewing: { path: string; channel_id?: string | null } = { path: "" };
248
249/** The page the person is on, so its own conversation does not toast. */
250export function setViewing(next: { path: string; channel_id?: string | null }): void {
251 viewing = next;
252 sendState();
253}
254
255function showToast(notification: FeedNotification): void {
256 const toasts = addToast(state.toasts, notification, viewing, Date.now());
257 if (toasts === state.toasts) return;
258 const offer =
259 state.offer ||
260 offerPush({
261 kind: notification.kind,
262 supported: pushSupported(),
263 permission: permission(),
264 choice: pushChoice(),
265 hasKey: !!state.vapidKey,
266 desktop: !!desktopBridge(),
267 });
268 set({ toasts, offer });
269}
270
271export function dismiss(id: string): void {
272 set({ toasts: dismissToast(state.toasts, id) });
273}
274
275export function closeOffer(): void {
276 set({ offer: false });
277}
278
279/** For the settings page's test, and the harness: a notification shown as if it arrived. */
280export function showLocally(notification: FeedNotification): void {
281 currentSink().deliver(notification, { toast: true, focused: true });
282}
283
284// ── The socket ───────────────────────────────────────────────────────────
285
286let socket: WebSocket | null = null;
287let workspace: string | null = null;
288let attempt = 0;
289let timer: ReturnType<typeof setTimeout> | null = null;
290let beat: ReturnType<typeof setInterval> | null = null;
291let running = false;
292let sink: NotificationSink = webSink;
293
294function focused(): boolean {
295 return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus();
296}
297
298// ── Idle ─────────────────────────────────────────────────────────────────
299
300let lastInput = Date.now();
301let idle = false;
302
303/** Input in this tab: back from idle at once, if it was. */
304function onInput(): void {
305 lastInput = Date.now();
306 if (idle) {
307 idle = false;
308 sendState();
309 }
310}
311
312/** Every heartbeat: gone idle since the last one? */
313function checkIdle(): void {
314 if (!idle && isIdle(lastInput, Date.now())) {
315 idle = true;
316 sendState();
317 }
318}
319
320const INPUT_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel", "touchstart"] as const;
321
322function sendState(): void {
323 if (socket?.readyState !== WebSocket.OPEN) return;
324 try {
325 socket.send(JSON.stringify({ type: "state", focused: focused(), path: viewing.path, idle }));
326 } catch {
327 // Closing; it comes back.
328 }
329}
330
331/** The inbox count a page just read, so every tab shows it. */
332export function reportInbox(unread: number): void {
333 if (state.inbox === unread) return;
334 set({ inbox: unread });
335 if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "inbox", unread }));
336}
337
338function onEvent(event: FeedEvent): void {
339 switch (event.type) {
340 case "hello":
341 set({ preferences: event.preferences, vapidKey: event.vapid_public_key });
342 break;
343 case "counts": {
344 const { type: _, ...counts } = event;
345 set({ counts: { ...state.counts, [counts.workspace]: counts }, inbox: counts.inbox_unread });
346 break;
347 }
348 case "notification":
349 sink.deliver(event.notification, { toast: event.toast, focused: focused() });
350 break;
351 case "preferences":
352 set({ preferences: event.preferences });
353 break;
354 case "inbox":
355 set({ inbox: event.unread });
356 break;
357 case "presence": {
358 const people = mergePeople(state.people, event.people, event.full);
359 if (people !== state.people) set({ people });
360 break;
361 }
362 case "me":
363 set({ me: event.me });
364 break;
365 }
366}
367
368function connect(): void {
369 if (!running) return;
370 timer = null;
371 const scheme = location.protocol === "https:" ? "wss:" : "ws:";
372 const query = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
373 let ws: WebSocket;
374 try {
375 ws = new WebSocket(`${scheme}//${location.host}/-/live${query}`);
376 } catch {
377 schedule();
378 return;
379 }
380 socket = ws;
381 ws.onopen = () => {
382 attempt = 0;
383 set({ connected: true });
384 sendState();
385 };
386 ws.onmessage = (message) => {
387 if (message.data === "pong") return;
388 try {
389 onEvent(JSON.parse(String(message.data)) as FeedEvent);
390 } catch {
391 // Not an event this page knows.
392 }
393 };
394 ws.onclose = () => {
395 if (socket === ws) socket = null;
396 set({ connected: false });
397 schedule();
398 };
399 ws.onerror = () => ws.close();
400}
401
402function schedule(): void {
403 if (!running || timer) return;
404 timer = setTimeout(connect, reconnectDelay(attempt++));
405}
406
407/** Back now: the tab is shown, or the network returned. */
408function now(): void {
409 sendState();
410 if (!running || socket || document.visibilityState !== "visible") return;
411 if (timer) clearTimeout(timer);
412 timer = null;
413 attempt = 0;
414 connect();
415}
416
417function onRead(event: Event): void {
418 const channel = String((event as CustomEvent).detail ?? "");
419 if (!channel || !workspace || !state.counts[workspace]) return;
420 set({ counts: { ...state.counts, [workspace]: markReadLocally(state.counts[workspace], channel) } });
421}
422
423/**
424 * Opens the feed for the signed-in person (once per tab; calling again
425 * with another workspace reconnects, so the feed reads that workspace's
426 * counts). Returns how to close it.
427 */
428export function startFeed(forWorkspace: string | null): () => void {
429 const next = forWorkspace?.toLowerCase() ?? null;
430 if (running && next === workspace) return stopFeed;
431 workspace = next;
432 sink = currentSink();
433 if (running) {
434 // Another workspace: its counts are read on connect.
435 socket?.close(1000, "workspace");
436 return stopFeed;
437 }
438 running = true;
439 connect();
440 beat = setInterval(() => {
441 checkIdle();
442 if (socket?.readyState === WebSocket.OPEN) socket.send("ping");
443 }, HEARTBEAT_MS);
444 lastInput = Date.now();
445 idle = false;
446 for (const name of INPUT_EVENTS) window.addEventListener(name, onInput, { passive: true, capture: true });
447 document.addEventListener("visibilitychange", now);
448 window.addEventListener("focus", now);
449 window.addEventListener("blur", sendState);
450 window.addEventListener("online", now);
451 window.addEventListener("g1t:chat-read", onRead);
452 const bridge = desktopBridge();
453 const off = bridge?.onNavigate?.((href) => openHref(href));
454 unbridge = off ?? null;
455 return stopFeed;
456}
457
458let unbridge: (() => void) | null = null;
459
460export function stopFeed(): void {
461 running = false;
462 if (timer) clearTimeout(timer);
463 if (beat) clearInterval(beat);
464 timer = beat = null;
465 socket?.close(1000, "bye");
466 socket = null;
467 document.removeEventListener("visibilitychange", now);
468 window.removeEventListener("focus", now);
469 window.removeEventListener("blur", sendState);
470 window.removeEventListener("online", now);
471 window.removeEventListener("g1t:chat-read", onRead);
472 for (const name of INPUT_EVENTS) window.removeEventListener(name, onInput, { capture: true });
473 unbridge?.();
474 unbridge = null;
475 set({ connected: false });
476}
477
478/** Keeps the tab title's count and the app badge in step with `count`. */
479export function applyAttention(badges: Partial<LiveBadges> | null): number {
480 const count = attentionCount(badges);
481 if (typeof document !== "undefined") {
482 const next = titleWith(document.title, count);
483 if (next !== document.title) document.title = next;
484 }
485 sink.setBadge(count);
486 return count;
487}
488
489// ── Sound ────────────────────────────────────────────────────────────────
490
491const SOUND_KEY = "g1t:notify:sound";
492
493export function soundOn(): boolean {
494 try {
495 return localStorage.getItem(SOUND_KEY) === "on";
496 } catch {
497 return false;
498 }
499}
500
501export function setSound(on: boolean): void {
502 try {
503 if (on) localStorage.setItem(SOUND_KEY, "on");
504 else localStorage.removeItem(SOUND_KEY);
505 } catch {
506 // Kept for this page only.
507 }
508}
509
510let audio: AudioContext | null = null;
511
512/** A soft two-note chime, made here: no file to load. */
513export function chime(): void {
514 try {
515 audio ??= new AudioContext();
516 const at = audio.currentTime;
517 for (const [i, freq] of [880, 1318.5].entries()) {
518 const osc = audio.createOscillator();
519 const gain = audio.createGain();
520 osc.type = "sine";
521 osc.frequency.value = freq;
522 const start = at + i * 0.09;
523 gain.gain.setValueAtTime(0, start);
524 gain.gain.linearRampToValueAtTime(0.06, start + 0.015);
525 gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.35);
526 osc.connect(gain).connect(audio.destination);
527 osc.start(start);
528 osc.stop(start + 0.4);
529 }
530 } catch {
531 // No audio: nothing lost.
532 }
533}
534
535// ── Browser push ─────────────────────────────────────────────────────────
536
537const PUSH_KEY = "g1t:notify:push";
538
539export function pushChoice(): PushChoice {
540 try {
541 const value = localStorage.getItem(PUSH_KEY);
542 return value === "declined" || value === "off" || value === "on" ? value : null;
543 } catch {
544 return null;
545 }
546}
547
548function setPushChoice(choice: PushChoice): void {
549 try {
550 if (choice) localStorage.setItem(PUSH_KEY, choice);
551 else localStorage.removeItem(PUSH_KEY);
552 } catch {
553 // Asked again next time; nothing worse.
554 }
555}
556
557/** The person closed the offer or said no: never offered again. */
558export function declinePush(): void {
559 setPushChoice("declined");
560 set({ offer: false });
561}
562
563export function pushSupported(): boolean {
564 return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
565}
566
567export function permission(): "default" | "granted" | "denied" {
568 return typeof window !== "undefined" && "Notification" in window ? Notification.permission : "denied";
569}
570
571function keyBytes(base64url: string): Uint8Array<ArrayBuffer> {
572 const padded = base64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (base64url.length % 4)) % 4);
573 const raw = atob(padded);
574 const out = new Uint8Array(new ArrayBuffer(raw.length));
575 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
576 return out;
577}
578
579async function registration(): Promise<ServiceWorkerRegistration> {
580 const existing = await navigator.serviceWorker.getRegistration("/");
581 return existing ?? navigator.serviceWorker.register("/sw.js", { scope: "/" });
582}
583
584async function api(body: object): Promise<Response> {
585 return fetch("/-/notify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
586}
587
588/** The public key browsers subscribe with, from the feed or asked for. */
589async function vapidKey(): Promise<string | null> {
590 if (state.vapidKey) return state.vapidKey;
591 const status = await notifyStatus().catch(() => null);
592 return status?.vapid_public_key ?? null;
593}
594
595export type PushResult = "on" | "denied" | "unsupported" | "unavailable" | "failed";
596
597/**
598 * Asks the browser, then subscribes it: only ever from a click. A no is
599 * kept, so g1t never asks again.
600 */
601export async function enablePush(): Promise<PushResult> {
602 if (!pushSupported()) return "unsupported";
603 const key = await vapidKey();
604 if (!key) return "unavailable";
605 const answer = permission() === "granted" ? "granted" : await Notification.requestPermission();
606 if (answer !== "granted") {
607 setPushChoice("declined");
608 set({ offer: false });
609 return "denied";
610 }
611 try {
612 const reg = await registration();
613 await navigator.serviceWorker.ready;
614 const subscription = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) }));
615 const response = await api({ intent: "subscribe", subscription: subscription.toJSON() });
616 if (!response.ok) return "failed";
617 setPushChoice("on");
618 set({ offer: false });
619 return "on";
620 } catch (error) {
621 console.error("notify: could not subscribe", error);
622 return "failed";
623 }
624}
625
626/** Stops pushes to this browser; kept as the person's choice. */
627export async function disablePush(): Promise<void> {
628 setPushChoice("off");
629 if (!pushSupported()) return;
630 const reg = await navigator.serviceWorker.getRegistration("/");
631 const subscription = await reg?.pushManager.getSubscription();
632 if (!subscription) return;
633 await api({ intent: "unsubscribe", endpoint: subscription.endpoint }).catch(() => null);
634 await subscription.unsubscribe().catch(() => false);
635}
636
637/** This browser's push endpoint, if subscribed. */
638export async function currentEndpoint(): Promise<string | null> {
639 if (!pushSupported()) return null;
640 const reg = await navigator.serviceWorker.getRegistration("/");
641 return (await reg?.pushManager.getSubscription())?.endpoint ?? null;
642}
643
644export async function notifyStatus(): Promise<import("@g1t/contracts").NotifyStatus | null> {
645 const endpoint = await currentEndpoint().catch(() => null);
646 const response = await fetch(`/-/notify${endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : ""}`, { headers: { accept: "application/json" } });
647 return response.ok ? response.json() : null;
648}
649
650/**
651 * On each visit, for someone who turned pushes on: the service worker is
652 * registered, and the subscription is handed over again (browsers renew
653 * them), so pushes keep arriving. Asks nothing.
654 */
655export async function refreshPush(): Promise<void> {
656 if (!pushSupported() || desktopBridge()) return;
657 if (permission() !== "granted" || pushChoice() === "off") return;
658 const key = await vapidKey();
659 if (!key) return;
660 const reg = await registration();
661 await navigator.serviceWorker.ready;
662 let subscription = await reg.pushManager.getSubscription();
663 subscription ??= await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) });
664 await api({ intent: "subscribe", subscription: subscription.toJSON() });
665}
666
667export async function savePreferences(preferences: import("@g1t/contracts").NotifyPreferencesChange): Promise<NotifyPreferences | null> {
668 const response = await api({ intent: "preferences", preferences });
669 if (!response.ok) return null;
670 const saved = (await response.json()) as NotifyPreferences;
671 set({ preferences: saved });
672 return saved;
673}
674
675/**
676 * Changes your own presence: a status, being away, Do Not Disturb. Shown
677 * at once, and put back if the feed refuses; every tab and everyone who
678 * shares a workspace with you hear of it over the socket.
679 */
680export async function setPresence(change: PresenceChange, optimistic?: Partial<OwnPresence>): Promise<OwnPresence | null> {
681 const before = state.me;
682 if (before && optimistic) set({ me: { ...before, ...optimistic } });
683 try {
684 const response = await api({ intent: "presence", change });
685 if (!response.ok) throw new Error(`status ${response.status}`);
686 const me = (await response.json()) as OwnPresence;
687 set({ me });
688 return me;
689 } catch (error) {
690 console.error("notify: could not change presence", error);
691 if (optimistic) set({ me: before });
692 return null;
693 }
694}
695
696export async function sendTest(): Promise<{ ok: boolean; pushed: number } | null> {
697 const response = await api({ intent: "test" });
698 return response.ok ? response.json() : null;
699}
700
701/** Test seams: the store's state as given, for the harness. */
702export function setStateForTest(next: Partial<NotifyState>): void {
703 set(next);
704}