316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
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<Notification[]>([]);
|
|
const [loadingList, setLoadingList] = useState(false);
|
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
// Track known notification IDs to detect new ones; null = not yet initialized
|
|
const knownIdsRef = useRef<Set<string> | 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<HTMLElement>) => {
|
|
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 (
|
|
<>
|
|
<Tooltip title="Benachrichtigungen">
|
|
<IconButton
|
|
color="inherit"
|
|
onClick={handleOpen}
|
|
aria-label="Benachrichtigungen öffnen"
|
|
size="small"
|
|
>
|
|
<Badge badgeContent={unreadCount} color="error" invisible={!hasUnread}>
|
|
{hasUnread ? <BellIcon /> : <BellEmptyIcon />}
|
|
</Badge>
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
<Popover
|
|
open={open}
|
|
anchorEl={anchorEl}
|
|
onClose={handleClose}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
|
PaperProps={{ sx: { width: 360, maxHeight: 500, display: 'flex', flexDirection: 'column' } }}
|
|
>
|
|
{/* Header */}
|
|
<Box sx={{ px: 2, py: 1.5, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Typography variant="subtitle1" fontWeight={600}>
|
|
Benachrichtigungen
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
|
{notifications.some((n) => n.gelesen) && (
|
|
<Button size="small" onClick={handleDeleteAllRead} sx={{ fontSize: '0.75rem' }}>
|
|
Gelesene löschen
|
|
</Button>
|
|
)}
|
|
{unreadCount > 0 && (
|
|
<Button size="small" onClick={handleMarkAllRead} sx={{ fontSize: '0.75rem' }}>
|
|
Alle als gelesen markieren
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
<Divider />
|
|
|
|
{/* Body */}
|
|
<Box sx={{ overflowY: 'auto', flexGrow: 1 }}>
|
|
{loadingList ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
|
<CircularProgress size={28} />
|
|
</Box>
|
|
) : notifications.length === 0 ? (
|
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
|
<BellEmptyIcon sx={{ fontSize: 40, color: 'text.disabled', mb: 1 }} />
|
|
<Typography variant="body2" color="text.secondary">
|
|
Keine Benachrichtigungen
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
<List disablePadding>
|
|
{notifications.map((n, idx) => (
|
|
<React.Fragment key={n.id}>
|
|
{idx > 0 && <Divider component="li" />}
|
|
<ListItem disablePadding>
|
|
<ListItemButton
|
|
onClick={() => handleClickNotification(n)}
|
|
sx={{
|
|
py: 1.5,
|
|
px: 2,
|
|
bgcolor: n.gelesen ? 'transparent' : 'action.hover',
|
|
alignItems: 'flex-start',
|
|
gap: 1,
|
|
}}
|
|
>
|
|
<CircleIcon
|
|
sx={{
|
|
fontSize: 8,
|
|
mt: 0.75,
|
|
flexShrink: 0,
|
|
color: n.gelesen ? 'transparent' : `${schwerebColor(n.schwere)}.main`,
|
|
}}
|
|
/>
|
|
<ListItemText
|
|
primary={
|
|
<Typography variant="body2" fontWeight={n.gelesen ? 400 : 600}>
|
|
{n.titel}
|
|
</Typography>
|
|
}
|
|
secondary={
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary" display="block">
|
|
{n.nachricht}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.disabled">
|
|
{formatRelative(n.erstellt_am)}
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
disableTypography
|
|
/>
|
|
</ListItemButton>
|
|
</ListItem>
|
|
</React.Fragment>
|
|
))}
|
|
</List>
|
|
)}
|
|
</Box>
|
|
</Popover>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default NotificationBell;
|