fix(notifications): a notice keeps what to say and says it in the reader's language when shown
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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"] ?? "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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") }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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") },
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user