Skip to content
375 linesCodeBlameRaw
1import { AlarmClock, Bell, Bookmark, Check, CheckCheck, CircleCheck, CircleX, Ellipsis, Hand, Info, Mail, MailOpen, Undo2 } from "lucide-react";
2import { type ReactNode, useEffect, useState } from "react";
3import { Link, useFetcher } from "react-router";
4
5import type { InboxCounts, InboxItem, InboxSeverity } from "@g1t/contracts";
6
7import { WaitingCards } from "./notifications/card-actions";
8import { SubmitButton } from "./ui";
9import { Badge, type BadgeTone } from "./ui/badge";
10import { Button } from "./ui/button";
11import { Hint } from "./ui/hint";
12import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
13import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
14import { Skeleton } from "./ui/skeleton";
15import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
16import { cn } from "../lib/cn";
17import { isPending } from "../lib/pending";
18import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, updatesLabel, whenShort } from "../lib/inbox";
19import type { InboxPanelData } from "../routes/inbox-json";
20
21/** Where every notifications form posts (routes/notifications.tsx). */
22const ACTION = "/notifications";
23/** How often the bell asks for its count while the page is in view. */
24const COUNT_EVERY_MS = 60_000;
25
26const TONE: Record<InboxSeverity, BadgeTone> = {
27 error: "danger",
28 warning: "warn",
29 success: "accent",
30 info: "info",
31};
32
33const ICON: Record<InboxSeverity, { icon: ReactNode; ring: string }> = {
34 error: { icon: <CircleX size={15} />, ring: "bg-danger/10 text-danger ring-danger/30" },
35 warning: { icon: <Hand size={15} />, ring: "bg-warn/10 text-warn ring-warn/30" },
36 success: { icon: <CircleCheck size={15} />, ring: "bg-success/10 text-success ring-success/30" },
37 info: { icon: <Info size={15} />, ring: "bg-info/10 text-info ring-info/30" },
38};
39
40/** The round mark that says what kind of item it is. */
41function SeverityMark({ severity }: { severity: InboxSeverity }) {
42 const { icon, ring } = ICON[severity];
43 return (
44 <span aria-hidden="true" className={cn("mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full ring-1", ring)}>
45 {icon}
46 </span>
47 );
48}
49
50/** Hidden fields naming one item's action. */
51function Fields({ intent, id, extra }: { intent: string; id: string; extra?: Record<string, string> }) {
52 return (
53 <>
54 <input type="hidden" name="intent" value={intent} />
55 <input type="hidden" name="id" value={id} />
56 {Object.entries(extra ?? {}).map(([name, value]) => (
57 <input key={name} type="hidden" name={name} value={value} />
58 ))}
59 </>
60 );
61}
62
63/**
64 * One item: what happened, to what, when, and how much it matters. The
65 * whole card opens what it is about and marks it read; Done is at hand,
66 * and the menu has the rest. Read items are dimmed. While Done or Snooze
67 * is on its way the card steps out of the list.
68 */
69export function InboxCard({ item, onOpen }: { item: InboxItem; onOpen?: () => void }) {
70 const fetcher = useFetcher<{ error?: string }>();
71 const unread = isUnread(item);
72 const leaving = isPending(fetcher, { id: item.id }) && ["done", "snooze", "undone"].includes(String(fetcher.formData?.get("intent")));
73 const [now, setNow] = useState(() => Date.now());
74 useEffect(() => setNow(Date.now()), [item.updatedAt]);
75 if (leaving) return null;
76
77 const submit = (intent: string, extra?: Record<string, string>) =>
78 fetcher.submit({ intent, id: item.id, ...extra }, { method: "post", action: ACTION });
79 const open = () => {
80 if (unread) submit("read");
81 onOpen?.();
82 };
83
84 return (
85 <li
86 className={cn(
87 "group relative flex gap-3 rounded-lg border border-line bg-bg/40 p-3 transition-colors hover:border-line-strong hover:bg-raised/50",
88 !unread && "opacity-60 hover:opacity-100",
89 )}
90 >
91 <SeverityMark severity={item.severity} />
92 <div className="min-w-0 flex-1">
93 <Link
94 to={item.url}
95 onClick={open}
96 className="block truncate pr-14 text-sm font-semibold text-fg outline-none [@media(hover:none)]:pr-24 after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-accent"
97 >
98 {item.title}
99 </Link>
100 {item.body && <p className="mt-0.5 truncate text-xs text-muted">{item.body}</p>}
101 <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-faint [@media(hover:none)]:pr-24">
102 <time dateTime={item.updatedAt} suppressHydrationWarning className="shrink-0 whitespace-nowrap">
103 {whenShort(item.updatedAt, now)}
104 </time>
105 <Badge tone={TONE[item.severity]} className="shrink-0">{SEVERITY_LABEL[item.severity]}</Badge>
106 {/* Why they were told, and how much has happened, said quietly. */}
107 <span className="truncate">{REASON_LABEL[item.reason] ?? item.reason}</span>
108 {updatesLabel(item.count) && (
109 <>
110 <span aria-hidden="true">·</span>
111 <span className="shrink-0 tabular-nums">{updatesLabel(item.count)}</span>
112 </>
113 )}
114 {item.saved && (
115 <span className="inline-flex items-center gap-1 text-faint">
116 <Bookmark size={12} aria-hidden="true" />
117 Saved
118 </span>
119 )}
120 </div>
121 {fetcher.data?.error && <p className="mt-1 text-xs text-danger">{fetcher.data.error}</p>}
122 </div>
123 {unread && <span aria-label="Unread" className="absolute top-3.5 right-3 size-2 rounded-full bg-accent group-focus-within:hidden group-hover:hidden [@media(hover:none)]:hidden" />}
124 {/* Above the card's link, so they act instead of opening it. On hover or focus, and always on a touch screen. */}
125 <div className="absolute top-2 right-2 z-10 hidden items-center gap-0.5 group-focus-within:flex group-hover:flex [@media(hover:none)]:flex">
126 {item.doneAt ? (
127 <fetcher.Form method="post" action={ACTION}>
128 <Fields intent="undone" id={item.id} />
129 <Hint label="Move back to Notifications">
130 <SubmitButton fetcher={fetcher} icon aria-label="Move back to Notifications" className={ICON_BUTTON}>
131 <Undo2 size={14} />
132 </SubmitButton>
133 </Hint>
134 </fetcher.Form>
135 ) : (
136 <fetcher.Form method="post" action={ACTION}>
137 <Fields intent="done" id={item.id} />
138 <Hint label="Done">
139 <SubmitButton fetcher={fetcher} icon aria-label="Done" className={ICON_BUTTON}>
140 <Check size={14} />
141 </SubmitButton>
142 </Hint>
143 </fetcher.Form>
144 )}
145 <DropdownMenu>
146 <DropdownMenuTrigger aria-label="More actions" className={ICON_BUTTON}>
147 <Ellipsis size={14} />
148 </DropdownMenuTrigger>
149 <DropdownMenuContent align="end">
150 <DropdownMenuItem onSelect={() => submit(unread ? "read" : "unread")}>
151 {unread ? <MailOpen /> : <Mail />}
152 {unread ? "Mark as read" : "Mark as unread"}
153 </DropdownMenuItem>
154 <DropdownMenuItem onSelect={() => submit(item.saved ? "unsave" : "save")}>
155 <Bookmark />
156 {item.saved ? "Unsave" : "Save"}
157 </DropdownMenuItem>
158 {!item.doneAt && (
159 <>
160 <DropdownMenuSeparator />
161 <DropdownMenuLabel>Snooze until</DropdownMenuLabel>
162 {SNOOZES.map((snooze) => (
163 <DropdownMenuItem key={snooze.key} onSelect={() => submit("snooze", { for: snooze.key })}>
164 <AlarmClock />
165 {snooze.label}
166 </DropdownMenuItem>
167 ))}
168 </>
169 )}
170 </DropdownMenuContent>
171 </DropdownMenu>
172 </div>
173 </li>
174 );
175}
176
177const ICON_BUTTON =
178 "flex size-7 max-sm:size-10 items-center justify-center rounded-md border border-line bg-surface text-muted transition-colors outline-none hover:border-line-strong hover:text-fg focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-60";
179
180/** What an empty list says, for its tab. */
181export function InboxEmpty({ tab, view }: { tab: InboxTab; view?: "inbox" | "saved" | "done" }) {
182 const { title, detail } = emptyFor(tab, view);
183 return (
184 <div className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-line px-6 py-12 text-center">
185 <Bell size={20} className="text-faint" aria-hidden="true" />
186 <p className="text-sm font-medium">{title}</p>
187 <p className="max-w-64 text-xs text-muted">{detail}</p>
188 </div>
189 );
190}
191
192/** The tab row, with what is unread under each. */
193export function InboxTabs({ tab, counts, onChange }: { tab: InboxTab; counts: InboxCounts | null; onChange: (tab: InboxTab) => void }) {
194 return (
195 <Tabs value={tab} onValueChange={(value) => onChange(value as InboxTab)}>
196 <TabsList className="flex w-full overflow-x-auto [scrollbar-width:none]">
197 {INBOX_TABS.map((entry) => {
198 const count = tabCount(counts, entry.tab);
199 return (
200 <TabsTrigger key={entry.tab} value={entry.tab} className="flex shrink-0 grow items-center justify-center gap-1.5">
201 {entry.label}
202 {count > 0 && (
203 <span
204 className={cn(
205 "rounded-full px-1.5 text-[0.6875rem] tabular-nums",
206 entry.tab === "needs" ? "bg-warn/15 text-warn" : "bg-line text-muted",
207 )}
208 >
209 {count}
210 </span>
211 )}
212 </TabsTrigger>
213 );
214 })}
215 </TabsList>
216 </Tabs>
217 );
218}
219
220/** Mark all read, for the tab being looked at. */
221export function MarkAllRead({ tab, disabled }: { tab: InboxTab; disabled: boolean }) {
222 const fetcher = useFetcher();
223 return (
224 <fetcher.Form method="post" action={ACTION}>
225 <input type="hidden" name="intent" value="read_all" />
226 <input type="hidden" name="tab" value={tab} />
227 <SubmitButton
228 fetcher={fetcher}
229 disabled={disabled}
230 pending="Marking…"
231 className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted"
232 >
233 <CheckCheck size={14} />
234 Mark all read
235 </SubmitButton>
236 </fetcher.Form>
237 );
238}
239
240/**
241 * The bell in the top bar, and the panel it opens from the right: the
242 * notifications's tabs and newest items, to work through without leaving the page.
243 * Its count comes with the page (root.tsx), and is asked for again every
244 * minute while the page is in view, and after anything is marked.
245 */
246export function NotificationsBell({ counts: loaded }: { counts: InboxCounts | null }) {
247 const [open, setOpen] = useState(false);
248 const [tab, setTab] = useState<InboxTab>("all");
249 const list = useFetcher<InboxPanelData>({ key: "inbox-panel" });
250 const poll = useFetcher<InboxPanelData>({ key: "inbox-count" });
251
252 useEffect(() => {
253 if (open) list.load(`/inbox.json?tab=${tab}`);
254 // eslint-disable-next-line react-hooks/exhaustive-deps
255 }, [open, tab]);
256 useEffect(() => {
257 const ask = () => {
258 if (document.visibilityState === "visible") poll.load("/inbox.json?only=counts");
259 };
260 const timer = setInterval(ask, COUNT_EVERY_MS);
261 window.addEventListener("focus", ask);
262 return () => {
263 clearInterval(timer);
264 window.removeEventListener("focus", ask);
265 };
266 // eslint-disable-next-line react-hooks/exhaustive-deps
267 }, []);
268
269 const counts = (open ? list.data?.counts : null) ?? poll.data?.counts ?? loaded;
270 const unread = counts?.unread ?? 0;
271 const items = list.data?.tab === tab ? list.data.items : null;
272 const shown = bellCount(unread);
273
274 return (
275 <Sheet open={open} onOpenChange={setOpen}>
276 <SheetTrigger asChild>
277 <Button variant="ghost" size="icon-sm" aria-label={unread ? `Notifications, ${unread} unread` : "Notifications"} className="relative">
278 <Bell size={17} />
279 {shown && (
280 // Amber while an agent waits on them, red for a failure, else the accent.
281 <span
282 className={cn(
283 "absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.625rem] font-semibold tabular-nums text-bg ring-2 ring-bg",
284 (counts?.warning ?? 0) > 0 ? "bg-warn" : (counts?.error ?? 0) > 0 ? "bg-danger" : "bg-accent",
285 )}
286 >
287 {shown}
288 </span>
289 )}
290 </Button>
291 </SheetTrigger>
292 <SheetContent
293 // Focus starts on the tab being looked at, not on Mark all read.
294 onOpenAutoFocus={(event) => {
295 event.preventDefault();
296 (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[role="tab"][data-state="active"]')?.focus();
297 }}
298 >
299 <SheetHeader className="flex-row items-center justify-between gap-3">
300 <SheetTitle>Notifications</SheetTitle>
301 <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />
302 </SheetHeader>
303 <div className="border-b border-line px-5 py-3">
304 <InboxTabs tab={tab} counts={counts} onChange={setTab} />
305 </div>
306 <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
307 {/* Chat cards waiting on you (live notifications), to act on from here. */}
308 <WaitingCards onNavigate={() => setOpen(false)} />
309 {items == null ? (
310 list.state === "idle" && list.data?.tab === tab ? (
311 <p className="text-sm text-muted">Notifications could not be loaded. Try again in a moment.</p>
312 ) : (
313 <ul aria-busy="true" className="space-y-2">
314 {Array.from({ length: 4 }, (_, index) => (
315 <li key={index} className="flex gap-3 rounded-lg border border-line p-3">
316 <Skeleton className="size-8 rounded-full" />
317 <div className="flex-1 space-y-2">
318 <Skeleton className="h-3.5 w-3/4" />
319 <Skeleton className="h-3 w-1/2" />
320 </div>
321 </li>
322 ))}
323 </ul>
324 )
325 ) : items.length === 0 ? (
326 <InboxEmpty tab={tab} />
327 ) : (
328 <ul className="space-y-2">
329 {items.map((item) => (
330 <InboxCard key={item.id} item={item} onOpen={() => setOpen(false)} />
331 ))}
332 </ul>
333 )}
334 </div>
335 <SheetFooter className="justify-between">
336 <SheetDescription className="text-xs">Done, saved and snoozed items are on the Notifications page.</SheetDescription>
337 <SheetClose asChild>
338 <Link to={tab === "all" ? "/notifications" : `/notifications?tab=${tab}`} className="shrink-0 text-sm font-medium text-accent hover:underline">
339 Open Notifications
340 </Link>
341 </SheetClose>
342 </SheetFooter>
343 </SheetContent>
344 </Sheet>
345 );
346}
347
348/**
349 * Mission control's card of what is unread in the person's notifications and
350 * worth a look: items an agent is waiting on, then failures. Titled for
351 * notifications, not "Needs you", which on mission control is the work list's
352 * own count. Not shown when there are none.
353 */
354export function InboxNeedsCard({ items, total }: { items: InboxItem[]; total: number }) {
355 if (items.length === 0) return null;
356 return (
357 <section className="rounded-xl border border-line bg-surface p-5">
358 <div className="flex items-center justify-between gap-3">
359 <h2 className="flex items-center gap-2 text-sm font-semibold">
360 <Bell size={14} className="text-warn" aria-hidden="true" />
361 From your notifications
362 <span className="rounded-full bg-warn/15 px-1.5 text-[0.6875rem] font-medium tabular-nums text-warn">{total}</span>
363 </h2>
364 <Link to="/notifications" className="text-xs font-medium text-accent hover:underline">
365 Open Notifications
366 </Link>
367 </div>
368 <ul className="mt-3 space-y-2">
369 {items.map((item) => (
370 <InboxCard key={item.id} item={item} />
371 ))}
372 </ul>
373 </section>
374 );
375}