- {toast.title &&
{toast.title}
}
+ {toast.title && (
+
{sayNotif(toast.title, t)}
+ )}
- {toast.message}
+ {sayNotif(toast.message, t)}
{toast.repeat > 1 && (
×{toast.repeat}
diff --git a/frontend/src/components/ui/copyItems.tsx b/frontend/src/components/ui/copyItems.tsx
index 87ce7819..493a3901 100644
--- a/frontend/src/components/ui/copyItems.tsx
+++ b/frontend/src/components/ui/copyItems.tsx
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Copy, Link2 } from "lucide-react";
import { copyText } from "../../lib/clipboard";
-import { notify } from "../../lib/notifications";
+import { msg, type NotifText, notify } from "../../lib/notifications";
import { type ActionMenuItem } from "./ActionMenu";
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
@@ -17,12 +17,12 @@ type Named = "title" | "name";
export function useCopyItems() {
const { t } = useTranslation();
- const copy = (text: string, done: string) => {
+ const copy = (text: string, done: NotifText) => {
void copyText(text).then((ok) =>
notify(
ok
? { level: "success", message: done }
- : { level: "error", message: t("common.copy.failed") },
+ : { level: "error", message: msg("common.copy.failed") },
),
);
};
@@ -43,7 +43,7 @@ export function useCopyItems() {
label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
icon: ,
inGesture: true,
- onSelect: () => copy(text, t("common.copy.done")),
+ onSelect: () => copy(text, msg("common.copy.done")),
},
]
: []),
@@ -54,7 +54,7 @@ export function useCopyItems() {
label: linkLabel ?? t("common.copy.link"),
icon: ,
inGesture: true,
- onSelect: () => copy(link, t("common.copy.linkDone")),
+ onSelect: () => copy(link, msg("common.copy.linkDone")),
},
]
: []),
diff --git a/frontend/src/components/ui/useCopyFeedback.dom.test.ts b/frontend/src/components/ui/useCopyFeedback.dom.test.ts
index 830bb4f9..50ab60e2 100644
--- a/frontend/src/components/ui/useCopyFeedback.dom.test.ts
+++ b/frontend/src/components/ui/useCopyFeedback.dom.test.ts
@@ -11,8 +11,12 @@ vi.mock("react-i18next", async (importOriginal) => ({
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
-vi.mock("../../lib/notifications", () => ({ notify: h.notify }));
+vi.mock("../../lib/notifications", async (importOriginal) => ({
+ ...(await importOriginal()),
+ notify: h.notify,
+}));
+import { msg } from "../../lib/notifications";
import { useCopyFeedback } from "./useCopyFeedback";
afterEach(() => {
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
await act(() => result.current.copy("sfl_x"));
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
expect(result.current.copied).toBe(true);
- expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" });
+ expect(h.notify).toHaveBeenCalledWith({ level: "success", message: msg("common.copy.done") });
act(() => void vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false);
});
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x"));
expect(result.current.copied).toBe(false);
- expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
+ expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: msg("common.copy.failed") });
});
});
diff --git a/frontend/src/components/ui/useCopyFeedback.ts b/frontend/src/components/ui/useCopyFeedback.ts
index 9c12c0a4..2efa2069 100644
--- a/frontend/src/components/ui/useCopyFeedback.ts
+++ b/frontend/src/components/ui/useCopyFeedback.ts
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from "react";
-import { useTranslation } from "react-i18next";
import { copyText } from "../../lib/clipboard";
-import { notify } from "../../lib/notifications";
+import { msg, notify } from "../../lib/notifications";
// How long a Copy button reads "Copied" after it worked.
const COPIED_MS = 2000;
@@ -15,7 +14,6 @@ const COPIED_MS = 2000;
*
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise } {
- const { t } = useTranslation();
const [copied, setCopied] = useState(false);
// One reset at a time: a copy again within the moment starts it over, not cut short by the last.
const timer = useRef(undefined);
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
const copy = async (value: string) => {
if (await copyText(value)) {
setCopied(true);
- notify({ level: "success", message: t("common.copy.done") });
+ notify({ level: "success", message: msg("common.copy.done") });
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
return;
}
- notify({ level: "warning", message: t("common.copy.failed") });
+ notify({ level: "warning", message: msg("common.copy.failed") });
};
return { copied, copy };
}
diff --git a/frontend/src/features/channel/TagManager.tsx b/frontend/src/features/channel/TagManager.tsx
index f93309a6..91660987 100644
--- a/frontend/src/features/channel/TagManager.tsx
+++ b/frontend/src/features/channel/TagManager.tsx
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../../lib/api";
-import { notify } from "../../lib/notifications";
+import { msg, type NotifText, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button";
@@ -137,22 +137,22 @@ export default function TagManager({
});
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
- const onFailed = (e: unknown, fallback: string) => {
+ const onFailed = (e: unknown, fallback: NotifText) => {
notifyActionError(e, fallback);
invalidate();
};
const rename = useMutation({
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
onSuccess: invalidate,
- onError: (e) => onFailed(e, t("tagManager.renameFailed")),
+ onError: (e) => onFailed(e, msg("tagManager.renameFailed")),
});
const del = useMutation({
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
onSuccess: (_d, v) => {
invalidate();
- notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
+ notify({ level: "success", message: msg("tagManager.deleted", { name: v.name }) });
},
- onError: (e) => onFailed(e, t("tagManager.deleteFailed")),
+ onError: (e) => onFailed(e, msg("tagManager.deleteFailed")),
});
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
diff --git a/frontend/src/features/comfort/comfortPin.tsx b/frontend/src/features/comfort/comfortPin.tsx
index 1ecef7ec..cb5b4db7 100644
--- a/frontend/src/features/comfort/comfortPin.tsx
+++ b/frontend/src/features/comfort/comfortPin.tsx
@@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react";
import { plexLang } from "../../i18n";
import { api, type DiscoverCard, type PinKind } 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 { type ActionMenuItem } from "../../components/ui/ActionMenu";
@@ -139,13 +139,13 @@ export function useComfortPin(open: boolean): ComfortPin {
await api.addComfortPin(target.kind, target.ref);
// The shelf lives on another page, so without this the click has no visible consequence at
// all from the feed — the one place a toast genuinely carries the whole feedback.
- notify({ level: "success", message: t("home.pin.added", { title }) });
+ notify({ level: "success", message: msg("home.pin.added", { title }) });
} catch (e) {
- notifyActionError(e, t("home.pin.addFailed"));
+ notifyActionError(e, msg("home.pin.addFailed"));
}
invalidate();
},
- [invalidate, t],
+ [invalidate],
);
const remove = useCallback(
@@ -153,14 +153,14 @@ export function useComfortPin(open: boolean): ComfortPin {
void (async () => {
try {
await api.removeComfortPin(pinId);
- notify({ level: "success", message: t("home.pin.removed", { title }) });
+ notify({ level: "success", message: msg("home.pin.removed", { title }) });
} catch (e) {
- notifyActionError(e, t("home.pin.removeFailed"));
+ notifyActionError(e, msg("home.pin.removeFailed"));
}
invalidate();
})();
},
- [invalidate, t],
+ [invalidate],
);
const toggle = useCallback(
diff --git a/frontend/src/features/diagnostics/DebugRecorder.tsx b/frontend/src/features/diagnostics/DebugRecorder.tsx
index 91e1033f..22f74770 100644
--- a/frontend/src/features/diagnostics/DebugRecorder.tsx
+++ b/frontend/src/features/diagnostics/DebugRecorder.tsx
@@ -18,7 +18,7 @@ import {
} from "../../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../../lib/format";
import { useLayer } from "../../lib/layerStack";
-import { notify } from "../../lib/notifications";
+import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { dockUnderToasts } from "../../lib/toastDock";
import { useMe } from "../../lib/useMe";
@@ -73,7 +73,7 @@ export default function DebugRecorder() {
if (!id || !until) return;
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
const timer = window.setTimeout(() => {
- notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
+ notify({ level: "warning", message: msg("recorder.pill.expired", { count: minutes ?? 0 }) });
openComposer(id);
void qc.invalidateQueries({ queryKey: qk.me() });
}, wait);
diff --git a/frontend/src/features/diagnostics/IssueComposer.tsx b/frontend/src/features/diagnostics/IssueComposer.tsx
index ff7a1024..cb635105 100644
--- a/frontend/src/features/diagnostics/IssueComposer.tsx
+++ b/frontend/src/features/diagnostics/IssueComposer.tsx
@@ -6,7 +6,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
import { closeComposer } from "../../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
-import { notify } from "../../lib/notifications";
+import { msg, notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import {
draftSteps,
@@ -217,7 +217,7 @@ function ComposerBody({
meta: { failTitle: "recorder.composer.sendFailed" },
onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session);
- notify({ level: "success", message: t("recorder.composer.sentOk") });
+ notify({ level: "success", message: msg("recorder.composer.sentOk") });
},
// A failure half-way still created the issue: show what the server now has.
onSettled: async () => {
diff --git a/frontend/src/features/diagnostics/useDebugRecording.ts b/frontend/src/features/diagnostics/useDebugRecording.ts
index c0a2b914..79fc095a 100644
--- a/frontend/src/features/diagnostics/useDebugRecording.ts
+++ b/frontend/src/features/diagnostics/useDebugRecording.ts
@@ -1,6 +1,5 @@
import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
-import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
@@ -14,7 +13,7 @@ import {
} from "../../lib/debugRecorder";
import { flushNow, liveSession } from "../../lib/diagnostics";
import { log } from "../../lib/log";
-import { notify } from "../../lib/notifications";
+import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { updateMeCache, useMe } from "../../lib/useMe";
@@ -22,7 +21,6 @@ import { updateMeCache, useMe } from "../../lib/useMe";
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
* answer, so a reload, another tab or the cap all show the same thing. */
export function useDebugRecording() {
- const { t } = useTranslation();
const qc = useQueryClient();
const me = useMe();
const session = liveSession(me?.debug_session);
@@ -54,7 +52,7 @@ export function useDebugRecording() {
meta: { failTitle: "recorder.pill.stopFailed" },
onSuccess: (r) => {
setSession(null);
- notify({ level: "success", message: t("recorder.pill.stopped") });
+ notify({ level: "success", message: msg("recorder.pill.stopped") });
openComposer(r.session.id);
},
});
@@ -78,7 +76,7 @@ export function useDebugRecording() {
);
// In the evidence too, where the step list is drafted from: which step it belongs to.
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
- notify({ level: "success", message: t("recorder.pill.shotSaved") });
+ notify({ level: "success", message: msg("recorder.pill.shotSaved") });
},
});
@@ -95,9 +93,9 @@ export function useDebugRecording() {
const n = nextStep(sessionId);
const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
- notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
+ notify({ level: "info", message: msg("recorder.pill.stepMarked", { n }) });
},
- [sessionId, t],
+ [sessionId],
);
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
@@ -111,7 +109,7 @@ export function useDebugRecording() {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
image = await captureTab();
} catch (err) {
- notifyActionError(err, t("recorder.pill.shotFailed"));
+ notifyActionError(err, msg("recorder.pill.shotFailed"));
}
hide(false);
if (image) shoot.mutate({ id: session.id, image });
diff --git a/frontend/src/features/downloads/DownloadDialog.tsx b/frontend/src/features/downloads/DownloadDialog.tsx
index c680672d..837c8ed9 100644
--- a/frontend/src/features/downloads/DownloadDialog.tsx
+++ b/frontend/src/features/downloads/DownloadDialog.tsx
@@ -9,7 +9,7 @@ import { invalidateDownloads } from "../../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
-import { notify } from "../../lib/notifications";
+import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { profileName } from "../../lib/vocab";
@@ -63,7 +63,7 @@ export default function DownloadDialog({
setLastProfile(chosen);
notify({
level: "success",
- message: t("downloads.dialog.added"),
+ message: msg("downloads.dialog.added"),
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) },
});
onClose();
@@ -71,7 +71,7 @@ export default function DownloadDialog({
// A quota or validation refusal (422/409) is already in the global error dialog, which the
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said.
- notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
+ notifyActionError(e, msg("errors.writeFailed.enqueueDownload"));
setBusy(false);
}
};
diff --git a/frontend/src/features/downloads/DownloadTray.dom.test.tsx b/frontend/src/features/downloads/DownloadTray.dom.test.tsx
index 69096443..2755a017 100644
--- a/frontend/src/features/downloads/DownloadTray.dom.test.tsx
+++ b/frontend/src/features/downloads/DownloadTray.dom.test.tsx
@@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
}));
import DownloadTray from "./DownloadTray";
+import { msg, type NotifText } from "../../lib/notifications";
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys";
@@ -199,13 +200,13 @@ describe("DownloadTray", () => {
poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
const notice = h.notify.mock.calls[0]![0] as {
- title: string;
- message: string;
+ title: NotifText;
+ message: NotifText;
action: { label: string; onClick: () => void };
secondaryAction: { label: string; onClick: () => void };
};
- expect(notice.title).toBe("tray.done.title:Clip 1");
- expect(notice.message).toBe("tray.done.phone");
+ expect(notice.title).toEqual(msg("tray.done.title", { title: "Clip 1" }));
+ expect(notice.message).toEqual(msg("tray.done.phone"));
expect(notice.action.label).toBe("tray.done.copyLink");
notice.action.onClick();
expect(h.share).toHaveBeenCalled();
@@ -225,9 +226,9 @@ describe("DownloadTray", () => {
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
poll([job(1, "done"), job(2, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
- expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([
- "tray.done.title:Clip 1",
- "tray.done.title:Clip 2",
+ expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
+ msg("tray.done.title", { title: "Clip 1" }),
+ msg("tray.done.title", { title: "Clip 2" }),
]);
});
@@ -236,7 +237,9 @@ describe("DownloadTray", () => {
const view = mount();
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
await waitFor(() =>
- expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })),
+ expect(h.notify).toHaveBeenCalledWith(
+ expect.objectContaining({ message: msg("tray.done.file") }),
+ ),
);
});
@@ -257,7 +260,7 @@ describe("DownloadTray", () => {
poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
- expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }),
+ expect.objectContaining({ title: msg("tray.done.uploadedTitle", { title: "Clip 1" }) }),
),
);
});
diff --git a/frontend/src/features/downloads/DownloadTray.tsx b/frontend/src/features/downloads/DownloadTray.tsx
index c7c80331..c6f30696 100644
--- a/frontend/src/features/downloads/DownloadTray.tsx
+++ b/frontend/src/features/downloads/DownloadTray.tsx
@@ -30,7 +30,7 @@ import {
} from "../../lib/downloadQueue";
import { anyBlockingLayer } from "../../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
-import { notify } from "../../lib/notifications";
+import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { LS } from "../../lib/storage";
import { useLiveQuery } from "../../lib/useLiveQuery";
@@ -191,10 +191,10 @@ export default function DownloadTray() {
notify({
level: "success",
// An upload the worker had to convert first is done here too — it was never downloaded.
- title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
+ title: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
title,
}),
- message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
+ message: msg(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: {
label: t("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
diff --git a/frontend/src/features/downloads/useDownloadActions.tsx b/frontend/src/features/downloads/useDownloadActions.tsx
index 436f3b95..cb896aba 100644
--- a/frontend/src/features/downloads/useDownloadActions.tsx
+++ b/frontend/src/features/downloads/useDownloadActions.tsx
@@ -9,6 +9,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups";
+import { msg } from "../../lib/notifications";
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
@@ -56,7 +57,7 @@ export function useDownloadActions() {
? api.resumeDownload(id)
: api.cancelDownload(id),
onSuccess: () => invalidateDownloads(qc),
- onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
+ onError: (e, v) => notifyActionError(e, msg(`errors.writeFailed.job.${v.op}`)),
});
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
diff --git a/frontend/src/features/filters/useSavedViewsStore.ts b/frontend/src/features/filters/useSavedViewsStore.ts
index cc599934..53f47ff8 100644
--- a/frontend/src/features/filters/useSavedViewsStore.ts
+++ b/frontend/src/features/filters/useSavedViewsStore.ts
@@ -8,6 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { ownKey } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext";
import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
+import { msg, type NotifText } from "../../lib/notifications";
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
@@ -114,7 +115,7 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
const isDirty = displayView != null && activeView == null;
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
- async function runViewMutation(fn: () => Promise, failMsg: string): Promise {
+ async function runViewMutation(fn: () => Promise, failMsg: NotifText): Promise {
let ok = true;
try {
await fn();
@@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
if (!clean) return false;
return runViewMutation(
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
- t("views.actionFailed"),
+ msg("views.actionFailed"),
);
},
rename: async (id, name) => {
const next = name.trim();
if (!next) return false;
- return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
+ return runViewMutation(() => api.updateView(id, { name: next }), msg("views.actionFailed"));
},
updateFilters: (id, filters) =>
- runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
+ runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")),
saveCurrent: async (id) => {
const filters = cfg.stripForSave(cfg.filters);
const ok = await runViewMutation(
() => api.updateView(id, { filters }),
- t("views.actionFailed"),
+ msg("views.actionFailed"),
);
return ok ? filters : null;
},
toggleDefault: (v) =>
runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }),
- t("views.actionFailed"),
+ msg("views.actionFailed"),
).then(() => undefined),
remove: async (v) => {
const ok = await confirm({
@@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
});
if (!ok) return false;
if (stickyId === v.id) setStickyId(null);
- return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
+ return runViewMutation(() => api.deleteView(v.id), msg("views.actionFailed"));
},
reorder: async (nextIds) => {
qc.setQueryData(
qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
);
- await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
+ await runViewMutation(() => api.reorderViews(nextIds), msg("views.reorderFailed"));
},
};
}
diff --git a/frontend/src/features/player/ClipPanel.dom.test.tsx b/frontend/src/features/player/ClipPanel.dom.test.tsx
index 5fd20636..650cfcbc 100644
--- a/frontend/src/features/player/ClipPanel.dom.test.tsx
+++ b/frontend/src/features/player/ClipPanel.dom.test.tsx
@@ -30,10 +30,11 @@ vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTr
const notifyActionError = vi.fn();
vi.mock("../../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal();
- return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
+ return { ...actual, notifyActionError: (e: unknown, t: NotifText) => notifyActionError(e, t) };
});
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
+import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -188,7 +189,7 @@ describe("ClipPanel", () => {
display_name: "The bit",
});
expect(notify).toHaveBeenCalledWith(
- expect.objectContaining({ level: "success", message: "clip.queued" }),
+ expect.objectContaining({ level: "success", message: msg("clip.queued") }),
);
// Its View opens the tray on the new clip.
(
@@ -214,7 +215,7 @@ describe("ClipPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
- expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed");
+ expect(notifyActionError.mock.calls[0]![1]).toEqual(msg("clip.failed"));
expect(onClose).not.toHaveBeenCalled();
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
false,
diff --git a/frontend/src/features/player/ClipPanel.tsx b/frontend/src/features/player/ClipPanel.tsx
index 9e1ee6cd..08690fd6 100644
--- a/frontend/src/features/player/ClipPanel.tsx
+++ b/frontend/src/features/player/ClipPanel.tsx
@@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc
import { api, type ClipRequest } from "../../lib/api";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { invalidateDownloads } from "../../lib/downloads";
-import { notify } from "../../lib/notifications";
+import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log";
@@ -197,14 +197,14 @@ export default function ClipPanel({
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({
level: "success",
- message: t("clip.queued"),
+ message: msg("clip.queued"),
action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
});
onClose();
} catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
- notifyActionError(err, t("clip.failed"));
+ notifyActionError(err, msg("clip.failed"));
setBusy(false);
}
};
diff --git a/frontend/src/features/player/LinkedPlayer.tsx b/frontend/src/features/player/LinkedPlayer.tsx
index f6f86231..d139fcf2 100644
--- a/frontend/src/features/player/LinkedPlayer.tsx
+++ b/frontend/src/features/player/LinkedPlayer.tsx
@@ -1,5 +1,4 @@
import { lazy, Suspense, useState } from "react";
-import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { type Video, type VideoStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
@@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { useStableCallback } from "../../lib/useLatest";
import { writeVideoState } from "../video/videoWrites";
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
+import { msg } from "../../lib/notifications";
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -14,7 +14,6 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
* channel manager, where the watched-videos dialog played it; any page a link names): the video
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
- const { t } = useTranslation();
const qc = useQueryClient();
const [own] = useState(!hosted);
const [video, setVideo] = useState