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