Skip to content

Commit

Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings

Cards say why each thread reached you and how many updates it has, timed by its latest activity; /inbox filters by reason. Issue and pull request sidebars have a Notifications box to subscribe, unsubscribe or stop ignoring, saying why you hear of it. The repository header has a Watch menu: participating and @mentions, all activity, ignore, or custom kinds. Settings has a Notifications page: what you are emailed for, how you watch repositories you create, and the repositories you watch.

syntaqxcommitted Parent73496a0Browse files
12 files+762−340/12 viewed
+14−6
1313 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
1414 import { cn } from "../lib/cn";
1515 import { isPending } from "../lib/pending";
16−import { INBOX_TABS, type InboxTab, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, whenShort } from "../lib/inbox";
16+import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, updatesLabel, whenShort } from "../lib/inbox";
1717 import type { InboxPanelData } from "../routes/inbox-json";
1818
1919 /** Where every inbox form posts (routes/inbox.tsx). */
6969 const unread = isUnread(item);
7070 const leaving = isPending(fetcher, { id: item.id }) && ["done", "snooze", "undone"].includes(String(fetcher.formData?.get("intent")));
7171 const [now, setNow] = useState(() => Date.now());
72− useEffect(() => setNow(Date.now()), [item.createdAt]);
72+ useEffect(() => setNow(Date.now()), [item.updatedAt]);
7373 if (leaving) return null;
7474
7575 const submit = (intent: string, extra?: Record<string, string>) =>
9696 {item.title}
9797 </Link>
9898 {item.body && <p className="mt-0.5 truncate text-xs text-muted">{item.body}</p>}
99− <div className="mt-2 flex items-center gap-2 text-xs text-faint">
100− <time dateTime={item.createdAt} suppressHydrationWarning>
101− {whenShort(item.createdAt, now)}
99+ <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-faint">
100+ <time dateTime={item.updatedAt} suppressHydrationWarning className="shrink-0 whitespace-nowrap">
101+ {whenShort(item.updatedAt, now)}
102102 </time>
103− <Badge tone={TONE[item.severity]}>{SEVERITY_LABEL[item.severity]}</Badge>
103+ <Badge tone={TONE[item.severity]} className="shrink-0">{SEVERITY_LABEL[item.severity]}</Badge>
104+ {/* Why they were told, and how much has happened, said quietly. */}
105+ <span className="truncate">{REASON_LABEL[item.reason] ?? item.reason}</span>
106+ {updatesLabel(item.count) && (
107+ <>
108+ <span aria-hidden="true">·</span>
109+ <span className="shrink-0 tabular-nums">{updatesLabel(item.count)}</span>
110+ </>
111+ )}
104112 {item.saved && (
105113 <span className="inline-flex items-center gap-1 text-faint">
106114 <Bookmark size={12} aria-hidden="true" />
+178−0
1+/**
2+ * Choosing what you hear of: subscribing to one issue or pull request, from
3+ * its sidebar, and watching a repository, from its header. Both post to
4+ * the repository's `notifications` route (routes/repo/notifications.ts);
5+ * the events service keeps the choice and the inbox follows it.
6+ */
7+import { Bell, BellOff, Check, ChevronDown, Eye, EyeOff } from "lucide-react";
8+import { useState } from "react";
9+import { useFetcher } from "react-router";
10+
11+import { WATCH_EVENTS, type ThreadSubscription, type WatchEvent, type WatchLevel } from "@g1t/contracts";
12+
13+import { SubmitButton } from "./ui";
14+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
15+import { cn } from "../lib/cn";
16+import { WATCH_CHOICES, WATCH_EVENT_LABEL, subscriptionLine, watchLabel } from "../lib/inbox";
17+
18+/**
19+ * The sidebar's Notifications box on an issue or pull request: one button
20+ * to subscribe or unsubscribe (or stop ignoring it), and a line saying
21+ * whether you hear of it, and why. While the choice is on its way, it
22+ * shows as made.
23+ */
24+export function SubscriptionBox({
25+ action,
26+ number,
27+ kind,
28+ subscription,
29+}: {
30+ /** The repository's notifications route: `/<owner>/<repo>/notifications`. */
31+ action: string;
32+ number: number;
33+ kind: "issue" | "pull";
34+ subscription: ThreadSubscription | null;
35+}) {
36+ const fetcher = useFetcher<{ error?: string }>();
37+ const asked = fetcher.formData?.get("intent");
38+ // What it will be once the choice lands.
39+ const shown: ThreadSubscription | null =
40+ asked === "subscribe"
41+ ? { ...(subscription ?? EMPTY), subscribed: true, ignored: false, reason: subscription?.subscribed ? subscription.reason : "manual" }
42+ : asked === "unsubscribe" || asked === "default"
43+ ? { ...(subscription ?? EMPTY), subscribed: false, ignored: false, reason: null }
44+ : subscription;
45+ const intent = shown?.ignored ? "default" : shown?.subscribed ? "unsubscribe" : "subscribe";
46+ const label = intent === "default" ? "Stop ignoring" : intent === "unsubscribe" ? "Unsubscribe" : "Subscribe";
47+ return (
48+ <section>
49+ <h3 className="text-sm font-medium">Notifications</h3>
50+ <fetcher.Form method="post" action={action} className="mt-2">
51+ <input type="hidden" name="intent" value={intent} />
52+ <input type="hidden" name="number" value={number} />
53+ <SubmitButton
54+ variant="quiet"
55+ fetcher={fetcher}
56+ className="inline-flex w-full items-center justify-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg disabled:opacity-60"
57+ >
58+ {intent === "subscribe" ? <Bell size={14} /> : <BellOff size={14} />}
59+ {label}
60+ </SubmitButton>
61+ </fetcher.Form>
62+ <p className="mt-2 text-xs text-muted">{subscriptionLine(shown, kind)}</p>
63+ {fetcher.data?.error && <p className="mt-1 text-xs text-danger">{fetcher.data.error}</p>}
64+ </section>
65+ );
66+}
67+
68+const EMPTY: ThreadSubscription = { subscribed: false, ignored: false, reason: null, repo: null, number: null, updatedAt: null };
69+
70+/**
71+ * The repository header's Watch menu: participating and @mentions (the
72+ * default), all activity, ignore, or custom, with the kinds a custom watch
73+ * follows checked under it. Each choice is saved as it is made.
74+ */
75+export function WatchMenu({ action, level, events }: { action: string; level: WatchLevel; events: WatchEvent[] }) {
76+ const fetcher = useFetcher<{ error?: string }>();
77+ // The choice on its way, as it will be.
78+ const pendingLevel = fetcher.formData?.get("level");
79+ const shownLevel = (typeof pendingLevel === "string" ? pendingLevel : level) as WatchLevel;
80+ const shownEvents = fetcher.formData ? (fetcher.formData.getAll("event").map(String) as WatchEvent[]) : events;
81+ // Custom opened to choose, before anything is chosen.
82+ const [choosing, setChoosing] = useState(false);
83+ const custom = shownLevel === "custom" || choosing;
84+
85+ const save = (next: WatchLevel, kinds: WatchEvent[] = []) => {
86+ const form = new FormData();
87+ form.set("intent", "watch");
88+ form.set("level", next);
89+ for (const kind of kinds) form.append("event", kind);
90+ fetcher.submit(form, { method: "post", action });
91+ };
92+ const toggle = (kind: WatchEvent) => {
93+ const had = shownLevel === "custom" ? shownEvents : [];
94+ const next = had.includes(kind) ? had.filter((event) => event !== kind) : [...had, kind];
95+ const ordered = WATCH_EVENTS.filter((event) => next.includes(event));
96+ // Nothing left to follow is the default.
97+ save(ordered.length > 0 ? "custom" : "participating", ordered);
98+ };
99+
100+ return (
101+ <DropdownMenu onOpenChange={(open) => !open && setChoosing(false)}>
102+ <DropdownMenuTrigger
103+ aria-label={`Watch: ${WATCH_CHOICES.find((choice) => choice.level === shownLevel)?.label ?? "Participating and @mentions"}`}
104+ className={cn(
105+ "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-2.5 text-[0.8125rem] text-fg/80 transition-colors outline-none hover:border-line-strong hover:bg-surface hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:border-line-strong",
106+ fetcher.state !== "idle" && "opacity-70",
107+ )}
108+ >
109+ {shownLevel === "ignore" ? <EyeOff size={14} /> : <Eye size={14} />}
110+ {/* The eye says it on a phone; the aria-label says it in full. */}
111+ <span className="hidden sm:inline">{watchLabel(shownLevel)}</span>
112+ <ChevronDown size={13} className="text-faint" />
113+ </DropdownMenuTrigger>
114+ <DropdownMenuContent align="end" className="w-72">
115+ <DropdownMenuLabel>Notifications from this repository</DropdownMenuLabel>
116+ {WATCH_CHOICES.map((choice) => {
117+ const selected = choice.level === "custom" ? custom : !choosing && shownLevel === choice.level;
118+ return (
119+ <DropdownMenuItem
120+ key={choice.level}
121+ onSelect={(event) => {
122+ if (choice.level === "custom") {
123+ // Stays open, to choose what to follow.
124+ event.preventDefault();
125+ setChoosing(true);
126+ return;
127+ }
128+ setChoosing(false);
129+ if (choice.level !== shownLevel) save(choice.level);
130+ }}
131+ className="items-start"
132+ >
133+ <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
134+ {selected && <Check size={14} className="!text-accent" />}
135+ </span>
136+ <span className="min-w-0">
137+ <span className="block text-sm text-fg">{choice.label}</span>
138+ <span className="block text-xs text-muted">{choice.detail}</span>
139+ </span>
140+ </DropdownMenuItem>
141+ );
142+ })}
143+ {custom && (
144+ <>
145+ <DropdownMenuSeparator />
146+ <DropdownMenuLabel>Also tell me about</DropdownMenuLabel>
147+ {WATCH_EVENTS.map((kind) => {
148+ const on = shownLevel === "custom" && shownEvents.includes(kind);
149+ return (
150+ <DropdownMenuItem
151+ key={kind}
152+ role="menuitemcheckbox"
153+ aria-checked={on}
154+ onSelect={(event) => {
155+ event.preventDefault();
156+ toggle(kind);
157+ }}
158+ >
159+ <span
160+ aria-hidden="true"
161+ className={cn(
162+ "flex size-4 shrink-0 items-center justify-center rounded border",
163+ on ? "border-accent bg-accent text-bg" : "border-line-strong",
164+ )}
165+ >
166+ {on && <Check size={11} className="!text-bg" />}
167+ </span>
168+ {WATCH_EVENT_LABEL[kind]}
169+ </DropdownMenuItem>
170+ );
171+ })}
172+ </>
173+ )}
174+ {fetcher.data?.error && <p className="px-2 py-1.5 text-xs text-danger">{fetcher.data.error}</p>}
175+ </DropdownMenuContent>
176+ </DropdownMenu>
177+ );
178+}
+5−0
66 export type AccountSettingsPage =
77 | "profile"
88 | "emails"
9+ | "notifications"
910 | "invites"
1011 | "keys"
1112 | "tokens"
2122 about:
2223 "Your primary address gets account mail and password resets. Any confirmed address signs you in and can reset your password, and commits that carry it are shown as yours.",
2324 },
25+ notifications: {
26+ title: "Notifications",
27+ about: "What you are also emailed for, and how you watch repositories. Everything comes to your inbox either way.",
28+ },
2429 invites: { title: "Invites", about: "Bring people to g1t, and see which invites were used." },
2530 keys: { title: "SSH keys", about: "Keys that let git on your computers reach g1t as you." },
2631 tokens: {
+93−2
33
44 import type { InboxItem } from "@g1t/contracts";
55
6−import { bellCount, emptyFor, inboxTab, inboxView, markFromForm, needsYou, severityOf, snoozeUntil, tabCount, whenShort } from "./inbox.ts";
6+import {
7+ EMAIL_REASONS,
8+ REASON_FILTERS,
9+ REASON_LABEL,
10+ bellCount,
11+ emailReasonsFromForm,
12+ emptyFor,
13+ inboxReason,
14+ inboxTab,
15+ inboxView,
16+ markFromForm,
17+ needsYou,
18+ severityOf,
19+ snoozeUntil,
20+ subscriptionFromForm,
21+ subscriptionLine,
22+ tabCount,
23+ updatesLabel,
24+ watchFromForm,
25+ watchLabel,
26+ whenShort,
27+} from "./inbox.ts";
728
829 const NOW = Date.parse("2026-10-07T12:00:00.000Z");
930
6990 test("what needs you puts a waiting agent before a failure, and leaves out the rest", () => {
7091 const item = (id: string, severity: InboxItem["severity"], createdAt: string, readAt: string | null = null): InboxItem => ({
7192 id,
72− reason: "x",
93+ reason: "agent",
7394 severity,
7495 title: id,
7596 body: "",
80101 url: "/acme/rocket/pull/1",
81102 actor: null,
82103 createdAt,
104+ updatedAt: createdAt,
105+ event: null,
106+ count: 1,
83107 readAt,
84108 doneAt: null,
85109 saved: false,
107131 assert.equal(emptyFor("needs").title, "Nothing needs you");
108132 assert.equal(emptyFor("all", "saved").title, "Nothing saved");
109133 });
134+
135+test("every reason has words, a filter and an email choice", () => {
136+ const reasons = Object.keys(REASON_LABEL);
137+ assert.equal(reasons.length, 11);
138+ assert.deepEqual(REASON_FILTERS.slice(1).map((entry) => entry.reason), reasons);
139+ assert.equal(REASON_FILTERS[0].label, "Any reason");
140+ assert.equal(REASON_FILTERS.find((entry) => entry.reason === "review_requested")?.label, "Review requested");
141+ // Every reason someone can be told for can be emailed, but a security
142+ // alert, which nothing sends yet.
143+ assert.deepEqual(
144+ EMAIL_REASONS.map((entry) => entry.reason).sort(),
145+ reasons.filter((reason) => reason !== "security_alert").sort(),
146+ );
147+ assert.equal(inboxReason("mention"), "mention");
148+ assert.equal(inboxReason("gossip"), null);
149+ assert.equal(inboxReason(null), null);
150+});
151+
152+test("a thread says how much happened on it once more than one thing has", () => {
153+ assert.equal(updatesLabel(1), "");
154+ assert.equal(updatesLabel(null), "");
155+ assert.equal(updatesLabel(4), "4 updates");
156+});
157+
158+test("watching is read from the menu, and a custom watch of nothing is the default", () => {
159+ const form = (fields: [string, string][]) => {
160+ const data = new FormData();
161+ for (const [name, value] of fields) data.append(name, value);
162+ return data;
163+ };
164+ assert.deepEqual(watchFromForm(form([["level", "all"]])), { level: "all", events: [] });
165+ assert.deepEqual(watchFromForm(form([["level", "custom"], ["event", "deployments"], ["event", "pulls"], ["event", "releases"]])), {
166+ level: "custom",
167+ events: ["pulls", "deployments"],
168+ });
169+ assert.deepEqual(watchFromForm(form([["level", "custom"]])), { level: "participating", events: [] });
170+ assert.equal(watchFromForm(form([["level", "loud"]])), null);
171+ assert.equal(watchLabel("all"), "Watching");
172+ assert.equal(watchLabel("ignore"), "Ignoring");
173+ assert.equal(watchLabel("participating"), "Watch");
174+});
175+
176+test("the subscribe button says whether, and why", () => {
177+ const sub = (changes: object) => ({ subscribed: true, ignored: false, reason: null, repo: null, number: 7, updatedAt: null, ...changes });
178+ assert.equal(subscriptionLine(sub({ reason: "assign" }), "issue"), "You're subscribed because you were assigned.");
179+ assert.equal(subscriptionLine(sub({ reason: "author" }), "pull"), "You're subscribed because you opened this pull request, or asked g1t for it.");
180+ assert.match(subscriptionLine(sub({ subscribed: false }), "issue"), /still hear if you're mentioned/);
181+ assert.match(subscriptionLine(sub({ subscribed: false, ignored: true }), "issue"), /ignore this issue/);
182+ assert.match(subscriptionLine(null, "issue"), /^Subscribe/);
183+ const intent = (value: string) => {
184+ const data = new FormData();
185+ data.set("intent", value);
186+ return subscriptionFromForm(data);
187+ };
188+ assert.deepEqual(intent("subscribe"), { subscribed: true, ignored: false });
189+ assert.deepEqual(intent("unsubscribe"), { subscribed: false, ignored: false });
190+ assert.deepEqual(intent("ignore"), { subscribed: false, ignored: true });
191+ assert.deepEqual(intent("default"), { subscribed: null, ignored: false });
192+ assert.equal(intent("explode"), null);
193+});
194+
195+test("email reasons are read from the settings form in rank order", () => {
196+ const data = new FormData();
197+ for (const value of ["mention", "nope", "agent", "mention"]) data.append("email", value);
198+ assert.deepEqual(emailReasonsFromForm(data), ["agent", "mention"]);
199+ assert.deepEqual(emailReasonsFromForm(new FormData()), []);
200+});
+169−7
11 /**
2− * The inbox's tabs, times and actions, shared by the panel in the top bar
3− * (components/inbox.tsx) and the page at /inbox (routes/inbox.tsx). The
4− * events service keeps the items and decides who is told of what.
2+ * The inbox's tabs, reasons, times and actions, shared by the panel in the
3+ * top bar (components/inbox.tsx) and the page at /inbox (routes/inbox.tsx),
4+ * and how people subscribe to threads and watch repositories
5+ * (components/notifications.tsx). The events service keeps the items and
6+ * decides who is told of what.
57 */
6−import type { InboxCounts, InboxItem, InboxMarkArgs, InboxSeverity, InboxView } from "@g1t/contracts";
8+import type {
9+ InboxCounts,
10+ InboxItem,
11+ InboxMarkArgs,
12+ InboxReason,
13+ InboxSeverity,
14+ InboxView,
15+ ThreadSubscription,
16+ WatchEvent,
17+ WatchLevel,
18+} from "@g1t/contracts";
19+
20+// The contracts' lists, as types only, so this file runs under `node --test`.
21+// Typed by the contract: a reason or kind added there must be added here.
22+const INBOX_REASONS: InboxReason[] = [
23+ "agent",
24+ "review_requested",
25+ "assign",
26+ "mention",
27+ "ci_activity",
28+ "security_alert",
29+ "state_change",
30+ "author",
31+ "comment",
32+ "manual",
33+ "subscribed",
34+];
35+const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"];
736
837 export type InboxTab = "all" | "needs" | "error" | "success" | "info";
938
1039 /** The tabs, in order, and the severity each shows. */
1140 export const INBOX_TABS: { tab: InboxTab; label: string; severity: InboxSeverity | null }[] = [
1241 { tab: "all", label: "All", severity: null },
13− // Warnings are what an agent is waiting on a person for.
42+ // Warnings are what is waiting on a person: an agent, or a review asked of them.
1443 { tab: "needs", label: "Needs you", severity: "warning" },
1544 { tab: "error", label: "Errors", severity: "error" },
1645 { tab: "success", label: "Success", severity: "success" },
5685 if (view === "done") return { title: "Nothing done yet", detail: "Items you mark done move here." };
5786 switch (tab) {
5887 case "needs":
59− return { title: "Nothing needs you", detail: "When an agent is waiting on you, it shows up here first." };
88+ return { title: "Nothing needs you", detail: "When an agent is waiting on you, or someone asks for your review, it shows up here first." };
6089 case "error":
6190 return { title: "No failures", detail: "Failed checks and workflows on your work show up here." };
6291 case "success":
136165 const rank = (item: InboxItem) => (item.severity === "warning" ? 0 : 1);
137166 const needs = items
138167 .filter((item) => isUnread(item) && (item.severity === "warning" || item.severity === "error"))
139− .sort((a, b) => rank(a) - rank(b) || b.createdAt.localeCompare(a.createdAt));
168+ .sort((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt));
140169 return { items: needs.slice(0, max), total: needs.length };
141170 }
142171
144173 export function isUnread(item: Pick<InboxItem, "readAt">): boolean {
145174 return item.readAt == null;
146175 }
176+
177+/** Why someone was told, in a few quiet words on the card. */
178+export const REASON_LABEL: Record<InboxReason, string> = {
179+ agent: "agent waiting",
180+ review_requested: "review requested",
181+ assign: "assigned",
182+ mention: "mentioned",
183+ ci_activity: "CI activity",
184+ security_alert: "security alert",
185+ state_change: "state changed",
186+ author: "your work",
187+ comment: "commented",
188+ manual: "subscribed",
189+ subscribed: "watching",
190+};
191+
192+/** The reason filter on /inbox, from the address: null for any reason. */
193+export function inboxReason(value: string | null | undefined): InboxReason | null {
194+ return INBOX_REASONS.find((reason) => reason === value) ?? null;
195+}
196+
197+/** The reason filter's choices, in the order reasons rank. */
198+export const REASON_FILTERS: { reason: InboxReason | null; label: string }[] = [
199+ { reason: null, label: "Any reason" },
200+ ...INBOX_REASONS.map((reason) => ({ reason, label: REASON_LABEL[reason].replace(/^./, (c) => c.toUpperCase()) })),
201+];
202+
203+/** How much has happened on a thread, when more than one thing has: "3 updates". */
204+export function updatesLabel(count: number | null | undefined): string {
205+ return count && count > 1 ? `${count} updates` : "";
206+}
207+
208+/** The ways to watch a repository, in the order the menu shows them. */
209+export const WATCH_CHOICES: { level: WatchLevel; label: string; detail: string }[] = [
210+ { level: "participating", label: "Participating and @mentions", detail: "Only what you take part in, or are mentioned in." },
211+ { level: "all", label: "All activity", detail: "Every issue and pull request, and every deployment." },
212+ { level: "ignore", label: "Ignore", detail: "Nothing at all, not even a mention." },
213+ { level: "custom", label: "Custom", detail: "What you take part in, and the kinds you choose." },
214+];
215+
216+/** The kinds a custom watch can follow, with their names for people. */
217+export const WATCH_EVENT_LABEL: Record<WatchEvent, string> = {
218+ issues: "Issues",
219+ pulls: "Pull requests",
220+ deployments: "Deployments",
221+ security: "Security alerts",
222+};
223+
224+/** The Watch button's words for how someone watches. */
225+export function watchLabel(level: WatchLevel | null | undefined): string {
226+ switch (level) {
227+ case "all":
228+ return "Watching";
229+ case "custom":
230+ return "Watching some";
231+ case "ignore":
232+ return "Ignoring";
233+ default:
234+ return "Watch";
235+ }
236+}
237+
238+/**
239+ * A posted watch form as the level and kinds it asks for: `level`, and for
240+ * `custom` the `event` fields checked. A custom watch with nothing checked
241+ * is participating. Null when the level is not one.
242+ */
243+export function watchFromForm(form: FormData): { level: WatchLevel; events: WatchEvent[] } | null {
244+ const level = String(form.get("level") ?? "");
245+ if (!["participating", "all", "ignore", "custom"].includes(level)) return null;
246+ if (level !== "custom") return { level: level as WatchLevel, events: [] };
247+ const checked = form.getAll("event").map(String);
248+ const events = WATCH_EVENTS.filter((event) => checked.includes(event));
249+ return events.length > 0 ? { level: "custom", events } : { level: "participating", events: [] };
250+}
251+
252+/** The line under the subscribe button: whether, and why. */
253+export function subscriptionLine(subscription: ThreadSubscription | null | undefined, kind: "issue" | "pull"): string {
254+ const thing = kind === "pull" ? "pull request" : "issue";
255+ if (!subscription) return `Subscribe to hear of what happens on this ${thing}.`;
256+ if (subscription.ignored) return `You ignore this ${thing}: you hear of nothing on it, not even a mention.`;
257+ if (!subscription.subscribed) return "You're not subscribed. You'll still hear if you're mentioned or asked to review.";
258+ switch (subscription.reason) {
259+ case "author":
260+ return `You're subscribed because you opened this ${thing}, or asked g1t for it.`;
261+ case "assign":
262+ return "You're subscribed because you were assigned.";
263+ case "review_requested":
264+ return "You're subscribed because you were asked to review.";
265+ case "comment":
266+ return "You're subscribed because you commented.";
267+ case "mention":
268+ return "You're subscribed because you were mentioned.";
269+ default:
270+ return `You're subscribed to this ${thing}.`;
271+ }
272+}
273+
274+/** What a posted subscription form asks for: subscribe, unsubscribe, ignore, or the default. */
275+export function subscriptionFromForm(form: FormData): { subscribed: boolean | null; ignored: boolean } | null {
276+ switch (String(form.get("intent") ?? "")) {
277+ case "subscribe":
278+ return { subscribed: true, ignored: false };
279+ case "unsubscribe":
280+ return { subscribed: false, ignored: false };
281+ case "ignore":
282+ return { subscribed: false, ignored: true };
283+ case "default":
284+ return { subscribed: null, ignored: false };
285+ default:
286+ return null;
287+ }
288+}
289+
290+/** The reasons someone can be emailed for, in the settings' order, with what each is. */
291+export const EMAIL_REASONS: { reason: InboxReason; label: string; detail: string }[] = [
292+ { reason: "agent", label: "An agent is waiting on you", detail: "It asked you something, or stopped until you step in." },
293+ { reason: "review_requested", label: "You're asked to review", detail: "Someone asked for your review of a pull request." },
294+ { reason: "mention", label: "You're mentioned", detail: "Someone wrote your @username in a comment." },
295+ { reason: "assign", label: "You're assigned", detail: "Someone assigned you an issue or a pull request." },
296+ { reason: "ci_activity", label: "Your work's checks and deployments", detail: "Checks, a workflow or a deployment failed on your work." },
297+ { reason: "state_change", label: "What you follow closes or merges", detail: "An issue or pull request you're subscribed to was closed, reopened or merged." },
298+ { reason: "author", label: "News on your work", detail: "An approval, changes asked for, or g1t finishing what you asked for." },
299+ { reason: "comment", label: "Conversations you're in", detail: "Comments on issues and pull requests you commented on." },
300+ { reason: "manual", label: "Threads you subscribed to", detail: "Activity on issues and pull requests you subscribed to by hand." },
301+ { reason: "subscribed", label: "Repositories you watch", detail: "Activity in repositories you watch." },
302+];
303+
304+/** The reasons checked on the settings form, each once, in rank order. */
305+export function emailReasonsFromForm(form: FormData): InboxReason[] {
306+ const checked = form.getAll("email").map(String);
307+ return INBOX_REASONS.filter((reason) => checked.includes(reason));
308+}
+3−0
2727 index("routes/settings/index.tsx"),
2828 route("profile", "routes/settings/profile.tsx"),
2929 route("emails", "routes/settings/emails.tsx"),
30+ route("notifications", "routes/settings/notifications.tsx"),
3031 route("invites", "routes/settings/invites.tsx"),
3132 route("keys", "routes/settings/keys.tsx"),
3233 route("tokens", "routes/settings/tokens.tsx"),
9394 route(":owner/:repo/audit.json", "routes/repo/audit-live.ts"),
9495 // An invitation to a repository, answered by someone who cannot see it yet.
9596 route(":owner/:repo/invitations", "routes/repo/invitations.tsx"),
97+ // Subscribing to its issues and pull requests, and watching it.
98+ route(":owner/:repo/notifications", "routes/repo/notifications.ts"),
9699 // A starter CI workflow, opened as a pull request (components/add-ci.tsx).
97100 route(":owner/:repo/add-ci", "routes/repo/add-ci.ts"),
98101 // A screenshot of a project's production, for its overview.
+25−8
88
99 import type { Route } from "./+types/inbox";
1010 import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox";
11+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
1112 import { cn } from "../lib/cn";
12−import { inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox";
13+import { REASON_FILTERS, inboxReason, inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox";
1314 import { page } from "../lib/meta";
1415 import { inbox } from "../lib/services.server";
1516 import { assertSameOrigin, requireUser } from "../lib/session.server";
2526 const url = new URL(request.url);
2627 const tab = inboxTab(url.searchParams.get("tab"));
2728 const view = inboxView(url.searchParams.get("view"));
29+ const reason = inboxReason(url.searchParams.get("reason"));
2830 const before = url.searchParams.get("before");
2931 const [list, counts] = await Promise.all([
30− inbox.list(user, { view, severity: severityOf(tab), before, limit: PAGE_ITEMS }).catch(() => null),
32+ inbox.list(user, { view, severity: severityOf(tab), reason, before, limit: PAGE_ITEMS }).catch(() => null),
3133 inbox.counts(user.username).catch(() => null),
3234 ]);
33− return { tab, view, before, items: list?.items ?? null, next: list?.next ?? null, counts };
35+ return { tab, view, reason, before, items: list?.items ?? null, next: list?.next ?? null, counts };
3436 }
3537
3638 export async function action({ request, context }: Route.ActionArgs) {
5355 ] as const;
5456
5557 export default function InboxPage({ loaderData }: Route.ComponentProps) {
56− const { tab, view, before, items, next, counts } = loaderData;
58+ const { tab, view, reason, before, items, next, counts } = loaderData;
5759 const navigate = useNavigate();
5860 const address = (changes: Record<string, string | null>) => {
5961 const params = new URLSearchParams();
60− const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, ...changes };
62+ const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, reason, ...changes };
6163 for (const [name, value] of Object.entries(merged)) if (value) params.set(name, value);
6264 const query = params.toString();
6365 return query ? `/inbox?${query}` : "/inbox";
6870 <div className="flex flex-wrap items-center justify-between gap-3">
6971 <div>
7072 <h1 className="text-2xl font-semibold tracking-tight">Inbox</h1>
71− <p className="mt-1 text-sm text-muted">What needs you, and what you follow. What an agent is waiting on comes first.</p>
73+ <p className="mt-1 text-sm text-muted">What needs you, and what you follow. What is waiting on you comes first.</p>
7274 </div>
7375 {view === "inbox" && <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />}
7476 </div>
9092 ))}
9193 </nav>
9294
93− <div className="mt-4">
94− <InboxTabs tab={tab} counts={view === "inbox" ? counts : null} onChange={(next) => navigate(address({ tab: next === "all" ? null : next, before: null }))} />
95+ <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
96+ <div className="min-w-0 grow">
97+ <InboxTabs tab={tab} counts={view === "inbox" ? counts : null} onChange={(next) => navigate(address({ tab: next === "all" ? null : next, before: null }))} />
98+ </div>
99+ {/* Why you were told: a review asked of you, a mention, what you watch. */}
100+ <Select value={reason ?? "any"} onValueChange={(value) => navigate(address({ reason: value === "any" ? null : value, before: null }))}>
101+ <SelectTrigger size="sm" aria-label="Reason" className="sm:w-44">
102+ <SelectValue />
103+ </SelectTrigger>
104+ <SelectContent align="end">
105+ {REASON_FILTERS.map((entry) => (
106+ <SelectItem key={entry.reason ?? "any"} value={entry.reason ?? "any"}>
107+ {entry.label}
108+ </SelectItem>
109+ ))}
110+ </SelectContent>
111+ </Select>
95112 </div>
96113
97114 <div className="mt-4">
+14−3
3737 import { notFound } from "../../lib/not-found.server";
3838 import { openedBy } from "../../lib/opened-by";
3939 import { computeNoteFor } from "../../lib/compute.server";
40−import { identity, integrations, work } from "../../lib/services.server";
40+import { identity, inbox, integrations, work } from "../../lib/services.server";
4141 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
42−import { accessTo, refusal } from "../../lib/access.server";
42+import { accessTo, refusal, repoFor } from "../../lib/access.server";
43+import { SubscriptionBox } from "../../components/notifications";
4344 import { useRefreshWhile } from "../../lib/refresh";
4445
4546
6667 // At once: only the plan's note waits for the viewer's role. Putting an
6768 // agent on it needs Write: Read cannot spend compute.
6869 const access = accessTo(context, params);
69− const [{ can }, found, labels, agentsEnabled, members, links, computeNote] = await Promise.all([
70+ const [{ can }, found, labels, agentsEnabled, members, links, computeNote, subscription] = await Promise.all([
7071 access,
7172 work.getIssue(path, number, viewer),
7273 work.listLabels(path, viewer),
7778 integrations.links(path, number).catch(() => []),
7879 // Before a member assigns g1t: whether the workspace's plan lets it start.
7980 access.then(({ can }) => (can.run ? computeNoteFor(params.owner, "agent") : null)),
81+ // Whether the viewer hears of it, for the sidebar's Notifications.
82+ viewer
83+ ? repoFor(context, params).then((repo) =>
84+ repo.ok ? inbox.subscription(viewer, { repoId: repo.value.id, number }).catch(() => null) : null,
85+ )
86+ : null,
8087 ]);
8188 if (!found.ok) {
8289 // Issues and pull requests share numbers; this one may be a pull request.
9299 agentsEnabled,
93100 computeNote,
94101 links,
102+ subscription,
95103 members: members?.ok ? members.value.map((member) => member.username) : [],
96104 // The author can close and reopen their own issue, and whoever g1t's
97105 // agent filed one for, that one; Triage and up, anyone's.
638646 </details>
639647 )}
640648
649+ {viewer && (
650+ <SubscriptionBox action={`${base}/notifications`} number={issue.number} kind="issue" subscription={loaderData.subscription} />
651+ )}
641652 </aside>
642653 </div>
643654 );
+22−5
11 import { Box, Lock } from "lucide-react";
2+import type { ReactNode } from "react";
23 import { Link, NavLink, Outlet, type ShouldRevalidateFunctionArgs, data, useLocation, useRouteLoaderData } from "react-router";
34
45 import type { Project } from "@g1t/contracts";
89 import { page } from "../../lib/meta";
910 import { type Tab as PageTab, tabsFor } from "../../lib/project-nav";
1011 import { Pill } from "../../components/ui";
12+import { WatchMenu } from "../../components/notifications";
1113 import { ArchivedBanner } from "../../components/repo-lifecycle";
1214 import { WelcomeBanner } from "../../components/welcome";
1315 import { clearWelcome, welcomes } from "../../lib/invites";
1416 import { notFound } from "../../lib/not-found.server";
1517 import { redirectIfRenamed, redirectIfTransferred } from "../../lib/renamed.server";
1618 import { accessFor, countsFor, repoFor } from "../../lib/access.server";
17−import { projects } from "../../lib/services.server";
19+import { inbox, projects } from "../../lib/services.server";
1820 import { getViewer, unwrap } from "../../lib/session.server";
1921
2022 export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
2426 export async function loader({ params, context, request }: Route.LoaderArgs) {
2527 const viewer = getViewer(context);
2628 const path = { namespace: params.owner, name: params.repo };
27− const [repo, counts, found] = await Promise.all([
29+ const [repo, counts, found, watching] = await Promise.all([
2830 repoFor(context, params),
2931 countsFor(context, params),
3032 projects.get(params.owner, params.repo, viewer),
33+ // How the person watches it, for the header's Watch menu, as soon as
34+ // the repository is known.
35+ viewer
36+ ? repoFor(context, params).then((found) => (found.ok ? inbox.watching(viewer.username, found.value.id).catch(() => null) : null))
37+ : null,
3138 ]);
3239 if (!repo.ok && !found.ok) {
3340 // Under a workspace's old name, after a rename: the project is at the new one.
5663 open: counts.ok ? counts.value : { issues: 0, pulls: 0 },
5764 access,
5865 member: access.insider,
66+ watching,
5967 }, { headers });
6068 }
6169
7583 }
7684
7785 /** A repository's topics, each a way into Explore. */
78−function Header({ project, isPrivate, archived, namespace, name, description, large }: {
86+function Header({ project, isPrivate, archived, namespace, name, description, large, actions }: {
7987 project: Project | null;
8088 isPrivate: boolean;
8189 namespace: string;
8391 description: string | null;
8492 archived?: boolean;
8593 large?: boolean;
94+ /** At the end of the row: the Watch menu. */
95+ actions?: ReactNode;
8696 }) {
8797 const base = `/${namespace}/${name}`;
8898 return (
103113 </h1>
104114 <Pill>{isPrivate ? "private" : "public"}</Pill>
105115 {archived && <Pill>archived</Pill>}
106− {description && <p className="min-w-0 truncate text-sm text-muted">{description}</p>}
116+ {/* On a phone, on its own line under the name and the Watch menu. */}
117+ {description && <p className="order-last min-w-0 basis-full truncate text-sm text-muted sm:order-none sm:basis-0 sm:flex-1">{description}</p>}
118+ {actions && <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div>}
107119 </div>
108120 );
109121 }
149161 }
150162
151163 export default function ProjectLayout({ loaderData }: Route.ComponentProps) {
152− const { repo, project, member, access, welcome } = loaderData;
164+ const { repo, project, member, access, welcome, watching } = loaderData;
153165 const base = `/${repo.namespace}/${repo.name}`;
154166 // The project's own description, else the repository's as it is now.
155167 const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description;
171183 name={repo.name}
172184 // The files' own About says it there, as the one place.
173185 description={filesPage ? null : description}
186+ actions={
187+ watching ? (
188+ <WatchMenu action={`${base}/notifications`} level={watching.level} events={watching.events} />
189+ ) : null
190+ }
174191 />
175192 {/* On the files' pages, About shows them. */}
176193 {!filesPage && <Topics topics={repo.topics} />}
+40−0
1+/**
2+ * What a person hears of from one repository: subscribing to one of its
3+ * issues or pull requests (`intent` subscribe, unsubscribe, ignore or
4+ * default, with its `number`), and watching it (`intent` watch, with
5+ * `level` and, for custom, the `event` kinds). Posted by the sidebar's
6+ * Notifications box and the header's Watch menu (components/notifications.tsx).
7+ * Only someone who can read the repository may choose.
8+ */
9+import { data } from "react-router";
10+
11+import type { Route } from "./+types/notifications";
12+import { subscriptionFromForm, watchFromForm } from "../../lib/inbox";
13+import { inbox, repos } from "../../lib/services.server";
14+import { assertSameOrigin, requireUser } from "../../lib/session.server";
15+
16+export async function action({ request, params, context }: Route.ActionArgs) {
17+ assertSameOrigin(request);
18+ const user = requireUser(context, request);
19+ const form = await request.formData();
20+ const repo = await repos.get({ namespace: params.owner, name: params.repo }, user);
21+ if (!repo.ok) return data({ error: "That repository could not be found." }, { status: 404 });
22+ const path = `${repo.value.namespace}/${repo.value.name}`;
23+ try {
24+ if (form.get("intent") === "watch") {
25+ const watch = watchFromForm(form);
26+ if (!watch) return data({ error: "Choose how to watch it." }, { status: 400 });
27+ // Participating is the default: no choice to keep.
28+ const level = watch.level === "participating" ? null : watch.level;
29+ return { error: null, watching: await inbox.watch(user.username, repo.value.id, path, level, watch.events) };
30+ }
31+ const choice = subscriptionFromForm(form);
32+ const number = Number(form.get("number"));
33+ if (!choice || !Number.isInteger(number) || number < 1) return data({ error: "Nothing to do." }, { status: 400 });
34+ const subscription = await inbox.subscribe(user, { repoId: repo.value.id, number }, choice.subscribed, choice.ignored);
35+ if (!subscription) return data({ error: "That issue or pull request could not be found." }, { status: 404 });
36+ return { error: null, subscription };
37+ } catch {
38+ return data({ error: "That did not save. Try again in a moment." }, { status: 503 });
39+ }
40+}
+14−3
7575 import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up";
7676 import { notFound } from "../../lib/not-found.server";
7777 import { computeNoteFor } from "../../lib/compute.server";
78−import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server";
78+import { actions, deployments, identity, inbox, projects, repos, work } from "../../lib/services.server";
7979 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
80−import { accessTo, refusal } from "../../lib/access.server";
80+import { accessTo, refusal, repoFor } from "../../lib/access.server";
81+import { SubscriptionBox } from "../../components/notifications";
8182 import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh";
8283
8384 const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
123124 const deps = projects.dependencies(params.owner, params.repo, viewer);
124125 // Awaited below, unless the pull request is missing first.
125126 deps.catch(() => null);
126− const [{ can }, found, repo, settings, agentsEnabled, members, computeNote, session, deployed] = await Promise.all([
127+ const [{ can }, found, repo, settings, agentsEnabled, members, computeNote, session, deployed, subscription] = await Promise.all([
127128 access,
128129 pullFound,
129130 repos.get(path, viewer),
136137 tab === "session" ? work.readSession(path, number, viewer) : null,
137138 // Its preview, for people with a role here: beside the rest, not after.
138139 access.then(({ insider }) => (insider ? deployments.list(ref, viewer).catch(() => null) : null)),
140+ // Whether the viewer hears of it, for the sidebar's Notifications.
141+ viewer
142+ ? repoFor(context, params).then((found) =>
143+ found.ok ? inbox.subscription(viewer, { repoId: found.value.id, number }).catch(() => null) : null,
144+ )
145+ : null,
139146 ]);
140147 if (!found.ok) {
141148 // Issues and pull requests share numbers; this one may be an issue.
197204 canUpdate: pull.fork ? viewer?.id === workOwner(pull).id : can.push,
198205 agentsEnabled,
199206 computeNote,
207+ subscription,
200208 members: members?.ok ? members.value.map((person) => person.username) : [],
201209 requireUpToDate: settings.ok && settings.value.requireUpToDate,
202210 mergeQueue: settings.ok && settings.value.mergeQueue,
13431351 {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
13441352 </p>
13451353 </section>
1354+ {loaderData.viewer && (
1355+ <SubscriptionBox action={`${base}/notifications`} number={pull.number} kind="pull" subscription={loaderData.subscription} />
1356+ )}
13461357 </aside>
13471358 </div>
13481359 </div>
+185−0
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+ */
7+import { Eye, EyeOff } from "lucide-react";
8+import { Form, Link, data, useNavigation } from "react-router";
9+
10+import type { WatchLevel } from "@g1t/contracts";
11+
12+import type { Route } from "./+types/notifications";
13+import { ErrorText, SubmitButton } from "../../components/ui";
14+import { CheckboxOption } from "../../components/ui/checkbox";
15+import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
16+import { EMAIL_REASONS, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm } from "../../lib/inbox";
17+import { page } from "../../lib/meta";
18+import { inbox } from "../../lib/services.server";
19+import { assertSameOrigin, requireUser } from "../../lib/session.server";
20+
21+export function meta(args: Route.MetaArgs) {
22+ return page(args, { title: "Notifications · Settings · g1t" });
23+}
24+
25+export 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+
34+export 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+
66+const LEVEL_LABEL: Record<WatchLevel, string> = Object.fromEntries(WATCH_CHOICES.map((choice) => [choice.level, choice.label])) as Record<
67+ WatchLevel,
68+ string
69+>;
70+
71+export 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+}