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