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

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