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