Skip to content
183 linesCodeBlameRaw
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 case "security":
85 return "mention";
86 case "thread_reply":
87 return "message";
88 case "agent_waiting":
89 return "agent_done";
90 default:
91 return null;
92 }
93}
94
95/** The conversation an arrival is in, by channel id; null for an inbox notification. */
96export function channelOf(arrival: Arrival): string | null {
97 return arrival.source === "chat" ? arrival.message.channel_id : (arrival.notification.channel_id ?? null);
98}
99
100/** Whether `me` wrote it. */
101function mine(arrival: Arrival, me: SoundContext["me"]): boolean {
102 if (!me) return false;
103 const who = arrival.source === "chat" ? arrival.message.author : arrival.notification.actor;
104 return who.kind === "user" && who.id === me.id;
105}
106
107/** Whether the person is looking at the conversation right now: on screen, in a window in front. */
108export function lookingAt(channelId: string | null, focus: FocusState): boolean {
109 return channelId !== null && focus.visible && focus.focused && focus.viewingChannel === channelId;
110}
111
112/** The cue an arrival earns, or null for silence. Every rule above, in order. */
113export function cueFor(arrival: Arrival, ctx: SoundContext): SoundCue | null {
114 if (!ctx.settings.sounds_enabled || ctx.dnd) return null;
115 if (mine(arrival, ctx.me)) return null;
116 const channel = channelOf(arrival);
117 if (channel && ctx.muted.has(channel)) return null;
118 if (lookingAt(channel, ctx.focus)) return null;
119 let cue: SoundCue | null;
120 if (arrival.source === "chat") {
121 const { message } = arrival;
122 if (message.deleted_at) return null;
123 if (finishedCard(message)) cue = "agent_done";
124 else if (ctx.me && mentionsMe(message.body, ctx.me.username)) cue = "mention";
125 else cue = arrival.channelKind === "dm" ? "direct" : "message";
126 } else {
127 cue = cueForNotificationKind(arrival.notification.kind);
128 }
129 return cue && cueOn(ctx.settings, cue) ? cue : null;
130}
131
132/** What identifies an arrival, so the same message sounds once: its message id. */
133export function arrivalKey(arrival: Arrival): string {
134 return arrival.source === "chat" ? `msg:${arrival.message.id}` : `msg:${arrival.notification.id}`;
135}
136
137/**
138 * Keeps sounds apart: one every `SOUND_GAP_MS` at most, and the same key
139 * (a message id) once in `SAME_MESSAGE_MS`. `allow` says whether to play
140 * now, and remembers it if so.
141 */
142export class SoundGate {
143 private lastAt = Number.NEGATIVE_INFINITY;
144 private readonly seen = new Map<string, number>();
145 private readonly gapMs: number;
146 private readonly sameMs: number;
147
148 constructor(gapMs = SOUND_GAP_MS, sameMs = SAME_MESSAGE_MS) {
149 this.gapMs = gapMs;
150 this.sameMs = sameMs;
151 }
152
153 allow(key: string | null, now: number): boolean {
154 for (const [k, at] of this.seen) if (now - at > this.sameMs) this.seen.delete(k);
155 if (key !== null && this.seen.has(key)) return false;
156 if (key !== null) this.seen.set(key, now);
157 if (now - this.lastAt < this.gapMs) return false;
158 this.lastAt = now;
159 return true;
160 }
161}
162
163/**
164 * Whether an open tab shows a system notification for a feed notification:
165 * the person turned desktop notifications on and the browser allows them,
166 * the window is not in front, it is something for them that the feed would
167 * toast, and this browser does not already get pushes (which show the same
168 * notification once no tab is in front).
169 */
170export function wantsDesktopToast(input: {
171 notification: Pick<FeedNotification, "kind">;
172 toast: boolean;
173 ctx: Pick<SoundContext, "focus" | "dnd" | "settings">;
174 permission: "default" | "granted" | "denied";
175 /** This browser is subscribed to pushes (`pushChoice() === "on"`). */
176 pushOn: boolean;
177}): boolean {
178 const { ctx } = input;
179 if (!ctx.settings.desktop_toasts || input.permission !== "granted" || input.pushOn) return false;
180 if (ctx.dnd || !input.toast) return false;
181 if (ctx.focus.visible && ctx.focus.focused) return false;
182 return cueForNotificationKind(input.notification.kind) !== null;
183}