| 1 | import { AlarmClock, Bell, Bookmark, Check, CheckCheck, CircleCheck, CircleX, Ellipsis, Hand, Inbox, Info, Mail, MailOpen, Sparkles, Undo2 } from "lucide-react"; |
| 2 | import { type ReactNode, useEffect, useState } from "react"; |
| 3 | import { Link, useFetcher } from "react-router"; |
| 4 | |
| 5 | import type { InboxCounts, InboxItem, InboxSeverity } from "@g1t/contracts"; |
| 6 | |
| 7 | import { WaitingCards } from "./notifications/card-actions"; |
| 8 | import { SubmitButton } from "./ui"; |
| 9 | import { Badge, type BadgeTone } from "./ui/badge"; |
| 10 | import { Hint } from "./ui/hint"; |
| 11 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; |
| 12 | import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet"; |
| 13 | import { Skeleton } from "./ui/skeleton"; |
| 14 | import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; |
| 15 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; |
| 16 | import { cn } from "../lib/cn"; |
| 17 | import { isPending } from "../lib/pending"; |
| 18 | import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, updatesLabel, whenShort } from "../lib/inbox"; |
| 19 | import type { InboxPanelData } from "../routes/inbox-json"; |
| 20 | |
| 21 | /** Where every inbox form posts (routes/inbox.tsx). */ |
| 22 | const ACTION = "/inbox"; |
| 23 | /** How often the bell asks for its count while the page is in view. */ |
| 24 | const COUNT_EVERY_MS = 60_000; |
| 25 | |
| 26 | const TONE: Record<InboxSeverity, BadgeTone> = { |
| 27 | error: "danger", |
| 28 | warning: "warn", |
| 29 | success: "accent", |
| 30 | info: "info", |
| 31 | }; |
| 32 | |
| 33 | const 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. */ |
| 41 | function 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. */ |
| 51 | function 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 | */ |
| 69 | export 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 | |
| 177 | const 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. */ |
| 181 | export 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. */ |
| 193 | export 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. */ |
| 221 | export 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 | */ |
| 246 | export 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 | */ |
| 354 | export 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 | */ |
| 390 | export 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 | } |