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

This commit is contained in:
2026-10-29 22:37:14 +01:00
parent 6986eb1937
commit 4ef772d37b
125 changed files with 973 additions and 674 deletions
@@ -11,7 +11,7 @@ import { hydrateSort, sortRows, type SortState } from "../../lib/columnSort";
import { usePref } from "../../lib/usePref";
import { useCardPager } from "../../lib/useCardPager";
import { subsColumn } from "./channelColumns";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import Tooltip from "../../components/ui/Tooltip";
import ChannelLink from "./ChannelLink";
@@ -70,13 +70,13 @@ export default function ChannelDiscovery({
// callbacks are gone (see ChannelSubscribedMeta).
notify({
level: "success",
title: t("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name }),
title: msg("channels.discovery.subscribedTitle"),
message: msg("channels.discovery.subscribedBody", { name }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
});
},
onError: (err: unknown) =>
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed"), onOpenWizard),
});
// Subscribing changes the user's real YouTube account and spends a little quota — confirm
+7 -7
View File
@@ -18,7 +18,7 @@ import { Sparkles } from "lucide-react";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { LS, ownKey } from "../../lib/storage";
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import Tooltip from "../../components/ui/Tooltip";
@@ -168,9 +168,9 @@ export default function Channels() {
(tagId: number, name: string) => {
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
notify({ level: "info", message: msg("channels.notify.filteredByTag", { name }) });
},
[setFeedFilters, feedFilters, setPage, t],
[setFeedFilters, feedFilters, setPage],
);
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
@@ -294,10 +294,10 @@ export default function Channels() {
invalidate();
notify({
level: "success",
message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }),
message: msg("channels.notify.synced", { count: r.subscriptions ?? 0 }),
});
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
onError: (e) => notifyYouTubeActionError(e, msg("channels.notify.syncFailed"), onOpenWizard),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
@@ -306,11 +306,11 @@ export default function Channels() {
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({
level: "success",
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
message: msg("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
});
},
onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
notifyYouTubeActionError(e, msg("channels.notify.fullHistoryFailed"), onOpenWizard),
});
// The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of
+2 -1
View File
@@ -12,6 +12,7 @@ import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { Pressable } from "../../components/ui/Pressable";
import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
@@ -64,7 +65,7 @@ export default function SyncStatus({
onError: (e) =>
notifyActionError(
e,
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
msg(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
});
@@ -16,6 +16,7 @@ import { Pressable } from "../../components/ui/Pressable";
import RemoteImage from "../../components/ui/RemoteImage";
import Tooltip from "../../components/ui/Tooltip";
import type { WatchedChannelActions } from "./useWatchedChannelActions";
import { msg } from "../../lib/notifications";
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
@@ -67,7 +68,7 @@ export default function WatchedVideosDialog({
);
void qc.invalidateQueries({ queryKey: qk.feed() });
})
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write;
});
const closePlayer = useStableCallback(() => setPlaying(null));
@@ -33,6 +33,7 @@ vi.mock("../../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.c
import { useChannelActions } from "./useChannelActions";
import { api, type ManagedChannel, type Tag } from "../../lib/api";
import type { ActionMenuItem } from "../../components/ui/ActionMenu";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -195,10 +196,10 @@ describe("useChannelActions — hide and unsubscribe", () => {
expect(h.confirm).not.toHaveBeenCalled();
expect(upd).toHaveBeenCalledWith("UC1", { hidden: true });
const toast = h.notify.mock.calls[0]![0] as {
message: string;
message: NotifText;
action: { onClick: () => void };
};
expect(toast.message).toBe("channels.notify.hidden");
expect(toast.message).toEqual(msg("channels.notify.hidden", { name: "Chan" }));
act(() => toast.action.onClick());
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false }));
});
@@ -7,7 +7,7 @@ import { api, type ManagedChannel, type Tag } from "../../lib/api";
import { priorityChoices } from "../../lib/channelOrder";
import { compareText } from "../../lib/collate";
import { channelYouTubeUrl } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useMe } from "../../lib/useMe";
import { useConfirm } from "../../components/ui/confirmContext";
@@ -70,7 +70,7 @@ export function useChannelActions({
refresh();
notify({
level: "success",
message: t("channels.notify.hidden", { name: nameOf(c) }),
message: msg("channels.notify.hidden", { name: nameOf(c) }),
action: {
label: t("common.undo"),
onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }),
@@ -82,19 +82,22 @@ export function useChannelActions({
mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id),
onSuccess: (_d, c) => {
invalidateSubscription(qc, c.id);
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: nameOf(c) }) });
notify({
level: "success",
message: msg("channels.notify.unsubscribed", { name: nameOf(c) }),
});
},
onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
notifyYouTubeActionError(e, msg("channels.notify.unsubscribeFailed"), onOpenWizard),
});
const resetBackfill = useMutation({
mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id),
onSuccess: (_d, c) => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.resetDone", { name: nameOf(c) }) });
notify({ level: "success", message: msg("channels.notify.resetDone", { name: nameOf(c) }) });
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
onError: (e) => notifyYouTubeActionError(e, msg("channels.notify.resetFailed"), onOpenWizard),
});
async function onUnsubscribe(c: ManagedChannel) {
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { invalidateSubscription } from "../../lib/subscriptionCache";
import { api, type WatchedChannel } from "../../lib/api";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useConfirm } from "../../components/ui/confirmContext";
@@ -21,13 +21,13 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
invalidateSubscription(qc, c.id);
notify({
level: "success",
title: t("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name: nameOf(c) }),
title: msg("channels.discovery.subscribedTitle"),
message: msg("channels.discovery.subscribedBody", { name: nameOf(c) }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) },
});
},
onError: (err: unknown) =>
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed"), onOpenWizard),
});
const restore = useMutation({
mutationFn: (c: WatchedChannel) => api.restoreWatchedChannel(c.id),
@@ -43,7 +43,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
void qc.invalidateQueries({ queryKey: qk.watchedChannels() });
notify({
level: "success",
message: t("channels.watched.dismissed", { name: nameOf(c) }),
message: msg("channels.watched.dismissed", { name: nameOf(c) }),
action: { label: t("common.undo"), onClick: () => restore.mutate(c) },
});
},