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