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.

260 linesCodeBlameRaw
1/**
2 * Settings → Notifications: what you hear when chat moves and Do not
3 * disturb (identity keeps the sounds, notify your presence), what pops up
4 * and is pushed (notify), what you are also emailed for, how you watch
5 * repositories you create, and the repositories you watch other than the
6 * default way. The events service keeps those three (`inbox_settings`,
7 * `inbox_watched`); the notifications themselves are at /notifications.
8 */
9import { Eye, EyeOff } from "lucide-react";
10import { Form, Link, data, useNavigation } from "react-router";
11
12import type { DigestCadence, WatchLevel } from "@g1t/contracts";
13
14import type { Route } from "./+types/notifications";
15import { ErrorText, SubmitButton } from "../../components/ui";
16import { Card } from "../../components/ui/card";
17import { CheckboxOption } from "../../components/ui/checkbox";
18import { SwitchCard } from "../../components/ui/switch";
19import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
20import { EMAIL_REASONS, SECURITY_DIGEST_CHOICES, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm, securityDigestFromForm } from "../../lib/inbox";
21import { page } from "../../lib/meta";
22import { identity, inbox } from "../../lib/services.server";
23import { assertSameOrigin, requireUser } from "../../lib/session.server";
24import { Unavailable } from "../../components/boundary";
25import { isFailed, part } from "../../lib/partial";
26// Live notifications: pop-ups and browser notifications (services/notify).
27import { LiveNotificationSettings } from "../../components/notifications/settings";
28// Sounds, Do not disturb and desktop notifications (identity, notify).
29import { SoundSettingsSection } from "../../components/notifications/sounds";
30
31export function meta(args: Route.MetaArgs) {
32 return page(args, { title: "Notifications · Settings · g1t" });
33}
34
35export async function loader({ request, context }: Route.LoaderArgs) {
36 const user = requireUser(context, request);
37 // Each section stands on its own read: one that could not be read says so
38 // in its place, and its form is not offered over values not known. The
39 // sounds save one change at a time, so without them the section starts
40 // from what the page already has (the root's read) and loses nothing.
41 const [settings, watched, sounds] = await Promise.all([
42 part(inbox.settings(user.username), "Notification settings"),
43 part(inbox.watched(user.username), "Repositories you watch"),
44 identity.soundSettings(user).catch(() => null),
45 ]);
46 return { settings, watched, sounds, workspaces: user.workspaces ?? [] };
47}
48
49export async function action({ request, context }: Route.ActionArgs) {
50 assertSameOrigin(request);
51 const user = requireUser(context, request);
52 const form = await request.formData();
53 const intent = String(form.get("intent") ?? "");
54 // One shape for every answer: what was said, beside the form that asked.
55 const answer = (saved: string | null, error: string | null = null) => ({ intent, saved, error });
56 try {
57 switch (intent) {
58 case "email":
59 await inbox.updateSettings(user.username, { email: emailReasonsFromForm(form) });
60 return answer("Saved. You'll be emailed for what is checked.");
61 case "security": {
62 const cadence: DigestCadence | null = securityDigestFromForm(form);
63 if (!cadence) return data(answer(null, "Choose when the rest of the security alerts reach you."), { status: 400 });
64 await inbox.updateSettings(user.username, { securityAlerts: form.get("security_alerts") === "on", securityDigest: cadence });
65 return answer("Saved. Critical and high alerts on repositories you look after still reach you at once.");
66 }
67 case "default_watch": {
68 const level = String(form.get("default_watch") ?? "");
69 if (level !== "participating" && level !== "all") return data(answer(null, "Choose one."), { status: 400 });
70 await inbox.updateSettings(user.username, { defaultWatch: level });
71 return answer("Saved. It applies to repositories you create from now on.");
72 }
73 case "unwatch": {
74 const repoId = String(form.get("repo_id") ?? "");
75 if (!repoId) return data(answer(null, "Nothing to do."), { status: 400 });
76 await inbox.watch(user.username, repoId, String(form.get("repo") ?? ""), null);
77 return answer(null);
78 }
79 default:
80 return data(answer(null, "Nothing to do."), { status: 400 });
81 }
82 } catch {
83 return data(answer(null, "That did not save. Try again in a moment."), { status: 503 });
84 }
85}
86
87const LEVEL_LABEL: Record<WatchLevel, string> = Object.fromEntries(WATCH_CHOICES.map((choice) => [choice.level, choice.label])) as Record<WatchLevel, string>;
88
89export default function NotificationSettings({ loaderData, actionData }: Route.ComponentProps) {
90 const { watched, sounds, workspaces } = loaderData;
91 const settings = isFailed(loaderData.settings) ? null : loaderData.settings;
92 const navigation = useNavigation();
93 const said = (intent: string) => (actionData?.intent === intent && navigation.state === "idle" ? actionData : null);
94 return (
95 <div className="space-y-10">
96 <SoundSettingsSection initial={sounds} />
97 <LiveNotificationSettings workspaces={workspaces} />
98 <section aria-labelledby="email-heading">
99 <h2 id="email-heading" className="text-sm font-semibold">
100 Email
101 </h2>
102 <p className="mt-1 text-sm text-muted">
103 Besides your{" "}
104 <Link to="/notifications" className="text-fg underline underline-offset-4">
105 notifications
106 </Link>
107 , email me at my primary address when:
108 </p>
109 {settings ? (
110 <Form method="post" className="mt-4 space-y-3">
111 <input type="hidden" name="intent" value="email" />
112 {EMAIL_REASONS.map((entry) => (
113 <CheckboxOption
114 key={entry.reason}
115 name="email"
116 value={entry.reason}
117 defaultChecked={settings.email.includes(entry.reason)}
118 label={entry.label}
119 description={entry.detail}
120 />
121 ))}
122 <div className="flex items-center gap-3 pt-2">
123 <SubmitButton variant="outline" match={{ intent: "email" }} pending="Saving…">
124 Save email settings
125 </SubmitButton>
126 {said("email")?.saved && <p className="text-xs text-success">{said("email")?.saved}</p>}
127 </div>
128 <ErrorText>{said("email")?.error}</ErrorText>
129 </Form>
130 ) : (
131 <div className="mt-4">
132 <Unavailable what="Your email settings" />
133 </div>
134 )}
135 <p className="mt-3 text-xs text-faint">Email goes only to a confirmed address, and only about repositories you can still read.</p>
136 </section>
137
138 <section aria-labelledby="security-heading">
139 <h2 id="security-heading" className="text-sm font-semibold">
140 Security
141 </h2>
142 <p className="mt-1 text-sm text-muted">
143 Each scan of a repository is one item, brought up to date by the next scan. Critical and high alerts, a push of yours that push protection refused and
144 a vulnerability report reach you at once; the rest, as you choose here. The workspaces you own, or manage security for, tell you of their urgent
145 alerts whatever you choose.
146 </p>
147 {settings ? (
148 <Form method="post" className="mt-4 space-y-4">
149 <input type="hidden" name="intent" value="security" />
150 <SwitchCard name="security_alerts" title="Critical and high alerts at once" defaultChecked={settings.securityAlerts}>
151 On repositories you watch for security alerts. Off, you hear of them only in the repositories you own or manage security for.
152 </SwitchCard>
153 <div>
154 <p className="mb-2 text-sm font-medium">Medium and low alerts</p>
155 <RadioGroup name="security_digest" defaultValue={settings.securityDigest}>
156 {SECURITY_DIGEST_CHOICES.map((choice) => (
157 <RadioOption key={choice.cadence} value={choice.cadence} label={choice.label} description={choice.detail} />
158 ))}
159 </RadioGroup>
160 </div>
161 <div className="flex items-center gap-3">
162 <SubmitButton variant="outline" match={{ intent: "security" }} pending="Saving…">
163 Save security settings
164 </SubmitButton>
165 {said("security")?.saved && <p className="text-xs text-success">{said("security")?.saved}</p>}
166 </div>
167 <ErrorText>{said("security")?.error}</ErrorText>
168 </Form>
169 ) : (
170 <div className="mt-4">
171 <Unavailable what="Your security notification settings" />
172 </div>
173 )}
174 <p className="mt-3 text-xs text-faint">Email for security alerts follows the Email section above: on by default.</p>
175 </section>
176
177 <section aria-labelledby="watching-heading">
178 <h2 id="watching-heading" className="text-sm font-semibold">
179 Repositories you create
180 </h2>
181 <p className="mt-1 text-sm text-muted">How you watch a repository when you make it. Change any one from its Watch menu.</p>
182 {settings ? (
183 <Form method="post" className="mt-4">
184 <input type="hidden" name="intent" value="default_watch" />
185 <RadioGroup name="default_watch" defaultValue={settings.defaultWatch === "participating" ? "participating" : "all"}>
186 {WATCH_CHOICES.filter((choice) => choice.level === "all" || choice.level === "participating").map((choice) => (
187 <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} />
188 ))}
189 </RadioGroup>
190 <div className="mt-4 flex items-center gap-3">
191 <SubmitButton variant="outline" match={{ intent: "default_watch" }} pending="Saving…">
192 Save
193 </SubmitButton>
194 {said("default_watch")?.saved && <p className="text-xs text-success">{said("default_watch")?.saved}</p>}
195 </div>
196 <ErrorText>{said("default_watch")?.error}</ErrorText>
197 </Form>
198 ) : (
199 <div className="mt-4">
200 <Unavailable what="How you watch what you create" />
201 </div>
202 )}
203 </section>
204
205 <section aria-labelledby="watched-heading">
206 <h2 id="watched-heading" className="text-sm font-semibold">
207 Repositories you watch
208 </h2>
209 <p className="mt-1 text-sm text-muted">Those you watch other than the default way, which is only what you take part in.</p>
210 {isFailed(watched) ? (
211 <div className="mt-4">
212 <Unavailable what="Repositories you watch" />
213 </div>
214 ) : watched.length === 0 ? (
215 <Card asChild tone="plain" radius="lg" className="mt-4 border-dashed px-4 py-6 text-center text-sm text-muted">
216 <p>You watch every repository the default way.</p>
217 </Card>
218 ) : (
219 <ul className="mt-4 divide-y divide-line rounded-md border border-line">
220 {watched.map((watching) => (
221 <li key={watching.repoId} className="flex items-center gap-3 px-4 py-3">
222 {watching.level === "ignore" ? (
223 <EyeOff size={15} className="shrink-0 text-faint" aria-hidden="true" />
224 ) : (
225 <Eye size={15} className="shrink-0 text-faint" aria-hidden="true" />
226 )}
227 <div className="min-w-0 grow">
228 {watching.repo ? (
229 <Link to={`/${watching.repo}`} className="block truncate font-mono text-sm hover:underline">
230 {watching.repo}
231 </Link>
232 ) : (
233 <span className="block truncate font-mono text-sm text-muted">{watching.repoId}</span>
234 )}
235 <p className="truncate text-xs text-muted">
236 {LEVEL_LABEL[watching.level]}
237 {watching.level === "custom" && watching.events.length > 0 && `: ${watching.events.map((kind) => WATCH_EVENT_LABEL[kind]).join(", ")}`}
238 </p>
239 </div>
240 <Form method="post" className="shrink-0">
241 <input type="hidden" name="intent" value="unwatch" />
242 <input type="hidden" name="repo_id" value={watching.repoId} />
243 <input type="hidden" name="repo" value={watching.repo ?? ""} />
244 <SubmitButton
245 variant="outline"
246 match={{ intent: "unwatch", repo_id: watching.repoId }}
247 pending="…"
248 className="rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-60"
249 >
250 {watching.level === "ignore" ? "Stop ignoring" : "Stop watching"}
251 </SubmitButton>
252 </Form>
253 </li>
254 ))}
255 </ul>
256 )}
257 </section>
258 </div>
259 );
260}