From e4c4efec1835d55c50c2074dfc84e97f6bf9ae43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sun, 2 Aug 2026 01:49:44 +0200 Subject: [PATCH] feat(i18n): localize Settings > General > Notifications Settings > General > Notifications held all of its copy as literals, so the page stayed English under a locale switch. Every string now resolves through t() / at render, and the page is on the i18nGuardFiles allowlist so it cannot drift back. Event titles, sound preset names and the permission button label resolve from maps of catalog keys rather than module-scope t(), so they follow a locale switch. The Apprise install notice is a single message so the link stays inside the sentence. SCREENS in the pseudo-locale oracle gains a per-screen allow list for text the frontend must not translate (backend-owned provider names). --- .../notification-events-table.test.tsx | 96 +++++++++++++++++++ .../settings/notification-events-table.tsx | 83 +++++++++++++--- .../notification-permission-section.test.tsx | 20 +++- .../notification-permission-section.tsx | 44 +++++---- .../notification-sound-section.test.tsx | 13 +++ .../settings/notification-sound-section.tsx | 30 ++++-- .../notifications-settings-actions.ts | 7 +- .../settings/notifications-settings.test.tsx | 70 ++++++++++++++ .../settings/notifications-settings.tsx | 67 +++++++------ .../e2e/tests/i18n/pseudo-coverage.spec.ts | 22 ++++- apps/web/eslint.i18n.options.mjs | 14 ++- apps/web/src/locales/en/settings.json | 62 +++++++++++- apps/web/src/locales/pseudo/settings.json | 62 +++++++++++- 13 files changed, 503 insertions(+), 87 deletions(-) create mode 100644 apps/web/components/settings/notification-events-table.test.tsx create mode 100644 apps/web/components/settings/notifications-settings.test.tsx diff --git a/apps/web/components/settings/notification-events-table.test.tsx b/apps/web/components/settings/notification-events-table.test.tsx new file mode 100644 index 0000000000..d10fda7923 --- /dev/null +++ b/apps/web/components/settings/notification-events-table.test.tsx @@ -0,0 +1,96 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED, + NOTIFICATION_EVENT_SESSION_TURN_FINISHED, +} from "@/lib/notifications/events"; +import type { NotificationProvider } from "@/lib/types/http"; +import { NotificationEventsTable } from "./notification-events-table"; + +function provider(overrides: Partial = {}): NotificationProvider { + return { + id: "provider-1", + name: "Desktop Notifications", + type: "local", + config: {}, + enabled: true, + events: [], + created_at: "", + updated_at: "", + ...overrides, + }; +} + +function renderTable(tableEvents: string[], providers = [provider()], onTestProvider = vi.fn()) { + return render( + , + ); +} + +afterEach(cleanup); + +describe("NotificationEventsTable", () => { + it("renders the translated title and description for a known event type", () => { + renderTable([NOTIFICATION_EVENT_SESSION_TURN_FINISHED]); + + // Once in the mobile list and once in the desktop table; both are always + // mounted and hidden by CSS, so each string appears twice. + expect(screen.getAllByText("Agent turn finished")).toHaveLength(2); + expect(screen.getAllByText("Notify after each completed agent turn.")).toHaveLength(2); + }); + + it("falls back to the raw event type for an event the catalog does not know", () => { + renderTable(["session.some_future_event"]); + + expect(screen.getAllByText("session.some_future_event")).toHaveLength(2); + expect(screen.getAllByText("Notify when this event occurs.")).toHaveLength(2); + }); + + it("names each checkbox with the event title and the provider name", () => { + renderTable([NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED]); + + expect( + screen.getAllByRole("checkbox", { + name: "Agent needs an answer for Desktop Notifications", + }), + ).toHaveLength(2); + }); + + it("names and wires the test control for a remote provider", async () => { + const onTestProvider = vi.fn().mockResolvedValue(undefined); + const remote = provider({ id: "apprise-1", name: "Ops channel", type: "apprise" }); + renderTable([NOTIFICATION_EVENT_SESSION_TURN_FINISHED], [remote], onTestProvider); + + const buttons = screen.getAllByRole("button", { + name: "Send test notification for Ops channel", + }); + expect(buttons).toHaveLength(2); + + // Radix Tooltip does not open from synthetic pointer events in this + // environment; focus is the reliable path (apps/web/AGENTS.md). + fireEvent.focus(buttons[0]); + expect((await screen.findByRole("tooltip")).textContent).toBe("Send test notification"); + + fireEvent.click(buttons[0]); + expect(onTestProvider).toHaveBeenCalledWith("apprise-1"); + }); + + it("omits the test control for the local provider, which cannot be tested remotely", () => { + renderTable([NOTIFICATION_EVENT_SESSION_TURN_FINISHED]); + + expect(screen.queryByRole("button", { name: /Send test notification/ })).toBeNull(); + }); + + it("reports an empty provider list instead of an empty table", () => { + renderTable([NOTIFICATION_EVENT_SESSION_TURN_FINISHED], []); + + expect(screen.getByText("No providers configured yet.")).toBeTruthy(); + expect(screen.queryByTestId("notification-events-desktop-table")).toBeNull(); + }); +}); diff --git a/apps/web/components/settings/notification-events-table.tsx b/apps/web/components/settings/notification-events-table.tsx index a78d3d4ddb..b075d7b999 100644 --- a/apps/web/components/settings/notification-events-table.tsx +++ b/apps/web/components/settings/notification-events-table.tsx @@ -1,10 +1,16 @@ "use client"; import { IconBell } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Checkbox } from "@kandev/ui/checkbox"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; -import { EVENT_LABELS } from "@/lib/notifications/events"; +import { + NOTIFICATION_EVENT_OFFICE_INBOX_ITEM, + NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED, + NOTIFICATION_EVENT_SESSION_TURN_FINISHED, + NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE, +} from "@/lib/notifications/events"; import type { NotificationProvider } from "@/lib/types/http"; type Props = { @@ -15,36 +21,76 @@ type Props = { onTestProvider: (providerId: string) => Promise; }; -function eventMeta(eventType: string) { - return ( - EVENT_LABELS[eventType] ?? { - title: eventType, - description: "Notify when this event occurs.", - } - ); +/** + * Catalog keys for each known event type — keys, not resolved copy. The event + * type itself is a wire sentinel and is never translated; only the row's title + * and description are. Resolving these at module scope would pin the copy to the + * boot locale (docs/i18n.md), so `useEventMeta` resolves them at render. + * + * An event type the backend introduces before this map knows it falls back to + * the raw type plus a generic description, exactly as it did before i18n. + */ +const EVENT_MESSAGE_KEYS: Record = { + [NOTIFICATION_EVENT_SESSION_TURN_FINISHED]: { + title: "settings:notificationEventTurnFinished", + description: "settings:notificationEventTurnFinishedDescription", + }, + [NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED]: { + title: "settings:notificationEventClarificationRequested", + description: "settings:notificationEventClarificationRequestedDescription", + }, + [NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE]: { + title: "settings:notificationEventUpdateAvailable", + description: "settings:notificationEventUpdateAvailableDescription", + }, + [NOTIFICATION_EVENT_OFFICE_INBOX_ITEM]: { + title: "settings:notificationEventOfficeInboxItem", + description: "settings:notificationEventOfficeInboxItemDescription", + }, +}; + +function useEventMeta() { + const { t } = useTranslation(); + return (eventType: string) => { + const keys = EVENT_MESSAGE_KEYS[eventType]; + return { + title: keys ? t(keys.title) : eventType, + description: keys ? t(keys.description) : t("settings:notifyWhenThisEventOccurs"), + }; + }; } +/** + * `eventTitle` comes from the row that owns it rather than a second + * `useEventMeta()` lookup, so the accessible name can never drift from the title + * the user reads next to the checkbox. + */ function EventCheckbox({ provider, baselineProviders, eventType, + eventTitle, onToggleEvent, mobile = false, }: { provider: NotificationProvider; baselineProviders: NotificationProvider[]; eventType: string; + eventTitle: string; onToggleEvent: Props["onToggleEvent"]; mobile?: boolean; }) { - const meta = eventMeta(eventType); + const { t } = useTranslation(); const checked = (provider.events ?? []).includes(eventType); const baselineChecked = ( baselineProviders.find((candidate) => candidate.id === provider.id)?.events ?? [] ).includes(eventType); const checkbox = ( onToggleEvent(provider, eventType)} @@ -69,6 +115,7 @@ function TestProviderButton({ provider: NotificationProvider; mobile?: boolean; }) { + const { t } = useTranslation(); if (provider.type === "local") return null; return ( @@ -78,13 +125,13 @@ function TestProviderButton({ variant="ghost" size="icon" className={mobile ? "h-11 w-11 shrink-0 cursor-pointer" : "h-6 w-6 cursor-pointer"} - aria-label={`Send test notification for ${provider.name}`} + aria-label={t("settings:sendTestNotificationFor", { name: provider.name })} onClick={() => void onTestProvider(provider.id)} > - Send test notification + {t("settings:sendTestNotification")} ); @@ -97,6 +144,7 @@ function MobileEventList({ onToggleEvent, onTestProvider, }: Props) { + const eventMeta = useEventMeta(); return (
{tableEvents.map((eventType) => { @@ -121,6 +169,7 @@ function MobileEventList({ provider={provider} baselineProviders={baselineProviders} eventType={eventType} + eventTitle={meta.title} onToggleEvent={onToggleEvent} mobile /> @@ -141,6 +190,8 @@ function DesktopEventTable({ onToggleEvent, onTestProvider, }: Props) { + const { t } = useTranslation(); + const eventMeta = useEventMeta(); return (
- Notification type + {t("settings:notificationType")} {tableProviders.map((provider) => (
@@ -176,6 +227,7 @@ function DesktopEventTable({ provider={provider} baselineProviders={baselineProviders} eventType={eventType} + eventTitle={meta.title} onToggleEvent={onToggleEvent} />
@@ -191,8 +243,11 @@ function DesktopEventTable({ } export function NotificationEventsTable(props: Props) { + const { t } = useTranslation(); if (props.tableProviders.length === 0) { - return

No providers configured yet.

; + return ( +

{t("settings:noProvidersConfiguredYet")}

+ ); } return ( diff --git a/apps/web/components/settings/notification-permission-section.test.tsx b/apps/web/components/settings/notification-permission-section.test.tsx index 3c0ba55129..2f501212aa 100644 --- a/apps/web/components/settings/notification-permission-section.test.tsx +++ b/apps/web/components/settings/notification-permission-section.test.tsx @@ -1,4 +1,4 @@ -import { render, renderHook, screen, waitFor } from "@testing-library/react"; +import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DesktopNotificationsSection, @@ -23,6 +23,7 @@ beforeEach(() => { }); afterEach(() => { + cleanup(); Object.defineProperty(globalThis, "Notification", { configurable: true, value: originalNotification, @@ -87,4 +88,21 @@ describe("DesktopNotificationsSection", () => { expect(screen.getByRole("button", { name: "Refresh notification permission" })).toBeTruthy(); }); + + it.each([ + ["default" as const, "Enable"], + ["granted" as const, "Enabled"], + ["error" as const, "Retry"], + ])("labels the permission button %s as %s", (permission, label) => { + render( + , + ); + + expect(screen.getByRole("button", { name: label })).toBeTruthy(); + }); }); diff --git a/apps/web/components/settings/notification-permission-section.tsx b/apps/web/components/settings/notification-permission-section.tsx index ea013931e2..2392c1deb2 100644 --- a/apps/web/components/settings/notification-permission-section.tsx +++ b/apps/web/components/settings/notification-permission-section.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { IconBell, IconRefresh } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -22,10 +23,15 @@ type DesktopNotificationsSectionProps = { onTestNotification: () => void; }; -function permissionActionLabel(permission: NotificationPermissionState) { - if (permission === "granted") return "Enabled"; - if (permission === "error") return "Retry"; - return "Enable"; +/** + * Returns a catalog key rather than copy: `permission` is a sentinel compared + * with `===` and this helper runs outside a component, so resolving here would + * either freeze the copy at the boot locale or need a `t` threaded through. + */ +function permissionActionLabelKey(permission: NotificationPermissionState) { + if (permission === "granted") return "settings:notificationPermissionEnabled"; + if (permission === "error") return "settings:notificationPermissionRetry"; + return "settings:notificationPermissionEnable"; } export function DesktopNotificationsSection({ @@ -34,18 +40,19 @@ export function DesktopNotificationsSection({ onRefreshPermission, onTestNotification, }: DesktopNotificationsSectionProps) { + const { t } = useTranslation(); return (
-
Desktop Notifications
+
{t("settings:desktopNotifications")}

- Notify this device when a selected agent turn, question, or Office event occurs. + {t("settings:desktopNotificationsDescription")}

@@ -66,20 +73,20 @@ export function DesktopNotificationsSection({ - Refresh permission status + {t("settings:refreshPermissionStatus")}
@@ -100,20 +107,15 @@ export function DesktopNotificationsSection({ {notificationPermission === "denied" && (

{nativeNotifications.isAvailable() - ? "Notifications are blocked in your OS app notification settings. Enable them there, then click Refresh." - : "Notifications are blocked in your browser. Enable them in site settings, then click Refresh."} + ? t("settings:notificationsBlockedInOs") + : t("settings:notificationsBlockedInBrowser")}

)} {notificationPermission === "unsupported" && ( -

- This browser does not support desktop notifications. -

+

{t("settings:notificationsUnsupported")}

)} {notificationPermission === "error" && ( -

- Kandev could not check notification permission. Try again, then check your browser or OS - notification settings. -

+

{t("settings:notificationPermissionCheckFailed")}

)}
); diff --git a/apps/web/components/settings/notification-sound-section.test.tsx b/apps/web/components/settings/notification-sound-section.test.tsx index fc57a2e957..ed34c56777 100644 --- a/apps/web/components/settings/notification-sound-section.test.tsx +++ b/apps/web/components/settings/notification-sound-section.test.tsx @@ -58,6 +58,19 @@ describe("NotificationSoundSection", () => { await waitFor(() => expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("false")); }); + it("shows the selected preset under its translated name", async () => { + setSoundPreferences({ enabled: true, presetId: "ding" }); + render( + + + , + ); + + // The preset id is the persisted sentinel; only the name is translated. + const trigger = await screen.findByRole("combobox", { name: "Notification sound" }); + expect(trigger.textContent).toContain("Ding"); + }); + it("reports dirtiness so the notifications parent card can highlight", async () => { const onDirtyChange = vi.fn(); render( diff --git a/apps/web/components/settings/notification-sound-section.tsx b/apps/web/components/settings/notification-sound-section.tsx index eebcd25b45..631ca37bdb 100644 --- a/apps/web/components/settings/notification-sound-section.tsx +++ b/apps/web/components/settings/notification-sound-section.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { IconVolume } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Switch } from "@kandev/ui/switch"; @@ -13,14 +14,28 @@ import { setSoundPreferences, SOUND_PRESETS, type SoundPreferences, + type SoundPresetId, } from "@/lib/notifications/sound"; import { useSettingsSaveContributor } from "./settings-save-provider"; +/** + * Catalog keys for the preset names, keyed by the preset id — the id is the + * persisted sentinel and must never be translated. Keys, not `t()` calls: a + * module-scope `t` would freeze the names at the boot locale (docs/i18n.md). + */ +const SOUND_PRESET_LABEL_KEYS: Record = { + plim: "settings:soundPresetPlim", + chime: "settings:soundPresetChime", + ding: "settings:soundPresetDing", + pop: "settings:soundPresetPop", +}; + export function NotificationSoundSection({ onDirtyChange, }: { onDirtyChange?: (isDirty: boolean) => void; }) { + const { t } = useTranslation(); const [saved, setSaved] = useState(getSoundPreferences); const [prefs, setPrefs] = useState(saved); const revision = JSON.stringify(prefs); @@ -48,17 +63,16 @@ export function NotificationSoundSection({ >
-
Notification Sound
+
{t("settings:notificationSound")}

- Play a sound on this device when a selected agent turn, question, or Office event - occurs. + {t("settings:notificationSoundDescription")}

setPrefs({ ...prefs, enabled })} - aria-label="Enable notification sound" + aria-label={t("settings:enableNotificationSound")} className="cursor-pointer" />
@@ -74,7 +88,7 @@ export function NotificationSoundSection({ > @@ -82,7 +96,7 @@ export function NotificationSoundSection({ {SOUND_PRESETS.map((preset) => ( - {preset.label} + {t(SOUND_PRESET_LABEL_KEYS[preset.id])} ))} @@ -94,13 +108,13 @@ export function NotificationSoundSection({ variant="outline" size="icon" className="cursor-pointer" - aria-label="Preview sound" + aria-label={t("settings:previewSound")} onClick={() => playSoundPreset(prefs.presetId)} > - Preview sound + {t("settings:previewSound")}
diff --git a/apps/web/components/settings/notifications-settings-actions.ts b/apps/web/components/settings/notifications-settings-actions.ts index cb85e23f91..e5325f3949 100644 --- a/apps/web/components/settings/notifications-settings-actions.ts +++ b/apps/web/components/settings/notifications-settings-actions.ts @@ -8,6 +8,7 @@ import { updateNotificationProvider, } from "@/lib/api"; import { useRequest } from "@/lib/http/use-request"; +import { t } from "@/lib/i18n"; import { DEFAULT_NOTIFICATION_EVENTS } from "@/lib/notifications/events"; import { nativeNotifications } from "@/lib/desktop/native-notification-client"; import { useNotificationProviders } from "@/hooks/domains/settings/use-notification-providers"; @@ -273,7 +274,7 @@ export function useSaveRequest(state: NotificationsState) { ? { name: appriseName, urls: parseAppriseUrls(appriseUrls) } : null; if (createDraft && createDraft.urls.length === 0) { - throw new Error("At least one Apprise service URL is required."); + throw new Error(t("settings:appriseUrlRequired")); } const updates: Array> = []; for (const provider of providers) { @@ -511,8 +512,8 @@ export function useNotificationsActions( bumpPermission(); } if (permission !== "granted") return; - new Notification("Test notification", { - body: "If you can read this, browser notifications are working.", + new Notification(t("settings:testNotificationTitle"), { + body: t("settings:testNotificationBody"), }); }; diff --git a/apps/web/components/settings/notifications-settings.test.tsx b/apps/web/components/settings/notifications-settings.test.tsx new file mode 100644 index 0000000000..49b4d039d6 --- /dev/null +++ b/apps/web/components/settings/notifications-settings.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { NotificationProvider } from "@/lib/types/http"; +import { NotificationsSettings } from "./notifications-settings"; +import { SettingsSaveProvider } from "./settings-save-provider"; + +const mocks = vi.hoisted(() => ({ + createNotificationProvider: vi.fn(), + deleteNotificationProvider: vi.fn(), + testNotificationProvider: vi.fn(), + updateNotificationProvider: vi.fn(), + setNotificationProviders: vi.fn(), +})); + +vi.mock("@/lib/api", () => ({ + createNotificationProvider: mocks.createNotificationProvider, + deleteNotificationProvider: mocks.deleteNotificationProvider, + testNotificationProvider: mocks.testNotificationProvider, + updateNotificationProvider: mocks.updateNotificationProvider, +})); + +// Identities must be stable: the hydration effect in `useNotificationsState` +// compares them by reference, so a fresh array per render re-hydrates forever. +const PROVIDERS: NotificationProvider[] = []; +const EVENTS = ["session.turn_finished"]; + +vi.mock("@/hooks/domains/settings/use-notification-providers", () => ({ + useNotificationProviders: () => ({ + providers: PROVIDERS, + events: EVENTS, + appriseAvailable: false, + loaded: true, + }), +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ setNotificationProviders: mocks.setNotificationProviders }), +})); + +vi.mock("@/lib/desktop/native-notification-client", () => ({ + nativeNotifications: { + isAvailable: () => false, + permission: { get: vi.fn(), request: vi.fn() }, + }, +})); + +afterEach(cleanup); + +describe("NotificationsSettings", () => { + it("renders the Apprise install notice as one sentence around its link", () => { + // addresses the anchor by child index, and a drifted index renders + // duplicated fragments with an empty tag rather than failing (docs/i18n.md). + const { container } = render( + + + , + ); + + const anchor = container.querySelector("a"); + expect(anchor?.textContent).toBe("View installation instructions"); + expect(anchor?.getAttribute("href")).toBe( + "https://github.com/caronc/apprise?tab=readme-ov-file#installation", + ); + expect(anchor?.parentElement?.textContent).toBe( + "Apprise is not installed yet. You can add it later to enable remote notifications. " + + "View installation instructions.", + ); + }); +}); diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx index 6596c8db44..52b2da9913 100644 --- a/apps/web/components/settings/notifications-settings.tsx +++ b/apps/web/components/settings/notifications-settings.tsx @@ -2,6 +2,7 @@ import { useMemo, useState, type FormEvent } from "react"; import { IconBell } from "@tabler/icons-react"; +import { Trans, useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Separator } from "@kandev/ui/separator"; @@ -36,6 +37,7 @@ function AppriseProviderCardActions({ onDeleteProvider: (providerId: string) => void; onTestProvider: (providerId: string) => Promise; }) { + const { t } = useTranslation(); return (
@@ -45,13 +47,13 @@ function AppriseProviderCardActions({ variant="outline" size="icon" className="h-8 w-8 cursor-pointer" - aria-label={`Send test notification for ${provider.name}`} + aria-label={t("settings:sendTestNotificationFor", { name: provider.name })} onClick={() => void onTestProvider(provider.id)} > - Send test notification + {t("settings:sendTestNotification")}
); @@ -148,6 +150,7 @@ function AppriseProviderList({
{provider.name}
+ {/* Product name — never translated (docs/i18n.md "Do not translate"). */}
Apprise
-
External Providers
+
{t("settings:externalProviders")}

- Configure external providers for remote notifications. + {t("settings:externalProvidersDescription")}

{!appriseAvailable && (

- Apprise is not installed yet. You can add it later to enable remote notifications.{" "} - - View installation instructions - - . + {/* The link is part of the sentence, so the whole notice is one + message and the <2> tag addresses the anchor by child index. */} + + Apprise is not installed yet. You can add it later to enable remote notifications.{" "} + + View installation instructions + + . +

)} {appriseProviders.length === 0 && ( -

No Apprise providers configured yet.

+

{t("settings:noAppriseProviders")}

)} onOpenForm("create")} disabled={showAppriseForm} > - Add Apprise Provider + {t("settings:addAppriseProvider")} {showAppriseForm && appriseFormMode === "create" && ( 0; @@ -331,12 +340,13 @@ function useNotificationPageSaveState(state: NotificationsState, soundIsDirty: b providerIsDirty, cardIsDirty: providerIsDirty || soundIsDirty, canSave, - invalidReason: canSave ? undefined : "At least one Apprise service URL is required.", + invalidReason: canSave ? undefined : t("settings:appriseUrlRequired"), revision, }; } export function NotificationsSettings() { + const { t } = useTranslation(); const state = useNotificationsState(); const { notificationPermission, refreshPermission } = useNotificationPermission(); const saveRequest = useSaveRequest(state); @@ -359,8 +369,8 @@ export function NotificationsSettings() { const appriseProviders = providers.filter((provider) => provider.type === "apprise"); return (
-
Notification Events
+
{t("settings:notificationEvents")}

- Select which providers should receive each notification type. + {t("settings:notificationEventsDescription")}

{tableProviders.length > 0 && ( @@ -450,24 +460,25 @@ function AppriseProviderForm({ formIsDirty = nameIsDirty || urlsIsDirty, showSubmit = true, }: AppriseProviderFormProps) { + const { t } = useTranslation(); return (
-
Apprise Provider
+
{t("settings:appriseProvider")}
onNameChange(event.target.value)} - placeholder="Provider name" + placeholder={t("settings:providerName")} data-settings-dirty={nameIsDirty} />