Skip to content
607 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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, a soft sound, the app badge) or, inside the desktop app, its
7 * native bridge.
8 *
9 * Nothing here polls. The feed sends counts on connect and after every
10 * change; while the socket is down, `connected` is false and the pages
11 * that used to poll may fall back to a slow refresh.
12 */
13import { useSyncExternalStore } from "react";
14
15import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences } from "@g1t/contracts";
16
17import {
18 HEARTBEAT_MS,
19 addToast,
20 attentionCount,
21 badgesOf,
22 dismissToast,
23 markReadLocally,
24 offerPush,
25 reconnectDelay,
26 titleWith,
27 type LiveBadges,
28 type PushChoice,
29 type Toast,
30} from "./notify-store";
31
32// ── The desktop bridge ───────────────────────────────────────────────────
33
34/**
35 * What the desktop app (an Electron shell loading this web app; see
36 * docs/WORKSPACE.md, "Desktop app") exposes from its preload script with
37 * `contextBridge.exposeInMainWorld("g1tDesktop", …)`. When it is there,
38 * notifications go to it instead of the browser:
39 *
40 * ```ts
41 * window.g1tDesktop = {
42 * version: "1.0.0",
43 * // A native notification. Clicking it brings the window forward and
44 * // navigates to `href` (calling the `onNavigate` listeners).
45 * notify({ id, title, body, href, tag, icon }) {},
46 * // The dock or taskbar badge and the tray's count; 0 clears it.
47 * setBadge(count) {},
48 * // Opens a URL outside the app, in the system browser.
49 * openUrl(url) {},
50 * // g1t:// deep links and notification clicks, as site paths ("/acme/-/chat/dm/chn_1").
51 * onNavigate(listener) { return () => {} },
52 * };
53 * ```
54 */
55export type DesktopBridge = {
56 version?: string;
57 notify(notification: { id: string; title: string; body: string; href: string; tag: string; icon?: string | null }): void;
58 setBadge(count: number): void;
59 openUrl(url: string): void;
60 onNavigate?(listener: (href: string) => void): () => void;
61};
62
63declare global {
64 interface Window {
65 g1tDesktop?: DesktopBridge;
66 }
67}
68
69export function desktopBridge(): DesktopBridge | null {
70 if (typeof window === "undefined") return null;
71 const bridge = window.g1tDesktop;
72 return bridge && typeof bridge.notify === "function" && typeof bridge.setBadge === "function" ? bridge : null;
73}
74
75// ── Sinks ────────────────────────────────────────────────────────────────
76
77/** Where a live notification goes once it arrives. */
78export interface NotificationSink {
79 readonly kind: "web" | "desktop";
80 /** A notification arrived; `toast` says the preferences want it shown, `focused` whether this tab is in front. */
81 deliver(notification: FeedNotification, options: { toast: boolean; focused: boolean }): void;
82 /** What needs the person, for the app icon. */
83 setBadge(count: number): void;
84 /** Goes to a page on the site. */
85 open(href: string): void;
86}
87
88let navigator_: ((href: string) => void) | null = null;
89
90/** The router's navigate, so a toast or a notification click moves without a reload. */
91export function setNavigator(navigate: ((href: string) => void) | null): void {
92 navigator_ = navigate;
93}
94
95function openHref(href: string): void {
96 if (navigator_) navigator_(href);
97 else if (typeof location !== "undefined") location.assign(href);
98}
99
100/** The web's: a toast in the tab, a soft sound if wanted, the installed app's badge. */
101export const webSink: NotificationSink = {
102 kind: "web",
103 deliver(notification, { toast }) {
104 if (!toast) return;
105 showToast(notification);
106 if (soundOn()) chime();
107 },
108 setBadge(count) {
109 const nav = typeof navigator !== "undefined" ? (navigator as Navigator & { setAppBadge?(n: number): Promise<void>; clearAppBadge?(): Promise<void> }) : null;
110 try {
111 if (count > 0) void nav?.setAppBadge?.(count)?.catch(() => {});
112 else void nav?.clearAppBadge?.()?.catch(() => {});
113 } catch {
114 // Not installed, or not allowed: the tab title says it anyway.
115 }
116 },
117 open: openHref,
118};
119
120/** The desktop app's: toasts while its window is in front, native notifications while it is not. */
121export function desktopSink(bridge: DesktopBridge): NotificationSink {
122 return {
123 kind: "desktop",
124 deliver(notification, { toast, focused }) {
125 if (!toast) return;
126 if (focused) {
127 showToast(notification);
128 if (soundOn()) chime();
129 return;
130 }
131 bridge.notify({
132 id: notification.id,
133 title: notification.title,
134 body: notification.body,
135 href: notification.href,
136 tag: notification.channel_id ? `chat:${notification.channel_id}` : `${notification.kind}:${notification.id}`,
137 icon: null,
138 });
139 },
140 setBadge(count) {
141 bridge.setBadge(count);
142 },
143 open: openHref,
144 };
145}
146
147/** The desktop bridge's sink when there is one, else the web's. */
148export function currentSink(): NotificationSink {
149 const bridge = desktopBridge();
150 return bridge ? desktopSink(bridge) : webSink;
151}
152
153// ── The store ────────────────────────────────────────────────────────────
154
155export type NotifyState = {
156 /** Whether the feed socket is up. */
157 connected: boolean;
158 /** Counts by workspace, as the feed last sent them. */
159 counts: Record<string, FeedCounts>;
160 /** The inbox's unread, the person's whole inbox. */
161 inbox: number | null;
162 toasts: Toast[];
163 preferences: NotifyPreferences | null;
164 vapidKey: string | null;
165 /** Whether to show the offer to turn on browser notifications. */
166 offer: boolean;
167};
168
169let state: NotifyState = { connected: false, counts: {}, inbox: null, toasts: [], preferences: null, vapidKey: null, offer: false };
170const listeners = new Set<() => void>();
171const SERVER_STATE = state;
172
173function set(next: Partial<NotifyState>): void {
174 state = { ...state, ...next };
175 for (const listener of listeners) listener();
176}
177
178function subscribeStore(listener: () => void): () => void {
179 listeners.add(listener);
180 return () => listeners.delete(listener);
181}
182
183export function useNotifyState(): NotifyState {
184 return useSyncExternalStore(subscribeStore, () => state, () => SERVER_STATE);
185}
186
187/** The rail's numbers for a workspace from the feed, or null while it cannot say. */
188export function useLiveBadges(workspace: string | null | undefined): Partial<LiveBadges> | null {
189 const s = useNotifyState();
190 return badgesOf(workspace ? s.counts[workspace.toLowerCase()] : null, s.inbox);
191}
192
193/** A workspace's counts from the feed, for the Chat sidebar. */
194export function useLiveCounts(workspace: string | null | undefined): FeedCounts | null {
195 const s = useNotifyState();
196 return workspace ? (s.counts[workspace.toLowerCase()] ?? null) : null;
197}
198
199/** Whether the feed is up: pages fall back to a slow refresh only while it is not. */
200export function feedConnected(): boolean {
201 return state.connected;
202}
203
204export function useFeedConnected(): boolean {
205 return useNotifyState().connected;
206}
207
208let viewing: { path: string; channel_id?: string | null } = { path: "" };
209
210/** The page the person is on, so its own conversation does not toast. */
211export function setViewing(next: { path: string; channel_id?: string | null }): void {
212 viewing = next;
213 sendState();
214}
215
216function showToast(notification: FeedNotification): void {
217 const toasts = addToast(state.toasts, notification, viewing, Date.now());
218 if (toasts === state.toasts) return;
219 const offer =
220 state.offer ||
221 offerPush({
222 kind: notification.kind,
223 supported: pushSupported(),
224 permission: permission(),
225 choice: pushChoice(),
226 hasKey: !!state.vapidKey,
227 desktop: !!desktopBridge(),
228 });
229 set({ toasts, offer });
230}
231
232export function dismiss(id: string): void {
233 set({ toasts: dismissToast(state.toasts, id) });
234}
235
236export function closeOffer(): void {
237 set({ offer: false });
238}
239
240/** For the settings page's test, and the harness: a notification shown as if it arrived. */
241export function showLocally(notification: FeedNotification): void {
242 currentSink().deliver(notification, { toast: true, focused: true });
243}
244
245// ── The socket ───────────────────────────────────────────────────────────
246
247let socket: WebSocket | null = null;
248let workspace: string | null = null;
249let attempt = 0;
250let timer: ReturnType<typeof setTimeout> | null = null;
251let beat: ReturnType<typeof setInterval> | null = null;
252let running = false;
253let sink: NotificationSink = webSink;
254
255function focused(): boolean {
256 return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus();
257}
258
259function sendState(): void {
260 if (socket?.readyState !== WebSocket.OPEN) return;
261 try {
262 socket.send(JSON.stringify({ type: "state", focused: focused(), path: viewing.path }));
263 } catch {
264 // Closing; it comes back.
265 }
266}
267
268/** The inbox count a page just read, so every tab shows it. */
269export function reportInbox(unread: number): void {
270 if (state.inbox === unread) return;
271 set({ inbox: unread });
272 if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "inbox", unread }));
273}
274
275function onEvent(event: FeedEvent): void {
276 switch (event.type) {
277 case "hello":
278 set({ preferences: event.preferences, vapidKey: event.vapid_public_key });
279 break;
280 case "counts": {
281 const { type: _, ...counts } = event;
282 set({ counts: { ...state.counts, [counts.workspace]: counts }, inbox: counts.inbox_unread });
283 break;
284 }
285 case "notification":
286 sink.deliver(event.notification, { toast: event.toast, focused: focused() });
287 break;
288 case "preferences":
289 set({ preferences: event.preferences });
290 break;
291 case "inbox":
292 set({ inbox: event.unread });
293 break;
294 }
295}
296
297function connect(): void {
298 if (!running) return;
299 timer = null;
300 const scheme = location.protocol === "https:" ? "wss:" : "ws:";
301 const query = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
302 let ws: WebSocket;
303 try {
304 ws = new WebSocket(`${scheme}//${location.host}/-/live${query}`);
305 } catch {
306 schedule();
307 return;
308 }
309 socket = ws;
310 ws.onopen = () => {
311 attempt = 0;
312 set({ connected: true });
313 sendState();
314 };
315 ws.onmessage = (message) => {
316 if (message.data === "pong") return;
317 try {
318 onEvent(JSON.parse(String(message.data)) as FeedEvent);
319 } catch {
320 // Not an event this page knows.
321 }
322 };
323 ws.onclose = () => {
324 if (socket === ws) socket = null;
325 set({ connected: false });
326 schedule();
327 };
328 ws.onerror = () => ws.close();
329}
330
331function schedule(): void {
332 if (!running || timer) return;
333 timer = setTimeout(connect, reconnectDelay(attempt++));
334}
335
336/** Back now: the tab is shown, or the network returned. */
337function now(): void {
338 sendState();
339 if (!running || socket || document.visibilityState !== "visible") return;
340 if (timer) clearTimeout(timer);
341 timer = null;
342 attempt = 0;
343 connect();
344}
345
346function onRead(event: Event): void {
347 const channel = String((event as CustomEvent).detail ?? "");
348 if (!channel || !workspace || !state.counts[workspace]) return;
349 set({ counts: { ...state.counts, [workspace]: markReadLocally(state.counts[workspace], channel) } });
350}
351
352/**
353 * Opens the feed for the signed-in person (once per tab; calling again
354 * with another workspace reconnects, so the feed reads that workspace's
355 * counts). Returns how to close it.
356 */
357export function startFeed(forWorkspace: string | null): () => void {
358 const next = forWorkspace?.toLowerCase() ?? null;
359 if (running && next === workspace) return stopFeed;
360 workspace = next;
361 sink = currentSink();
362 if (running) {
363 // Another workspace: its counts are read on connect.
364 socket?.close(1000, "workspace");
365 return stopFeed;
366 }
367 running = true;
368 connect();
369 beat = setInterval(() => {
370 if (socket?.readyState === WebSocket.OPEN) socket.send("ping");
371 }, HEARTBEAT_MS);
372 document.addEventListener("visibilitychange", now);
373 window.addEventListener("focus", now);
374 window.addEventListener("blur", sendState);
375 window.addEventListener("online", now);
376 window.addEventListener("g1t:chat-read", onRead);
377 const bridge = desktopBridge();
378 const off = bridge?.onNavigate?.((href) => openHref(href));
379 unbridge = off ?? null;
380 return stopFeed;
381}
382
383let unbridge: (() => void) | null = null;
384
385export function stopFeed(): void {
386 running = false;
387 if (timer) clearTimeout(timer);
388 if (beat) clearInterval(beat);
389 timer = beat = null;
390 socket?.close(1000, "bye");
391 socket = null;
392 document.removeEventListener("visibilitychange", now);
393 window.removeEventListener("focus", now);
394 window.removeEventListener("blur", sendState);
395 window.removeEventListener("online", now);
396 window.removeEventListener("g1t:chat-read", onRead);
397 unbridge?.();
398 unbridge = null;
399 set({ connected: false });
400}
401
402/** Keeps the tab title's count and the app badge in step with `count`. */
403export function applyAttention(badges: Partial<LiveBadges> | null): number {
404 const count = attentionCount(badges);
405 if (typeof document !== "undefined") {
406 const next = titleWith(document.title, count);
407 if (next !== document.title) document.title = next;
408 }
409 sink.setBadge(count);
410 return count;
411}
412
413// ── Sound ────────────────────────────────────────────────────────────────
414
415const SOUND_KEY = "g1t:notify:sound";
416
417export function soundOn(): boolean {
418 try {
419 return localStorage.getItem(SOUND_KEY) === "on";
420 } catch {
421 return false;
422 }
423}
424
425export function setSound(on: boolean): void {
426 try {
427 if (on) localStorage.setItem(SOUND_KEY, "on");
428 else localStorage.removeItem(SOUND_KEY);
429 } catch {
430 // Kept for this page only.
431 }
432}
433
434let audio: AudioContext | null = null;
435
436/** A soft two-note chime, made here: no file to load. */
437export function chime(): void {
438 try {
439 audio ??= new AudioContext();
440 const at = audio.currentTime;
441 for (const [i, freq] of [880, 1318.5].entries()) {
442 const osc = audio.createOscillator();
443 const gain = audio.createGain();
444 osc.type = "sine";
445 osc.frequency.value = freq;
446 const start = at + i * 0.09;
447 gain.gain.setValueAtTime(0, start);
448 gain.gain.linearRampToValueAtTime(0.06, start + 0.015);
449 gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.35);
450 osc.connect(gain).connect(audio.destination);
451 osc.start(start);
452 osc.stop(start + 0.4);
453 }
454 } catch {
455 // No audio: nothing lost.
456 }
457}
458
459// ── Browser push ─────────────────────────────────────────────────────────
460
461const PUSH_KEY = "g1t:notify:push";
462
463export function pushChoice(): PushChoice {
464 try {
465 const value = localStorage.getItem(PUSH_KEY);
466 return value === "declined" || value === "off" || value === "on" ? value : null;
467 } catch {
468 return null;
469 }
470}
471
472function setPushChoice(choice: PushChoice): void {
473 try {
474 if (choice) localStorage.setItem(PUSH_KEY, choice);
475 else localStorage.removeItem(PUSH_KEY);
476 } catch {
477 // Asked again next time; nothing worse.
478 }
479}
480
481/** The person closed the offer or said no: never offered again. */
482export function declinePush(): void {
483 setPushChoice("declined");
484 set({ offer: false });
485}
486
487export function pushSupported(): boolean {
488 return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
489}
490
491export function permission(): "default" | "granted" | "denied" {
492 return typeof window !== "undefined" && "Notification" in window ? Notification.permission : "denied";
493}
494
495function keyBytes(base64url: string): Uint8Array<ArrayBuffer> {
496 const padded = base64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (base64url.length % 4)) % 4);
497 const raw = atob(padded);
498 const out = new Uint8Array(new ArrayBuffer(raw.length));
499 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
500 return out;
501}
502
503async function registration(): Promise<ServiceWorkerRegistration> {
504 const existing = await navigator.serviceWorker.getRegistration("/");
505 return existing ?? navigator.serviceWorker.register("/sw.js", { scope: "/" });
506}
507
508async function api(body: object): Promise<Response> {
509 return fetch("/-/notify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
510}
511
512/** The public key browsers subscribe with, from the feed or asked for. */
513async function vapidKey(): Promise<string | null> {
514 if (state.vapidKey) return state.vapidKey;
515 const status = await notifyStatus().catch(() => null);
516 return status?.vapid_public_key ?? null;
517}
518
519export type PushResult = "on" | "denied" | "unsupported" | "unavailable" | "failed";
520
521/**
522 * Asks the browser, then subscribes it: only ever from a click. A no is
523 * kept, so g1t never asks again.
524 */
525export async function enablePush(): Promise<PushResult> {
526 if (!pushSupported()) return "unsupported";
527 const key = await vapidKey();
528 if (!key) return "unavailable";
529 const answer = permission() === "granted" ? "granted" : await Notification.requestPermission();
530 if (answer !== "granted") {
531 setPushChoice("declined");
532 set({ offer: false });
533 return "denied";
534 }
535 try {
536 const reg = await registration();
537 await navigator.serviceWorker.ready;
538 const subscription = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) }));
539 const response = await api({ intent: "subscribe", subscription: subscription.toJSON() });
540 if (!response.ok) return "failed";
541 setPushChoice("on");
542 set({ offer: false });
543 return "on";
544 } catch (error) {
545 console.error("notify: could not subscribe", error);
546 return "failed";
547 }
548}
549
550/** Stops pushes to this browser; kept as the person's choice. */
551export async function disablePush(): Promise<void> {
552 setPushChoice("off");
553 if (!pushSupported()) return;
554 const reg = await navigator.serviceWorker.getRegistration("/");
555 const subscription = await reg?.pushManager.getSubscription();
556 if (!subscription) return;
557 await api({ intent: "unsubscribe", endpoint: subscription.endpoint }).catch(() => null);
558 await subscription.unsubscribe().catch(() => false);
559}
560
561/** This browser's push endpoint, if subscribed. */
562export async function currentEndpoint(): Promise<string | null> {
563 if (!pushSupported()) return null;
564 const reg = await navigator.serviceWorker.getRegistration("/");
565 return (await reg?.pushManager.getSubscription())?.endpoint ?? null;
566}
567
568export async function notifyStatus(): Promise<import("@g1t/contracts").NotifyStatus | null> {
569 const endpoint = await currentEndpoint().catch(() => null);
570 const response = await fetch(`/-/notify${endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : ""}`, { headers: { accept: "application/json" } });
571 return response.ok ? response.json() : null;
572}
573
574/**
575 * On each visit, for someone who turned pushes on: the service worker is
576 * registered, and the subscription is handed over again (browsers renew
577 * them), so pushes keep arriving. Asks nothing.
578 */
579export async function refreshPush(): Promise<void> {
580 if (!pushSupported() || desktopBridge()) return;
581 if (permission() !== "granted" || pushChoice() === "off") return;
582 const key = await vapidKey();
583 if (!key) return;
584 const reg = await registration();
585 await navigator.serviceWorker.ready;
586 let subscription = await reg.pushManager.getSubscription();
587 subscription ??= await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) });
588 await api({ intent: "subscribe", subscription: subscription.toJSON() });
589}
590
591export async function savePreferences(preferences: import("@g1t/contracts").NotifyPreferencesChange): Promise<NotifyPreferences | null> {
592 const response = await api({ intent: "preferences", preferences });
593 if (!response.ok) return null;
594 const saved = (await response.json()) as NotifyPreferences;
595 set({ preferences: saved });
596 return saved;
597}
598
599export async function sendTest(): Promise<{ ok: boolean; pushed: number } | null> {
600 const response = await api({ intent: "test" });
601 return response.ok ? response.json() : null;
602}
603
604/** Test seams: the store's state as given, for the harness. */
605export function setStateForTest(next: Partial<NotifyState>): void {
606 set(next);
607}

This file's history is long; its oldest lines are credited to the oldest commit read.