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