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