Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

275 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 messages, or in a
121 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">
133 {off ? "Off. Chat is silent; counts and pop-ups still show." : "On. A short sound for what is for you."}
134 </p>
135 </div>
136 <Switch checked={settings.sounds_enabled} onCheckedChange={(on) => void save({ sounds_enabled: on })} aria-label="Sounds" />
137 </div>
138
139 <div className={`space-y-4 p-4 ${off ? "opacity-60" : ""}`}>
140 <div className="flex flex-wrap items-center gap-3">
141 <div className="min-w-0 grow">
142 <p className="text-sm font-medium text-fg">Sound set</p>
143 <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>
144 </div>
145 <ToggleGroup
146 type="single"
147 variant="outline"
148 size="sm"
149 value={settings.sound_set}
150 disabled={off}
151 onValueChange={(value) => {
152 if (!value) return;
153 const set = value as SoundSet;
154 void save({ sound_set: set });
155 preview("message", set, settings.sound_volume);
156 }}
157 aria-label="Sound set"
158 >
159 {SETS.map((choice) => (
160 <ToggleGroupItem key={choice.set} value={choice.set} className="px-3">
161 {choice.label}
162 </ToggleGroupItem>
163 ))}
164 </ToggleGroup>
165 </div>
166
167 <div className="flex items-center gap-3">
168 <p id="volume-label" className="w-16 shrink-0 text-sm font-medium text-fg">
169 Volume
170 </p>
171 <Slider
172 value={[settings.sound_volume]}
173 min={0}
174 max={100}
175 step={5}
176 disabled={off}
177 aria-labelledby="volume-label"
178 onValueChange={([value]) => {
179 if (value == null) return;
180 applySoundSettings({ ...settings, sound_volume: value });
181 }}
182 onValueCommit={([value]) => {
183 if (value == null) return;
184 void save({ sound_volume: value });
185 preview("message", settings.sound_set, value);
186 }}
187 className="grow"
188 />
189 <span className="w-10 shrink-0 text-right text-xs text-muted tabular-nums">{settings.sound_volume}%</span>
190 </div>
191 </div>
192
193 <ul className={off ? "opacity-60" : ""} aria-label="Sounds">
194 {CUES.map((cue) => {
195 const { label, detail } = CUE[cue];
196 return (
197 <li key={cue} className="flex items-center gap-3 px-4 py-2.5">
198 <Hint label={`Play ${label.toLowerCase()}`}>
199 <Button
200 type="button"
201 variant="outline"
202 size="icon-xs"
203 aria-label={`Play ${label.toLowerCase()}`}
204 onClick={() => preview(cue, settings.sound_set, settings.sound_volume)}
205 >
206 <Play size={13} />
207 </Button>
208 </Hint>
209 <div className="min-w-0 grow">
210 <p className="text-sm text-fg">{label}</p>
211 <p className="truncate text-xs text-muted">{detail}</p>
212 </div>
213 <Switch
214 size="sm"
215 checked={cueOn(settings, cue)}
216 disabled={off}
217 onCheckedChange={(on) => void save({ sound_cues: { [cue]: on } })}
218 aria-label={label}
219 />
220 </li>
221 );
222 })}
223 </ul>
224 </Card>
225
226 <Card divided>
227 <div className="flex flex-wrap items-center gap-3 p-4">
228 <span className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${paused ? "bg-warn/15 text-warn" : "bg-accent/12 text-accent"}`}>
229 <BellOff size={16} />
230 </span>
231 <div className="min-w-0 grow basis-40">
232 <p className="text-sm font-medium text-fg">Do not disturb</p>
233 <p className="mt-0.5 text-xs text-muted" role="status">
234 {paused && me?.dnd_until
235 ? `On, ${untilLabel(me.dnd_until, new Date(now))}. No sounds, pop-ups or browser notifications; counts still move.`
236 : "Silence sounds, pop-ups and browser notifications for a while. Everyone sees your amber dot."}
237 </p>
238 </div>
239 <Select value={dndValue} onValueChange={setDnd}>
240 <SelectTrigger size="sm" className="w-44" aria-label="Do not disturb">
241 <SelectValue />
242 </SelectTrigger>
243 <SelectContent>
244 <SelectItem value="off">Off</SelectItem>
245 {paused && me?.dnd_until && <SelectItem value="on">On, {untilLabel(me.dnd_until, new Date(now))}</SelectItem>}
246 {PAUSE_FOR.map((choice) => (
247 <SelectItem key={choice.key} value={choice.key}>
248 {choice.label.replace(/^For /, "For ").replace("Until tomorrow", "Until tomorrow, 9:00")}
249 </SelectItem>
250 ))}
251 </SelectContent>
252 </Select>
253 </div>
254 <div className="flex items-start gap-4 p-4">
255 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent">
256 <MonitorSmartphone size={16} />
257 </span>
258 <div className="min-w-0 grow">
259 <p className="text-sm font-medium text-fg">Desktop notifications</p>
260 <p className="mt-0.5 text-xs text-muted" role="status">
261 {DESKTOP_LINE[desktop]}
262 </p>
263 </div>
264 <Switch
265 checked={desktop === "on" || desktop === "app"}
266 disabled={desktopLocked || busy}
267 onCheckedChange={(on) => void toggleDesktop(on)}
268 aria-label="Desktop notifications"
269 />
270 </div>
271 </Card>
272 {said && <p className="text-xs text-danger">{said}</p>}
273 </section>
274 );
275}