diff --git a/__mocks__/languages.json b/__mocks__/languages.json index 36ec89561b..35a400808b 100644 --- a/__mocks__/languages.json +++ b/__mocks__/languages.json @@ -1,10 +1,4 @@ { - "en": { - "fileName": "en_EN.json", - "label": "English" - }, - "en-us": { - "fileName": "en_US.json", - "label": "English (US)" - } + "en": "en_EN.json", + "en-us": "en_US.json" } diff --git a/cypress/e2e/settings/general-user-settings-tab.spec.ts b/cypress/e2e/settings/general-user-settings-tab.spec.ts index 2879d6d930..7c78ee7196 100644 --- a/cypress/e2e/settings/general-user-settings-tab.spec.ts +++ b/cypress/e2e/settings/general-user-settings-tab.spec.ts @@ -133,10 +133,12 @@ describe("General user settings tab", () => { cy.findByRole("button", { name: "Language Dropdown" }).click(); // Assert that the default option is rendered and highlighted - cy.findByRole("option", { name: /Bahasa Indonesia/ }) + cy.findByRole("option", { name: /Albanian/ }) .should("be.visible") .should("have.class", "mx_Dropdown_option_highlight"); + cy.findByRole("option", { name: /Deutsch/ }).should("be.visible"); + // Click again to close the dropdown cy.findByRole("button", { name: "Language Dropdown" }).click(); diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 2b45962f31..fcebafcfcc 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -190,6 +190,7 @@ @import "./views/elements/_InteractiveTooltip.pcss"; @import "./views/elements/_InviteReason.pcss"; @import "./views/elements/_LabelledCheckbox.pcss"; +@import "./views/elements/_LanguageDropdown.pcss"; @import "./views/elements/_MiniAvatarUploader.pcss"; @import "./views/elements/_Pill.pcss"; @import "./views/elements/_PowerSelector.pcss"; diff --git a/res/css/views/elements/_LanguageDropdown.pcss b/res/css/views/elements/_LanguageDropdown.pcss new file mode 100644 index 0000000000..60f406af73 --- /dev/null +++ b/res/css/views/elements/_LanguageDropdown.pcss @@ -0,0 +1,21 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_LanguageDropdown { + .mx_Dropdown_option > div { + text-transform: capitalize; + } +} diff --git a/src/components/views/auth/CountryDropdown.tsx b/src/components/views/auth/CountryDropdown.tsx index a1b428de68..1d482f8a6a 100644 --- a/src/components/views/auth/CountryDropdown.tsx +++ b/src/components/views/auth/CountryDropdown.tsx @@ -70,7 +70,7 @@ export default class CountryDropdown extends React.Component { const locale = new Intl.Locale(navigator.language ?? navigator.languages[0]); const code = locale.region ?? locale.language ?? locale.baseName; const displayNames = new Intl.DisplayNames(["en"], { type: "region" }); - const displayName = displayNames.of(code)?.toUpperCase(); + const displayName = displayNames.of(code)!.toUpperCase(); defaultCountry = COUNTRIES.find( (c) => c.iso2 === code.toUpperCase() || c.name.toUpperCase() === displayName, ); diff --git a/src/components/views/elements/LanguageDropdown.tsx b/src/components/views/elements/LanguageDropdown.tsx index 5de1ffe785..ff27b11283 100644 --- a/src/components/views/elements/LanguageDropdown.tsx +++ b/src/components/views/elements/LanguageDropdown.tsx @@ -16,6 +16,7 @@ limitations under the License. */ import React, { ReactElement } from "react"; +import classNames from "classnames"; import * as languageHandler from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; @@ -24,9 +25,10 @@ import Spinner from "./Spinner"; import Dropdown from "./Dropdown"; import { 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; if (language.label.toUpperCase().includes(query.toUpperCase())) return true; if (language.value.toUpperCase() === query.toUpperCase()) return true; return false; @@ -56,23 +58,30 @@ export default class LanguageDropdown extends React.Component { public componentDidMount(): void { languageHandler - .getAllLanguagesFromJson() + .getAllLanguagesWithLabels() .then((langs) => { langs.sort(function (a, b) { - if (a.label < b.label) return -1; - if (a.label > b.label) return 1; + if (a.labelInTargetLanguage < b.labelInTargetLanguage) return -1; + if (a.labelInTargetLanguage > b.labelInTargetLanguage) return 1; return 0; }); this.setState({ langs }); }) .catch(() => { - this.setState({ langs: [{ value: "en", label: "English" }] }); + this.setState({ + langs: [ + { + value: "en", + label: "English", + labelInTargetLanguage: "English", + }, + ], + }); }); 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. + // 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(); this.props.onOptionChange(language); } @@ -89,7 +98,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); @@ -99,7 +108,7 @@ export default class LanguageDropdown extends React.Component { } const options = displayedLanguages.map((language) => { - return
{language.label}
; + return
{language.labelInTargetLanguage}
; }) as NonEmptyArray; // default value here too, otherwise we need to handle null / undefined @@ -116,7 +125,7 @@ export default class LanguageDropdown extends React.Component { return ( >; +type Languages = { + value: string; + label: string; // translated +}[]; function languageMatchesSearchQuery(query: string, language: Languages[0]): boolean { if (language.label.toUpperCase().includes(query.toUpperCase())) return true; if (language.value.toUpperCase() === query.toUpperCase()) return true; @@ -58,6 +60,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component< public componentDidMount(): void { const plaf = PlatformPeg.get(); if (plaf) { + const languageNames = new Intl.DisplayNames([getUserLanguage()], { type: "language", style: "short" }); plaf.getAvailableSpellCheckLanguages() ?.then((languages) => { languages.sort(function (a, b) { @@ -68,7 +71,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component< const langs: Languages = []; languages.forEach((language) => { langs.push({ - label: language, + label: languageNames.of(language)!, value: language, }); }); @@ -79,7 +82,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component< languages: [ { value: "en", - label: "English", + label: languageNames.of("en")!, }, ], }); diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 33d6bdd6c7..ca2ec105b6 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -433,10 +433,7 @@ export function setMissingEntryGenerator(f: (value: string) => void): void { } type Languages = { - [lang: string]: { - fileName: string; - label: string; - }; + [lang: string]: string; }; export function setLanguage(preferredLangs: string | string[]): Promise { @@ -467,7 +464,7 @@ export function setLanguage(preferredLangs: string | string[]): Promise { logger.error("Unable to find an appropriate language"); } - return getLanguageRetry(i18nFolder + availLangs[langToUse].fileName); + return getLanguageRetry(i18nFolder + availLangs[langToUse]); }) .then(async (langData): Promise => { counterpart.registerTranslations(langToUse, langData); @@ -481,7 +478,7 @@ export function setLanguage(preferredLangs: string | string[]): Promise { // Set 'en' as fallback language: if (langToUse !== "en") { - return getLanguageRetry(i18nFolder + availLangs["en"].fileName); + return getLanguageRetry(i18nFolder + availLangs["en"]); } }) .then(async (langData): Promise => { @@ -492,21 +489,23 @@ export function setLanguage(preferredLangs: string | string[]): Promise { type Language = { value: string; - label: string; + label: string; // translated + labelInTargetLanguage: string; // translated }; -export function getAllLanguagesFromJson(): Promise { - return getLangsJson().then((langsObject) => { - const langs: Language[] = []; - for (const langKey in langsObject) { - if (langsObject.hasOwnProperty(langKey)) { - langs.push({ - value: langKey, - label: langsObject[langKey].label, - }); - } - } - return langs; +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)!, + }; }); } diff --git a/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap b/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap index 5bd7b22f4e..9b8a77dfe8 100644 --- a/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap +++ b/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap @@ -37,7 +37,7 @@ exports[` with a soft-logged-out session should show the soft-logo Matrix
", () => { + it("renders as expected", async () => { + const platform: any = { getAvailableSpellCheckLanguages: jest.fn().mockResolvedValue(["en", "de", "qq"]) }; + PlatformPeg.set(platform); + + const { asFragment } = render( + , + ); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…")); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/elements/__snapshots__/SpellCheckLanguagesDropdown-test.tsx.snap b/test/components/views/elements/__snapshots__/SpellCheckLanguagesDropdown-test.tsx.snap new file mode 100644 index 0000000000..b1b5f161c2 --- /dev/null +++ b/test/components/views/elements/__snapshots__/SpellCheckLanguagesDropdown-test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders as expected 1`] = ` + +
+ +
+
+`; diff --git a/test/i18n/languages.json b/test/i18n/languages.json index 5dc02003f7..bdb46584b9 100644 --- a/test/i18n/languages.json +++ b/test/i18n/languages.json @@ -1,6 +1,3 @@ { - "en": { - "fileName": "en_EN.json", - "label": "English" - } + "en": "en_EN.json" } diff --git a/test/languageHandler-test.ts b/test/languageHandler-test.ts deleted file mode 100644 index 0f22962831..0000000000 --- a/test/languageHandler-test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import SdkConfig from "../src/SdkConfig"; -import { - _t, - CustomTranslationOptions, - ICustomTranslations, - registerCustomTranslations, - setLanguage, - UserFriendlyError, -} from "../src/languageHandler"; - -async function setupTranslationOverridesForTests(overrides: ICustomTranslations) { - const lookupUrl = "/translations.json"; - const fn = (url: string): ICustomTranslations => { - expect(url).toEqual(lookupUrl); - return overrides; - }; - - SdkConfig.add({ - custom_translations_url: lookupUrl, - }); - CustomTranslationOptions.lookupFn = fn; - await registerCustomTranslations({ - testOnlyIgnoreCustomTranslationsCache: true, - }); -} - -describe("languageHandler", () => { - afterEach(() => { - SdkConfig.reset(); - CustomTranslationOptions.lookupFn = undefined; - }); - - it("should support overriding translations", async () => { - const str = "This is a test string that does not exist in the app."; - const enOverride = "This is the English version of a custom string."; - const deOverride = "This is the German version of a custom string."; - - // First test that overrides aren't being used - await setLanguage("en"); - expect(_t(str)).toEqual(str); - await setLanguage("de"); - expect(_t(str)).toEqual(str); - - 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); - }); - - describe("UserFriendlyError", () => { - const testErrorMessage = "This email address is already in use (%(email)s)"; - 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"); - expect(friendlyError.cause).toBeUndefined(); - }); - }); -}); diff --git a/test/i18n-test/languageHandler-test.tsx b/test/languageHandler-test.tsx similarity index 63% rename from test/i18n-test/languageHandler-test.tsx rename to test/languageHandler-test.tsx index 66c88fe567..d27de458c4 100644 --- a/test/i18n-test/languageHandler-test.tsx +++ b/test/languageHandler-test.tsx @@ -15,18 +15,160 @@ limitations under the License. */ import React from "react"; +import fetchMock from "fetch-mock-jest"; +import SdkConfig from "../src/SdkConfig"; import { _t, _tDom, - TranslatedString, + CustomTranslationOptions, + getAllLanguagesWithLabels, + ICustomTranslations, + registerCustomTranslations, setLanguage, setMissingEntryGenerator, substitute, -} from "../../src/languageHandler"; -import { stubClient } from "../test-utils"; + TranslatedString, + UserFriendlyError, +} from "../src/languageHandler"; +import { stubClient } from "./test-utils"; +import { setupLanguageMock } from "./setup/setupLanguage"; -describe("languageHandler", function () { +async function setupTranslationOverridesForTests(overrides: ICustomTranslations) { + const lookupUrl = "/translations.json"; + const fn = (url: string): ICustomTranslations => { + expect(url).toEqual(lookupUrl); + return overrides; + }; + + SdkConfig.add({ + custom_translations_url: lookupUrl, + }); + CustomTranslationOptions.lookupFn = fn; + await registerCustomTranslations({ + testOnlyIgnoreCustomTranslationsCache: true, + }); +} + +describe("languageHandler", () => { + beforeEach(async () => { + await setLanguage("en"); + }); + + afterEach(() => { + SdkConfig.reset(); + CustomTranslationOptions.lookupFn = undefined; + }); + + it("should support overriding translations", async () => { + const str = "This is a test string that does not exist in the app."; + const enOverride = "This is the English version of a custom string."; + const deOverride = "This is the German version of a custom string."; + + // First test that overrides aren't being used + await setLanguage("en"); + expect(_t(str)).toEqual(str); + await setLanguage("de"); + expect(_t(str)).toEqual(str); + + 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); + }); + + describe("UserFriendlyError", () => { + const testErrorMessage = "This email address is already in use (%(email)s)"; + 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"); + expect(friendlyError.cause).toBeUndefined(); + }); + }); + + describe("getAllLanguagesWithLabels", () => { + it("should handle unknown language sanely", async () => { + fetchMock.getOnce( + "/i18n/languages.json", + { + en: "en_EN.json", + de: "de_DE.json", + qq: "qq.json", + }, + { overwriteRoutes: true }, + ); + await expect(getAllLanguagesWithLabels()).resolves.toMatchInlineSnapshot(` + [ + { + "label": "English", + "labelInTargetLanguage": "English", + "value": "en", + }, + { + "label": "German", + "labelInTargetLanguage": "Deutsch", + "value": "de", + }, + { + "label": "qq", + "labelInTargetLanguage": "qq", + "value": "qq", + }, + ] + `); + setupLanguageMock(); // restore language mock + }); + }); +}); + +describe("languageHandler JSX", function () { // See setupLanguage.ts for how we are stubbing out translations to provide fixture data for these tests const basicString = "Rooms"; const selfClosingTagSub = "Accept to continue:"; diff --git a/test/setup/setupLanguage.ts b/test/setup/setupLanguage.ts index 4780a97d5e..b9eeb4d0a4 100644 --- a/test/setup/setupLanguage.ts +++ b/test/setup/setupLanguage.ts @@ -32,24 +32,18 @@ const lv = { // de_DE.json // lv.json - mock version with few translations, used to test fallback translation -fetchMock - .get("/i18n/languages.json", { - en: { - fileName: "en_EN.json", - label: "English", - }, - de: { - fileName: "de_DE.json", - label: "German", - }, - lv: { - fileName: "lv.json", - label: "Latvian", - }, - }) - .get("end:en_EN.json", en) - .get("end:de_DE.json", de) - .get("end:lv.json", lv); +export function setupLanguageMock() { + fetchMock + .get("/i18n/languages.json", { + en: "en_EN.json", + de: "de_DE.json", + lv: "lv.json", + }) + .get("end:en_EN.json", en) + .get("end:de_DE.json", de) + .get("end:lv.json", lv); +} +setupLanguageMock(); languageHandler.setLanguage("en"); languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]);