import { useEffect, useSyncExternalStore, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { Activity, Bell, Check, CheckCheck, Download, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X, } from "lucide-react"; import { api, type AppNotification } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { useLiveQuery } from "../lib/useLiveQuery"; import { focusAccessRequestsTab } from "../lib/adminUsersTab"; 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 { channelYouTubeUrl } from "../lib/format"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { LEVEL_STYLE } from "./ui/toastLevels"; import { PageToolbar } from "./ui/PageShell"; import { PageMenu } from "./ui/PageMenu"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useNavigationActions } from "./navigationContext"; import { useChannelsActions } from "./channelsContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { SURFACE } from "./ui/surfaces"; import { SectionLabel } from "./ui/Headings"; import NavGrid from "./ui/NavGrid"; import { navItemAttrs } from "../lib/listNav"; import { useContextMenu, type ContextMenuController } from "./ui/useContextMenu"; import { ContextMenu } from "./ui/ContextMenu"; import { MoreButton } from "./ui/MoreButton"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { menuGroups } from "./ui/menuGroups"; import { HOVER_REVEAL } from "./ui/reveal"; import { useConfirm } from "./confirmContext"; import { RelativeTime } from "./ui/RelativeTime"; // 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; const byId = (n: { id: number }) => n.id; export default function NotificationsPanel() { const { t } = useTranslation(); const { setPage } = useNavigationActions(); const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); // "Focus this channel in the manager" (from a new-subscription notification) lives in // ChannelsProvider — read it here and pass it down to the notification rows. const { focusChannel: onFocusChannel } = useChannelsActions(); const qc = useQueryClient(); const confirm = useConfirm(); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( qk.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: qk.notifications() }); qc.invalidateQueries({ queryKey: qk.notifUnread() }); }; const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh, meta: { failTitle: "errors.writeFailed.notifRead" }, }); const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh, meta: { failTitle: "errors.writeFailed.notifReadAll" }, }); const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh, meta: { failTitle: "errors.writeFailed.notifDismiss" }, }); const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh, meta: { failTitle: "errors.writeFailed.notifClear" }, }); 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(); } // It empties both lists for good (the server's are deleted), so it asks first — it moved from the // band into the header's "…" (E28 C1), where a destructive row keeps its confirm. async function clearEverything() { const ok = await confirm({ title: t("inbox.clearConfirmTitle"), message: t("inbox.clearConfirmBody"), confirmLabel: t("inbox.clearAll"), danger: true, }); if (!ok) return; 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. // The narrowing filters that could leave the video out (a tag, "no tag", a search, an age cap) are // dropped: kept, a channel outside the reader's tag filter found an empty feed. function locate(meta: VideoHiddenMeta | VideoWatchedMeta) { setFilters({ ...filters, tags: [], untagged: false, q: "", maxAgeDays: undefined, show: meta.kind === "video-hidden" ? "hidden" : "watched", channelIds: meta.channelId ? [meta.channelId] : [], }); setPage("feed"); } function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) { api .setState(meta.videoId, "new") .then(() => { qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feedSummary() }); // Quietly resolve the now-stale notice (no confirmation toast — the change is visible). resolveVideo(meta.videoId); }) .catch((e) => notifyActionError(e, t("inbox.revertFailed"))); } // --- The rows' ONE menu (E28 C1) ------------------------------------------------------------- // A row keeps its link and its unread mark in sight; its dismiss shows on hover. Everything it can // do — the link again, find / undo on an activity row, mark read, dismiss — is its menu. // One menu per list, keyed by the notification's id: the server list polls, and the "…" must still // close the menu a poll replaced its row under. const serverMenu = useContextMenu({ keyOf: byId }); const clientMenu = useContextMenu({ keyOf: byId }); const openScheduler = () => setPage("scheduler"); const openDownloads = () => setPage("downloads"); const reviewAccess = () => { focusAccessRequestsTab(); setPage("users"); }; const icon = (I: typeof Check) => ; const dismissRow = (onSelect: () => void): ActionMenuItem => ({ id: "dismiss", label: t("inbox.dismiss"), icon: icon(X), onSelect, }); function serverRows(n: AppNotification): ActionMenuItem[] { const kind = serverKind(n); return menuGroups( [ ...(kind === "scheduler" ? [ { id: "open", label: t("inbox.openScheduler"), icon: icon(Activity), onSelect: openScheduler, }, ] : kind === "download" ? [ { id: "open", label: t("inbox.openDownloads"), icon: icon(Download), onSelect: openDownloads, }, ] : []), ...(!n.read ? [ { id: "read", label: t("inbox.markRead"), icon: icon(Check), onSelect: () => readMut.mutate(n.id), }, ] : []), ], [dismissRow(() => dismissMut.mutate(n.id))], ); } function clientRows(n: ClientNotif): ActionMenuItem[] { const m = n.meta; const own: ActionMenuItem[] = m?.kind === "video-hidden" || m?.kind === "video-watched" ? [ { id: "find", label: t("notifications.findInFeed"), icon: icon(Search), onSelect: () => locate(m), }, { id: "revert", label: t( m.kind === "video-hidden" ? "notifications.unhide" : "notifications.unwatch", ), icon: icon(m.kind === "video-hidden" ? Eye : RotateCcw), onSelect: () => revertState(m), }, ] : m?.kind === "channel-subscribed" ? [ { id: "manager", label: t("notifications.openInManager"), icon: icon(Tv), onSelect: () => onFocusChannel(m.channelName), }, { id: "youtube", label: t("notifications.openOnYouTube"), icon: icon(ExternalLink), // A new tab only opens inside the click (a popup blocker refuses one a task later). inGesture: true, onSelect: () => void window.open(channelYouTubeUrl(m.channelId), "_blank", "noopener,noreferrer"), }, ] : m?.kind === "access-requests" ? [ { id: "review", label: t("common.accessRequestsReview"), icon: icon(UserPlus), onSelect: reviewAccess, }, ] : n.action && !n.dismissed ? [ { id: "action", label: n.action.label, onSelect: () => { n.action!.onClick(); dismissClient(n.id); }, }, ] : []; return menuGroups(own, [dismissRow(() => removeClient(n.id))]); } const more = (menu: ContextMenuController, n: T) => { const mine = menu.open && menu.target?.id === n.id; return ( ); }; // --- The page's own menu: the header's "…" ------------------------------------------------------ const headerMenu = useContextMenu<"inbox">(); const headerItems: ActionMenuItem[] = [ { id: "read-all", label: t("inbox.markAllRead"), icon: icon(CheckCheck), disabled: !hasUnread || readAllMut.isPending, onSelect: markAll, }, { id: "clear", label: t("inbox.clearAll"), icon: icon(Trash2), danger: true, separatorBefore: true, disabled: clearMut.isPending, onSelect: () => void clearEverything(), }, ]; return ( <> {/* The inbox band (B4): Mark all read in sight, Clear everything in its "…" (E28 C1), fixed while the list scrolls. No title card: the nav rail and the tab title name the page, and the shell's hidden h1 names it to a screen reader. */} {/* The page's background menu (DESIGN "Background menu"): the band's own non-destructive action — Clear everything stays behind its "…" and its confirm. */} {hasAny && ( !("danger" in it && it.danger))} /> )} {hasAny && (
)}
{q.isError && !hasAny ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading && !q.data && clientItems.length === 0 ? ( ) : !hasAny ? (
{t("inbox.empty")}
) : (
{serverItems.length > 0 && (
{t("inbox.sectionSystem")} {serverItems.map((n, i) => { return ( readMut.mutate(n.id)} onDismiss={() => dismissMut.mutate(n.id)} onOpenScheduler={openScheduler} onOpenDownloads={openDownloads} /> ); })}
)} {clientItems.length > 0 && (
{t("inbox.sectionActivity")} {clientItems.map((n, i) => { return ( removeClient(n.id)} /> ); })}
)}
)}
); } /** A server notification's link, from its type and typed payload (the row's button and its menu). */ function serverKind(n: AppNotification): "scheduler" | "download" | null { if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler"; if (n.type === "download_done") return "download"; return null; } /** A row's end: its unread mark (server rows), its dismiss on hover (mouse; on touch it is in the * "…"), then its "…". */ function RowEnd({ onRead, onDismiss, more, }: { onRead?: () => void; onDismiss: () => void; more: ReactNode; }) { const { t } = useTranslation(); return (
{onRead && ( )} {more}
); } function NotificationRow({ n, t, navIndex, rowProps, more, onRead, onDismiss, onOpenScheduler, onOpenDownloads, }: { n: AppNotification; t: TFunction; navIndex: number; rowProps: Record; more: ReactNode; onRead: () => void; onDismiss: () => void; onOpenScheduler: () => void; onOpenDownloads: () => 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 = serverKind(n) === "scheduler"; const isDownloadDone = serverKind(n) === "download"; const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string"; let title = n.title; let body = n.body; if (isMaintenance) { // Guarded above (typeof count === "number"); shape the JSON `data` bag for typed access. const d = n.data as { count: number }; title = t("inbox.maintenance.title"); body = t("inbox.maintenance.body", { count: d.count }); } else if (isScheduler) { // " finished/failed", with the raw result summary as the (technical) body. const d = n.data as { job_id: string; status?: string; summary?: string }; const job = t(`scheduler.jobs.${d.job_id}`, d.job_id); title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { job, }); body = d.summary || n.body; } else if (isEmailUndelivered) { // Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name // the message that was lost, since that is what tells the operator what their users didn't get. const d = n.data as { reason: string; subject?: string }; title = t("inbox.emailUndelivered.title"); body = t( d.reason === "not_configured" ? "inbox.emailUndelivered.notConfigured" : "inbox.emailUndelivered.sendFailed", { subject: d.subject || n.body || "" }, ); } else if (isDownloadDone) { // "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body // (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled. const d = n.data as { title?: string | null; kind?: string }; title = t(d.kind === "edit" ? "inbox.editDone.title" : "inbox.downloadDone.title"); body = d.title || n.body; } return (
{!n.read && }
{title}
{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 && ( )} {isDownloadDone && ( )}
); } function ClientActivityRow({ n, t, navIndex, rowProps, more, onFocusChannel, onReviewAccess, onRemove, }: { n: ClientNotif; t: TFunction; navIndex: number; rowProps: Record; more: ReactNode; onFocusChannel: (name: string) => void; onReviewAccess: () => void; onRemove: () => void; }) { const { icon: Icon, color } = LEVEL_STYLE[n.level]; const needsAction = n.requiresInteraction && !n.dismissed; // A hidden / watched notice's Find in feed and Undo are in its menu (E28 C1): the row reads. const quiet = n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched"; const subscribed: ChannelSubscribedMeta | null = n.meta?.kind === "channel-subscribed" ? n.meta : null; const access = n.meta?.kind === "access-requests" ? n.meta : null; return (
{n.title &&
{n.title}
}
{n.message} {n.repeat > 1 && ( ×{n.repeat} )}
{needsAction && ( {t("notifications.actionNeeded")} )}
{quiet ? null : subscribed ? (
e.stopPropagation()} > {t("notifications.openOnYouTube")}
) : access ? ( ) : ( n.action && !n.dismissed && ( ) )}
); }