Files
siftlode/frontend/src/modules/notifications/NotificationsPanel.tsx
T
peter b515c16117 fix(notifications): a notice's button is said when shown, like its text
An action label was translated when the notice was raised, so after a language switch a live
toast or center row read in one language with its button in the other. The access-requests nudge
no longer re-issues on a switch for it. New tests: the button and a server refusal follow a
switch.
2026-10-30 00:10:38 +01:00

944 lines
36 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, 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<ReadonlySet<string>>(
() => 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<ReadonlySet<number>>(() => 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<AppNotification>({ keyOf: byId });
const clientMenu = useContextMenu<ClientNotif>({ 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) => <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 === "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 = <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"}
/>
);
};
// 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" ? (
<NotificationRow
key={entryKey(e)}
n={e.n}
t={t}
navIndex={i}
rowProps={serverMenu.triggerProps(e.n)}
more={more(serverMenu, e.n)}
onRead={() => markRead(e.n.id)}
onDismiss={() => dismissMut.mutate(e.n.id)}
onOpenScheduler={openScheduler}
onOpenTakeout={openTakeout}
onOpenFile={() => openDone(e.n)}
onOpenTokens={openTokens}
/>
) : (
<ClientActivityRow
key={entryKey(e)}
n={e.n}
t={t}
navIndex={i}
rowProps={clientMenu.triggerProps(e.n)}
more={more(clientMenu, e.n)}
onFocusChannel={onFocusChannel}
onReviewAccess={reviewAccess}
onRemove={() => 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 && (
<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={!canMarkAll || 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">
{order.needsYou.length > 0 && (
<section>
<SectionLabel className="mb-2">
{t("inbox.sectionNeedsYou", { count: order.needsYou.length })}
</SectionLabel>
<NavGrid className="flex flex-col gap-2">{order.needsYou.map(entry)}</NavGrid>
</section>
)}
{order.recent.length > 0 && (
<section>
<SectionLabel className="mb-2">{t("inbox.sectionRecent")}</SectionLabel>
<NavGrid className="flex flex-col gap-2">{order.recent.map(entry)}</NavGrid>
</section>
)}
{order.earlier.length > 0 && (
<section>
{/* 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. */}
<SectionLabel className={earlierOpen ? "mb-2" : undefined}>
<Pressable
aria-expanded={earlierOpen}
onClick={() => setEarlierOpen((o) => !o)}
className="min-h-6 inline-flex items-center gap-1 uppercase hover:text-fg transition"
>
{earlierOpen ? (
<ChevronDown className="size-3.5" aria-hidden />
) : (
<ChevronRight className="size-3.5" aria-hidden />
)}
{t("inbox.earlier", { count: order.earlier.length })}
</Pressable>
</SectionLabel>
{earlierOpen && (
<NavGrid className="flex flex-col gap-2">{order.earlier.map(entry)}</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 client notice's live actions (they do not survive a reload): its action, then the lesser one. */
function liveActions(n: ClientNotif): NonNullable<ClientNotif["action"]>[] {
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.<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" | "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 (
<div className="flex items-center gap-1 shrink-0">
{onRead && (
<IconButton
label={t("inbox.markRead")}
size="sm"
variant="ghost"
onClick={(e) => {
// The button goes once the row reads read, and the focus on it would fall to <body>:
// hand it to the row first, where j / k carry on.
const row = e.currentTarget.closest<HTMLElement>("[data-nav-index]");
if (row?.parentElement && document.activeElement === e.currentTarget)
focusNavItem(row.parentElement, Number(row.getAttribute("data-nav-index")));
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>
);
}
/** 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) {
// "<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 (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<string, unknown>;
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 (
<div
// The unread state is spoken too (the dot is only drawn), apart from the name Back finds it by.
{...navItemAttrs(navIndex, name, {
label: n.read ? undefined : t("inbox.unreadName", { name }),
})}
{...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={isTakeout ? onOpenTakeout : onOpenScheduler}
icon={
isTakeout ? <History className="w-3.5 h-3.5" /> : <Activity className="w-3.5 h-3.5" />
}
>
{t(isTakeout ? "inbox.openTakeout" : "inbox.openScheduler")}
</Button>
)}
{isTokenExpiring && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenTokens}
icon={<KeyRound className="w-3.5 h-3.5" />}
>
{t("inbox.openTokens")}
</Button>
)}
{isDownloadDone && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenFile}
icon={<FolderOpen className="w-3.5 h-3.5" />}
>
{t("inbox.openFile")}
</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, clientRowName(n, t))}
{...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">{sayNotif(n.title, t)}</div>}
<div className="text-sm wrap-break-word">
{sayNotif(n.message, t)}
{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.dismissed &&
liveActions(n).length > 0 && (
<div className="mt-1 flex flex-wrap gap-x-4">
{liveActions(n).map((action, i) => (
<Button
key={i}
variant="link"
className="text-sm"
// The first is the row's own: Enter on the row runs it.
data-nav-open={i === 0 ? "" : undefined}
onClick={() => {
action.onClick();
dismissClient(n.id);
}}
>
{sayNotif(action.label, t)}
</Button>
))}
</div>
)
)}
</div>
<RowEnd onDismiss={onRemove} more={more} />
</div>
);
}