Skip to content
170 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.
14 * - Never for your own messages, never in a muted conversation, never
15 * while Do not disturb is on, and never for the conversation you are
16 * looking at in a window that is in front: what is on screen is silent.
17 * - An old agent's message (agents no longer exist) counts like a person's.
18 * - At most one sound every 1.5 seconds, and never twice for one message,
19 * however many ways it arrives (its conversation's socket and your feed).
20 */
21import type { ChatMessage, FeedNotification } from "@g1t/contracts";
22import { type SoundCue, type SoundSettings, cueOn } from "@g1t/contracts/sounds";
23
24/** What the tab can see of itself: shown, in front, and which conversation it has open. */
25export type FocusState = {
26 /** `document.visibilityState === "visible"`. */
27 visible: boolean;
28 /** `document.hasFocus()`: the window is in front and this tab has the keyboard. */
29 focused: boolean;
30 /** The conversation on screen, by channel id; null on any other page. */
31 viewingChannel: string | null;
32};
33
34export type SoundContext = {
35 me: { id: string; username: string } | null;
36 focus: FocusState;
37 /** Conversations muted in the sidebar, by channel id. */
38 muted: ReadonlySet<string>;
39 /** Do not disturb holds (the person's presence, `dnd_until`). */
40 dnd: boolean;
41 settings: SoundSettings;
42};
43
44/**
45 * Something that arrived: a message over the open conversation's socket,
46 * or a notification over the feed (which only carries what is for you: a
47 * DM, a mention, a reply in your thread).
48 */
49export type Arrival =
50 | { source: "chat"; message: ChatMessage; channelKind: "channel" | "dm" }
51 | { source: "feed"; notification: FeedNotification };
52
53/** No two sounds closer than this: a burst of messages is one sound. */
54export const SOUND_GAP_MS = 1_500;
55/** The same message is not sounded again within this, whichever way it arrives. */
56export const SAME_MESSAGE_MS = 10_000;
57
58/** `@you` in a message's text: whole handles only, so `@you-and-me` is not you, and not inside code. */
59export function mentionsMe(body: string, username: string): boolean {
60 const name = username.trim().toLowerCase();
61 if (!name) return false;
62 const outsideCode = String(body ?? "").replace(/```[\s\S]*?(```|$)/g, " ").replace(/`[^`\n]*`/g, " ");
63 const pattern = new RegExp(`(^|[^a-z0-9_.@-])@${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9_/-])`, "i");
64 return pattern.test(outsideCode);
65}
66
67/** What a feed notification's kind sounds like; null for what has no sound (inbox items have the bell). */
68export function cueForNotificationKind(kind: FeedNotification["kind"]): SoundCue | null {
69 switch (kind) {
70 case "dm":
71 return "direct";
72 case "mention":
73 case "approval":
74 case "security":
75 return "mention";
76 case "thread_reply":
77 return "message";
78 default:
79 return null;
80 }
81}
82
83/** The conversation an arrival is in, by channel id; null for an inbox notification. */
84export function channelOf(arrival: Arrival): string | null {
85 return arrival.source === "chat" ? arrival.message.channel_id : (arrival.notification.channel_id ?? null);
86}
87
88/** Whether `me` wrote it. */
89function mine(arrival: Arrival, me: SoundContext["me"]): boolean {
90 if (!me) return false;
91 const who = arrival.source === "chat" ? arrival.message.author : arrival.notification.actor;
92 return who.kind === "user" && who.id === me.id;
93}
94
95/** Whether the person is looking at the conversation right now: on screen, in a window in front. */
96export function lookingAt(channelId: string | null, focus: FocusState): boolean {
97 return channelId !== null && focus.visible && focus.focused && focus.viewingChannel === channelId;
98}
99
100/** The cue an arrival earns, or null for silence. Every rule above, in order. */
101export function cueFor(arrival: Arrival, ctx: SoundContext): SoundCue | null {
102 if (!ctx.settings.sounds_enabled || ctx.dnd) return null;
103 if (mine(arrival, ctx.me)) return null;
104 const channel = channelOf(arrival);
105 if (channel && ctx.muted.has(channel)) return null;
106 if (lookingAt(channel, ctx.focus)) return null;
107 let cue: SoundCue | null;
108 if (arrival.source === "chat") {
109 const { message } = arrival;
110 if (message.deleted_at) return null;
111 if (ctx.me && mentionsMe(message.body, ctx.me.username)) cue = "mention";
112 else cue = arrival.channelKind === "dm" ? "direct" : "message";
113 } else {
114 cue = cueForNotificationKind(arrival.notification.kind);
115 }
116 return cue && cueOn(ctx.settings, cue) ? cue : null;
117}
118
119/** What identifies an arrival, so the same message sounds once: its message id. */
120export function arrivalKey(arrival: Arrival): string {
121 return arrival.source === "chat" ? `msg:${arrival.message.id}` : `msg:${arrival.notification.id}`;
122}
123
124/**
125 * Keeps sounds apart: one every `SOUND_GAP_MS` at most, and the same key
126 * (a message id) once in `SAME_MESSAGE_MS`. `allow` says whether to play
127 * now, and remembers it if so.
128 */
129export class SoundGate {
130 private lastAt = Number.NEGATIVE_INFINITY;
131 private readonly seen = new Map<string, number>();
132 private readonly gapMs: number;
133 private readonly sameMs: number;
134
135 constructor(gapMs = SOUND_GAP_MS, sameMs = SAME_MESSAGE_MS) {
136 this.gapMs = gapMs;
137 this.sameMs = sameMs;
138 }
139
140 allow(key: string | null, now: number): boolean {
141 for (const [k, at] of this.seen) if (now - at > this.sameMs) this.seen.delete(k);
142 if (key !== null && this.seen.has(key)) return false;
143 if (key !== null) this.seen.set(key, now);
144 if (now - this.lastAt < this.gapMs) return false;
145 this.lastAt = now;
146 return true;
147 }
148}
149
150/**
151 * Whether an open tab shows a system notification for a feed notification:
152 * the person turned desktop notifications on and the browser allows them,
153 * the window is not in front, it is something for them that the feed would
154 * toast, and this browser does not already get pushes (which show the same
155 * notification once no tab is in front).
156 */
157export function wantsDesktopToast(input: {
158 notification: Pick<FeedNotification, "kind">;
159 toast: boolean;
160 ctx: Pick<SoundContext, "focus" | "dnd" | "settings">;
161 permission: "default" | "granted" | "denied";
162 /** This browser is subscribed to pushes (`pushChoice() === "on"`). */
163 pushOn: boolean;
164}): boolean {
165 const { ctx } = input;
166 if (!ctx.settings.desktop_toasts || input.permission !== "granted" || input.pushOn) return false;
167 if (ctx.dnd || !input.toast) return false;
168 if (ctx.focus.visible && ctx.focus.focused) return false;
169 return cueForNotificationKind(input.notification.kind) !== null;
170}