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.

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