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.
33 files+1985−1360/33 viewed
| 38 | 38 | | --- | --- | --- | | |
| 39 | 39 | | 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. | | |
| 40 | 40 | | 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). | | |
| 41 | 42 | | 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. | | |
| 42 | 43 | | 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. | | |
| 43 | 44 | | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens): their permissions, where they reach, and when they expire. | |
| 603 | 603 | | | | | |
| 604 | 604 | | --- | --- | | |
| 605 | 605 | | **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). | | |
| 607 | 607 | | **Counts** | The rail's Chat badge and each conversation's unread and mention counts update live, in every open tab. | | |
| 608 | 608 | | **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. | | |
| 609 | 609 | ||
| ⋯ | |||
| 621 | 621 | such as **Everything** for a small team and **DMs and mentions** for a big | |
| 622 | 622 | one. **Send a test notification** shows you what one looks like. | |
| 623 | 623 | ||
| 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: | |
| 625 | 629 | ||
| 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. | |
| 631 | 670 | ||
| 632 | 671 | At most three pop-ups show at once. Each stays six seconds (one with a | |
| 633 | 672 | card's buttons, twelve), and stays while your pointer or keyboard is on | |
| 187 | 187 | ||
| 188 | 188 | | Setting | What it does | Default | | |
| 189 | 189 | | --- | --- | --- | | |
| 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 | | |
| 190 | 194 | | **Email** | One checkbox per reason: you are also emailed when you are told of something for it. | agent waiting, review requested, mentioned | | |
| 191 | 195 | | **Repositories you create** | How you watch a new repository you create: **Participating and @mentions** or **All activity**. | All activity | | |
| 192 | 196 | | **Watched repositories** | Every repository you watch other than the default way, with how. | | |
| 72 | 72 | import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar"; | |
| 73 | 73 | import { useAddresses } from "../../lib/addresses"; | |
| 74 | 74 | import { applyReactionEvent, keepMine, toggleReaction } from "../../lib/emoji"; | |
| 75 | + | import { dndNow } from "../../lib/notify-client"; | |
| 76 | + | import { chatMessageArrived, messageSent, viewingConversation } from "../../lib/sound-events"; | |
| 75 | 77 | import type { ChannelData } from "../../routes/workspace/chat/channel"; | |
| 76 | 78 | ||
| 77 | 79 | type Loaded = Extract<ChannelData, { unavailable: false }>; | |
| ⋯ | |||
| 179 | 181 | const entry = sidebar?.entries.find((e) => e.channel.id === data.channel.id); | |
| 180 | 182 | const starred = entry?.starred ?? members.find((m) => m.member.kind === "user" && m.member.id === me?.id)?.starred ?? false; | |
| 181 | 183 | 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]); | |
| 182 | 190 | const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member); | |
| 183 | 191 | const isDm = data.channel.kind === "dm"; | |
| 184 | 192 | ||
| ⋯ | |||
| 295 | 303 | if (event.type === "message.created" || event.type === "message.updated") { | |
| 296 | 304 | const message = event.message; | |
| 297 | 305 | 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 }); | |
| 298 | 308 | if (message.thread_root) { | |
| 299 | 309 | if (message.thread_root === threadId) setThread((now) => (now ? mergeMessages(now, [keepMine(now, message)]) : now)); | |
| 300 | 310 | if (event.type === "message.created") countReply(message.thread_root, message.id, message.created_at); | |
| ⋯ | |||
| 333 | 343 | refresh(); | |
| 334 | 344 | } | |
| 335 | 345 | }, | |
| 336 | − | [data.channel.id, threadId, me?.id, countReply, refresh], | |
| 346 | + | [data.channel.id, data.channel.kind, muted, threadId, me?.id, countReply, refresh], | |
| 337 | 347 | ); | |
| 338 | 348 | // Back after a drop: what was missed. | |
| 339 | 349 | const onReconnect = useCallback(async () => { | |
| ⋯ | |||
| 415 | 425 | stick.current = true; | |
| 416 | 426 | setMessages(put); | |
| 417 | 427 | } | |
| 428 | + | messageSent({ dnd: dndNow() }); | |
| 418 | 429 | const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root }); | |
| 419 | 430 | const settle = (list: ShownMessage[]) => | |
| 420 | 431 | saved.ok | |
| 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 | + | } |
| 7 | 7 | ||
| 8 | 8 | import { conversationCache } from "./conversation-cache"; | |
| 9 | 9 | import { CreateChannelButton, NewMessageButton, useChatData, useChatSend, useChatSidebar } from "./actions"; | |
| 10 | + | import { DndMenu } from "./dnd-menu"; | |
| 10 | 11 | import { MemberAvatar, StatusDot, statusLabel } from "./marks"; | |
| 11 | 12 | import { AgentAvatar } from "../agent-avatar"; | |
| 12 | 13 | import { PersonStatusEmoji } from "../presence"; | |
| ⋯ | |||
| 17 | 18 | import { type ChatFilter, agentDmOf, channelPath, filterEntries, sections } from "../../lib/chat"; | |
| 18 | 19 | import { feedConnected } from "../../lib/notify-client"; | |
| 19 | 20 | import { FALLBACK_REFRESH_MS } from "../../lib/notify-store"; | |
| 21 | + | import { setMutedConversations } from "../../lib/sound-events"; | |
| 20 | 22 | ||
| 21 | 23 | /** How often the sidebar asks again while the tab is shown and the feed socket is down. */ | |
| 22 | 24 | const REFRESH_MS = FALLBACK_REFRESH_MS; | |
| ⋯ | |||
| 76 | 78 | // Pins show at once and go back if the service refuses. | |
| 77 | 79 | const [pins, setPins] = useState<Map<string, boolean>>(new Map()); | |
| 78 | 80 | 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]); | |
| 79 | 85 | const entries = (sidebar?.entries ?? []).map((e) => (pins.has(e.channel.id) ? { ...e, starred: pins.get(e.channel.id)! } : e)); | |
| 80 | 86 | const shown = filterEntries(entries, filter, query); | |
| 81 | 87 | const { pinned, channels, dms } = sections(shown); | |
| ⋯ | |||
| 144 | 150 | {heading && ( | |
| 145 | 151 | <div className="flex h-9 shrink-0 items-center justify-between pr-1 pl-3"> | |
| 146 | 152 | <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> | |
| 148 | 157 | </div> | |
| 149 | 158 | )} | |
| 150 | 159 | <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> | |
| 172 | 185 | <div role="radiogroup" aria-label="Show" className="grid grid-cols-3 rounded-md bg-surface p-0.5 ring-1 ring-line"> | |
| 173 | 186 | {(["all", "unread", "mentions"] as const).map((key) => ( | |
| 174 | 187 | <button | |
| 17 | 17 | import { cn } from "../lib/cn"; | |
| 18 | 18 | import { isPending } from "../lib/pending"; | |
| 19 | 19 | 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"; | |
| 20 | 22 | import type { InboxPanelData } from "../routes/inbox-json"; | |
| 21 | 23 | ||
| 22 | 24 | /** Where every notifications form posts (routes/notifications.tsx). */ | |
| ⋯ | |||
| 275 | 277 | const unread = counts?.unread ?? 0; | |
| 276 | 278 | const items = list.data?.tab === tab ? list.data.items : null; | |
| 277 | 279 | 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()); | |
| 278 | 282 | ||
| 279 | 283 | return ( | |
| 280 | 284 | <Sheet open={open} onOpenChange={setOpen}> | |
| 281 | 285 | <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 | + | > | |
| 283 | 292 | <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" />} | |
| 284 | 294 | {shown && ( | |
| 285 | 295 | // Amber while an agent waits on them, red for a failure, else the accent. | |
| 286 | 296 | <span | |
| 1 | 1 | import { useEffect } from "react"; | |
| 2 | 2 | import { useLocation, useNavigate } from "react-router"; | |
| 3 | 3 | ||
| 4 | + | import type { SoundSettings } from "@g1t/contracts/sounds"; | |
| 5 | + | ||
| 4 | 6 | 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"; | |
| 5 | 9 | import { Toaster } from "./toaster"; | |
| 6 | 10 | ||
| 7 | 11 | /** | |
| ⋯ | |||
| 11 | 15 | * | |
| 12 | 16 | * `workspace` is the one the page is in, whose counts the feed reads on | |
| 13 | 17 | * 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). | |
| 15 | 22 | */ | |
| 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 | + | }) { | |
| 17 | 34 | const navigate = useNavigate(); | |
| 18 | 35 | const { pathname, search } = useLocation(); | |
| 19 | 36 | const badges = useLiveBadges(workspace); | |
| 20 | 37 | ||
| 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 | + | ||
| 21 | 43 | useEffect(() => { | |
| 22 | 44 | setNavigator((href) => navigate(href)); | |
| 23 | 45 | return () => setNavigator(null); | |
| 8 | 8 | import { RadioGroup, RadioOption } from "../ui/radio-group"; | |
| 9 | 9 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; | |
| 10 | 10 | 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"; | |
| 25 | 12 | ||
| 26 | 13 | const LEVELS: { level: NotifyLevel; label: string; detail: string }[] = [ | |
| 27 | 14 | { level: "all", label: "Everything", detail: "Every message in conversations you're in, and everything in your notifications." }, | |
| ⋯ | |||
| 54 | 41 | ||
| 55 | 42 | /** | |
| 56 | 43 | * 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). | |
| 59 | 46 | */ | |
| 60 | 47 | export function LiveNotificationSettings({ workspaces, initial = null }: { workspaces: Membership[]; initial?: NotifyStatus | null }) { | |
| 61 | 48 | const [status, setStatus] = useState<NotifyStatus | null>(initial); | |
| 62 | 49 | const [prefs, setPrefs] = useState<NotifyPreferences | null>(initial?.preferences ?? null); | |
| 63 | 50 | const [device, setDevice] = useState<Device>("off"); | |
| 64 | − | const [sound, setSoundState] = useState(false); | |
| 65 | 51 | const [busy, setBusy] = useState<string | null>(null); | |
| 66 | 52 | const [said, setSaid] = useState<string | null>(null); | |
| 67 | 53 | const [failed, setFailed] = useState(false); | |
| 68 | 54 | ||
| 69 | 55 | useEffect(() => { | |
| 70 | − | setSoundState(soundOn()); | |
| 71 | 56 | if (initial) { | |
| 72 | 57 | setDevice(deviceState(initial)); | |
| 73 | 58 | return; | |
| ⋯ | |||
| 191 | 176 | </div> | |
| 192 | 177 | )} | |
| 193 | 178 | ||
| 194 | − | <Card divided> | |
| 179 | + | <Card> | |
| 195 | 180 | <div className="flex items-start gap-4 p-4"> | |
| 196 | 181 | <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent"> | |
| 197 | 182 | <BellRing size={16} /> | |
| ⋯ | |||
| 207 | 192 | disabled={deviceLocked || busy === "device" || !status} | |
| 208 | 193 | onCheckedChange={(on) => void toggleDevice(on)} | |
| 209 | 194 | 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" | |
| 225 | 195 | /> | |
| 226 | 196 | </div> | |
| 227 | 197 | </Card> | |
| 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 & 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 | + | } |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 3 | 3 | * feed (`/-/live`, services/notify), a small store the page reads (counts | |
| 4 | 4 | * for the rail, the Chat sidebar and the tab title; toasts; whether the | |
| 5 | 5 | * 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). | |
| 8 | 10 | * | |
| 9 | 11 | * Nothing here polls. The feed sends counts on connect and after every | |
| 10 | 12 | * change; while the socket is down, `connected` is false and the pages | |
| ⋯ | |||
| 20 | 22 | import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts"; | |
| 21 | 23 | ||
| 22 | 24 | import { openLive } from "./live-socket"; | |
| 23 | − | import { isIdle, mergePeople } from "./presence"; | |
| 25 | + | import { dndOn, isIdle, mergePeople } from "./presence"; | |
| 26 | + | import { notificationArrived } from "./sound-events"; | |
| 24 | 27 | ||
| 25 | 28 | import { | |
| 26 | 29 | HEARTBEAT_MS, | |
| ⋯ | |||
| 108 | 111 | else if (typeof location !== "undefined") location.assign(href); | |
| 109 | 112 | } | |
| 110 | 113 | ||
| 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. */ | |
| 112 | 115 | export const webSink: NotificationSink = { | |
| 113 | 116 | kind: "web", | |
| 114 | 117 | deliver(notification, { toast }) { | |
| 115 | 118 | if (!toast) return; | |
| 116 | 119 | showToast(notification); | |
| 117 | − | if (soundOn()) chime(); | |
| 118 | 120 | }, | |
| 119 | 121 | setBadge(count) { | |
| 120 | 122 | const nav = typeof navigator !== "undefined" ? (navigator as Navigator & { setAppBadge?(n: number): Promise<void>; clearAppBadge?(): Promise<void> }) : null; | |
| ⋯ | |||
| 136 | 138 | if (!toast) return; | |
| 137 | 139 | if (focused) { | |
| 138 | 140 | showToast(notification); | |
| 139 | − | if (soundOn()) chime(); | |
| 140 | 141 | return; | |
| 141 | 142 | } | |
| 142 | 143 | bridge.notify({ | |
| ⋯ | |||
| 267 | 268 | return useNotifyState().me; | |
| 268 | 269 | } | |
| 269 | 270 | ||
| 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 | + | ||
| 270 | 281 | /** Whether the feed is up: pages fall back to a slow refresh only while it is not. */ | |
| 271 | 282 | export function feedConnected(): boolean { | |
| 272 | 283 | return state.connected; | |
| ⋯ | |||
| 311 | 322 | /** For the settings page's test, and the harness: a notification shown as if it arrived. */ | |
| 312 | 323 | export function showLocally(notification: FeedNotification): void { | |
| 313 | 324 | currentSink().deliver(notification, { toast: true, focused: true }); | |
| 325 | + | heard(notification, true); | |
| 314 | 326 | } | |
| 315 | 327 | ||
| 316 | 328 | // ── The socket ─────────────────────────────────────────────────────────── | |
| ⋯ | |||
| 380 | 392 | case "notification": | |
| 381 | 393 | set({ recent: addRecent(state.recent, [event.notification]) }); | |
| 382 | 394 | sink.deliver(event.notification, { toast: event.toast, focused: focused() }); | |
| 395 | + | heard(event.notification, event.toast); | |
| 383 | 396 | break; | |
| 384 | 397 | case "preferences": | |
| 385 | 398 | set({ preferences: event.preferences }); | |
| ⋯ | |||
| 539 | 552 | } | |
| 540 | 553 | sink.setBadge(count); | |
| 541 | 554 | 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 | − | } | |
| 588 | 555 | } | |
| 589 | 556 | ||
| 590 | 557 | // ── Browser push ───────────────────────────────────────────────────────── | |
| 45 | 45 | test("pausing notifications: half an hour, an hour, or until nine tomorrow morning", () => { | |
| 46 | 46 | assert.equal(pauseUntil("30m", NOW), new Date(NOW.getTime() + 30 * MIN).toISOString()); | |
| 47 | 47 | 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()); | |
| 48 | 49 | assert.equal(pauseUntil("tomorrow", NOW), new Date(2026, 9, 8, 9, 0).toISOString()); | |
| 49 | 50 | // Just after midnight, tomorrow is still the next day's morning. | |
| 50 | 51 | assert.equal(pauseUntil("tomorrow", new Date(2026, 9, 8, 0, 30)), new Date(2026, 9, 9, 9, 0).toISOString()); |
| 36 | 36 | { emoji: "🌴", text: "On vacation", clear: "never" }, | |
| 37 | 37 | ]; | |
| 38 | 38 | ||
| 39 | − | export type PauseFor = "30m" | "1h" | "tomorrow"; | |
| 39 | + | export type PauseFor = "30m" | "1h" | "2h" | "tomorrow"; | |
| 40 | 40 | ||
| 41 | 41 | export const PAUSE_FOR: { key: PauseFor; label: string }[] = [ | |
| 42 | 42 | { key: "30m", label: "For 30 minutes" }, | |
| 43 | 43 | { key: "1h", label: "For 1 hour" }, | |
| 44 | + | { key: "2h", label: "For 2 hours" }, | |
| 44 | 45 | { key: "tomorrow", label: "Until tomorrow" }, | |
| 45 | 46 | ]; | |
| 46 | 47 | ||
| ⋯ | |||
| 82 | 83 | } | |
| 83 | 84 | } | |
| 84 | 85 | ||
| 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. */ | |
| 86 | 87 | export function pauseUntil(choice: PauseFor, now: Date): string { | |
| 87 | 88 | if (choice === "30m") return new Date(now.getTime() + 30 * MINUTE).toISOString(); | |
| 88 | 89 | if (choice === "1h") return new Date(now.getTime() + 60 * MINUTE).toISOString(); | |
| 90 | + | if (choice === "2h") return new Date(now.getTime() + 120 * MINUTE).toISOString(); | |
| 89 | 91 | const at = nextMidnight(now); | |
| 90 | 92 | at.setHours(9, 0, 0, 0); | |
| 91 | 93 | return at.toISOString(); | |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 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 | + | } |
| 82 | 82 | const dock = readCookie(cookies, DOCK_COOKIE); | |
| 83 | 83 | // Whether sign-up takes an invite: the sign-up page says so, and | |
| 84 | 84 | // 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([ | |
| 86 | 86 | // An account still confirming its address sees only the pages that | |
| 87 | 87 | // allows (lib/confirm-gate.ts), in the visitor's frame. | |
| 88 | 88 | user && !awaitsConfirmation(user) | |
| ⋯ | |||
| 94 | 94 | }) | |
| 95 | 95 | : visitorShell(params, context), | |
| 96 | 96 | 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), | |
| 97 | 100 | ]); | |
| 98 | 101 | // The apps this person pinned to their rail here: as their account keeps | |
| 99 | 102 | // them (read with the rest of the shell), else as this device's cookie | |
| ⋯ | |||
| 110 | 113 | sidebarClosed: sidebarClosed(readCookie(cookies, SIDEBAR_COOKIE)), | |
| 111 | 114 | // Their Appearance, so the page is drawn in it from the first byte (lib/theme.ts). | |
| 112 | 115 | theme: readTheme(readCookie(cookies, THEME_COOKIE)), | |
| 116 | + | // What they hear when chat moves (components/notifications/sounds.tsx). | |
| 117 | + | sounds, | |
| 113 | 118 | inviteOnly: mode !== "open", | |
| 114 | 119 | addresses: addresses(), | |
| 115 | 120 | // Visitors from where the law asks first are asked before analytics runs. | |
| ⋯ | |||
| 447 | 452 | )} | |
| 448 | 453 | {/* Live notifications for the signed-in: the feed socket, toasts, the tab's count (components/notifications). */} | |
| 449 | 454 | {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 | + | /> | |
| 451 | 461 | )} | |
| 452 | 462 | <AnalyticsConsent /> | |
| 453 | 463 | <ScrollRestoration nonce={nonce} /> | |
| 2 | 2 | import { data } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { notifyClient, type NotifyPreferencesChange, type PresenceChange, type PushSubscriptionJson } from "@g1t/contracts"; | |
| 5 | + | import type { SoundSettingsChange } from "@g1t/contracts/sounds"; | |
| 5 | 6 | ||
| 6 | 7 | import type { Route } from "./+types/api"; | |
| 8 | + | import { identity } from "../../lib/services.server"; | |
| 7 | 9 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 8 | 10 | ||
| 9 | 11 | function client() { | |
| ⋯ | |||
| 14 | 16 | /** | |
| 15 | 17 | * The signed-in person's notification settings, as JSON: `GET` their | |
| 16 | 18 | * 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). | |
| 19 | 22 | */ | |
| 20 | 23 | export async function loader({ context, request }: Route.LoaderArgs) { | |
| 21 | 24 | const user = requireUser(context, request); | |
| ⋯ | |||
| 29 | 32 | subscription?: PushSubscriptionJson & { expirationTime?: number | null }; | |
| 30 | 33 | endpoint?: string; | |
| 31 | 34 | preferences?: NotifyPreferencesChange; | |
| 32 | − | change?: PresenceChange; | |
| 35 | + | change?: PresenceChange | SoundSettingsChange; | |
| 33 | 36 | }; | |
| 34 | 37 | ||
| 35 | 38 | export async function action({ context, request }: Route.ActionArgs) { | |
| ⋯ | |||
| 50 | 53 | return Response.json(await notify.setPreferences(user, sent.preferences ?? {})); | |
| 51 | 54 | case "test": | |
| 52 | 55 | 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 | + | } | |
| 53 | 61 | case "presence": { | |
| 54 | 62 | // 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; | |
| 56 | 64 | const status = change.status ? { ...change.status, source: "manual" as const } : change.status; | |
| 57 | 65 | return Response.json(await notify.setPresence(user, { ...change, ...("status" in change ? { status } : {}) })); | |
| 58 | 66 | } | |
| 1 | 1 | /** | |
| 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 | |
| 3 | 5 | * 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`, | |
| 5 | 7 | * `inbox_watched`); the notifications themselves are at /notifications. | |
| 6 | 8 | */ | |
| 7 | 9 | import { Eye, EyeOff } from "lucide-react"; | |
| ⋯ | |||
| 16 | 18 | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 17 | 19 | import { EMAIL_REASONS, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm } from "../../lib/inbox"; | |
| 18 | 20 | import { page } from "../../lib/meta"; | |
| 19 | − | import { inbox } from "../../lib/services.server"; | |
| 21 | + | import { identity, inbox } from "../../lib/services.server"; | |
| 20 | 22 | 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). | |
| 22 | 24 | import { LiveNotificationSettings } from "../../components/notifications/settings"; | |
| 25 | + | // Sounds, Do not disturb and desktop notifications (identity, notify). | |
| 26 | + | import { SoundSettingsSection } from "../../components/notifications/sounds"; | |
| 23 | 27 | ||
| 24 | 28 | export function meta(args: Route.MetaArgs) { | |
| 25 | 29 | return page(args, { title: "Notifications · Settings · g1t" }); | |
| ⋯ | |||
| 27 | 31 | ||
| 28 | 32 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 29 | 33 | const user = requireUser(context, request); | |
| 30 | − | const [settings, watched] = await Promise.all([ | |
| 34 | + | const [settings, watched, sounds] = await Promise.all([ | |
| 31 | 35 | inbox.settings(user.username).catch(() => null), | |
| 32 | 36 | inbox.watched(user.username).catch(() => null), | |
| 37 | + | identity.soundSettings(user).catch(() => null), | |
| 33 | 38 | ]); | |
| 34 | − | return { settings, watched, workspaces: user.workspaces ?? [] }; | |
| 39 | + | return { settings, watched, sounds, workspaces: user.workspaces ?? [] }; | |
| 35 | 40 | } | |
| 36 | 41 | ||
| 37 | 42 | export async function action({ request, context }: Route.ActionArgs) { | |
| ⋯ | |||
| 72 | 77 | >; | |
| 73 | 78 | ||
| 74 | 79 | export default function NotificationSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 75 | − | const { settings, watched, workspaces } = loaderData; | |
| 80 | + | const { settings, watched, sounds, workspaces } = loaderData; | |
| 76 | 81 | const navigation = useNavigation(); | |
| 77 | 82 | const said = (intent: string) => (actionData?.intent === intent && navigation.state === "idle" ? actionData : null); | |
| 78 | 83 | if (!settings) { | |
| ⋯ | |||
| 80 | 85 | } | |
| 81 | 86 | return ( | |
| 82 | 87 | <div className="space-y-10"> | |
| 88 | + | <SoundSettingsSection initial={sounds} /> | |
| 83 | 89 | <LiveNotificationSettings workspaces={workspaces} /> | |
| 84 | 90 | <section aria-labelledby="email-heading"> | |
| 85 | 91 | <h2 id="email-heading" className="text-sm font-semibold"> | |
| 1100 | 1100 | pub apps: Vec<String>, | |
| 1101 | 1101 | } | |
| 1102 | 1102 | ||
| 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 | + | ||
| 1103 | 1188 | /// `directory`: every account or every workspace, as their public pages | |
| 1104 | 1189 | /// show them, a page at a time in name order. For services that index | |
| 1105 | 1190 | /// them, such as search; nothing private is in it. Returns |
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "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" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 180 | 180 | call("profile_workspaces", { username, viewer, public: publicIn }), | |
| 181 | 181 | dockPins: (user, workspace) => call("dock_pins", { user, workspace }), | |
| 182 | 182 | 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 }), | |
| 183 | 185 | listSshKeys: (user) => call("list_ssh_keys", { user }), | |
| 184 | 186 | addSshKey: (user, title, publicKey) => | |
| 185 | 187 | call("add_ssh_key", { user, title, publicKey }), |
| 1 | + | import type { SoundSettings, SoundSettingsChange } from "./sounds"; | |
| 1 | 2 | import type { AccessClient, BasePermission, RepoGrant } from "./access"; | |
| 2 | 3 | import type { Permissions, ScopeLevel, ScopeResource } from "./scopes"; | |
| 3 | 4 | import type { MemberPrivileges, OrgRole, PolicyHold } from "./members"; | |
| ⋯ | |||
| 1049 | 1050 | */ | |
| 1050 | 1051 | setDockPins(user: User, workspace: string, apps: string[]): Promise<Result<string[]>>; | |
| 1051 | 1052 | ||
| 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 | + | ||
| 1052 | 1065 | listSshKeys(user: User): Promise<SshKey[]>; | |
| 1053 | 1066 | /** Takes one line in OpenSSH public key format. */ | |
| 1054 | 1067 | addSshKey(user: User, title: string, publicKey: string): Promise<Result<SshKey>>; | |
| 27 | 27 | export * from "./github"; | |
| 28 | 28 | export * from "./guardrails"; | |
| 29 | 29 | export * from "./identity"; | |
| 30 | + | export * from "./sounds"; | |
| 30 | 31 | export * from "./inbox"; | |
| 31 | 32 | export * from "./instances"; | |
| 32 | 33 | export * from "./marketplace"; |
| 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 | + | } |
| 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 | + | ); |
| 332 | 332 | "workspace_members", | |
| 333 | 333 | "team_members", | |
| 334 | 334 | "dock_pins", | |
| 335 | + | "sound_settings", | |
| 335 | 336 | ] { | |
| 336 | 337 | sql.push((format!("DELETE FROM {table} WHERE user_id = ?1 AND {STILL}"), 1)); | |
| 337 | 338 | } | |
| ⋯ | |||
| 1179 | 1180 | let sql: Vec<String> = purge_statements().into_iter().map(|(sql, _)| sql).collect(); | |
| 1180 | 1181 | assert!(sql[0].starts_with("INSERT OR REPLACE INTO deleted_users")); | |
| 1181 | 1182 | 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"] { | |
| 1183 | 1184 | assert!(sql.iter().any(|s| s.starts_with(&format!("DELETE FROM {table} WHERE user_id = ?1"))), "{table}"); | |
| 1184 | 1185 | } | |
| 1185 | 1186 | // The row goes last, and everything before it only while the | |
| 28 | 28 | mod run_credentials; | |
| 29 | 29 | mod security; | |
| 30 | 30 | mod shared_invites; | |
| 31 | + | mod sounds; | |
| 31 | 32 | mod teams; | |
| 32 | 33 | mod throttle; | |
| 33 | 34 | mod token_reach; | |
| ⋯ | |||
| 995 | 996 | // Each person's dock pins, per workspace; see dock.rs. | |
| 996 | 997 | "dock_pins" => reply(&identity.dock_pins(args(body)?).await?), | |
| 997 | 998 | "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?), | |
| 998 | 1002 | "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?), | |
| 999 | 1003 | // Services only: who registered each key, for verifying commit | |
| 1000 | 1004 | // signatures (repos' signatures.rs). | |
| 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(¤t, &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 | + | } |