import type { DashboardEvent } from '../../../../packages/api-contracts/src/index.js'; import { createContext, useCallback, useContext, useEffect, useMemo, useState, type PropsWithChildren, } from 'react'; import { Link } from 'react-router-dom'; import { z } from 'zod'; import { useDashboardRealtime } from './realtime-provider.js'; import './notifications.css'; export type NotificationTone = 'info' | 'success' | 'warning' | 'error'; export interface NotificationItem { id: string; title: string; message: string; occurredAt: string; tone: NotificationTone; href?: string; isRead: boolean; } export type NotificationDraft = Omit; interface NotificationContextValue { isOpen: boolean; notifications: readonly NotificationItem[]; toasts: readonly NotificationItem[]; unreadCount: number; clearAll(): void; dismissToast(id: string): void; setOpen(isOpen: boolean): void; } interface RealtimeSubscription { subscribe(listener: (event: DashboardEvent) => void): () => void; } const STORAGE_KEY = 'reddit-bot.notifications.v1'; const MAX_NOTIFICATIONS = 50; const NotificationContext = createContext(null); const StoredNotificationsSchema = z.array(z.strictObject({ id: z.string(), title: z.string(), message: z.string(), occurredAt: z.string(), tone: z.enum(['info', 'success', 'warning', 'error']), href: z.string().optional(), isRead: z.boolean(), })); function notificationStorage(): Storage | undefined { try { return globalThis.localStorage; } catch { return undefined; } } function titleCase(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1).replaceAll('-', ' '); } export function notificationForEvent(event: DashboardEvent): NotificationDraft | null { switch (event.type) { case 'reply.received': return { id: event.id, occurredAt: event.occurredAt, title: `New reply from u/${event.payload.sender}`, message: event.payload.preview?.trim() || 'A new message arrived in this conversation.', tone: 'success', href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`, }; case 'media.received': return { id: event.id, occurredAt: event.occurredAt, title: `Image received from u/${event.payload.sender}`, message: event.payload.count > 1 ? `${event.payload.count} images are attached to this conversation.` : 'An image is attached to this conversation.', tone: 'success', href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`, }; case 'message.sent': return null; case 'message.failed': return { id: event.id, occurredAt: event.occurredAt, title: `Message to u/${event.payload.recipient} failed`, message: event.payload.reason, tone: 'error', href: event.payload.conversationId ? `/conversations/${encodeURIComponent(event.payload.conversationId)}` : '/operations', }; case 'bot.status.changed': if (event.payload.status !== 'stopped') return null; return { id: event.id, occurredAt: event.occurredAt, title: `Bot ${event.payload.status}`, message: event.payload.reason ?? `The bot is now ${event.payload.status}.`, tone: 'warning', href: '/operations', }; case 'sync.completed': return null; case 'sync.failed': return { id: event.id, occurredAt: event.occurredAt, title: `${titleCase(event.payload.kind)} sync failed`, message: event.payload.reason, tone: 'error', href: '/operations', }; case 'rate-limit.changed': if (!event.payload.limited) return null; return { id: event.id, occurredAt: event.occurredAt, title: 'Rate limit reached', message: event.payload.reason ?? 'Sending will resume when the limit resets.', tone: 'warning', href: '/operations', }; case 'reddit.session.changed': if (!['expired', 'logged-out', 'error'].includes(event.payload.status)) return null; return { id: event.id, occurredAt: event.occurredAt, title: 'Reddit account needs attention', message: event.payload.reason ?? (event.payload.username ? `The session for u/${event.payload.username} changed to ${event.payload.status}.` : `Session status changed to ${event.payload.status}.`), tone: 'error', href: '/accounts', }; case 'conversation.updated': case 'sync.started': case 'sync.progress': case 'log.created': return null; } } function loadNotifications(): NotificationItem[] { const storage = notificationStorage(); if (!storage) return []; try { const value: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? '[]'); const parsed = StoredNotificationsSchema.safeParse(value); return parsed.success ? parsed.data.slice(0, MAX_NOTIFICATIONS) : []; } catch { return []; } } export interface NotificationProviderProps extends PropsWithChildren { realtime?: RealtimeSubscription; } export function NotificationProvider({ children, realtime: realtimeOverride }: NotificationProviderProps) { const dashboardRealtime = useDashboardRealtime(); const realtime = realtimeOverride ?? dashboardRealtime; const [isOpen, setIsOpenState] = useState(false); const [notifications, setNotifications] = useState(loadNotifications); const [toasts, setToasts] = useState([]); const dismissToast = useCallback((id: string) => { setToasts((current) => current.filter((toast) => toast.id !== id)); }, []); const setOpen = useCallback((nextOpen: boolean) => { setIsOpenState(nextOpen); if (nextOpen) setNotifications((current) => current.map((item) => ({ ...item, isRead: true }))); }, []); useEffect(() => { try { notificationStorage()?.setItem(STORAGE_KEY, JSON.stringify(notifications)); } catch { // Storage can be unavailable in private or restricted browser contexts. } }, [notifications]); useEffect(() => { if (isOpen && notifications.some((item) => !item.isRead)) { setNotifications((current) => current.map((item) => ({ ...item, isRead: true }))); } }, [isOpen, notifications]); useEffect(() => realtime.subscribe((event) => { const draft = notificationForEvent(event); if (!draft) return; const item = { ...draft, isRead: false }; setNotifications((current) => current.some((entry) => entry.id === item.id) ? current : [item, ...current].slice(0, MAX_NOTIFICATIONS)); setToasts((current) => current.some((entry) => entry.id === item.id) ? current : [...current, item]); }), [realtime]); useEffect(() => { function closeOnEscape(event: KeyboardEvent): void { if (event.key === 'Escape') setOpen(false); } globalThis.addEventListener('keydown', closeOnEscape); return () => globalThis.removeEventListener('keydown', closeOnEscape); }, [setOpen]); const value = useMemo(() => ({ isOpen, notifications, toasts, unreadCount: notifications.filter((item) => !item.isRead).length, clearAll: () => setNotifications([]), dismissToast, setOpen, }), [dismissToast, isOpen, notifications, setOpen, toasts]); return {children}; } function useNotifications(): NotificationContextValue { const context = useContext(NotificationContext); if (!context) throw new Error('Notifications must be used within NotificationProvider'); return context; } function BellIcon() { return ( ); } export function NotificationBell({ className = '' }: { className?: string }) { const { isOpen, setOpen, unreadCount } = useNotifications(); return ( ); } function formattedTime(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date); } function NotificationContent({ item }: { item: NotificationItem }) { return ( <>