import React, { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Box, Button, CircularProgress, Divider, IconButton, Popover, Stack, Tooltip, Typography, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DoneIcon from "@mui/icons-material/Done"; import { api, getApiErrorMessage } from "../api"; import { useI18n } from "../i18n/I18nProvider"; import { notificationDateLabel, UserNotification } from "../notifications"; type Props = { anchorEl: HTMLElement | null; onClose: () => void; onNavigate: (path: string) => void; onChanged: () => void; }; export default function NotificationsPopover({ anchorEl, onClose, onNavigate, onChanged }: Props) { const { language, t } = useI18n(); const open = Boolean(anchorEl); const [notifications, setNotifications] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState<{ cause: unknown; action: boolean } | null>(null); const [busyId, setBusyId] = useState(null); const activeLoad = useRef(null); const openGeneration = useRef(0); const pendingAction = useRef(false); const contentRef = useRef(null); const headingRef = useRef(null); const actionFocus = useRef(null); const load = useCallback(async () => { if (activeLoad.current || pendingAction.current) return; const controller = new AbortController(); activeLoad.current = controller; setLoading(true); try { const response = await api.get("/notifications?limit=10", { signal: controller.signal }); if (controller.signal.aborted) return; setNotifications(response.data ?? []); setError(null); } catch (requestError) { if (!controller.signal.aborted) setError({ cause: requestError, action: false }); } finally { if (activeLoad.current === controller) { activeLoad.current = null; setLoading(false); } } }, []); useEffect(() => { openGeneration.current += 1; if (open) void load(); else { activeLoad.current?.abort(); setNotifications([]); setError(null); setBusyId(null); } return () => { openGeneration.current += 1; activeLoad.current?.abort(); activeLoad.current = null; pendingAction.current = false; actionFocus.current = null; }; }, [load, open]); useEffect(() => { if (!open || loading || busyId || !actionFocus.current) return; const target = actionFocus.current; actionFocus.current = null; if (document.activeElement !== contentRef.current) return; if (target.isConnected && !target.matches(":disabled")) target.focus(); else headingRef.current?.focus(); }, [busyId, loading, notifications, open]); const rememberFocus = () => { const active = document.activeElement; if (active instanceof HTMLElement && active !== contentRef.current && contentRef.current?.contains(active)) { actionFocus.current = active; contentRef.current.focus(); } }; const markRead = async (notification: UserNotification) => { if (notification.readAtUtc) return true; if (pendingAction.current) return false; pendingAction.current = true; const generation = openGeneration.current; rememberFocus(); setBusyId(notification.id); try { await api.post(`/notifications/${notification.id}/read`); if (generation !== openGeneration.current) return false; setNotifications((items) => items.map((item) => item.id === notification.id ? { ...item, readAtUtc: new Date().toISOString() } : item)); setError(null); onChanged(); return true; } catch (requestError) { if (generation === openGeneration.current) setError({ cause: requestError, action: true }); return false; } finally { if (generation === openGeneration.current) { pendingAction.current = false; setBusyId(null); } } }; const dismiss = async (notification: UserNotification) => { if (pendingAction.current) return; pendingAction.current = true; const generation = openGeneration.current; rememberFocus(); setBusyId(notification.id); try { await api.delete(`/notifications/${notification.id}`); if (generation !== openGeneration.current) return; setNotifications((items) => items.filter((item) => item.id !== notification.id)); setError(null); onChanged(); } catch (requestError) { if (generation === openGeneration.current) setError({ cause: requestError, action: true }); } finally { if (generation === openGeneration.current) { pendingAction.current = false; setBusyId(null); } } }; const openNotification = async (notification: UserNotification) => { if (!notification.linkPath || pendingAction.current) return; const generation = openGeneration.current; if (!notification.readAtUtc && !await markRead(notification)) return; if (generation !== openGeneration.current) return; pendingAction.current = true; onClose(); onNavigate(notification.linkPath); }; return ( {t("notifications")} {t("notificationsRecent")} {loading ? : null} {error ? { rememberFocus(); void load(); }}>{t("retry")}} sx={{ m: 0.5, overflowWrap: "anywhere" }}>{getApiErrorMessage(error.cause, t(error.action ? "notificationsActionFailed" : "notificationsLoadFailed"))} : null} {!loading && !error && notifications.length === 0 ? ( {t("notificationsEmptyTitle")} {t("notificationsEmptyBody")} ) : null} {!loading ? notifications.map((notification) => ( {notification.title} {!notification.readAtUtc ? {t("notificationsUnread")} : null} {notification.message} {notificationDateLabel(notification.createdAtUtc, language === "nb" ? "nb-NO" : "en-GB")} {notification.linkPath ? : null} {!notification.readAtUtc ? ( ) : null} )) : null} ); }