Skip to content

g1t/apps/web/app/routes/settings/notifications.tsx

185 lines8,981 bytesCodeBlame
1/**
2 * Settings → Notifications: what you are also emailed for, how you watch
3 * repositories you create, and the repositories you watch other than the
4 * default way. The events service keeps all three (`inbox_settings`,
5 * `inbox_watched`); the inbox itself is at /inbox.
6 */
7import { Eye, EyeOff } from "lucide-react";
8import { Form, Link, data, useNavigation } from "react-router";
9
10import type { WatchLevel } from "@g1t/contracts";
11
12import type { Route } from "./+types/notifications";
13import { ErrorText, SubmitButton } from "../../components/ui";
14import { CheckboxOption } from "../../components/ui/checkbox";
15import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
16import { EMAIL_REASONS, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm } from "../../lib/inbox";
17import { page } from "../../lib/meta";
18import { inbox } from "../../lib/services.server";
19import { assertSameOrigin, requireUser } from "../../lib/session.server";
20
21export function meta(args: Route.MetaArgs) {
22 return page(args, { title: "Notifications · Settings · g1t" });
23}
24
25export async function loader({ request, context }: Route.LoaderArgs) {
26 const user = requireUser(context, request);
27 const [settings, watched] = await Promise.all([
28 inbox.settings(user.username).catch(() => null),
29 inbox.watched(user.username).catch(() => null),
30 ]);
31 return { settings, watched };
32}
33
34export async function action({ request, context }: Route.ActionArgs) {
35 assertSameOrigin(request);
36 const user = requireUser(context, request);
37 const form = await request.formData();
38 const intent = String(form.get("intent") ?? "");
39 // One shape for every answer: what was said, beside the form that asked.
40 const answer = (saved: string | null, error: string | null = null) => ({ intent, saved, error });
41 try {
42 switch (intent) {
43 case "email":
44 await inbox.updateSettings(user.username, { email: emailReasonsFromForm(form) });
45 return answer("Saved. You'll be emailed for what is checked.");
46 case "default_watch": {
47 const level = String(form.get("default_watch") ?? "");
48 if (level !== "participating" && level !== "all") return data(answer(null, "Choose one."), { status: 400 });
49 await inbox.updateSettings(user.username, { defaultWatch: level });
50 return answer("Saved. It applies to repositories you create from now on.");
51 }
52 case "unwatch": {
53 const repoId = String(form.get("repo_id") ?? "");
54 if (!repoId) return data(answer(null, "Nothing to do."), { status: 400 });
55 await inbox.watch(user.username, repoId, String(form.get("repo") ?? ""), null);
56 return answer(null);
57 }
58 default:
59 return data(answer(null, "Nothing to do."), { status: 400 });
60 }
61 } catch {
62 return data(answer(null, "That did not save. Try again in a moment."), { status: 503 });
63 }
64}
65
66const LEVEL_LABEL: Record<WatchLevel, string> = Object.fromEntries(WATCH_CHOICES.map((choice) => [choice.level, choice.label])) as Record<
67 WatchLevel,
68 string
69>;
70
71export default function NotificationSettings({ loaderData, actionData }: Route.ComponentProps) {
72 const { settings, watched } = loaderData;
73 const navigation = useNavigation();
74 const said = (intent: string) => (actionData?.intent === intent && navigation.state === "idle" ? actionData : null);
75 if (!settings) {
76 return <p className="rounded-lg border border-line px-4 py-6 text-sm text-muted">Your notification settings could not be loaded. Try again in a moment.</p>;
77 }
78 return (
79 <div className="space-y-10">
80 <section aria-labelledby="email-heading">
81 <h2 id="email-heading" className="text-sm font-semibold">
82 Email
83 </h2>
84 <p className="mt-1 text-sm text-muted">
85 Besides your <Link to="/inbox" className="text-fg underline underline-offset-4">inbox</Link>, email me at my primary address when:
86 </p>
87 <Form method="post" className="mt-4 space-y-3">
88 <input type="hidden" name="intent" value="email" />
89 {EMAIL_REASONS.map((entry) => (
90 <CheckboxOption
91 key={entry.reason}
92 name="email"
93 value={entry.reason}
94 defaultChecked={settings.email.includes(entry.reason)}
95 label={entry.label}
96 description={entry.detail}
97 />
98 ))}
99 <div className="flex items-center gap-3 pt-2">
100 <SubmitButton variant="quiet" match={{ intent: "email" }} pending="Saving…">
101 Save email settings
102 </SubmitButton>
103 {said("email")?.saved && <p className="text-xs text-accent">{said("email")?.saved}</p>}
104 </div>
105 <ErrorText>{said("email")?.error}</ErrorText>
106 </Form>
107 <p className="mt-3 text-xs text-faint">Email goes only to a confirmed address, and only about repositories you can still read.</p>
108 </section>
109
110 <section aria-labelledby="watching-heading">
111 <h2 id="watching-heading" className="text-sm font-semibold">
112 Repositories you create
113 </h2>
114 <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>
115 <Form method="post" className="mt-4">
116 <input type="hidden" name="intent" value="default_watch" />
117 <RadioGroup name="default_watch" defaultValue={settings.defaultWatch === "participating" ? "participating" : "all"}>
118 {WATCH_CHOICES.filter((choice) => choice.level === "all" || choice.level === "participating").map((choice) => (
119 <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} />
120 ))}
121 </RadioGroup>
122 <div className="mt-4 flex items-center gap-3">
123 <SubmitButton variant="quiet" match={{ intent: "default_watch" }} pending="Saving…">
124 Save
125 </SubmitButton>
126 {said("default_watch")?.saved && <p className="text-xs text-accent">{said("default_watch")?.saved}</p>}
127 </div>
128 <ErrorText>{said("default_watch")?.error}</ErrorText>
129 </Form>
130 </section>
131
132 <section aria-labelledby="watched-heading">
133 <h2 id="watched-heading" className="text-sm font-semibold">
134 Repositories you watch
135 </h2>
136 <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>
137 {watched == null ? (
138 <p className="mt-4 text-sm text-muted">This list could not be loaded. Try again in a moment.</p>
139 ) : watched.length === 0 ? (
140 <p className="mt-4 rounded-lg border border-dashed border-line px-4 py-6 text-center text-sm text-muted">
141 You watch every repository the default way.
142 </p>
143 ) : (
144 <ul className="mt-4 divide-y divide-line rounded-md border border-line">
145 {watched.map((watching) => (
146 <li key={watching.repoId} className="flex items-center gap-3 px-4 py-3">
147 {watching.level === "ignore" ? (
148 <EyeOff size={15} className="shrink-0 text-faint" aria-hidden="true" />
149 ) : (
150 <Eye size={15} className="shrink-0 text-faint" aria-hidden="true" />
151 )}
152 <div className="min-w-0 grow">
153 {watching.repo ? (
154 <Link to={`/${watching.repo}`} className="block truncate font-mono text-sm hover:underline">
155 {watching.repo}
156 </Link>
157 ) : (
158 <span className="block truncate font-mono text-sm text-muted">{watching.repoId}</span>
159 )}
160 <p className="truncate text-xs text-muted">
161 {LEVEL_LABEL[watching.level]}
162 {watching.level === "custom" && watching.events.length > 0 && `: ${watching.events.map((kind) => WATCH_EVENT_LABEL[kind]).join(", ")}`}
163 </p>
164 </div>
165 <Form method="post" className="shrink-0">
166 <input type="hidden" name="intent" value="unwatch" />
167 <input type="hidden" name="repo_id" value={watching.repoId} />
168 <input type="hidden" name="repo" value={watching.repo ?? ""} />
169 <SubmitButton
170 variant="quiet"
171 match={{ intent: "unwatch", repo_id: watching.repoId }}
172 pending="…"
173 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"
174 >
175 {watching.level === "ignore" ? "Stop ignoring" : "Stop watching"}
176 </SubmitButton>
177 </Form>
178 </li>
179 ))}
180 </ul>
181 )}
182 </section>
183 </div>
184 );
185}