import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Bell, Eye, RotateCcw, Search, Trash2 } from "lucide-react"; import { api, type FeedFilters } from "../lib/api"; import { clearAll, dismiss, getNotifications, getUnreadCount, markAllRead, notify, subscribe, type VideoHiddenMeta, type VideoWatchedMeta, } from "../lib/notifications"; import { LEVEL_STYLE } from "./Toaster"; function relTime(ts: number, t: TFunction): string { const s = Math.round((Date.now() - ts) / 1000); if (s < 60) return t("notifications.time.justNow"); const m = Math.round(s / 60); if (m < 60) return t("notifications.time.minutes", { count: m }); const h = Math.round(m / 60); if (h < 24) return t("notifications.time.hours", { count: h }); const d = Math.round(h / 24); return t("notifications.time.days", { count: d }); } export default function NotificationCenter({ filters, setFilters, variant = "header", }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; variant?: "header" | "rail"; }) { const { t } = useTranslation(); const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications); const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); const [open, setOpen] = useState(false); const wrap = useRef(null); const btnRef = useRef(null); const panelRef = useRef(null); // Rail variant anchors the panel right + above the button (fixed, viewport coords) and // portals it to so it escapes the nav's backdrop-filter. const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); const qc = useQueryClient(); // Close when clicking anywhere outside the button + panel. useEffect(() => { if (!open) return; function onDown(e: MouseEvent) { const target = e.target as Node; if (btnRef.current?.contains(target) || panelRef.current?.contains(target)) return; if (variant === "header" && wrap.current?.contains(target)) return; setOpen(false); } document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, [open, variant]); function toggle() { if (!open && variant === "rail") { const r = btnRef.current?.getBoundingClientRect(); if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); } setOpen((o) => !o); } // Opening the center marks everything read. useEffect(() => { if (open) markAllRead(); }, [open, notifications.length]); function locateHidden(meta: VideoHiddenMeta) { setFilters({ ...filters, show: "hidden", channelId: meta.channelId || undefined, channelName: meta.channelName || undefined, }); setOpen(false); } function revertState( meta: VideoHiddenMeta | VideoWatchedMeta, messageKey: "notifications.unhidden" | "notifications.unwatched" ) { api .setState(meta.videoId, "new") .then(() => { qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed-count"] }); notify({ level: "success", message: t(messageKey, { title: meta.title }) }); }) .catch(() => {}); } function renderPanel(el: JSX.Element) { if (variant !== "rail") return el; return createPortal(
{el}
, document.body ); } return (
{open && renderPanel(
{t("notifications.title")}
{notifications.length > 0 && ( )}
{notifications.length === 0 ? (
{t("notifications.empty")}
) : ( notifications.map((n) => { const { icon: Icon, color } = LEVEL_STYLE[n.level]; const needsAction = n.requiresInteraction && !n.dismissed; const hidden = n.meta?.kind === "video-hidden" ? n.meta : null; const watched = n.meta?.kind === "video-watched" ? n.meta : null; return (
{n.title &&
{n.title}
}
{n.message}
{relTime(n.ts, t)} {needsAction && ( {t("notifications.actionNeeded")} )}
{hidden ? (
) : watched ? (
) : ( n.action && !n.dismissed && ( ) )}
); }) )}
)}
); }