import { useEffect, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, X } from "lucide-react"; import { api, type AppNotification, type FeedFilters } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { clearAll as clearClient, dismiss as dismissClient, getNotifications, getUnreadCount, markAllRead as markAllClientRead, remove as removeClient, resolveVideo, subscribe, type Notification as ClientNotif, type ChannelSubscribedMeta, type VideoHiddenMeta, type VideoWatchedMeta, } from "../lib/notifications"; import type { Page } from "../lib/urlState"; import { LEVEL_STYLE } from "./Toaster"; // The single notification center. "System" = durable, server-backed notifications (inbox // phase 1). "Activity" = the client-side transient events (action confirmations, errors, with // their inline undo/find actions) that used to live behind a separate bell — folded in here so // there's one indicator and one place to look. Transient toasts still pop via the Toaster. const POLL_MS = 8000; function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string { if (!iso) return ""; return relativeFromMs(new Date(iso).getTime(), t); } function relativeFromMs(ms: number, t: (k: string, o?: any) => string): string { const secs = Math.max(0, Math.round((Date.now() - ms) / 1000)); // Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes. if (secs < 60) return t("notifications.time.justNow"); const mins = Math.round(secs / 60); if (mins < 60) return t("notifications.time.minutes", { count: mins }); const hours = Math.round(mins / 60); if (hours < 24) return t("notifications.time.hours", { count: hours }); return t("notifications.time.days", { count: Math.round(hours / 24) }); } export default function NotificationsPanel({ filters, setFilters, setPage, onFocusChannel, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; setPage: (p: Page) => void; onFocusChannel: (name: string) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( ["notifications"], () => api.notifications(), { intervalMs: POLL_MS } ); const serverItems = q.data?.items ?? []; const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications); const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); // Opening the center marks the (ephemeral) client items read, matching the old bell. Server // items stay unread until acted on individually. useEffect(() => { markAllClientRead(); }, []); const refresh = () => { qc.invalidateQueries({ queryKey: ["notifications"] }); qc.invalidateQueries({ queryKey: ["notif-unread"] }); }; const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh }); const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh }); const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh }); const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh }); const serverUnread = serverItems.some((n) => !n.read); const hasAny = serverItems.length > 0 || clientItems.length > 0; const hasUnread = serverUnread || clientUnread > 0; function markAll() { markAllClientRead(); if (serverUnread) readAllMut.mutate(); else refresh(); } function clearEverything() { clearClient(); clearMut.mutate(); } // "Find in feed" / undo, ported from the old bell. function locateHidden(meta: VideoHiddenMeta) { setFilters({ ...filters, show: "hidden", channelId: meta.channelId || undefined, channelName: meta.channelName || undefined, }); setPage("feed"); } function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) { api .setState(meta.videoId, "new") .then(() => { qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed-count"] }); // Quietly resolve the now-stale notice (no confirmation toast — the change is visible). resolveVideo(meta.videoId); }) .catch(() => {}); } return (