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.

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