Skip to content
238 linesCodeBlameRaw
1import { BellRing, Send } from "lucide-react";
2import { useEffect, useState } from "react";
3
4import type { Membership, NotifyLevel, NotifyPreferences, NotifyPreferencesChange, NotifyStatus } from "@g1t/contracts";
5
6import { Button } from "../ui/button";
7import { Card } from "../ui/card";
8import { RadioGroup, RadioOption } from "../ui/radio-group";
9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
10import { Switch } from "../ui/switch";
11import {
12 desktopBridge,
13 disablePush,
14 enablePush,
15 notifyStatus,
16 permission,
17 pushChoice,
18 pushSupported,
19 savePreferences,
20 sendTest,
21 setSound,
22 soundOn,
23 chime,
24} from "../../lib/notify-client";
25
26const LEVELS: { level: NotifyLevel; label: string; detail: string }[] = [
27 { level: "all", label: "Everything", detail: "Every message in conversations you're in, and everything in your notifications." },
28 {
29 level: "dms_mentions",
30 label: "Direct messages, mentions and replies",
31 detail: "Messages to you, @mentions, replies in your threads, and agents or reviews waiting on you.",
32 },
33 { level: "none", label: "Nothing", detail: "No pop-ups or browser notifications. Counts still update." },
34];
35
36type Device = "on" | "off" | "blocked" | "unsupported" | "unavailable" | "desktop";
37
38function deviceState(status: NotifyStatus | null): Device {
39 if (desktopBridge()) return "desktop";
40 if (!pushSupported()) return "unsupported";
41 if (status && !status.vapid_public_key) return "unavailable";
42 if (permission() === "denied") return "blocked";
43 return permission() === "granted" && status?.subscribed && pushChoice() !== "off" ? "on" : "off";
44}
45
46const DEVICE_LINE: Record<Device, string> = {
47 on: "On. This browser shows a notification when you're away from g1t.",
48 off: "Off. You'll only see notifications while g1t is open.",
49 blocked: "Blocked by your browser. Allow notifications for this site in its settings, then come back.",
50 unsupported: "This browser can't show notifications.",
51 unavailable: "Browser notifications aren't set up on this server.",
52 desktop: "The desktop app shows notifications itself.",
53};
54
55/**
56 * Settings → Notifications, the live part: what pops up and is pushed,
57 * this browser's notifications, the sound, and a test. Kept by the notify
58 * service (services/notify); the sound is this browser's own.
59 */
60export function LiveNotificationSettings({ workspaces, initial = null }: { workspaces: Membership[]; initial?: NotifyStatus | null }) {
61 const [status, setStatus] = useState<NotifyStatus | null>(initial);
62 const [prefs, setPrefs] = useState<NotifyPreferences | null>(initial?.preferences ?? null);
63 const [device, setDevice] = useState<Device>("off");
64 const [sound, setSoundState] = useState(false);
65 const [busy, setBusy] = useState<string | null>(null);
66 const [said, setSaid] = useState<string | null>(null);
67 const [failed, setFailed] = useState(false);
68
69 useEffect(() => {
70 setSoundState(soundOn());
71 if (initial) {
72 setDevice(deviceState(initial));
73 return;
74 }
75 notifyStatus()
76 .then((s) => {
77 setStatus(s);
78 setPrefs(s?.preferences ?? null);
79 setDevice(deviceState(s));
80 if (!s) setFailed(true);
81 })
82 .catch(() => setFailed(true));
83 }, [initial]);
84
85 const save = async (change: NotifyPreferencesChange) => {
86 if (prefs) {
87 const workspaces = { ...prefs.workspaces };
88 for (const [slug, level] of Object.entries(change.workspaces ?? {})) {
89 if (level) workspaces[slug] = level;
90 else delete workspaces[slug];
91 }
92 setPrefs({ level: change.level ?? prefs.level, workspaces });
93 }
94 setBusy("prefs");
95 const saved = await savePreferences(change).catch(() => null);
96 setBusy(null);
97 if (saved) {
98 setPrefs(saved);
99 setSaid("Saved.");
100 } else setSaid("That didn't save. Try again in a moment.");
101 };
102
103 const toggleDevice = async (on: boolean) => {
104 setBusy("device");
105 if (on) {
106 const result = await enablePush();
107 setDevice(result === "on" ? "on" : result === "denied" ? (permission() === "denied" ? "blocked" : "off") : deviceState(status));
108 if (result === "failed") setSaid("This browser couldn't be set up. Try again in a moment.");
109 } else {
110 await disablePush().catch(() => {});
111 setDevice("off");
112 }
113 setBusy(null);
114 };
115
116 const test = async () => {
117 setBusy("test");
118 const sent = await sendTest().catch(() => null);
119 setBusy(null);
120 if (!sent) setSaid("The test didn't send. Try again in a moment.");
121 else if (sent.pushed > 0) setSaid(`Sent. Also pushed to ${sent.pushed === 1 ? "1 browser" : `${sent.pushed} browsers`}.`);
122 else setSaid("Sent. It shows here; turn on browser notifications to get it when you're away.");
123 };
124
125 if (failed) {
126 return (
127 <section aria-labelledby="live-heading">
128 <h2 id="live-heading" className="text-sm font-semibold">
129 Pop-ups and browser notifications
130 </h2>
131 <Card asChild tone="plain" radius="lg" className="mt-3 px-4 py-6 text-sm text-muted">
132 <p>These settings could not be loaded. Try again in a moment.</p>
133 </Card>
134 </section>
135 );
136 }
137
138 const deviceOn = device === "on";
139 const deviceLocked = device === "unsupported" || device === "unavailable" || device === "blocked" || device === "desktop";
140
141 return (
142 <section aria-labelledby="live-heading" className="space-y-6">
143 <div>
144 <h2 id="live-heading" className="text-sm font-semibold">
145 Pop-ups and browser notifications
146 </h2>
147 <p className="mt-1 text-sm text-muted">What pops up while g1t is open, and what reaches you when it isn't.</p>
148 </div>
149
150 <div>
151 <h3 className="text-[0.8125rem] font-medium text-fg">Notify me about</h3>
152 <RadioGroup
153 className="mt-3"
154 value={prefs?.level ?? "dms_mentions"}
155 disabled={!prefs}
156 onValueChange={(value) => void save({ level: value as NotifyLevel })}
157 aria-label="Notify me about"
158 >
159 {LEVELS.map((choice) => (
160 <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} />
161 ))}
162 </RadioGroup>
163 </div>
164
165 {workspaces.length > 1 && prefs && (
166 <div>
167 <h3 className="text-[0.8125rem] font-medium text-fg">For a workspace</h3>
168 <Card asChild tone="plain" radius="lg" divided className="mt-3">
169 <ul>
170 {workspaces.map((membership) => (
171 <li key={membership.slug} className="flex items-center gap-3 px-3.5 py-2.5">
172 <span className="min-w-0 grow truncate text-sm">{membership.name || membership.slug}</span>
173 <Select
174 value={prefs.workspaces[membership.slug] ?? "default"}
175 onValueChange={(value) => void save({ workspaces: { [membership.slug]: value === "default" ? null : (value as NotifyLevel) } })}
176 >
177 <SelectTrigger size="sm" className="w-44" aria-label={`Notifications for ${membership.name || membership.slug}`}>
178 <SelectValue />
179 </SelectTrigger>
180 <SelectContent>
181 <SelectItem value="default">As above</SelectItem>
182 <SelectItem value="all">Everything</SelectItem>
183 <SelectItem value="dms_mentions">DMs and mentions</SelectItem>
184 <SelectItem value="none">Nothing</SelectItem>
185 </SelectContent>
186 </Select>
187 </li>
188 ))}
189 </ul>
190 </Card>
191 </div>
192 )}
193
194 <Card divided>
195 <div className="flex items-start gap-4 p-4">
196 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent">
197 <BellRing size={16} />
198 </span>
199 <div className="min-w-0 grow">
200 <p className="text-sm font-medium text-fg">Browser notifications</p>
201 <p className="mt-0.5 text-xs text-muted" role="status">
202 {DEVICE_LINE[device]}
203 </p>
204 </div>
205 <Switch
206 checked={deviceOn}
207 disabled={deviceLocked || busy === "device" || !status}
208 onCheckedChange={(on) => void toggleDevice(on)}
209 aria-label="Browser notifications"
210 />
211 </div>
212 <div className="flex items-start gap-4 p-4">
213 <div className="min-w-0 grow pl-12">
214 <p className="text-sm font-medium text-fg">Sound</p>
215 <p className="mt-0.5 text-xs text-muted">A soft chime with each pop-up, in this browser.</p>
216 </div>
217 <Switch
218 checked={sound}
219 onCheckedChange={(on) => {
220 setSound(on);
221 setSoundState(on);
222 if (on) chime();
223 }}
224 aria-label="Sound"
225 />
226 </div>
227 </Card>
228
229 <div className="flex flex-wrap items-center gap-3">
230 <Button type="button" variant="outline" onClick={() => void test()} disabled={busy === "test" || !status}>
231 <Send size={14} />
232 {busy === "test" ? "Sending…" : "Send a test notification"}
233 </Button>
234 {said && <p className="text-xs text-muted">{said}</p>}
235 </div>
236 </section>
237 );
238}