refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,453 @@
// @vitest-environment jsdom
//
// The notification center: a row keeps its link and its unread mark in sight, its dismiss
// shows on hover (on touch it is in the "…"), and everything it can do is its ONE menu — opened by
// the row's "…" or a right-click; a hidden / watched notice's Find in feed and Undo live only there.
// The band keeps Mark all read; Clear everything moved into the header's "…" and asks first.
// i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type AppNotification } from "../../lib/api";
import type { Notification as ClientNotif } from "../../lib/notifications";
const h = vi.hoisted(() => ({
server: [] as unknown[],
client: [] as unknown[],
setPage: vi.fn(),
openFile: vi.fn(),
focusSettingsTab: vi.fn(),
setFilters: vi.fn(),
remove: vi.fn(),
clearAll: vi.fn(),
confirm: vi.fn(async () => true),
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
vi.mock("../../lib/useLiveQuery", () => ({
useLiveQuery: () => ({
data: { items: h.server, total: h.server.length },
isError: false,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
subscribe: () => () => {},
getNotifications: () => h.client,
getUnreadCount: () => 0,
markAllRead: () => {},
remove: (id: number) => h.remove(id),
clearAll: () => h.clearAll(),
}));
vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
vi.mock("../../features/navigation/navigationContext", () => ({
useNavigationActions: () => ({ setPage: h.setPage }),
}));
vi.mock("../files/filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
vi.mock("../channels/channelsContext", () => ({
useChannelsActions: () => ({ focusChannel: vi.fn() }),
}));
vi.mock("../feed/feedFiltersContext", () => ({
// Every narrowing a feed can carry (the year, the custom range, the length, the
// content types and the scope survived "Find in feed" and could still hide the video).
useFeedFilters: () => ({
tags: [54],
tagModes: { language: "and", topic: "or", other: "or" },
untagged: true,
q: "rust",
maxAgeDays: 30,
dateFrom: "2024-01-01",
dateTo: "2024-02-01",
year: 2024,
minDuration: 60,
maxDuration: 600,
includeNormal: true,
includeShorts: false,
includeLive: false,
scope: "my",
librarySource: "organic",
sort: "oldest",
show: "unwatched",
channelIds: ["x"],
}),
useFeedFiltersActions: () => ({ setFilters: h.setFilters }),
}));
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import NotificationsPanel from "./NotificationsPanel";
import PageShell from "../../components/ui/PageShell";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
for (const f of [
h.setPage,
h.openFile,
h.focusSettingsTab,
h.setFilters,
h.remove,
h.clearAll,
h.confirm,
])
f.mockClear();
});
const server = (id: number, extra: Partial<AppNotification> = {}) =>
({
id,
type: "download_done",
title: `Server ${id}`,
body: `Body ${id}`,
data: { title: `Ready ${id}` },
read: false,
created_at: new Date().toISOString(),
...extra,
}) as unknown as AppNotification;
const client = (id: number, extra: Partial<ClientNotif> = {}) =>
({
id,
level: "success",
message: `Activity ${id}`,
requiresInteraction: false,
ts: Date.now(),
read: true,
dismissed: true,
transient: false,
repeat: 1,
...extra,
}) as unknown as ClientNotif;
function mount() {
const client = new QueryClient();
const tree = () => (
<QueryClientProvider client={client}>
<NotificationsPanel />
</QueryClientProvider>
);
const { rerender } = render(tree());
return { rerender: () => rerender(tree()) };
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="inbox.rowMenu"]')!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu(open: () => void) {
act(open);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
const choose = (label: string) =>
fireEvent.click(
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
);
describe("NotificationsPanel · a server row", () => {
it("keeps its link and mark read in sight; dismiss shows on hover, then its '…'", () => {
h.server = [server(1)];
h.client = [];
mount();
const row = rowOf("Ready 1");
expect(row.classList.contains("group")).toBe(true);
const buttons = [...row.querySelectorAll("button")];
expect(buttons.map((b) => b.getAttribute("aria-label") ?? b.textContent)).toEqual([
"inbox.openFile",
"inbox.markRead",
"inbox.dismiss",
"inbox.rowMenu",
]);
// Hover-only on a mouse (and gone on touch, where the menu holds it).
expect(buttons[2]!.className).toContain("reveal-hover");
expect(moreOf("Ready 1").classList.contains("reveal")).toBe(true);
});
it("a finished download opens on its file, in its folder; an older notice on Files", async () => {
h.server = [
server(1, { data: { title: "Ready 1", kind: "download", job_id: 41, folder_id: 3 } }),
server(2),
];
h.client = [];
mount();
fireEvent.click(within(rowOf("Ready 1")).getByText("inbox.openFile"));
expect(h.openFile).toHaveBeenCalledWith(41, 3);
fireEvent.click(within(rowOf("Ready 2")).getByText("inbox.openFile"));
expect(h.setPage).toHaveBeenCalledWith("files");
expect(h.openFile).toHaveBeenCalledTimes(1);
});
it("a token about to run out is named in the reader's words and opens Settings on its tab", async () => {
h.server = [
server(1, {
type: "api_token_expiring",
title: "API token Phone expires soon",
data: { token_id: 4, name: "Phone", expires_at: "2026-10-21T10:00:00+00:00" },
}),
];
h.client = [];
mount();
const row = rowOf("inbox.apiTokenExpiring.title");
expect(row.textContent).toContain("inbox.apiTokenExpiring.body");
expect(row.textContent).not.toContain("API token Phone expires soon");
fireEvent.click(within(row).getByText("inbox.openTokens"));
expect(h.focusSettingsTab).toHaveBeenCalledWith("tokens");
expect(h.setPage).toHaveBeenCalledWith("settings");
await openMenu(() => fireEvent.click(moreOf("inbox.apiTokenExpiring.title")));
expect(menuRows()).toEqual(["inbox.openTokens", "inbox.markRead", "inbox.dismiss"]);
});
it("holds the link, mark read and dismiss", async () => {
h.server = [server(1)];
h.client = [];
const dismiss = vi.spyOn(api, "dismissNotification").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.click(moreOf("Ready 1")));
expect(menuRows()).toEqual(["inbox.openFile", "inbox.markRead", "inbox.dismiss"]);
choose("inbox.dismiss");
await waitFor(() => expect(dismiss).toHaveBeenCalledWith(1, expect.anything()));
});
it("closes on a second press of the same '…', on a server and an activity row", async () => {
h.server = [server(1)];
h.client = [client(8)];
mount();
for (const name of ["Ready 1", "Activity 8"]) {
await openMenu(() => fireEvent.click(moreOf(name)));
act(() => {
fireEvent.pointerDown(moreOf(name), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf(name));
fireEvent.click(moreOf(name), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
}
});
it("still closes when a poll replaced the notification under the open menu", async () => {
h.server = [server(1)];
h.client = [];
const { rerender } = mount();
await openMenu(() => fireEvent.click(moreOf("Ready 1")));
h.server = [server(1)]; // the next poll: a NEW object for the same notification
rerender();
act(() => {
fireEvent.pointerDown(moreOf("Ready 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Ready 1"));
fireEvent.click(moreOf("Ready 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
});
it("leaves mark read out of a read row's menu", async () => {
h.server = [server(2, { read: true, type: "maintenance", data: {} } as never)];
h.client = [];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Server 2")));
expect(menuRows()).toEqual(["inbox.dismiss"]);
});
});
describe("NotificationsPanel · an activity row", () => {
it("puts a hidden notice's Find in feed and Undo into its menu only", async () => {
h.server = [];
// Its toast is still up (not dismissed), so it carries the toast's Undo too: the row shows
// neither that nor Find in feed — only its dismiss and its "…".
h.client = [
client(7, {
dismissed: false,
action: { label: "feed.undo", onClick: () => {} },
meta: { kind: "video-hidden", videoId: "v", channelId: "c" },
} as never),
];
const setState = vi.spyOn(api, "setState").mockResolvedValue({} as never);
mount();
expect(
[...rowOf("Activity 7").querySelectorAll("button")].map((b) => b.getAttribute("aria-label")),
).toEqual(["inbox.dismiss", "inbox.rowMenu"]);
await openMenu(() => fireEvent.contextMenu(rowOf("Activity 7")));
expect(menuRows()).toEqual([
"notifications.findInFeed",
"notifications.unhide",
"inbox.dismiss",
]);
choose("notifications.unhide");
await waitFor(() => expect(setState).toHaveBeenCalledWith("v", "new"));
});
it("a live notice with two actions shows both, in the row and in its menu", async () => {
h.server = [];
const copy = vi.fn();
const open = vi.fn();
h.client = [
client(5, {
dismissed: false,
action: { label: "tray.done.copyLink", onClick: copy },
secondaryAction: { label: "tray.done.open", onClick: open },
}),
];
mount();
fireEvent.click(within(rowOf("Activity 5")).getByText("tray.done.open"));
expect(open).toHaveBeenCalled();
await openMenu(() => fireEvent.contextMenu(rowOf("Activity 5")));
expect(menuRows()).toEqual(["tray.done.copyLink", "tray.done.open", "inbox.dismiss"]);
choose("tray.done.copyLink");
await waitFor(() => expect(copy).toHaveBeenCalled());
});
it("finds a watched notice's video with nothing narrowing the feed past it", async () => {
h.server = [];
h.client = [
client(9, { meta: { kind: "video-watched", videoId: "v", channelId: "c" } } as never),
];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Activity 9")));
choose("notifications.findInFeed");
await waitFor(() => expect(h.setFilters).toHaveBeenCalled());
// Nothing but the state and the channel narrows; the reader's sort and tag modes stay theirs.
expect(h.setFilters).toHaveBeenCalledWith({
tags: [],
tagModes: { language: "and", topic: "or", other: "or" },
untagged: false,
q: "",
includeNormal: true,
includeShorts: true,
includeLive: true,
scope: "all",
librarySource: "all",
sort: "oldest",
show: "watched",
channelIds: ["c"],
});
expect(h.setPage).toHaveBeenCalledWith("feed");
});
it("dismisses a plain activity row from its menu", async () => {
h.server = [];
h.client = [client(8)];
mount();
await openMenu(() => fireEvent.click(moreOf("Activity 8")));
expect(menuRows()).toEqual(["inbox.dismiss"]);
choose("inbox.dismiss");
await waitFor(() => expect(h.remove).toHaveBeenCalledWith(8));
});
});
describe("NotificationsPanel · the header's '…'", () => {
it("keeps Mark all read in the band and asks before Clear everything", async () => {
h.server = [server(1)];
h.client = [client(8)];
const clear = vi.spyOn(api, "clearNotifications").mockResolvedValue({} as never);
mount();
expect(screen.getByRole("button", { name: "inbox.markAllRead" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "inbox.clearAll" })).toBeNull();
await openMenu(() => fireEvent.click(screen.getByRole("button", { name: "inbox.menu" })));
expect(menuRows()).toEqual(["inbox.markAllRead", "inbox.clearAll"]);
h.confirm.mockResolvedValueOnce(false);
choose("inbox.clearAll");
await waitFor(() => expect(h.confirm).toHaveBeenCalledTimes(1));
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
expect(clear).not.toHaveBeenCalled();
expect(h.clearAll).not.toHaveBeenCalled();
await openMenu(() => fireEvent.click(screen.getByRole("button", { name: "inbox.menu" })));
choose("inbox.clearAll");
await waitFor(() => expect(clear).toHaveBeenCalled());
expect(h.clearAll).toHaveBeenCalled();
});
});
describe("NotificationsPanel · the page's background menu", () => {
// DESIGN "Background menu": it mirrors the band, and a right-click on blank page must never be one
// click from a destructive row — Clear everything stays behind the "…" and its confirm.
it("holds Mark all read, never Clear everything", async () => {
// PageShell measures its header and the scroller its edges; jsdom has no ResizeObserver.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
onTestFinished(() => {
vi.unstubAllGlobals();
});
h.server = [server(1)];
h.client = [client(8)];
const client_ = new QueryClient();
const { container } = render(
<QueryClientProvider client={client_}>
<PageShell>
<NotificationsPanel />
</PageShell>
</QueryClientProvider>,
);
const blank = container.querySelector("main") ?? container.firstElementChild!;
await openMenu(() => fireEvent.contextMenu(blank, { clientX: 5, clientY: 5 }));
expect(screen.getByRole("menu", { hidden: true }).getAttribute("aria-label")).toBe(
"common.pageMenu",
);
expect(menuRows()).toEqual(["inbox.markAllRead"]);
});
});
describe("a download-lifecycle notice", () => {
it("is named from its data in the reader's language, never the server's English title", () => {
h.server = [
server(1, {
type: "download_shared",
title: "Cats was shared with you",
body: null,
data: { kind: "download_shared", from: "a@b.c", title: "Cats" },
} as never),
server(2, {
type: "download_expiring",
title: "Dogs",
data: { kind: "download_expiring", title: "Dogs" },
} as never),
server(3, {
type: "download_expired",
title: "download", // an untitled video: the server's English fallback word
data: { kind: "download_expired", title: null },
} as never),
server(4, {
type: "download_evicted",
title: "Birds",
data: { kind: "download_evicted", title: "Birds" },
} as never),
];
mount();
for (const key of ["downloadShared", "downloadExpiring", "downloadExpired", "downloadEvicted"])
expect(screen.getByText(`inbox.${key}.title`)).toBeTruthy();
for (const title of ["Cats", "Dogs", "Birds"]) expect(screen.getByText(title)).toBeTruthy();
expect(screen.queryByText("Cats was shared with you")).toBeNull();
expect(screen.queryByText("download")).toBeNull();
});
});
describe("NotificationsPanel · a finished job's title", () => {
it("names what finished: a download, a clip, an upload", () => {
h.server = [
server(1),
server(2, { data: { title: "Ready 2", kind: "edit" } }),
server(3, { data: { title: "Ready 3", kind: "upload" } }),
];
h.client = [];
mount();
const titleOf = (text: string) => rowOf(text).textContent ?? "";
expect(titleOf("Ready 1")).toContain("inbox.downloadDone.title");
expect(titleOf("Ready 2")).toContain("inbox.editDone.title");
expect(titleOf("Ready 3")).toContain("inbox.uploadDone.title");
});
});
@@ -0,0 +1,780 @@
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,
ExternalLink,
Eye,
FolderOpen,
KeyRound,
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 { focusSettingsTab } from "../../lib/settingsTab";
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, formatDate } from "../../lib/format";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
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 { useFilesActions } from "../files/filesContext";
import { useChannelsActions } from "../channels/channelsContext";
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext";
import { SURFACE } from "../../components/ui/surfaces";
import { SectionLabel } from "../../components/ui/Headings";
import NavGrid from "../../components/ui/NavGrid";
import { navItemAttrs } 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. "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 { 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);
// 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 openTokens = () => {
focusSettingsTab("tokens");
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 === "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: () => 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.dismissed
? liveActions(n).map((action, i) => ({
id: `action${i}`,
label: action.label,
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"}
/>
);
};
// --- 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}
onOpenFile={() => openDone(n)}
onOpenTokens={openTokens}
/>
);
})}
</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 client notice's live actions (they do not survive a reload): its action, then the lesser one. */
function liveActions(n: ClientNotif): { label: string; onClick: () => void }[] {
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" | "download" | "token" | null {
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={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,
onOpenFile,
onOpenTokens,
}: {
n: AppNotification;
t: TFunction;
navIndex: number;
rowProps: Record<string, unknown>;
more: ReactNode;
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => 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
: [];
// 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 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 (
<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>
)}
{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, 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.dismissed &&
liveActions(n).length > 0 && (
<div className="mt-1 flex flex-wrap gap-x-4">
{liveActions(n).map((action, i) => (
<Button
key={action.label}
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);
}}
>
{action.label}
</Button>
))}
</div>
)
)}
</div>
<RowEnd onDismiss={onRemove} more={more} />
</div>
);
}