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