Files
siftlode/frontend/src/components/NotificationsPanel.tsx
T
peter 6073a006b8 fix(downloads): an upload's cancel, progress, failure and notice speak of an upload
- Cancelling an upload asks "Delete upload?" and says the uploaded file is
  removed from the server with it.
- The phone-safe conversion's real ffmpeg % shows as a bar ("Converting for
  phones"), not an indeterminate pulse.
- A failed upload is titled "The upload isn't ready", with its own reasons
  (convert_failed, retryable; upload_missing) instead of download copy.
- The inbox says "Upload ready to share" for a converted upload.
2026-10-06 03:19:32 +02:00

729 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { jobRunText } from "../lib/jobResult";
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 "…", 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) {
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 -------------------------------------------------------------
// 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<AppNotification>({ keyOf: byId });
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
const openScheduler = () => setPage("scheduler");
const openDownloads = () => setPage("downloads");
const reviewAccess = () => {
focusAccessRequestsTab();
setPage("users");
};
const icon = (I: typeof Check) => <I className="w-4 h-4" />;
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 = <T extends { id: number }>(menu: ContextMenuController<T>, n: T) => {
const mine = menu.open && menu.target?.id === n.id;
return (
<MoreButton
label={t("inbox.rowMenu")}
expanded={mine}
menuProps={menu.buttonProps(n)}
className={mine ? undefined : "reveal"}
/>
);
};
// --- 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 "…", 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 && (
<PageMenu
ariaLabel={t("common.pageMenu")}
items={headerItems.filter((it) => !("danger" in it && it.danger))}
/>
)}
{hasAny && (
<PageToolbar>
<div className="px-4 pt-2 pb-1 max-w-3xl w-full mx-auto flex justify-end items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={markAll}
disabled={!hasUnread || readAllMut.isPending}
icon={<CheckCheck />}
>
{t("inbox.markAllRead")}
</Button>
<MoreButton
label={t("inbox.menu")}
expanded={headerMenu.open}
menuProps={headerMenu.buttonProps("inbox")}
/>
</div>
</PageToolbar>
)}
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto space-y-4">
{q.isError && !hasAny ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data && clientItems.length === 0 ? (
<LoadingState shape="list" />
) : !hasAny ? (
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
{t("inbox.empty")}
</div>
) : (
<div className="space-y-4">
{serverItems.length > 0 && (
<section>
<SectionLabel className="mb-2">{t("inbox.sectionSystem")}</SectionLabel>
<NavGrid className="flex flex-col gap-2">
{serverItems.map((n, i) => {
return (
<NotificationRow
key={n.id}
n={n}
t={t}
navIndex={i}
rowProps={serverMenu.triggerProps(n)}
more={more(serverMenu, n)}
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler}
onOpenDownloads={openDownloads}
/>
);
})}
</NavGrid>
</section>
)}
{clientItems.length > 0 && (
<section>
<SectionLabel className="mb-2">{t("inbox.sectionActivity")}</SectionLabel>
<NavGrid className="flex flex-col gap-2">
{clientItems.map((n, i) => {
return (
<ClientActivityRow
key={n.id}
n={n}
t={t}
navIndex={i}
rowProps={clientMenu.triggerProps(n)}
more={more(clientMenu, n)}
onFocusChannel={onFocusChannel}
onReviewAccess={reviewAccess}
onRemove={() => removeClient(n.id)}
/>
);
})}
</NavGrid>
</section>
)}
</div>
)}
</div>
<ContextMenu
menu={serverMenu}
ariaLabel={t("inbox.rowMenu")}
items={serverMenu.target ? serverRows(serverMenu.target) : []}
/>
<ContextMenu
menu={clientMenu}
ariaLabel={t("inbox.rowMenu")}
items={clientMenu.target ? clientRows(clientMenu.target) : []}
/>
<ContextMenu menu={headerMenu} ariaLabel={t("inbox.menu")} items={headerItems} />
</>
);
}
/** 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.<key>` copy.
const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
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<string, string | undefined> = {
edit: "inbox.editDone.title",
upload: "inbox.uploadDone.title",
};
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 (
<div className="flex items-center gap-1 shrink-0">
{onRead && (
<IconButton label={t("inbox.markRead")} size="sm" variant="ghost" onClick={onRead}>
<Check className="w-4 h-4" />
</IconButton>
)}
<IconButton
label={t("inbox.dismiss")}
size="sm"
variant="ghost"
onClick={onDismiss}
className={HOVER_REVEAL}
>
<X className="w-4 h-4" />
</IconButton>
{more}
</div>
);
}
function NotificationRow({
n,
t,
navIndex,
rowProps,
more,
onRead,
onDismiss,
onOpenScheduler,
onOpenDownloads,
}: {
n: AppNotification;
t: TFunction;
navIndex: number;
rowProps: Record<string, unknown>;
more: ReactNode;
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
onOpenDownloads: () => 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
: [];
// 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";
// 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) {
// "<Job label> 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 (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 (
<div
{...navItemAttrs(navIndex, title)}
{...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 transition ${
n.read ? "opacity-70" : ""
}`}
>
{!n.read && <span className="mt-1.5 w-2 h-2 rounded-full bg-accent shrink-0" aria-hidden />}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{title}</span>
<RelativeTime at={n.created_at} className="text-2xs text-muted shrink-0" />
</div>
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
{videos.length > 0 && (
<ul className="mt-2 text-xs text-muted list-disc pl-4 space-y-0.5">
{videos.slice(0, 8).map((v) => (
<li key={v.id} className="truncate">
{v.title || v.id}
</li>
))}
{videos.length > 8 && (
<li className="list-none text-muted/70">
{t("inbox.andMore", { count: videos.length - 8 })}
</li>
)}
</ul>
)}
{isScheduler && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenScheduler}
icon={<Activity className="w-3.5 h-3.5" />}
>
{t("inbox.openScheduler")}
</Button>
)}
{isDownloadDone && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenDownloads}
icon={<Download className="w-3.5 h-3.5" />}
>
{t("inbox.openDownloads")}
</Button>
)}
</div>
<RowEnd onRead={n.read ? undefined : onRead} onDismiss={onDismiss} more={more} />
</div>
);
}
function ClientActivityRow({
n,
t,
navIndex,
rowProps,
more,
onFocusChannel,
onReviewAccess,
onRemove,
}: {
n: ClientNotif;
t: TFunction;
navIndex: number;
rowProps: Record<string, unknown>;
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 (
<div
{...navItemAttrs(navIndex, n.title || n.message)}
{...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm wrap-break-word">
{n.message}
{n.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{n.repeat}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<RelativeTime at={n.ts} className="text-2xs text-muted" />
{needsAction && (
<span className="text-3xs uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
</span>
)}
</div>
{quiet ? null : subscribed ? (
<div className="flex items-center gap-3 mt-1">
<Button
variant="link"
className="text-sm"
data-nav-open=""
onClick={() => onFocusChannel(subscribed.channelName)}
icon={<Tv className="w-3.5 h-3.5" />}
>
{t("notifications.openInManager")}
</Button>
<a
href={channelYouTubeUrl(subscribed.channelId)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
// The link keeps the browser's own right-click menu; the row's answers elsewhere.
onContextMenu={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3.5 h-3.5" />
{t("notifications.openOnYouTube")}
</a>
</div>
) : access ? (
<Button
variant="link"
className="text-sm mt-1"
data-nav-open=""
onClick={() => onReviewAccess()}
icon={<UserPlus className="w-3.5 h-3.5" />}
>
{t("common.accessRequestsReview")}
</Button>
) : (
n.action &&
!n.dismissed && (
<Button
variant="link"
className="mt-1 text-sm"
data-nav-open=""
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
}}
>
{n.action.label}
</Button>
)
)}
</div>
<RowEnd onDismiss={onRemove} more={more} />
</div>
);
}