Skip to content
182 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.

Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.1/**
2 * When chat makes a sound, apart from the browser: which cue a message or
3 * a notification earns, given who you are, what you are looking at, what
4 * you muted and whether you are not to be disturbed; and how close
5 * together two sounds may come. Pure, so every rule is tested on its own
6 * (chat-sounds.test.ts); lib/sound-events.ts runs it on each event with
7 * the page's real state, and lib/sounds.ts plays the result.
8 *
9 * The rules, as the chat guide says them ("Sounds and do not disturb"):
10 *
11 * - `mention` for a message that @mentions you, `direct` for a direct
12 * message, `message` for a message in a conversation you have open while
13 * the window is not in front or you are in another conversation, and
14 * `agent_done` when an agent posts a card saying it finished something.
15 * - Never for your own messages, never in a muted conversation, never
16 * while Do not disturb is on, and never for the conversation you are
17 * looking at in a window that is in front: what is on screen is silent.
18 * - Agents' messages count like people's.
19 * - At most one sound every 1.5 seconds, and never twice for one message,
20 * however many ways it arrives (its conversation's socket and your feed).
21 */
22import type { ChatMessage, FeedNotification } from "@g1t/contracts";
23import { type SoundCue, type SoundSettings, cueOn } from "@g1t/contracts/sounds";
24
25/** What the tab can see of itself: shown, in front, and which conversation it has open. */
26export type FocusState = {
27 /** `document.visibilityState === "visible"`. */
28 visible: boolean;
29 /** `document.hasFocus()`: the window is in front and this tab has the keyboard. */
30 focused: boolean;
31 /** The conversation on screen, by channel id; null on any other page. */
32 viewingChannel: string | null;
33};
34
35export type SoundContext = {
36 me: { id: string; username: string } | null;
37 focus: FocusState;
38 /** Conversations muted in the sidebar, by channel id. */
39 muted: ReadonlySet<string>;
40 /** Do not disturb holds (the person's presence, `dnd_until`). */
41 dnd: boolean;
42 settings: SoundSettings;
43};
44
45/**
46 * Something that arrived: a message over the open conversation's socket,
47 * or a notification over the feed (which only carries what is for you: a
48 * DM, a mention, a reply in your thread, an agent waiting on you).
49 */
50export type Arrival =
51 | { source: "chat"; message: ChatMessage; channelKind: "channel" | "dm" }
52 | { source: "feed"; notification: FeedNotification };
53
54/** No two sounds closer than this: a burst of messages is one sound. */
55export const SOUND_GAP_MS = 1_500;
56/** The same message is not sounded again within this, whichever way it arrives. */
57export const SAME_MESSAGE_MS = 10_000;
58
59/** `@you` in a message's text: whole handles only, so `@you-and-me` is not you, and not inside code. */
60export function mentionsMe(body: string, username: string): boolean {
61 const name = username.trim().toLowerCase();
62 if (!name) return false;
63 const outsideCode = String(body ?? "").replace(/```[\s\S]*?(```|$)/g, " ").replace(/`[^`\n]*`/g, " ");
64 const pattern = new RegExp(`(^|[^a-z0-9_.@-])@${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9_/-])`, "i");
65 return pattern.test(outsideCode);
66}
67
68/** States a card shows when the agent's work is done, as agents' cards word them. */
69const FINISHED = /^(done|finished|complete|completed|filed|merged|shipped|ready|passed)\b/i;
70
71/** Whether a message is an agent's card saying it finished something. */
72export function finishedCard(message: Pick<ChatMessage, "kind" | "author" | "card">): boolean {
73 if (message.kind !== "card" || message.author.kind !== "agent" || !message.card) return false;
74 return FINISHED.test(message.card.state ?? "") || FINISHED.test(message.card.detail ?? "");
75}
76
77/** What a feed notification's kind sounds like; null for what has no sound (inbox items have the bell). */
78export function cueForNotificationKind(kind: FeedNotification["kind"]): SoundCue | null {
79 switch (kind) {
80 case "dm":
81 return "direct";
82 case "mention":
83 case "approval":
84 return "mention";
85 case "thread_reply":
86 return "message";
87 case "agent_waiting":
88 return "agent_done";
89 default:
90 return null;
91 }
92}
93
94/** The conversation an arrival is in, by channel id; null for an inbox notification. */
95export function channelOf(arrival: Arrival): string | null {
96 return arrival.source === "chat" ? arrival.message.channel_id : (arrival.notification.channel_id ?? null);
97}
98
99/** Whether `me` wrote it. */
100function mine(arrival: Arrival, me: SoundContext["me"]): boolean {
101 if (!me) return false;
102 const who = arrival.source === "chat" ? arrival.message.author : arrival.notification.actor;
103 return who.kind === "user" && who.id === me.id;
104}
105
106/** Whether the person is looking at the conversation right now: on screen, in a window in front. */
107export function lookingAt(channelId: string | null, focus: FocusState): boolean {
108 return channelId !== null && focus.visible && focus.focused && focus.viewingChannel === channelId;
109}
110
111/** The cue an arrival earns, or null for silence. Every rule above, in order. */
112export function cueFor(arrival: Arrival, ctx: SoundContext): SoundCue | null {
113 if (!ctx.settings.sounds_enabled || ctx.dnd) return null;
114 if (mine(arrival, ctx.me)) return null;
115 const channel = channelOf(arrival);
116 if (channel && ctx.muted.has(channel)) return null;
117 if (lookingAt(channel, ctx.focus)) return null;
118 let cue: SoundCue | null;
119 if (arrival.source === "chat") {
120 const { message } = arrival;
121 if (message.deleted_at) return null;
122 if (finishedCard(message)) cue = "agent_done";
123 else if (ctx.me && mentionsMe(message.body, ctx.me.username)) cue = "mention";
124 else cue = arrival.channelKind === "dm" ? "direct" : "message";
125 } else {
126 cue = cueForNotificationKind(arrival.notification.kind);
127 }
128 return cue && cueOn(ctx.settings, cue) ? cue : null;
129}
130
131/** What identifies an arrival, so the same message sounds once: its message id. */
132export function arrivalKey(arrival: Arrival): string {
133 return arrival.source === "chat" ? `msg:${arrival.message.id}` : `msg:${arrival.notification.id}`;
134}
135
136/**
137 * Keeps sounds apart: one every `SOUND_GAP_MS` at most, and the same key
138 * (a message id) once in `SAME_MESSAGE_MS`. `allow` says whether to play
139 * now, and remembers it if so.
140 */
141export class SoundGate {
142 private lastAt = Number.NEGATIVE_INFINITY;
143 private readonly seen = new Map<string, number>();
144 private readonly gapMs: number;
145 private readonly sameMs: number;
146
147 constructor(gapMs = SOUND_GAP_MS, sameMs = SAME_MESSAGE_MS) {
148 this.gapMs = gapMs;
149 this.sameMs = sameMs;
150 }
151
152 allow(key: string | null, now: number): boolean {
153 for (const [k, at] of this.seen) if (now - at > this.sameMs) this.seen.delete(k);
154 if (key !== null && this.seen.has(key)) return false;
155 if (key !== null) this.seen.set(key, now);
156 if (now - this.lastAt < this.gapMs) return false;
157 this.lastAt = now;
158 return true;
159 }
160}
161
162/**
163 * Whether an open tab shows a system notification for a feed notification:
164 * the person turned desktop notifications on and the browser allows them,
165 * the window is not in front, it is something for them that the feed would
166 * toast, and this browser does not already get pushes (which show the same
167 * notification once no tab is in front).
168 */
169export function wantsDesktopToast(input: {
170 notification: Pick<FeedNotification, "kind">;
171 toast: boolean;
172 ctx: Pick<SoundContext, "focus" | "dnd" | "settings">;
173 permission: "default" | "granted" | "denied";
174 /** This browser is subscribed to pushes (`pushChoice() === "on"`). */
175 pushOn: boolean;
176}): boolean {
177 const { ctx } = input;
178 if (!ctx.settings.desktop_toasts || input.permission !== "granted" || input.pushOn) return false;
179 if (ctx.dnd || !input.toast) return false;
180 if (ctx.focus.visible && ctx.focus.focused) return false;
181 return cueForNotificationKind(input.notification.kind) !== null;
182}