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.

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