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