| 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 | } |