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

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