fix(notifications): a notice keeps what to say and says it in the reader's language when shown

This commit is contained in:
2026-10-29 22:37:14 +01:00
parent 6986eb1937
commit 4ef772d37b
125 changed files with 973 additions and 674 deletions
@@ -8,7 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { sinceOf } from "../../lib/activityUrlState";
import { auditActionLabel, auditText } from "../../lib/auditText";
import { formatDateTimeSec } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { useHistoryList } from "../../lib/useHistoryList";
import type { Shared } from "./ActivityRuns";
@@ -70,9 +70,9 @@ export default function ActivityAudit({
onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: qk.adminAudit() });
void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
notify({ level: "success", message: msg("audit.cleared", { count: r.cleared }) });
},
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
onError: (e) => notifyActionError(e, msg("audit.clearFailed")),
});
const onClear = async () => {
if (
+3 -3
View File
@@ -15,7 +15,7 @@ import { nextPageOnClick } from "../../lib/nextPage";
import { LEVEL_GROUPS, type LevelGroup } from "../../lib/log";
import { sinceOf } from "../../lib/activityUrlState";
import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../../lib/logsUrlState";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { HistoryFrame } from "./HistoryList";
@@ -187,8 +187,8 @@ export default function AdminLogs({
const ok = await copyText(text);
notify(
ok
? { level: "success", message: t("adminLogs.copied", { count: rows.length }) }
: { level: "error", message: t("adminLogs.copyFailed") },
? { level: "success", message: msg("adminLogs.copied", { count: rows.length }) }
: { level: "error", message: msg("adminLogs.copyFailed") },
);
};
@@ -11,7 +11,7 @@ import { hydrateSort, sortRows, type SortState } from "../../lib/columnSort";
import { usePref } from "../../lib/usePref";
import { useCardPager } from "../../lib/useCardPager";
import { subsColumn } from "./channelColumns";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import Tooltip from "../../components/ui/Tooltip";
import ChannelLink from "./ChannelLink";
@@ -70,13 +70,13 @@ export default function ChannelDiscovery({
// callbacks are gone (see ChannelSubscribedMeta).
notify({
level: "success",
title: t("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name }),
title: msg("channels.discovery.subscribedTitle"),
message: msg("channels.discovery.subscribedBody", { name }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
});
},
onError: (err: unknown) =>
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed"), onOpenWizard),
});
// Subscribing changes the user's real YouTube account and spends a little quota — confirm
+7 -7
View File
@@ -18,7 +18,7 @@ import { Sparkles } from "lucide-react";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { LS, ownKey } from "../../lib/storage";
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import Tooltip from "../../components/ui/Tooltip";
@@ -168,9 +168,9 @@ export default function Channels() {
(tagId: number, name: string) => {
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
notify({ level: "info", message: msg("channels.notify.filteredByTag", { name }) });
},
[setFeedFilters, feedFilters, setPage, t],
[setFeedFilters, feedFilters, setPage],
);
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
@@ -294,10 +294,10 @@ export default function Channels() {
invalidate();
notify({
level: "success",
message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }),
message: msg("channels.notify.synced", { count: r.subscriptions ?? 0 }),
});
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
onError: (e) => notifyYouTubeActionError(e, msg("channels.notify.syncFailed"), onOpenWizard),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
@@ -306,11 +306,11 @@ export default function Channels() {
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({
level: "success",
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
message: msg("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
});
},
onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
notifyYouTubeActionError(e, msg("channels.notify.fullHistoryFailed"), onOpenWizard),
});
// The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of
+2 -1
View File
@@ -12,6 +12,7 @@ import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { Pressable } from "../../components/ui/Pressable";
import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
@@ -64,7 +65,7 @@ export default function SyncStatus({
onError: (e) =>
notifyActionError(
e,
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
msg(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
});
@@ -16,6 +16,7 @@ import { Pressable } from "../../components/ui/Pressable";
import RemoteImage from "../../components/ui/RemoteImage";
import Tooltip from "../../components/ui/Tooltip";
import type { WatchedChannelActions } from "./useWatchedChannelActions";
import { msg } from "../../lib/notifications";
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
@@ -67,7 +68,7 @@ export default function WatchedVideosDialog({
);
void qc.invalidateQueries({ queryKey: qk.feed() });
})
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write;
});
const closePlayer = useStableCallback(() => setPlaying(null));
@@ -33,6 +33,7 @@ vi.mock("../../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.c
import { useChannelActions } from "./useChannelActions";
import { api, type ManagedChannel, type Tag } from "../../lib/api";
import type { ActionMenuItem } from "../../components/ui/ActionMenu";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -195,10 +196,10 @@ describe("useChannelActions — hide and unsubscribe", () => {
expect(h.confirm).not.toHaveBeenCalled();
expect(upd).toHaveBeenCalledWith("UC1", { hidden: true });
const toast = h.notify.mock.calls[0]![0] as {
message: string;
message: NotifText;
action: { onClick: () => void };
};
expect(toast.message).toBe("channels.notify.hidden");
expect(toast.message).toEqual(msg("channels.notify.hidden", { name: "Chan" }));
act(() => toast.action.onClick());
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false }));
});
@@ -7,7 +7,7 @@ import { api, type ManagedChannel, type Tag } from "../../lib/api";
import { priorityChoices } from "../../lib/channelOrder";
import { compareText } from "../../lib/collate";
import { channelYouTubeUrl } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useMe } from "../../lib/useMe";
import { useConfirm } from "../../components/ui/confirmContext";
@@ -70,7 +70,7 @@ export function useChannelActions({
refresh();
notify({
level: "success",
message: t("channels.notify.hidden", { name: nameOf(c) }),
message: msg("channels.notify.hidden", { name: nameOf(c) }),
action: {
label: t("common.undo"),
onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }),
@@ -82,19 +82,22 @@ export function useChannelActions({
mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id),
onSuccess: (_d, c) => {
invalidateSubscription(qc, c.id);
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: nameOf(c) }) });
notify({
level: "success",
message: msg("channels.notify.unsubscribed", { name: nameOf(c) }),
});
},
onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
notifyYouTubeActionError(e, msg("channels.notify.unsubscribeFailed"), onOpenWizard),
});
const resetBackfill = useMutation({
mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id),
onSuccess: (_d, c) => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.resetDone", { name: nameOf(c) }) });
notify({ level: "success", message: msg("channels.notify.resetDone", { name: nameOf(c) }) });
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
onError: (e) => notifyYouTubeActionError(e, msg("channels.notify.resetFailed"), onOpenWizard),
});
async function onUnsubscribe(c: ManagedChannel) {
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { invalidateSubscription } from "../../lib/subscriptionCache";
import { api, type WatchedChannel } from "../../lib/api";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useConfirm } from "../../components/ui/confirmContext";
@@ -21,13 +21,13 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
invalidateSubscription(qc, c.id);
notify({
level: "success",
title: t("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name: nameOf(c) }),
title: msg("channels.discovery.subscribedTitle"),
message: msg("channels.discovery.subscribedBody", { name: nameOf(c) }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) },
});
},
onError: (err: unknown) =>
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed"), onOpenWizard),
});
const restore = useMutation({
mutationFn: (c: WatchedChannel) => api.restoreWatchedChannel(c.id),
@@ -43,7 +43,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
void qc.invalidateQueries({ queryKey: qk.watchedChannels() });
notify({
level: "success",
message: t("channels.watched.dismissed", { name: nameOf(c) }),
message: msg("channels.watched.dismissed", { name: nameOf(c) }),
action: { label: t("common.undo"), onClick: () => restore.mutate(c) },
});
},
+8 -8
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plug, RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem, type PlexTestResult } from "../../lib/api";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip";
import InlineNotice from "../../components/ui/InlineNotice";
import { useMe } from "../../lib/useMe";
@@ -130,7 +130,7 @@ export default function ConfigPanel() {
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
} catch (e) {
setSaveState("error");
notifyActionError(e, t("config.saveFailed"));
notifyActionError(e, msg("config.saveFailed"));
}
}
@@ -142,8 +142,8 @@ export default function ConfigPanel() {
const testEmail = useMutation({
mutationFn: () => api.testEmail(),
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
onError: (e) => notifyActionError(e, t("config.testFailed")),
onSuccess: (r) => notify({ level: "success", message: msg("config.testSent", { to: r.to }) }),
onError: (e) => notifyActionError(e, msg("config.testFailed")),
});
// Plex "Test connection": verifies the server URL + token and returns the library sections,
@@ -154,19 +154,19 @@ export default function ConfigPanel() {
mutationFn: () => api.testPlex(),
onSuccess: (r) => {
setPlexResult(r);
notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) });
notify({ level: "success", message: msg("config.plexTestOk", { name: r.server_name }) });
},
onError: (e) => {
setPlexResult(null);
notifyActionError(e, t("config.plexTestFailed"));
notifyActionError(e, msg("config.plexTestFailed"));
},
});
// Bug reports' "Test connection": the repository answers, takes issues, is private.
const testIssues = useMutation({
mutationFn: () => api.testIssues(),
onSuccess: (r) =>
notify({ level: "success", message: t("config.issuesTestOk", { repo: r.repo }) }),
onError: (e) => notifyActionError(e, t("config.issuesTestFailed")),
notify({ level: "success", message: msg("config.issuesTestOk", { repo: r.repo }) }),
onError: (e) => notifyActionError(e, msg("config.issuesTestFailed")),
});
const plexLibs = (draft["plex_libraries"] ?? "")
+8 -8
View File
@@ -42,7 +42,7 @@ import {
} from "../../lib/channelPageUrlState";
import { LS } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { notifyActionError } from "../../lib/actionErrors";
import {
@@ -152,11 +152,11 @@ export default function ChannelPage({
})
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
// channel page that looked like a channel with no videos.
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
.catch((err: unknown) => notifyActionError(err, msg("errors.writeFailed.exploreChannel")))
.finally(() => setExploring(false))
);
},
[channelId, qc, t],
[channelId, qc],
);
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are
@@ -201,16 +201,16 @@ export default function ChannelPage({
mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => {
invalidateSubscription(qc, channelId);
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
notify({ level: "info", message: msg("channel.subscribed", { name: ch?.title ?? "" }) });
},
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
// here, so no Connect button — but the user at least sees why it didn't work).
onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")),
onError: (err) => notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed")),
});
const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => invalidateSubscription(qc, channelId),
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
onError: (err) => notifyYouTubeActionError(err, msg("channels.notify.unsubscribeFailed")),
});
// "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's
// uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full
@@ -222,9 +222,9 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "info", message: t("channel.fullHistoryQueued") });
notify({ level: "info", message: msg("channel.fullHistoryQueued") });
},
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")),
onError: (err) => notifyYouTubeActionError(err, msg("channel.fullHistoryFailed")),
});
// Feed priority, editable here with the same stepper + debounced-flush the manager uses (shared
+6 -11
View File
@@ -31,7 +31,6 @@ import {
type Video,
type VideoStatus,
} from "../../lib/api";
import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors";
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
import {
@@ -39,7 +38,7 @@ import {
writeVideoState,
writeWatchLater,
} from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import { YT_COUNTS } from "../../lib/ytSearchUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
@@ -419,7 +418,7 @@ export default function Feed({
// Server rejected the change — drop the optimistic override so the card
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
// a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
notifyActionError(e, msg("errors.writeFailed.videoState"));
setOverrides((o) => withoutWrite(o, id, seq));
});
return write;
@@ -447,7 +446,7 @@ export default function Feed({
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
})
.catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
notifyActionError(e, msg("errors.writeFailed.videoReset"));
setOverrides((o) => withoutWrite(o, id, seq));
if (resets.current.get(id) !== seq) return;
resets.current.delete(id);
@@ -471,9 +470,7 @@ export default function Feed({
.catch((e: unknown) => {
notifyActionError(
e,
i18n.t(
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
);
setSavedOverrides((o) => withoutWrite(o, id, seq));
});
@@ -619,11 +616,9 @@ export default function Feed({
const ids = ytItems.map((v) => v.id);
api
.clearSearch(ids)
.then((r) =>
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
)
.then((r) => notify({ message: msg("feed.yt.cleared", { count: r.removed }) }))
.catch((e: unknown) =>
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
notifyActionError(e, msg("errors.writeFailed.clearSearch")),
);
setFilters({ ...filters, librarySource: "organic", sort: FEED_DEFAULT_SORT });
onExitYtSearch();
@@ -116,6 +116,7 @@ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
}));
import Files from "./Files";
import { msg } from "../../lib/notifications";
import { FILES_LIST_CAP, type QueueJob } from "../../lib/api";
import { noteQueue, resetQueueWatch, useFinished } from "../../lib/downloadQueue";
@@ -589,10 +590,13 @@ describe("Files · a library past the list's cap", { timeout: 30_000 }, () => {
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({
level: "info",
message: "files.arrival.notListed",
message: msg("files.arrival.notListed"),
}),
);
expect(mocks.notify).not.toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" });
expect(mocks.notify).not.toHaveBeenCalledWith({
level: "info",
message: msg("files.arrival.gone"),
});
});
it("an arrival's cleared search is asked of the server at once, not after the typing pause", () => {
@@ -805,7 +809,7 @@ describe("Files · a file the address names", () => {
mount();
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "warning", message: "files.linkedGone" }),
expect.objectContaining({ level: "warning", message: msg("files.linkedGone") }),
),
);
expect(screen.queryByRole("dialog")).toBeNull();
@@ -822,11 +826,11 @@ describe("Files · a file the address names", () => {
mount();
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }),
expect.objectContaining({ level: "info", message: msg("files.linkedNotListed") }),
),
);
expect(mocks.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "files.linkedGone" }),
expect.objectContaining({ message: msg("files.linkedGone") }),
);
expect(here().get("fview")).toBe("9999");
}, 30_000); // a full page of FILES_LIST_CAP rows
@@ -839,11 +843,11 @@ describe("Files · a file the address names", () => {
mount();
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }),
expect.objectContaining({ level: "info", message: msg("files.linkedNotListed") }),
),
);
expect(mocks.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "files.linkedGone" }),
expect.objectContaining({ message: msg("files.linkedGone") }),
);
expect(here().get("fview")).toBe("9999");
}, 30_000); // a full page of FILES_LIST_CAP rows
@@ -1344,7 +1348,10 @@ describe("Files · when what it needs is missing", () => {
mocks.folders = [YT];
mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } });
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }),
expect(mocks.notify).toHaveBeenCalledWith({
level: "info",
message: msg("files.arrival.gone"),
}),
);
});
});
+8 -5
View File
@@ -19,7 +19,7 @@ import {
import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions";
import { formatBytes, formatDateTime, relativeTime } from "../../lib/format";
import { focusNavItem } from "../../lib/listNav";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { LS, ownKey } from "../../lib/storage";
import { useCardPager } from "../../lib/useCardPager";
@@ -296,9 +296,9 @@ export default function Files() {
const openLinked = useEffectEvent((file: LibraryFile | undefined) => {
// Looked for in the list it was shown in: a cut one cannot tell (the newest files, or the first by
// its sort or search), as the arrival decides it.
if (!file && cut) notify({ level: "info", message: t("files.linkedNotListed") });
if (!file && cut) notify({ level: "info", message: msg("files.linkedNotListed") });
else if (!file) {
notify({ level: "warning", message: t("files.linkedGone") });
notify({ level: "warning", message: msg("files.linkedGone") });
clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY);
} else if (linkedFile?.open === "play") play(file);
else setViewing(file.id);
@@ -387,7 +387,10 @@ export default function Files() {
consumeFocus(null);
// Said, not swallowed. A list the cap cut cannot tell a deleted file from one beyond its end;
// a whole list can: an old notice's file may have expired or been deleted since.
notify({ level: "info", message: t(cut ? "files.arrival.notListed" : "files.arrival.gone") });
notify({
level: "info",
message: msg(cut ? "files.arrival.notListed" : "files.arrival.gone"),
});
}
}, [here, missing, lookEverywhere, cut, viewKey, setView, consumeFocus, t]);
const arrival = arrived && arrived.view === viewKey ? arrived : null;
@@ -434,7 +437,7 @@ export default function Files() {
const dropped = e.dataTransfer.files;
const first = dropped.item(0);
if (first) setUpload({ file: first });
if (dropped.length > 1) notify({ level: "info", message: t("files.drop.oneAtATime") });
if (dropped.length > 1) notify({ level: "info", message: msg("files.drop.oneAtATime") });
}
setDragging(dragDepth.current > 0);
});
+3 -3
View File
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { formatDate } from "../../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../../lib/api";
@@ -40,9 +40,9 @@ function UserShare({ job }: { job: DownloadJob }) {
const share = useMutation({
mutationFn: (email: string) => api.shareDownload(job.id, email),
onSuccess: (r) => {
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
notify({ level: "success", message: msg("downloads.share.done", { email: r.shared_with }) });
},
onError: (e) => notifyActionError(e, t("downloads.share.failed")),
onError: (e) => notifyActionError(e, msg("downloads.share.failed")),
});
return (
@@ -45,6 +45,7 @@ vi.mock("../../lib/downloadQueue", () => ({
import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext";
import { msg, type NotifText } from "../../lib/notifications";
const wrap = (
onClose = vi.fn(),
@@ -101,7 +102,9 @@ describe("UploadDialog", () => {
expect(passedFile).toBe(f);
expect((opts as { displayName?: string }).displayName).toBe("My notes");
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect(h.notify.mock.calls[0]![0]).toMatchObject({ message: "downloads.upload.added" });
expect((h.notify.mock.calls[0]![0] as { message: NotifText }).message).toEqual(
msg("downloads.upload.added"),
);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
@@ -115,9 +118,9 @@ describe("UploadDialog", () => {
});
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect(h.notify.mock.calls[0]![0]).toMatchObject({
message: "downloads.upload.addedConverting",
});
expect((h.notify.mock.calls[0]![0] as { message: NotifText }).message).toEqual(
msg("downloads.upload.addedConverting"),
);
// Its finish is still to come, and the tray's to announce.
expect(h.uploadEnded).toHaveBeenCalledWith(null);
});
+3 -3
View File
@@ -9,7 +9,7 @@ import { qk } from "../../lib/queryKeys";
import { isAborted } from "../../lib/api/core";
import { notifyActionError } from "../../lib/actionErrors";
import { invalidateDownloads } from "../../lib/downloads";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { showInTray, uploadEnded, uploadStarted } from "../../lib/downloadQueue";
import type { DownloadJob } from "../../lib/api";
import { useFilesActions } from "./filesContext";
@@ -105,7 +105,7 @@ export default function UploadDialog({
invalidateDownloads(qc);
notify({
level: "success",
message: t(uploadedKey(job.status)),
message: msg(uploadedKey(job.status)),
action: {
label: t("downloads.dialog.view"),
onClick: () => openUploaded(job),
@@ -120,7 +120,7 @@ export default function UploadDialog({
// named and no job in the list. The comment here claimed the global error modal had it; it
// had not since the shared `reportFailure` landed.
uploadEnded(null);
if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed"));
if (!isAborted(e)) notifyActionError(e, msg("downloads.upload.failed"));
// Re-arm the form so the user can retry or pick again.
setPct(null);
abortRef.current = null;
@@ -22,6 +22,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
import VideoEditor from "./VideoEditor";
import { api, HttpError, type DownloadJob } from "../../lib/api";
import { markReported } from "../../lib/api/core";
import { msg } from "../../lib/notifications";
beforeAll(() => {
class Stub {
@@ -88,7 +89,10 @@ describe("VideoEditor — a split export that fails part-way", () => {
await act(async () => refuse(refused));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }),
expect.objectContaining({
level: "error",
title: msg("editor.partlyCreated", { done: 1, total: 2 }),
}),
),
);
expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)");
+4 -4
View File
@@ -6,7 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
import clsx from "clsx";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../../lib/api";
import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
import { CropFrame } from "../../components/ui/CropFrame";
@@ -288,7 +288,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// what the dialog does not tell.
notify({
level: "error",
title: t("editor.partlyCreated", { done: done.length, total: N }),
title: msg("editor.partlyCreated", { done: done.length, total: N }),
message: failureReason(e),
});
markReported(e);
@@ -300,10 +300,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onSuccess: ({ files }) => {
// The whole library moves with it: an edit the user already made is done at once, and lists.
invalidateDownloads(qc);
notify({ level: "success", message: t("editor.created", { count: files }) });
notify({ level: "success", message: msg("editor.created", { count: files }) });
onClose();
},
onError: (e) => notifyActionError(e, t("editor.failed")),
onError: (e) => notifyActionError(e, msg("editor.failed")),
onSettled: () => {
creating.current = false;
},
@@ -28,6 +28,7 @@ import { jobTitle } from "../../features/downloads/downloadTitle";
import { folderLabel } from "../../features/folders/fileFolders";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups";
import { msg } from "../../lib/notifications";
// The Files rows' ONE menu, and the actions the rows show as icons (the same ones, so a row's every
// action is reachable by right click — DESIGN.md "Library list columns"). My own files and the ones
@@ -79,7 +80,7 @@ export function useFileActions({
const update = useMutation({
mutationFn: ({ id, patch }: { id: number; patch: Patch }) => api.updateFile(id, patch),
onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))),
onError: (e, v) => notifyActionError(e, msg(patchFail(v.patch))),
});
const remove = useMutation({
mutationFn: (id: number) => api.deleteFile(id),
@@ -9,6 +9,7 @@ import { useConfirm } from "../../components/ui/confirmContext";
import { folderLabel } from "../../features/folders/fileFolders";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups";
import { msg } from "../../lib/notifications";
// A library folder's ONE menu — the rail entry's right click and the Files page's "…" beside an open
// folder's name. An own folder is shared by link (its link opens whatever is in it), renamed (inline,
@@ -43,7 +44,7 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
const update = useMutation({
mutationFn: ({ id, patch }: { id: number; patch: FolderPatch }) => api.updateFolder(id, patch),
onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))),
onError: (e, v) => notifyActionError(e, msg(patchFail(v.patch))),
});
const remove = useMutation({
mutationFn: (id: number) => api.deleteFolder(id),
+9 -8
View File
@@ -81,6 +81,7 @@ import RemoteImage from "../../components/ui/RemoteImage";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { msg } from "../../lib/notifications";
// The Home surface — "Mit nézzek most?".
//
@@ -868,10 +869,10 @@ export default function Home() {
// click raises a bare fetch TypeError, so there is no server reason to prefer over it. It
// would describe a state the user can see is untrue, over a fully populated page whose
// invalidations never even ran (they sit inside `.then`).
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
.catch((e: unknown) => notifyActionError(e, msg("home.actionFailed")));
return write;
},
[qc, t],
[qc],
);
const onToggleSave = useCallback(
@@ -887,9 +888,9 @@ export default function Home() {
qc.invalidateQueries({ queryKey: qk.discover() });
})
// The write key, for the same reason as `onState` above.
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
.catch((e: unknown) => notifyActionError(e, msg("home.actionFailed")));
},
[qc, t],
[qc],
);
// One click plays. A shelf tile resolves through its `next` (a show → the episode that would
@@ -940,11 +941,11 @@ export default function Home() {
// re-asks the server rather than trying to predict a cross-rail consequence.
const onToggleWatched = useCallback(
(card: PlexCard) =>
void toggleWatchState(card, t, {
void toggleWatchState(card, {
optimistic: () => {},
resync: () => qc.invalidateQueries({ queryKey: qk.discover() }),
}),
[qc, t],
[qc],
);
// Shelf reorder, from the comfort rail's menu. Optimistic on THIS budget's cached payload so the
@@ -986,7 +987,7 @@ export default function Home() {
.reorderComfortPins(ids)
.catch((e: unknown) => {
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: cards } : old));
notifyActionError(e, t("home.shelf.moveFailed"));
notifyActionError(e, msg("home.shelf.moveFailed"));
})
.finally(() => {
shelfSavingRef.current = false;
@@ -1000,7 +1001,7 @@ export default function Home() {
void qc.invalidateQueries({ queryKey: qk.discover() });
});
},
[budget, lang, qc, t],
[budget, lang, qc],
);
const reroll = useCallback(
@@ -9,7 +9,7 @@ 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";
import { raw, type Notification as ClientNotif } from "../../lib/notifications";
const h = vi.hoisted(() => ({
server: [] as unknown[],
@@ -116,7 +116,7 @@ const client = (id: number, extra: Partial<ClientNotif> = {}) =>
({
id,
level: "success",
message: `Activity ${id}`,
message: raw(`Activity ${id}`),
requiresInteraction: false,
ts: Date.now(),
read: true,
@@ -50,6 +50,7 @@ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
import NotificationsPanel from "./NotificationsPanel";
import { focusNavItem, restoreNavPosition } from "../../lib/listNav";
import { raw } from "../../lib/notifications";
// The server list goes through the real live query, so an optimistic patch of its cache is seen.
beforeEach(() => {
@@ -80,7 +81,7 @@ const client = (id: number, ageMs: number, extra: Partial<ClientNotif> = {}) =>
({
id,
level: "success",
message: `Activity ${id}.`,
message: raw(`Activity ${id}.`),
requiresInteraction: false,
ts: Date.now() - ageMs,
read: true,
@@ -122,8 +123,8 @@ describe("NotificationsPanel · order", () => {
h.client = [
client(9, 5 * 60 * 1000, {
level: "warning",
title: "common.accessRequestsTitle",
message: "1 pending",
title: raw("common.accessRequestsTitle"),
message: raw("1 pending"),
meta: { kind: "access-requests" },
}),
client(8, 2 * D),
@@ -366,8 +367,8 @@ describe("NotificationsPanel · order", () => {
h.client = [
client(9, 5 * 60 * 1000, {
level: "warning",
title: "common.accessRequestsTitle",
message: "1 pending",
title: raw("common.accessRequestsTitle"),
message: raw("1 pending"),
meta: { kind: "access-requests" },
}),
];
@@ -465,7 +466,9 @@ describe("NotificationsPanel · order", () => {
// A screen reader hears the name while the keyboard is on the row, and Back finds the row it
// left by it: "Download ready" alone was every download's name.
h.server = [server(1, 1 * H, true), server(2, 2 * H, true)];
h.client = [client(3, 3 * H, { title: "Something broke", message: "Unexpected error." })];
h.client = [
client(3, 3 * H, { title: raw("Something broke"), message: raw("Unexpected error.") }),
];
mount();
await loaded();
expect(
@@ -29,16 +29,18 @@ 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 Notification as ClientNotif,
type ChannelSubscribedMeta,
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../../lib/notifications";
@@ -250,7 +252,7 @@ export default function NotificationsPanel() {
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId);
})
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
.catch((e) => notifyActionError(e, msg("inbox.revertFailed")));
}
// --- The rows' ONE menu -------------------------------------------------------------
@@ -707,10 +709,12 @@ function serverRowText(
* 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): string =>
n.title ? rowName(n.title, n.message) : rowName(n.message, null);
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);
if (e.src === "client") return clientRowName(e.n, t);
const { title, body } = serverRowText(e.n, t, lang);
return rowName(title, body);
};
@@ -854,15 +858,15 @@ function ClientActivityRow({
const access = n.meta?.kind === "access-requests" ? n.meta : null;
return (
<div
{...navItemAttrs(navIndex, clientRowName(n))}
{...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">{n.title}</div>}
{n.title && <div className="text-sm font-semibold">{sayNotif(n.title, t)}</div>}
<div className="text-sm wrap-break-word">
{n.message}
{sayNotif(n.message, t)}
{n.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{n.repeat}
@@ -94,6 +94,7 @@ import { PageMenuSlot, type PageMenuOpener } from "../../components/ui/pageMenuS
import { api, HttpError, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { patchCachedProgress } from "../../lib/progressCache";
import { msg, type NotifText } from "../../lib/notifications";
beforeAll(() => {
class Stub {
@@ -644,7 +645,7 @@ describe("Playlists — a change the server refused is taken back on screen", ()
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "playlists.removeFailed" }),
expect.objectContaining({ level: "error", title: msg("playlists.removeFailed") }),
);
});
});
@@ -662,8 +663,8 @@ describe("Playlists — removing a row, and undoing it", () => {
// removal may notify too.
const lastToast = () =>
h.notify.mock.calls
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } })
.filter((n) => n.message?.startsWith("playlists.removedToast"))
.map((c) => c[0] as { message: NotifText; action?: { label: string; onClick: () => void } })
.filter((n) => "key" in n.message && n.message.key === "playlists.removedToast")
.pop()!;
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
// can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake).
@@ -686,7 +687,9 @@ describe("Playlists — removing a row, and undoing it", () => {
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
// No confirm: the toast's Undo is the safety net.
expect(h.confirm).not.toHaveBeenCalled();
await waitFor(() => expect(lastToast().message).toBe("playlists.removedToast:Video B"));
await waitFor(() =>
expect(lastToast().message).toEqual(msg("playlists.removedToast", { title: "Video B" })),
);
expect(lastToast().action?.label).toBe("common.undo");
act(() => lastToast().action!.onClick());
// Index 1 of the STORED order: where it was, not the end of the list.
@@ -733,7 +736,7 @@ describe("Playlists — removing a row, and undoing it", () => {
act(() => lastToast().action!.onClick());
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "playlists.undoRemoveFailed" }),
expect.objectContaining({ level: "error", title: msg("playlists.undoRemoveFailed") }),
),
);
});
+11 -9
View File
@@ -34,10 +34,10 @@ import {
type Video,
type VideoStatus,
} from "../../lib/api";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { playlistName } from "../../lib/playlistName";
import { playlistName, playlistNameText } from "../../lib/playlistName";
import { reinsertAt } from "../../lib/reinsert";
import { useUndoable } from "../../lib/useUndoable";
import { cycleSort, type SortState } from "../../lib/columnSort";
@@ -231,7 +231,7 @@ function PlaylistDetail() {
// Was a bare .then(): a failed reorder left the UI showing an order the server had
// rejected, and produced an unhandled rejection. Say so, and put back the order the server
// still has — or the rejected one becomes the base for the next drag.
notifyYouTubeActionError(e, t("playlists.reorderFailed"));
notifyYouTubeActionError(e, msg("playlists.reorderFailed"));
restoreFromCache(listId);
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
});
@@ -535,7 +535,7 @@ function PlaylistDetail() {
// Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player
// there is not even a row snapping back to show it) — unless `req` already put it in its error
// dialog, which `notifyActionError` checks.
notifyActionError(e, t("playlists.removeFailed"));
notifyActionError(e, msg("playlists.removeFailed"));
// …and the row goes back NOW, not when the resync lands: a removal that failed because the
// server was unreachable has a resync that fails too, and the list kept showing the row as
// removed while it was still in the playlist.
@@ -545,7 +545,7 @@ function PlaylistDetail() {
}
notify({
level: "success",
message: t("playlists.removedToast", { title: video.title }),
message: msg("playlists.removedToast", { title: video.title ?? "" }),
action: {
label: t("common.undo"),
// ONE request that puts it back at its index (the server renumbers around it): an append
@@ -567,7 +567,7 @@ function PlaylistDetail() {
if (back.keys.length)
return addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]);
})
.catch((e: unknown) => notifyActionError(e, t("playlists.undoRemoveFailed")))
.catch((e: unknown) => notifyActionError(e, msg("playlists.undoRemoveFailed")))
.finally(() => {
refreshAll(listId);
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
@@ -619,6 +619,8 @@ function PlaylistDetail() {
const next = items.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
// Outside the `try`: a `??` inside it makes the React Compiler skip the component.
const title = video.title ?? "";
try {
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
// whenever the second call fails — see the endpoint's docstring.
@@ -628,7 +630,7 @@ function PlaylistDetail() {
setLastMoveTargetId(target.id);
notify({
level: "success",
message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
message: msg("playlists.movedToast", { title, name: playlistNameText(target) }),
});
} catch (e) {
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
@@ -637,7 +639,7 @@ function PlaylistDetail() {
// explanation and the shipped moveErrors strings were unreachable.
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
// checks (a review found it reported twice).
notifyActionError(e, t("playlists.moveFailed"));
notifyActionError(e, msg("playlists.moveFailed"));
restoreFromCache(selectedId); // the row is still in this list
}
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
@@ -677,7 +679,7 @@ function PlaylistDetail() {
qc.invalidateQueries({ queryKey: qk.feed() });
})
// No catch used to mean an unhandled rejection and nothing said.
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write;
}
@@ -7,7 +7,7 @@ import { YouTubeIcon } from "../../lib/brandIcons";
import { api, type Playlist } from "../../lib/api";
import { compareText } from "../../lib/collate";
import { formatRuntime } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { LS } from "../../lib/storage";
import { usePref } from "../../lib/usePref";
@@ -155,9 +155,9 @@ export default function PlaylistsRail() {
const r = await api.syncYoutubePlaylists();
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
notify({ level: "success", message: msg("playlists.syncedToast", { count: r.synced }) });
} catch (e) {
notifyYouTubeActionError(e, t("playlists.syncFailed"));
notifyYouTubeActionError(e, msg("playlists.syncFailed"));
}
setSyncing(false);
}
@@ -31,6 +31,7 @@ vi.mock("../../lib/useMe", () => ({ useMe: () => ({ can_write: h.canWrite }) }))
import { usePlaylistActions } from "./usePlaylistActions";
import { HttpError, api } from "../../lib/api";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
type Menuable = Parameters<ReturnType<typeof usePlaylistActions>["items"]>[0];
@@ -73,21 +74,21 @@ describe("usePlaylistActions — deleting a list", () => {
const { result } = mount();
await act(() => result.current.remove(linked));
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "playlists.deleteYtFailed" }),
);
expect(h.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Unknown playlist" }),
expect.objectContaining({ title: msg("playlists.deleteYtFailed") }),
);
expect(
h.notify.mock.calls.map(([n]) => sayNotif((n as { message: NotifText }).message)),
).not.toContain("Unknown playlist");
});
it("prefers a STRUCTURED reason, which the request layer has already localized", async () => {
it("prefers a STRUCTURED reason, kept as the refusal to say in the reader's language", async () => {
vi.spyOn(api, "deletePlaylist").mockRejectedValue(
new HttpError(404, "Ez a lista már nem létezik.", { code: "not_found", reason: "playlist" }),
);
const { result } = mount();
await act(() => result.current.remove(linked));
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ message: "Ez a lista már nem létezik." }),
expect.objectContaining({ message: { detail: { code: "not_found", reason: "playlist" } } }),
);
});
});
@@ -4,10 +4,10 @@ import { ExternalLink, Pencil, RotateCcw, Trash2, Upload } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { qk } from "../../lib/queryKeys";
import { api, type Playlist } from "../../lib/api";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { playlistName } from "../../lib/playlistName";
import { playlistName, playlistNameText } from "../../lib/playlistName";
import { useChoice, useConfirm } from "../../components/ui/confirmContext";
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
import { useMe } from "../../lib/useMe";
@@ -72,7 +72,7 @@ export function usePlaylistActions() {
try {
await api.renamePlaylist(id, next);
} catch (e) {
notifyYouTubeActionError(e, t("playlists.renameFailed"));
notifyYouTubeActionError(e, msg("playlists.renameFailed"));
}
refresh(id);
}
@@ -112,7 +112,7 @@ export function usePlaylistActions() {
// `req` already put it in its error dialog: not twice (notifyActionError checks).
notifyActionError(
err,
onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed"),
msg(onYoutube ? "playlists.deleteYtFailed" : "playlists.deleteFailed"),
"warning",
);
}
@@ -136,29 +136,28 @@ export function usePlaylistActions() {
refresh(pl.id);
notify({
level: "success",
message: t("playlists.revertDone", { name: playlistName(pl, t) }),
message: msg("playlists.revertDone", { name: playlistNameText(pl) }),
});
} catch (e) {
notifyYouTubeActionError(e, t("playlists.revertFailed"));
notifyYouTubeActionError(e, msg("playlists.revertFailed"));
}
mark("reverting", pl.id, false);
}
async function push(pl: Menuable) {
if (syncing(pl.id)) return;
const name = playlistName(pl, t);
mark("pushing", pl.id, true);
try {
await pushSteps(pl, name);
await pushSteps(pl);
} catch (e) {
notifyYouTubeActionError(e, t("playlists.pushFailed"));
notifyYouTubeActionError(e, msg("playlists.pushFailed"));
}
mark("pushing", pl.id, false);
}
/** push()'s steps, apart so its "pushing" mark clears on every way out without a try/finally
* (the React Compiler cannot lower one yet, and skips the whole hook). */
async function pushSteps(pl: Menuable, name: string) {
async function pushSteps(pl: Menuable) {
const plan = await api.playlistPushPlan(pl.id);
const nothingToDo =
plan.action === "update" &&
@@ -169,13 +168,16 @@ export function usePlaylistActions() {
!plan.to_reorder &&
!plan.rename;
if (nothingToDo) {
notify({ level: "info", message: t("playlists.pushUpToDate", { name }) });
notify({
level: "info",
message: msg("playlists.pushUpToDate", { name: playlistNameText(pl) }),
});
return;
}
if (!plan.affordable) {
notify({
level: "warning",
message: t("playlists.pushNoQuota", {
message: msg("playlists.pushNoQuota", {
left: plan.remaining_today,
units: plan.units_estimate,
}),
@@ -224,10 +226,13 @@ export function usePlaylistActions() {
if (r.failures.length) {
notify({
level: "warning",
message: t("playlists.pushPartial", { count: r.failures.length }),
message: msg("playlists.pushPartial", { count: r.failures.length }),
});
} else {
notify({ level: "success", message: t("playlists.pushDone", { name }) });
notify({
level: "success",
message: msg("playlists.pushDone", { name: playlistNameText(pl) }),
});
}
}
+1 -1
View File
@@ -393,7 +393,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
);
}
const toggleWatched = (card: PlexCard) =>
toggleWatchState(card, t, {
toggleWatchState(card, {
optimistic: (next) => optimisticStatus(card.id, next),
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
});
@@ -13,6 +13,7 @@ import { useConfirm } from "../../components/ui/confirmContext";
import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// Admin-only "manage this movie's collections" dialog. Lists the library's EDITABLE
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
@@ -85,7 +86,7 @@ export default function PlexCollectionEditor({
// 403/404 are left to the caller by `req` (see reportFailure), so without this the tick
// simply never appeared and the user clicked again — a collection deleted on the Plex server
// or flipped read-only failed in silence.
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
}
setBusy(null);
}
@@ -97,7 +98,7 @@ export default function PlexCollectionEditor({
await api.plexSetCollectionEditable(id, true);
refresh();
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
}
setBusy(null);
}
@@ -114,7 +115,7 @@ export default function PlexCollectionEditor({
} catch (e) {
// The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so
// it reads in the reader's language (see localizeDetail).
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
}
setBusy(null);
}
@@ -134,7 +135,7 @@ export default function PlexCollectionEditor({
} catch (e) {
// The worst of the three to lose: the user has just confirmed a destructive dialog, and the
// row stayed where it was with nothing said.
notifyActionError(e, t("plex.collection.deleteFailed"));
notifyActionError(e, msg("plex.collection.deleteFailed"));
}
setBusy(null);
}
@@ -44,6 +44,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
}));
import PlexCollectionView from "./PlexCollectionView";
import { msg } from "../../lib/notifications";
import {
api,
HttpError,
@@ -135,7 +136,7 @@ describe("PlexCollectionView — Play-all", () => {
fireEvent.click(await screen.findByText("plex.collection.playAll"));
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "plex.collection.playEmpty" }),
expect.objectContaining({ level: "info", message: msg("plex.collection.playEmpty") }),
),
);
expect(onPlay).not.toHaveBeenCalled();
@@ -8,7 +8,7 @@ import { plexLang } from "../../i18n";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../../lib/api";
import { formatRuntime } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { showEpisodeKeys } from "../../lib/plexQueue";
import { healPlexSort, plexSortOptions } from "../../lib/plexSort";
import { usePref } from "../../lib/usePref";
@@ -154,9 +154,9 @@ export default function PlexCollectionView({
// canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched /
// empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly.
if (queue.length > 0) onPlay(queue[0]!, queue);
else notify({ level: "info", message: t("plex.collection.playEmpty") });
else notify({ level: "info", message: msg("plex.collection.playEmpty") });
} catch (e) {
notifyActionError(e, t("plex.collection.playFailed"));
notifyActionError(e, msg("plex.collection.playFailed"));
}
setQueueBusy(false);
}
@@ -168,7 +168,7 @@ export default function PlexCollectionView({
const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
const toggleWatched = (card: PlexCard) =>
toggleWatchState(card, t, {
toggleWatchState(card, {
optimistic: (next) => {
qc.setQueryData<PlexCollectionDetail>(detailKey, (old) =>
old
@@ -211,7 +211,7 @@ export default function PlexCollectionView({
setRenaming(false);
} catch (e) {
// The backend enforces per-library name uniqueness — a taken name comes back localized.
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
}
setBusy(false);
}
@@ -226,7 +226,7 @@ export default function PlexCollectionView({
invalidate();
onBack();
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
setBusy(false);
}
}
@@ -240,7 +240,7 @@ export default function PlexCollectionView({
await api.plexCollectionRemoveItem(collectionId, card.id);
invalidate();
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
}
setBusy(false);
}
@@ -251,7 +251,7 @@ export default function PlexCollectionView({
await api.plexSetCollectionEditable(collectionId, true);
invalidate();
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
}
setBusy(false);
}
@@ -495,7 +495,7 @@ function AddItemPanel({
setAdded((s) => new Set(s).add(card.id));
onAdded();
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
}
}
+1 -1
View File
@@ -172,7 +172,7 @@ export default function PlexDiscover({
: old,
);
const toggleWatched = (card: PlexCard) =>
toggleWatchState(card, t, {
toggleWatchState(card, {
optimistic: (next) => {
const flip = (cards: PlexCard[]) =>
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
+3 -2
View File
@@ -36,6 +36,7 @@ import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions"
import { toggleWatchState } from "../../lib/plexWatch";
import { vocab } from "../../lib/vocab";
import RemoteImage from "../../components/ui/RemoteImage";
import { msg } from "../../lib/notifications";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -254,7 +255,7 @@ export default function PlexInfo({
try {
await api.plexSetState(detail.id, status);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
notifyActionError(e, msg("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
@@ -267,7 +268,7 @@ export default function PlexInfo({
// is; the resync corrects it to the truth either way.
const colMenu = useContextMenu<PlexCard>();
const toggleColWatched = (card: PlexCard) =>
toggleWatchState(card, t, {
toggleWatchState(card, {
optimistic: (next) =>
qc.setQueriesData<PlexItemDetail>({ queryKey: qk.plexItem(detail.id) }, (old) =>
old
@@ -20,7 +20,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import PlexPlayer from "./PlexPlayer";
import { api, HttpError, type PlexItemDetail } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { notify } from "../../lib/notifications";
import { notify, raw } from "../../lib/notifications";
import { claimLocalData } from "../../lib/storage";
type Media = HTMLMediaElement & { _t?: number };
@@ -285,7 +285,7 @@ it("shows a toast raised while it is open inside itself, not under its scrim", a
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
await screen.findByText("Title A");
act(() => {
notify({ level: "error", message: "Couldn't mark it watched." });
notify({ level: "error", message: raw("Couldn't mark it watched.") });
});
const toast = await screen.findByText("Couldn't mark it watched.");
let host: Element | null = video();
+3 -2
View File
@@ -103,6 +103,7 @@ import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPo
import { Slider } from "../../components/ui/NativeControls";
import { formatUnit } from "../../lib/format";
import { trackLabel } from "../../lib/vocab";
import { msg } from "../../lib/notifications";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -716,8 +717,8 @@ export default function PlexPlayer({
});
const reportWatchedError = useCallback(
(e: unknown) => notifyActionError(e, t("plex.watchedFailed")),
[t],
(e: unknown) => notifyActionError(e, msg("plex.watchedFailed")),
[],
);
const go = useCallback(
@@ -29,6 +29,7 @@ vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confi
import PlexPlaylistView from "./PlexPlaylistView";
import { api, type PlexCard } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { msg, type NotifText } from "../../lib/notifications";
beforeAll(() => {
class Stub {
@@ -98,8 +99,8 @@ const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="plex.playlist.rowMenu"]')!;
const toast = () =>
h.notify.mock.calls
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } })
.filter((n) => n.message?.startsWith("plex.playlist.removedToast"))
.map((c) => c[0] as { message: NotifText; action?: { label: string; onClick: () => void } })
.filter((n) => "key" in n.message && n.message.key === "plex.playlist.removedToast")
.pop();
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
// can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake).
@@ -181,7 +182,9 @@ describe("PlexPlaylistView · a row's menu", () => {
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.playlist.removeFromPlaylist");
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
expect(h.confirm).not.toHaveBeenCalled();
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:Movie 2"));
await waitFor(() =>
expect(toast()?.message).toEqual(msg("plex.playlist.removedToast", { title: "Movie 2" })),
);
act(() => toast()!.action!.onClick());
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [1]));
});
@@ -208,7 +211,9 @@ describe("PlexPlaylistView · a row's menu", () => {
const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
await pick(() => fireEvent.click(moreOf("The Show")), "plex.playlist.removeShow");
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"]));
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:The Show"));
await waitFor(() =>
expect(toast()?.message).toEqual(msg("plex.playlist.removedToast", { title: "The Show" })),
);
});
it("removes one season from its label's menu", async () => {
@@ -349,7 +354,7 @@ describe("PlexPlaylistView · a failed watched toggle", () => {
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.markWatched");
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "plex.watchedFailed" }),
expect.objectContaining({ level: "error", title: msg("plex.watchedFailed") }),
),
);
await act(async () => {});
@@ -58,6 +58,7 @@ import { MEDIA_BADGE } from "../../components/ui/mediaCardStyles";
import { seasonTitle } from "../../lib/plexDetail";
import { cardImage } from "../../lib/plexImage";
import RemoteImage from "../../components/ui/RemoteImage";
import { msg } from "../../lib/notifications";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -459,7 +460,7 @@ export default function PlexPlaylistView({
try {
await api.plexRenamePlaylist(playlistId, title);
} catch (e) {
notifyActionError(e, t("plex.playlist.renameFailed")); // the field stays open to try again
notifyActionError(e, msg("plex.playlist.renameFailed")); // the field stays open to try again
return;
}
setRenaming(false);
@@ -479,7 +480,7 @@ export default function PlexPlaylistView({
// Gone already (deleted on another device): what was asked for is true — leave the dead page.
// Anything else is a real failure, and the page stays.
if (!(e instanceof HttpError && e.status === 404)) {
notifyActionError(e, t("plex.playlist.deleteFailed"));
notifyActionError(e, msg("plex.playlist.deleteFailed"));
return;
}
}
@@ -569,7 +570,7 @@ export default function PlexPlaylistView({
onRestart: (c) => onPlay(c.id, order, true),
onInfo: onInfoCard,
onToggleWatched: (c) =>
void toggleWatchState(c, t, {
void toggleWatchState(c, {
optimistic: (next) => setStatus(c.id, next),
// The flip is on the view's LOCAL copy: a failed write's resync refetches an identical
// list, react-query keeps the same `data`, and the copy is never re-seeded from it.
+2 -1
View File
@@ -22,6 +22,7 @@ import { usePlex, usePlexActions } from "./plexContext";
import { DismissibleChip } from "../../components/ui/Chip";
import { Input } from "../../components/ui/Input";
import { useRail } from "../../lib/useRail";
import { msg } from "../../lib/notifications";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
@@ -350,7 +351,7 @@ export function PlaylistPicker() {
onOpenPlaylist(pl.id);
} catch (e) {
// Called without an await from the picker, so a refusal was an unhandled rejection.
notifyActionError(e, t("plex.playlistAdd.createFailed"));
notifyActionError(e, msg("plex.playlistAdd.createFailed"));
}
}
@@ -16,6 +16,7 @@ import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
@@ -103,7 +104,7 @@ function NewCollectionPanel({
} catch (e) {
// The backend is authoritative on per-library name uniqueness — a taken name comes back
// structured, and so localized (see localizeDetail).
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
setBusy(false);
}
}
+6 -5
View File
@@ -69,6 +69,7 @@ import { PageHeading } from "../../components/ui/Headings";
import { vocab } from "../../lib/vocab";
import { formatDate, formatDecimal } from "../../lib/format";
import RemoteImage from "../../components/ui/RemoteImage";
import { msg } from "../../lib/notifications";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
@@ -209,7 +210,7 @@ export function PlexPersonView({
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
// helper; also invalidate the library so its grid agrees.
const toggle = (card: PlexCard) =>
toggleWatchState(card, t, {
toggleWatchState(card, {
optimistic: (next) =>
qc.setQueryData<PlexPersonDetail>(personKey, (old) =>
old
@@ -500,7 +501,7 @@ export function PlexShowView({
try {
await api.plexShowState(showId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
notifyActionError(e, msg("plex.watchedFailed"));
}
setBusy(false);
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
@@ -510,7 +511,7 @@ export function PlexShowView({
try {
await api.plexSeasonState(seasonRk, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
notifyActionError(e, msg("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
@@ -836,7 +837,7 @@ export function PlexSeasonView({
try {
await api.plexSeasonState(seasonId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
notifyActionError(e, msg("plex.watchedFailed"));
}
setBusy(false);
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
@@ -847,7 +848,7 @@ export function PlexSeasonView({
try {
await api.plexSetState(ep.id, next);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
notifyActionError(e, msg("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
@@ -17,6 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
import { api, type PlexCard } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { usePlayShow } from "./usePlayShow";
import { msg } from "../../lib/notifications";
afterEach(() => {
vi.restoreAllMocks();
@@ -106,7 +107,7 @@ describe("usePlayShow", () => {
vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down"));
const { play, openShow, playShow } = mount();
await act(() => playShow(show, false));
expect(notified).toHaveBeenCalledWith(expect.any(Error), "plex.playShowFailed");
expect(notified).toHaveBeenCalledWith(expect.any(Error), msg("plex.playShowFailed"));
expect(play).not.toHaveBeenCalled();
expect(openShow).not.toHaveBeenCalled();
});
+3 -2
View File
@@ -6,6 +6,7 @@ import { qk } from "../../lib/queryKeys";
import { notifyActionError } from "../../lib/actionErrors";
import { plexLang } from "../../i18n";
import { useLatest } from "../../lib/useLatest";
import { msg } from "../../lib/notifications";
/** A show's card plays like any card: the episode in progress, else the first unwatched, else the
* first (`/show/{id}/next`). Asked fresh on the click, since the card's preview may hold an answer
@@ -25,7 +26,7 @@ export function usePlayShow(
const asked = useRef(0);
// Unmounted, nothing of it may navigate any more.
useEffect(() => () => void ++asked.current, []);
const { t, i18n } = useTranslation();
const { i18n } = useTranslation();
const lang = plexLang(i18n.language);
// `undefined` when the question failed (said in the toast).
const next = (id: string) =>
@@ -37,7 +38,7 @@ export function usePlayShow(
})
.then(
(r) => r.next,
(e: unknown) => void notifyActionError(e, t("plex.playShowFailed")),
(e: unknown) => void notifyActionError(e, msg("plex.playShowFailed")),
);
return async (show: PlexCard, fromStart: boolean) => {
const ask = ++asked.current;
@@ -15,7 +15,8 @@ vi.mock("react-i18next", async (importOriginal) => ({
}));
const { notify } = vi.hoisted(() => ({
notify: vi.fn(
(_n: { level: string; message: string; action?: { label: string; onClick: () => void } }) => 0,
(_n: { level: string; message: NotifText; action?: { label: string; onClick: () => void } }) =>
0,
),
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
@@ -26,6 +27,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
import { api, type PlexCard } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -39,7 +41,7 @@ const ids = (items: PlexCard[]) => items.map((i) => i.id);
const errors = () =>
notify.mock.calls
.filter(([n]) => n.level === "error")
.map(([n]) => (n as { title?: string }).title);
.map(([n]) => (n as { title?: NotifText }).title);
function setup(items: PlexCard[]) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -61,7 +63,7 @@ describe("usePlexPlaylistEdits · the reorder", () => {
act(() => hook.result.current.commit([movie("3"), movie("1"), movie("2")]));
expect(ids(hook.result.current.items)).toEqual(["3", "1", "2"]); // optimistic
await waitFor(() => expect(errors()).toEqual(["plex.playlist.reorderFailed"]));
await waitFor(() => expect(errors()).toEqual([msg("plex.playlist.reorderFailed")]));
expect(ids(hook.result.current.items)).toEqual(["1", "2", "3"]);
});
@@ -112,7 +114,7 @@ describe("usePlexPlaylistEdits · the reorder", () => {
await waitFor(() => expect(ids(hook.result.current.items)).toEqual(["1", "3"]));
await act(async () => fail(new Error("offline")));
await waitFor(() => expect(errors()).toEqual(["plex.playlist.reorderFailed"]));
await waitFor(() => expect(errors()).toEqual([msg("plex.playlist.reorderFailed")]));
expect(ids(hook.result.current.items)).toEqual(["1", "3"]);
});
});
@@ -124,7 +126,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(new Error("offline"));
await act(() => hook.result.current.onRemove(["2"], "Movie 2"));
expect(errors()).toEqual(["plex.playlist.removeFailed"]);
expect(errors()).toEqual([msg("plex.playlist.removeFailed")]);
expect(ids(hook.result.current.items)).toEqual(["1", "2"]);
expect(hook.result.current.busy).toBe(false);
// A failed removal offers no Undo: there is nothing to put back.
@@ -144,7 +146,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
// A season whose episodes do not sit together: rows 2 and 4.
await act(() => hook.result.current.onRemove(["4", "2"], "Show · Season 1"));
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
expect(toast.message).toBe("plex.playlist.removedToast");
expect(toast.message).toEqual(msg("plex.playlist.removedToast", { title: "Show · Season 1" }));
expect(toast.action!.label).toBe("common.undo");
act(() => toast.action!.onClick());
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
@@ -180,6 +182,6 @@ describe("usePlexPlaylistEdits · the removal", () => {
await act(() => hook.result.current.onRemove(["1"], "Movie 1"));
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
act(() => toast.action!.onClick());
await waitFor(() => expect(errors()).toEqual(["plex.playlist.undoRemoveFailed"]));
await waitFor(() => expect(errors()).toEqual([msg("plex.playlist.undoRemoveFailed")]));
});
});
@@ -5,7 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { plexLang } from "../../i18n";
import { api, type PlexCard } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { reinsertAt } from "../../lib/reinsert";
@@ -79,7 +79,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
})
.catch((e: unknown) => {
notifyActionError(e, t("plex.playlist.reorderFailed"));
notifyActionError(e, msg("plex.playlist.reorderFailed"));
putBack();
});
}
@@ -102,7 +102,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
else await api.plexPlaylistRemoveBulk(playlistId, keys);
invalidate();
} catch (e) {
notifyActionError(e, t("plex.playlist.removeFailed"));
notifyActionError(e, msg("plex.playlist.removeFailed"));
putBack();
removed = false;
}
@@ -110,7 +110,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
if (!removed) return;
notify({
level: "success",
message: t("plex.playlist.removedToast", { title }),
message: msg("plex.playlist.removedToast", { title }),
action: {
label: t("common.undo"),
onClick: () =>
@@ -129,7 +129,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
if (back.keys.length)
return api.plexPlaylistAddBulk(playlistId, back.keys, back.positions);
})
.catch((e: unknown) => notifyActionError(e, t("plex.playlist.undoRemoveFailed")))
.catch((e: unknown) => notifyActionError(e, msg("plex.playlist.undoRemoveFailed")))
.finally(invalidate),
},
});
+8 -6
View File
@@ -31,7 +31,7 @@ import { Button } from "../../components/ui/Button";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useConfirm } from "../../components/ui/confirmContext";
import { formatDateTime, formatNumber, formatUnit, relativeTime } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip";
import { notifyActionError } from "../../lib/actionErrors";
import { IconButton } from "../../components/ui/IconButton";
@@ -550,14 +550,14 @@ export default function Scheduler() {
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
onError: (e) => notifyActionError(e, t("scheduler.toggleFailed")),
onError: (e) => notifyActionError(e, msg("scheduler.toggleFailed")),
});
const intervalMut = useMutation({
mutationFn: (v: { jobId: string; minutes: number }) =>
api.updateSchedulerJob(v.jobId, v.minutes),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
onError: (e) => notifyActionError(e, t("scheduler.intervalFailed")),
onError: (e) => notifyActionError(e, msg("scheduler.intervalFailed")),
});
// Manual "run now" triggers. The wrapper runs in a background thread server-side; the
@@ -568,7 +568,9 @@ export default function Scheduler() {
onSuccess: (_d, jobId) => {
notify({
level: "success",
message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }),
message: msg("scheduler.triggered", {
job: msg(`scheduler.jobs.${jobId}`, { defaultValue: jobId }),
}),
});
qc.invalidateQueries({ queryKey: qk.scheduler() });
},
@@ -579,7 +581,7 @@ export default function Scheduler() {
onSuccess: (res) => {
notify({
level: "success",
message: t("scheduler.triggeredAll", { count: res.started.length }),
message: msg("scheduler.triggeredAll", { count: res.started.length }),
});
qc.invalidateQueries({ queryKey: qk.scheduler() });
},
@@ -595,7 +597,7 @@ export default function Scheduler() {
onSuccess: (res) => {
const removed =
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) });
notify({ level: "success", message: msg("scheduler.purgedDiscovery", { count: removed }) });
qc.invalidateQueries({ queryKey: qk.scheduler() });
},
});
@@ -12,7 +12,7 @@ import {
type LevelGroup as Filter,
type LogEvent,
} from "../../lib/log";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import LogLine from "../../features/diagnostics/LogLine";
import NavGrid from "../../components/ui/NavGrid";
import { Button } from "../../components/ui/Button";
@@ -115,8 +115,8 @@ export default function MyLogViewer() {
const ok = await copyText(text);
notify(
ok
? { level: "success", message: t("settings.myLog.copied", { count: shown.length }) }
: { level: "error", message: t("settings.myLog.copyFailed") },
? { level: "success", message: msg("settings.myLog.copied", { count: shown.length }) }
: { level: "error", message: msg("settings.myLog.copyFailed") },
);
};
+10 -10
View File
@@ -29,7 +29,7 @@ import { LS, useTabPersistedState } from "../../lib/storage";
import { SETTINGS_TAB_KEY, settingsTabParams } from "../../lib/settingsTab";
import { useUrlMirror } from "../../lib/useUrlMirror";
import Avatar from "../../components/ui/Avatar";
import { notify, type NotifSettings } from "../../lib/notifications";
import { msg, type NotifSettings, notify } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip";
import { Section, SwitchRow } from "../../components/ui/form";
import { Badge } from "../../components/ui/Badge";
@@ -297,8 +297,8 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
onClick={() =>
notify({
level: "info",
title: t("settings.notifications.testTitle"),
message: t("settings.notifications.testMessage"),
title: msg("settings.notifications.testTitle"),
message: msg("settings.notifications.testMessage"),
sound: true,
})
}
@@ -379,9 +379,9 @@ export function SignInMethods({ me }: { me: Me }) {
// Every API token went with the other sessions: the Tokens tab asks again when it next opens,
// rather than trusting its cached list for another half minute.
void qc.invalidateQueries({ queryKey: qk.apiTokens() });
notify({ level: "success", message: t("settings.account.sessions.done") });
notify({ level: "success", message: msg("settings.account.sessions.done") });
} catch (e) {
notifyActionError(e, t("settings.account.sessions.failed"));
notifyActionError(e, msg("settings.account.sessions.failed"));
}
setSessBusy(false);
};
@@ -402,7 +402,7 @@ export function SignInMethods({ me }: { me: Me }) {
await qc.invalidateQueries({ queryKey: qk.me() });
notify({
level: "success",
message: t("settings.account.password.saved", { email: me.email }),
message: msg("settings.account.password.saved", { email: me.email }),
});
} catch (e) {
setErr(
@@ -540,7 +540,7 @@ function PlexWatchSync() {
if (imp) {
notify({
level: "success",
message: t("settings.plexSync.imported", {
message: msg("settings.plexSync.imported", {
watched: imp.watched,
in_progress: imp.in_progress,
}),
@@ -640,7 +640,7 @@ export function DownloadAuth() {
qc.setQueryData(qk.downloadCredentials(), res);
setOpenHost(null);
setDraft("");
notify({ level: "success", message: t("settings.downloadAuth.saved") });
notify({ level: "success", message: msg("settings.downloadAuth.saved") });
},
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the
@@ -652,7 +652,7 @@ export function DownloadAuth() {
meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" },
onSuccess: (res) => {
qc.setQueryData(qk.downloadCredentials(), res);
notify({ level: "success", message: t("settings.downloadAuth.removed") });
notify({ level: "success", message: msg("settings.downloadAuth.removed") });
},
});
@@ -837,7 +837,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
} catch (e) {
// A refusal with a reason (the last admin, a 409) is already in the global error dialog, which
// the reporter skips; anything else used to end the confirmed delete with nothing said.
notifyActionError(e, t("errors.writeFailed.deleteAccount"));
notifyActionError(e, msg("errors.writeFailed.deleteAccount"));
}
};
return (
@@ -10,14 +10,16 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, HttpError, type TakeoutStatus, type TakeoutUpload } from "../../lib/api";
const mocks = vi.hoisted(() => ({ notify: vi.fn(), notifyActionError: vi.fn() }));
const mocks = vi.hoisted(() => ({
notify: vi.fn(),
notifyActionError: vi.fn(),
// The values ride along, so an assertion can see what each said.
t: (key: string, o?: Record<string, unknown>) =>
o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key,
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key,
}),
useTranslation: () => ({ i18n: { language: "en" }, t: mocks.t }),
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
@@ -29,7 +31,9 @@ vi.mock("../../lib/format", async (importOriginal) => ({
formatDateTime: (iso: string) => `at ${iso}`,
}));
import type { TFunction } from "i18next";
import TakeoutImport from "./TakeoutImport";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -184,13 +188,16 @@ describe("YouTube history import · the upload", () => {
await waitFor(() => expect(line()).toContain("settings.takeout.count.watched(7,7)"));
expect(sent).toEqual(["watch-history.json", "bad.html", "Watch later-videos.csv"]);
expect(mocks.notify.mock.calls.map(([n]) => (n as { message: string }).message)).toEqual([
// What each notice says, said by the same `t` the page speaks.
const said = (n: unknown) =>
sayNotif((n as { message: NotifText }).message, mocks.t as TFunction);
expect(mocks.notify.mock.calls.map(([n]) => said(n))).toEqual([
"settings.takeout.read(watch-history.json,9034,9,034)",
"settings.takeout.read(Watch later-videos.csv,56,56)",
]);
expect(mocks.notifyActionError).toHaveBeenCalledWith(
refused,
"errors.writeFailed.takeoutUpload",
msg("errors.writeFailed.takeoutUpload"),
);
});
@@ -274,8 +281,14 @@ describe("YouTube history import · while it runs", () => {
expect(send).not.toHaveBeenCalled();
expect(mocks.notify).toHaveBeenCalledWith({
level: "error",
message: "settings.takeout.tooLarge(watch-history.json,10 B)",
message: msg("settings.takeout.tooLarge", {
name: "watch-history.json",
limit: { bytes: 10 },
}),
});
expect(sayNotif(mocks.notify.mock.calls[0]![0].message, mocks.t as TFunction)).toBe(
"settings.takeout.tooLarge(watch-history.json,10 B)",
);
// Nothing was taken: the zone stays open for the right file, and so after a refusal.
await waitFor(() => expect(zone()).toBeTruthy());
send.mockRejectedValue(new HttpError(422, "refused"));
@@ -9,9 +9,9 @@ import { StateMessage } from "../../components/ui/QueryState";
import { api, type TakeoutStatus, type TakeoutUpload } from "../../lib/api";
import type { TAKEOUT_OUTCOMES } from "../../lib/api/feed";
import { qk } from "../../lib/queryKeys";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { formatBytes, formatDateTime, formatNumber } from "../../lib/format";
import { formatDateTime, formatNumber } from "../../lib/format";
import { importBusy, invalidateImported, watchTakeout } from "./takeoutWatch";
// Settings › Account: the user's Google Takeout export, imported. What they watched on YouTube and
@@ -129,7 +129,7 @@ export default function TakeoutImport() {
// Refused here, not after minutes of upload, and with the limit named.
notify({
level: "error",
message: t("settings.takeout.tooLarge", { name: file.name, limit: formatBytes(max, lang) }),
message: msg("settings.takeout.tooLarge", { name: file.name, limit: { bytes: max } }),
});
return false;
}
@@ -139,15 +139,15 @@ export default function TakeoutImport() {
res = await api.takeoutUpload(file);
} catch (e) {
// A refusal names its reason (not a Takeout file, nothing in it); the next file still goes.
notifyActionError(e, t("errors.writeFailed.takeoutUpload"));
notifyActionError(e, msg("errors.writeFailed.takeoutUpload"));
}
if (res === null) return false;
notify({
level: "success",
message: t("settings.takeout.read", {
message: msg("settings.takeout.read", {
name: file.name,
count: res.videos,
n: formatNumber(res.videos, lang),
n: { number: res.videos },
}),
});
return true;
+5 -5
View File
@@ -4,7 +4,7 @@ import { qk } from "../../lib/queryKeys";
import { History, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import { formatEta, formatNumber, quotaActionLabel } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useWizard } from "../../features/wizard/wizardContext";
import Tooltip from "../../components/ui/Tooltip";
@@ -43,7 +43,7 @@ function Overview() {
qc.invalidateQueries({ queryKey: qk.channels() });
notify({
level: "success",
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
message: msg("stats.sync.synced", { count: r.subscriptions ?? 0 }),
});
},
// The same helper and the same wizard handle as the Channels page: it translates the 403 into
@@ -51,7 +51,7 @@ function Overview() {
// English detail under a Hungarian UI — and delegates everything else to notifyActionError so
// the server's own reason wins, which is what makes the SEC-14 cooldown read as a pace limit
// rather than "Sync failed".
onError: (e) => notifyYouTubeActionError(e, t("stats.sync.syncFailed"), openWizard),
onError: (e) => notifyYouTubeActionError(e, msg("stats.sync.syncFailed"), openWizard),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
@@ -60,10 +60,10 @@ function Overview() {
qc.invalidateQueries({ queryKey: qk.channels() });
notify({
level: "success",
message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
message: msg("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
});
},
onError: (e) => notifyActionError(e, t("stats.sync.fullHistoryFailed")),
onError: (e) => notifyActionError(e, msg("stats.sync.fullHistoryFailed")),
});
return (
+5 -5
View File
@@ -7,7 +7,7 @@ import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { api } from "../../lib/api";
import { displayName } from "../../lib/api/account";
import { qk } from "../../lib/queryKeys";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { formatDateTime } from "../../lib/format";
import { invalidateUserAccess } from "../../lib/adminUsers";
@@ -73,19 +73,19 @@ export default function AccountPanel({
const issue = useMutation({
mutationFn: () => api.createSetPasswordLink(user.id),
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
onError: (err) => notifyActionError(err, t("users.account.linkFailed")),
onError: (err) => notifyActionError(err, msg("users.account.linkFailed")),
});
const resend = useMutation({
mutationFn: () => api.resendUserVerification(user.id),
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
onSuccess: () => notify({ level: "success", message: msg("users.account.resendSent") }),
onError: (err) => notifyActionError(err, msg("users.pendingAccess.resendFailed")),
});
const activate = useMutation({
mutationFn: () => api.activateUser(user.id),
onSuccess: () => invalidateUserAccess(qc, user.id),
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
onError: (err) => notifyActionError(err, msg("users.pendingAccess.activateFailed")),
});
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
@@ -26,7 +26,7 @@ vi.mock("../../lib/useMe", () => ({
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
notify: vi.fn((_n: { level: string; title?: NotifText; message: NotifText }) => 0),
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
@@ -39,6 +39,7 @@ import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import { api, type AdminUserRow } from "../../lib/api";
import { relativeTime } from "../../lib/format";
import i18n from "../../i18n";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
const row = {
id: 2,
@@ -175,10 +176,12 @@ describe("a user action that fails", () => {
await waitFor(() =>
expect(notify).toHaveBeenCalledWith({
level: "error",
title: "users.roles.failed", // what failed…
message: i18n.t("errors.notFound.user"), // …and why: the request layer localizes a structured detail
title: msg("users.roles.failed"), // what failed…
message: { detail: { code: "not_found", reason: "user" } }, // …and why: the server's refusal
}),
);
// …said in the reader's language when it is shown.
expect(sayNotif(notify.mock.lastCall![0].message)).toBe(i18n.t("errors.notFound.user"));
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
});
+33 -24
View File
@@ -24,7 +24,7 @@ import {
type PendingAccessRow,
} from "../../lib/api";
import { displayName } from "../../lib/api/account";
import { notify } from "../../lib/notifications";
import { msg, type NotifText, notify } from "../../lib/notifications";
import { formatDateTime, relativeTime } from "../../lib/format";
import { invalidateUserAccess } from "../../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY, usersTabParams } from "../../lib/adminUsersTab";
@@ -134,7 +134,7 @@ function UsersRoles({ me }: { me: Me }) {
// A failed user action: said once (notifyActionError skips what `req` already put in its dialog),
// and the list refetched — a user deleted in another tab would otherwise stay listed, every click
// on the row doing nothing.
const onGoneOrFailed = (e: unknown, fallback: string) => {
const onGoneOrFailed = (e: unknown, fallback: NotifText) => {
notifyActionError(e, fallback);
qc.invalidateQueries({ queryKey: qk.adminUsers() });
};
@@ -144,12 +144,12 @@ function UsersRoles({ me }: { me: Me }) {
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
notify({ level: "success", message: msg("users.roles.updated", { email: vars.email }) });
},
onSettled: (_d, _e, vars) => endPending(vars.id),
// Guards (last admin / self) surface via the global error dialog with the server's detail; what
// it does not show (a user deleted meanwhile, a 404) is said here — see onGoneOrFailed.
onError: (e) => onGoneOrFailed(e, t("users.roles.failed")),
onError: (e) => onGoneOrFailed(e, msg("users.roles.failed")),
});
const suspend = useMutation({
@@ -160,24 +160,24 @@ function UsersRoles({ me }: { me: Me }) {
qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({
level: "success",
message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
message: msg(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
email: vars.email,
}),
});
},
onSettled: (_d, _e, vars) => endPending(vars.id),
// Guards (self / last admin) surface via the global error dialog; the rest, as above.
onError: (e) => onGoneOrFailed(e, t("users.suspend.failed")),
onError: (e) => onGoneOrFailed(e, msg("users.suspend.failed")),
});
const del = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
notify({ level: "success", message: msg("users.delete.done", { email: vars.email }) });
},
onSettled: (_d, _e, vars) => endPending(vars.id),
onError: (e) => onGoneOrFailed(e, t("users.delete.failed")),
onError: (e) => onGoneOrFailed(e, msg("users.delete.failed")),
});
const onToggle = async (u: AdminUserRow) => {
@@ -367,20 +367,23 @@ function PendingAccess() {
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
onSuccess: (_r, u) => {
refresh();
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
notify({
level: "success",
message: msg("users.pendingAccess.activated", { email: u.email }),
});
},
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
onError: (err) => notifyActionError(err, msg("users.pendingAccess.activateFailed")),
});
const resend = useMutation({
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
onSuccess: (_r, u) => {
refresh();
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
notify({ level: "success", message: msg("users.pendingAccess.resent", { email: u.email }) });
},
// The public resend is deliberately silent; this one is not, and the server's reason says WHY
// (no working SMTP, a refused send) — a success toast for a mail never sent would be the lie.
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
onError: (err) => notifyActionError(err, msg("users.pendingAccess.resendFailed")),
});
const list = rows.data ?? [];
@@ -543,17 +546,20 @@ function AdminInvites() {
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
onSuccess: (_d, vars) => {
refresh();
notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) });
notify({
level: "success",
message: msg("settings.invites.approved", { email: vars.email }),
});
},
onError: (e) => notifyActionError(e, t("settings.invites.approveFailed")),
onError: (e) => notifyActionError(e, msg("settings.invites.approveFailed")),
});
const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id),
onSuccess: (_d, _id) => {
refresh();
notify({ level: "success", message: t("settings.invites.denied") });
notify({ level: "success", message: msg("settings.invites.denied") });
},
onError: (e) => notifyActionError(e, t("settings.invites.denyFailed")),
onError: (e) => notifyActionError(e, msg("settings.invites.denyFailed")),
});
const onDeny = async (i: { id: number; email: string }) => {
const ok = await confirm({
@@ -571,15 +577,15 @@ function AdminInvites() {
// Removing the row is not always a revoke: is_allowed falls back to the env allow-lists, so
// for an address on those the person keeps access. Saying "removed" alone would mislead.
if (d.still_allowed_by_env)
notify({ level: "warning", message: t("settings.invites.deletedStillAllowed") });
else notify({ level: "success", message: t("settings.invites.deleted") });
notify({ level: "warning", message: msg("settings.invites.deletedStillAllowed") });
else notify({ level: "success", message: msg("settings.invites.deleted") });
},
onError: (e) => {
// A 409 means the list was stale — someone registered on that address since it loaded. Say
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
const conflict = e instanceof HttpError && e.status === 409;
if (conflict) notifyKnownFailure(e, t("settings.invites.deleteHasAccount"));
else notifyActionError(e, t("settings.invites.deleteFailed"));
if (conflict) notifyKnownFailure(e, msg("settings.invites.deleteHasAccount"));
else notifyActionError(e, msg("settings.invites.deleteFailed"));
if (conflict) refresh();
},
});
@@ -607,9 +613,12 @@ function AdminInvites() {
mutationFn: (v: { inv: Invite; lang: LangCode }) => api.sendInvitation(v.inv.id, v.lang),
onSuccess: (_r, v) => {
refresh();
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
notify({
level: "success",
message: msg("settings.invites.invited", { email: v.inv.email }),
});
},
onError: (err) => notifyActionError(err, t("settings.invites.inviteFailed")),
onError: (err) => notifyActionError(err, msg("settings.invites.inviteFailed")),
});
// The one recipient the app has no language signal about: the admin typed this address in, so
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
@@ -648,9 +657,9 @@ function AdminInvites() {
onSuccess: (_d, email) => {
setNewEmail("");
refresh();
notify({ level: "success", message: t("settings.invites.addedToWhitelist", { email }) });
notify({ level: "success", message: msg("settings.invites.addedToWhitelist", { email }) });
},
onError: (e) => notifyActionError(e, t("settings.invites.addFailed")),
onError: (e) => notifyActionError(e, msg("settings.invites.addFailed")),
});
const list = invites.data ?? [];
@@ -13,6 +13,7 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { NotifText } from "../../lib/notifications";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -51,6 +52,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
});
const { HttpError } = await import("../../lib/api");
const { msg, sayNotif } = await import("../../lib/notifications");
const i18n = (await import("../../i18n")).default;
const AccountPanel = (await import("./AccountPanel")).default;
const user = {
@@ -90,7 +93,7 @@ afterEach(cleanup);
// it), else the specific sentence (the no-mail case).
const errorMessages = () =>
notifyMock.mock.calls
.map(([a]) => a as { level?: string; title?: string; message?: string })
.map(([a]) => a as { level?: string; title?: NotifText; message?: NotifText })
.filter((a) => a.level === "error")
.map((a) => a.title ?? a.message);
@@ -100,13 +103,13 @@ describe("the account panel reports a refused recovery action", () => {
await clickAction("users.account.setPassword");
await waitFor(() => expect(errorMessages()).toContain("users.account.linkFailed"));
await waitFor(() => expect(errorMessages()).toContainEqual(msg("users.account.linkFailed")));
// …and no link block was rendered, so the toast is the only thing that happened.
expect(screen.queryByText("users.account.copy")).toBeNull();
});
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
// The toast carries the refusal itself, said in the reader's language when it is shown.
apiMock.resendUserVerification.mockRejectedValue(
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
);
@@ -116,11 +119,14 @@ describe("the account panel reports a refused recovery action", () => {
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "users.pendingAccess.resendFailed",
message: "errors.admin.email_off",
title: msg("users.pendingAccess.resendFailed"),
message: { detail: { code: "resend", reason: "email_off" } },
}),
),
);
// …which says the no-mail sentence, not a generic failure.
const [shown] = notifyMock.mock.lastCall as [{ message: NotifText }];
expect(sayNotif(shown.message)).toBe(i18n.t("errors.admin.email_off"));
});
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
@@ -130,11 +136,13 @@ describe("the account panel reports a refused recovery action", () => {
await clickAction("users.account.resend");
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendFailed"));
// The success toast must NOT also fire — that is the exact lie the backend guards against.
expect(notifyMock.mock.calls.map(([a]) => (a as { message?: string }).message)).not.toContain(
"users.account.resendSent",
await waitFor(() =>
expect(errorMessages()).toContainEqual(msg("users.pendingAccess.resendFailed")),
);
// The success toast must NOT also fire — that is the exact lie the backend guards against.
expect(
notifyMock.mock.calls.map(([a]) => (a as { message?: NotifText }).message),
).not.toContainEqual(msg("users.account.resendSent"));
});
it("reports a refused activation", async () => {
@@ -144,6 +152,8 @@ describe("the account panel reports a refused recovery action", () => {
await clickAction("users.account.activate");
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
await waitFor(() =>
expect(errorMessages()).toContainEqual(msg("users.pendingAccess.activateFailed")),
);
});
});
@@ -81,6 +81,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
import { HttpError } from "../../lib/api";
import AdminUsers from "./AdminUsers";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
import i18n from "../../i18n";
const invite = (over: Partial<Record<string, unknown>> = {}) => ({
id: 1,
@@ -207,7 +209,7 @@ describe("Access requests — the delete affordance", () => {
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({
level: "warning",
message: "settings.invites.deletedStillAllowed",
message: msg("settings.invites.deletedStillAllowed"),
}),
);
});
@@ -223,7 +225,7 @@ describe("Access requests — the delete affordance", () => {
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
message: "settings.invites.deleteHasAccount",
message: msg("settings.invites.deleteHasAccount"),
}),
);
});
@@ -261,7 +263,9 @@ describe("inviting someone the admin whitelisted", () => {
// Falls back to the admin's own UI language: nobody ever told us anything about this mailbox.
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "settings.invites.invited:asker@example.com" }),
expect.objectContaining({
message: msg("settings.invites.invited", { email: "asker@example.com" }),
}),
);
});
@@ -356,14 +360,16 @@ describe("inviting someone the admin whitelisted", () => {
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
// A button the admin pressed must not answer with a success toast for a mail nobody received.
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
// The toast carries the refusal itself, said in the reader's language when it is shown.
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "settings.invites.inviteFailed",
message: "errors.admin.email_off",
title: msg("settings.invites.inviteFailed"),
message: { detail: { code: "invite_send", reason: "email_off" } },
}),
),
);
const [shown] = notifyMock.mock.lastCall as [{ message: NotifText }];
expect(sayNotif(shown.message)).toBe(i18n.t("errors.admin.email_off"));
});
});
@@ -69,6 +69,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
});
import AdminUsers from "./AdminUsers";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
import i18n from "../../i18n";
/** A row as /api/admin/pending-access serves it. */
const row = (over: Record<string, unknown> = {}) => ({
@@ -113,7 +115,9 @@ describe("the stuck sign-ups section", () => {
await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "users.pendingAccess.activated:later@example.com" }),
expect.objectContaining({
message: msg("users.pendingAccess.activated", { email: "later@example.com" }),
}),
);
});
@@ -165,16 +169,18 @@ describe("the stuck sign-ups section", () => {
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
// back on. A button the admin pressed must not answer with a success toast. `req` has already
// localized the refusal into `detail` (lib/api/core); the toast carries it.
// back on. A button the admin pressed must not answer with a success toast. The toast carries the
// refusal itself, said in the reader's language when it is shown.
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "users.pendingAccess.resendFailed",
message: "errors.admin.email_off",
title: msg("users.pendingAccess.resendFailed"),
message: { detail: { code: "resend", reason: "email_off" } },
}),
),
);
const [shown] = notifyMock.mock.lastCall as [{ message: NotifText }];
expect(sayNotif(shown.message)).toBe(i18n.t("errors.admin.email_off"));
});
it("renders nothing at all when nobody is stuck", async () => {