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