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