Skip to content

g1t/apps/web/app/components/notifications.tsx

178 lines8,235 bytesCodeBlame
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
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
68const 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 */
75export 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}