import { useEffect, useState, 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, ChevronDown, ChevronRight, ExternalLink, Eye, FolderOpen, History, KeyRound, RotateCcw, Search, Trash2, Tv, UserPlus, X, } from "lucide-react"; import { api, TAKEOUT_JOB, type AppNotification } from "../../lib/api"; import { jobRunText } from "../../lib/jobResult"; import { notifyActionError } from "../../lib/actionErrors"; import { useLiveQuery } from "../../lib/useLiveQuery"; import { focusAccessRequestsTab } from "../../lib/adminUsersTab"; import { focusSettingsTab } from "../../lib/settingsTab"; import { type ChannelSubscribedMeta, clearAll as clearClient, dismiss as dismissClient, getNotifications, getUnreadCount, markAllRead as markAllClientRead, msg, type Notification as ClientNotif, remove as removeClient, resolveVideo, sayNotif, subscribe, type VideoHiddenMeta, type VideoWatchedMeta, } from "../../lib/notifications"; import { channelYouTubeUrl, formatDate } from "../../lib/format"; import { clientAsks, entryKey, keyOf, orderInbox, type InboxEntry } from "../../lib/inboxOrder"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; import { Pressable } from "../../components/ui/Pressable"; import { LEVEL_STYLE } from "../../components/ui/toastLevels"; import { PageToolbar } from "../../components/ui/PageShell"; import { PageMenu } from "../../components/ui/PageMenu"; import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { useNavigationActions } from "../../features/navigation/navigationContext"; import { writeVideoState } from "../../features/video/videoWrites"; import { useFilesActions } from "../files"; import { useChannelsActions } from "../channels"; import { useFeedFilters, useFeedFiltersActions } from "../feed"; import { SURFACE } from "../../components/ui/surfaces"; import { SectionLabel } from "../../components/ui/Headings"; import NavGrid from "../../components/ui/NavGrid"; import { focusNavItem, navItemAttrs, pendingNavName } from "../../lib/listNav"; import { useContextMenu, type ContextMenuController } from "../../components/ui/useContextMenu"; import { ContextMenu } from "../../components/ui/ContextMenu"; import { MoreButton } from "../../components/ui/MoreButton"; import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { menuGroups } from "../../components/ui/menuGroups"; import { HOVER_REVEAL } from "../../components/ui/reveal"; import { useConfirm } from "../../components/ui/confirmContext"; import { RelativeTime } from "../../components/ui/RelativeTime"; // The single notification center, over two sources: the durable, server-backed notifications, and // this browser's client-side 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. Both sources share one order // (`lib/inboxOrder`): what asks for the reader first, then one stream, old read rows folded. const POLL_MS = 8000; const byId = (n: { id: number }) => n.id; export default function NotificationsPanel() { const { t, i18n } = useTranslation(); const { setPage } = useNavigationActions(); const { openFile } = useFilesActions(); 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); // The rows that asked for the reader in this visit keep their place in "Needs you" once read // (`lib/inboxOrder`): seeded with the client notices unread at open, which the effect below marks // read, and grown by every row that asks while the center is open. Mark all read empties it — the // one gesture that means "I have seen the lot". The fold is measured from the time the center // opened, fixed for the visit, so a row does not fold away under the reader. const [held, setHeld] = useState>( () => new Set(getNotifications().flatMap((n) => (n.read ? [] : [keyOf("client", n.id)]))), ); const [openedAt] = useState(() => Date.now()); const [earlierOpen, setEarlierOpen] = useState(false); // Back to a row that folded away since it was opened (read on that visit, and over a week old): // Earlier opens on it, or the restore finds no list holding it and the focus falls on the row now // at its old place. Read once, as the center opens; settled once the server's list is in. const [seeking, setSeeking] = useState(pendingNavName); // 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() }); }; // A row marked read reads read at once, in the press's own render: its Mark read button hands the // focus to the row, and a row still unread then was announced "…, unread" until the write landed. const [readNow, setReadNow] = useState>(() => new Set()); const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh, // Refused: the row reads unread again. onError: (_e, id) => setReadNow((s) => { const next = new Set(s); next.delete(id); return next; }), meta: { failTitle: "errors.writeFailed.notifRead" }, }); function markRead(id: number) { setReadNow((s) => new Set(s).add(id)); readMut.mutate(id); } const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, // Settled, not only succeeded: a refused write brings the rows back unread at once, rather than // leaving them read until the next poll moves them under the reader. // And it settles once the read after it has landed: until then the cache holds what a read // before the commit said, and the band would take those rows back the moment the mask below lifts. onSettled: () => Promise.all([ qc.invalidateQueries({ queryKey: qk.notifications() }), qc.invalidateQueries({ queryKey: qk.notifUnread() }), ]), 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" }, }); // While Mark all read is on its way, a read that lands before the server commits it (the poll, or // another row's write refreshing the list) still says "unread": those rows are read already, or // the band would take them back and hold them for the rest of the visit. const shownServer = serverItems.map((n) => n.read || !(readAllMut.isPending || readNow.has(n.id)) ? n : { ...n, read: true }, ); const order = orderInbox(shownServer, clientItems, { now: openedAt, held }); const asking = order.needsYou.map(entryKey).filter((k) => !held.has(k)); if (asking.length > 0) setHeld(new Set([...held, ...asking])); if (seeking && q.data) { setSeeking(null); // Only a row that can be nowhere else: a namesake in Earlier while the row itself is still in // sight would open it for nothing, and the name standing twice on the page loses the row. const sought = (e: InboxEntry) => entryName(e, t, i18n.language) === seeking; if (!order.needsYou.some(sought) && !order.recent.some(sought) && order.earlier.some(sought)) setEarlierOpen(true); } const serverUnread = serverItems.some((n) => !n.read); const hasAny = serverItems.length > 0 || clientItems.length > 0; // A band of rows already read still offers the gesture that empties it — but not one that only // holds what asks by its kind (an access-requests nudge): pressing it would change nothing. const canMarkAll = serverUnread || clientUnread > 0 || order.needsYou.some((e) => e.src === "server" || !clientAsks(e.n)); function markAll() { markAllClientRead(); // Read at once, not when the write lands: until then the rows still read unread, and the band // would take them straight back. A failed write refetches the truth when it settles. void qc.cancelQueries({ queryKey: qk.notifications() }); qc.setQueryData<{ items: AppNotification[]; total: number }>(qk.notifications(), (d) => d ? { ...d, items: d.items.map((n) => (n.read ? n : { ...n, read: true })) } : d, ); setHeld(new Set()); 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 "…", 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. // Built from nothing, not spread over the reader's filters: EVERY narrowing that could leave the // video out goes — a tag, "no tag", a search, the age cap, the year, the custom range, the length, // a content type (a hidden Short under "no Shorts"), the scope and the library source (the // spread kept whatever this list forgot). Only how the reader orders and matches stays. function locate(meta: VideoHiddenMeta | VideoWatchedMeta) { setFilters({ tags: [], tagModes: filters.tagModes, untagged: false, q: "", includeNormal: true, includeShorts: true, includeLive: true, scope: "all", librarySource: "all", sort: filters.sort, show: meta.kind === "video-hidden" ? "hidden" : "watched", channelIds: meta.channelId ? [meta.channelId] : [], }); setPage("feed"); } function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) { writeVideoState(qc, 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, msg("inbox.revertFailed"))); } // --- The rows' ONE menu ------------------------------------------------------------- // 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 openTokens = () => { focusSettingsTab("tokens"); setPage("settings"); }; const openTakeout = () => { focusSettingsTab("account"); setPage("settings"); }; // A finished download opens on its file, in its folder; a notice from before it named one, on Files. const openDone = (n: AppNotification) => { const { job_id: id, folder_id: folder } = n.data ?? {}; if (typeof id === "number") openFile(id, typeof folder === "number" ? folder : null); else setPage("files"); }; 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 === "takeout" ? [ { id: "open", label: t("inbox.openTakeout"), icon: icon(History), onSelect: openTakeout, }, ] : kind === "scheduler" ? [ { id: "open", label: t("inbox.openScheduler"), icon: icon(Activity), onSelect: openScheduler, }, ] : kind === "download" ? [ { id: "open", label: t("inbox.openFile"), icon: icon(FolderOpen), onSelect: () => openDone(n), }, ] : kind === "token" ? [ { id: "open", label: t("inbox.openTokens"), icon: icon(KeyRound), onSelect: openTokens, }, ] : []), ...(!n.read ? [ { id: "read", label: t("inbox.markRead"), icon: icon(Check), onSelect: () => markRead(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.dismissed ? liveActions(n).map((action, i) => ({ id: `action${i}`, label: sayNotif(action.label, t), onSelect: () => { 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 ( ); }; // One row of either source; `i` is its place in the list it is rendered into. const entry = (e: InboxEntry, i: number) => e.src === "server" ? ( markRead(e.n.id)} onDismiss={() => dismissMut.mutate(e.n.id)} onOpenScheduler={openScheduler} onOpenTakeout={openTakeout} onOpenFile={() => openDone(e.n)} onOpenTokens={openTokens} /> ) : ( removeClient(e.n.id)} /> ); // --- 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: !canMarkAll || 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 "…", 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")}
) : (
{order.needsYou.length > 0 && (
{t("inbox.sectionNeedsYou", { count: order.needsYou.length })} {order.needsYou.map(entry)}
)} {order.recent.length > 0 && (
{t("inbox.sectionRecent")} {order.recent.map(entry)}
)} {order.earlier.length > 0 && (
{/* A heading like its siblings, its text the disclosure's button (the ARIA disclosure pattern); 24px tall so the target clears the WCAG 2.5.8 floor. */} setEarlierOpen((o) => !o)} className="min-h-6 inline-flex items-center gap-1 uppercase hover:text-fg transition" > {earlierOpen ? ( ) : ( )} {t("inbox.earlier", { count: order.earlier.length })} {earlierOpen && ( {order.earlier.map(entry)} )}
)}
)}
); } /** A client notice's live actions (they do not survive a reload): its action, then the lesser one. */ function liveActions(n: ClientNotif): NonNullable[] { return [n.action, n.secondaryAction].filter((a) => a !== undefined); } /** A server notification's link, from its type and typed payload (the row's button and its menu). */ // The download-lifecycle notification types → their `inbox.` copy. const DOWNLOAD_LIFECYCLE: Record = { download_shared: "downloadShared", download_expiring: "downloadExpiring", download_expired: "downloadExpired", download_evicted: "downloadEvicted", }; // A `download_done` notice's title by the producer's `kind`; any other kind reads as a download. const DONE_TITLE: Record = { edit: "inbox.editDone.title", upload: "inbox.uploadDone.title", }; function serverKind(n: AppNotification): "scheduler" | "takeout" | "download" | "token" | null { // A Takeout import's run is the user's own: told like any run, opened where they started it. if (n.type === "scheduler" && n.data?.job_id === TAKEOUT_JOB) return "takeout"; if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler"; if (n.type === "download_done") return "download"; if (n.type === "api_token_expiring" && typeof n.data?.name === "string") return "token"; 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 && ( { // The button goes once the row reads read, and the focus on it would fall to : // hand it to the row first, where j / k carry on. const row = e.currentTarget.closest("[data-nav-index]"); if (row?.parentElement && document.activeElement === e.currentTarget) focusNavItem(row.parentElement, Number(row.getAttribute("data-nav-index"))); onRead(); }} > )} {more}
); } /** What a server row says: its title and body — in the reader's language for a known type. */ function serverRowText( n: AppNotification, t: TFunction, lang: string, ): { title: string; body: string | null } { // 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 isTakeout = serverKind(n) === "takeout"; const isScheduler = serverKind(n) === "scheduler" || isTakeout; const isDownloadDone = serverKind(n) === "download"; const isTokenExpiring = serverKind(n) === "token"; const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string"; // A download's life after it is done: shared with you, about to expire, expired, evicted. const lifecycle = DOWNLOAD_LIFECYCLE[n.type]; 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 result as the body — named in the reader's language // when it rode along as data, else the English summary of an older notice. const run = jobRunText(t, n.data as { job_id: string }, lang); title = run.title; body = run.body || 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 (lifecycle) { // The server stores an English title ("… was shared with you", or the bare word "download" for an // untitled video); the notice is named from `data`, the video's title as the body — none when it // has no title (or the share notice predates the title riding along). const d = n.data as { title?: string | null; from?: string }; title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" }); body = d.title || null; } else if (isTokenExpiring) { // A machine's key to the library runs out within a week: which one, and when. const d = n.data as { name: string; expires_at?: string | null }; title = t("inbox.apiTokenExpiring.title"); body = t("inbox.apiTokenExpiring.body", { name: d.name, date: formatDate(d.expires_at, lang) }); } else if (isDownloadDone) { // "Download ready" / "Edit ready" / "Upload 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 or an // upload isn't called a download. const d = n.data as { title?: string | null; kind?: string }; title = t(DONE_TITLE[d.kind ?? ""] ?? "inbox.downloadDone.title"); body = d.title || n.body; } return { title, body }; } /** A row's name, for a screen reader and for Back to find it by: what it is about, not its kind. * Trimmed as Back saves it (`lib/listNav`), or a title ending in a space never matches its row. */ const rowName = (title: string, body: string | null | undefined): string => (body ? `${title}: ${body}` : title).trim(); const clientRowName = (n: ClientNotif, t: TFunction): string => n.title ? rowName(sayNotif(n.title, t), sayNotif(n.message, t)) : rowName(sayNotif(n.message, t), null); const entryName = (e: InboxEntry, t: TFunction, lang: string): string => { if (e.src === "client") return clientRowName(e.n, t); const { title, body } = serverRowText(e.n, t, lang); return rowName(title, body); }; function NotificationRow({ n, t, navIndex, rowProps, more, onRead, onDismiss, onOpenScheduler, onOpenTakeout, onOpenFile, onOpenTokens, }: { n: AppNotification; t: TFunction; navIndex: number; rowProps: Record; more: ReactNode; onRead: () => void; onDismiss: () => void; onOpenScheduler: () => void; onOpenTakeout: () => void; onOpenFile: () => void; onOpenTokens: () => void; }) { const lang = useTranslation().i18n.language; // 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 : []; const { title, body } = serverRowText(n, t, lang); const isTakeout = serverKind(n) === "takeout"; const isScheduler = serverKind(n) === "scheduler" || isTakeout; const isDownloadDone = serverKind(n) === "download"; const isTokenExpiring = serverKind(n) === "token"; // Named by what it is about, not its kind alone: every "Download ready" read the same to a screen // reader, and Back could not tell the row it was opened from by its name. const name = rowName(title, 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 && ( )} {isTokenExpiring && ( )} {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: 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 &&
{sayNotif(n.title, t)}
}
{sayNotif(n.message, t)} {n.repeat > 1 && ( ×{n.repeat} )}
{needsAction && ( {t("notifications.actionNeeded")} )}
{quiet ? null : subscribed ? (
e.stopPropagation()} > {t("notifications.openOnYouTube")}
) : access ? ( ) : ( !n.dismissed && liveActions(n).length > 0 && (
{liveActions(n).map((action, i) => ( ))}
) )}
); }