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 | */ | |
| 22 | import type { ChatMessage, FeedNotification } from "@g1t/contracts"; | |
| 23 | import { 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. */ | |
| 26 | export 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 | ||
| 35 | export 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 | */ | |
| 50 | export 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. */ | |
| 55 | export const SOUND_GAP_MS = 1_500; | |
| 56 | /** The same message is not sounded again within this, whichever way it arrives. */ | |
| 57 | export 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. */ | |
| 60 | export 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. */ | |
| 69 | const 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. */ | |
| 72 | export 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). */ | |
| 78 | export 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. */ | |
| 95 | export 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. */ | |
| 100 | function 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. */ | |
| 107 | export 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. */ | |
| 112 | export 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. */ | |
| 132 | export 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 | */ | |
| 141 | export 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 | */ | |
| 169 | export 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 | } |