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