import { useEffect, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, 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. Works for both hidden and watched // notices — jump to the feed filtered to that state and the video's channel. function locate(meta: VideoHiddenMeta | VideoWatchedMeta) { setFilters({ ...filters, show: meta.kind === "video-hidden" ? "hidden" : "watched", 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 (
{t("inbox.title")}
{t("inbox.subtitle")}
{hasAny && (
)}
{q.isLoading && !q.data && clientItems.length === 0 ? (
{t("common.loading")}
) : !hasAny ? (
{t("inbox.empty")}
) : (
{serverItems.length > 0 && (
{t("inbox.sectionSystem")}
{serverItems.map((n) => ( readMut.mutate(n.id)} onDismiss={() => dismissMut.mutate(n.id)} onOpenScheduler={() => setPage("scheduler")} /> ))}
)} {clientItems.length > 0 && (
{t("inbox.sectionActivity")}
{clientItems.map((n) => ( removeClient(n.id)} /> ))}
)}
)}
); } function NotificationRow({ n, t, onRead, onDismiss, onOpenScheduler, }: { n: AppNotification; t: (k: string, o?: any) => string; onRead: () => void; onDismiss: () => void; onOpenScheduler: () => void; }) { // The maintenance producer ships the affected videos in `data.videos`; list a few titles. const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos) ? n.data!.videos : []; // Known notification types are rendered from i18n (so they're trilingual) using the typed // payload; the server-stored title/body are an English fallback for any unknown type. const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number"; const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string"; let title = n.title; let body = n.body; if (isMaintenance) { title = t("inbox.maintenance.title"); body = t("inbox.maintenance.body", { count: n.data!.count }); } else if (isScheduler) { // " finished/failed", with the raw result summary as the (technical) body. const job = t(`scheduler.jobs.${n.data!.job_id}`, n.data!.job_id); title = t( n.data!.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { job } ); body = n.data!.summary || n.body; } return (
{!n.read && }
{title} {relativeTime(n.created_at, t)}
{body &&
{body}
} {videos.length > 0 && (
    {videos.slice(0, 8).map((v) => (
  • {v.title || v.id}
  • ))} {videos.length > 8 && (
  • {t("inbox.andMore", { count: videos.length - 8 })}
  • )}
)} {isScheduler && ( )}
{!n.read && ( )}
); } function ClientActivityRow({ n, t, onFind, onRevert, onFocusChannel, onRemove, }: { n: ClientNotif; t: (k: string, o?: any) => string; onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onFocusChannel: (name: string) => void; onRemove: () => void; }) { 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; const subscribed: ChannelSubscribedMeta | null = n.meta?.kind === "channel-subscribed" ? n.meta : null; return (
{n.title &&
{n.title}
}
{n.message}
{relativeFromMs(n.ts, t)} {needsAction && ( {t("notifications.actionNeeded")} )}
{hidden ? (
) : watched ? (
) : subscribed ? (
{t("notifications.openOnYouTube")}
) : ( n.action && !n.dismissed && ( ) )}
); }