Skip to content
411 linesCodeBlameRaw
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 { WaitingCards } from "./notifications/card-actions";
8import { SubmitButton } from "./ui";
9import { Badge, type BadgeTone } from "./ui/badge";
10import { Hint } from "./ui/hint";
11import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
12import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
13import { Skeleton } from "./ui/skeleton";
14import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
15import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
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 inbox form posts (routes/inbox.tsx). */
22const ACTION = "/inbox";
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 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">
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 the inbox">
130 <SubmitButton fetcher={fetcher} icon aria-label="Move back to the inbox" 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 <Inbox 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 * inbox'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 InboxBell({ 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
277 aria-label={unread ? `Inbox, ${unread} unread` : "Inbox"}
278 className="relative flex size-9 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-fg"
279 >
280 <Bell size={17} />
281 {shown && (
282 // Amber while an agent waits on them, red for a failure, else the accent.
283 <span
284 className={cn(
285 "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",
286 (counts?.warning ?? 0) > 0 ? "bg-warn" : (counts?.error ?? 0) > 0 ? "bg-danger" : "bg-accent",
287 )}
288 >
289 {shown}
290 </span>
291 )}
292 </SheetTrigger>
293 <SheetContent
294 // Focus starts on the tab being looked at, not on Mark all read.
295 onOpenAutoFocus={(event) => {
296 event.preventDefault();
297 (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[role="tab"][data-state="active"]')?.focus();
298 }}
299 >
300 <SheetHeader className="flex-row items-center justify-between gap-3">
301 <SheetTitle>Inbox</SheetTitle>
302 <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />
303 </SheetHeader>
304 <div className="border-b border-line px-5 py-3">
305 <InboxTabs tab={tab} counts={counts} onChange={setTab} />
306 </div>
307 <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
308 {/* Chat cards waiting on you (live notifications), to act on from here. */}
309 <WaitingCards onNavigate={() => setOpen(false)} />
310 {items == null ? (
311 list.state === "idle" && list.data?.tab === tab ? (
312 <p className="text-sm text-muted">The inbox could not be loaded. Try again in a moment.</p>
313 ) : (
314 <ul aria-busy="true" className="space-y-2">
315 {Array.from({ length: 4 }, (_, index) => (
316 <li key={index} className="flex gap-3 rounded-lg border border-line p-3">
317 <Skeleton className="size-8 rounded-full" />
318 <div className="flex-1 space-y-2">
319 <Skeleton className="h-3.5 w-3/4" />
320 <Skeleton className="h-3 w-1/2" />
321 </div>
322 </li>
323 ))}
324 </ul>
325 )
326 ) : items.length === 0 ? (
327 <InboxEmpty tab={tab} />
328 ) : (
329 <ul className="space-y-2">
330 {items.map((item) => (
331 <InboxCard key={item.id} item={item} onOpen={() => setOpen(false)} />
332 ))}
333 </ul>
334 )}
335 </div>
336 <SheetFooter className="justify-between">
337 <SheetDescription className="text-xs">Done, saved and snoozed items are in the full inbox.</SheetDescription>
338 <SheetClose asChild>
339 <Link to={tab === "all" ? "/inbox" : `/inbox?tab=${tab}`} className="shrink-0 text-sm font-medium text-accent hover:underline">
340 Open inbox
341 </Link>
342 </SheetClose>
343 </SheetFooter>
344 </SheetContent>
345 </Sheet>
346 );
347}
348
349/**
350 * Agent: g1t's agent to talk to, here to say it is coming, and not yet usable. A disabled button
351 * gets no pointer events, so the tooltip hangs on a span around it: shown on hover and focus,
352 * and on a tap, since a touch screen has no hover.
353 */
354export function AgentButton() {
355 const [open, setOpen] = useState(false);
356 return (
357 <Tooltip open={open} onOpenChange={setOpen}>
358 <TooltipTrigger asChild>
359 <span
360 tabIndex={0}
361 // A tap opens it; without preventDefault the trigger's own click would close it again.
362 onClick={(event) => {
363 event.preventDefault();
364 setOpen(true);
365 }}
366 className="inline-flex rounded-md outline-none focus-visible:ring-2 focus-visible:ring-accent"
367 >
368 <button
369 type="button"
370 disabled
371 aria-label="Agent"
372 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"
373 >
374 <Sparkles size={15} className="text-accent" />
375 <span className="hidden sm:inline">Agent</span>
376 </button>
377 </span>
378 </TooltipTrigger>
379 <TooltipContent>Agent is coming soon</TooltipContent>
380 </Tooltip>
381 );
382}
383
384/**
385 * Mission control's card of what is unread in the person's inbox and
386 * worth a look: items an agent is waiting on, then failures. Titled for
387 * the inbox, not "Needs you", which on mission control is the work list's
388 * own count. Not shown when there are none.
389 */
390export function InboxNeedsCard({ items, total }: { items: InboxItem[]; total: number }) {
391 if (items.length === 0) return null;
392 return (
393 <section className="rounded-xl border border-line bg-surface p-5">
394 <div className="flex items-center justify-between gap-3">
395 <h2 className="flex items-center gap-2 text-sm font-semibold">
396 <Inbox size={14} className="text-warn" aria-hidden="true" />
397 From your inbox
398 <span className="rounded-full bg-warn/15 px-1.5 text-[0.6875rem] font-medium tabular-nums text-warn">{total}</span>
399 </h2>
400 <Link to="/inbox" className="text-xs font-medium text-accent hover:underline">
401 Open inbox
402 </Link>
403 </div>
404 <ul className="mt-3 space-y-2">
405 {items.map((item) => (
406 <InboxCard key={item.id} item={item} />
407 ))}
408 </ul>
409 </section>
410 );
411}