Skip to content
174 linesCodeBlameRaw
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 */
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} />
53 <SubmitButton variant="outline" size="sm" fetcher={fetcher} className="w-full">
54 {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}