Skip to content
265 linesCodeBlameRaw
1import { BellOff, MonitorSmartphone, Play, Volume2, VolumeX } from "lucide-react";
2import { useEffect, useState } from "react";
3
4import { type SoundCue, type SoundSet, type SoundSettings, SOUND_CUES, cueOn } from "@g1t/contracts/sounds";
5
6import { Button } from "../ui/button";
7import { Card } from "../ui/card";
8import { Hint } from "../ui/hint";
9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
10import { Slider } from "../ui/slider";
11import { Switch } from "../ui/switch";
12import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
13import { permission, pushChoice, setPresence, useOwnPresence } from "../../lib/notify-client";
14import { PAUSE_FOR, type PauseFor, dndOn, pauseUntil, untilLabel } from "../../lib/presence";
15import { applySoundSettings, preview, saveSoundSettings, useSoundSettings } from "../../lib/sounds";
16
17const 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
25const 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
30type Desktop = "on" | "off" | "blocked" | "unsupported" | "pushed";
31
32function 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
39const 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 */
54export function SoundSettingsSection({ initial }: { initial: SoundSettings | null }) {
55 const settings = useSoundSettings();
56 const me = useOwnPresence();
57 const [now, setNow] = useState(() => Date.now());
58 const [said, setSaid] = useState<string | null>(null);
59 const [desktop, setDesktop] = useState<Desktop>("off");
60 const [busy, setBusy] = useState(false);
61
62 useEffect(() => {
63 // The page's own read, in case the root's is older than a change made elsewhere.
64 applySoundSettings(initial);
65 }, [initial]);
66 useEffect(() => setDesktop(desktopState(settings)), [settings]);
67 useEffect(() => {
68 const timer = setInterval(() => setNow(Date.now()), 30_000);
69 return () => clearInterval(timer);
70 }, []);
71
72 const save = async (change: Parameters<typeof saveSoundSettings>[0]) => {
73 const saved = await saveSoundSettings(change);
74 setSaid(saved ? null : "That didn't save. Try again in a moment.");
75 };
76
77 const toggleDesktop = async (on: boolean) => {
78 if (!on) return save({ desktop_toasts: false });
79 setBusy(true);
80 try {
81 // Only ever from this click: never when a page loads.
82 const answer = permission() === "granted" ? "granted" : await Notification.requestPermission();
83 if (answer !== "granted") {
84 setDesktop(permission() === "denied" ? "blocked" : "off");
85 return;
86 }
87 await save({ desktop_toasts: true });
88 } finally {
89 setBusy(false);
90 }
91 };
92
93 const paused = dndOn(me, now);
94 const dndValue = paused ? "on" : "off";
95 const setDnd = (value: string) => {
96 if (value === "off") {
97 void setPresence({ dnd_until: null }, { dnd_until: null });
98 return;
99 }
100 if (value === "on") return;
101 const until = pauseUntil(value as PauseFor, new Date());
102 void setPresence({ dnd_until: until }, { dnd_until: until });
103 };
104
105 const off = !settings.sounds_enabled;
106 const desktopLocked = desktop === "blocked" || desktop === "unsupported" || desktop === "pushed";
107
108 return (
109 <section aria-labelledby="sounds-heading" className="space-y-4">
110 <div>
111 <h2 id="sounds-heading" className="text-sm font-semibold">
112 Sounds &amp; notifications
113 </h2>
114 <p className="mt-1 text-sm text-muted">
115 What you hear when chat moves, on every device you sign in on. Nothing sounds for the conversation you're looking at, for your own
116 messages, or in a muted conversation.
117 </p>
118 </div>
119
120 <Card divided>
121 <div className="flex items-center gap-4 p-4">
122 <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent">
123 {off ? <VolumeX size={16} /> : <Volume2 size={16} />}
124 </span>
125 <div className="min-w-0 grow">
126 <p className="text-sm font-medium text-fg">Sounds</p>
127 <p className="mt-0.5 text-xs text-muted">{off ? "Off. Chat is silent; counts and pop-ups still show." : "On. A short sound for what is for you."}</p>
128 </div>
129 <Switch checked={settings.sounds_enabled} onCheckedChange={(on) => void save({ sounds_enabled: on })} aria-label="Sounds" />
130 </div>
131
132 <div className={`space-y-4 p-4 ${off ? "opacity-60" : ""}`}>
133 <div className="flex flex-wrap items-center gap-3">
134 <div className="min-w-0 grow">
135 <p className="text-sm font-medium text-fg">Sound set</p>
136 <p className="mt-0.5 text-xs text-muted">{SETS.find((s) => s.set === settings.sound_set)?.detail ?? ""} Press play on any sound to hear it.</p>
137 </div>
138 <ToggleGroup
139 type="single"
140 variant="outline"
141 size="sm"
142 value={settings.sound_set}
143 disabled={off}
144 onValueChange={(value) => {
145 if (!value) return;
146 const set = value as SoundSet;
147 void save({ sound_set: set });
148 preview("message", set, settings.sound_volume);
149 }}
150 aria-label="Sound set"
151 >
152 {SETS.map((choice) => (
153 <ToggleGroupItem key={choice.set} value={choice.set} className="px-3">
154 {choice.label}
155 </ToggleGroupItem>
156 ))}
157 </ToggleGroup>
158 </div>
159
160 <div className="flex items-center gap-3">
161 <p id="volume-label" className="w-16 shrink-0 text-sm font-medium text-fg">
162 Volume
163 </p>
164 <Slider
165 value={[settings.sound_volume]}
166 min={0}
167 max={100}
168 step={5}
169 disabled={off}
170 aria-labelledby="volume-label"
171 onValueChange={([value]) => {
172 if (value == null) return;
173 applySoundSettings({ ...settings, sound_volume: value });
174 }}
175 onValueCommit={([value]) => {
176 if (value == null) return;
177 void save({ sound_volume: value });
178 preview("message", settings.sound_set, value);
179 }}
180 className="grow"
181 />
182 <span className="w-10 shrink-0 text-right text-xs text-muted tabular-nums">{settings.sound_volume}%</span>
183 </div>
184 </div>
185
186 <ul className={off ? "opacity-60" : ""} aria-label="Sounds">
187 {SOUND_CUES.map((cue) => (
188 <li key={cue} className="flex items-center gap-3 px-4 py-2.5">
189 <Hint label={`Play ${CUE[cue].label.toLowerCase()}`}>
190 <Button
191 type="button"
192 variant="outline"
193 size="icon-xs"
194 aria-label={`Play ${CUE[cue].label.toLowerCase()}`}
195 onClick={() => preview(cue, settings.sound_set, settings.sound_volume)}
196 >
197 <Play size={13} />
198 </Button>
199 </Hint>
200 <div className="min-w-0 grow">
201 <p className="text-sm text-fg">{CUE[cue].label}</p>
202 <p className="truncate text-xs text-muted">{CUE[cue].detail}</p>
203 </div>
204 <Switch
205 size="sm"
206 checked={cueOn(settings, cue)}
207 disabled={off}
208 onCheckedChange={(on) => void save({ sound_cues: { [cue]: on } })}
209 aria-label={CUE[cue].label}
210 />
211 </li>
212 ))}
213 </ul>
214 </Card>
215
216 <Card divided>
217 <div className="flex flex-wrap items-center gap-3 p-4">
218 <span className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${paused ? "bg-warn/15 text-warn" : "bg-accent/12 text-accent"}`}>
219 <BellOff size={16} />
220 </span>
221 <div className="min-w-0 grow basis-40">
222 <p className="text-sm font-medium text-fg">Do not disturb</p>
223 <p className="mt-0.5 text-xs text-muted" role="status">
224 {paused && me?.dnd_until
225 ? `On, ${untilLabel(me.dnd_until, new Date(now))}. No sounds, pop-ups or browser notifications; counts still move.`
226 : "Silence sounds, pop-ups and browser notifications for a while. Everyone sees your amber dot."}
227 </p>
228 </div>
229 <Select value={dndValue} onValueChange={setDnd}>
230 <SelectTrigger size="sm" className="w-44" aria-label="Do not disturb">
231 <SelectValue />
232 </SelectTrigger>
233 <SelectContent>
234 <SelectItem value="off">Off</SelectItem>
235 {paused && me?.dnd_until && <SelectItem value="on">On, {untilLabel(me.dnd_until, new Date(now))}</SelectItem>}
236 {PAUSE_FOR.map((choice) => (
237 <SelectItem key={choice.key} value={choice.key}>
238 {choice.label.replace(/^For /, "For ").replace("Until tomorrow", "Until tomorrow, 9:00")}
239 </SelectItem>
240 ))}
241 </SelectContent>
242 </Select>
243 </div>
244 <div className="flex items-start gap-4 p-4">
245 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent">
246 <MonitorSmartphone size={16} />
247 </span>
248 <div className="min-w-0 grow">
249 <p className="text-sm font-medium text-fg">Desktop notifications</p>
250 <p className="mt-0.5 text-xs text-muted" role="status">
251 {DESKTOP_LINE[desktop]}
252 </p>
253 </div>
254 <Switch
255 checked={desktop === "on"}
256 disabled={desktopLocked || busy}
257 onCheckedChange={(on) => void toggleDesktop(on)}
258 aria-label="Desktop notifications"
259 />
260 </div>
261 </Card>
262 {said && <p className="text-xs text-danger">{said}</p>}
263 </section>
264 );
265}