Skip to content

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

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