Skip to content
174 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings1/**
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 */
7import { Bell, BellOff, Check, ChevronDown, Eye, EyeOff } from "lucide-react";
8import { useState } from "react";
9import { useFetcher } from "react-router";
10
11import { WATCH_EVENTS, type ThreadSubscription, type WatchEvent, type WatchLevel } from "@g1t/contracts";
12
13import { SubmitButton } from "./ui";
14import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
15import { cn } from "../lib/cn";
16import { 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 */
24export 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} />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.53 <SubmitButton variant="outline" size="sm" fetcher={fetcher} className="w-full">
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings54 {intent === "subscribe" ? <Bell size={14} /> : <BellOff size={14} />}
55 {label}
56 </SubmitButton>
57 </fetcher.Form>
58 <p className="mt-2 text-xs text-muted">{subscriptionLine(shown, kind)}</p>
59 {fetcher.data?.error && <p className="mt-1 text-xs text-danger">{fetcher.data.error}</p>}
60 </section>
61 );
62}
63
64const EMPTY: ThreadSubscription = { subscribed: false, ignored: false, reason: null, repo: null, number: null, updatedAt: null };
65
66/**
67 * The repository header's Watch menu: participating and @mentions (the
68 * default), all activity, ignore, or custom, with the kinds a custom watch
69 * follows checked under it. Each choice is saved as it is made.
70 */
71export function WatchMenu({ action, level, events }: { action: string; level: WatchLevel; events: WatchEvent[] }) {
72 const fetcher = useFetcher<{ error?: string }>();
73 // The choice on its way, as it will be.
74 const pendingLevel = fetcher.formData?.get("level");
75 const shownLevel = (typeof pendingLevel === "string" ? pendingLevel : level) as WatchLevel;
76 const shownEvents = fetcher.formData ? (fetcher.formData.getAll("event").map(String) as WatchEvent[]) : events;
77 // Custom opened to choose, before anything is chosen.
78 const [choosing, setChoosing] = useState(false);
79 const custom = shownLevel === "custom" || choosing;
80
81 const save = (next: WatchLevel, kinds: WatchEvent[] = []) => {
82 const form = new FormData();
83 form.set("intent", "watch");
84 form.set("level", next);
85 for (const kind of kinds) form.append("event", kind);
86 fetcher.submit(form, { method: "post", action });
87 };
88 const toggle = (kind: WatchEvent) => {
89 const had = shownLevel === "custom" ? shownEvents : [];
90 const next = had.includes(kind) ? had.filter((event) => event !== kind) : [...had, kind];
91 const ordered = WATCH_EVENTS.filter((event) => next.includes(event));
92 // Nothing left to follow is the default.
93 save(ordered.length > 0 ? "custom" : "participating", ordered);
94 };
95
96 return (
97 <DropdownMenu onOpenChange={(open) => !open && setChoosing(false)}>
98 <DropdownMenuTrigger
99 aria-label={`Watch: ${WATCH_CHOICES.find((choice) => choice.level === shownLevel)?.label ?? "Participating and @mentions"}`}
100 className={cn(
101 "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",
102 fetcher.state !== "idle" && "opacity-70",
103 )}
104 >
105 {shownLevel === "ignore" ? <EyeOff size={14} /> : <Eye size={14} />}
106 {/* The eye says it on a phone; the aria-label says it in full. */}
107 <span className="hidden sm:inline">{watchLabel(shownLevel)}</span>
108 <ChevronDown size={13} className="text-faint" />
109 </DropdownMenuTrigger>
110 <DropdownMenuContent align="end" className="w-72">
111 <DropdownMenuLabel>Notifications from this repository</DropdownMenuLabel>
112 {WATCH_CHOICES.map((choice) => {
113 const selected = choice.level === "custom" ? custom : !choosing && shownLevel === choice.level;
114 return (
115 <DropdownMenuItem
116 key={choice.level}
117 onSelect={(event) => {
118 if (choice.level === "custom") {
119 // Stays open, to choose what to follow.
120 event.preventDefault();
121 setChoosing(true);
122 return;
123 }
124 setChoosing(false);
125 if (choice.level !== shownLevel) save(choice.level);
126 }}
127 className="items-start"
128 >
129 <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
130 {selected && <Check size={14} className="!text-accent" />}
131 </span>
132 <span className="min-w-0">
133 <span className="block text-sm text-fg">{choice.label}</span>
134 <span className="block text-xs text-muted">{choice.detail}</span>
135 </span>
136 </DropdownMenuItem>
137 );
138 })}
139 {custom && (
140 <>
141 <DropdownMenuSeparator />
142 <DropdownMenuLabel>Also tell me about</DropdownMenuLabel>
143 {WATCH_EVENTS.map((kind) => {
144 const on = shownLevel === "custom" && shownEvents.includes(kind);
145 return (
146 <DropdownMenuItem
147 key={kind}
148 role="menuitemcheckbox"
149 aria-checked={on}
150 onSelect={(event) => {
151 event.preventDefault();
152 toggle(kind);
153 }}
154 >
155 <span
156 aria-hidden="true"
157 className={cn(
158 "flex size-4 shrink-0 items-center justify-center rounded border",
159 on ? "border-accent bg-accent text-bg" : "border-line-strong",
160 )}
161 >
162 {on && <Check size={11} className="!text-bg" />}
163 </span>
164 {WATCH_EVENT_LABEL[kind]}
165 </DropdownMenuItem>
166 );
167 })}
168 </>
169 )}
170 {fetcher.data?.error && <p className="px-2 py-1.5 text-xs text-danger">{fetcher.data.error}</p>}
171 </DropdownMenuContent>
172 </DropdownMenu>
173 );
174}