import { useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AlarmClock, ArrowRightLeft, AtSign, Bell, Bot, Check, Inbox, RefreshCw, UserPlus, type LucideIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { useApiQuery, useApiMutation, api } from "@/lib/api"; import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useRealtime } from "@/hooks/use-realtime"; import { formatDistanceToNow } from "date-fns"; import type { Notification, NotificationCount, NotificationsResponse } from "@/lib/types"; const NOTIFICATION_META: Record = { mention: { icon: AtSign, color: "text-blue-500" }, status_change: { icon: ArrowRightLeft, color: "text-violet-500" }, due_soon: { icon: AlarmClock, color: "text-amber-500" }, automation: { icon: Bot, color: "text-emerald-500" }, assignment: { icon: UserPlus, color: "text-cyan-500" }, }; /** Navigate to the entity a notification points at. Returns true when a route * was matched (and the sheet should close). */ function navigateToEntity(navigate: ReturnType, n: Notification): boolean { if (!n.entityId || !n.entityType) return false; switch (n.entityType) { case "task": navigate({ to: "/tasks/$id", params: { id: n.entityId } }); return true; case "note": navigate({ to: "/notes/$id", params: { id: n.entityId } }); return true; case "project": navigate({ to: "/projects/$id", params: { id: n.entityId } }); return true; case "habit": navigate({ to: "/habits/$id", params: { id: n.entityId } }); return true; default: return false; } } export function NotificationCenter() { const navigate = useNavigate(); const queryClient = useQueryClient(); const domainId = useApiDomain(); const [open, setOpen] = useState(false); // Own SSE connection so the badge stays live regardless of which page is // mounted; notification events invalidate the count + list queries. useRealtime({ enabled: true }); const countQuery = useApiQuery( ["notifications-count", domainId], "/notifications/count" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}` : ""), { enabled: !!domainId, refetchInterval: 30_000 } ); const unreadCount = countQuery.data?.count ?? 0; const listQuery = useApiQuery( ["notifications", domainId], "/notifications" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}&limit=50` : ""), { enabled: !!domainId && open } ); const notifications = listQuery.data?.items ?? []; const loading = listQuery.isLoading || listQuery.isFetching; const invalidateNotifications = () => { queryClient.invalidateQueries({ queryKey: ["notifications-count"] }); queryClient.invalidateQueries({ queryKey: ["notifications"] }); }; const markRead = useMutation({ mutationFn: (id: string) => api.patch(`/notifications/${id}`), onMutate: (id) => { // Optimistically decrement the badge so the UI feels instant. queryClient.setQueryData(["notifications-count", domainId], (old) => old && old.count > 0 ? { count: old.count - 1 } : old ); queryClient.setQueryData(["notifications", domainId], (old) => old ? { ...old, items: old.items.map((n) => (n.id === id && !n.readAt ? { ...n, readAt: new Date().toISOString() } : n)), unreadCount: Math.max(0, old.unreadCount - 1), } : old ); return id; }, onSuccess: invalidateNotifications, }); const markAllRead = useApiMutation<{ success: boolean; updated: number }, { workspace_id?: string }>( "post", "/notifications/read-all", { onMutate: () => { queryClient.setQueryData(["notifications-count", domainId], (old) => old ? { count: 0 } : old ); queryClient.setQueryData(["notifications", domainId], (old) => old ? { ...old, items: old.items.map((n) => (n.readAt ? n : { ...n, readAt: new Date().toISOString() })), unreadCount: 0, } : old ); }, onSuccess: invalidateNotifications, } ); const handleNotificationClick = (n: Notification) => { if (!n.readAt) markRead.mutate(n.id); if (navigateToEntity(navigate, n)) { setOpen(false); } }; const badgeLabel = unreadCount > 99 ? "99+" : String(unreadCount); return ( {unreadCount === 0 ? "No notifications" : `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`} Notifications
{loading && notifications.length === 0 ? (
Loading notifications…
) : notifications.length === 0 ? (
No notifications yet
) : (
    {notifications.map((n) => { const meta = NOTIFICATION_META[n.type] ?? { icon: Bell, color: "text-muted-foreground" }; const Icon = meta.icon; const unread = !n.readAt; return (
  • ); })}
)}
); }