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