Skip to content

Commit

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.

syntaqxcommitted Parentb29a7e4Browse files
33 files+1985−1360/33 viewed
+1−0
3838 | --- | --- | --- |
3939 | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location, website and time zone. |
4040 | Emails | [`/settings/emails`](https://g1t.sh/settings/emails) | Your [email addresses](#email-addresses), the backup address, and [keeping your address private](#keeping-your-address-private). |
41+| Notifications | [`/settings/notifications`](https://g1t.sh/settings/notifications) | [Sounds and do not disturb](/guides/chat/#sounds-and-do-not-disturb), [pop-ups and browser notifications](/guides/chat/#notifications), and [what you are emailed for and how you watch repositories](/guides/notifications/#settings). |
4142 | Invites to g1t | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites to g1t](#making-invites), while g1t is invite-only; after that, the invites you made. |
4243 | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/#ssh), each with when it was added and last used. |
4344 | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens): their permissions, where they reach, and when they expire. |
+46−7
603603 | | |
604604 | --- | --- |
605605 | **Pop-ups** | While g1t is open in any tab, a message for you pops up in the corner, whichever page you are on: a DM, a mention, a reply in your thread, an agent waiting on you, a review requested. Reply from the pop-up without leaving the page, or open the conversation. A pop-up about a card has its buttons: **Approve more**, **Stop**, **File issue**. |
606−| **Sound** | A soft chime with each pop-up, in this browser, if you turn it on. |
606+| **Sounds** | A short sound when something is for you: a message in a conversation you have open but are not looking at, a direct message, a mention, an agent finishing. Which, and how loud, is yours to set; see [Sounds and do not disturb](#sounds-and-do-not-disturb). |
607607 | **Counts** | The rail's Chat badge and each conversation's unread and mention counts update live, in every open tab. |
608608 | **Browser notifications** | Turn them on, and this browser shows a notification when you're away from g1t, even with every tab closed. g1t never pushes to a browser where you're already looking at g1t. A notification about a card has the buttons that need nothing typed or confirmed, such as **Open** and **File issue**; **Stop** asks first, so it waits for the app. |
609609
621621 such as **Everything** for a small team and **DMs and mentions** for a big
622622 one. **Send a test notification** shows you what one looks like.
623623
624−### Pause notifications
624+### Sounds and do not disturb
625+
626+Chat makes a sound when something is for you, and only then. Each sound is
627+short (under a quarter of a second), warm and two-toned, made by g1t
628+itself: nothing is downloaded. What plays when:
625629
626−From the menu on your avatar, **Pause notifications** for 30 minutes, an
627−hour, or until tomorrow morning (9:00 your time). Until then nothing pops
628−up and no browser notification is sent, whatever your settings; counts and
629−your notifications still update, and everyone sees your amber dot. **Resume
630−notifications** ends it early.
630+| Sound | When |
631+| --- | --- |
632+| **New message** | A message in a conversation you have open, while the window is behind another or you are in another conversation. |
633+| **Direct message** | A direct message from someone, or from an agent, that is not from you. |
634+| **Mention** | A message that @mentions you. It wins over the other two. |
635+| **Agent finished** | An agent posts a card saying it finished something for you, or is waiting on you. |
636+| **Sent** | A soft tick as your message goes. Off until you turn it on. |
637+
638+The rules behind them:
639+
640+- Nothing sounds for the conversation you are looking at in a window that
641+ is in front: what is on screen is silent.
642+- Nothing sounds for your own messages, in a conversation you
643+ [muted](#star-and-mute), or while Do not disturb is on.
644+- Agents' messages count like people's.
645+- At most one sound every 1.5 seconds: a burst of messages is one sound,
646+ and the same message never sounds twice.
647+- Browsers allow sound only after you have clicked or typed on the page,
648+ so the first message after a page opens may be silent.
649+
650+Open **Settings → Notifications** (`g1t.sh/settings/notifications`),
651+under **Sounds & notifications**, to change them. The settings are kept
652+with your account, so every device you sign in on sounds the same:
653+
654+| Setting | What it does | Default |
655+| --- | --- | --- |
656+| **Sounds** | Whether anything plays at all. Counts and pop-ups still show. | On |
657+| **Sound set** | **Soft** (warm and low) or **Bright** (higher and shorter). Press play by any sound to hear it. | Soft |
658+| **Volume** | From 0 to 100. | 60 |
659+| **New message**, **Direct message**, **Mention**, **Agent finished**, **Sent** | Each sound on or off. | All on but Sent |
660+| **Do not disturb** | Off, or for 30 minutes, an hour, two hours, or until tomorrow at 9:00 your time. | Off |
661+| **Desktop notifications** | While this window is behind another, a message to you shows as a system notification from the open tab. Turning it on asks the browser's permission once, from this switch and never when a page loads. Not offered where browser notifications above are on: those already show it. | Off |
662+
663+**Do not disturb** is one thing everywhere: the bell-off button at the top
664+of the chat sidebar, **Pause notifications** on the menu on your avatar,
665+and the setting above all set it. While it holds, nothing sounds, nothing
666+pops up and no browser notification is sent, whatever your settings;
667+counts and your notifications still update, the bell in the top bar shows
668+a small amber dot, and everyone sees your amber dot. It ends on its own at
669+the time you chose; **Resume notifications**, or **Off**, ends it early.
631670
632671 At most three pop-ups show at once. Each stays six seconds (one with a
633672 card's buttons, twelve), and stays while your pointer or keyboard is on
+4−0
187187
188188 | Setting | What it does | Default |
189189 | --- | --- | --- |
190+| **Sounds**, **Sound set**, **Volume**, one switch per sound | What chat sounds like; see [Sounds and do not disturb](/guides/chat/#sounds-and-do-not-disturb). | On, Soft, 60, all but Sent |
191+| **Do not disturb** | Silences sounds, pop-ups and browser notifications until a time you choose. | Off |
192+| **Desktop notifications** | A system notification from an open tab for a message to you while the window is behind another. | Off |
193+| **Notify me about**, **Browser notifications** | What pops up and is pushed; see [chat notifications](/guides/chat/#notifications). | DMs and mentions; off |
190194 | **Email** | One checkbox per reason: you are also emailed when you are told of something for it. | agent waiting, review requested, mentioned |
191195 | **Repositories you create** | How you watch a new repository you create: **Participating and @mentions** or **All activity**. | All activity |
192196 | **Watched repositories** | Every repository you watch other than the default way, with how. | |
+12−1
7272 import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar";
7373 import { useAddresses } from "../../lib/addresses";
7474 import { applyReactionEvent, keepMine, toggleReaction } from "../../lib/emoji";
75+import { dndNow } from "../../lib/notify-client";
76+import { chatMessageArrived, messageSent, viewingConversation } from "../../lib/sound-events";
7577 import type { ChannelData } from "../../routes/workspace/chat/channel";
7678
7779 type Loaded = Extract<ChannelData, { unavailable: false }>;
179181 const entry = sidebar?.entries.find((e) => e.channel.id === data.channel.id);
180182 const starred = entry?.starred ?? members.find((m) => m.member.kind === "user" && m.member.id === me?.id)?.starred ?? false;
181183 const muted = entry?.muted ?? false;
184+
185+ // Sounds know which conversation is on screen: it is silent while looked at (lib/sound-events.ts).
186+ useEffect(() => {
187+ viewingConversation(data.channel.id);
188+ return () => viewingConversation(null);
189+ }, [data.channel.id]);
182190 const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member);
183191 const isDm = data.channel.kind === "dm";
184192
295303 if (event.type === "message.created" || event.type === "message.updated") {
296304 const message = event.message;
297305 if (message.channel_id !== data.channel.id) return;
306+ // A sound, when the rules say so: not for your own, not while looking at it in front.
307+ if (event.type === "message.created") chatMessageArrived(message, data.channel.kind, { dnd: dndNow(), muted });
298308 if (message.thread_root) {
299309 if (message.thread_root === threadId) setThread((now) => (now ? mergeMessages(now, [keepMine(now, message)]) : now));
300310 if (event.type === "message.created") countReply(message.thread_root, message.id, message.created_at);
333343 refresh();
334344 }
335345 },
336− [data.channel.id, threadId, me?.id, countReply, refresh],
346+ [data.channel.id, data.channel.kind, muted, threadId, me?.id, countReply, refresh],
337347 );
338348 // Back after a drop: what was missed.
339349 const onReconnect = useCallback(async () => {
415425 stick.current = true;
416426 setMessages(put);
417427 }
428+ messageSent({ dnd: dndNow() });
418429 const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
419430 const settle = (list: ShownMessage[]) =>
420431 saved.ok
+75−0
1+import { BellOff, BellRing } from "lucide-react";
2+import { useEffect, useState } from "react";
3+
4+import { Button } from "../ui/button";
5+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu";
6+import { Hint } from "../ui/hint";
7+import { setPresence, useOwnPresence } from "../../lib/notify-client";
8+import { PAUSE_FOR, dndOn, pauseUntil, untilLabel } from "../../lib/presence";
9+
10+/**
11+ * Do not disturb from the chat sidebar's header: one bell-off button. Off,
12+ * it opens the choices (30 minutes, an hour, two, tomorrow at 9); on, it
13+ * is lit and the menu ends it. The same Do not disturb as the avatar
14+ * menu's Pause notifications and the settings page: your presence
15+ * (`dnd_until`), which silences sounds, pop-ups and pushes alike.
16+ */
17+export function DndMenu({ className }: { className?: string }) {
18+ const me = useOwnPresence();
19+ const [now, setNow] = useState(() => Date.now());
20+ // It ends on its own: the button follows, a minute at a time.
21+ useEffect(() => {
22+ const timer = setInterval(() => setNow(Date.now()), 60_000);
23+ return () => clearInterval(timer);
24+ }, []);
25+ const on = dndOn(me, now);
26+ const until = on && me?.dnd_until ? untilLabel(me.dnd_until, new Date(now)) : null;
27+ return (
28+ <DropdownMenu>
29+ <Hint label={on ? `Do not disturb, ${until}` : "Do not disturb"}>
30+ <DropdownMenuTrigger asChild>
31+ <Button
32+ variant="ghost"
33+ size="icon-xs"
34+ aria-label={on ? `Do not disturb is on, ${until}` : "Do not disturb"}
35+ aria-pressed={on}
36+ className={`${on ? "text-warn hover:text-warn aria-pressed:text-warn" : "text-faint"} ${className ?? ""}`}
37+ >
38+ <BellOff size={15} />
39+ </Button>
40+ </DropdownMenuTrigger>
41+ </Hint>
42+ <DropdownMenuContent align="end" className="min-w-52">
43+ {on ? (
44+ <DropdownMenuItem
45+ className="gap-2.5 px-2.5"
46+ onSelect={() => {
47+ void setPresence({ dnd_until: null }, { dnd_until: null });
48+ }}
49+ >
50+ <BellRing />
51+ <span className="grow">Resume notifications</span>
52+ {until && <span className="text-xs text-faint">{until}</span>}
53+ </DropdownMenuItem>
54+ ) : (
55+ PAUSE_FOR.map((choice) => (
56+ <DropdownMenuItem
57+ key={choice.key}
58+ className="px-2.5"
59+ onSelect={() => {
60+ const at = pauseUntil(choice.key, new Date());
61+ void setPresence({ dnd_until: at }, { dnd_until: at });
62+ }}
63+ >
64+ {choice.label}
65+ </DropdownMenuItem>
66+ ))
67+ )}
68+ <DropdownMenuSeparator />
69+ <p className="max-w-56 px-2.5 py-1 text-xs leading-snug text-faint">
70+ {on ? "No sounds, pop-ups or browser notifications until then. Counts still move." : "Silences sounds, pop-ups and browser notifications. Counts still move."}
71+ </p>
72+ </DropdownMenuContent>
73+ </DropdownMenu>
74+ );
75+}
+35−22
77
88 import { conversationCache } from "./conversation-cache";
99 import { CreateChannelButton, NewMessageButton, useChatData, useChatSend, useChatSidebar } from "./actions";
10+import { DndMenu } from "./dnd-menu";
1011 import { MemberAvatar, StatusDot, statusLabel } from "./marks";
1112 import { AgentAvatar } from "../agent-avatar";
1213 import { PersonStatusEmoji } from "../presence";
1718 import { type ChatFilter, agentDmOf, channelPath, filterEntries, sections } from "../../lib/chat";
1819 import { feedConnected } from "../../lib/notify-client";
1920 import { FALLBACK_REFRESH_MS } from "../../lib/notify-store";
21+import { setMutedConversations } from "../../lib/sound-events";
2022
2123 /** How often the sidebar asks again while the tab is shown and the feed socket is down. */
2224 const REFRESH_MS = FALLBACK_REFRESH_MS;
7678 // Pins show at once and go back if the service refuses.
7779 const [pins, setPins] = useState<Map<string, boolean>>(new Map());
7880 useEffect(() => setPins(new Map()), [sidebar]);
81+ // Sounds leave muted conversations alone (lib/sound-events.ts).
82+ useEffect(() => {
83+ if (sidebar) setMutedConversations(sidebar.entries.filter((e) => e.muted).map((e) => e.channel.id));
84+ }, [sidebar]);
7985 const entries = (sidebar?.entries ?? []).map((e) => (pins.has(e.channel.id) ? { ...e, starred: pins.get(e.channel.id)! } : e));
8086 const shown = filterEntries(entries, filter, query);
8187 const { pinned, channels, dms } = sections(shown);
144150 {heading && (
145151 <div className="flex h-9 shrink-0 items-center justify-between pr-1 pl-3">
146152 <h2 className="text-[0.6875rem] font-medium tracking-wide text-faint uppercase">Chat</h2>
147− <NewMessageButton slug={slug} />
153+ <div className="flex items-center gap-0.5">
154+ <DndMenu />
155+ <NewMessageButton slug={slug} />
156+ </div>
148157 </div>
149158 )}
150159 <div className="space-y-2.5 px-2.5 pt-3">
151− <label className="flex h-8 items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] ring-1 ring-line transition-shadow focus-within:ring-accent-dim/70">
152− <Search size={14} className="shrink-0 text-faint" />
153− <input
154− value={query}
155− onChange={(event) => setQuery(event.target.value)}
156− onKeyDown={(event) => {
157− if (event.key === "Enter") {
158− const first = [...pinned, ...channels, ...dms][0];
159− if (first) {
160− navigate(channelPath(slug, first.channel));
161− setQuery("");
162− } else if (agents[0]) void openAgent(agents[0]);
163− } else if (event.key === "Escape") setQuery("");
164− }}
165− placeholder="Jump to channel or person"
166− aria-label="Jump to channel or person"
167− autoComplete="off"
168− data-1p-ignore
169− className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint"
170− />
171− </label>
160+ <div className="flex items-center gap-2">
161+ <label className="flex h-8 min-w-0 grow items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] ring-1 ring-line transition-shadow focus-within:ring-accent-dim/70">
162+ <Search size={14} className="shrink-0 text-faint" />
163+ <input
164+ value={query}
165+ onChange={(event) => setQuery(event.target.value)}
166+ onKeyDown={(event) => {
167+ if (event.key === "Enter") {
168+ const first = [...pinned, ...channels, ...dms][0];
169+ if (first) {
170+ navigate(channelPath(slug, first.channel));
171+ setQuery("");
172+ } else if (agents[0]) void openAgent(agents[0]);
173+ } else if (event.key === "Escape") setQuery("");
174+ }}
175+ placeholder="Jump to channel or person"
176+ aria-label="Jump to channel or person"
177+ autoComplete="off"
178+ data-1p-ignore
179+ className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint"
180+ />
181+ </label>
182+ {/* A phone has no heading row: Do not disturb sits by the search box instead. */}
183+ {!heading && <DndMenu className="size-8 max-md:size-11" />}
184+ </div>
172185 <div role="radiogroup" aria-label="Show" className="grid grid-cols-3 rounded-md bg-surface p-0.5 ring-1 ring-line">
173186 {(["all", "unread", "mentions"] as const).map((key) => (
174187 <button
+11−1
1717 import { cn } from "../lib/cn";
1818 import { isPending } from "../lib/pending";
1919 import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, updatesLabel, whenShort } from "../lib/inbox";
20+import { useOwnPresence } from "../lib/notify-client";
21+import { dndOn } from "../lib/presence";
2022 import type { InboxPanelData } from "../routes/inbox-json";
2123
2224 /** Where every notifications form posts (routes/notifications.tsx). */
275277 const unread = counts?.unread ?? 0;
276278 const items = list.data?.tab === tab ? list.data.items : null;
277279 const shown = bellCount(unread);
280+ // Do not disturb shows on the bell: an amber dot while it holds (the feed says; nothing on the server).
281+ const dnd = dndOn(useOwnPresence(), Date.now());
278282
279283 return (
280284 <Sheet open={open} onOpenChange={setOpen}>
281285 <SheetTrigger asChild>
282− <Button variant="ghost" size="icon-sm" aria-label={unread ? `Notifications, ${unread} unread` : "Notifications"} className="relative">
286+ <Button
287+ variant="ghost"
288+ size="icon-sm"
289+ aria-label={`${unread ? `Notifications, ${unread} unread` : "Notifications"}${dnd ? ", do not disturb is on" : ""}`}
290+ className="relative"
291+ >
283292 <Bell size={17} />
293+ {dnd && <span aria-hidden="true" className="absolute -right-0.5 -bottom-0.5 size-2 rounded-full bg-warn ring-2 ring-bg" />}
284294 {shown && (
285295 // Amber while an agent waits on them, red for a failure, else the accent.
286296 <span
+24−2
11 import { useEffect } from "react";
22 import { useLocation, useNavigate } from "react-router";
33
4+import type { SoundSettings } from "@g1t/contracts/sounds";
5+
46 import { applyAttention, refreshPush, reportInbox, setNavigator, setViewing, startFeed, useLiveBadges } from "../../lib/notify-client";
7+import { setSoundViewer } from "../../lib/sound-events";
8+import { applySoundSettings, startSounds } from "../../lib/sounds";
59 import { Toaster } from "./toaster";
610
711 /**
1115 *
1216 * `workspace` is the one the page is in, whose counts the feed reads on
1317 * connect; `inbox` the inbox count the page's data last read, handed to
14− * the feed so every tab shows it.
18+ * the feed so every tab shows it. `sounds` are the person's sound settings
19+ * as the root loader read them with the page, so the first event already
20+ * respects them, and `me` who they are, so their own messages never sound
21+ * (lib/sounds.ts, lib/sound-events.ts).
1522 */
16−export function LiveNotifications({ workspace, inbox }: { workspace: string | null; inbox: number | null }) {
23+export function LiveNotifications({
24+ workspace,
25+ inbox,
26+ sounds,
27+ me,
28+}: {
29+ workspace: string | null;
30+ inbox: number | null;
31+ sounds: SoundSettings | null;
32+ me: { id: string; username: string } | null;
33+}) {
1734 const navigate = useNavigate();
1835 const { pathname, search } = useLocation();
1936 const badges = useLiveBadges(workspace);
2037
38+ // Sounds: the settings with the page, and audio allowed from the first click or key.
39+ useEffect(() => applySoundSettings(sounds), [sounds]);
40+ useEffect(() => setSoundViewer(me), [me?.id, me?.username]); // eslint-disable-line react-hooks/exhaustive-deps
41+ useEffect(() => startSounds(), []);
42+
2143 useEffect(() => {
2244 setNavigator((href) => navigate(href));
2345 return () => setNavigator(null);
+4−34
88 import { RadioGroup, RadioOption } from "../ui/radio-group";
99 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
1010 import { Switch } from "../ui/switch";
11−import {
12− desktopBridge,
13− disablePush,
14− enablePush,
15− notifyStatus,
16− permission,
17− pushChoice,
18− pushSupported,
19− savePreferences,
20− sendTest,
21− setSound,
22− soundOn,
23− chime,
24−} from "../../lib/notify-client";
11+import { desktopBridge, disablePush, enablePush, notifyStatus, permission, pushChoice, pushSupported, savePreferences, sendTest } from "../../lib/notify-client";
2512
2613 const LEVELS: { level: NotifyLevel; label: string; detail: string }[] = [
2714 { level: "all", label: "Everything", detail: "Every message in conversations you're in, and everything in your notifications." },
5441
5542 /**
5643 * Settings → Notifications, the live part: what pops up and is pushed,
57− * this browser's notifications, the sound, and a test. Kept by the notify
58− * service (services/notify); the sound is this browser's own.
44+ * this browser's notifications, and a test. Kept by the notify service
45+ * (services/notify). Sounds are the section above (sounds.tsx).
5946 */
6047 export function LiveNotificationSettings({ workspaces, initial = null }: { workspaces: Membership[]; initial?: NotifyStatus | null }) {
6148 const [status, setStatus] = useState<NotifyStatus | null>(initial);
6249 const [prefs, setPrefs] = useState<NotifyPreferences | null>(initial?.preferences ?? null);
6350 const [device, setDevice] = useState<Device>("off");
64− const [sound, setSoundState] = useState(false);
6551 const [busy, setBusy] = useState<string | null>(null);
6652 const [said, setSaid] = useState<string | null>(null);
6753 const [failed, setFailed] = useState(false);
6854
6955 useEffect(() => {
70− setSoundState(soundOn());
7156 if (initial) {
7257 setDevice(deviceState(initial));
7358 return;
191176 </div>
192177 )}
193178
194− <Card divided>
179+ <Card>
195180 <div className="flex items-start gap-4 p-4">
196181 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent">
197182 <BellRing size={16} />
207192 disabled={deviceLocked || busy === "device" || !status}
208193 onCheckedChange={(on) => void toggleDevice(on)}
209194 aria-label="Browser notifications"
210− />
211− </div>
212− <div className="flex items-start gap-4 p-4">
213− <div className="min-w-0 grow pl-12">
214− <p className="text-sm font-medium text-fg">Sound</p>
215− <p className="mt-0.5 text-xs text-muted">A soft chime with each pop-up, in this browser.</p>
216− </div>
217− <Switch
218− checked={sound}
219− onCheckedChange={(on) => {
220− setSound(on);
221− setSoundState(on);
222− if (on) chime();
223− }}
224− aria-label="Sound"
225195 />
226196 </div>
227197 </Card>
+265−0
1+import { BellOff, MonitorSmartphone, Play, Volume2, VolumeX } from "lucide-react";
2+import { useEffect, useState } from "react";
3+
4+import { type SoundCue, type SoundSet, type SoundSettings, SOUND_CUES, cueOn } from "@g1t/contracts/sounds";
5+
6+import { Button } from "../ui/button";
7+import { Card } from "../ui/card";
8+import { Hint } from "../ui/hint";
9+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
10+import { Slider } from "../ui/slider";
11+import { Switch } from "../ui/switch";
12+import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
13+import { permission, pushChoice, setPresence, useOwnPresence } from "../../lib/notify-client";
14+import { PAUSE_FOR, type PauseFor, dndOn, pauseUntil, untilLabel } from "../../lib/presence";
15+import { applySoundSettings, preview, saveSoundSettings, useSoundSettings } from "../../lib/sounds";
16+
17+const CUE: Record<Exclude<SoundCue, "call">, { label: string; detail: string }> = {
18+ message: { label: "New message", detail: "In a conversation you have open, while you're looking elsewhere." },
19+ direct: { label: "Direct message", detail: "Someone, or an agent, messages you directly." },
20+ mention: { label: "Mention", detail: "You were @mentioned." },
21+ agent_done: { label: "Agent finished", detail: "An agent posts that it finished something for you." },
22+ sent: { label: "Sent", detail: "A soft tick as your message goes." },
23+};
24+
25+const SETS: { set: SoundSet; label: string; detail: string }[] = [
26+ { set: "soft", label: "Soft", detail: "Warm and low." },
27+ { set: "bright", label: "Bright", detail: "Higher and shorter." },
28+];
29+
30+type Desktop = "on" | "off" | "blocked" | "unsupported" | "pushed";
31+
32+function desktopState(settings: SoundSettings): Desktop {
33+ if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
34+ if (permission() === "denied") return "blocked";
35+ if (pushChoice() === "on" && permission() === "granted") return "pushed";
36+ return settings.desktop_toasts && permission() === "granted" ? "on" : "off";
37+}
38+
39+const DESKTOP_LINE: Record<Desktop, string> = {
40+ on: "On. While this window is behind another, a message to you shows as a system notification.",
41+ off: "Off. Turning it on asks this browser's permission once.",
42+ blocked: "Blocked by your browser. Allow notifications for this site in its settings, then come back.",
43+ unsupported: "This browser can't show system notifications.",
44+ pushed: "Browser notifications above already show a message to you whenever no tab is in front.",
45+};
46+
47+/**
48+ * Settings → Notifications, the sounds: whether anything plays, which set
49+ * and how loud, each cue on or off with a way to hear it, Do not disturb,
50+ * and system notifications from an open tab. Kept with the account by
51+ * identity (lib/sounds.ts saves; the root loader hands them over with the
52+ * page), except Do not disturb, which is your presence (services/notify).
53+ */
54+export function SoundSettingsSection({ initial }: { initial: SoundSettings | null }) {
55+ const settings = useSoundSettings();
56+ const me = useOwnPresence();
57+ const [now, setNow] = useState(() => Date.now());
58+ const [said, setSaid] = useState<string | null>(null);
59+ const [desktop, setDesktop] = useState<Desktop>("off");
60+ const [busy, setBusy] = useState(false);
61+
62+ useEffect(() => {
63+ // The page's own read, in case the root's is older than a change made elsewhere.
64+ applySoundSettings(initial);
65+ }, [initial]);
66+ useEffect(() => setDesktop(desktopState(settings)), [settings]);
67+ useEffect(() => {
68+ const timer = setInterval(() => setNow(Date.now()), 30_000);
69+ return () => clearInterval(timer);
70+ }, []);
71+
72+ const save = async (change: Parameters<typeof saveSoundSettings>[0]) => {
73+ const saved = await saveSoundSettings(change);
74+ setSaid(saved ? null : "That didn't save. Try again in a moment.");
75+ };
76+
77+ const toggleDesktop = async (on: boolean) => {
78+ if (!on) return save({ desktop_toasts: false });
79+ setBusy(true);
80+ try {
81+ // Only ever from this click: never when a page loads.
82+ const answer = permission() === "granted" ? "granted" : await Notification.requestPermission();
83+ if (answer !== "granted") {
84+ setDesktop(permission() === "denied" ? "blocked" : "off");
85+ return;
86+ }
87+ await save({ desktop_toasts: true });
88+ } finally {
89+ setBusy(false);
90+ }
91+ };
92+
93+ const paused = dndOn(me, now);
94+ const dndValue = paused ? "on" : "off";
95+ const setDnd = (value: string) => {
96+ if (value === "off") {
97+ void setPresence({ dnd_until: null }, { dnd_until: null });
98+ return;
99+ }
100+ if (value === "on") return;
101+ const until = pauseUntil(value as PauseFor, new Date());
102+ void setPresence({ dnd_until: until }, { dnd_until: until });
103+ };
104+
105+ const off = !settings.sounds_enabled;
106+ const desktopLocked = desktop === "blocked" || desktop === "unsupported" || desktop === "pushed";
107+
108+ return (
109+ <section aria-labelledby="sounds-heading" className="space-y-4">
110+ <div>
111+ <h2 id="sounds-heading" className="text-sm font-semibold">
112+ Sounds &amp; notifications
113+ </h2>
114+ <p className="mt-1 text-sm text-muted">
115+ What you hear when chat moves, on every device you sign in on. Nothing sounds for the conversation you're looking at, for your own
116+ messages, or in a muted conversation.
117+ </p>
118+ </div>
119+
120+ <Card divided>
121+ <div className="flex items-center gap-4 p-4">
122+ <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent">
123+ {off ? <VolumeX size={16} /> : <Volume2 size={16} />}
124+ </span>
125+ <div className="min-w-0 grow">
126+ <p className="text-sm font-medium text-fg">Sounds</p>
127+ <p className="mt-0.5 text-xs text-muted">{off ? "Off. Chat is silent; counts and pop-ups still show." : "On. A short sound for what is for you."}</p>
128+ </div>
129+ <Switch checked={settings.sounds_enabled} onCheckedChange={(on) => void save({ sounds_enabled: on })} aria-label="Sounds" />
130+ </div>
131+
132+ <div className={`space-y-4 p-4 ${off ? "opacity-60" : ""}`}>
133+ <div className="flex flex-wrap items-center gap-3">
134+ <div className="min-w-0 grow">
135+ <p className="text-sm font-medium text-fg">Sound set</p>
136+ <p className="mt-0.5 text-xs text-muted">{SETS.find((s) => s.set === settings.sound_set)?.detail ?? ""} Press play on any sound to hear it.</p>
137+ </div>
138+ <ToggleGroup
139+ type="single"
140+ variant="outline"
141+ size="sm"
142+ value={settings.sound_set}
143+ disabled={off}
144+ onValueChange={(value) => {
145+ if (!value) return;
146+ const set = value as SoundSet;
147+ void save({ sound_set: set });
148+ preview("message", set, settings.sound_volume);
149+ }}
150+ aria-label="Sound set"
151+ >
152+ {SETS.map((choice) => (
153+ <ToggleGroupItem key={choice.set} value={choice.set} className="px-3">
154+ {choice.label}
155+ </ToggleGroupItem>
156+ ))}
157+ </ToggleGroup>
158+ </div>
159+
160+ <div className="flex items-center gap-3">
161+ <p id="volume-label" className="w-16 shrink-0 text-sm font-medium text-fg">
162+ Volume
163+ </p>
164+ <Slider
165+ value={[settings.sound_volume]}
166+ min={0}
167+ max={100}
168+ step={5}
169+ disabled={off}
170+ aria-labelledby="volume-label"
171+ onValueChange={([value]) => {
172+ if (value == null) return;
173+ applySoundSettings({ ...settings, sound_volume: value });
174+ }}
175+ onValueCommit={([value]) => {
176+ if (value == null) return;
177+ void save({ sound_volume: value });
178+ preview("message", settings.sound_set, value);
179+ }}
180+ className="grow"
181+ />
182+ <span className="w-10 shrink-0 text-right text-xs text-muted tabular-nums">{settings.sound_volume}%</span>
183+ </div>
184+ </div>
185+
186+ <ul className={off ? "opacity-60" : ""} aria-label="Sounds">
187+ {SOUND_CUES.map((cue) => (
188+ <li key={cue} className="flex items-center gap-3 px-4 py-2.5">
189+ <Hint label={`Play ${CUE[cue].label.toLowerCase()}`}>
190+ <Button
191+ type="button"
192+ variant="outline"
193+ size="icon-xs"
194+ aria-label={`Play ${CUE[cue].label.toLowerCase()}`}
195+ onClick={() => preview(cue, settings.sound_set, settings.sound_volume)}
196+ >
197+ <Play size={13} />
198+ </Button>
199+ </Hint>
200+ <div className="min-w-0 grow">
201+ <p className="text-sm text-fg">{CUE[cue].label}</p>
202+ <p className="truncate text-xs text-muted">{CUE[cue].detail}</p>
203+ </div>
204+ <Switch
205+ size="sm"
206+ checked={cueOn(settings, cue)}
207+ disabled={off}
208+ onCheckedChange={(on) => void save({ sound_cues: { [cue]: on } })}
209+ aria-label={CUE[cue].label}
210+ />
211+ </li>
212+ ))}
213+ </ul>
214+ </Card>
215+
216+ <Card divided>
217+ <div className="flex flex-wrap items-center gap-3 p-4">
218+ <span className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${paused ? "bg-warn/15 text-warn" : "bg-accent/12 text-accent"}`}>
219+ <BellOff size={16} />
220+ </span>
221+ <div className="min-w-0 grow basis-40">
222+ <p className="text-sm font-medium text-fg">Do not disturb</p>
223+ <p className="mt-0.5 text-xs text-muted" role="status">
224+ {paused && me?.dnd_until
225+ ? `On, ${untilLabel(me.dnd_until, new Date(now))}. No sounds, pop-ups or browser notifications; counts still move.`
226+ : "Silence sounds, pop-ups and browser notifications for a while. Everyone sees your amber dot."}
227+ </p>
228+ </div>
229+ <Select value={dndValue} onValueChange={setDnd}>
230+ <SelectTrigger size="sm" className="w-44" aria-label="Do not disturb">
231+ <SelectValue />
232+ </SelectTrigger>
233+ <SelectContent>
234+ <SelectItem value="off">Off</SelectItem>
235+ {paused && me?.dnd_until && <SelectItem value="on">On, {untilLabel(me.dnd_until, new Date(now))}</SelectItem>}
236+ {PAUSE_FOR.map((choice) => (
237+ <SelectItem key={choice.key} value={choice.key}>
238+ {choice.label.replace(/^For /, "For ").replace("Until tomorrow", "Until tomorrow, 9:00")}
239+ </SelectItem>
240+ ))}
241+ </SelectContent>
242+ </Select>
243+ </div>
244+ <div className="flex items-start gap-4 p-4">
245+ <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent">
246+ <MonitorSmartphone size={16} />
247+ </span>
248+ <div className="min-w-0 grow">
249+ <p className="text-sm font-medium text-fg">Desktop notifications</p>
250+ <p className="mt-0.5 text-xs text-muted" role="status">
251+ {DESKTOP_LINE[desktop]}
252+ </p>
253+ </div>
254+ <Switch
255+ checked={desktop === "on"}
256+ disabled={desktopLocked || busy}
257+ onCheckedChange={(on) => void toggleDesktop(on)}
258+ aria-label="Desktop notifications"
259+ />
260+ </div>
261+ </Card>
262+ {said && <p className="text-xs text-danger">{said}</p>}
263+ </section>
264+ );
265+}
+45−0
1+import { Slider as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's slider, styled with g1t's tokens: a value picked along a
7+// line, with the keyboard (arrows, Home, End, Page Up and Down) as well as
8+// the pointer. Give it an `aria-label`, or point a <Label> at the thumb
9+// through `aria-labelledby`. One thumb unless `value` has several.
10+
11+export function Slider({ className, value, defaultValue, min = 0, max = 100, ...props }: ComponentProps<typeof Primitive.Root>) {
12+ const thumbs = Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min];
13+ return (
14+ <Primitive.Root
15+ data-slot="slider"
16+ value={value}
17+ defaultValue={defaultValue}
18+ min={min}
19+ max={max}
20+ className={cn(
21+ "relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50",
22+ "data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
23+ className,
24+ )}
25+ {...props}
26+ >
27+ <Primitive.Track
28+ data-slot="slider-track"
29+ className="relative grow overflow-hidden rounded-full bg-line data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
30+ >
31+ <Primitive.Range data-slot="slider-range" className="absolute bg-accent data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full" />
32+ </Primitive.Track>
33+ {thumbs.map((_, index) => (
34+ <Primitive.Thumb
35+ key={index}
36+ data-slot="slider-thumb"
37+ className={cn(
38+ "block size-4 shrink-0 rounded-full border border-accent bg-fg shadow-sm transition-[color,box-shadow] outline-none",
39+ "hover:ring-4 hover:ring-accent/25 focus-visible:ring-4 focus-visible:ring-accent/40 disabled:pointer-events-none",
40+ )}
41+ />
42+ ))}
43+ </Primitive.Root>
44+ );
45+}
+222−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { ChatMessage, FeedNotification } from "@g1t/contracts";
5+import { DEFAULT_SOUND_SETTINGS, type SoundSettings } from "@g1t/contracts/sounds";
6+
7+import {
8+ type Arrival,
9+ SAME_MESSAGE_MS,
10+ SOUND_GAP_MS,
11+ type SoundContext,
12+ SoundGate,
13+ arrivalKey,
14+ cueFor,
15+ cueForNotificationKind,
16+ finishedCard,
17+ lookingAt,
18+ mentionsMe,
19+ wantsDesktopToast,
20+} from "./chat-sounds.ts";
21+
22+const ana = { kind: "user" as const, id: "u1", name: "ana", display_name: "Ana", avatar: null, role: null };
23+const bea = { kind: "user" as const, id: "u2", name: "bea", display_name: "Bea", avatar: null, role: null };
24+const margo = { kind: "agent" as const, id: "a1", name: "margo", display_name: "Margo", avatar: null, role: "Reviews pull requests" };
25+
26+function message(over: Partial<ChatMessage> = {}): ChatMessage {
27+ return {
28+ id: over.id ?? "msg_1",
29+ channel_id: "chn_general",
30+ author: bea,
31+ kind: "text",
32+ body: "hello there",
33+ card: null,
34+ thread_root: null,
35+ reply_count: 0,
36+ last_reply_at: null,
37+ created_at: "2026-10-10T10:00:00.000Z",
38+ edited_at: null,
39+ deleted_at: null,
40+ ...over,
41+ };
42+}
43+
44+function notification(over: Partial<FeedNotification> = {}): FeedNotification {
45+ return {
46+ id: "msg_9",
47+ kind: "dm",
48+ workspace: "acme",
49+ title: "Bea",
50+ body: "hi",
51+ href: "/acme/-/chat/dm/chn_dm",
52+ actor: { kind: "user", id: "u2", name: "Bea" },
53+ channel_id: "chn_dm",
54+ created_at: "2026-10-10T10:00:00.000Z",
55+ ...over,
56+ };
57+}
58+
59+/** Ana, in #general, window in front: the quiet case every rule starts from. */
60+function ctx(over: Partial<SoundContext> = {}, settings: Partial<SoundSettings> = {}): SoundContext {
61+ return {
62+ me: { id: "u1", username: "ana" },
63+ focus: { visible: true, focused: true, viewingChannel: "chn_general" },
64+ muted: new Set(),
65+ dnd: false,
66+ settings: { ...DEFAULT_SOUND_SETTINGS, ...settings, sound_cues: { ...DEFAULT_SOUND_SETTINGS.sound_cues, ...settings.sound_cues } },
67+ ...over,
68+ };
69+}
70+
71+const chat = (m: ChatMessage, channelKind: "channel" | "dm" = "channel"): Arrival => ({ source: "chat", message: m, channelKind });
72+const feed = (n: FeedNotification): Arrival => ({ source: "feed", notification: n });
73+
74+test("a message in the conversation on screen, in a window in front, is silent", () => {
75+ assert.equal(cueFor(chat(message()), ctx()), null);
76+ assert.ok(lookingAt("chn_general", ctx().focus));
77+});
78+
79+test("the same message plays `message` once the window is not in front, or the tab is hidden", () => {
80+ assert.equal(cueFor(chat(message()), ctx({ focus: { visible: true, focused: false, viewingChannel: "chn_general" } })), "message");
81+ assert.equal(cueFor(chat(message()), ctx({ focus: { visible: false, focused: false, viewingChannel: "chn_general" } })), "message");
82+});
83+
84+test("a message in another conversation you have open plays `message`", () => {
85+ assert.equal(cueFor(chat(message({ channel_id: "chn_other" })), ctx()), "message");
86+ assert.equal(cueFor(feed(notification({ kind: "thread_reply", channel_id: "chn_other" })), ctx()), "message");
87+});
88+
89+test("a direct message not from you plays `direct`; one from you plays nothing", () => {
90+ const dm = message({ channel_id: "chn_dm" });
91+ assert.equal(cueFor(chat(dm, "dm"), ctx()), "direct");
92+ assert.equal(cueFor(chat({ ...dm, author: ana }, "dm"), ctx()), null);
93+ assert.equal(cueFor(feed(notification()), ctx()), "direct");
94+ assert.equal(cueFor(feed(notification({ actor: { kind: "user", id: "u1", name: "Ana" } })), ctx()), null);
95+});
96+
97+test("a mention of you plays `mention`, over `direct` and `message`", () => {
98+ assert.equal(cueFor(chat(message({ channel_id: "chn_other", body: "hey @ana look" })), ctx()), "mention");
99+ assert.equal(cueFor(chat(message({ channel_id: "chn_dm", body: "@ana?" }), "dm"), ctx()), "mention");
100+ assert.equal(cueFor(feed(notification({ kind: "mention", channel_id: "chn_other" })), ctx()), "mention");
101+ // Looking at it, in front: still silent.
102+ assert.equal(cueFor(chat(message({ body: "@ana" })), ctx()), null);
103+});
104+
105+test("@you is a whole handle, outside code", () => {
106+ assert.ok(mentionsMe("thanks @ana", "ana"));
107+ assert.ok(mentionsMe("@Ana: see this", "ana"));
108+ assert.ok(mentionsMe("(@ana)", "ana"));
109+ assert.ok(!mentionsMe("@ana-marie did it", "ana"));
110+ assert.ok(!mentionsMe("@anatole", "ana"));
111+ assert.ok(!mentionsMe("mail me@ana.example", "ana"));
112+ assert.ok(!mentionsMe("run `@ana` in code", "ana"));
113+ assert.ok(!mentionsMe("```\n@ana\n```", "ana"));
114+ assert.ok(!mentionsMe("@ana", ""));
115+});
116+
117+test("your own messages never sound, whichever way they arrive", () => {
118+ const unfocused = ctx({ focus: { visible: true, focused: false, viewingChannel: "chn_general" } });
119+ assert.equal(cueFor(chat(message({ author: ana })), unfocused), null);
120+ assert.equal(cueFor(chat(message({ author: ana, body: "@ana" })), unfocused), null);
121+ assert.equal(cueFor(feed(notification({ kind: "mention", actor: { kind: "user", id: "u1", name: "Ana" } })), unfocused), null);
122+});
123+
124+test("a muted conversation is silent, even for a mention or a DM", () => {
125+ const muted = ctx({ muted: new Set(["chn_other", "chn_dm"]) });
126+ assert.equal(cueFor(chat(message({ channel_id: "chn_other", body: "@ana" })), muted), null);
127+ assert.equal(cueFor(chat(message({ channel_id: "chn_dm" }), "dm"), muted), null);
128+ assert.equal(cueFor(feed(notification()), muted), null);
129+ assert.equal(cueFor(chat(message({ channel_id: "chn_third" })), muted), "message");
130+});
131+
132+test("Do not disturb silences everything", () => {
133+ const quiet = ctx({ dnd: true, focus: { visible: false, focused: false, viewingChannel: null } });
134+ assert.equal(cueFor(chat(message({ body: "@ana" })), quiet), null);
135+ assert.equal(cueFor(chat(message({ channel_id: "chn_dm" }), "dm"), quiet), null);
136+ assert.equal(cueFor(feed(notification({ kind: "agent_waiting" })), quiet), null);
137+});
138+
139+test("sounds off, or one cue off, is silent for that", () => {
140+ const away = { visible: false, focused: false, viewingChannel: null };
141+ assert.equal(cueFor(chat(message()), ctx({ focus: away }, { sounds_enabled: false })), null);
142+ assert.equal(cueFor(chat(message()), ctx({ focus: away }, { sound_cues: { message: false } })), null);
143+ assert.equal(cueFor(chat(message({ body: "@ana" })), ctx({ focus: away }, { sound_cues: { message: false } })), "mention");
144+ assert.equal(cueFor(chat(message({ body: "@ana" })), ctx({ focus: away }, { sound_cues: { mention: false } })), null);
145+});
146+
147+test("agents' messages count like people's, and a finished card plays `agent_done`", () => {
148+ const away = ctx({ focus: { visible: true, focused: false, viewingChannel: "chn_general" } });
149+ assert.equal(cueFor(chat(message({ author: margo })), away), "message");
150+ assert.equal(cueFor(chat(message({ author: margo, channel_id: "chn_dm" }), "dm"), away), "direct");
151+ assert.equal(cueFor(chat(message({ author: margo, body: "@ana done?" })), away), "mention");
152+ const card = message({
153+ author: margo,
154+ kind: "card",
155+ body: "",
156+ card: { kind: "session", title: "Fix the flaky test", detail: "2 files changed", state: "Done", href: "/acme/web/pull/4" },
157+ });
158+ assert.ok(finishedCard(card));
159+ assert.equal(cueFor(chat(card), away), "agent_done");
160+ assert.equal(cueFor(chat(card), ctx()), null, "looking at it: silent");
161+ assert.equal(cueFor(chat(card), ctx({ focus: away.focus }, { sound_cues: { agent_done: false } })), null);
162+ // A card still running, or a person's card, is a message.
163+ assert.ok(!finishedCard({ ...card, card: { ...card.card!, state: "Running" } }));
164+ assert.ok(!finishedCard({ ...card, author: bea }));
165+ assert.equal(cueFor(chat({ ...card, card: { ...card.card!, state: "Running" } }), away), "message");
166+ // An agent waiting on you, over the feed, is the same cue.
167+ assert.equal(cueFor(feed(notification({ kind: "agent_waiting", actor: { kind: "agent", id: "a1", name: "Margo" } })), away), "agent_done");
168+});
169+
170+test("a deleted message, and an inbox notification, make no sound", () => {
171+ const away = ctx({ focus: { visible: false, focused: false, viewingChannel: null } });
172+ assert.equal(cueFor(chat(message({ deleted_at: "2026-10-10T10:01:00.000Z" })), away), null);
173+ assert.equal(cueFor(feed(notification({ kind: "inbox", channel_id: null })), away), null);
174+ assert.equal(cueForNotificationKind("inbox"), null);
175+ assert.equal(cueForNotificationKind("approval"), "mention");
176+});
177+
178+test("sounds come at most one every 1.5 s, and one message sounds once however it arrives", () => {
179+ const gate = new SoundGate();
180+ const t0 = 1_000_000;
181+ assert.ok(gate.allow("msg:1", t0));
182+ assert.ok(!gate.allow("msg:2", t0 + 200), "a burst is one sound");
183+ assert.ok(!gate.allow("msg:3", t0 + SOUND_GAP_MS - 1));
184+ assert.ok(gate.allow("msg:4", t0 + SOUND_GAP_MS));
185+ // The same message, from the conversation's socket and then the feed.
186+ assert.ok(!gate.allow("msg:4", t0 + SOUND_GAP_MS * 3));
187+ assert.ok(!gate.allow("msg:4", t0 + SOUND_GAP_MS + SAME_MESSAGE_MS - 1));
188+ assert.ok(gate.allow("msg:4", t0 + SOUND_GAP_MS + SAME_MESSAGE_MS + 1), "long after, it is a new message to the gate");
189+ // A key refused by the gap is still remembered, so the feed's copy a moment later stays quiet.
190+ const again = new SoundGate();
191+ assert.ok(again.allow("msg:a", t0));
192+ assert.ok(!again.allow("msg:b", t0 + 100));
193+ assert.ok(!again.allow("msg:b", t0 + SOUND_GAP_MS + 100), "msg:b was heard of, if not sounded");
194+ // No key: only the gap applies.
195+ assert.ok(again.allow(null, t0 + SOUND_GAP_MS * 2));
196+ assert.ok(!again.allow(null, t0 + SOUND_GAP_MS * 2 + 10));
197+});
198+
199+test("an arrival's key is its message id, the same over the socket and the feed", () => {
200+ assert.equal(arrivalKey(chat(message({ id: "msg_7" }))), "msg:msg_7");
201+ assert.equal(arrivalKey(feed(notification({ id: "msg_7" }))), "msg:msg_7");
202+});
203+
204+test("a desktop notification shows only when wanted, allowed, out of front, and not already pushed", () => {
205+ const base = {
206+ notification: { kind: "dm" as const },
207+ toast: true,
208+ ctx: ctx({ focus: { visible: true, focused: false, viewingChannel: null } }, { desktop_toasts: true }),
209+ permission: "granted" as const,
210+ pushOn: false,
211+ };
212+ assert.ok(wantsDesktopToast(base));
213+ assert.ok(!wantsDesktopToast({ ...base, ctx: ctx({ focus: base.ctx.focus }) }), "off unless turned on");
214+ assert.ok(!wantsDesktopToast({ ...base, permission: "default" }));
215+ assert.ok(!wantsDesktopToast({ ...base, permission: "denied" }));
216+ assert.ok(!wantsDesktopToast({ ...base, pushOn: true }), "the push shows it instead");
217+ assert.ok(!wantsDesktopToast({ ...base, toast: false }), "what the feed would not toast");
218+ assert.ok(!wantsDesktopToast({ ...base, ctx: { ...base.ctx, dnd: true } }));
219+ assert.ok(!wantsDesktopToast({ ...base, ctx: { ...base.ctx, focus: { visible: true, focused: true, viewingChannel: null } } }), "in front: the toast is enough");
220+ assert.ok(wantsDesktopToast({ ...base, ctx: { ...base.ctx, focus: { visible: false, focused: false, viewingChannel: null } } }));
221+ assert.ok(!wantsDesktopToast({ ...base, notification: { kind: "inbox" } }));
222+});
+182−0
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+}
+19−52
33 * feed (`/-/live`, services/notify), a small store the page reads (counts
44 * for the rail, the Chat sidebar and the tab title; toasts; whether the
55 * socket is up), and delivery through a `NotificationSink`: the web's own
6− * (toasts, a soft sound, the app badge) or, inside the desktop app, its
7− * native bridge.
6+ * (toasts and the app badge) or, inside the desktop app, its native
7+ * bridge. Sounds are apart: every notification goes to
8+ * lib/sound-events.ts, which decides and plays (lib/chat-sounds.ts,
9+ * lib/sounds.ts).
810 *
911 * Nothing here polls. The feed sends counts on connect and after every
1012 * change; while the socket is down, `connected` is false and the pages
2022 import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts";
2123
2224 import { openLive } from "./live-socket";
23−import { isIdle, mergePeople } from "./presence";
25+import { dndOn, isIdle, mergePeople } from "./presence";
26+import { notificationArrived } from "./sound-events";
2427
2528 import {
2629 HEARTBEAT_MS,
108111 else if (typeof location !== "undefined") location.assign(href);
109112 }
110113
111−/** The web's: a toast in the tab, a soft sound if wanted, the installed app's badge. */
114+/** The web's: a toast in the tab, the installed app's badge. */
112115 export const webSink: NotificationSink = {
113116 kind: "web",
114117 deliver(notification, { toast }) {
115118 if (!toast) return;
116119 showToast(notification);
117− if (soundOn()) chime();
118120 },
119121 setBadge(count) {
120122 const nav = typeof navigator !== "undefined" ? (navigator as Navigator & { setAppBadge?(n: number): Promise<void>; clearAppBadge?(): Promise<void> }) : null;
136138 if (!toast) return;
137139 if (focused) {
138140 showToast(notification);
139− if (soundOn()) chime();
140141 return;
141142 }
142143 bridge.notify({
267268 return useNotifyState().me;
268269 }
269270
271+/** Whether you are not to be disturbed right now, for code outside React (the sound rules). */
272+export function dndNow(): boolean {
273+ return dndOn(state.me, Date.now());
274+}
275+
276+/** Every notification, however it is shown, goes to the sound rules and the desktop toast (lib/sound-events.ts). */
277+function heard(notification: FeedNotification, toast: boolean): void {
278+ notificationArrived(notification, { toast, dnd: dndNow(), permission: permission(), pushOn: pushChoice() === "on", open: openHref });
279+}
280+
270281 /** Whether the feed is up: pages fall back to a slow refresh only while it is not. */
271282 export function feedConnected(): boolean {
272283 return state.connected;
311322 /** For the settings page's test, and the harness: a notification shown as if it arrived. */
312323 export function showLocally(notification: FeedNotification): void {
313324 currentSink().deliver(notification, { toast: true, focused: true });
325+ heard(notification, true);
314326 }
315327
316328 // ── The socket ───────────────────────────────────────────────────────────
380392 case "notification":
381393 set({ recent: addRecent(state.recent, [event.notification]) });
382394 sink.deliver(event.notification, { toast: event.toast, focused: focused() });
395+ heard(event.notification, event.toast);
383396 break;
384397 case "preferences":
385398 set({ preferences: event.preferences });
539552 }
540553 sink.setBadge(count);
541554 return count;
542−}
543−
544−// ── Sound ────────────────────────────────────────────────────────────────
545−
546−const SOUND_KEY = "g1t:notify:sound";
547−
548−export function soundOn(): boolean {
549− try {
550− return localStorage.getItem(SOUND_KEY) === "on";
551− } catch {
552− return false;
553− }
554−}
555−
556−export function setSound(on: boolean): void {
557− try {
558− if (on) localStorage.setItem(SOUND_KEY, "on");
559− else localStorage.removeItem(SOUND_KEY);
560− } catch {
561− // Kept for this page only.
562− }
563−}
564−
565−let audio: AudioContext | null = null;
566−
567−/** A soft two-note chime, made here: no file to load. */
568−export function chime(): void {
569− try {
570− audio ??= new AudioContext();
571− const at = audio.currentTime;
572− for (const [i, freq] of [880, 1318.5].entries()) {
573− const osc = audio.createOscillator();
574− const gain = audio.createGain();
575− osc.type = "sine";
576− osc.frequency.value = freq;
577− const start = at + i * 0.09;
578− gain.gain.setValueAtTime(0, start);
579− gain.gain.linearRampToValueAtTime(0.06, start + 0.015);
580− gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.35);
581− osc.connect(gain).connect(audio.destination);
582− osc.start(start);
583− osc.stop(start + 0.4);
584− }
585− } catch {
586− // No audio: nothing lost.
587− }
588555 }
589556
590557 // ── Browser push ─────────────────────────────────────────────────────────
+1−0
4545 test("pausing notifications: half an hour, an hour, or until nine tomorrow morning", () => {
4646 assert.equal(pauseUntil("30m", NOW), new Date(NOW.getTime() + 30 * MIN).toISOString());
4747 assert.equal(pauseUntil("1h", NOW), new Date(NOW.getTime() + 60 * MIN).toISOString());
48+ assert.equal(pauseUntil("2h", NOW), new Date(NOW.getTime() + 120 * MIN).toISOString());
4849 assert.equal(pauseUntil("tomorrow", NOW), new Date(2026, 9, 8, 9, 0).toISOString());
4950 // Just after midnight, tomorrow is still the next day's morning.
5051 assert.equal(pauseUntil("tomorrow", new Date(2026, 9, 8, 0, 30)), new Date(2026, 9, 9, 9, 0).toISOString());
+4−2
3636 { emoji: "🌴", text: "On vacation", clear: "never" },
3737 ];
3838
39−export type PauseFor = "30m" | "1h" | "tomorrow";
39+export type PauseFor = "30m" | "1h" | "2h" | "tomorrow";
4040
4141 export const PAUSE_FOR: { key: PauseFor; label: string }[] = [
4242 { key: "30m", label: "For 30 minutes" },
4343 { key: "1h", label: "For 1 hour" },
44+ { key: "2h", label: "For 2 hours" },
4445 { key: "tomorrow", label: "Until tomorrow" },
4546 ];
4647
8283 }
8384 }
8485
85−/** When notifications resume: in half an hour, an hour, or at nine tomorrow morning. */
86+/** When notifications resume: in half an hour, an hour, two hours, or at nine tomorrow morning. */
8687 export function pauseUntil(choice: PauseFor, now: Date): string {
8788 if (choice === "30m") return new Date(now.getTime() + 30 * MINUTE).toISOString();
8889 if (choice === "1h") return new Date(now.getTime() + 60 * MINUTE).toISOString();
90+ if (choice === "2h") return new Date(now.getTime() + 120 * MINUTE).toISOString();
8991 const at = nextMidnight(now);
9092 at.setHours(9, 0, 0, 0);
9193 return at.toISOString();
+141−0
1+/**
2+ * Where chat's events meet the sound rules: a message over the open
3+ * conversation's socket (components/chat/channel.tsx) or a notification
4+ * over the feed (lib/notify-client.ts) comes here with what the page knows
5+ * of itself, lib/chat-sounds.ts decides, and lib/sounds.ts plays. One gate
6+ * keeps sounds apart. Browser-only; the rules it runs are tested in
7+ * chat-sounds.test.ts.
8+ *
9+ * It also shows the system notification (the browser's Notifications API)
10+ * a person asked for under Desktop notifications: for a message to them
11+ * while the window is not in front, from the open tab itself, with no
12+ * service worker and no push involved.
13+ */
14+import type { ChatMessage, FeedNotification } from "@g1t/contracts";
15+import { type SoundCue, cueOn } from "@g1t/contracts/sounds";
16+
17+import { type Arrival, type FocusState, SoundGate, arrivalKey, cueFor, wantsDesktopToast } from "./chat-sounds";
18+import { play, soundSettings } from "./sounds";
19+
20+let me: { id: string; username: string } | null = null;
21+let viewing: string | null = null;
22+let muted: ReadonlySet<string> = new Set();
23+const gate = new SoundGate();
24+
25+/** Who is signed in, from the root with the page: their own messages never sound. */
26+export function setSoundViewer(viewer: { id: string; username: string } | null): void {
27+ me = viewer;
28+}
29+
30+/** The conversation on screen, from its page: null when leaving it. */
31+export function viewingConversation(channelId: string | null): void {
32+ viewing = channelId;
33+}
34+
35+export function viewingChannel(): string | null {
36+ return viewing;
37+}
38+
39+/** The conversations muted in the sidebar, as it last read them. */
40+export function setMutedConversations(channelIds: Iterable<string>): void {
41+ muted = new Set(channelIds);
42+}
43+
44+/** What the tab can see of itself right now. */
45+export function focusState(): FocusState {
46+ const has = typeof document !== "undefined";
47+ return {
48+ visible: has && document.visibilityState === "visible",
49+ focused: has && document.hasFocus(),
50+ viewingChannel: viewing,
51+ };
52+}
53+
54+function sound(arrival: Arrival, dnd: boolean, mutedHere?: boolean): SoundCue | null {
55+ const all = mutedHere ? new Set([...muted, arrival.source === "chat" ? arrival.message.channel_id : (arrival.notification.channel_id ?? "")]) : muted;
56+ const cue = cueFor(arrival, { me, focus: focusState(), muted: all, dnd, settings: soundSettings() });
57+ // Heard of either way, so the same message over the other door stays quiet.
58+ const allowed = gate.allow(arrivalKey(arrival), Date.now());
59+ if (!cue || !allowed) return null;
60+ play(cue);
61+ return cue;
62+}
63+
64+/**
65+ * A new message in the conversation open on this page. `muted` is that
66+ * conversation's own setting, which the sidebar may not have told us yet.
67+ * Returns what played, for the harness.
68+ */
69+export function chatMessageArrived(message: ChatMessage, channelKind: "channel" | "dm", options: { dnd: boolean; muted?: boolean }): SoundCue | null {
70+ return sound({ source: "chat", message, channelKind }, options.dnd, options.muted);
71+}
72+
73+/**
74+ * A notification over the feed. `toast` is the feed's own decision about a
75+ * pop-up; the sound is decided here from the same facts. Shows the system
76+ * notification too, when wanted (`wantsDesktopToast`).
77+ */
78+export function notificationArrived(
79+ notification: FeedNotification,
80+ options: {
81+ toast: boolean;
82+ dnd: boolean;
83+ permission: "default" | "granted" | "denied";
84+ pushOn: boolean;
85+ open: (href: string) => void;
86+ },
87+): { cue: SoundCue | null; desktopToast: boolean } {
88+ const cue = sound({ source: "feed", notification }, options.dnd);
89+ const desktopToast = wantsDesktopToast({
90+ notification,
91+ toast: options.toast,
92+ ctx: { focus: focusState(), dnd: options.dnd, settings: soundSettings() },
93+ permission: options.permission,
94+ pushOn: options.pushOn,
95+ });
96+ if (desktopToast) showDesktopToast(notification, options.open);
97+ return { cue, desktopToast };
98+}
99+
100+/** The soft tick as you send, if turned on (it is off until then), and never while not to be disturbed. */
101+export function messageSent(options: { dnd: boolean }): void {
102+ const settings = soundSettings();
103+ if (!settings.sounds_enabled || options.dnd || !cueOn(settings, "sent")) return;
104+ play("sent");
105+}
106+
107+/**
108+ * The browser's own notification, from this tab: it closes itself after a
109+ * while, a newer one about the same conversation replaces it (`tag`), and a
110+ * click brings the window forward and opens the conversation. Never asks
111+ * for permission: that is the settings page's button alone.
112+ */
113+export function showDesktopToast(notification: FeedNotification, open: (href: string) => void): void {
114+ try {
115+ if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
116+ const shown = new Notification(notification.title, {
117+ body: notification.body,
118+ tag: notification.channel_id ? `chat:${notification.channel_id}` : `${notification.kind}:${notification.id}`,
119+ icon: "/icon-192.png",
120+ silent: true,
121+ });
122+ shown.onclick = () => {
123+ try {
124+ window.focus();
125+ } catch {
126+ // Some browsers refuse; the page still moves.
127+ }
128+ open(notification.href);
129+ shown.close();
130+ };
131+ setTimeout(() => shown.close(), 8_000);
132+ } catch {
133+ // A browser that has the API but not here (an iframe, say): nothing shown.
134+ }
135+}
136+
137+/** Test seam: the gate forgets everything, so a harness can play cue after cue. */
138+export function resetSoundGateForTest(): void {
139+ (gate as unknown as { lastAt: number; seen: Map<string, number> }).lastAt = Number.NEGATIVE_INFINITY;
140+ (gate as unknown as { seen: Map<string, number> }).seen.clear();
141+}
+71−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { SOUND_CUES } from "@g1t/contracts/sounds";
5+
6+import { PEAK, cueLength, cueSpec, envelope, renderCue, volumeGain } from "./sound-synth.ts";
7+
8+const RATE = 48_000;
9+
10+test("every cue in both sets is short, starts and ends in silence, and never clips", () => {
11+ for (const set of ["soft", "bright"] as const) {
12+ for (const cue of [...SOUND_CUES, "call"] as const) {
13+ const spec = cueSpec(set, cue);
14+ const length = cueLength(spec);
15+ assert.ok(length >= 40 && length <= 260, `${set} ${cue} lasts ${length} ms`);
16+ const samples = renderCue(spec, RATE);
17+ assert.equal(samples.length, Math.ceil((length / 1000) * RATE));
18+ // No click: the first and last millisecond are quiet.
19+ const ms = RATE / 1000;
20+ for (let i = 0; i < ms; i++) {
21+ assert.ok(Math.abs(samples[i]!) < 0.15, `${set} ${cue} clicks on at sample ${i}: ${samples[i]}`);
22+ assert.ok(Math.abs(samples[samples.length - 1 - i]!) < 0.05, `${set} ${cue} clicks off`);
23+ }
24+ assert.ok(Math.abs(samples[0]!) < 1e-6);
25+ assert.ok(Math.abs(samples[samples.length - 1]!) < 1e-3);
26+ let peak = 0;
27+ let energy = 0;
28+ for (const s of samples) {
29+ peak = Math.max(peak, Math.abs(s));
30+ energy += s * s;
31+ }
32+ assert.ok(Math.abs(peak - PEAK) < 1e-6, `${set} ${cue} peaks at ${peak}`);
33+ assert.ok(energy > 1, `${set} ${cue} is audible`);
34+ }
35+ }
36+});
37+
38+test("the cues are told apart: different figures, and Bright is higher and shorter than Soft", () => {
39+ const soft = [...SOUND_CUES].map((cue) => cueSpec("soft", cue));
40+ const figures = new Set(soft.map((spec) => spec.notes.map((n) => `${n.freq.toFixed(0)}@${n.at}+${n.dur}`).join(" ")));
41+ assert.equal(figures.size, soft.length, "no two Soft cues are the same figure");
42+ for (const cue of SOUND_CUES) {
43+ const s = cueSpec("soft", cue);
44+ const b = cueSpec("bright", cue);
45+ assert.equal(b.notes.length, s.notes.length, "the same roles");
46+ for (const [i, note] of b.notes.entries()) {
47+ assert.ok(note.freq > s.notes[i]!.freq, `${cue}: Bright is higher`);
48+ assert.ok(note.dur < s.notes[i]!.dur, `${cue}: Bright is shorter`);
49+ }
50+ assert.ok(cueLength(b) < cueLength(s));
51+ }
52+ // The tick as you send is the quietest and shortest.
53+ assert.ok(cueLength(cueSpec("soft", "sent")) < 80);
54+});
55+
56+test("the envelope rises from nothing, falls, and lands on exactly nothing", () => {
57+ assert.equal(envelope(-1, 100), 0);
58+ assert.equal(envelope(0, 100), 0);
59+ assert.ok(envelope(3, 100) > 0 && envelope(3, 100) < envelope(6, 100));
60+ assert.ok(envelope(50, 100) < envelope(10, 100), "it decays");
61+ assert.ok(envelope(99.9, 100) < 0.01);
62+ assert.equal(envelope(100, 100), 0);
63+});
64+
65+test("volume is a curve: half way reads as half as loud, not a third", () => {
66+ assert.equal(volumeGain(0), 0);
67+ assert.equal(volumeGain(100), 1);
68+ assert.ok(volumeGain(50) > 0.3 && volumeGain(50) < 0.35);
69+ assert.equal(volumeGain(200), 1);
70+ assert.equal(volumeGain(-5), 0);
71+});
+166−0
1+/**
2+ * The sounds themselves, as numbers: each cue is a few notes with an
3+ * envelope, rendered into samples here so nothing is downloaded and nothing
4+ * is licensed. Pure, so the shapes are tested on their own
5+ * (sound-synth.test.ts); lib/sounds.ts puts the samples in an AudioBuffer
6+ * and plays them.
7+ *
8+ * Two sets with the same roles. "Soft" is warm and low: a sine with a
9+ * little of its second and third harmonic, 190–250 ms, like a muted
10+ * marimba. "Bright" is the same figures a fifth higher and a quarter
11+ * shorter, nearly pure sines, like a small bell. Every note fades in over
12+ * 6 ms and out over its last 12 ms, so there is never a click.
13+ */
14+import type { SoundCue, SoundSet } from "@g1t/contracts/sounds";
15+
16+/** A note: when it starts and how long it lasts, in ms; `gain` scales it (1 is full). */
17+export type Note = { freq: number; at: number; dur: number; gain?: number };
18+
19+/** The timbre of a set: which overtones ride on each note, as fractions of the fundamental. */
20+export type Timbre = { partials: { ratio: number; gain: number; decay: number }[] };
21+
22+export type CueSpec = { notes: Note[]; timbre: Timbre };
23+
24+// Pitches, in Hz (equal temperament, A4 = 440).
25+const C5 = 523.25;
26+const D5 = 587.33;
27+const E5 = 659.25;
28+const G5 = 783.99;
29+const A5 = 880;
30+const C6 = 1046.5;
31+const D6 = 1174.66;
32+const A4 = 440;
33+const CS5 = 554.37;
34+
35+/** Warm: a rounded fundamental with a touch of the octave and the twelfth, which die away first. */
36+const WARM: Timbre = {
37+ partials: [
38+ { ratio: 1, gain: 1, decay: 1 },
39+ { ratio: 2, gain: 0.22, decay: 0.55 },
40+ { ratio: 3, gain: 0.07, decay: 0.4 },
41+ ],
42+};
43+
44+/** Bright: nearly a pure sine, with a faint octave for sparkle. */
45+const BRIGHT: Timbre = {
46+ partials: [
47+ { ratio: 1, gain: 1, decay: 1 },
48+ { ratio: 2, gain: 0.1, decay: 0.5 },
49+ ],
50+};
51+
52+/**
53+ * The Soft set. Each is short, two tones, and tells its own story: a
54+ * message steps up a third (something arrived), a direct message leaps a
55+ * fifth (someone is talking to you), a mention rings twice on the high note
56+ * (look up), an agent finishing settles down a fifth (resolved), and
57+ * sending is a single soft tick. `call` is reserved: a two-tone to repeat.
58+ */
59+export const SOFT: Record<SoundCue, Note[]> = {
60+ message: [
61+ { freq: C5, at: 0, dur: 90, gain: 0.9 },
62+ { freq: E5, at: 70, dur: 120, gain: 0.8 },
63+ ],
64+ direct: [
65+ { freq: D5, at: 0, dur: 90, gain: 0.9 },
66+ { freq: A5, at: 80, dur: 150, gain: 0.85 },
67+ ],
68+ mention: [
69+ { freq: A5, at: 0, dur: 60, gain: 0.8 },
70+ { freq: D6, at: 60, dur: 90, gain: 0.9 },
71+ { freq: D6, at: 150, dur: 90, gain: 0.75 },
72+ ],
73+ agent_done: [
74+ { freq: G5, at: 0, dur: 80, gain: 0.85 },
75+ { freq: C5, at: 80, dur: 160, gain: 0.9 },
76+ ],
77+ sent: [{ freq: C6, at: 0, dur: 55, gain: 0.45 }],
78+ call: [
79+ { freq: A4, at: 0, dur: 120, gain: 0.9 },
80+ { freq: CS5, at: 120, dur: 130, gain: 0.9 },
81+ ],
82+};
83+
84+/** A fifth higher and a quarter shorter: the Bright set from the Soft figures. */
85+function brighten(notes: Note[]): Note[] {
86+ return notes.map((note) => ({ ...note, freq: note.freq * 1.5, at: note.at * 0.75, dur: note.dur * 0.75 }));
87+}
88+
89+export const BRIGHT_NOTES: Record<SoundCue, Note[]> = {
90+ message: brighten(SOFT.message),
91+ direct: brighten(SOFT.direct),
92+ mention: brighten(SOFT.mention),
93+ agent_done: brighten(SOFT.agent_done),
94+ sent: brighten(SOFT.sent),
95+ call: brighten(SOFT.call),
96+};
97+
98+export function cueSpec(set: SoundSet, cue: SoundCue): CueSpec {
99+ return set === "bright" ? { notes: BRIGHT_NOTES[cue], timbre: BRIGHT } : { notes: SOFT[cue], timbre: WARM };
100+}
101+
102+/** How long a cue lasts, in ms: its last note's end. */
103+export function cueLength(spec: CueSpec): number {
104+ return Math.max(0, ...spec.notes.map((note) => note.at + note.dur));
105+}
106+
107+/** The fade in and out of each note, in ms. */
108+export const ATTACK_MS = 6;
109+export const RELEASE_MS = 12;
110+/** The loudest any rendered sample is: room left under full scale. */
111+export const PEAK = 0.8;
112+
113+/**
114+ * A note's loudness at `t` ms into it: a raised-cosine rise over the
115+ * attack, an exponential fall to about a third by the end, and a
116+ * raised-cosine release over the last `RELEASE_MS` to exactly zero.
117+ */
118+export function envelope(t: number, dur: number): number {
119+ if (t < 0 || t >= dur) return 0;
120+ const rise = t < ATTACK_MS ? 0.5 - 0.5 * Math.cos((Math.PI * t) / ATTACK_MS) : 1;
121+ const fall = Math.exp((-1.1 * t) / dur);
122+ const left = dur - t;
123+ const release = left < RELEASE_MS ? 0.5 - 0.5 * Math.cos((Math.PI * left) / RELEASE_MS) : 1;
124+ return rise * fall * release;
125+}
126+
127+/**
128+ * The cue as mono samples at `sampleRate`, peaking at `PEAK`. Each note is
129+ * the sum of its timbre's partials, each with the envelope; overlapping
130+ * notes add. A quiet cue is scaled up to the peak and a loud one down, so
131+ * the volume setting is the only thing that changes loudness.
132+ */
133+export function renderCue(spec: CueSpec, sampleRate: number): Float32Array<ArrayBuffer> {
134+ const length = Math.ceil((cueLength(spec) / 1000) * sampleRate);
135+ const out = new Float32Array(new ArrayBuffer(length * 4));
136+ for (const note of spec.notes) {
137+ const start = Math.floor((note.at / 1000) * sampleRate);
138+ const count = Math.floor((note.dur / 1000) * sampleRate);
139+ const gain = note.gain ?? 1;
140+ for (let i = 0; i < count && start + i < length; i++) {
141+ const tMs = (i / sampleRate) * 1000;
142+ const shape = envelope(tMs, note.dur);
143+ if (shape === 0) continue;
144+ let sample = 0;
145+ for (const partial of spec.timbre.partials) {
146+ // Higher partials fade faster than the fundamental: the tone mellows as it rings.
147+ const fade = partial.decay === 1 ? 1 : Math.exp((-(1 - partial.decay) * 4 * tMs) / note.dur);
148+ sample += partial.gain * fade * Math.sin((2 * Math.PI * note.freq * partial.ratio * i) / sampleRate);
149+ }
150+ out[start + i] += gain * shape * sample;
151+ }
152+ }
153+ let peak = 0;
154+ for (const sample of out) peak = Math.max(peak, Math.abs(sample));
155+ if (peak > 0) {
156+ const scale = PEAK / peak;
157+ for (let i = 0; i < out.length; i++) out[i] *= scale;
158+ }
159+ return out;
160+}
161+
162+/** The perceived loudness of a 0–100 setting as a gain: the ear hears a curve, not a line. */
163+export function volumeGain(volume: number): number {
164+ const v = Math.max(0, Math.min(100, volume)) / 100;
165+ return v ** 1.6;
166+}
+233−0
1+/**
2+ * The sound layer: named cues (`message`, `direct`, `mention`,
3+ * `agent_done`, `sent`, and `call`, reserved), the person's sound settings
4+ * as the page knows them, and a `SoundPort` that plays a cue.
5+ *
6+ * The default port is Web Audio: each cue of a set is rendered once from
7+ * lib/sound-synth.ts into an AudioBuffer and reused, nothing is downloaded,
8+ * and the context is made and resumed on the first click or key (browsers
9+ * allow sound only after a gesture), so a cue that arrives before anyone
10+ * has touched the page is kept as a note in the log, not an error. Nothing
11+ * here throws: a browser with no audio plays nothing.
12+ *
13+ * The port is one interface on purpose: the desktop app (Tauri, loading
14+ * this same web code) swaps in a native implementation with
15+ * `setSoundPort`, so cues play through the system mixer and respect the
16+ * system's own do-not-disturb, and nothing else in the app changes. Which
17+ * cue plays when is lib/chat-sounds.ts; lib/sound-events.ts runs it.
18+ */
19+import { useSyncExternalStore } from "react";
20+
21+import { DEFAULT_SOUND_SETTINGS, type SoundCue, type SoundSet, type SoundSettings, type SoundSettingsChange } from "@g1t/contracts/sounds";
22+
23+import { cueSpec, renderCue, volumeGain } from "./sound-synth";
24+
25+// ── The port ─────────────────────────────────────────────────────────────
26+
27+/** Where a cue goes to be heard. One implementation at a time (`setSoundPort`). */
28+export interface SoundPort {
29+ /** Whether a sound could be heard now: audio is there and has been allowed. */
30+ canPlay(): boolean;
31+ /** Plays a cue from a set at a volume (0–100). Never throws. */
32+ play(cue: SoundCue, set: SoundSet, volume: number): void;
33+ /** The same, from the settings page: a set the person is choosing, now, whatever is on screen. */
34+ preview(cue: SoundCue, set: SoundSet, volume: number): void;
35+}
36+
37+/** Web Audio: the browser's own, used unless the desktop app installs its port. */
38+export class WebAudioPort implements SoundPort {
39+ private context: AudioContext | null = null;
40+ private readonly buffers = new Map<string, AudioBuffer>();
41+
42+ /** The context, made on first use; null where the browser has none. */
43+ private audio(): AudioContext | null {
44+ if (this.context) return this.context;
45+ try {
46+ if (typeof window === "undefined" || typeof AudioContext === "undefined") return null;
47+ this.context = new AudioContext();
48+ } catch {
49+ return null;
50+ }
51+ return this.context;
52+ }
53+
54+ /** Makes the context and resumes it: called from a user gesture, so the browser allows it. */
55+ unlock(): void {
56+ const context = this.audio();
57+ if (context && context.state === "suspended") void context.resume().catch(() => {});
58+ }
59+
60+ canPlay(): boolean {
61+ const context = this.audio();
62+ return !!context && context.state === "running";
63+ }
64+
65+ private buffer(context: AudioContext, cue: SoundCue, set: SoundSet): AudioBuffer {
66+ const key = `${set}:${cue}:${context.sampleRate}`;
67+ let buffer = this.buffers.get(key);
68+ if (!buffer) {
69+ const samples = renderCue(cueSpec(set, cue), context.sampleRate);
70+ buffer = context.createBuffer(1, Math.max(1, samples.length), context.sampleRate);
71+ buffer.copyToChannel(samples, 0);
72+ this.buffers.set(key, buffer);
73+ }
74+ return buffer;
75+ }
76+
77+ play(cue: SoundCue, set: SoundSet, volume: number): void {
78+ try {
79+ const context = this.audio();
80+ if (!context) return;
81+ if (context.state === "suspended") void context.resume().catch(() => {});
82+ const source = context.createBufferSource();
83+ source.buffer = this.buffer(context, cue, set);
84+ const gain = context.createGain();
85+ gain.gain.value = volumeGain(volume);
86+ source.connect(gain).connect(context.destination);
87+ source.start();
88+ } catch {
89+ // No audio here: nothing lost.
90+ }
91+ }
92+
93+ preview(cue: SoundCue, set: SoundSet, volume: number): void {
94+ this.unlock();
95+ this.play(cue, set, volume);
96+ }
97+}
98+
99+let port: SoundPort = new WebAudioPort();
100+
101+/** Installs another way to play cues (the desktop app's native one). Returns the one it replaced. */
102+export function setSoundPort(next: SoundPort): SoundPort {
103+ const before = port;
104+ port = next;
105+ return before;
106+}
107+
108+export function soundPort(): SoundPort {
109+ return port;
110+}
111+
112+// ── Settings ─────────────────────────────────────────────────────────────
113+
114+let settings: SoundSettings = DEFAULT_SOUND_SETTINGS;
115+const listeners = new Set<() => void>();
116+
117+function notify(): void {
118+ for (const listener of listeners) listener();
119+}
120+
121+/** The settings as the page has them: the root loader's with the page, then as saved here. */
122+export function soundSettings(): SoundSettings {
123+ return settings;
124+}
125+
126+/** The root hands the person's settings over with the page, so the first event already respects them. */
127+export function applySoundSettings(next: SoundSettings | null | undefined): void {
128+ if (!next) return;
129+ settings = { ...DEFAULT_SOUND_SETTINGS, ...next, sound_cues: { ...DEFAULT_SOUND_SETTINGS.sound_cues, ...next.sound_cues } };
130+ notify();
131+}
132+
133+function subscribe(listener: () => void): () => void {
134+ listeners.add(listener);
135+ return () => listeners.delete(listener);
136+}
137+
138+export function useSoundSettings(): SoundSettings {
139+ return useSyncExternalStore(subscribe, () => settings, () => DEFAULT_SOUND_SETTINGS);
140+}
141+
142+/**
143+ * Saves a change with the account (`/-/notify`, intent `sounds`): shown at
144+ * once, put back if it does not save. Returns the settings as kept, or null.
145+ */
146+export async function saveSoundSettings(change: SoundSettingsChange): Promise<SoundSettings | null> {
147+ const before = settings;
148+ applySoundSettings({ ...settings, ...change, sound_cues: { ...settings.sound_cues, ...change.sound_cues } });
149+ try {
150+ const response = await fetch("/-/notify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ intent: "sounds", change }) });
151+ if (!response.ok) throw new Error(`status ${response.status}`);
152+ const saved = (await response.json()) as SoundSettings;
153+ applySoundSettings(saved);
154+ return saved;
155+ } catch (error) {
156+ console.error("sounds: the settings did not save", error);
157+ applySoundSettings(before);
158+ return null;
159+ }
160+}
161+
162+// ── Playing ──────────────────────────────────────────────────────────────
163+
164+/** What happened, for the harness and for anyone debugging: the last few cues. */
165+export type SoundEvent = { cue: SoundCue; set: SoundSet; volume: number; at: number; how: "play" | "preview" | "silent" };
166+
167+const LOG_KEPT = 20;
168+const log: SoundEvent[] = [];
169+
170+function record(event: SoundEvent): void {
171+ log.push(event);
172+ if (log.length > LOG_KEPT) log.shift();
173+ if (typeof window !== "undefined") {
174+ try {
175+ window.dispatchEvent(new CustomEvent("g1t:sound", { detail: event }));
176+ } catch {
177+ // Nothing listening.
178+ }
179+ }
180+}
181+
182+/** The last cues played (or not, `silent`, when audio was not allowed yet), oldest first. */
183+export function recentSounds(): readonly SoundEvent[] {
184+ return log;
185+}
186+
187+/** Plays a cue from the person's set at their volume. The caller has already decided it should sound. */
188+export function play(cue: SoundCue): void {
189+ const { sound_set: set, sound_volume: volume } = settings;
190+ if (volume <= 0) {
191+ record({ cue, set, volume, at: Date.now(), how: "silent" });
192+ return;
193+ }
194+ const how = port.canPlay() ? "play" : "silent";
195+ record({ cue, set, volume, at: Date.now(), how });
196+ port.play(cue, set, volume);
197+}
198+
199+/** From the settings page: a cue from any set, at the volume being chosen. */
200+export function preview(cue: SoundCue, set: SoundSet = settings.sound_set, volume: number = settings.sound_volume): void {
201+ record({ cue, set, volume, at: Date.now(), how: "preview" });
202+ port.preview(cue, set, volume);
203+}
204+
205+export function canPlay(): boolean {
206+ return port.canPlay();
207+}
208+
209+// ── Allowing sound ───────────────────────────────────────────────────────
210+
211+const GESTURES = ["pointerdown", "keydown", "touchstart"] as const;
212+let unlocking = false;
213+
214+/**
215+ * From the first click or key on the page, audio is allowed: the context is
216+ * made then, so a cue a second later plays. Mounted once by the root
217+ * (components/notifications/live-notifications.tsx). Returns how to stop.
218+ */
219+export function startSounds(): () => void {
220+ if (typeof window === "undefined" || unlocking) return () => {};
221+ unlocking = true;
222+ const unlock = () => {
223+ const current = port;
224+ if (current instanceof WebAudioPort) current.unlock();
225+ if (current.canPlay()) stop();
226+ };
227+ const stop = () => {
228+ for (const name of GESTURES) window.removeEventListener(name, unlock, { capture: true });
229+ unlocking = false;
230+ };
231+ for (const name of GESTURES) window.addEventListener(name, unlock, { capture: true, passive: true });
232+ return stop;
233+}
+12−2
8282 const dock = readCookie(cookies, DOCK_COOKIE);
8383 // Whether sign-up takes an invite: the sign-up page says so, and
8484 // Settings → Invites offers invites to g1t only then. Cached per isolate.
85− const [shell, mode] = await Promise.all([
85+ const [shell, mode, sounds] = await Promise.all([
8686 // An account still confirming its address sees only the pages that
8787 // allows (lib/confirm-gate.ts), in the visitor's frame.
8888 user && !awaitsConfirmation(user)
9494 })
9595 : visitorShell(params, context),
9696 registrationMode(),
97+ // Their chat sounds, with the page, so the first live event already
98+ // respects them (lib/sounds.ts); the defaults when identity is slow.
99+ user && !awaitsConfirmation(user) ? identity.soundSettings(user).catch(() => null) : Promise.resolve(null),
97100 ]);
98101 // The apps this person pinned to their rail here: as their account keeps
99102 // them (read with the rest of the shell), else as this device's cookie
110113 sidebarClosed: sidebarClosed(readCookie(cookies, SIDEBAR_COOKIE)),
111114 // Their Appearance, so the page is drawn in it from the first byte (lib/theme.ts).
112115 theme: readTheme(readCookie(cookies, THEME_COOKIE)),
116+ // What they hear when chat moves (components/notifications/sounds.tsx).
117+ sounds,
113118 inviteOnly: mode !== "open",
114119 addresses: addresses(),
115120 // Visitors from where the law asks first are asked before analytics runs.
447452 )}
448453 {/* Live notifications for the signed-in: the feed socket, toasts, the tab's count (components/notifications). */}
449454 {user && !awaitsConfirmation(user) && (
450− <LiveNotifications workspace={root?.shell?.workspace?.slug ?? null} inbox={root?.shell?.inbox?.unread ?? null} />
455+ <LiveNotifications
456+ workspace={root?.shell?.workspace?.slug ?? null}
457+ inbox={root?.shell?.inbox?.unread ?? null}
458+ sounds={root?.sounds ?? null}
459+ me={{ id: user.id, username: user.username }}
460+ />
451461 )}
452462 <AnalyticsConsent />
453463 <ScrollRestoration nonce={nonce} />
+12−4
22 import { data } from "react-router";
33
44 import { notifyClient, type NotifyPreferencesChange, type PresenceChange, type PushSubscriptionJson } from "@g1t/contracts";
5+import type { SoundSettingsChange } from "@g1t/contracts/sounds";
56
67 import type { Route } from "./+types/api";
8+import { identity } from "../../lib/services.server";
79 import { assertSameOrigin, requireUser } from "../../lib/session.server";
810
911 function client() {
1416 /**
1517 * The signed-in person's notification settings, as JSON: `GET` their
1618 * status (with `?endpoint=` for whether this browser gets pushes), `POST`
17− * a change: `subscribe`, `unsubscribe`, `preferences`, `test`, or
18− * `presence` (your status, being away, Do Not Disturb).
19+ * a change: `subscribe`, `unsubscribe`, `preferences`, `test`,
20+ * `presence` (your status, being away, Do Not Disturb), or `sounds` (what
21+ * you hear when chat moves, kept with the account by identity).
1922 */
2023 export async function loader({ context, request }: Route.LoaderArgs) {
2124 const user = requireUser(context, request);
2932 subscription?: PushSubscriptionJson & { expirationTime?: number | null };
3033 endpoint?: string;
3134 preferences?: NotifyPreferencesChange;
32− change?: PresenceChange;
35+ change?: PresenceChange | SoundSettingsChange;
3336 };
3437
3538 export async function action({ context, request }: Route.ActionArgs) {
5053 return Response.json(await notify.setPreferences(user, sent.preferences ?? {}));
5154 case "test":
5255 return Response.json(await notify.test(user));
56+ case "sounds": {
57+ const saved = await identity.setSoundSettings(user, (sent.change ?? {}) as SoundSettingsChange);
58+ if (!saved.ok) return Response.json({ ok: false, error: saved.error.message }, { status: 400 });
59+ return Response.json(saved.value);
60+ }
5361 case "presence": {
5462 // Set by hand here: a calendar's or an integration's comes through their own door.
55− const change = sent.change ?? {};
63+ const change = (sent.change ?? {}) as PresenceChange;
5664 const status = change.status ? { ...change.status, source: "manual" as const } : change.status;
5765 return Response.json(await notify.setPresence(user, { ...change, ...("status" in change ? { status } : {}) }));
5866 }
+13−7
11 /**
2− * Settings → Notifications: what you are also emailed for, how you watch
2+ * Settings → Notifications: what you hear when chat moves and Do not
3+ * disturb (identity keeps the sounds, notify your presence), what pops up
4+ * and is pushed (notify), what you are also emailed for, how you watch
35 * repositories you create, and the repositories you watch other than the
4− * default way. The events service keeps all three (`inbox_settings`,
6+ * default way. The events service keeps those three (`inbox_settings`,
57 * `inbox_watched`); the notifications themselves are at /notifications.
68 */
79 import { Eye, EyeOff } from "lucide-react";
1618 import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
1719 import { EMAIL_REASONS, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm } from "../../lib/inbox";
1820 import { page } from "../../lib/meta";
19−import { inbox } from "../../lib/services.server";
21+import { identity, inbox } from "../../lib/services.server";
2022 import { assertSameOrigin, requireUser } from "../../lib/session.server";
21−// Live notifications: pop-ups, browser notifications, sound (services/notify).
23+// Live notifications: pop-ups and browser notifications (services/notify).
2224 import { LiveNotificationSettings } from "../../components/notifications/settings";
25+// Sounds, Do not disturb and desktop notifications (identity, notify).
26+import { SoundSettingsSection } from "../../components/notifications/sounds";
2327
2428 export function meta(args: Route.MetaArgs) {
2529 return page(args, { title: "Notifications · Settings · g1t" });
2731
2832 export async function loader({ request, context }: Route.LoaderArgs) {
2933 const user = requireUser(context, request);
30− const [settings, watched] = await Promise.all([
34+ const [settings, watched, sounds] = await Promise.all([
3135 inbox.settings(user.username).catch(() => null),
3236 inbox.watched(user.username).catch(() => null),
37+ identity.soundSettings(user).catch(() => null),
3338 ]);
34− return { settings, watched, workspaces: user.workspaces ?? [] };
39+ return { settings, watched, sounds, workspaces: user.workspaces ?? [] };
3540 }
3641
3742 export async function action({ request, context }: Route.ActionArgs) {
7277 >;
7378
7479 export default function NotificationSettings({ loaderData, actionData }: Route.ComponentProps) {
75− const { settings, watched, workspaces } = loaderData;
80+ const { settings, watched, sounds, workspaces } = loaderData;
7681 const navigation = useNavigation();
7782 const said = (intent: string) => (actionData?.intent === intent && navigation.state === "idle" ? actionData : null);
7883 if (!settings) {
8085 }
8186 return (
8287 <div className="space-y-10">
88+ <SoundSettingsSection initial={sounds} />
8389 <LiveNotificationSettings workspaces={workspaces} />
8490 <section aria-labelledby="email-heading">
8591 <h2 id="email-heading" className="text-sm font-semibold">
+85−0
11001100 pub apps: Vec<String>,
11011101 }
11021102
1103+// --- Sounds and desktop notifications --------------------------------------
1104+//
1105+// What a person hears when chat moves, kept with their account so a phone,
1106+// a laptop and the desktop app all sound the same. Which sound plays when
1107+// is the web app's rule (apps/web/app/lib/chat-sounds.ts); identity keeps
1108+// the choices. Do not disturb is not here: it is the person's presence
1109+// (`dnd_until`, services/notify), which also silences pop-ups and pushes.
1110+// Mirrors `SoundSettings` in packages/contracts/src/identity.ts.
1111+
1112+/// The sets of sounds a person can choose from.
1113+pub const SOUND_SETS: [&str; 2] = ["soft", "bright"];
1114+/// The cues a person can turn on and off. `call` is reserved and has no setting.
1115+pub const SOUND_CUES: [&str; 5] = ["message", "direct", "mention", "agent_done", "sent"];
1116+/// Before anyone changes them: every cue but the tick as you send.
1117+pub const SOUND_CUES_OFF_BY_DEFAULT: [&str; 1] = ["sent"];
1118+pub const DEFAULT_SOUND_SET: &str = "soft";
1119+pub const DEFAULT_SOUND_VOLUME: u8 = 60;
1120+
1121+/// A person's sound settings, as kept and as answered. Wire fields are
1122+/// snake_case, as the site's JSON is.
1123+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1124+pub struct SoundSettings {
1125+ /// Whether anything plays at all.
1126+ pub sounds_enabled: bool,
1127+ /// One of [`SOUND_SETS`].
1128+ pub sound_set: String,
1129+ /// 0 to 100.
1130+ pub sound_volume: u8,
1131+ /// Each cue on or off, by name; a cue left out is on, except `sent`.
1132+ #[serde(default)]
1133+ pub sound_cues: std::collections::BTreeMap<String, bool>,
1134+ /// Whether an open tab shows a system notification for a message to
1135+ /// you while its window is not in front.
1136+ pub desktop_toasts: bool,
1137+}
1138+
1139+impl Default for SoundSettings {
1140+ fn default() -> Self {
1141+ SoundSettings {
1142+ sounds_enabled: true,
1143+ sound_set: DEFAULT_SOUND_SET.to_owned(),
1144+ sound_volume: DEFAULT_SOUND_VOLUME,
1145+ sound_cues: SOUND_CUES
1146+ .iter()
1147+ .map(|cue| ((*cue).to_owned(), !SOUND_CUES_OFF_BY_DEFAULT.contains(cue)))
1148+ .collect(),
1149+ desktop_toasts: false,
1150+ }
1151+ }
1152+}
1153+
1154+/// A change to them: only what is sent changes; `sound_cues` merges by cue.
1155+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1156+pub struct SoundSettingsChange {
1157+ #[serde(default)]
1158+ pub sounds_enabled: Option<bool>,
1159+ #[serde(default)]
1160+ pub sound_set: Option<String>,
1161+ #[serde(default)]
1162+ pub sound_volume: Option<i64>,
1163+ #[serde(default)]
1164+ pub sound_cues: Option<std::collections::BTreeMap<String, bool>>,
1165+ #[serde(default)]
1166+ pub desktop_toasts: Option<bool>,
1167+}
1168+
1169+/// `sound_settings`: `user`'s own, or the defaults when they never changed
1170+/// them. Returns `SoundSettings`. People only.
1171+#[derive(Debug, Default, Serialize, Deserialize)]
1172+#[serde(rename_all = "camelCase")]
1173+pub struct SoundSettingsArgs {
1174+ pub user: User,
1175+}
1176+
1177+/// `set_sound_settings`: changes `user`'s own. Returns
1178+/// `Outcome<SoundSettings>`: the settings as kept, or `invalid` for a set
1179+/// or cue g1t does not have, or a volume outside 0 to 100.
1180+#[derive(Debug, Default, Serialize, Deserialize)]
1181+#[serde(rename_all = "camelCase")]
1182+pub struct SetSoundSettingsArgs {
1183+ pub user: User,
1184+ #[serde(default)]
1185+ pub change: SoundSettingsChange,
1186+}
1187+
11031188 /// `directory`: every account or every workspace, as their public pages
11041189 /// show them, a page at a time in name order. For services that index
11051190 /// them, such as search; nothing private is in it. Returns
+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+2−0
180180 call("profile_workspaces", { username, viewer, public: publicIn }),
181181 dockPins: (user, workspace) => call("dock_pins", { user, workspace }),
182182 setDockPins: (user, workspace, apps) => call("set_dock_pins", { user, workspace, apps }),
183+ soundSettings: (user) => call("sound_settings", { user }),
184+ setSoundSettings: (user, change) => call("set_sound_settings", { user, change }),
183185 listSshKeys: (user) => call("list_ssh_keys", { user }),
184186 addSshKey: (user, title, publicKey) =>
185187 call("add_ssh_key", { user, title, publicKey }),
+13−0
1+import type { SoundSettings, SoundSettingsChange } from "./sounds";
12 import type { AccessClient, BasePermission, RepoGrant } from "./access";
23 import type { Permissions, ScopeLevel, ScopeResource } from "./scopes";
34 import type { MemberPrivileges, OrgRole, PolicyHold } from "./members";
10491050 */
10501051 setDockPins(user: User, workspace: string, apps: string[]): Promise<Result<string[]>>;
10511052
1053+ /**
1054+ * How chat sounds and desktop notifications reach `user`, kept with the
1055+ * account so every device and the desktop app behave the same
1056+ * (`SoundSettings`). The defaults when they never changed them.
1057+ */
1058+ soundSettings(user: User): Promise<SoundSettings>;
1059+ /**
1060+ * Changes some of them: only the fields sent change, and `sound_cues`
1061+ * merges one cue at a time. Returns the settings as kept.
1062+ */
1063+ setSoundSettings(user: User, change: SoundSettingsChange): Promise<Result<SoundSettings>>;
1064+
10521065 listSshKeys(user: User): Promise<SshKey[]>;
10531066 /** Takes one line in OpenSSH public key format. */
10541067 addSshKey(user: User, title: string, publicKey: string): Promise<Result<SshKey>>;
+1−0
2727 export * from "./github";
2828 export * from "./guardrails";
2929 export * from "./identity";
30+export * from "./sounds";
3031 export * from "./inbox";
3132 export * from "./instances";
3233 export * from "./marketplace";
+60−0
1+/**
2+ * Sounds and desktop notifications: what a person hears when chat moves,
3+ * kept with their account by identity (`sound_settings` /
4+ * `set_sound_settings`) so a phone, a laptop and the desktop app all sound
5+ * the same. Which sound plays when is the web app's rule
6+ * (apps/web/app/lib/chat-sounds.ts); identity keeps the choices. Do not
7+ * disturb is not here: it is the person's presence (`dnd_until`,
8+ * services/notify), which also silences pop-ups and pushes.
9+ *
10+ * On its own, with no imports, so node tests can load it.
11+ */
12+
13+/**
14+ * The sounds, by what they are for. `message`: a new message in a
15+ * conversation you have open but are not looking at. `direct`: a direct
16+ * message. `mention`: you were @mentioned. `agent_done`: an agent finished
17+ * something for you. `sent`: a soft tick as you send (off unless turned
18+ * on). `call` is reserved for calls and has no setting yet.
19+ */
20+export type SoundCue = "message" | "direct" | "mention" | "agent_done" | "sent" | "call";
21+
22+/** The cues a person can turn on and off, in the order the settings list them. */
23+export const SOUND_CUES: readonly Exclude<SoundCue, "call">[] = ["message", "direct", "mention", "agent_done", "sent"];
24+
25+/** A set of sounds: the same roles, a different character. */
26+export type SoundSet = "soft" | "bright";
27+
28+export const SOUND_SETS: readonly SoundSet[] = ["soft", "bright"];
29+
30+export type SoundSettings = {
31+ /** Whether anything plays at all. */
32+ sounds_enabled: boolean;
33+ sound_set: SoundSet;
34+ /** 0 to 100. */
35+ sound_volume: number;
36+ /** Each cue on or off; a cue left out is on, except `sent`. */
37+ sound_cues: Partial<Record<SoundCue, boolean>>;
38+ /**
39+ * Whether an open tab shows a system notification (the browser's
40+ * Notifications API) for a message to you while the window is not in
41+ * front. Off unless turned on; the browser's permission is asked only
42+ * from the settings page.
43+ */
44+ desktop_toasts: boolean;
45+};
46+
47+/** A change to them: only what is sent changes; `sound_cues` merges by cue. */
48+export type SoundSettingsChange = Partial<Omit<SoundSettings, "sound_cues">> & { sound_cues?: Partial<Record<SoundCue, boolean>> };
49+
50+/** Which cues play before anyone changed them: all but the tick as you send. */
51+export const DEFAULT_SOUND_CUES: Record<Exclude<SoundCue, "call">, boolean> = { message: true, direct: true, mention: true, agent_done: true, sent: false };
52+
53+export const DEFAULT_SOUND_SETTINGS: SoundSettings = { sounds_enabled: true, sound_set: "soft", sound_volume: 60, sound_cues: { ...DEFAULT_SOUND_CUES }, desktop_toasts: false };
54+
55+/** Whether a cue plays under these settings: its own switch, else its default. */
56+export function cueOn(settings: Pick<SoundSettings, "sound_cues">, cue: SoundCue): boolean {
57+ const chosen = settings.sound_cues[cue];
58+ if (typeof chosen === "boolean") return chosen;
59+ return cue === "call" ? true : DEFAULT_SOUND_CUES[cue];
60+}
+16−0
1+-- What a person hears when chat moves, kept with their account so a phone,
2+-- a laptop and the desktop app all sound the same: whether sounds play, which
3+-- set, how loud, each cue on or off (`sound_cues` is a JSON object such as
4+-- {"message":true,"sent":false}), and whether an open tab shows a system
5+-- notification while its window is not in front. A person with no row has
6+-- the defaults. Do not disturb is not here: it is the person's presence
7+-- (services/notify). See src/sounds.rs.
8+CREATE TABLE sound_settings (
9+ user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
10+ sounds_enabled INTEGER NOT NULL DEFAULT 1,
11+ sound_set TEXT NOT NULL DEFAULT 'soft',
12+ sound_volume INTEGER NOT NULL DEFAULT 60,
13+ sound_cues TEXT NOT NULL DEFAULT '{}',
14+ desktop_toasts INTEGER NOT NULL DEFAULT 0,
15+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
16+);
+2−1
332332 "workspace_members",
333333 "team_members",
334334 "dock_pins",
335+ "sound_settings",
335336 ] {
336337 sql.push((format!("DELETE FROM {table} WHERE user_id = ?1 AND {STILL}"), 1));
337338 }
11791180 let sql: Vec<String> = purge_statements().into_iter().map(|(sql, _)| sql).collect();
11801181 assert!(sql[0].starts_with("INSERT OR REPLACE INTO deleted_users"));
11811182 assert!(sql.iter().any(|s| s.starts_with("UPDATE workspaces SET created_by = 'usr_ghost' WHERE created_by = ?1")));
1182− for table in ["user_emails", "github_accounts", "two_factor", "two_factor_recovery", "security_events", "ssh_keys", "dock_pins"] {
1183+ for table in ["user_emails", "github_accounts", "two_factor", "two_factor_recovery", "security_events", "ssh_keys", "dock_pins", "sound_settings"] {
11831184 assert!(sql.iter().any(|s| s.starts_with(&format!("DELETE FROM {table} WHERE user_id = ?1"))), "{table}");
11841185 }
11851186 // The row goes last, and everything before it only while the
+4−0
2828 mod run_credentials;
2929 mod security;
3030 mod shared_invites;
31+mod sounds;
3132 mod teams;
3233 mod throttle;
3334 mod token_reach;
995996 // Each person's dock pins, per workspace; see dock.rs.
996997 "dock_pins" => reply(&identity.dock_pins(args(body)?).await?),
997998 "set_dock_pins" => reply(&identity.set_dock_pins(args(body)?).await?),
999+ // Each person's chat sounds and desktop notifications; see sounds.rs.
1000+ "sound_settings" => reply(&identity.sound_settings(args(body)?).await?),
1001+ "set_sound_settings" => reply(&identity.set_sound_settings(args(body)?).await?),
9981002 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
9991003 // Services only: who registered each key, for verifying commit
10001004 // signatures (repos' signatures.rs).
+203−0
1+//! Sound settings: what a person hears when chat moves, kept with their
2+//! account so a phone, a laptop and the desktop app all sound the same.
3+//!
4+//! One row per person (migration 0046); a person with no row has the
5+//! defaults. Only the person reads or changes their own. Which sound plays
6+//! when is the web app's rule (apps/web/app/lib/chat-sounds.ts); this only
7+//! keeps the choices, checking each against the sets and cues g1t has.
8+//! Do not disturb is the person's presence (services/notify), not a row here.
9+
10+use std::collections::BTreeMap;
11+
12+use g1t_contracts::identity::*;
13+use g1t_contracts::time::SQL_NOW;
14+use g1t_contracts::{FailureCode, Outcome};
15+use serde::Deserialize;
16+use worker::Result;
17+
18+use crate::Identity;
19+use crate::security::is_person;
20+
21+/// `current` with `change` over it, each field checked. Refused whole when
22+/// a set or a cue is not one g1t has, or the volume is out of range.
23+pub fn apply_change(current: &SoundSettings, change: &SoundSettingsChange) -> std::result::Result<SoundSettings, String> {
24+ let mut next = current.clone();
25+ if let Some(on) = change.sounds_enabled {
26+ next.sounds_enabled = on;
27+ }
28+ if let Some(set) = &change.sound_set {
29+ let set = set.trim();
30+ if !SOUND_SETS.contains(&set) {
31+ return Err("That is not a sound set g1t has.".to_owned());
32+ }
33+ next.sound_set = set.to_owned();
34+ }
35+ if let Some(volume) = change.sound_volume {
36+ if !(0..=100).contains(&volume) {
37+ return Err("Volume is from 0 to 100.".to_owned());
38+ }
39+ next.sound_volume = volume as u8;
40+ }
41+ if let Some(cues) = &change.sound_cues {
42+ for (cue, on) in cues {
43+ if !SOUND_CUES.contains(&cue.as_str()) {
44+ return Err("That is not a sound g1t plays.".to_owned());
45+ }
46+ next.sound_cues.insert(cue.clone(), *on);
47+ }
48+ }
49+ if let Some(on) = change.desktop_toasts {
50+ next.desktop_toasts = on;
51+ }
52+ Ok(next)
53+}
54+
55+#[derive(Deserialize)]
56+struct Row {
57+ sounds_enabled: i64,
58+ sound_set: String,
59+ sound_volume: i64,
60+ sound_cues: String,
61+ desktop_toasts: i64,
62+}
63+
64+/// A row as settings: a field that cannot be read falls back to its
65+/// default rather than failing the page, and a cue that no longer exists
66+/// is dropped.
67+fn settings_from(row: Row) -> SoundSettings {
68+ let defaults = SoundSettings::default();
69+ let mut cues = defaults.sound_cues.clone();
70+ let saved: BTreeMap<String, bool> = serde_json::from_str(&row.sound_cues).unwrap_or_default();
71+ for (cue, on) in saved {
72+ if SOUND_CUES.contains(&cue.as_str()) {
73+ cues.insert(cue, on);
74+ }
75+ }
76+ SoundSettings {
77+ sounds_enabled: row.sounds_enabled != 0,
78+ sound_set: if SOUND_SETS.contains(&row.sound_set.as_str()) { row.sound_set } else { defaults.sound_set },
79+ sound_volume: u8::try_from(row.sound_volume.clamp(0, 100)).unwrap_or(DEFAULT_SOUND_VOLUME),
80+ sound_cues: cues,
81+ desktop_toasts: row.desktop_toasts != 0,
82+ }
83+}
84+
85+const COLUMNS: &str = "sounds_enabled, sound_set, sound_volume, sound_cues, desktop_toasts";
86+
87+impl Identity {
88+ async fn read_sound_settings(&self, user_id: &str) -> Result<SoundSettings> {
89+ let row = self
90+ .db
91+ .prepare(format!("SELECT {COLUMNS} FROM sound_settings WHERE user_id = ?"))
92+ .bind(&[user_id.into()])?
93+ .first::<Row>(None)
94+ .await?;
95+ Ok(row.map(settings_from).unwrap_or_default())
96+ }
97+
98+ pub async fn sound_settings(&self, a: SoundSettingsArgs) -> Result<SoundSettings> {
99+ if !is_person(&a.user) {
100+ return Ok(SoundSettings::default());
101+ }
102+ self.read_sound_settings(&a.user.id).await
103+ }
104+
105+ pub async fn set_sound_settings(&self, a: SetSoundSettingsArgs) -> Result<Outcome<SoundSettings>> {
106+ if !is_person(&a.user) {
107+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person has sound settings."));
108+ }
109+ let current = self.read_sound_settings(&a.user.id).await?;
110+ let next = match apply_change(&current, &a.change) {
111+ Ok(next) => next,
112+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
113+ };
114+ // Only the cues that differ from their defaults are kept, so a new
115+ // default later applies to everyone who never chose.
116+ let defaults = SoundSettings::default();
117+ let chosen: BTreeMap<&String, &bool> = next.sound_cues.iter().filter(|(cue, on)| defaults.sound_cues.get(*cue) != Some(on)).collect();
118+ let cues = serde_json::to_string(&chosen)?;
119+ let row = self
120+ .db
121+ .prepare(format!(
122+ "INSERT INTO sound_settings (user_id, sounds_enabled, sound_set, sound_volume, sound_cues, desktop_toasts, updated_at)
123+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, {SQL_NOW})
124+ ON CONFLICT (user_id) DO UPDATE SET sounds_enabled = excluded.sounds_enabled, sound_set = excluded.sound_set,
125+ sound_volume = excluded.sound_volume, sound_cues = excluded.sound_cues, desktop_toasts = excluded.desktop_toasts,
126+ updated_at = excluded.updated_at
127+ RETURNING {COLUMNS}"
128+ ))
129+ .bind(&[
130+ a.user.id.as_str().into(),
131+ i32::from(next.sounds_enabled).into(),
132+ next.sound_set.as_str().into(),
133+ i32::from(next.sound_volume).into(),
134+ cues.as_str().into(),
135+ i32::from(next.desktop_toasts).into(),
136+ ])?
137+ .first::<Row>(None)
138+ .await?;
139+ Ok(match row {
140+ Some(row) => Outcome::Ok(settings_from(row)),
141+ None => Outcome::Ok(next),
142+ })
143+ }
144+}
145+
146+#[cfg(test)]
147+mod tests {
148+ use super::*;
149+
150+ fn cues(list: &[(&str, bool)]) -> BTreeMap<String, bool> {
151+ list.iter().map(|(cue, on)| ((*cue).to_owned(), *on)).collect()
152+ }
153+
154+ #[test]
155+ fn the_defaults_play_everything_but_the_tick_as_you_send() {
156+ let d = SoundSettings::default();
157+ assert!(d.sounds_enabled);
158+ assert_eq!(d.sound_set, "soft");
159+ assert_eq!(d.sound_volume, 60);
160+ assert_eq!(d.sound_cues.get("message"), Some(&true));
161+ assert_eq!(d.sound_cues.get("mention"), Some(&true));
162+ assert_eq!(d.sound_cues.get("sent"), Some(&false));
163+ assert!(!d.desktop_toasts);
164+ }
165+
166+ #[test]
167+ fn a_change_touches_only_what_it_names() {
168+ let d = SoundSettings::default();
169+ let next = apply_change(&d, &SoundSettingsChange { sound_volume: Some(30), sound_cues: Some(cues(&[("sent", true)])), ..Default::default() }).unwrap();
170+ assert_eq!(next.sound_volume, 30);
171+ assert_eq!(next.sound_set, "soft");
172+ assert_eq!(next.sound_cues.get("sent"), Some(&true));
173+ assert_eq!(next.sound_cues.get("message"), Some(&true));
174+ let bright = apply_change(&next, &SoundSettingsChange { sound_set: Some("bright".to_owned()), ..Default::default() }).unwrap();
175+ assert_eq!(bright.sound_set, "bright");
176+ assert_eq!(bright.sound_volume, 30);
177+ }
178+
179+ #[test]
180+ fn an_unknown_set_cue_or_volume_is_refused() {
181+ let d = SoundSettings::default();
182+ assert!(apply_change(&d, &SoundSettingsChange { sound_set: Some("loud".to_owned()), ..Default::default() }).is_err());
183+ assert!(apply_change(&d, &SoundSettingsChange { sound_volume: Some(101), ..Default::default() }).is_err());
184+ assert!(apply_change(&d, &SoundSettingsChange { sound_volume: Some(-1), ..Default::default() }).is_err());
185+ assert!(apply_change(&d, &SoundSettingsChange { sound_cues: Some(cues(&[("call", false)])), ..Default::default() }).is_err());
186+ assert!(apply_change(&d, &SoundSettingsChange { sound_cues: Some(cues(&[("klaxon", true)])), ..Default::default() }).is_err());
187+ }
188+
189+ #[test]
190+ fn a_row_reads_with_defaults_for_what_it_cannot_say() {
191+ let row = Row { sounds_enabled: 0, sound_set: "gone".to_owned(), sound_volume: 400, sound_cues: "not json".to_owned(), desktop_toasts: 1 };
192+ let s = settings_from(row);
193+ assert!(!s.sounds_enabled);
194+ assert_eq!(s.sound_set, "soft");
195+ assert_eq!(s.sound_volume, 100);
196+ assert_eq!(s.sound_cues, SoundSettings::default().sound_cues);
197+ assert!(s.desktop_toasts);
198+ let row = Row { sounds_enabled: 1, sound_set: "bright".to_owned(), sound_volume: 20, sound_cues: r#"{"sent":true,"old_cue":true}"#.to_owned(), desktop_toasts: 0 };
199+ let s = settings_from(row);
200+ assert_eq!(s.sound_cues.get("sent"), Some(&true));
201+ assert_eq!(s.sound_cues.get("old_cue"), None);
202+ }
203+}