| 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 | } |