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