/** * NotificationsView - Linear Inbox-style notifications page. * Single list showing all notifications with unread indicator. * Includes a filter chip bar to filter by trigger name. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { getTriggerColorDef } from '@shared/constants/triggerColors'; import { useVirtualizer } from '@tanstack/react-virtual'; import { CheckCheck, Inbox, Loader2, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { NotificationRow } from './NotificationRow'; import type { DetectedError } from '@renderer/types/data'; // Virtual list constants const ROW_HEIGHT = 56; const OVERSCAN = 5; /** Label used for notifications without a triggerName */ const OTHER_LABEL = 'Other'; interface FilterChip { label: string; count: number; colorHex: string; } export const NotificationsView = (): React.JSX.Element => { const { notifications, unreadCount, fetchNotifications, markNotificationRead, markAllNotificationsRead, deleteNotification, clearNotifications, navigateToError, } = useStore( useShallow((s) => ({ notifications: s.notifications, unreadCount: s.unreadCount, fetchNotifications: s.fetchNotifications, markNotificationRead: s.markNotificationRead, markAllNotificationsRead: s.markAllNotificationsRead, deleteNotification: s.deleteNotification, clearNotifications: s.clearNotifications, navigateToError: s.navigateToError, })) ); const parentRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [showClearConfirm, setShowClearConfirm] = useState(false); const [activeFilter, setActiveFilter] = useState(null); // Fetch notifications on mount useEffect(() => { const loadNotifications = async (): Promise => { setIsLoading(true); try { await fetchNotifications(); } finally { setIsLoading(false); } }; void loadNotifications(); }, [fetchNotifications]); // Sort notifications by timestamp (most recent first) const sortedNotifications = useMemo(() => { return [...notifications].sort((a, b) => b.timestamp - a.timestamp); }, [notifications]); // Derive filter chips from notifications const filterChips = useMemo((): FilterChip[] => { const counts = new Map(); for (const n of sortedNotifications) { const label = n.triggerName ?? OTHER_LABEL; const existing = counts.get(label); if (existing) { existing.count++; } else { counts.set(label, { count: 1, colorHex: getTriggerColorDef(n.triggerColor).hex, }); } } // Sort by frequency descending return Array.from(counts.entries()) .sort((a, b) => b[1].count - a[1].count) .map(([label, { count, colorHex }]) => ({ label, count, colorHex })); }, [sortedNotifications]); // Reset filter when all notifications are cleared useEffect(() => { if (notifications.length === 0) { setActiveFilter(null); } }, [notifications.length]); // Apply filter const filteredNotifications = useMemo(() => { if (activeFilter === null) return sortedNotifications; return sortedNotifications.filter((n) => { const label = n.triggerName ?? OTHER_LABEL; return label === activeFilter; }); }, [sortedNotifications, activeFilter]); // Estimate item size const estimateSize = useCallback(() => ROW_HEIGHT, []); // Set up virtualizer const rowVirtualizer = useVirtualizer({ count: filteredNotifications.length, getScrollElement: () => parentRef.current, estimateSize, overscan: OVERSCAN, }); // Scroll to top when filter changes useEffect(() => { rowVirtualizer.scrollToIndex(0); }, [activeFilter, rowVirtualizer]); // Derive filtered unread count for scoped button visibility const filteredUnreadCount = useMemo(() => { if (activeFilter === null) return unreadCount; return filteredNotifications.filter((n) => !n.isRead).length; }, [activeFilter, filteredNotifications, unreadCount]); // Handle mark all read (scoped to active filter) const handleMarkAllRead = async (): Promise => { await markAllNotificationsRead(activeFilter ?? undefined); }; // Handle clear all with confirmation (scoped to active filter) const handleClearAll = async (): Promise => { if (showClearConfirm) { await clearNotifications(activeFilter ?? undefined); setShowClearConfirm(false); } else { setShowClearConfirm(true); // Auto-hide confirmation after 3 seconds setTimeout(() => setShowClearConfirm(false), 3000); } }; // Handle archive (mark as read) const handleArchive = async (id: string): Promise => { await markNotificationRead(id); }; // Handle delete const handleDelete = async (id: string): Promise => { await deleteNotification(id); }; // Handle row click - navigate to error const handleRowClick = (error: DetectedError): void => { // Mark as read when navigating if (!error.isRead) { void markNotificationRead(error.id); } navigateToError(error); }; // Handle filter chip click const handleFilterClick = (label: string): void => { setActiveFilter((prev) => (prev === label ? null : label)); }; // Loading state if (isLoading) { return (
Loading notifications...
); } return (
{/* Header */}
{/* Title */}
Notifications {notifications.length > 0 && ( {activeFilter !== null ? filteredUnreadCount > 0 ? `${filteredUnreadCount} unread in filter` : `${filteredNotifications.length} in filter` : unreadCount > 0 ? `${unreadCount} unread` : `${notifications.length} total`} )}
{/* Action Buttons */} {notifications.length > 0 && (
{/* Mark all/filtered read */} {filteredUnreadCount > 0 && ( )} {/* Clear all/filtered */}
)}
{/* Filter Chip Bar */} {filterChips.length > 1 && (
{/* All chip */} {/* Trigger chips */} {filterChips.map((chip) => ( ))}
)} {/* Notifications List */}
{filteredNotifications.length === 0 ? (

{activeFilter !== null ? 'No matching notifications' : 'No notifications'}

{activeFilter !== null ? 'Try a different filter' : "You're all caught up!"}

) : (
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const notification = filteredNotifications[virtualRow.index]; if (!notification) return null; return (
handleRowClick(notification)} onArchive={() => handleArchive(notification.id)} onDelete={() => handleDelete(notification.id)} />
); })}
)}
); };