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.

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