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} />