diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index c8265edfd5..d984454c71 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -232,7 +232,13 @@ module.exports = { }, }, { - files: ["src/**/*.test.{ts,tsx}", "test/**/*.{ts,tsx}", "playwright/**/*.ts"], + files: [ + "src/**/*.test.{ts,tsx}", + "src/**/__mocks__/*.{ts,tsx}", + "src/test/*.ts", + "test/**/*.{ts,tsx}", + "playwright/**/*.ts", + ], extends: ["plugin:matrix-org/jest"], rules: { // We don't need super strict typing in test utilities @@ -258,6 +264,7 @@ module.exports = { // These are fine in tests "no-restricted-globals": "off", "react-compiler/react-compiler": "off", + "jest/no-mocks-import": "off", }, }, { diff --git a/apps/web/src/DateUtils.ts b/apps/web/src/DateUtils.ts index e975fe834f..b9788ef1f1 100644 --- a/apps/web/src/DateUtils.ts +++ b/apps/web/src/DateUtils.ts @@ -8,7 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { _t, getUserLanguage } from "./languageHandler"; +import { _t } from "./languageHandler"; +import { getUserLanguage } from "./i18n/settings"; import { getUserTimezone } from "./TimezoneHandler"; export { formatSeconds } from "@element-hq/web-shared-components"; diff --git a/apps/web/src/Terms.ts b/apps/web/src/Terms.ts index 002ad0464f..21db4b738c 100644 --- a/apps/web/src/Terms.ts +++ b/apps/web/src/Terms.ts @@ -18,7 +18,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import Modal from "./Modal"; import TermsDialog from "./components/views/dialogs/TermsDialog"; -import { pickBestLanguage } from "./languageHandler.tsx"; +import { pickBestLanguage } from "./i18n/utils"; export class TermsNotSignedError extends Error {} diff --git a/apps/web/src/components/views/auth/CountryDropdown.tsx b/apps/web/src/components/views/auth/CountryDropdown.tsx index f25ed95e96..869f592e8e 100644 --- a/apps/web/src/components/views/auth/CountryDropdown.tsx +++ b/apps/web/src/components/views/auth/CountryDropdown.tsx @@ -10,7 +10,8 @@ import React, { type ReactElement } from "react"; import { COUNTRIES, getEmojiFlag, type PhoneNumberCountryDefinition } from "../../../phonenumber"; import SdkConfig from "../../../SdkConfig"; -import { _t, getUserLanguage } from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; +import { getUserLanguage } from "../../../i18n/settings"; import Dropdown from "../elements/Dropdown"; import { type NonEmptyArray } from "../../../@types/common"; diff --git a/apps/web/src/components/views/dialogs/ModalWidgetDialog.tsx b/apps/web/src/components/views/dialogs/ModalWidgetDialog.tsx index c0af2b632b..6c438bfd3a 100644 --- a/apps/web/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/apps/web/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -25,7 +25,8 @@ import { import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import BaseDialog from "./BaseDialog"; -import { _t, getUserLanguage } from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; +import { getUserLanguage } from "../../../i18n/settings"; import AccessibleButton, { type AccessibleButtonKind } from "../elements/AccessibleButton"; import { ElementWidgetDriver } from "../../../stores/widgets/ElementWidgetDriver"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; diff --git a/apps/web/src/components/views/elements/LanguageDropdown.tsx b/apps/web/src/components/views/elements/LanguageDropdown.tsx index 7bf50f7806..a61bc8281e 100644 --- a/apps/web/src/components/views/elements/LanguageDropdown.tsx +++ b/apps/web/src/components/views/elements/LanguageDropdown.tsx @@ -10,13 +10,14 @@ Please see LICENSE files in the repository root for full details. import React, { type ReactElement } from "react"; import classNames from "classnames"; -import * as languageHandler from "../../../languageHandler"; import { _t } from "../../../languageHandler"; +import { getAllLanguagesWithLabels } from "../../../i18n/utils"; +import { getUserLanguage } from "../../../i18n/settings"; import Spinner from "./Spinner"; import Dropdown from "./Dropdown"; import { type NonEmptyArray } from "../../../@types/common"; -type Languages = Awaited>; +type Languages = Awaited>; function languageMatchesSearchQuery(query: string, language: Languages[0]): boolean { if (language.labelInTargetLanguage.toUpperCase().includes(query.toUpperCase())) return true; @@ -48,8 +49,7 @@ export default class LanguageDropdown extends React.Component { } public componentDidMount(): void { - languageHandler - .getAllLanguagesWithLabels() + getAllLanguagesWithLabels() .then((langs) => { langs.sort(function (a, b) { if (a.labelInTargetLanguage < b.labelInTargetLanguage) return -1; @@ -73,7 +73,7 @@ export default class LanguageDropdown extends React.Component { if (!this.props.value) { // If no value is given, we start with the first country selected, // but our parent component doesn't know this, therefore we do this. - const language = languageHandler.getUserLanguage(); + const language = getUserLanguage(); this.props.onOptionChange(language); } } @@ -89,7 +89,7 @@ export default class LanguageDropdown extends React.Component { return ; } - let displayedLanguages: Awaited>; + let displayedLanguages: Awaited>; if (this.state.searchQuery) { displayedLanguages = this.state.langs.filter((lang) => { return languageMatchesSearchQuery(this.state.searchQuery, lang); diff --git a/apps/web/src/components/views/elements/SpellCheckLanguagesDropdown.tsx b/apps/web/src/components/views/elements/SpellCheckLanguagesDropdown.tsx index 763fe4b5f8..6a4d98b925 100644 --- a/apps/web/src/components/views/elements/SpellCheckLanguagesDropdown.tsx +++ b/apps/web/src/components/views/elements/SpellCheckLanguagesDropdown.tsx @@ -10,7 +10,8 @@ import React, { type ReactElement } from "react"; import Dropdown from "../../views/elements/Dropdown"; import PlatformPeg from "../../../PlatformPeg"; -import { _t, getUserLanguage } from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; +import { getUserLanguage } from "../../../i18n/settings"; import Spinner from "./Spinner"; import { type NonEmptyArray } from "../../../@types/common"; diff --git a/apps/web/src/components/views/settings/SpellCheckSettings.tsx b/apps/web/src/components/views/settings/SpellCheckSettings.tsx index d1c759418d..bbb3579767 100644 --- a/apps/web/src/components/views/settings/SpellCheckSettings.tsx +++ b/apps/web/src/components/views/settings/SpellCheckSettings.tsx @@ -10,7 +10,8 @@ import React from "react"; import SpellCheckLanguagesDropdown from "../../../components/views/elements/SpellCheckLanguagesDropdown"; import AccessibleButton, { type ButtonEvent } from "../../../components/views/elements/AccessibleButton"; -import { _t, getUserLanguage } from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; +import { getUserLanguage } from "../../../i18n/settings"; interface ExistingSpellCheckLanguageIProps { language: string; diff --git a/apps/web/src/i18n/UserFriendlyError.test.ts b/apps/web/src/i18n/UserFriendlyError.test.ts new file mode 100644 index 0000000000..6e25be09b7 --- /dev/null +++ b/apps/web/src/i18n/UserFriendlyError.test.ts @@ -0,0 +1,70 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +// @vitest-environment happy-dom + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fetchMock from "@fetch-mock/vitest"; + +import SdkConfig from "../../src/SdkConfig"; +import { setLanguage } from "./settings"; +import { UserFriendlyError } from "./UserFriendlyError"; +import { setupTranslationOverridesForTests } from "./__mocks__"; + +describe("UserFriendlyError", () => { + const testErrorMessage = "This email address is already in use (%(email)s)" as TranslationKey; + beforeEach(async () => { + await setLanguage("en"); + // Setup some strings with variable substituations that we can use in the tests. + const deOverride = "Diese E-Mail-Adresse wird bereits verwendet (%(email)s)"; + await setupTranslationOverridesForTests({ + [testErrorMessage]: { + en: testErrorMessage, + de: deOverride, + }, + }); + }); + + afterEach(() => { + SdkConfig.reset(); + fetchMock.removeRoute("i18n-override"); + }); + + it("includes English message and localized translated message", async () => { + await setLanguage("de"); + + const friendlyError = new UserFriendlyError(testErrorMessage, { + email: "test@example.com", + cause: undefined, + }); + + // Ensure message is in English so it's readable in the logs + expect(friendlyError.message).toStrictEqual("This email address is already in use (test@example.com)"); + // Ensure the translated message is localized appropriately + expect(friendlyError.translatedMessage).toStrictEqual( + "Diese E-Mail-Adresse wird bereits verwendet (test@example.com)", + ); + }); + + it("includes underlying cause error", async () => { + await setLanguage("de"); + + const underlyingError = new Error("Fake underlying error"); + const friendlyError = new UserFriendlyError(testErrorMessage, { + email: "test@example.com", + cause: underlyingError, + }); + + expect(friendlyError.cause).toStrictEqual(underlyingError); + }); + + it("ok to omit the substitution variables and cause object, there just won't be any cause", async () => { + const friendlyError = new UserFriendlyError("foo error" as TranslationKey); + expect(friendlyError.cause).toBeUndefined(); + }); +}); diff --git a/apps/web/src/i18n/UserFriendlyError.ts b/apps/web/src/i18n/UserFriendlyError.ts new file mode 100644 index 0000000000..a247bc7aa1 --- /dev/null +++ b/apps/web/src/i18n/UserFriendlyError.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type StringVariables } from "@element-hq/web-shared-components"; +import { _t } from "@element-hq/web-shared-components"; + +export interface ErrorOptions { + // Because we're mixing the substitution variables and `cause` into the same object + // below, we want them to always explicitly say whether there is an underlying error + // or not to avoid typos of "cause" slipping through unnoticed. + cause: unknown | undefined; +} + +/** + * Used to rethrow an error with a user-friendly translatable message while maintaining + * access to that original underlying error. Downstream consumers can display the + * `translatedMessage` property in the UI and inspect the underlying error with the + * `cause` property. + * + * The error message will display as English in the console and logs so Element + * developers can easily understand the error and find the source in the code. It also + * helps tools like Sentry deduplicate the error, or just generally searching in + * rageshakes to find all instances regardless of the users locale. + * + * @param message - The untranslated error message text, e.g "Something went wrong with %(foo)s". + * @param substitutionVariablesAndCause - Variable substitutions for the translation and + * original cause of the error. If there is no cause, just pass `undefined`, e.g { foo: + * 'bar', cause: err || undefined } + */ +export class UserFriendlyError extends Error { + public readonly translatedMessage: string; + + public constructor( + message: TranslationKey, + substitutionVariablesAndCause?: Omit | ErrorOptions, + ) { + // Prevent "Could not find /%\(cause\)s/g in x" logs to the console by removing it from the list + const { cause, ...substitutionVariables } = substitutionVariablesAndCause ?? {}; + const errorOptions = { cause }; + + // Create the error with the English version of the message that we want to show up in the logs + const englishTranslatedMessage = _t(message, { ...substitutionVariables, locale: "en" }); + super(englishTranslatedMessage, errorOptions); + + // Also provide a translated version of the error in the users locale to display + this.translatedMessage = _t(message, substitutionVariables); + } +} diff --git a/apps/web/src/i18n/__mocks__/index.ts b/apps/web/src/i18n/__mocks__/index.ts new file mode 100644 index 0000000000..895aa73c6d --- /dev/null +++ b/apps/web/src/i18n/__mocks__/index.ts @@ -0,0 +1,30 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type TranslationStringsObject } from "@matrix-org/react-sdk-module-api"; +import { vi, beforeAll } from "vitest"; +import fetchMock from "@fetch-mock/vitest"; + +import SdkConfig from "../../SdkConfig"; +import { registerCustomTranslations } from "../custom"; + +beforeAll(() => { + vi.stubEnv("NODE_ENV", "test"); +}); + +export async function setupTranslationOverridesForTests(overrides: TranslationStringsObject) { + const lookupUrl = "/translations.json"; + + SdkConfig.add({ + custom_translations_url: lookupUrl, + }); + fetchMock.get(lookupUrl, overrides, { name: "i18n-override" }); + await registerCustomTranslations({ + testOnlyIgnoreCustomTranslationsCache: true, + }); +} diff --git a/apps/web/src/i18n/browser.test.ts b/apps/web/src/i18n/browser.test.ts new file mode 100644 index 0000000000..24b8f23e31 --- /dev/null +++ b/apps/web/src/i18n/browser.test.ts @@ -0,0 +1,36 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +// @vitest-environment happy-dom + +import { vi, describe, it, expect, beforeEach } from "vitest"; + +import { getLanguagesFromBrowser } from "./browser"; + +describe("getLanguagesFromBrowser", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("should return navigator.languages if available", () => { + vi.spyOn(window.navigator, "languages", "get").mockReturnValue(["en", "de"]); + expect(getLanguagesFromBrowser()).toEqual(["en", "de"]); + }); + + it("should return navigator.language if available", () => { + vi.spyOn(window.navigator, "languages", "get").mockReturnValue([]); + vi.spyOn(window.navigator, "language", "get").mockReturnValue("de"); + expect(getLanguagesFromBrowser()).toEqual(["de"]); + }); + + it("should return 'en' otherwise", () => { + vi.spyOn(window.navigator, "languages", "get").mockReturnValue([]); + vi.spyOn(window.navigator, "language", "get").mockReturnValue(undefined as any); + expect(getLanguagesFromBrowser()).toEqual(["en"]); + }); +}); diff --git a/apps/web/src/i18n/browser.ts b/apps/web/src/i18n/browser.ts new file mode 100644 index 0000000000..5b0a0e111f --- /dev/null +++ b/apps/web/src/i18n/browser.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** + * Query the browser for the user's language preferences + */ +export function getLanguagesFromBrowser(): readonly string[] { + if (navigator.languages && navigator.languages.length) return navigator.languages; + return [navigator.language ?? "en"]; +} + +/** + * Query the browser for the user's primary language preference + */ +export function getLanguageFromBrowser(): string { + return getLanguagesFromBrowser()[0]; +} diff --git a/apps/web/src/i18n/custom.test.ts b/apps/web/src/i18n/custom.test.ts new file mode 100644 index 0000000000..53aa48b2cf --- /dev/null +++ b/apps/web/src/i18n/custom.test.ts @@ -0,0 +1,91 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +// @vitest-environment happy-dom + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fetchMock from "@fetch-mock/vitest"; +import { type Translation } from "matrix-web-i18n"; + +import SdkConfig from "../../src/SdkConfig"; +import { _t } from "."; +import { setLanguage } from "./settings"; +import { setupTranslationOverridesForTests } from "./__mocks__"; + +describe("registerCustomTranslations", () => { + beforeEach(async () => { + await setLanguage("en"); + }); + + afterEach(() => { + SdkConfig.reset(); + fetchMock.removeRoute("i18n-override"); + }); + + it("should support overriding translations", async () => { + const str: TranslationKey = "power_level|default"; + const enOverride: Translation = "Visitor"; + const deOverride: Translation = "Besucher"; + + // First test that overrides aren't being used + await setLanguage("en"); + expect(_t(str)).toMatchInlineSnapshot(`"Default"`); + await setLanguage("de"); + expect(_t(str)).toMatchInlineSnapshot(`"Standard"`); + + await setupTranslationOverridesForTests({ + [str]: { + en: enOverride, + de: deOverride, + }, + }); + + // Now test that they *are* being used + await setLanguage("en"); + expect(_t(str)).toEqual(enOverride); + + await setLanguage("de"); + expect(_t(str)).toEqual(deOverride); + }); + + it("should support overriding plural translations", async () => { + const str: TranslationKey = "voip|n_people_joined"; + const enOverride: Translation = { + other: "%(count)s people in the call", + one: "%(count)s person in the call", + }; + const deOverride: Translation = { + other: "%(count)s Personen im Anruf", + one: "%(count)s Person im Anruf", + }; + + // First test that overrides aren't being used + await setLanguage("en"); + expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 person joined"`); + expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 people joined"`); + await setLanguage("de"); + expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 Person beigetreten"`); + expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 Personen beigetreten"`); + + await setupTranslationOverridesForTests({ + [str]: { + en: enOverride, + de: deOverride, + }, + }); + + // Now test that they *are* being used + await setLanguage("en"); + expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 person in the call"`); + expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 people in the call"`); + + await setLanguage("de"); + expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 Person im Anruf"`); + expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 Personen im Anruf"`); + }); +}); diff --git a/apps/web/src/i18n/custom.ts b/apps/web/src/i18n/custom.ts new file mode 100644 index 0000000000..ac3d7fa328 --- /dev/null +++ b/apps/web/src/i18n/custom.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { logger } from "matrix-js-sdk/src/logger"; +import { MapWithDefault } from "matrix-js-sdk/src/utils"; +import _ from "lodash"; +import { type TranslationStringsObject } from "@matrix-org/react-sdk-module-api"; +import { KEY_SEPARATOR, registerTranslations } from "@element-hq/web-shared-components"; + +import SdkConfig from "../SdkConfig"; +import { ModuleRunner } from "../modules/ModuleRunner"; + +let cachedCustomTranslations: TranslationStringsObject | undefined; +let cachedCustomTranslationsExpire = 0; // zero to trigger expiration right away + +/** + * Any custom modules with translations to load are parsed first, followed by an + * optionally defined translations file in the config. If no customization is made, + * or the file can't be parsed, no action will be taken. + * + * This function should be called *after* registering other translations data to + * ensure it overrides strings properly. + */ +export async function registerCustomTranslations({ + testOnlyIgnoreCustomTranslationsCache = false, +}: { + testOnlyIgnoreCustomTranslationsCache?: boolean; +} = {}): Promise { + const moduleTranslations = ModuleRunner.instance.allTranslations; + doRegisterTranslations(moduleTranslations); + + const lookupUrl = SdkConfig.get().custom_translations_url; + if (!lookupUrl) return; // easy - nothing to do + + try { + let json: TranslationStringsObject | undefined; + if (testOnlyIgnoreCustomTranslationsCache || Date.now() >= cachedCustomTranslationsExpire) { + json = (await (await fetch(lookupUrl)).json()) as TranslationStringsObject; + cachedCustomTranslations = json; + + // Set expiration to the future, but not too far. Just trying to avoid + // repeated, successive, calls to the server rather than anything long-term. + cachedCustomTranslationsExpire = Date.now() + 5 * 60 * 1000; + } else { + json = cachedCustomTranslations; + } + + // If the (potentially cached) json is invalid, don't use it. + if (!json) return; + + // Finally, register it. + doRegisterTranslations(json); + } catch (e) { + // We consume all exceptions because it's considered non-fatal for custom + // translations to break. Most failures will be during initial development + // of the json file and not (hopefully) at runtime. + logger.warn("Ignoring error while registering custom translations: ", e); + + // Like above: trigger a cache of the json to avoid successive calls. + cachedCustomTranslationsExpire = Date.now() + 5 * 60 * 1000; + } +} + +function doRegisterTranslations(customTranslations: TranslationStringsObject): void { + // We convert the operator-friendly version into something counterpart can consume. + // Map: lang → Record: string → translation + const langs: MapWithDefault> = new MapWithDefault(() => ({})); + for (const [translationKey, translations] of Object.entries(customTranslations)) { + for (const [lang, translation] of Object.entries(translations)) { + _.set(langs.getOrCreate(lang), translationKey.split(KEY_SEPARATOR), translation); + } + } + + // Finally, tell counterpart about our translations + for (const [lang, translations] of langs) { + registerTranslations(lang, translations); + } +} diff --git a/apps/web/test/unit-tests/languageHandler-test.tsx b/apps/web/src/i18n/index.test.tsx similarity index 52% rename from apps/web/test/unit-tests/languageHandler-test.tsx rename to apps/web/src/i18n/index.test.tsx index c775c65e0d..25fa33f65b 100644 --- a/apps/web/test/unit-tests/languageHandler-test.tsx +++ b/apps/web/src/i18n/index.test.tsx @@ -6,215 +6,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; -import fetchMock from "@fetch-mock/jest"; -import { type Translation } from "matrix-web-i18n"; -import { type TranslationStringsObject } from "@matrix-org/react-sdk-module-api"; +// @vitest-environment happy-dom -import SdkConfig from "../../src/SdkConfig"; +import React from "react"; +import { describe, it, expect, beforeEach } from "vitest"; import { - _t, - _tDom, - getAllLanguagesWithLabels, - registerCustomTranslations, - setLanguage, setMissingEntryGenerator, substitute, type TranslatedString, - UserFriendlyError, type IVariables, type Tags, - getLanguagesFromBrowser, -} from "../../src/languageHandler"; -import { stubClient } from "../test-utils"; +} from "@element-hq/web-shared-components"; -async function setupTranslationOverridesForTests(overrides: TranslationStringsObject) { - const lookupUrl = "/translations.json"; +import { _t, _tDom } from "."; +import { setLanguage } from "./settings"; +import { stubClient } from "../../test/test-utils"; - SdkConfig.add({ - custom_translations_url: lookupUrl, - }); - fetchMock.get(lookupUrl, overrides, { name: "i18n-override" }); - await registerCustomTranslations({ - testOnlyIgnoreCustomTranslationsCache: true, - }); -} - -describe("languageHandler", () => { - beforeEach(async () => { - await setLanguage("en"); - }); - - afterEach(() => { - SdkConfig.reset(); - fetchMock.removeRoute("i18n-override"); - }); - - it("should support overriding translations", async () => { - const str: TranslationKey = "power_level|default"; - const enOverride: Translation = "Visitor"; - const deOverride: Translation = "Besucher"; - - // First test that overrides aren't being used - await setLanguage("en"); - expect(_t(str)).toMatchInlineSnapshot(`"Default"`); - await setLanguage("de"); - expect(_t(str)).toMatchInlineSnapshot(`"Standard"`); - - await setupTranslationOverridesForTests({ - [str]: { - en: enOverride, - de: deOverride, - }, - }); - - // Now test that they *are* being used - await setLanguage("en"); - expect(_t(str)).toEqual(enOverride); - - await setLanguage("de"); - expect(_t(str)).toEqual(deOverride); - }); - - it("should support overriding plural translations", async () => { - const str: TranslationKey = "voip|n_people_joined"; - const enOverride: Translation = { - other: "%(count)s people in the call", - one: "%(count)s person in the call", - }; - const deOverride: Translation = { - other: "%(count)s Personen im Anruf", - one: "%(count)s Person im Anruf", - }; - - // First test that overrides aren't being used - await setLanguage("en"); - expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 person joined"`); - expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 people joined"`); - await setLanguage("de"); - expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 Person beigetreten"`); - expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 Personen beigetreten"`); - - await setupTranslationOverridesForTests({ - [str]: { - en: enOverride, - de: deOverride, - }, - }); - - // Now test that they *are* being used - await setLanguage("en"); - expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 person in the call"`); - expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 people in the call"`); - - await setLanguage("de"); - expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 Person im Anruf"`); - expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 Personen im Anruf"`); - }); - - describe("UserFriendlyError", () => { - const testErrorMessage = "This email address is already in use (%(email)s)" as TranslationKey; - beforeEach(async () => { - // Setup some strings with variable substituations that we can use in the tests. - const deOverride = "Diese E-Mail-Adresse wird bereits verwendet (%(email)s)"; - await setupTranslationOverridesForTests({ - [testErrorMessage]: { - en: testErrorMessage, - de: deOverride, - }, - }); - }); - - it("includes English message and localized translated message", async () => { - await setLanguage("de"); - - const friendlyError = new UserFriendlyError(testErrorMessage, { - email: "test@example.com", - cause: undefined, - }); - - // Ensure message is in English so it's readable in the logs - expect(friendlyError.message).toStrictEqual("This email address is already in use (test@example.com)"); - // Ensure the translated message is localized appropriately - expect(friendlyError.translatedMessage).toStrictEqual( - "Diese E-Mail-Adresse wird bereits verwendet (test@example.com)", - ); - }); - - it("includes underlying cause error", async () => { - await setLanguage("de"); - - const underlyingError = new Error("Fake underlying error"); - const friendlyError = new UserFriendlyError(testErrorMessage, { - email: "test@example.com", - cause: underlyingError, - }); - - expect(friendlyError.cause).toStrictEqual(underlyingError); - }); - - it("ok to omit the substitution variables and cause object, there just won't be any cause", async () => { - const friendlyError = new UserFriendlyError("foo error" as TranslationKey); - expect(friendlyError.cause).toBeUndefined(); - }); - }); - - describe("getAllLanguagesWithLabels", () => { - it("should handle unknown language sanely", async () => { - fetchMock.modifyRoute("languages", { - response: { - en: "en_EN.json", - de: "de_DE.json", - qq: "qq.json", - }, - }); - await expect(getAllLanguagesWithLabels()).resolves.toMatchInlineSnapshot(` - [ - { - "label": "English", - "labelInTargetLanguage": "English", - "value": "en", - }, - { - "label": "German", - "labelInTargetLanguage": "Deutsch", - "value": "de", - }, - { - "label": "qq", - "labelInTargetLanguage": "qq", - "value": "qq", - }, - ] - `); - }); - }); - - describe("getLanguagesFromBrowser", () => { - beforeEach(() => { - jest.restoreAllMocks(); - }); - - it("should return navigator.languages if available", () => { - jest.spyOn(window.navigator, "languages", "get").mockReturnValue(["en", "de"]); - expect(getLanguagesFromBrowser()).toEqual(["en", "de"]); - }); - - it("should return navigator.language if available", () => { - jest.spyOn(window.navigator, "languages", "get").mockReturnValue([]); - jest.spyOn(window.navigator, "language", "get").mockReturnValue("de"); - expect(getLanguagesFromBrowser()).toEqual(["de"]); - }); - - it("should return 'en' otherwise", () => { - jest.spyOn(window.navigator, "languages", "get").mockReturnValue([]); - jest.spyOn(window.navigator, "language", "get").mockReturnValue(undefined as any); - expect(getLanguagesFromBrowser()).toEqual(["en"]); - }); - }); -}); - -describe("languageHandler JSX", function () { +describe("languageHandler", function () { // See setupLanguage.ts for how we are stubbing out translations to provide fixture data for these tests const basicString = "common|rooms"; const selfClosingTagSub = "Accept to continue:" as TranslationKey; @@ -270,16 +78,6 @@ describe("languageHandler JSX", function () { ], ]; - let oldNodeEnv: string | undefined; - beforeAll(() => { - oldNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "test"; - }); - - afterAll(() => { - process.env.NODE_ENV = oldNodeEnv; - }); - describe("when translations exist in language", () => { beforeEach(async () => { stubClient(); diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts new file mode 100644 index 0000000000..5ca2085642 --- /dev/null +++ b/apps/web/src/i18n/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** + * This file is heavily imported and must be careful not to import anything which imports something else popular, + * e.g. SettingsStore as this would cause import cycles. + */ + +export { + _t, + _td, + _tDom, + type IVariables, + type Tags, + type TranslatedString, + lookupString, + sanitizeForTranslation, + normalizeLanguageKey, + getNormalizedLanguageKeys, + getLocale as getCurrentLanguage, +} from "@element-hq/web-shared-components"; + +export * from "./UserFriendlyError"; diff --git a/apps/web/src/i18n/languages.ts b/apps/web/src/i18n/languages.ts new file mode 100644 index 0000000000..89e0c0c741 --- /dev/null +++ b/apps/web/src/i18n/languages.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { logger } from "matrix-js-sdk/src/logger"; + +import { retry } from "../utils/promise"; + +const i18nFolder = "i18n/"; + +interface ICounterpartTranslation { + [key: string]: + | string + | { + [pluralisation: string]: string; + }; +} + +/** + * Fetch a language file with configurable retry behaviour + * @param langPath the name of the language file within the i18n dir + * @param num the number of times to retry + */ +export async function getLanguageRetry(langPath: string, num = 3): Promise { + return retry( + () => getLanguage(i18nFolder + langPath), + num, + (e) => { + logger.log("Failed to load i18n", langPath); + logger.error(e); + return true; // always retry + }, + ); +} + +async function getLanguage(langPath: string): Promise { + const res = await fetch(langPath, { method: "GET" }); + + if (!res.ok) { + throw new Error(`Failed to load ${langPath}, got ${res.status}`); + } + + return res.json(); +} diff --git a/apps/web/src/i18n/settings.ts b/apps/web/src/i18n/settings.ts new file mode 100644 index 0000000000..dadeceabc2 --- /dev/null +++ b/apps/web/src/i18n/settings.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { logger } from "matrix-js-sdk/src/logger"; +import { normalizeLanguageKey, getLangsJson, registerTranslations, setLocale } from "@element-hq/web-shared-components"; + +import SettingsStore from "../settings/SettingsStore"; +import PlatformPeg from "../PlatformPeg"; +import { SettingLevel } from "../settings/SettingLevel"; +import { getLanguageRetry } from "./languages"; +import { getLanguageFromBrowser } from "./browser"; +import { registerCustomTranslations } from "./custom"; + +export function getUserLanguage(): string { + const language = SettingsStore.getValue("language", null, /*excludeDefault:*/ true); + if (typeof language === "string" && language !== "") { + return language; + } else { + return normalizeLanguageKey(getLanguageFromBrowser()); + } +} + +export async function setLanguage(...preferredLangs: string[]): Promise { + PlatformPeg.get()?.setLanguage(preferredLangs); + + const availableLanguages = await getLangsJson(); + let chosenLanguage = preferredLangs.find((lang) => availableLanguages.hasOwnProperty(lang)); + if (!chosenLanguage) { + // Fallback to en_EN if none is found + chosenLanguage = "en"; + logger.error("Unable to find an appropriate language, preferred: ", preferredLangs); + } + + const languageData = await getLanguageRetry(availableLanguages[chosenLanguage]); + + registerTranslations(chosenLanguage, languageData); + setLocale(chosenLanguage); + + await SettingsStore.setValue("language", null, SettingLevel.DEVICE, chosenLanguage); + // Adds a lot of noise to test runs, so disable logging there. + if (process.env.NODE_ENV !== "test") { + logger.log("set language to " + chosenLanguage); + } + + // Set 'en' as fallback language: + if (chosenLanguage !== "en") { + const fallbackLanguageData = await getLanguageRetry(availableLanguages["en"]); + registerTranslations("en", fallbackLanguageData); + } + + await registerCustomTranslations(); +} diff --git a/apps/web/src/i18n/utils.test.ts b/apps/web/src/i18n/utils.test.ts new file mode 100644 index 0000000000..b4e893aab6 --- /dev/null +++ b/apps/web/src/i18n/utils.test.ts @@ -0,0 +1,45 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +// @vitest-environment happy-dom + +import { describe, it, expect } from "vitest"; +import fetchMock from "@fetch-mock/vitest"; + +import { getAllLanguagesWithLabels } from "./utils"; + +describe("getAllLanguagesWithLabels", () => { + it("should handle unknown language sanely", async () => { + fetchMock.modifyRoute("languages", { + response: { + en: "en_EN.json", + de: "de_DE.json", + qq: "qq.json", + }, + }); + await expect(getAllLanguagesWithLabels()).resolves.toMatchInlineSnapshot(` + [ + { + "label": "English", + "labelInTargetLanguage": "English", + "value": "en", + }, + { + "label": "German", + "labelInTargetLanguage": "Deutsch", + "value": "de", + }, + { + "label": "qq", + "labelInTargetLanguage": "qq", + "value": "qq", + }, + ] + `); + }); +}); diff --git a/apps/web/src/i18n/utils.ts b/apps/web/src/i18n/utils.ts new file mode 100644 index 0000000000..7386a4adbb --- /dev/null +++ b/apps/web/src/i18n/utils.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { getLangsJson, getLocale, normalizeLanguageKey } from "@element-hq/web-shared-components"; + +async function getAllLanguagesFromJson(): Promise { + return Object.keys(await getLangsJson()); +} + +type Language = { + value: string; + label: string; // translated + labelInTargetLanguage: string; // translated +}; + +export async function getAllLanguagesWithLabels(): Promise { + const languageNames = new Intl.DisplayNames([getLocale()], { type: "language", style: "short" }); + const languages = await getAllLanguagesFromJson(); + return languages.map((langKey) => { + return { + value: langKey, + label: languageNames.of(langKey)!, + labelInTargetLanguage: new Intl.DisplayNames([langKey], { type: "language", style: "short" }).of(langKey)!, + }; + }); +} + +/** + * Given a list of language codes, pick the most appropriate one + * given the current language (ie. getCurrentLanguage()) + * English is assumed to be a reasonable default. + * + * @param {string[]} langs List of language codes to pick from + * @returns {string} The most appropriate language code from langs + */ +export function pickBestLanguage(langs: string[]): string { + const currentLang = getLocale(); + const normalisedLangs = langs.map(normalizeLanguageKey); + + { + // Best is an exact match + const currentLangIndex = normalisedLangs.indexOf(currentLang); + if (currentLangIndex > -1) return langs[currentLangIndex]; + } + + { + // Failing that, a different dialect of the same language + const closeLangIndex = normalisedLangs.findIndex((l) => l.slice(0, 2) === currentLang.slice(0, 2)); + if (closeLangIndex > -1) return langs[closeLangIndex]; + } + + { + // Neither of those? Try an english variant. + const enIndex = normalisedLangs.findIndex((l) => l.startsWith("en")); + if (enIndex > -1) return langs[enIndex]; + } + + // if nothing else, use the first + return langs[0]; +} diff --git a/apps/web/src/languageHandler.tsx b/apps/web/src/languageHandler.tsx index 7aca16c84a..9c4cc41bb4 100644 --- a/apps/web/src/languageHandler.tsx +++ b/apps/web/src/languageHandler.tsx @@ -1,304 +1,12 @@ /* - * Copyright 2025 New Vector Ltd. + * Copyright 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial * Please see LICENSE files in the repository root for full details. */ -import { logger } from "matrix-js-sdk/src/logger"; -import { MapWithDefault } from "matrix-js-sdk/src/utils"; -import { type TranslationStringsObject } from "@matrix-org/react-sdk-module-api"; -import _ from "lodash"; -import { - _t, - normalizeLanguageKey, - type StringVariables, - KEY_SEPARATOR, - getLangsJson, - registerTranslations, - setLocale, - getLocale, - setMissingEntryGenerator as setMissingEntryGeneratorSharedComponents, -} from "@element-hq/web-shared-components"; - -import SettingsStore from "./settings/SettingsStore"; -import PlatformPeg from "./PlatformPeg"; -import { SettingLevel } from "./settings/SettingLevel"; -import { retry } from "./utils/promise"; -import SdkConfig from "./SdkConfig"; -import { ModuleRunner } from "./modules/ModuleRunner"; - -export { - _t, - type IVariables, - type Tags, - type TranslatedString, - _td, - _tDom, - lookupString, - sanitizeForTranslation, - normalizeLanguageKey, - getNormalizedLanguageKeys, - substitute, -} from "@element-hq/web-shared-components"; - -const i18nFolder = "i18n/"; - -export interface ErrorOptions { - // Because we're mixing the substitution variables and `cause` into the same object - // below, we want them to always explicitly say whether there is an underlying error - // or not to avoid typos of "cause" slipping through unnoticed. - cause: unknown | undefined; -} - /** - * Used to rethrow an error with a user-friendly translatable message while maintaining - * access to that original underlying error. Downstream consumers can display the - * `translatedMessage` property in the UI and inspect the underlying error with the - * `cause` property. - * - * The error message will display as English in the console and logs so Element - * developers can easily understand the error and find the source in the code. It also - * helps tools like Sentry deduplicate the error, or just generally searching in - * rageshakes to find all instances regardless of the users locale. - * - * @param message - The untranslated error message text, e.g "Something went wrong with %(foo)s". - * @param substitutionVariablesAndCause - Variable substitutions for the translation and - * original cause of the error. If there is no cause, just pass `undefined`, e.g { foo: - * 'bar', cause: err || undefined } + * Re-export of the i18n main export, to lessen the diff for the pull request. */ -export class UserFriendlyError extends Error { - public readonly translatedMessage: string; - public constructor( - message: TranslationKey, - substitutionVariablesAndCause?: Omit | ErrorOptions, - ) { - // Prevent "Could not find /%\(cause\)s/g in x" logs to the console by removing it from the list - const { cause, ...substitutionVariables } = substitutionVariablesAndCause ?? {}; - const errorOptions = { cause }; - - // Create the error with the English version of the message that we want to show up in the logs - const englishTranslatedMessage = _t(message, { ...substitutionVariables, locale: "en" }); - super(englishTranslatedMessage, errorOptions); - - // Also provide a translated version of the error in the users locale to display - this.translatedMessage = _t(message, substitutionVariables); - } -} - -export function getUserLanguage(): string { - const language = SettingsStore.getValue("language", null, /*excludeDefault:*/ true); - if (typeof language === "string" && language !== "") { - return language; - } else { - return normalizeLanguageKey(getLanguageFromBrowser()); - } -} - -/** - * Allow overriding the text displayed when no translation exists - * Currently only used in unit tests to avoid having to load - * the translations in element-web - * @knipignore - */ -export function setMissingEntryGenerator(f: (value: string) => void): void { - setMissingEntryGeneratorSharedComponents(f); -} - -export async function setLanguage(...preferredLangs: string[]): Promise { - PlatformPeg.get()?.setLanguage(preferredLangs); - - const availableLanguages = await getLangsJson(); - let chosenLanguage = preferredLangs.find((lang) => availableLanguages.hasOwnProperty(lang)); - if (!chosenLanguage) { - // Fallback to en_EN if none is found - chosenLanguage = "en"; - logger.error("Unable to find an appropriate language, preferred: ", preferredLangs); - } - - const languageData = await getLanguageRetry(i18nFolder + availableLanguages[chosenLanguage]); - - registerTranslations(chosenLanguage, languageData); - setLocale(chosenLanguage); - - await SettingsStore.setValue("language", null, SettingLevel.DEVICE, chosenLanguage); - // Adds a lot of noise to test runs, so disable logging there. - if (process.env.NODE_ENV !== "test") { - logger.log("set language to " + chosenLanguage); - } - - // Set 'en' as fallback language: - if (chosenLanguage !== "en") { - const fallbackLanguageData = await getLanguageRetry(i18nFolder + availableLanguages["en"]); - registerTranslations("en", fallbackLanguageData); - } - - await registerCustomTranslations(); -} - -type Language = { - value: string; - label: string; // translated - labelInTargetLanguage: string; // translated -}; - -export async function getAllLanguagesFromJson(): Promise { - return Object.keys(await getLangsJson()); -} - -export async function getAllLanguagesWithLabels(): Promise { - const languageNames = new Intl.DisplayNames([getUserLanguage()], { type: "language", style: "short" }); - const languages = await getAllLanguagesFromJson(); - return languages.map((langKey) => { - return { - value: langKey, - label: languageNames.of(langKey)!, - labelInTargetLanguage: new Intl.DisplayNames([langKey], { type: "language", style: "short" }).of(langKey)!, - }; - }); -} - -export function getLanguagesFromBrowser(): readonly string[] { - if (navigator.languages && navigator.languages.length) return navigator.languages; - return [navigator.language ?? "en"]; -} - -export function getLanguageFromBrowser(): string { - return getLanguagesFromBrowser()[0]; -} - -export function getCurrentLanguage(): string { - return getLocale(); -} - -/** - * Given a list of language codes, pick the most appropriate one - * given the current language (ie. getCurrentLanguage()) - * English is assumed to be a reasonable default. - * - * @param {string[]} langs List of language codes to pick from - * @returns {string} The most appropriate language code from langs - */ -export function pickBestLanguage(langs: string[]): string { - const currentLang = getCurrentLanguage(); - const normalisedLangs = langs.map(normalizeLanguageKey); - - { - // Best is an exact match - const currentLangIndex = normalisedLangs.indexOf(currentLang); - if (currentLangIndex > -1) return langs[currentLangIndex]; - } - - { - // Failing that, a different dialect of the same language - const closeLangIndex = normalisedLangs.findIndex((l) => l.slice(0, 2) === currentLang.slice(0, 2)); - if (closeLangIndex > -1) return langs[closeLangIndex]; - } - - { - // Neither of those? Try an english variant. - const enIndex = normalisedLangs.findIndex((l) => l.startsWith("en")); - if (enIndex > -1) return langs[enIndex]; - } - - // if nothing else, use the first - return langs[0]; -} - -interface ICounterpartTranslation { - [key: string]: - | string - | { - [pluralisation: string]: string; - }; -} - -async function getLanguageRetry(langPath: string, num = 3): Promise { - return retry( - () => getLanguage(langPath), - num, - (e) => { - logger.log("Failed to load i18n", langPath); - logger.error(e); - return true; // always retry - }, - ); -} - -async function getLanguage(langPath: string): Promise { - const res = await fetch(langPath, { method: "GET" }); - - if (!res.ok) { - throw new Error(`Failed to load ${langPath}, got ${res.status}`); - } - - return res.json(); -} - -let cachedCustomTranslations: TranslationStringsObject | undefined; -let cachedCustomTranslationsExpire = 0; // zero to trigger expiration right away - -function doRegisterTranslations(customTranslations: TranslationStringsObject): void { - // We convert the operator-friendly version into something counterpart can consume. - // Map: lang → Record: string → translation - const langs: MapWithDefault> = new MapWithDefault(() => ({})); - for (const [translationKey, translations] of Object.entries(customTranslations)) { - for (const [lang, translation] of Object.entries(translations)) { - _.set(langs.getOrCreate(lang), translationKey.split(KEY_SEPARATOR), translation); - } - } - - // Finally, tell counterpart about our translations - for (const [lang, translations] of langs) { - registerTranslations(lang, translations); - } -} - -/** - * Any custom modules with translations to load are parsed first, followed by an - * optionally defined translations file in the config. If no customization is made, - * or the file can't be parsed, no action will be taken. - * - * This function should be called *after* registering other translations data to - * ensure it overrides strings properly. - */ -export async function registerCustomTranslations({ - testOnlyIgnoreCustomTranslationsCache = false, -}: { - testOnlyIgnoreCustomTranslationsCache?: boolean; -} = {}): Promise { - const moduleTranslations = ModuleRunner.instance.allTranslations; - doRegisterTranslations(moduleTranslations); - - const lookupUrl = SdkConfig.get().custom_translations_url; - if (!lookupUrl) return; // easy - nothing to do - - try { - let json: TranslationStringsObject | undefined; - if (testOnlyIgnoreCustomTranslationsCache || Date.now() >= cachedCustomTranslationsExpire) { - json = (await (await fetch(lookupUrl)).json()) as TranslationStringsObject; - cachedCustomTranslations = json; - - // Set expiration to the future, but not too far. Just trying to avoid - // repeated, successive, calls to the server rather than anything long-term. - cachedCustomTranslationsExpire = Date.now() + 5 * 60 * 1000; - } else { - json = cachedCustomTranslations; - } - - // If the (potentially cached) json is invalid, don't use it. - if (!json) return; - - // Finally, register it. - doRegisterTranslations(json); - } catch (e) { - // We consume all exceptions because it's considered non-fatal for custom - // translations to break. Most failures will be during initial development - // of the json file and not (hopefully) at runtime. - logger.warn("Ignoring error while registering custom translations: ", e); - - // Like above: trigger a cache of the json to avoid successive calls. - cachedCustomTranslationsExpire = Date.now() + 5 * 60 * 1000; - } -} +export * from "./i18n"; diff --git a/apps/web/src/stores/widgets/WidgetMessaging.ts b/apps/web/src/stores/widgets/WidgetMessaging.ts index 6731f1d1d9..62a99913c9 100644 --- a/apps/web/src/stores/widgets/WidgetMessaging.ts +++ b/apps/web/src/stores/widgets/WidgetMessaging.ts @@ -37,7 +37,8 @@ import { } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; -import { _t, getUserLanguage } from "../../languageHandler"; +import { _t } from "../../languageHandler"; +import { getUserLanguage } from "../../i18n/settings"; import { ElementWidgetDriver } from "./ElementWidgetDriver"; import { WidgetMessagingStore } from "./WidgetMessagingStore"; import { MatrixClientPeg } from "../../MatrixClientPeg"; diff --git a/apps/web/src/test/setupGlobals.ts b/apps/web/src/test/setupGlobals.ts new file mode 100644 index 0000000000..bdcb327151 --- /dev/null +++ b/apps/web/src/test/setupGlobals.ts @@ -0,0 +1,20 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { vi } from "vitest"; + +import { mocks } from "../../test/setup/mocks.ts"; + +// set up AudioContext API mock +vi.stubGlobal("AudioContext", function () { + return mocks.AudioContext; +}); + +if (globalThis.window === undefined) { + // We are in a node environment, stub a basic window so singletons work + vi.stubGlobal("window", {}); +} diff --git a/apps/web/src/test/setupLanguage.ts b/apps/web/src/test/setupLanguage.ts new file mode 100644 index 0000000000..48063c8ae9 --- /dev/null +++ b/apps/web/src/test/setupLanguage.ts @@ -0,0 +1,66 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import fetchMock from "@fetch-mock/vitest"; +import { merge } from "lodash"; +import { setMissingEntryGenerator, setLanguage } from "@element-hq/web-shared-components"; + +import enElementWeb from "../i18n/strings/en_EN.json"; +import deElementWeb from "../i18n/strings/de_DE.json"; +// Cheat and import relatively here as these aren't exported by the module (should they be?) +// eslint-disable-next-line no-restricted-imports +import enSharedComponents from "../../../../packages/shared-components/src/i18n/strings/en_EN.json"; +// eslint-disable-next-line no-restricted-imports +import deSharedComponents from "../../../../packages/shared-components/src/i18n/strings/de_DE.json"; + +const lv = { + Save: "Saglabāt", + room: { + upload: { + uploading_multiple_file: { + one: "Качване на %(filename)s и %(count)s друг", + }, + }, + }, +}; + +// Fake languages.json containing references to en_EN, de_DE and lv +// en_EN.json +// de_DE.json +// lv.json - mock version with few translations, used to test fallback translation + +export function setupLanguageMock() { + // Pull the translations from shared components too as they have + // the strings for things like `humanizeTime` which do appear in + // snapshots (needs 'merge' which does a deep-merge rather than just + // replacing top-level keys). + const enTranslations = merge(enElementWeb, enSharedComponents); + const deTranslations = merge(deElementWeb, deSharedComponents); + + fetchMock + .get( + "end:/i18n/languages.json", + { + en: "en_EN.json", + de: "de_DE.json", + lv: "lv.json", + }, + { name: "languages" }, + ) + .get("end:en_EN.json", enTranslations) + .get("end:de_DE.json", deTranslations) + .get("end:lv.json", lv); +} + +// Initialise the fetchMock before the test starts so the languageHandler.setLanguage call below can function +fetchMock.mockGlobal(); +fetchMock.catch(404); +setupLanguageMock(); + +setLanguage("en"); +setMissingEntryGenerator((key) => key.split("|", 2)[1]); diff --git a/apps/web/src/test/setupTests.ts b/apps/web/src/test/setupTests.ts index 84e2000a1e..9f2125dba6 100644 --- a/apps/web/src/test/setupTests.ts +++ b/apps/web/src/test/setupTests.ts @@ -5,11 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { vi, beforeEach } from "vitest"; +import { beforeEach, afterEach } from "vitest"; import fetchMock, { manageFetchMockGlobally } from "@fetch-mock/vitest"; -import { mocks } from "../../test/setup/mocks.ts"; import SdkConfig, { DEFAULTS } from "../SdkConfig"; +import "./setupGlobals.ts"; +import { setupLanguageMock } from "./setupLanguage.ts"; manageFetchMockGlobally(); @@ -18,17 +19,11 @@ beforeEach(() => { fetchMock.hardReset(); fetchMock.catch(404); fetchMock.mockGlobal(); + + setupLanguageMock(); }); -// set up AudioContext API mock -vi.stubGlobal("AudioContext", function () { - return mocks.AudioContext; -}); - -if (globalThis.window === undefined) { - // We are in a node environment, stub a basic window so singletons work - vi.stubGlobal("window", {}); -} +afterEach(() => fetchMock.callHistory.flush()); // uninitialised SdkConfig causes lots of warnings in console, init with defaults SdkConfig.put(DEFAULTS); diff --git a/apps/web/src/utils/FormattingUtils.ts b/apps/web/src/utils/FormattingUtils.ts index b952c96170..217ee4ac39 100644 --- a/apps/web/src/utils/FormattingUtils.ts +++ b/apps/web/src/utils/FormattingUtils.ts @@ -10,7 +10,8 @@ Please see LICENSE files in the repository root for full details. import { type ReactElement, type ReactNode } from "react"; import { useIdColorHash } from "@vector-im/compound-web"; -import { _t, getCurrentLanguage, getUserLanguage } from "../languageHandler"; +import { _t, getCurrentLanguage } from "../languageHandler"; +import { getUserLanguage } from "../i18n/settings"; import { jsxJoin } from "./ReactUtils"; export { formatBytes } from "@element-hq/web-shared-components"; diff --git a/apps/web/src/vector/init.tsx b/apps/web/src/vector/init.tsx index 2557e74ba3..90ebb2dde6 100644 --- a/apps/web/src/vector/init.tsx +++ b/apps/web/src/vector/init.tsx @@ -12,8 +12,11 @@ import { createRoot } from "react-dom/client"; import React, { StrictMode } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { ModuleLoader } from "@element-hq/element-web-module-api"; +import { getNormalizedLanguageKeys } from "@element-hq/web-shared-components"; -import * as languageHandler from "../languageHandler"; +import { getLanguagesFromBrowser } from "../i18n/browser"; +import { setLanguage } from "../i18n/settings"; +import { getCurrentLanguage } from "../i18n"; import SettingsStore from "../settings/SettingsStore"; import PlatformPeg from "../PlatformPeg"; import SdkConfig from "../SdkConfig"; @@ -68,15 +71,13 @@ export async function loadLanguage(): Promise { let langs: string[] = []; if (!prefLang) { - languageHandler.getLanguagesFromBrowser().forEach((l) => { - langs.push(...languageHandler.getNormalizedLanguageKeys(l)); - }); + langs = getLanguagesFromBrowser().flatMap(getNormalizedLanguageKeys); } else { langs = [prefLang]; } try { - await languageHandler.setLanguage(...langs); - document.documentElement.setAttribute("lang", languageHandler.getCurrentLanguage()); + await setLanguage(...langs); + document.documentElement.setAttribute("lang", getCurrentLanguage()); } catch (e) { logger.error("Unable to set language", e); } diff --git a/apps/web/src/viewmodels/room/timeline/DateSeparatorViewModel.tsx b/apps/web/src/viewmodels/room/timeline/DateSeparatorViewModel.tsx index 131c365530..c29c2b222c 100644 --- a/apps/web/src/viewmodels/room/timeline/DateSeparatorViewModel.tsx +++ b/apps/web/src/viewmodels/room/timeline/DateSeparatorViewModel.tsx @@ -20,7 +20,8 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import dispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { _t, getUserLanguage } from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; +import { getUserLanguage } from "../../../i18n/settings"; import Modal from "../../../Modal"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; diff --git a/apps/web/test/setup/setupLanguage.ts b/apps/web/test/setup/setupLanguage.ts index 6e98061427..40c98ae4ff 100644 --- a/apps/web/test/setup/setupLanguage.ts +++ b/apps/web/test/setup/setupLanguage.ts @@ -9,8 +9,9 @@ Please see LICENSE files in the repository root for full details. import fetchMock from "@fetch-mock/jest"; import { ModuleLoader } from "@element-hq/element-web-module-api"; import { merge } from "lodash"; +import { setMissingEntryGenerator } from "@element-hq/web-shared-components"; -import * as languageHandler from "../../src/languageHandler"; +import { setLanguage } from "../../src/i18n/settings"; import enElementWeb from "../../src/i18n/strings/en_EN.json"; import deElementWeb from "../../src/i18n/strings/de_DE.json"; // Cheat and import relatively here as these aren't exported by the module (should they be?) @@ -64,8 +65,8 @@ afterEach(() => fetchMock.callHistory.flush()); // Initialise the fetchMock before the test starts so the languageHandler.setLanguage call below can function setupLanguageMock(); -languageHandler.setLanguage("en"); -languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]); +setLanguage("en"); +setMissingEntryGenerator((key) => key.split("|", 2)[1]); // Set up the module API (so the i18n API exists) const moduleLoader = new ModuleLoader(ModuleApi.instance); diff --git a/apps/web/test/unit-tests/components/views/elements/EventListSummary-test.tsx b/apps/web/test/unit-tests/components/views/elements/EventListSummary-test.tsx index dec1e89484..937025c243 100644 --- a/apps/web/test/unit-tests/components/views/elements/EventListSummary-test.tsx +++ b/apps/web/test/unit-tests/components/views/elements/EventListSummary-test.tsx @@ -21,7 +21,7 @@ import { import EventListSummary from "../../../../../src/components/views/elements/EventListSummary"; import { Layout } from "../../../../../src/settings/enums/Layout"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import * as languageHandler from "../../../../../src/languageHandler"; +import * as languageSettings from "../../../../../src/i18n/settings"; describe("EventListSummary", function () { const roomId = "!room:server.org"; @@ -130,7 +130,7 @@ describe("EventListSummary", function () { beforeEach(function () { jest.clearAllMocks(); - jest.spyOn(languageHandler, "getUserLanguage").mockReturnValue("en-GB"); + jest.spyOn(languageSettings, "getUserLanguage").mockReturnValue("en-GB"); }); afterAll(() => { diff --git a/apps/web/test/unit-tests/components/views/messages/MPollBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/MPollBody-test.tsx index 360e5be4ae..f6b93ad044 100644 --- a/apps/web/test/unit-tests/components/views/messages/MPollBody-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MPollBody-test.tsx @@ -36,7 +36,7 @@ import { import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { type RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; -import * as languageHandler from "../../../../../src/languageHandler"; +import * as languageSettings from "../../../../../src/i18n/settings"; const CHECKED = "mx_PollOption_checked"; const userId = "@me:example.com"; @@ -55,7 +55,7 @@ describe("MPollBody", () => { mockClient.getRoom.mockReturnValue(null); mockClient.relations.mockResolvedValue({ events: [] }); - jest.spyOn(languageHandler, "getUserLanguage").mockReturnValue("en-GB"); + jest.spyOn(languageSettings, "getUserLanguage").mockReturnValue("en-GB"); }); it("finds no votes if there are none", () => { diff --git a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx index 332f6d1ac1..704cab45fe 100644 --- a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -11,6 +11,7 @@ import { type MatrixClient, type MatrixEvent, PushRuleKind, type Room } from "ma import { mocked, type MockedObject } from "jest-mock-vitest-adapter"; import { act, render, waitFor } from "jest-matrix-react"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; +import { setMissingEntryGenerator } from "@element-hq/web-shared-components"; import { getMockClientWithEventEmitter, @@ -19,7 +20,6 @@ import { mkStubRoom, mockClientPushProcessor, } from "../../../../test-utils"; -import * as languageHandler from "../../../../../src/languageHandler"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import { TextualBodyFactory as TextualBody } from "../../../../../src/components/views/messages/TextualBodyFactory"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; @@ -470,7 +470,7 @@ describe("", () => { let matrixClient: MatrixClient; beforeEach(() => { - languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]); + setMissingEntryGenerator((key) => key.split("|", 2)[1]); matrixClient = getMockClientWithEventEmitter({ getRoom: jest.fn(), getUserId: jest.fn(), diff --git a/apps/web/test/unit-tests/components/views/rooms/ReadReceiptGroup-test.tsx b/apps/web/test/unit-tests/components/views/rooms/ReadReceiptGroup-test.tsx index 0af49f767d..7842e960da 100644 --- a/apps/web/test/unit-tests/components/views/rooms/ReadReceiptGroup-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/ReadReceiptGroup-test.tsx @@ -17,7 +17,7 @@ import { ReadReceiptPerson, readReceiptTooltip, } from "../../../../../src/components/views/rooms/ReadReceiptGroup"; -import * as languageHandler from "../../../../../src/languageHandler"; +import * as languageSettings from "../../../../../src/i18n/settings"; import { stubClient } from "../../../../test-utils"; import dispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; @@ -46,7 +46,7 @@ describe("ReadReceiptGroup", () => { expect(readReceiptTooltip([], 1)).toBe(""); }); it("returns a pretty list without hasMore", () => { - jest.spyOn(languageHandler, "getUserLanguage").mockReturnValue("en-GB"); + jest.spyOn(languageSettings, "getUserLanguage").mockReturnValue("en-GB"); expect(readReceiptTooltip(["Alice", "Bob", "Charlie", "Dan", "Eve"], 5)).toEqual( "Alice, Bob, Charlie, Dan and Eve", ); diff --git a/apps/web/test/unit-tests/components/views/rooms/RoomKnocksBar-test.tsx b/apps/web/test/unit-tests/components/views/rooms/RoomKnocksBar-test.tsx index 4f1ad97bca..fa818850dd 100644 --- a/apps/web/test/unit-tests/components/views/rooms/RoomKnocksBar-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/RoomKnocksBar-test.tsx @@ -32,7 +32,7 @@ import { getMockClientWithEventEmitter, mockClientMethodsUser, } from "../../../../test-utils"; -import * as languageHandler from "../../../../../src/languageHandler"; +import * as languageSettings from "../../../../../src/i18n/settings"; describe("RoomKnocksBar", () => { const userId = "@alice:example.org"; @@ -132,7 +132,7 @@ describe("RoomKnocksBar", () => { jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(true); jest.spyOn(Modal, "createDialog"); jest.spyOn(dis, "dispatch"); - jest.spyOn(languageHandler, "getUserLanguage").mockReturnValue("en-GB"); + jest.spyOn(languageSettings, "getUserLanguage").mockReturnValue("en-GB"); }); it("does not render if user can neither approve nor deny", () => { diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx index 5412f96892..837ddbb0bc 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx @@ -18,7 +18,7 @@ import { import { FormattingButtons } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons"; import * as LinkModal from "../../../../../../../src/components/views/rooms/wysiwyg_composer/components/LinkModal"; -import { setLanguage } from "../../../../../../../src/languageHandler"; +import { setLanguage } from "../../../../../../../src/i18n/settings"; const mockWysiwyg = { bold: jest.fn(), diff --git a/apps/web/test/unit-tests/modules/ProxiedModuleApi-test.tsx b/apps/web/test/unit-tests/modules/ProxiedModuleApi-test.tsx index 95cd9ccef7..bd6e8bddff 100644 --- a/apps/web/test/unit-tests/modules/ProxiedModuleApi-test.tsx +++ b/apps/web/test/unit-tests/modules/ProxiedModuleApi-test.tsx @@ -17,7 +17,7 @@ import { type Mocked } from "jest-mock-vitest-adapter"; import { ProxiedModuleApi } from "../../../src/modules/ProxiedModuleApi"; import { getMockClientWithEventEmitter, mkRoom, stubClient } from "../../test-utils"; -import { setLanguage } from "../../../src/languageHandler"; +import { setLanguage } from "../../../src/i18n/settings"; import { ModuleRunner } from "../../../src/modules/ModuleRunner"; import { registerMockModule } from "./MockModule"; import defaultDispatcher from "../../../src/dispatcher/dispatcher"; diff --git a/apps/web/test/unit-tests/utils/DateUtils-test.ts b/apps/web/test/unit-tests/utils/DateUtils-test.ts index e61f79fde7..f677b4e044 100644 --- a/apps/web/test/unit-tests/utils/DateUtils-test.ts +++ b/apps/web/test/unit-tests/utils/DateUtils-test.ts @@ -27,7 +27,7 @@ import { DAY_MS, } from "../../../src/DateUtils"; import { REPEATABLE_DATE, mockIntlDateTimeFormat, unmockIntlDateTimeFormat } from "../../test-utils"; -import * as languageHandler from "../../../src/languageHandler"; +import * as languageSettings from "../../../src/i18n/settings"; describe("getDaysArray", () => { it("should return Sunday-Saturday in long mode", () => { @@ -369,7 +369,7 @@ describe("formatLocalDateShort()", () => { }); const timestamp = new Date("Fri Dec 17 2021 09:09:00 GMT+0100 (Central European Standard Time)").getTime(); it("formats date correctly by locale", () => { - const locale = jest.spyOn(languageHandler, "getUserLanguage"); + const locale = jest.spyOn(languageSettings, "getUserLanguage"); mockIntlDateTimeFormat(); // format is DD/MM/YY