import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Badge, Box, Button, CircularProgress, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Popover, Tooltip, Typography, } from '@mui/material'; import { Notifications as BellIcon, NotificationsNone as BellEmptyIcon, Circle as CircleIcon, } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; import { notificationsApi } from '../../services/notifications'; import { useNotification } from '../../contexts/NotificationContext'; import type { Notification, NotificationSchwere } from '../../types/notification.types'; const POLL_INTERVAL_MS = 15_000; // 15 seconds function playNotificationSound() { try { const ctx = new AudioContext(); const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.connect(gain); gain.connect(ctx.destination); oscillator.type = 'sine'; oscillator.frequency.value = 600; const now = ctx.currentTime; gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(0.3, now + 0.02); gain.gain.linearRampToValueAtTime(0, now + 0.15); oscillator.start(now); oscillator.stop(now + 0.15); oscillator.onended = () => ctx.close(); } catch { // Audio blocked before first user interaction — fail silently } } /** * Only allow window.open for URLs whose origin matches the current app origin. * External-looking URLs (different host or protocol-relative) are rejected to * prevent open-redirect / tab-napping via notification link data from the backend. */ function isTrustedUrl(url: string): boolean { try { const parsed = new URL(url, window.location.origin); return parsed.origin === window.location.origin; } catch { return false; } } function schwerebColor(schwere: NotificationSchwere): 'error' | 'warning' | 'info' { if (schwere === 'fehler') return 'error'; if (schwere === 'warnung') return 'warning'; return 'info'; } function formatRelative(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60_000); if (minutes < 2) return 'Gerade eben'; if (minutes < 60) return `vor ${minutes} Min.`; const hours = Math.floor(minutes / 60); if (hours < 24) return `vor ${hours} Std.`; const days = Math.floor(hours / 24); return `vor ${days} Tag${days !== 1 ? 'en' : ''}`; } const NotificationBell: React.FC = () => { const navigate = useNavigate(); const { showNotificationToast } = useNotification(); const [unreadCount, setUnreadCount] = useState(0); const [notifications, setNotifications] = useState([]); const [loadingList, setLoadingList] = useState(false); const [anchorEl, setAnchorEl] = useState(null); const pollTimerRef = useRef | null>(null); // Track known notification IDs to detect new ones; null = not yet initialized const knownIdsRef = useRef | null>(null); const fetchAndToastNew = useCallback(async () => { try { const data = await notificationsApi.getNotifications(); const unread = data.filter((n) => !n.gelesen); const count = unread.length; setUnreadCount(count); if (knownIdsRef.current === null) { // First load — initialize without toasting knownIdsRef.current = new Set(data.map((n) => n.id)); return; } // Find notifications we haven't seen before const newOnes = unread.filter((n) => !knownIdsRef.current!.has(n.id)); if (newOnes.length > 0) { playNotificationSound(); } newOnes.forEach((n) => { knownIdsRef.current!.add(n.id); const severity = n.schwere === 'fehler' ? 'error' : n.schwere === 'warnung' ? 'warning' : 'info'; showNotificationToast(n.titel, severity); }); // Also add all known IDs to avoid re-toasting on re-fetch data.forEach((n) => knownIdsRef.current!.add(n.id)); } catch { // non-critical } }, [showNotificationToast]); // Poll for new notifications every 15 seconds useEffect(() => { fetchAndToastNew(); pollTimerRef.current = setInterval(fetchAndToastNew, POLL_INTERVAL_MS); return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); }; }, [fetchAndToastNew]); const handleOpen = async (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); setLoadingList(true); try { const data = await notificationsApi.getNotifications(); setNotifications(data); // Mark all as known so we don't toast them again if (knownIdsRef.current === null) { knownIdsRef.current = new Set(data.map((n) => n.id)); } else { data.forEach((n) => knownIdsRef.current!.add(n.id)); } // Refresh count after loading full list const count = await notificationsApi.getUnreadCount(); setUnreadCount(count); } catch { // non-critical } finally { setLoadingList(false); } }; const handleClose = () => { setAnchorEl(null); }; const handleClickNotification = async (n: Notification) => { if (!n.gelesen) { try { await notificationsApi.markRead(n.id); setNotifications((prev) => prev.map((item) => item.id === n.id ? { ...item, gelesen: true } : item) ); setUnreadCount((c) => Math.max(0, c - 1)); } catch { // non-critical } } handleClose(); if (n.link) { if (n.link.startsWith('http://') || n.link.startsWith('https://')) { if (isTrustedUrl(n.link)) { window.open(n.link, '_blank'); } else { console.warn('NotificationBell: blocked navigation to untrusted URL', n.link); } } else { navigate(n.link); } } }; const handleMarkAllRead = async () => { try { await notificationsApi.markAllRead(); setNotifications((prev) => prev.map((n) => ({ ...n, gelesen: true }))); setUnreadCount(0); } catch { // non-critical } }; const handleDeleteAllRead = async () => { try { await notificationsApi.deleteAllRead(); setNotifications((prev) => prev.filter((n) => !n.gelesen)); } catch { // non-critical } }; const open = Boolean(anchorEl); const hasUnread = unreadCount > 0; return ( <> {hasUnread ? : } {/* Header */} Benachrichtigungen {notifications.some((n) => n.gelesen) && ( )} {unreadCount > 0 && ( )} {/* Body */} {loadingList ? ( ) : notifications.length === 0 ? ( Keine Benachrichtigungen ) : ( {notifications.map((n, idx) => ( {idx > 0 && } handleClickNotification(n)} sx={{ py: 1.5, px: 2, bgcolor: n.gelesen ? 'transparent' : 'action.hover', alignItems: 'flex-start', gap: 1, }} > {n.titel} } secondary={ {n.nachricht} {formatRelative(n.erstellt_am)} } disableTypography /> ))} )} ); }; export default NotificationBell;