Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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