mirror of
https://github.com/vector-im/element-web.git
synced 2026-07-02 21:40:11 +00:00
Refactor languageHandler to avoid import cycles (#33948)
* Refactor languageHandler to avoid import cycles Also move its tests to vitest * Small refactor * Iterate * Iterate
This commit is contained in:
committed by
GitHub
parent
29b9c04d20
commit
f9b97be1d7
@@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<ReturnType<typeof languageHandler.getAllLanguagesWithLabels>>;
|
||||
type Languages = Awaited<ReturnType<typeof getAllLanguagesWithLabels>>;
|
||||
|
||||
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<IProps, IState> {
|
||||
}
|
||||
|
||||
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<IProps, IState> {
|
||||
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<IProps, IState> {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
let displayedLanguages: Awaited<ReturnType<typeof languageHandler.getAllLanguagesWithLabels>>;
|
||||
let displayedLanguages: Awaited<ReturnType<typeof getAllLanguagesWithLabels>>;
|
||||
if (this.state.searchQuery) {
|
||||
displayedLanguages = this.state.langs.filter((lang) => {
|
||||
return languageMatchesSearchQuery(this.state.searchQuery, lang);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<StringVariables, keyof ErrorOptions> | 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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"`);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<string, Record<string, string>> = 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);
|
||||
}
|
||||
}
|
||||
+8
-210
@@ -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 <policyLink /> 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();
|
||||
@@ -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";
|
||||
@@ -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<ICounterpartTranslation> {
|
||||
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<ICounterpartTranslation> {
|
||||
const res = await fetch(langPath, { method: "GET" });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load ${langPath}, got ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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<string[]> {
|
||||
return Object.keys(await getLangsJson());
|
||||
}
|
||||
|
||||
type Language = {
|
||||
value: string;
|
||||
label: string; // translated
|
||||
labelInTargetLanguage: string; // translated
|
||||
};
|
||||
|
||||
export async function getAllLanguagesWithLabels(): Promise<Language[]> {
|
||||
const languageNames = new Intl.DisplayNames([getLocale()], { type: "language", style: "short" });
|
||||
const languages = await getAllLanguagesFromJson();
|
||||
return languages.map<Language>((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];
|
||||
}
|
||||
@@ -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<StringVariables, keyof ErrorOptions> | 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<void> {
|
||||
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<string[]> {
|
||||
return Object.keys(await getLangsJson());
|
||||
}
|
||||
|
||||
export async function getAllLanguagesWithLabels(): Promise<Language[]> {
|
||||
const languageNames = new Intl.DisplayNames([getUserLanguage()], { type: "language", style: "short" });
|
||||
const languages = await getAllLanguagesFromJson();
|
||||
return languages.map<Language>((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<ICounterpartTranslation> {
|
||||
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<ICounterpartTranslation> {
|
||||
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<string, Record<string, string>> = 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<void> {
|
||||
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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", {});
|
||||
}
|
||||
@@ -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]);
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("<TextualBody />", () => {
|
||||
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(),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user