Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
163 linesCodeBlameRaw
1/**
2 * Where chat's events meet the sound rules: a message over the open
3 * conversation's socket (components/chat/channel.tsx) or a notification
4 * over the feed (lib/notify-client.ts) comes here with what the page knows
5 * of itself, lib/chat-sounds.ts decides, and lib/sounds.ts plays. One gate
6 * keeps sounds apart. Browser-only; the rules it runs are tested in
7 * chat-sounds.test.ts.
8 *
9 * It also shows the system notification (the browser's Notifications API)
10 * a person asked for under Desktop notifications: for a message to them
11 * while the window is not in front, from the open tab itself, with no
12 * service worker and no push involved.
13 *
14 * Every open tab hears the same arrival, so one of them pings: a sound and
15 * a toast are played only by the tab that claims the arrival first across
16 * the browser (lib/one-tab.ts), the one in front when there is one.
17 */
18import type { ChatMessage, FeedNotification } from "@g1t/contracts";
19import { type SoundCue, cueOn } from "@g1t/contracts/sounds";
20
21import { type Arrival, type FocusState, SoundGate, arrivalKey, cueFor, wantsDesktopToast } from "./chat-sounds";
22import { claimPing } from "./one-tab";
23import { play, soundSettings } from "./sounds";
24
25let me: { id: string; username: string } | null = null;
26let viewing: string | null = null;
27let muted: ReadonlySet<string> = new Set();
28const gate = new SoundGate();
29
30/** Who is signed in, from the root with the page: their own messages never sound. */
31export function setSoundViewer(viewer: { id: string; username: string } | null): void {
32 me = viewer;
33}
34
35/** The conversation on screen, from its page: null when leaving it. */
36export function viewingConversation(channelId: string | null): void {
37 viewing = channelId;
38}
39
40export function viewingChannel(): string | null {
41 return viewing;
42}
43
44/** The conversations muted in the sidebar, as it last read them. */
45export function setMutedConversations(channelIds: Iterable<string>): void {
46 muted = new Set(channelIds);
47}
48
49/** What the tab can see of itself right now. */
50export function focusState(): FocusState {
51 const has = typeof document !== "undefined";
52 return {
53 visible: has && document.visibilityState === "visible",
54 focused: has && document.hasFocus(),
55 viewingChannel: viewing,
56 };
57}
58
59/** The cue this arrival earns in this tab, through the tab's own gate; nothing is played yet. */
60function sound(arrival: Arrival, dnd: boolean, mutedHere?: boolean): SoundCue | null {
61 const all = mutedHere ? new Set([...muted, arrival.source === "chat" ? arrival.message.channel_id : (arrival.notification.channel_id ?? "")]) : muted;
62 const cue = cueFor(arrival, { me, focus: focusState(), muted: all, dnd, settings: soundSettings() });
63 // Heard of either way, so the same message over the other door stays quiet.
64 const allowed = gate.allow(arrivalKey(arrival), Date.now());
65 return cue && allowed ? cue : null;
66}
67
68/** Pings once across the browser: the tab that claims the arrival plays the sound and shows the toast. */
69function ping(arrival: Arrival, cue: SoundCue | null, toast: (() => void) | null): void {
70 if (!cue && !toast) return;
71 const key = arrivalKey(arrival) ?? `${arrival.source}:${Date.now()}`;
72 const focus = focusState();
73 void claimPing(key, focus.visible && focus.focused).then((mine) => {
74 if (!mine) return;
75 if (cue) play(cue);
76 toast?.();
77 });
78}
79
80/**
81 * A new message in the conversation open on this page. `muted` is that
82 * conversation's own setting, which the sidebar may not have told us yet.
83 * Returns what played, for the harness.
84 */
85export function chatMessageArrived(message: ChatMessage, channelKind: "channel" | "dm", options: { dnd: boolean; muted?: boolean }): SoundCue | null {
86 const arrival: Arrival = { source: "chat", message, channelKind };
87 const cue = sound(arrival, options.dnd, options.muted);
88 ping(arrival, cue, null);
89 return cue;
90}
91
92/**
93 * A notification over the feed. `toast` is the feed's own decision about a
94 * pop-up; the sound is decided here from the same facts. Shows the system
95 * notification too, when wanted (`wantsDesktopToast`).
96 */
97export function notificationArrived(
98 notification: FeedNotification,
99 options: {
100 toast: boolean;
101 dnd: boolean;
102 permission: "default" | "granted" | "denied";
103 pushOn: boolean;
104 open: (href: string) => void;
105 },
106): { cue: SoundCue | null; desktopToast: boolean } {
107 const arrival: Arrival = { source: "feed", notification };
108 const cue = sound(arrival, options.dnd);
109 const desktopToast = wantsDesktopToast({
110 notification,
111 toast: options.toast,
112 ctx: { focus: focusState(), dnd: options.dnd, settings: soundSettings() },
113 permission: options.permission,
114 pushOn: options.pushOn,
115 });
116 ping(arrival, cue, desktopToast ? () => showDesktopToast(notification, options.open) : null);
117 return { cue, desktopToast };
118}
119
120/** The soft tick as you send, if turned on (it is off until then), and never while not to be disturbed. */
121export function messageSent(options: { dnd: boolean }): void {
122 const settings = soundSettings();
123 if (!settings.sounds_enabled || options.dnd || !cueOn(settings, "sent")) return;
124 play("sent");
125}
126
127/**
128 * The browser's own notification, from this tab: it closes itself after a
129 * while, a newer one about the same conversation replaces it (`tag`), and a
130 * click brings the window forward and opens the conversation. Never asks
131 * for permission: that is the settings page's button alone.
132 */
133export function showDesktopToast(notification: FeedNotification, open: (href: string) => void): void {
134 try {
135 // Inside the desktop app the shell shows it (lib/notify-client.ts, desktopSink).
136 if (typeof window !== "undefined" && window.g1tDesktop) return;
137 if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
138 const shown = new Notification(notification.title, {
139 body: notification.body,
140 tag: notification.channel_id ? `chat:${notification.channel_id}` : `${notification.kind}:${notification.id}`,
141 icon: "/icon-192.png",
142 silent: true,
143 });
144 shown.onclick = () => {
145 try {
146 window.focus();
147 } catch {
148 // Some browsers refuse; the page still moves.
149 }
150 open(notification.href);
151 shown.close();
152 };
153 setTimeout(() => shown.close(), 8_000);
154 } catch {
155 // A browser that has the API but not here (an iframe, say): nothing shown.
156 }
157}
158
159/** Test seam: the gate forgets everything, so a harness can play cue after cue. */
160export function resetSoundGateForTest(): void {
161 (gate as unknown as { lastAt: number; seen: Map<string, number> }).lastAt = Number.NEGATIVE_INFINITY;
162 (gate as unknown as { seen: Map<string, number> }).seen.clear();
163}