mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: use plainer English and German wording for sign-in and error messages when plain language is on
This commit is contained in:
@@ -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/<en|de>/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 (
|
||||
<>
|
||||
<p data-testid="plain">{t('error.invalid_credentials')}</p>
|
||||
<p data-testid="normal">{t('sign_in')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown> }).login.sign_in).toBeUndefined();
|
||||
expect((dePlain as { login: Record<string, unknown> }).login.sign_in).toBeUndefined();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useLocaleStore.setState({ locale: '' });
|
||||
useSettingsStore.setState({ plainLanguage: false });
|
||||
});
|
||||
|
||||
it('keeps the normal strings while the setting is off', async () => {
|
||||
render(<IntlProvider locale="en" messages={enCommon}><Strings /></IntlProvider>);
|
||||
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(<IntlProvider locale="en" messages={enCommon}><Strings /></IntlProvider>);
|
||||
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(<IntlProvider locale="de" messages={deCommon}><Strings /></IntlProvider>);
|
||||
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(<IntlProvider locale="fr" messages={frCommon}><Strings /></IntlProvider>);
|
||||
await settle();
|
||||
expect(text('plain')).toBe(frCommon.login.error.invalid_credentials);
|
||||
});
|
||||
|
||||
it('switches live when the setting changes', async () => {
|
||||
render(<IntlProvider locale="en" messages={enCommon}><Strings /></IntlProvider>);
|
||||
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(<IntlProvider locale="en" messages={enCommon}><Strings /></IntlProvider>);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -64,6 +65,7 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
|
||||
const [browserTimeZone, setBrowserTimeZone] = useState<string>('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<Record<string, Messages>>({});
|
||||
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 <html> 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;
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { mergeMessages } from './merge-messages';
|
||||
|
||||
type Messages = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 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/<code>/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<string, () => Promise<Messages>> = {
|
||||
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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/<code>/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<string, unknown>;
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user