Skip to content

Commit 8c01551

Browse files
t3chguyDileep Bandla
authored andcommitted
Element Module API v1.0 support (element-hq#29934)
1 parent 1546ad1 commit 8c01551

File tree

10 files changed

+152
-80
lines changed

10 files changed

+152
-80
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
},
8282
"dependencies": {
8383
"@babel/runtime": "^7.12.5",
84-
"@element-hq/element-web-module-api": "^0.1.1",
84+
"@element-hq/element-web-module-api": "1.0.0",
8585
"@fontsource/inconsolata": "^5",
8686
"@fontsource/inter": "^5",
8787
"@formatjs/intl-segmenter": "^11.5.7",

playwright/e2e/modules/loader.spec.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ test.describe("Module loading", () => {
1515
test.describe("Example Module", () => {
1616
test.use({
1717
config: {
18+
brand: "TestBrand",
1819
modules: ["/modules/example-module.js"],
1920
},
2021
page: async ({ page }, use) => {
@@ -25,11 +26,31 @@ test.describe("Module loading", () => {
2526
},
2627
});
2728

28-
test("should show alert", async ({ page }) => {
29-
const dialogPromise = page.waitForEvent("dialog");
30-
await page.goto("/");
31-
const dialog = await dialogPromise;
32-
expect(dialog.message()).toBe("Testing module loading successful!");
33-
});
29+
const testCases = [
30+
["en", "TestBrand module loading successful!"],
31+
["de", "TestBrand-Module erfolgreich geladen!"],
32+
];
33+
34+
for (const [lang, message] of testCases) {
35+
test.describe(`language-${lang}`, () => {
36+
test.use({
37+
config: async ({ config }, use) => {
38+
await use({
39+
...config,
40+
setting_defaults: {
41+
language: lang,
42+
},
43+
});
44+
},
45+
});
46+
47+
test("should show alert", async ({ page }) => {
48+
const dialogPromise = page.waitForEvent("dialog");
49+
await page.goto("/");
50+
const dialog = await dialogPromise;
51+
expect(dialog.message()).toBe(message);
52+
});
53+
});
54+
}
3455
});
3556
});

playwright/sample-files/example-module.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ Please see LICENSE files in the repository root for full details.
66
*/
77

88
export default class ExampleModule {
9-
static moduleApiVersion = "^0.1.0";
9+
static moduleApiVersion = "^1.0.0";
1010
constructor(api) {
1111
this.api = api;
12+
13+
this.api.i18n.register({
14+
key: {
15+
en: "%(brand)s module loading successful!",
16+
de: "%(brand)s-Module erfolgreich geladen!",
17+
},
18+
});
1219
}
1320
async load() {
14-
alert("Testing module loading successful!");
21+
const brand = this.api.config.get("brand");
22+
alert(this.api.i18n.translate("key", { brand }));
1523
}
1624
}

src/languageHandler.tsx

Lines changed: 23 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -454,55 +454,35 @@ type Languages = {
454454
[lang: string]: string;
455455
};
456456

457-
export function setLanguage(preferredLangs: string | string[]): Promise<void> {
458-
if (!Array.isArray(preferredLangs)) {
459-
preferredLangs = [preferredLangs];
457+
export async function setLanguage(...preferredLangs: string[]): Promise<void> {
458+
PlatformPeg.get()?.setLanguage(preferredLangs);
459+
460+
const availableLanguages = await getLangsJson();
461+
let chosenLanguage = preferredLangs.find((lang) => availableLanguages.hasOwnProperty(lang));
462+
if (!chosenLanguage) {
463+
// Fallback to en_EN if none is found
464+
chosenLanguage = "en";
465+
logger.error("Unable to find an appropriate language, preferred: ", preferredLangs);
460466
}
461467

462-
const plaf = PlatformPeg.get();
463-
if (plaf) {
464-
plaf.setLanguage(preferredLangs);
465-
}
468+
const languageData = await getLanguageRetry(i18nFolder + availableLanguages[chosenLanguage]);
466469

467-
let langToUse: string;
468-
let availLangs: Languages;
469-
return getLangsJson()
470-
.then((result) => {
471-
availLangs = result;
470+
counterpart.registerTranslations(chosenLanguage, languageData);
471+
counterpart.setLocale(chosenLanguage);
472472

473-
for (let i = 0; i < preferredLangs.length; ++i) {
474-
if (availLangs.hasOwnProperty(preferredLangs[i])) {
475-
langToUse = preferredLangs[i];
476-
break;
477-
}
478-
}
479-
if (!langToUse) {
480-
// Fallback to en_EN if none is found
481-
langToUse = "en";
482-
logger.error("Unable to find an appropriate language");
483-
}
473+
await SettingsStore.setValue("language", null, SettingLevel.DEVICE, chosenLanguage);
474+
// Adds a lot of noise to test runs, so disable logging there.
475+
if (process.env.NODE_ENV !== "test") {
476+
logger.log("set language to " + chosenLanguage);
477+
}
484478

485-
return getLanguageRetry(i18nFolder + availLangs[langToUse]);
486-
})
487-
.then(async (langData): Promise<ICounterpartTranslation | undefined> => {
488-
counterpart.registerTranslations(langToUse, langData);
489-
await registerCustomTranslations();
490-
counterpart.setLocale(langToUse);
491-
await SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse);
492-
// Adds a lot of noise to test runs, so disable logging there.
493-
if (process.env.NODE_ENV !== "test") {
494-
logger.log("set language to " + langToUse);
495-
}
479+
// Set 'en' as fallback language:
480+
if (chosenLanguage !== "en") {
481+
const fallbackLanguageData = await getLanguageRetry(i18nFolder + availableLanguages["en"]);
482+
counterpart.registerTranslations("en", fallbackLanguageData);
483+
}
496484

497-
// Set 'en' as fallback language:
498-
if (langToUse !== "en") {
499-
return getLanguageRetry(i18nFolder + availLangs["en"]);
500-
}
501-
})
502-
.then(async (langData): Promise<void> => {
503-
if (langData) counterpart.registerTranslations("en", langData);
504-
await registerCustomTranslations();
505-
});
485+
await registerCustomTranslations();
506486
}
507487

508488
type Language = {

src/modules/Api.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
55
Please see LICENSE files in the repository root for full details.
66
*/
77

8-
import type { Api, RuntimeModuleConstructor, Config } from "@element-hq/element-web-module-api";
8+
import { createRoot, type Root } from "react-dom/client";
9+
10+
import type { Api, RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
911
import { ModuleRunner } from "./ModuleRunner.ts";
1012
import AliasCustomisations from "../customisations/Alias.ts";
1113
import { RoomListCustomisations } from "../customisations/RoomList.ts";
@@ -17,7 +19,8 @@ import * as MediaCustomisations from "../customisations/Media.ts";
1719
import UserIdentifierCustomisations from "../customisations/UserIdentifier.ts";
1820
import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissions.ts";
1921
import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts";
20-
import SdkConfig from "../SdkConfig.ts";
22+
import { ConfigApi } from "./ConfigApi.ts";
23+
import { I18nApi } from "./I18nApi.ts";
2124

2225
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
2326
let used = false;
@@ -28,17 +31,6 @@ const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) =>
2831
};
2932
};
3033

31-
class ConfigApi {
32-
public get(): Config;
33-
public get<K extends keyof Config>(key: K): Config[K];
34-
public get<K extends keyof Config = never>(key?: K): Config | Config[K] {
35-
if (key === undefined) {
36-
return SdkConfig.get() as Config;
37-
}
38-
return SdkConfig.get(key);
39-
}
40-
}
41-
4234
/**
4335
* Implementation of the @element-hq/element-web-module-api runtime module API.
4436
*/
@@ -65,6 +57,12 @@ class ModuleApi implements Api {
6557
/* eslint-enable @typescript-eslint/naming-convention */
6658

6759
public readonly config = new ConfigApi();
60+
public readonly i18n = new I18nApi();
61+
public readonly rootNode = document.getElementById("matrixchat")!;
62+
63+
public createRoot(element: Element): Root {
64+
return createRoot(element);
65+
}
6866
}
6967

7068
export type ModuleApiType = ModuleApi;

src/modules/ConfigApi.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import type { ConfigApi as IConfigApi, Config } from "@element-hq/element-web-module-api";
9+
import SdkConfig from "../SdkConfig.ts";
10+
11+
export class ConfigApi implements IConfigApi {
12+
public get(): Config;
13+
public get<K extends keyof Config>(key: K): Config[K];
14+
public get<K extends keyof Config = never>(key?: K): Config | Config[K] {
15+
if (key === undefined) {
16+
return SdkConfig.get() as Config;
17+
}
18+
return SdkConfig.get(key);
19+
}
20+
}

src/modules/I18nApi.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type I18nApi as II18nApi, type Variables, type Translations } from "@element-hq/element-web-module-api";
9+
import counterpart from "counterpart";
10+
11+
import { _t, getCurrentLanguage, type TranslationKey } from "../languageHandler.tsx";
12+
13+
export class I18nApi implements II18nApi {
14+
/**
15+
* Read the current language of the user in IETF Language Tag format
16+
*/
17+
public get language(): string {
18+
return getCurrentLanguage();
19+
}
20+
21+
/**
22+
* Register translations for the module, may override app's existing translations
23+
*/
24+
public register(translations: Partial<Translations>): void {
25+
const langs: Record<string, Record<string, string>> = {};
26+
for (const key in translations) {
27+
for (const lang in translations[key]) {
28+
langs[lang] = langs[lang] || {};
29+
langs[lang][key] = translations[key][lang];
30+
}
31+
}
32+
33+
// Finally, tell counterpart about our translations
34+
for (const lang in langs) {
35+
counterpart.registerTranslations(lang, langs[lang]);
36+
}
37+
}
38+
39+
/**
40+
* Perform a translation, with optional variables
41+
* @param key - The key to translate
42+
* @param variables - Optional variables to interpolate into the translation
43+
*/
44+
public translate(key: TranslationKey, variables?: Variables): string {
45+
return _t(key, variables);
46+
}
47+
}

src/vector/index.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,21 +162,18 @@ async function start(): Promise<void> {
162162
// now that the config is ready, try to persist logs
163163
const persistLogsPromise = setupLogStorage();
164164

165-
// Load modules & plugins before language to ensure any custom translations are respected, and any app
166-
// startup functionality is run
167-
const loadModulesPromise = loadModules();
168-
await settled(loadModulesPromise);
169-
const loadPluginsPromise = loadPlugins();
170-
await settled(loadPluginsPromise);
171-
172165
// Load language after loading config.json so that settingsDefaults.language can be applied
173166
const loadLanguagePromise = loadLanguage();
174167
// as quickly as we possibly can, set a default theme...
175168
const loadThemePromise = loadTheme();
176-
177169
// await things settling so that any errors we have to render have features like i18n running
178170
await settled(loadThemePromise, loadLanguagePromise);
179171

172+
const loadModulesPromise = loadModules();
173+
await settled(loadModulesPromise);
174+
const loadPluginsPromise = loadPlugins();
175+
await settled(loadPluginsPromise);
176+
180177
let acceptBrowser = supportedBrowser;
181178
if (!acceptBrowser && window.localStorage) {
182179
acceptBrowser = Boolean(window.localStorage.getItem("mx_accepts_unsupported_browser"));

src/vector/init.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export async function loadLanguage(): Promise<void> {
7575
langs = [prefLang];
7676
}
7777
try {
78-
await languageHandler.setLanguage(langs);
78+
await languageHandler.setLanguage(...langs);
7979
document.documentElement.setAttribute("lang", languageHandler.getCurrentLanguage());
8080
} catch (e) {
8181
logger.error("Unable to set language", e);

yarn.lock

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1657,10 +1657,10 @@
16571657
resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.10.0.tgz#cae352015d7f2f8830907c24ecea6642994d3e42"
16581658
integrity sha512-glH/U67Jz3fhpvCMonto0I1/YzpAXqavhZsRVkHe9YoHsJs1FUw9Pv8NcAXh2zENL9jHFlinzqr+CZKyS9VM3w==
16591659

1660-
"@element-hq/element-web-module-api@^0.1.1":
1661-
version "0.1.1"
1662-
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-0.1.1.tgz#e2b24aa38aa9f7b6af3c4993e6402a8b7e2f3cb5"
1663-
integrity sha512-qtEQD5nFaRJ+vfAis7uhKB66SyCjrz7O+qGz/hKJjgNhBLT/6C5DK90waKINXSw0J3stFR43IWzEk5GBOrTMow==
1660+
"@element-hq/element-web-module-api@1.0.0":
1661+
version "1.0.0"
1662+
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.0.0.tgz#df09108b0346a44ad2898c603d1a6cda5f50d80b"
1663+
integrity sha512-FYId5tYgaKvpqAXRXqs0pY4+7/A09bEl1mCxFqlS9jlZOCjlMZVvZuv8spbY8ZN9HaMvuVmx9J00Fn2gCJd0TQ==
16641664

16651665
"@element-hq/element-web-playwright-common@^1.1.5":
16661666
version "1.1.6"
@@ -3765,15 +3765,16 @@
37653765
classnames "^2.5.1"
37663766
vaul "^1.0.0"
37673767

3768-
"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm":
3768+
"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm":
37693769
version "0.0.0"
3770+
uid ""
37703771

37713772
"@vector-im/[email protected]":
37723773
version "2.38.3"
37733774
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.3.tgz#cc54d8b3e9472bcd8e622126ba364ee31952cd8a"
37743775
integrity sha512-fqo8P55Vc/t0vxpFar9RDJN5gKEjJmzrLo+O4piDbFda6VrRoqrWAtiu0Au0g6B4hRDPKIuFupk8v9Ja7q8Hvg==
37753776
dependencies:
3776-
"@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm"
3777+
"@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm"
37773778

37783779
"@webassemblyjs/[email protected]", "@webassemblyjs/ast@^1.14.1":
37793780
version "1.14.1"

0 commit comments

Comments
 (0)