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