Skip to content
735 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.
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers12 *
13 * Presence rides the same socket: the tab says when it goes idle (no input
14 * for `IDLE_MS`) or comes back, the feed sends how everyone in the
15 * workspace shows (`presence`) and your own (`me`), and `setPresence`
16 * changes yours (lib/presence.ts, components/presence.tsx).
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)17 */
18import { useSyncExternalStore } from "react";
19
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers20import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts";
21
22import { isIdle, mergePeople } from "./presence";
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)23
24import {
25 HEARTBEAT_MS,
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread26 addRecent,
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)27 addToast,
28 attentionCount,
29 badgesOf,
30 dismissToast,
31 markReadLocally,
32 offerPush,
33 reconnectDelay,
34 titleWith,
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread35 waitingCards,
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)36 type LiveBadges,
37 type PushChoice,
38 type Toast,
39} from "./notify-store";
40
41// ── The desktop bridge ───────────────────────────────────────────────────
42
43/**
44 * What the desktop app (an Electron shell loading this web app; see
45 * docs/WORKSPACE.md, "Desktop app") exposes from its preload script with
46 * `contextBridge.exposeInMainWorld("g1tDesktop", …)`. When it is there,
47 * notifications go to it instead of the browser:
48 *
49 * ```ts
50 * window.g1tDesktop = {
51 * version: "1.0.0",
52 * // A native notification. Clicking it brings the window forward and
53 * // navigates to `href` (calling the `onNavigate` listeners).
54 * notify({ id, title, body, href, tag, icon }) {},
55 * // The dock or taskbar badge and the tray's count; 0 clears it.
56 * setBadge(count) {},
57 * // Opens a URL outside the app, in the system browser.
58 * openUrl(url) {},
59 * // g1t:// deep links and notification clicks, as site paths ("/acme/-/chat/dm/chn_1").
60 * onNavigate(listener) { return () => {} },
61 * };
62 * ```
63 */
64export type DesktopBridge = {
65 version?: string;
66 notify(notification: { id: string; title: string; body: string; href: string; tag: string; icon?: string | null }): void;
67 setBadge(count: number): void;
68 openUrl(url: string): void;
69 onNavigate?(listener: (href: string) => void): () => void;
70};
71
72declare global {
73 interface Window {
74 g1tDesktop?: DesktopBridge;
75 }
76}
77
78export function desktopBridge(): DesktopBridge | null {
79 if (typeof window === "undefined") return null;
80 const bridge = window.g1tDesktop;
81 return bridge && typeof bridge.notify === "function" && typeof bridge.setBadge === "function" ? bridge : null;
82}
83
84// ── Sinks ────────────────────────────────────────────────────────────────
85
86/** Where a live notification goes once it arrives. */
87export interface NotificationSink {
88 readonly kind: "web" | "desktop";
89 /** A notification arrived; `toast` says the preferences want it shown, `focused` whether this tab is in front. */
90 deliver(notification: FeedNotification, options: { toast: boolean; focused: boolean }): void;
91 /** What needs the person, for the app icon. */
92 setBadge(count: number): void;
93 /** Goes to a page on the site. */
94 open(href: string): void;
95}
96
97let navigator_: ((href: string) => void) | null = null;
98
99/** The router's navigate, so a toast or a notification click moves without a reload. */
100export function setNavigator(navigate: ((href: string) => void) | null): void {
101 navigator_ = navigate;
102}
103
104function openHref(href: string): void {
105 if (navigator_) navigator_(href);
106 else if (typeof location !== "undefined") location.assign(href);
107}
108
109/** The web's: a toast in the tab, a soft sound if wanted, the installed app's badge. */
110export const webSink: NotificationSink = {
111 kind: "web",
112 deliver(notification, { toast }) {
113 if (!toast) return;
114 showToast(notification);
115 if (soundOn()) chime();
116 },
117 setBadge(count) {
118 const nav = typeof navigator !== "undefined" ? (navigator as Navigator & { setAppBadge?(n: number): Promise<void>; clearAppBadge?(): Promise<void> }) : null;
119 try {
120 if (count > 0) void nav?.setAppBadge?.(count)?.catch(() => {});
121 else void nav?.clearAppBadge?.()?.catch(() => {});
122 } catch {
123 // Not installed, or not allowed: the tab title says it anyway.
124 }
125 },
126 open: openHref,
127};
128
129/** The desktop app's: toasts while its window is in front, native notifications while it is not. */
130export function desktopSink(bridge: DesktopBridge): NotificationSink {
131 return {
132 kind: "desktop",
133 deliver(notification, { toast, focused }) {
134 if (!toast) return;
135 if (focused) {
136 showToast(notification);
137 if (soundOn()) chime();
138 return;
139 }
140 bridge.notify({
141 id: notification.id,
142 title: notification.title,
143 body: notification.body,
144 href: notification.href,
145 tag: notification.channel_id ? `chat:${notification.channel_id}` : `${notification.kind}:${notification.id}`,
146 icon: null,
147 });
148 },
149 setBadge(count) {
150 bridge.setBadge(count);
151 },
152 open: openHref,
153 };
154}
155
156/** The desktop bridge's sink when there is one, else the web's. */
157export function currentSink(): NotificationSink {
158 const bridge = desktopBridge();
159 return bridge ? desktopSink(bridge) : webSink;
160}
161
162// ── The store ────────────────────────────────────────────────────────────
163
164export type NotifyState = {
165 /** Whether the feed socket is up. */
166 connected: boolean;
167 /** Counts by workspace, as the feed last sent them. */
168 counts: Record<string, FeedCounts>;
169 /** The inbox's unread, the person's whole inbox. */
170 inbox: number | null;
171 toasts: Toast[];
172 preferences: NotifyPreferences | null;
173 vapidKey: string | null;
174 /** Whether to show the offer to turn on browser notifications. */
175 offer: boolean;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers176 /** How the people who share a workspace with you show, by user id. */
177 people: Record<string, PresenceEntry>;
178 /** Your own presence, status and Do Not Disturb, once the feed has said. */
179 me: OwnPresence | null;
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread180 /** The latest notifications, newest first: the panel's cards waiting on you come from these. */
181 recent: FeedNotification[];
182 /** Card notifications acted on (or put away) in this tab, by id. */
183 settled: ReadonlySet<string>;
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)184};
185
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread186let state: NotifyState = {
187 connected: false,
188 counts: {},
189 inbox: null,
190 toasts: [],
191 preferences: null,
192 vapidKey: null,
193 offer: false,
194 people: {},
195 me: null,
196 recent: [],
197 settled: new Set(),
198};
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)199const listeners = new Set<() => void>();
200const SERVER_STATE = state;
201
202function set(next: Partial<NotifyState>): void {
203 state = { ...state, ...next };
204 for (const listener of listeners) listener();
205}
206
207function subscribeStore(listener: () => void): () => void {
208 listeners.add(listener);
209 return () => listeners.delete(listener);
210}
211
212export function useNotifyState(): NotifyState {
213 return useSyncExternalStore(subscribeStore, () => state, () => SERVER_STATE);
214}
215
216/** The rail's numbers for a workspace from the feed, or null while it cannot say. */
217export function useLiveBadges(workspace: string | null | undefined): Partial<LiveBadges> | null {
218 const s = useNotifyState();
219 return badgesOf(workspace ? s.counts[workspace.toLowerCase()] : null, s.inbox);
220}
221
222/** A workspace's counts from the feed, for the Chat sidebar. */
223export function useLiveCounts(workspace: string | null | undefined): FeedCounts | null {
224 const s = useNotifyState();
225 return workspace ? (s.counts[workspace.toLowerCase()] ?? null) : null;
226}
227
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread228/** A card's notification was acted on, or put away: its toast goes, and the panel stops waiting on it. */
229export function settle(id: string): void {
230 if (state.settled.has(id)) return;
231 set({ settled: new Set(state.settled).add(id), toasts: dismissToast(state.toasts, id) });
232}
233
234/** The chat cards waiting on the person (docs/WORKSPACE.md, "Cards"), newest first, for the panel. */
235export function useWaitingCards(workspace?: string | null): FeedNotification[] {
236 const s = useNotifyState();
237 return waitingCards(s.recent, s.settled, Date.now(), workspace);
238}
239
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers240/** People's presence by user id, with an index by username (lowercased). */
241let peopleIndex: { from: Record<string, PresenceEntry>; byName: Map<string, PresenceEntry> } = { from: {}, byName: new Map() };
242
243function byName(people: Record<string, PresenceEntry>): Map<string, PresenceEntry> {
244 if (peopleIndex.from !== people) {
245 peopleIndex = { from: people, byName: new Map(Object.values(people).map((entry) => [entry.username.toLowerCase(), entry])) };
246 }
247 return peopleIndex.byName;
248}
249
250/**
251 * How a person shows, by id or username, or null while the feed has not
252 * said (they share no workspace with you, or the socket is not up yet).
253 * Your own comes from `me`, so it moves the moment you change it.
254 */
255export function usePresenceOf(person: { id?: string | null; username?: string | null } | null | undefined): PresenceEntry | null {
256 const s = useNotifyState();
257 if (!person) return null;
258 const mine = s.me && ((person.id && person.id === s.me.user_id) || (person.username && person.username.toLowerCase() === s.me.username.toLowerCase()));
259 if (mine) return s.me;
260 if (person.id && s.people[person.id]) return s.people[person.id]!;
261 return person.username ? (byName(s.people).get(person.username.toLowerCase()) ?? null) : null;
262}
263
264export function useOwnPresence(): OwnPresence | null {
265 return useNotifyState().me;
266}
267
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)268/** Whether the feed is up: pages fall back to a slow refresh only while it is not. */
269export function feedConnected(): boolean {
270 return state.connected;
271}
272
273export function useFeedConnected(): boolean {
274 return useNotifyState().connected;
275}
276
277let viewing: { path: string; channel_id?: string | null } = { path: "" };
278
279/** The page the person is on, so its own conversation does not toast. */
280export function setViewing(next: { path: string; channel_id?: string | null }): void {
281 viewing = next;
282 sendState();
283}
284
285function showToast(notification: FeedNotification): void {
286 const toasts = addToast(state.toasts, notification, viewing, Date.now());
287 if (toasts === state.toasts) return;
288 const offer =
289 state.offer ||
290 offerPush({
291 kind: notification.kind,
292 supported: pushSupported(),
293 permission: permission(),
294 choice: pushChoice(),
295 hasKey: !!state.vapidKey,
296 desktop: !!desktopBridge(),
297 });
298 set({ toasts, offer });
299}
300
301export function dismiss(id: string): void {
302 set({ toasts: dismissToast(state.toasts, id) });
303}
304
305export function closeOffer(): void {
306 set({ offer: false });
307}
308
309/** For the settings page's test, and the harness: a notification shown as if it arrived. */
310export function showLocally(notification: FeedNotification): void {
311 currentSink().deliver(notification, { toast: true, focused: true });
312}
313
314// ── The socket ───────────────────────────────────────────────────────────
315
316let socket: WebSocket | null = null;
317let workspace: string | null = null;
318let attempt = 0;
319let timer: ReturnType<typeof setTimeout> | null = null;
320let beat: ReturnType<typeof setInterval> | null = null;
321let running = false;
322let sink: NotificationSink = webSink;
323
324function focused(): boolean {
325 return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus();
326}
327
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers328// ── Idle ─────────────────────────────────────────────────────────────────
329
330let lastInput = Date.now();
331let idle = false;
332
333/** Input in this tab: back from idle at once, if it was. */
334function onInput(): void {
335 lastInput = Date.now();
336 if (idle) {
337 idle = false;
338 sendState();
339 }
340}
341
342/** Every heartbeat: gone idle since the last one? */
343function checkIdle(): void {
344 if (!idle && isIdle(lastInput, Date.now())) {
345 idle = true;
346 sendState();
347 }
348}
349
350const INPUT_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel", "touchstart"] as const;
351
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)352function sendState(): void {
353 if (socket?.readyState !== WebSocket.OPEN) return;
354 try {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers355 socket.send(JSON.stringify({ type: "state", focused: focused(), path: viewing.path, idle }));
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)356 } catch {
357 // Closing; it comes back.
358 }
359}
360
361/** The inbox count a page just read, so every tab shows it. */
362export function reportInbox(unread: number): void {
363 if (state.inbox === unread) return;
364 set({ inbox: unread });
365 if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "inbox", unread }));
366}
367
368function onEvent(event: FeedEvent): void {
369 switch (event.type) {
370 case "hello":
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread371 set({ preferences: event.preferences, vapidKey: event.vapid_public_key, recent: addRecent(state.recent, event.notifications ?? []) });
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)372 break;
373 case "counts": {
374 const { type: _, ...counts } = event;
375 set({ counts: { ...state.counts, [counts.workspace]: counts }, inbox: counts.inbox_unread });
376 break;
377 }
378 case "notification":
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread379 set({ recent: addRecent(state.recent, [event.notification]) });
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)380 sink.deliver(event.notification, { toast: event.toast, focused: focused() });
381 break;
382 case "preferences":
383 set({ preferences: event.preferences });
384 break;
385 case "inbox":
386 set({ inbox: event.unread });
387 break;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers388 case "presence": {
389 const people = mergePeople(state.people, event.people, event.full);
390 if (people !== state.people) set({ people });
391 break;
392 }
393 case "me":
394 set({ me: event.me });
395 break;
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)396 }
397}
398
399function connect(): void {
400 if (!running) return;
401 timer = null;
402 const scheme = location.protocol === "https:" ? "wss:" : "ws:";
403 const query = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
404 let ws: WebSocket;
405 try {
406 ws = new WebSocket(`${scheme}//${location.host}/-/live${query}`);
407 } catch {
408 schedule();
409 return;
410 }
411 socket = ws;
412 ws.onopen = () => {
413 attempt = 0;
414 set({ connected: true });
415 sendState();
416 };
417 ws.onmessage = (message) => {
418 if (message.data === "pong") return;
419 try {
420 onEvent(JSON.parse(String(message.data)) as FeedEvent);
421 } catch {
422 // Not an event this page knows.
423 }
424 };
425 ws.onclose = () => {
426 if (socket === ws) socket = null;
427 set({ connected: false });
428 schedule();
429 };
430 ws.onerror = () => ws.close();
431}
432
433function schedule(): void {
434 if (!running || timer) return;
435 timer = setTimeout(connect, reconnectDelay(attempt++));
436}
437
438/** Back now: the tab is shown, or the network returned. */
439function now(): void {
440 sendState();
441 if (!running || socket || document.visibilityState !== "visible") return;
442 if (timer) clearTimeout(timer);
443 timer = null;
444 attempt = 0;
445 connect();
446}
447
448function onRead(event: Event): void {
449 const channel = String((event as CustomEvent).detail ?? "");
450 if (!channel || !workspace || !state.counts[workspace]) return;
451 set({ counts: { ...state.counts, [workspace]: markReadLocally(state.counts[workspace], channel) } });
452}
453
454/**
455 * Opens the feed for the signed-in person (once per tab; calling again
456 * with another workspace reconnects, so the feed reads that workspace's
457 * counts). Returns how to close it.
458 */
459export function startFeed(forWorkspace: string | null): () => void {
460 const next = forWorkspace?.toLowerCase() ?? null;
461 if (running && next === workspace) return stopFeed;
462 workspace = next;
463 sink = currentSink();
464 if (running) {
465 // Another workspace: its counts are read on connect.
466 socket?.close(1000, "workspace");
467 return stopFeed;
468 }
469 running = true;
470 connect();
471 beat = setInterval(() => {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers472 checkIdle();
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)473 if (socket?.readyState === WebSocket.OPEN) socket.send("ping");
474 }, HEARTBEAT_MS);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers475 lastInput = Date.now();
476 idle = false;
477 for (const name of INPUT_EVENTS) window.addEventListener(name, onInput, { passive: true, capture: true });
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)478 document.addEventListener("visibilitychange", now);
479 window.addEventListener("focus", now);
480 window.addEventListener("blur", sendState);
481 window.addEventListener("online", now);
482 window.addEventListener("g1t:chat-read", onRead);
483 const bridge = desktopBridge();
484 const off = bridge?.onNavigate?.((href) => openHref(href));
485 unbridge = off ?? null;
486 return stopFeed;
487}
488
489let unbridge: (() => void) | null = null;
490
491export function stopFeed(): void {
492 running = false;
493 if (timer) clearTimeout(timer);
494 if (beat) clearInterval(beat);
495 timer = beat = null;
496 socket?.close(1000, "bye");
497 socket = null;
498 document.removeEventListener("visibilitychange", now);
499 window.removeEventListener("focus", now);
500 window.removeEventListener("blur", sendState);
501 window.removeEventListener("online", now);
502 window.removeEventListener("g1t:chat-read", onRead);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers503 for (const name of INPUT_EVENTS) window.removeEventListener(name, onInput, { capture: true });
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)504 unbridge?.();
505 unbridge = null;
506 set({ connected: false });
507}
508
509/** Keeps the tab title's count and the app badge in step with `count`. */
510export function applyAttention(badges: Partial<LiveBadges> | null): number {
511 const count = attentionCount(badges);
512 if (typeof document !== "undefined") {
513 const next = titleWith(document.title, count);
514 if (next !== document.title) document.title = next;
515 }
516 sink.setBadge(count);
517 return count;
518}
519
520// ── Sound ────────────────────────────────────────────────────────────────
521
522const SOUND_KEY = "g1t:notify:sound";
523
524export function soundOn(): boolean {
525 try {
526 return localStorage.getItem(SOUND_KEY) === "on";
527 } catch {
528 return false;
529 }
530}
531
532export function setSound(on: boolean): void {
533 try {
534 if (on) localStorage.setItem(SOUND_KEY, "on");
535 else localStorage.removeItem(SOUND_KEY);
536 } catch {
537 // Kept for this page only.
538 }
539}
540
541let audio: AudioContext | null = null;
542
543/** A soft two-note chime, made here: no file to load. */
544export function chime(): void {
545 try {
546 audio ??= new AudioContext();
547 const at = audio.currentTime;
548 for (const [i, freq] of [880, 1318.5].entries()) {
549 const osc = audio.createOscillator();
550 const gain = audio.createGain();
551 osc.type = "sine";
552 osc.frequency.value = freq;
553 const start = at + i * 0.09;
554 gain.gain.setValueAtTime(0, start);
555 gain.gain.linearRampToValueAtTime(0.06, start + 0.015);
556 gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.35);
557 osc.connect(gain).connect(audio.destination);
558 osc.start(start);
559 osc.stop(start + 0.4);
560 }
561 } catch {
562 // No audio: nothing lost.
563 }
564}
565
566// ── Browser push ─────────────────────────────────────────────────────────
567
568const PUSH_KEY = "g1t:notify:push";
569
570export function pushChoice(): PushChoice {
571 try {
572 const value = localStorage.getItem(PUSH_KEY);
573 return value === "declined" || value === "off" || value === "on" ? value : null;
574 } catch {
575 return null;
576 }
577}
578
579function setPushChoice(choice: PushChoice): void {
580 try {
581 if (choice) localStorage.setItem(PUSH_KEY, choice);
582 else localStorage.removeItem(PUSH_KEY);
583 } catch {
584 // Asked again next time; nothing worse.
585 }
586}
587
588/** The person closed the offer or said no: never offered again. */
589export function declinePush(): void {
590 setPushChoice("declined");
591 set({ offer: false });
592}
593
594export function pushSupported(): boolean {
595 return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
596}
597
598export function permission(): "default" | "granted" | "denied" {
599 return typeof window !== "undefined" && "Notification" in window ? Notification.permission : "denied";
600}
601
602function keyBytes(base64url: string): Uint8Array<ArrayBuffer> {
603 const padded = base64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (base64url.length % 4)) % 4);
604 const raw = atob(padded);
605 const out = new Uint8Array(new ArrayBuffer(raw.length));
606 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
607 return out;
608}
609
610async function registration(): Promise<ServiceWorkerRegistration> {
611 const existing = await navigator.serviceWorker.getRegistration("/");
612 return existing ?? navigator.serviceWorker.register("/sw.js", { scope: "/" });
613}
614
615async function api(body: object): Promise<Response> {
616 return fetch("/-/notify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
617}
618
619/** The public key browsers subscribe with, from the feed or asked for. */
620async function vapidKey(): Promise<string | null> {
621 if (state.vapidKey) return state.vapidKey;
622 const status = await notifyStatus().catch(() => null);
623 return status?.vapid_public_key ?? null;
624}
625
626export type PushResult = "on" | "denied" | "unsupported" | "unavailable" | "failed";
627
628/**
629 * Asks the browser, then subscribes it: only ever from a click. A no is
630 * kept, so g1t never asks again.
631 */
632export async function enablePush(): Promise<PushResult> {
633 if (!pushSupported()) return "unsupported";
634 const key = await vapidKey();
635 if (!key) return "unavailable";
636 const answer = permission() === "granted" ? "granted" : await Notification.requestPermission();
637 if (answer !== "granted") {
638 setPushChoice("declined");
639 set({ offer: false });
640 return "denied";
641 }
642 try {
643 const reg = await registration();
644 await navigator.serviceWorker.ready;
645 const subscription = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) }));
646 const response = await api({ intent: "subscribe", subscription: subscription.toJSON() });
647 if (!response.ok) return "failed";
648 setPushChoice("on");
649 set({ offer: false });
650 return "on";
651 } catch (error) {
652 console.error("notify: could not subscribe", error);
653 return "failed";
654 }
655}
656
657/** Stops pushes to this browser; kept as the person's choice. */
658export async function disablePush(): Promise<void> {
659 setPushChoice("off");
660 if (!pushSupported()) return;
661 const reg = await navigator.serviceWorker.getRegistration("/");
662 const subscription = await reg?.pushManager.getSubscription();
663 if (!subscription) return;
664 await api({ intent: "unsubscribe", endpoint: subscription.endpoint }).catch(() => null);
665 await subscription.unsubscribe().catch(() => false);
666}
667
668/** This browser's push endpoint, if subscribed. */
669export async function currentEndpoint(): Promise<string | null> {
670 if (!pushSupported()) return null;
671 const reg = await navigator.serviceWorker.getRegistration("/");
672 return (await reg?.pushManager.getSubscription())?.endpoint ?? null;
673}
674
675export async function notifyStatus(): Promise<import("@g1t/contracts").NotifyStatus | null> {
676 const endpoint = await currentEndpoint().catch(() => null);
677 const response = await fetch(`/-/notify${endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : ""}`, { headers: { accept: "application/json" } });
678 return response.ok ? response.json() : null;
679}
680
681/**
682 * On each visit, for someone who turned pushes on: the service worker is
683 * registered, and the subscription is handed over again (browsers renew
684 * them), so pushes keep arriving. Asks nothing.
685 */
686export async function refreshPush(): Promise<void> {
687 if (!pushSupported() || desktopBridge()) return;
688 if (permission() !== "granted" || pushChoice() === "off") return;
689 const key = await vapidKey();
690 if (!key) return;
691 const reg = await registration();
692 await navigator.serviceWorker.ready;
693 let subscription = await reg.pushManager.getSubscription();
694 subscription ??= await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) });
695 await api({ intent: "subscribe", subscription: subscription.toJSON() });
696}
697
698export async function savePreferences(preferences: import("@g1t/contracts").NotifyPreferencesChange): Promise<NotifyPreferences | null> {
699 const response = await api({ intent: "preferences", preferences });
700 if (!response.ok) return null;
701 const saved = (await response.json()) as NotifyPreferences;
702 set({ preferences: saved });
703 return saved;
704}
705
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers706/**
707 * Changes your own presence: a status, being away, Do Not Disturb. Shown
708 * at once, and put back if the feed refuses; every tab and everyone who
709 * shares a workspace with you hear of it over the socket.
710 */
711export async function setPresence(change: PresenceChange, optimistic?: Partial<OwnPresence>): Promise<OwnPresence | null> {
712 const before = state.me;
713 if (before && optimistic) set({ me: { ...before, ...optimistic } });
714 try {
715 const response = await api({ intent: "presence", change });
716 if (!response.ok) throw new Error(`status ${response.status}`);
717 const me = (await response.json()) as OwnPresence;
718 set({ me });
719 return me;
720 } catch (error) {
721 console.error("notify: could not change presence", error);
722 if (optimistic) set({ me: before });
723 return null;
724 }
725}
726
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)727export async function sendTest(): Promise<{ ok: boolean; pushed: number } | null> {
728 const response = await api({ intent: "test" });
729 return response.ok ? response.json() : null;
730}
731
732/** Test seams: the store's state as given, for the harness. */
733export function setStateForTest(next: Partial<NotifyState>): void {
734 set(next);
735}

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