'use client'; /** * Unified Inbox - merges the previous AlertBell + NotificationBell into * a single bell with two tabs: * * System - port-wide operational alerts (system-generated, all * users with `view_alerts` see the same list). Sourced from * `useAlertList('open')` + `useAlertCount()`. * * Personal - per-user notifications (you signed something, someone * @-mentioned you, your reminder fired, etc.). Sourced from * `useNotifications` + the /api/v1/notifications endpoint. * * The bell icon shows a single combined unread count. The popover opens * to whichever tab has unread items first; ties → Personal. * * Replaces the topbar's `` + ``. The two * old components stay in the tree (untouched) so anything that imports * them directly (tests, deep-link pages) keeps working - they're simply * no longer mounted in the topbar. */ import { useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Bell, ShieldAlert, Inbox as InboxIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { apiFetch } from '@/lib/api/client'; import { useUIStore } from '@/stores/ui-store'; import { cn } from '@/lib/utils'; import { useNotifications } from '@/hooks/use-notifications'; import { NotificationItem } from '@/components/notifications/notification-item'; import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card'; import { useAlertCount, useAlertList, useAlertRealtime } from '@/components/alerts/use-alerts'; interface NotificationListResponse { data: Array<{ id: string; type: string; title: string; description: string | null; link: string | null; isRead: boolean; createdAt: Date; }>; total: number; } type TabKey = 'personal' | 'system'; export function Inbox() { const portSlug = useUIStore((s) => s.currentPortSlug); const [open, setOpen] = useState(false); // ── System (alerts) ── const { data: alertCount } = useAlertCount(); const { data: alertList, isLoading: alertsLoading } = useAlertList('open', open); useAlertRealtime(); const systemTotal = alertCount?.total ?? 0; const systemCritical = alertCount?.bySeverity.critical ?? 0; const systemAlerts = alertList?.data ?? []; const systemTop = systemAlerts.slice(0, 8); // ── Personal (notifications) ── const { unreadCount: personalUnread } = useNotifications(); const queryClient = useQueryClient(); const { data: notifData, isLoading: notifLoading } = useQuery({ queryKey: ['notifications', 'list'], queryFn: () => apiFetch('/api/v1/notifications?limit=20'), staleTime: 30_000, // Only fetch the list when the popover opens - count stays live in the // background via useNotifications' realtime hook. enabled: open, }); const notifications = notifData?.data ?? []; const markRead = useMutation({ mutationFn: (id: string) => apiFetch(`/api/v1/notifications/${id}`, { method: 'PATCH' }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }), }); const markAllRead = useMutation({ mutationFn: () => apiFetch('/api/v1/notifications/read-all', { method: 'POST' }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }), }); // ── Combined badge + initial tab ── const combined = systemTotal + personalUnread; const initialTab = useMemo(() => { // Default to whichever side has unread items; tie → Personal (most users // care about their own pings before system-wide rumblings). if (systemTotal > 0 && personalUnread === 0) return 'system'; return 'personal'; }, [systemTotal, personalUnread]); const [activeTab, setActiveTab] = useState(initialTab); // Re-anchor to the unread side ONLY on the rising edge of `open` (i.e. // when the popover actually opens). The previous version ran whenever // `initialTab` changed too, which yanked the tab out from under the // user any time a new socket event re-derived the initial tab while // they were actively reading the other one. const initialTabRef = useRef(initialTab); useEffect(() => { initialTabRef.current = initialTab; }); useEffect(() => { if (open) setActiveTab(initialTabRef.current); }, [open]); return ( setActiveTab(v as TabKey)}>
Personal {personalUnread > 0 ? ( {personalUnread > 99 ? '99+' : personalUnread} ) : null} System {systemTotal > 0 ? ( 0 ? 'bg-destructive/15 text-destructive' : 'bg-amber-500/15 text-amber-600', )} > {systemTotal > 99 ? '99+' : systemTotal} ) : null}
{/* ── Personal tab ── */}

Your notifications

{personalUnread > 0 ? ( ) : ( View all )}
{notifLoading ? (
Loading…
) : notifications.length === 0 ? (
No notifications
) : (
{notifications.map((n) => ( markRead.mutate(id)} /> ))}
)}
{/* ── System tab ── */}

Active alerts

View all
{alertsLoading ? (
Loading…
) : systemTop.length === 0 ? (
) : (
{systemTop.map((a) => ( ))}
)}
); }