Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 4de315f

Browse files
authored
Use Intl for names of languages (#11427)
* Use Intl for names of languages * Tweak Intl language style from "American English" -> "US English" * Update tests * Fix tests * Consolidate languageHandler-test files * Improve coverage * Consistent casing for languages in dropdown * Update LanguageDropdown.tsx * Delint & update snapshot * Fix tests * Improve coverage `of` will fallback to the given code with fallback=code (default)
1 parent 3684c77 commit 4de315f

File tree

15 files changed

+304
-193
lines changed

15 files changed

+304
-193
lines changed

__mocks__/languages.json

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
11
{
2-
"en": {
3-
"fileName": "en_EN.json",
4-
"label": "English"
5-
},
6-
"en-us": {
7-
"fileName": "en_US.json",
8-
"label": "English (US)"
9-
}
2+
"en": "en_EN.json",
3+
"en-us": "en_US.json"
104
}

cypress/e2e/settings/general-user-settings-tab.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,12 @@ describe("General user settings tab", () => {
133133
cy.findByRole("button", { name: "Language Dropdown" }).click();
134134

135135
// Assert that the default option is rendered and highlighted
136-
cy.findByRole("option", { name: /Bahasa Indonesia/ })
136+
cy.findByRole("option", { name: /Albanian/ })
137137
.should("be.visible")
138138
.should("have.class", "mx_Dropdown_option_highlight");
139139

140+
cy.findByRole("option", { name: /Deutsch/ }).should("be.visible");
141+
140142
// Click again to close the dropdown
141143
cy.findByRole("button", { name: "Language Dropdown" }).click();
142144

res/css/_components.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@
190190
@import "./views/elements/_InteractiveTooltip.pcss";
191191
@import "./views/elements/_InviteReason.pcss";
192192
@import "./views/elements/_LabelledCheckbox.pcss";
193+
@import "./views/elements/_LanguageDropdown.pcss";
193194
@import "./views/elements/_MiniAvatarUploader.pcss";
194195
@import "./views/elements/_Pill.pcss";
195196
@import "./views/elements/_PowerSelector.pcss";
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
.mx_LanguageDropdown {
18+
.mx_Dropdown_option > div {
19+
text-transform: capitalize;
20+
}
21+
}

src/components/views/auth/CountryDropdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export default class CountryDropdown extends React.Component<IProps, IState> {
7070
const locale = new Intl.Locale(navigator.language ?? navigator.languages[0]);
7171
const code = locale.region ?? locale.language ?? locale.baseName;
7272
const displayNames = new Intl.DisplayNames(["en"], { type: "region" });
73-
const displayName = displayNames.of(code)?.toUpperCase();
73+
const displayName = displayNames.of(code)!.toUpperCase();
7474
defaultCountry = COUNTRIES.find(
7575
(c) => c.iso2 === code.toUpperCase() || c.name.toUpperCase() === displayName,
7676
);

src/components/views/elements/LanguageDropdown.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ limitations under the License.
1616
*/
1717

1818
import React, { ReactElement } from "react";
19+
import classNames from "classnames";
1920

2021
import * as languageHandler from "../../../languageHandler";
2122
import SettingsStore from "../../../settings/SettingsStore";
@@ -24,9 +25,10 @@ import Spinner from "./Spinner";
2425
import Dropdown from "./Dropdown";
2526
import { NonEmptyArray } from "../../../@types/common";
2627

27-
type Languages = Awaited<ReturnType<typeof languageHandler.getAllLanguagesFromJson>>;
28+
type Languages = Awaited<ReturnType<typeof languageHandler.getAllLanguagesWithLabels>>;
2829

2930
function languageMatchesSearchQuery(query: string, language: Languages[0]): boolean {
31+
if (language.labelInTargetLanguage.toUpperCase().includes(query.toUpperCase())) return true;
3032
if (language.label.toUpperCase().includes(query.toUpperCase())) return true;
3133
if (language.value.toUpperCase() === query.toUpperCase()) return true;
3234
return false;
@@ -56,23 +58,30 @@ export default class LanguageDropdown extends React.Component<IProps, IState> {
5658

5759
public componentDidMount(): void {
5860
languageHandler
59-
.getAllLanguagesFromJson()
61+
.getAllLanguagesWithLabels()
6062
.then((langs) => {
6163
langs.sort(function (a, b) {
62-
if (a.label < b.label) return -1;
63-
if (a.label > b.label) return 1;
64+
if (a.labelInTargetLanguage < b.labelInTargetLanguage) return -1;
65+
if (a.labelInTargetLanguage > b.labelInTargetLanguage) return 1;
6466
return 0;
6567
});
6668
this.setState({ langs });
6769
})
6870
.catch(() => {
69-
this.setState({ langs: [{ value: "en", label: "English" }] });
71+
this.setState({
72+
langs: [
73+
{
74+
value: "en",
75+
label: "English",
76+
labelInTargetLanguage: "English",
77+
},
78+
],
79+
});
7080
});
7181

7282
if (!this.props.value) {
73-
// If no value is given, we start with the first
74-
// country selected, but our parent component
75-
// doesn't know this, therefore we do this.
83+
// If no value is given, we start with the first country selected,
84+
// but our parent component doesn't know this, therefore we do this.
7685
const language = languageHandler.getUserLanguage();
7786
this.props.onOptionChange(language);
7887
}
@@ -89,7 +98,7 @@ export default class LanguageDropdown extends React.Component<IProps, IState> {
8998
return <Spinner />;
9099
}
91100

92-
let displayedLanguages: Awaited<ReturnType<typeof languageHandler.getAllLanguagesFromJson>>;
101+
let displayedLanguages: Awaited<ReturnType<typeof languageHandler.getAllLanguagesWithLabels>>;
93102
if (this.state.searchQuery) {
94103
displayedLanguages = this.state.langs.filter((lang) => {
95104
return languageMatchesSearchQuery(this.state.searchQuery, lang);
@@ -99,7 +108,7 @@ export default class LanguageDropdown extends React.Component<IProps, IState> {
99108
}
100109

101110
const options = displayedLanguages.map((language) => {
102-
return <div key={language.value}>{language.label}</div>;
111+
return <div key={language.value}>{language.labelInTargetLanguage}</div>;
103112
}) as NonEmptyArray<ReactElement & { key: string }>;
104113

105114
// default value here too, otherwise we need to handle null / undefined
@@ -116,7 +125,7 @@ export default class LanguageDropdown extends React.Component<IProps, IState> {
116125
return (
117126
<Dropdown
118127
id="mx_LanguageDropdown"
119-
className={this.props.className}
128+
className={classNames("mx_LanguageDropdown", this.props.className)}
120129
onOptionChange={this.props.onOptionChange}
121130
onSearchChange={this.onSearchChange}
122131
searchEnabled={true}

src/components/views/elements/SpellCheckLanguagesDropdown.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ import React, { ReactElement } from "react";
1919
import Dropdown from "../../views/elements/Dropdown";
2020
import PlatformPeg from "../../../PlatformPeg";
2121
import SettingsStore from "../../../settings/SettingsStore";
22-
import { _t } from "../../../languageHandler";
22+
import { _t, getUserLanguage } from "../../../languageHandler";
2323
import Spinner from "./Spinner";
24-
import * as languageHandler from "../../../languageHandler";
2524
import { NonEmptyArray } from "../../../@types/common";
2625

27-
type Languages = Awaited<ReturnType<typeof languageHandler.getAllLanguagesFromJson>>;
26+
type Languages = {
27+
value: string;
28+
label: string; // translated
29+
}[];
2830
function languageMatchesSearchQuery(query: string, language: Languages[0]): boolean {
2931
if (language.label.toUpperCase().includes(query.toUpperCase())) return true;
3032
if (language.value.toUpperCase() === query.toUpperCase()) return true;
@@ -58,6 +60,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component<
5860
public componentDidMount(): void {
5961
const plaf = PlatformPeg.get();
6062
if (plaf) {
63+
const languageNames = new Intl.DisplayNames([getUserLanguage()], { type: "language", style: "short" });
6164
plaf.getAvailableSpellCheckLanguages()
6265
?.then((languages) => {
6366
languages.sort(function (a, b) {
@@ -68,7 +71,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component<
6871
const langs: Languages = [];
6972
languages.forEach((language) => {
7073
langs.push({
71-
label: language,
74+
label: languageNames.of(language)!,
7275
value: language,
7376
});
7477
});
@@ -79,7 +82,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component<
7982
languages: [
8083
{
8184
value: "en",
82-
label: "English",
85+
label: languageNames.of("en")!,
8386
},
8487
],
8588
});

src/languageHandler.tsx

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -433,10 +433,7 @@ export function setMissingEntryGenerator(f: (value: string) => void): void {
433433
}
434434

435435
type Languages = {
436-
[lang: string]: {
437-
fileName: string;
438-
label: string;
439-
};
436+
[lang: string]: string;
440437
};
441438

442439
export function setLanguage(preferredLangs: string | string[]): Promise<void> {
@@ -467,7 +464,7 @@ export function setLanguage(preferredLangs: string | string[]): Promise<void> {
467464
logger.error("Unable to find an appropriate language");
468465
}
469466

470-
return getLanguageRetry(i18nFolder + availLangs[langToUse].fileName);
467+
return getLanguageRetry(i18nFolder + availLangs[langToUse]);
471468
})
472469
.then(async (langData): Promise<ICounterpartTranslation | undefined> => {
473470
counterpart.registerTranslations(langToUse, langData);
@@ -481,7 +478,7 @@ export function setLanguage(preferredLangs: string | string[]): Promise<void> {
481478

482479
// Set 'en' as fallback language:
483480
if (langToUse !== "en") {
484-
return getLanguageRetry(i18nFolder + availLangs["en"].fileName);
481+
return getLanguageRetry(i18nFolder + availLangs["en"]);
485482
}
486483
})
487484
.then(async (langData): Promise<void> => {
@@ -492,21 +489,23 @@ export function setLanguage(preferredLangs: string | string[]): Promise<void> {
492489

493490
type Language = {
494491
value: string;
495-
label: string;
492+
label: string; // translated
493+
labelInTargetLanguage: string; // translated
496494
};
497495

498-
export function getAllLanguagesFromJson(): Promise<Language[]> {
499-
return getLangsJson().then((langsObject) => {
500-
const langs: Language[] = [];
501-
for (const langKey in langsObject) {
502-
if (langsObject.hasOwnProperty(langKey)) {
503-
langs.push({
504-
value: langKey,
505-
label: langsObject[langKey].label,
506-
});
507-
}
508-
}
509-
return langs;
496+
export async function getAllLanguagesFromJson(): Promise<string[]> {
497+
return Object.keys(await getLangsJson());
498+
}
499+
500+
export async function getAllLanguagesWithLabels(): Promise<Language[]> {
501+
const languageNames = new Intl.DisplayNames([getUserLanguage()], { type: "language", style: "short" });
502+
const languages = await getAllLanguagesFromJson();
503+
return languages.map<Language>((langKey) => {
504+
return {
505+
value: langKey,
506+
label: languageNames.of(langKey)!,
507+
labelInTargetLanguage: new Intl.DisplayNames([langKey], { type: "language", style: "short" }).of(langKey)!,
508+
};
510509
});
511510
}
512511

test/components/structures/__snapshots__/MatrixChat-test.tsx.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ exports[`<MatrixChat /> with a soft-logged-out session should show the soft-logo
3737
Matrix
3838
</aside>
3939
<div
40-
class="mx_Dropdown mx_AuthBody_language"
40+
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
4141
>
4242
<div
4343
aria-describedby="mx_LanguageDropdown_value"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from "react";
18+
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
19+
20+
import SpellCheckLanguagesDropdown from "../../../../src/components/views/elements/SpellCheckLanguagesDropdown";
21+
import PlatformPeg from "../../../../src/PlatformPeg";
22+
23+
describe("<SpellCheckLanguagesDropdown />", () => {
24+
it("renders as expected", async () => {
25+
const platform: any = { getAvailableSpellCheckLanguages: jest.fn().mockResolvedValue(["en", "de", "qq"]) };
26+
PlatformPeg.set(platform);
27+
28+
const { asFragment } = render(
29+
<SpellCheckLanguagesDropdown
30+
className="mx_GeneralUserSettingsTab_spellCheckLanguageInput"
31+
value="en"
32+
onOptionChange={jest.fn()}
33+
/>,
34+
);
35+
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
36+
expect(asFragment()).toMatchSnapshot();
37+
});
38+
});

0 commit comments

Comments
 (0)