From bb739c74458523d09ed3f9a25943f6af93c8fdcb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:55:04 +0100 Subject: [PATCH] feat: use plainer English and German wording for sign-in and error messages when plain language is on --- .../intl-provider-plain-language.test.tsx | 102 ++++++++++++++++++ components/providers/intl-provider.tsx | 26 ++++- i18n/plain-language.ts | 27 +++++ lib/__tests__/de-formal-address.test.ts | 8 ++ lib/__tests__/plain-language-strings.test.ts | 67 ++++++++++++ locales/de/plain.json | 49 +++++++++ locales/en/plain.json | 49 +++++++++ 7 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 components/providers/__tests__/intl-provider-plain-language.test.tsx create mode 100644 i18n/plain-language.ts create mode 100644 lib/__tests__/plain-language-strings.test.ts create mode 100644 locales/de/plain.json create mode 100644 locales/en/plain.json diff --git a/components/providers/__tests__/intl-provider-plain-language.test.tsx b/components/providers/__tests__/intl-provider-plain-language.test.tsx new file mode 100644 index 000000000..19332c0dd --- /dev/null +++ b/components/providers/__tests__/intl-provider-plain-language.test.tsx @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { useTranslations } from 'next-intl'; +import { IntlProvider } from '../intl-provider'; +import { useLocaleStore } from '@/stores/locale-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import enCommon from '@/locales/en/common.json'; +import deCommon from '@/locales/de/common.json'; +import frCommon from '@/locales/fr/common.json'; +import enPlain from '@/locales/en/plain.json'; +import dePlain from '@/locales/de/plain.json'; + +// Plain-language mode lays locales//plain.json over the catalog when the +// setting is on. Every useTranslations call has to pick it up from the one +// provider, live, and only for English and German. + +vi.unmock('next-intl'); + +function Strings() { + const t = useTranslations('login'); + return ( + <> +

{t('error.invalid_credentials')}

+

{t('sign_in')}

+ + ); +} + +const text = (id: string) => screen.getByTestId(id).textContent; + +// Lets lazily imported catalogs land. +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); +} + +describe('plain-language mode', () => { + beforeEach(() => { + // The fallback assertions need a key that has no plain version. + expect((enPlain as { login: Record }).login.sign_in).toBeUndefined(); + expect((dePlain as { login: Record }).login.sign_in).toBeUndefined(); + }); + + afterEach(() => { + useLocaleStore.setState({ locale: '' }); + useSettingsStore.setState({ plainLanguage: false }); + }); + + it('keeps the normal strings while the setting is off', async () => { + render(); + await settle(); + expect(text('plain')).toBe(enCommon.login.error.invalid_credentials); + }); + + it('uses the plain English string when the setting is on', async () => { + useSettingsStore.setState({ plainLanguage: true }); + render(); + expect(await screen.findByText(enPlain.login.error.invalid_credentials)).toBeTruthy(); + expect(enPlain.login.error.invalid_credentials).not.toBe(enCommon.login.error.invalid_credentials); + // A key without a plain version keeps the common.json string. + expect(text('normal')).toBe(enCommon.login.sign_in); + }); + + it('uses the plain German string for a German UI', async () => { + useSettingsStore.setState({ plainLanguage: true }); + render(); + expect(await screen.findByText(dePlain.login.error.invalid_credentials)).toBeTruthy(); + expect(text('normal')).toBe(deCommon.login.sign_in); + }); + + it('leaves other locales on their normal strings', async () => { + useSettingsStore.setState({ plainLanguage: true }); + render(); + await settle(); + expect(text('plain')).toBe(frCommon.login.error.invalid_credentials); + }); + + it('switches live when the setting changes', async () => { + render(); + await settle(); + expect(text('plain')).toBe(enCommon.login.error.invalid_credentials); + + act(() => useSettingsStore.setState({ plainLanguage: true })); + expect(await screen.findByText(enPlain.login.error.invalid_credentials)).toBeTruthy(); + + act(() => useSettingsStore.setState({ plainLanguage: false })); + expect(text('plain')).toBe(enCommon.login.error.invalid_credentials); + }); + + it('follows a language change while the setting stays on', async () => { + useSettingsStore.setState({ plainLanguage: true }); + render(); + expect(await screen.findByText(enPlain.login.error.invalid_credentials)).toBeTruthy(); + + act(() => useLocaleStore.setState({ locale: 'de' })); + expect(await screen.findByText(dePlain.login.error.invalid_credentials)).toBeTruthy(); + + act(() => useLocaleStore.setState({ locale: 'fr' })); + expect(await screen.findByText(frCommon.login.error.invalid_credentials)).toBeTruthy(); + }); +}); diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx index 726bbcb7c..ff6e5dd08 100644 --- a/components/providers/intl-provider.tsx +++ b/components/providers/intl-provider.tsx @@ -9,6 +9,7 @@ import enMessages from '@/locales/en/common.json'; import { getLocaleDirection } from '@/i18n/direction'; import { mergeMessages } from '@/i18n/merge-messages'; import { detectBrowserLocale } from '@/i18n/detect-locale'; +import { PLAIN_LANGUAGE_LOADERS, hasPlainLanguage, withPlainLanguage } from '@/i18n/plain-language'; type Messages = Record; @@ -64,6 +65,7 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages, const [browserTimeZone, setBrowserTimeZone] = useState('UTC'); const timeZoneSetting = useSettingsStore((state) => state.timeZone); const timeZone = resolveTimeZone(timeZoneSetting, browserTimeZone); + const plainLanguage = useSettingsStore((state) => state.plainLanguage); // The catalog currently in use. Seeded with the server-provided messages for // the SSR-resolved locale, so the first render needs no async work and @@ -116,6 +118,22 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages, // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentLocale]); + // Plain-language strings, loaded the first time the setting is on for an + // English or German UI. They arrive after the first render, so hydration + // always matches the server's normal strings. + const [plainCatalogs, setPlainCatalogs] = useState>({}); + const plainLocale = plainLanguage && catalog && hasPlainLanguage(catalog.locale) ? catalog.locale : null; + useEffect(() => { + if (!plainLocale || plainCatalogs[plainLocale]) return; + let stale = false; + PLAIN_LANGUAGE_LOADERS[plainLocale]() + .catch(() => ({} as Messages)) + .then((plain) => { + if (!stale) setPlainCatalogs((prev) => ({ ...prev, [plainLocale]: plain })); + }); + return () => { stale = true; }; + }, [plainLocale, plainCatalogs]); + // Keep lang/dir in sync with the rendered locale (RTL for he/fa). const activeLocale = catalog?.locale; useEffect(() => { @@ -126,13 +144,19 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages, // Fall back to English for any key the active locale has not translated, so // untranslated strings show English text instead of a raw message key. - const messages = useMemo( + const baseMessages = useMemo( () => !catalog || catalog.locale === 'en' ? (enMessages as Messages) : mergeMessages(enMessages as Messages, catalog.messages), [catalog] ); + // Plain strings win over the catalog; keys without a plain version keep it. + const plainMessages = plainLocale ? plainCatalogs[plainLocale] : undefined; + const messages = useMemo( + () => withPlainLanguage(baseMessages, plainMessages), + [baseMessages, plainMessages] + ); if (!catalog) return null; diff --git a/i18n/plain-language.ts b/i18n/plain-language.ts new file mode 100644 index 000000000..655382133 --- /dev/null +++ b/i18n/plain-language.ts @@ -0,0 +1,27 @@ +import { mergeMessages } from './merge-messages'; + +type Messages = Record; + +/** + * Plain-language mode (CO-14): simpler wording for settings and error + * messages. Only English and German have plain versions; every other locale + * keeps its normal strings when the setting is on. + * + * The plain strings live in `locales//plain.json`, outside common.json, + * so the translation parity test doesn't ask every locale for them. Each file + * holds only the keys that get a simpler version, under the same key paths as + * common.json; anything it leaves out falls back to the normal string. + */ +export const PLAIN_LANGUAGE_LOADERS: Record Promise> = { + en: () => import('@/locales/en/plain.json').then((m) => m.default as Messages), + de: () => import('@/locales/de/plain.json').then((m) => m.default as Messages), +}; + +export function hasPlainLanguage(locale: string): boolean { + return Object.prototype.hasOwnProperty.call(PLAIN_LANGUAGE_LOADERS, locale); +} + +/** The catalog with the plain strings laid over it, or the catalog unchanged. */ +export function withPlainLanguage(messages: Messages, plain: Messages | undefined): Messages { + return plain ? mergeMessages(messages, plain) : messages; +} diff --git a/lib/__tests__/de-formal-address.test.ts b/lib/__tests__/de-formal-address.test.ts index ce3af2f5a..d4c3d5ff7 100644 --- a/lib/__tests__/de-formal-address.test.ts +++ b/lib/__tests__/de-formal-address.test.ts @@ -24,4 +24,12 @@ describe('German translations', () => { .map(([key]) => key); expect(informal).toEqual([]); }); + + it('use the formal "Sie" in the plain-language strings too', () => { + const plain = JSON.parse(readFileSync('locales/de/plain.json', 'utf8')); + const informal = strings(plain) + .filter(([, text]) => INFORMAL.test(text)) + .map(([key]) => key); + expect(informal).toEqual([]); + }); }); diff --git a/lib/__tests__/plain-language-strings.test.ts b/lib/__tests__/plain-language-strings.test.ts new file mode 100644 index 000000000..12f117c1f --- /dev/null +++ b/lib/__tests__/plain-language-strings.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment node +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { PLAIN_LANGUAGE_LOADERS } from '@/i18n/plain-language'; + +// locales//plain.json holds simpler versions of common.json strings for +// plain-language mode (CO-14). An override only works while its key path +// exists in common.json: a renamed or removed key would leave a dead override +// that nobody sees, so every path is checked here. + +const localesDir = path.resolve(__dirname, '../../locales'); + +type Tree = Record; + +function leaves(node: unknown, prefix = ''): Array<[string, unknown]> { + if (node && typeof node === 'object' && !Array.isArray(node)) { + return Object.entries(node as Tree).flatMap(([key, value]) => leaves(value, prefix ? `${prefix}.${key}` : key)); + } + return [[prefix, node]]; +} + +const load = (locale: string, file: string): Tree => + JSON.parse(fs.readFileSync(path.join(localesDir, locale, file), 'utf-8')); + +// ICU argument names ({name}, {count, plural, ...}); plural selectors are not arguments. +function icuArgs(text: string): string[] { + const names = [...text.matchAll(/\{\s*(\w+)/g)].map((m) => m[1]); + return [...new Set(names)].filter((n) => !['zero', 'one', 'two', 'few', 'many', 'other'].includes(n)).sort(); +} + +const plainLocales = fs.readdirSync(localesDir).filter((l) => fs.existsSync(path.join(localesDir, l, 'plain.json'))); + +describe('plain-language strings', () => { + it('exist for English and German only, each with a loader', () => { + expect(plainLocales.sort()).toEqual(['de', 'en']); + expect(Object.keys(PLAIN_LANGUAGE_LOADERS).sort()).toEqual(['de', 'en']); + }); + + for (const locale of plainLocales) { + describe(locale, () => { + const plain = leaves(load(locale, 'plain.json')); + const common = new Map(leaves(load(locale, 'common.json'))); + + it('has strings', () => { + expect(plain.length).toBeGreaterThan(0); + }); + + it('only overrides keys that exist in common.json', () => { + const dead = plain.map(([key]) => key).filter((key) => !common.has(key)); + expect(dead).toEqual([]); + }); + + it('only holds non-empty strings', () => { + const bad = plain.filter(([, value]) => typeof value !== 'string' || !value.trim()).map(([key]) => key); + expect(bad).toEqual([]); + }); + + it('keeps the placeholders of the string it replaces', () => { + const mismatched = plain + .filter(([key, value]) => JSON.stringify(icuArgs(String(value))) !== JSON.stringify(icuArgs(String(common.get(key))))) + .map(([key]) => key); + expect(mismatched).toEqual([]); + }); + }); + } +}); diff --git a/locales/de/plain.json b/locales/de/plain.json new file mode 100644 index 000000000..f48edc881 --- /dev/null +++ b/locales/de/plain.json @@ -0,0 +1,49 @@ +{ + "login": { + "jmap_endpoint_cors_hint": "Der Mailserver muss Anmeldungen von dieser Website erlauben. Ihre Administration kann das einrichten.", + "reconnecting": "Die Verbindung ist weg. Wir versuchen, wieder zu verbinden…", + "error": { + "invalid_credentials": "Die E-Mail-Adresse oder das Passwort ist falsch. Prüfen Sie beides und versuchen Sie es noch einmal.", + "invalid_token": "Der Server hat dieses Zugriffstoken nicht angenommen. Vielleicht ist es nicht vollständig oder nicht mehr gültig. Kopieren Sie es noch einmal oder erstellen Sie ein neues.", + "connection_failed": "Wir erreichen den Mailserver nicht. Prüfen Sie Ihre Internetverbindung und versuchen Sie es noch einmal.", + "cors_blocked": "Der Mailserver läuft, erlaubt aber keine Anmeldung von dieser Website. Bitten Sie Ihre Administration, diese Website auf dem Server zu erlauben.", + "server_error": "Der Mailserver hat gerade ein Problem. Versuchen Sie es in ein paar Minuten noch einmal.", + "generic": "Etwas ist schiefgelaufen. Versuchen Sie es noch einmal. Wenn das öfter passiert, fragen Sie Ihre Administration.", + "totp_required": "Ihr Konto braucht zur Anmeldung einen zweiten Code. Öffnen Sie Ihre Authenticator-App und geben Sie unten den 6-stelligen Code ein.", + "totp_invalid": "Dieser Code ist falsch oder zu alt. Geben Sie den Code ein, den Ihre Authenticator-App gerade anzeigt.", + "oauth_discovery_failed": "Die Anmeldung über Ihre Organisation funktioniert gerade nicht. Versuchen Sie es später noch einmal oder fragen Sie Ihre Administration.", + "token_exchange_failed": "Passwort und Code sind richtig, aber der Mailserver hat diese App nicht hereingelassen. Bitten Sie Ihre Administration, die Anmelde-Einstellungen für diese App zu prüfen." + }, + "totp_toggle": "Ich habe einen Code aus meiner Authenticator-App", + "token_hint": "Manche Mailanbieter geben Ihnen ein Token statt eines Passworts. Erstellen Sie es in den Einstellungen Ihres Mailanbieters, kopieren Sie es und fügen Sie es hier ein.", + "config_error": { + "title": "Das Webmail ist nicht richtig eingerichtet", + "fetch_failed": "Das Webmail konnte seine Einstellungen nicht laden. Versuchen Sie es in ein paar Minuten noch einmal.", + "server_not_configured": "Es ist noch kein Mailserver eingerichtet. Bitten Sie Ihre Administration, einen einzurichten." + }, + "session_expired": "Sie wurden abgemeldet. Melden Sie sich wieder an, um weiterzumachen.", + "oauth_error": { + "title": "Die Anmeldung hat nicht geklappt", + "invalid_state": "Die Anmeldung wurde unterbrochen. Fangen Sie noch einmal an.", + "missing_params": "Es fehlen Anmeldedaten. Fangen Sie noch einmal an.", + "token_exchange_failed": "Wir konnten die Anmeldung nicht abschließen. Versuchen Sie es noch einmal.", + "access_denied": "Sie dürfen sich hier nicht anmelden. Bitten Sie Ihre Administration um Zugang." + } + }, + "totp_reauth": { + "title": "Neuen Code eingeben", + "description": "Ihr letzter Code gilt nicht mehr. Öffnen Sie Ihre Authenticator-App und geben Sie den neuen Code ein, um weiterzumachen.", + "oauth_hint": "Diese Abfrage kommt immer wieder. Ihre Administration kann das abstellen, indem sie eine andere Anmeldeart (OAuth) einschaltet.", + "code_label": "Code aus Ihrer Authenticator-App" + }, + "errors": { + "page_error_description": "Diese Seite funktioniert gerade nicht. Versuchen Sie es noch einmal oder gehen Sie zurück zum Posteingang.", + "sidebar_error": "Ihre Ordner konnten nicht geladen werden", + "email_list_error": "Ihre E-Mails konnten nicht geladen werden", + "viewer_error_title": "Diese E-Mail kann nicht angezeigt werden", + "viewer_error_description": "Etwas in dieser E-Mail verhindert, dass sie hier angezeigt wird. Vielleicht nutzt sie ein Format, das wir nicht unterstützen.", + "composer_error": "Das Fenster für neue E-Mails konnte nicht geöffnet werden", + "settings_error_title": "Die Einstellungen konnten nicht geöffnet werden", + "settings_error_description": "Ihre Einstellungen konnten nicht geladen werden. Was Sie jetzt ändern, wird vielleicht nicht gespeichert." + } +} diff --git a/locales/en/plain.json b/locales/en/plain.json new file mode 100644 index 000000000..056d8584f --- /dev/null +++ b/locales/en/plain.json @@ -0,0 +1,49 @@ +{ + "login": { + "jmap_endpoint_cors_hint": "The mail server must allow sign-ins from this website. Your administrator can set this up.", + "reconnecting": "The connection is lost. We are trying to connect again…", + "error": { + "invalid_credentials": "The email address or password is wrong. Check both and try again.", + "invalid_token": "The server did not accept this access token. Maybe it is not complete, or it is no longer valid. Copy it again or create a new one.", + "connection_failed": "We can't reach the mail server. Check your internet connection and try again.", + "cors_blocked": "The mail server works, but it does not allow sign-ins from this website. Ask your administrator to allow this website on the server.", + "server_error": "The mail server has a problem right now. Try again in a few minutes.", + "generic": "Something went wrong. Try again. If it keeps happening, ask your administrator for help.", + "totp_required": "Your account needs a second code to sign in. Open your authenticator app and type the 6-digit code below.", + "totp_invalid": "This code is wrong or too old. Type the code your authenticator app shows right now.", + "oauth_discovery_failed": "Sign-in through your organisation does not work right now. Try again later or ask your administrator.", + "token_exchange_failed": "Your password and code are correct, but the mail server did not let this app in. Ask your administrator to check the sign-in settings for this app." + }, + "totp_toggle": "I have a code from my authenticator app", + "token_hint": "Some mail providers give you a token instead of a password. Create one in your mail provider's settings, copy it, and paste it here.", + "config_error": { + "title": "The webmail is not set up correctly", + "fetch_failed": "The webmail could not load its settings. Try again in a few minutes.", + "server_not_configured": "No mail server is set up yet. Ask your administrator to set one up." + }, + "session_expired": "You were signed out. Sign in again to continue.", + "oauth_error": { + "title": "Sign-in did not work", + "invalid_state": "The sign-in was interrupted. Start again.", + "missing_params": "Some sign-in data is missing. Start again.", + "token_exchange_failed": "We could not finish signing you in. Try again.", + "access_denied": "You are not allowed to sign in here. Ask your administrator for access." + } + }, + "totp_reauth": { + "title": "Enter a new code", + "description": "Your last code is no longer valid. Open your authenticator app and type the new code to continue.", + "oauth_hint": "This question comes back from time to time. Your administrator can stop it by turning on a different way to sign in (OAuth).", + "code_label": "Code from your authenticator app" + }, + "errors": { + "page_error_description": "This page stopped working. Try again, or go back to your inbox.", + "sidebar_error": "Your folders could not be loaded", + "email_list_error": "Your messages could not be loaded", + "viewer_error_title": "This message can't be shown", + "viewer_error_description": "Something in this message stops it from being shown here. It may use a format we don't support.", + "composer_error": "The editor for new messages could not be opened", + "settings_error_title": "Settings could not be opened", + "settings_error_description": "Your settings could not be loaded. Changes you make now may not be saved." + } +}