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