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