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