"use client"; import { Archive, Bell, BellOff, Check, CheckCheck } from "lucide-react"; import { AnimatePresence, motion, useAnimate, useReducedMotion, } from "motion/react"; import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_PRESS } from "@/lib/ease"; import { useDismiss } from "@/lib/hooks/use-dismiss"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; export interface NotificationItem { id: string; title: ReactNode; description?: ReactNode; /** When it happened: Date, epoch ms or an ISO string. */ timestamp: Date | number | string; read?: boolean; /** Avatar image URL. */ avatar?: string; /** Sender name — used for the avatar's alt text and initials fallback. */ name?: string; /** Icon shown instead of an avatar (system notifications). */ icon?: ReactNode; } export type NotificationTab = "all" | "unread"; export interface NotificationCenterProps { /** Controlled list. Archived items are removed from it via `onNotificationsChange`. */ notifications?: NotificationItem[]; /** Uncontrolled initial list. */ defaultNotifications?: NotificationItem[]; onNotificationsChange?: (notifications: NotificationItem[]) => void; onMarkRead?: (id: string) => void; onMarkAllRead?: () => void; onArchive?: (id: string) => void; /** Row click. The row is also marked read. */ onSelect?: (notification: NotificationItem) => void; /** Controlled panel open state. */ open?: boolean; /** Uncontrolled initial open state. Default false. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Return true for an outside target that should not close the panel (e.g. a * "simulate" or compose button beside it). Must be stable. */ ignoreOutside?: (target: Element) => boolean; /** Panel edge aligned with the bell. Default "end". */ align?: "start" | "end"; /** Locale for relative times. Default "en-US" so server and client agree. */ locale?: string; title?: ReactNode; emptyLabel?: ReactNode; className?: string; panelClassName?: string; } // The panel grows out of the bell's corner: a clip that keeps only that // corner, tweened (a sprung clip-path can snap as it resolves). const clipHidden = (align: "start" | "end") => align === "end" ? "inset(0% 0% 92% 88% round 16px)" : "inset(0% 88% 92% 0% round 16px)"; const CLIP_SHOWN = "inset(0% 0% 0% 0% round 16px)"; const CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const; const DAY = 86_400_000; function toMs(t: NotificationItem["timestamp"]) { return t instanceof Date ? t.getTime() : typeof t === "number" ? t : Date.parse(t); } function initials(name?: string) { if (!name) return "?"; const parts = name.trim().split(/\s+/); return ((parts[0]?.[0] ?? "") + (parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : "")).toUpperCase(); } function useControllable( value: T | undefined, defaultValue: T, onChange?: (next: T) => void, ) { const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : internal; const set = useCallback( (next: T) => { if (!controlled) setInternal(next); onChange?.(next); }, [controlled, onChange], ); return [current, set] as const; } /** * Bell trigger with an unread badge that bumps when the count rises, opening * a panel that grows out of the bell. All/Unread tabs with a gliding * indicator, Today/Earlier groups, relative times, and per-row mark-read / * archive actions — rows leaving the list collapse out while the rest glide * up. Times and grouping read the clock only after mount. */ export function NotificationCenter({ notifications, defaultNotifications = [], onNotificationsChange, onMarkRead, onMarkAllRead, onArchive, onSelect, open: openProp, defaultOpen = false, onOpenChange, ignoreOutside, align = "end", locale = "en-US", title = "Notifications", emptyLabel = "You're all caught up", className, panelClassName, }: NotificationCenterProps) { const reduce = useReducedMotion() ?? false; const canHover = useHoverCapable(); const baseId = useId(); const [list, setList] = useControllable(notifications, defaultNotifications, onNotificationsChange); const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange); const [tab, setTab] = useState("all"); // "Now" is read after mount (never during server render) and ticks each // minute so relative times stay fresh. const [now, setNow] = useState(null); useEffect(() => { setNow(Date.now()); const t = window.setInterval(() => setNow(Date.now()), 30_000); return () => window.clearInterval(t); }, []); const rootRef = useRef(null); const triggerRef = useRef(null); const panelRef = useRef(null); const focusOnOpen = useRef(false); const unread = list.filter((n) => !n.read).length; const close = useCallback(() => { const focused = document.activeElement; const inPanel = focused instanceof HTMLElement && panelRef.current?.contains(focused); setOpen(false); if (inPanel) triggerRef.current?.focus(); }, [setOpen]); useDismiss(open, close, rootRef, { ignore: ignoreOutside }); // Focus moves into the panel only when the user opened it — a panel that // starts open (defaultOpen) must not steal focus on page load. useEffect(() => { if (open && now !== null && focusOnOpen.current) { focusOnOpen.current = false; panelRef.current?.focus(); } }, [open, now]); // Badge bump + bell ring when the unread count rises. const [badgeScope, animateBadge] = useAnimate(); const [bellScope, animateBell] = useAnimate(); const prevUnread = useRef(unread); useEffect(() => { const rose = unread > prevUnread.current; prevUnread.current = unread; if (!rose || reduce) return; if (badgeScope.current) { animateBadge(badgeScope.current, { scale: 1.35 }, { duration: 0.1, ease: EASE_OUT }).then( () => { if (badgeScope.current) animateBadge(badgeScope.current, { scale: 1 }, SPRING_PRESS); }, ); } if (bellScope.current) { animateBell( bellScope.current, { rotate: [0, -14, 11, -7, 4, 0] }, { duration: 0.5, ease: EASE_OUT }, ); } }, [unread, reduce, animateBadge, animateBell, badgeScope, bellScope]); const markRead = useCallback( (id: string) => { if (!list.some((n) => n.id === id && !n.read)) return; setList(list.map((n) => (n.id === id ? { ...n, read: true } : n))); onMarkRead?.(id); }, [list, setList, onMarkRead], ); const archive = useCallback( (id: string, row: HTMLElement | null) => { // The row stays in the DOM while it animates out; don't strand focus on it. if (row?.contains(document.activeElement)) panelRef.current?.focus(); setList(list.filter((n) => n.id !== id)); onArchive?.(id); }, [list, setList, onArchive], ); const markAllRead = () => { if (!unread) return; setList(list.map((n) => (n.read ? n : { ...n, read: true }))); onMarkAllRead?.(); }; const visible = tab === "unread" ? list.filter((n) => !n.read) : list; const groups = useMemo(() => { if (now === null) return []; const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0); const today: NotificationItem[] = []; const earlier: NotificationItem[] = []; for (const n of [...visible].sort((a, b) => toMs(b.timestamp) - toMs(a.timestamp))) { (toMs(n.timestamp) >= startOfToday.getTime() ? today : earlier).push(n); } return [ { id: "today", label: "Today", items: today }, { id: "earlier", label: "Earlier", items: earlier }, ].filter((g) => g.items.length > 0); }, [visible, now]); const rtf = useMemo( () => new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "short" }), [locale], ); const dateFmt = useMemo( () => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }), [locale], ); const relative = (t: NotificationItem["timestamp"]) => { if (now === null) return ""; const diff = Math.max(0, now - toMs(t)); const s = Math.round(diff / 1000); if (s < 45) return rtf.format(0, "second"); const m = Math.round(s / 60); if (m < 60) return rtf.format(-m, "minute"); const h = Math.round(m / 60); if (h < 24) return rtf.format(-h, "hour"); const d = Math.round(diff / DAY); if (d < 7) return rtf.format(-d, "day"); return dateFmt.format(toMs(t)); }; const tabs: { value: NotificationTab; label: string; count?: number }[] = [ { value: "all", label: "All" }, { value: "unread", label: "Unread", count: unread }, ]; const onTabKey = (event: KeyboardEvent) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight" && event.key !== "Home" && event.key !== "End") return; event.preventDefault(); const next: NotificationTab = tab === "all" ? "unread" : "all"; const target = event.key === "Home" ? "all" : event.key === "End" ? "unread" : next; setTab(target); document.getElementById(`${baseId}-tab-${target}`)?.focus(); }; const badgeText = unread > 99 ? "99+" : String(unread); return (
{open && now !== null ? (

{title}

{/* layoutRoot: the indicator glides in panel coordinates, so a scrolled page can't replay as movement. */} {tabs.map((t) => { const active = tab === t.value; return ( ); })} {groups.length === 0 ? (

{emptyLabel}

{tab === "unread" ? "No unread notifications." : "New notifications will show up here."}

) : ( groups.map((group) => (

{group.label}

    {group.items.map((n) => ( { markRead(n.id); onSelect(n); } : undefined } onMarkRead={() => markRead(n.id)} onArchive={(row) => archive(n.id, row)} /> ))}
)) )}
) : null}
); } function NotificationRow({ item, time, reduce, alwaysShowActions, onSelect, onMarkRead, onArchive, }: { item: NotificationItem; time: string; reduce: boolean; alwaysShowActions: boolean; onSelect?: () => void; onMarkRead: () => void; onArchive: (row: HTMLElement | null) => void; }) { const rowRef = useRef(null); const unread = !item.read; const body = ( <> {item.icon ? ( {item.icon} ) : item.avatar ? ( // biome-ignore lint/performance/noImgElement: library component, framework-agnostic. {item.name ) : ( {initials(item.name)} )} {item.title} {item.description ? ( {item.description} ) : null} {time} {unread ? , unread : null} ); return ( {onSelect ? ( ) : (
{body}
)} {unread ? ( ) : null} {unread ? ( ) : null} onArchive(rowRef.current)}>
); } function RowAction({ label, onClick, children, }: { label: string; onClick: () => void; children: ReactNode; }) { return ( ); }