From 785a12a029e19669c6e0baa4b57ba4c335fa2df3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 14 May 2025 09:21:24 +0100 Subject: [PATCH] Element Module API v1.0 support (#29934) --- package.json | 2 +- playwright/e2e/modules/loader.spec.ts | 33 +++++++++-- playwright/sample-files/example-module.js | 12 +++- src/languageHandler.tsx | 68 ++++++++--------------- src/modules/Api.ts | 24 ++++---- src/modules/ConfigApi.ts | 20 +++++++ src/modules/I18nApi.ts | 47 ++++++++++++++++ src/vector/index.ts | 13 ++--- src/vector/init.tsx | 2 +- yarn.lock | 13 +++-- 10 files changed, 153 insertions(+), 81 deletions(-) create mode 100644 src/modules/ConfigApi.ts create mode 100644 src/modules/I18nApi.ts diff --git a/package.json b/package.json index ce3ebf3d58..bef1a86508 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", - "@element-hq/element-web-module-api": "^0.1.1", + "@element-hq/element-web-module-api": "1.0.0", "@fontsource/inconsolata": "^5", "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", diff --git a/playwright/e2e/modules/loader.spec.ts b/playwright/e2e/modules/loader.spec.ts index e21b5c2d92..52c7a02988 100644 --- a/playwright/e2e/modules/loader.spec.ts +++ b/playwright/e2e/modules/loader.spec.ts @@ -15,6 +15,7 @@ test.describe("Module loading", () => { test.describe("Example Module", () => { test.use({ config: { + brand: "TestBrand", modules: ["/modules/example-module.js"], }, page: async ({ page }, use) => { @@ -25,11 +26,31 @@ test.describe("Module loading", () => { }, }); - test("should show alert", async ({ page }) => { - const dialogPromise = page.waitForEvent("dialog"); - await page.goto("/"); - const dialog = await dialogPromise; - expect(dialog.message()).toBe("Testing module loading successful!"); - }); + const testCases = [ + ["en", "TestBrand module loading successful!"], + ["de", "TestBrand-Module erfolgreich geladen!"], + ]; + + for (const [lang, message] of testCases) { + test.describe(`language-${lang}`, () => { + test.use({ + config: async ({ config }, use) => { + await use({ + ...config, + setting_defaults: { + language: lang, + }, + }); + }, + }); + + test("should show alert", async ({ page }) => { + const dialogPromise = page.waitForEvent("dialog"); + await page.goto("/"); + const dialog = await dialogPromise; + expect(dialog.message()).toBe(message); + }); + }); + } }); }); diff --git a/playwright/sample-files/example-module.js b/playwright/sample-files/example-module.js index cb9b80a93b..561dea5fd3 100644 --- a/playwright/sample-files/example-module.js +++ b/playwright/sample-files/example-module.js @@ -6,11 +6,19 @@ Please see LICENSE files in the repository root for full details. */ export default class ExampleModule { - static moduleApiVersion = "^0.1.0"; + static moduleApiVersion = "^1.0.0"; constructor(api) { this.api = api; + + this.api.i18n.register({ + key: { + en: "%(brand)s module loading successful!", + de: "%(brand)s-Module erfolgreich geladen!", + }, + }); } async load() { - alert("Testing module loading successful!"); + const brand = this.api.config.get("brand"); + alert(this.api.i18n.translate("key", { brand })); } } diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index e99bb3706f..1a3efed321 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -454,55 +454,35 @@ type Languages = { [lang: string]: string; }; -export function setLanguage(preferredLangs: string | string[]): Promise { - if (!Array.isArray(preferredLangs)) { - preferredLangs = [preferredLangs]; +export async function setLanguage(...preferredLangs: string[]): Promise { + PlatformPeg.get()?.setLanguage(preferredLangs); + + const availableLanguages = await getLangsJson(); + let chosenLanguage = preferredLangs.find((lang) => availableLanguages.hasOwnProperty(lang)); + if (!chosenLanguage) { + // Fallback to en_EN if none is found + chosenLanguage = "en"; + logger.error("Unable to find an appropriate language, preferred: ", preferredLangs); } - const plaf = PlatformPeg.get(); - if (plaf) { - plaf.setLanguage(preferredLangs); + const languageData = await getLanguageRetry(i18nFolder + availableLanguages[chosenLanguage]); + + counterpart.registerTranslations(chosenLanguage, languageData); + counterpart.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); } - let langToUse: string; - let availLangs: Languages; - return getLangsJson() - .then((result) => { - availLangs = result; + // Set 'en' as fallback language: + if (chosenLanguage !== "en") { + const fallbackLanguageData = await getLanguageRetry(i18nFolder + availableLanguages["en"]); + counterpart.registerTranslations("en", fallbackLanguageData); + } - for (let i = 0; i < preferredLangs.length; ++i) { - if (availLangs.hasOwnProperty(preferredLangs[i])) { - langToUse = preferredLangs[i]; - break; - } - } - if (!langToUse) { - // Fallback to en_EN if none is found - langToUse = "en"; - logger.error("Unable to find an appropriate language"); - } - - return getLanguageRetry(i18nFolder + availLangs[langToUse]); - }) - .then(async (langData): Promise => { - counterpart.registerTranslations(langToUse, langData); - await registerCustomTranslations(); - counterpart.setLocale(langToUse); - await SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse); - // Adds a lot of noise to test runs, so disable logging there. - if (process.env.NODE_ENV !== "test") { - logger.log("set language to " + langToUse); - } - - // Set 'en' as fallback language: - if (langToUse !== "en") { - return getLanguageRetry(i18nFolder + availLangs["en"]); - } - }) - .then(async (langData): Promise => { - if (langData) counterpart.registerTranslations("en", langData); - await registerCustomTranslations(); - }); + await registerCustomTranslations(); } type Language = { diff --git a/src/modules/Api.ts b/src/modules/Api.ts index ad87088840..23abadf529 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -5,7 +5,9 @@ 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 type { Api, RuntimeModuleConstructor, Config } from "@element-hq/element-web-module-api"; +import { createRoot, type Root } from "react-dom/client"; + +import type { Api, RuntimeModuleConstructor } from "@element-hq/element-web-module-api"; import { ModuleRunner } from "./ModuleRunner.ts"; import AliasCustomisations from "../customisations/Alias.ts"; import { RoomListCustomisations } from "../customisations/RoomList.ts"; @@ -17,7 +19,8 @@ import * as MediaCustomisations from "../customisations/Media.ts"; import UserIdentifierCustomisations from "../customisations/UserIdentifier.ts"; import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissions.ts"; import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts"; -import SdkConfig from "../SdkConfig.ts"; +import { ConfigApi } from "./ConfigApi.ts"; +import { I18nApi } from "./I18nApi.ts"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; @@ -28,17 +31,6 @@ const legacyCustomisationsFactory = (baseCustomisations: T) => }; }; -class ConfigApi { - public get(): Config; - public get(key: K): Config[K]; - public get(key?: K): Config | Config[K] { - if (key === undefined) { - return SdkConfig.get() as Config; - } - return SdkConfig.get(key); - } -} - /** * Implementation of the @element-hq/element-web-module-api runtime module API. */ @@ -65,6 +57,12 @@ class ModuleApi implements Api { /* eslint-enable @typescript-eslint/naming-convention */ public readonly config = new ConfigApi(); + public readonly i18n = new I18nApi(); + public readonly rootNode = document.getElementById("matrixchat")!; + + public createRoot(element: Element): Root { + return createRoot(element); + } } export type ModuleApiType = ModuleApi; diff --git a/src/modules/ConfigApi.ts b/src/modules/ConfigApi.ts new file mode 100644 index 0000000000..512a1c4abe --- /dev/null +++ b/src/modules/ConfigApi.ts @@ -0,0 +1,20 @@ +/* +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 { ConfigApi as IConfigApi, Config } from "@element-hq/element-web-module-api"; +import SdkConfig from "../SdkConfig.ts"; + +export class ConfigApi implements IConfigApi { + public get(): Config; + public get(key: K): Config[K]; + public get(key?: K): Config | Config[K] { + if (key === undefined) { + return SdkConfig.get() as Config; + } + return SdkConfig.get(key); + } +} diff --git a/src/modules/I18nApi.ts b/src/modules/I18nApi.ts new file mode 100644 index 0000000000..43c101eca6 --- /dev/null +++ b/src/modules/I18nApi.ts @@ -0,0 +1,47 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type I18nApi as II18nApi, type Variables, type Translations } from "@element-hq/element-web-module-api"; +import counterpart from "counterpart"; + +import { _t, getCurrentLanguage, type TranslationKey } from "../languageHandler.tsx"; + +export class I18nApi implements II18nApi { + /** + * Read the current language of the user in IETF Language Tag format + */ + public get language(): string { + return getCurrentLanguage(); + } + + /** + * Register translations for the module, may override app's existing translations + */ + public register(translations: Partial): void { + const langs: Record> = {}; + for (const key in translations) { + for (const lang in translations[key]) { + langs[lang] = langs[lang] || {}; + langs[lang][key] = translations[key][lang]; + } + } + + // Finally, tell counterpart about our translations + for (const lang in langs) { + counterpart.registerTranslations(lang, langs[lang]); + } + } + + /** + * Perform a translation, with optional variables + * @param key - The key to translate + * @param variables - Optional variables to interpolate into the translation + */ + public translate(key: TranslationKey, variables?: Variables): string { + return _t(key, variables); + } +} diff --git a/src/vector/index.ts b/src/vector/index.ts index f582a46c6b..943ed49c6a 100644 --- a/src/vector/index.ts +++ b/src/vector/index.ts @@ -162,21 +162,18 @@ async function start(): Promise { // now that the config is ready, try to persist logs const persistLogsPromise = setupLogStorage(); - // Load modules & plugins before language to ensure any custom translations are respected, and any app - // startup functionality is run - const loadModulesPromise = loadModules(); - await settled(loadModulesPromise); - const loadPluginsPromise = loadPlugins(); - await settled(loadPluginsPromise); - // Load language after loading config.json so that settingsDefaults.language can be applied const loadLanguagePromise = loadLanguage(); // as quickly as we possibly can, set a default theme... const loadThemePromise = loadTheme(); - // await things settling so that any errors we have to render have features like i18n running await settled(loadThemePromise, loadLanguagePromise); + const loadModulesPromise = loadModules(); + await settled(loadModulesPromise); + const loadPluginsPromise = loadPlugins(); + await settled(loadPluginsPromise); + let acceptBrowser = supportedBrowser; if (!acceptBrowser && window.localStorage) { acceptBrowser = Boolean(window.localStorage.getItem("mx_accepts_unsupported_browser")); diff --git a/src/vector/init.tsx b/src/vector/init.tsx index 1169a6df28..e481e34b97 100644 --- a/src/vector/init.tsx +++ b/src/vector/init.tsx @@ -75,7 +75,7 @@ export async function loadLanguage(): Promise { langs = [prefLang]; } try { - await languageHandler.setLanguage(langs); + await languageHandler.setLanguage(...langs); document.documentElement.setAttribute("lang", languageHandler.getCurrentLanguage()); } catch (e) { logger.error("Unable to set language", e); diff --git a/yarn.lock b/yarn.lock index 46b5429711..59b7abd296 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1657,10 +1657,10 @@ resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.10.0.tgz#cae352015d7f2f8830907c24ecea6642994d3e42" integrity sha512-glH/U67Jz3fhpvCMonto0I1/YzpAXqavhZsRVkHe9YoHsJs1FUw9Pv8NcAXh2zENL9jHFlinzqr+CZKyS9VM3w== -"@element-hq/element-web-module-api@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-0.1.1.tgz#e2b24aa38aa9f7b6af3c4993e6402a8b7e2f3cb5" - integrity sha512-qtEQD5nFaRJ+vfAis7uhKB66SyCjrz7O+qGz/hKJjgNhBLT/6C5DK90waKINXSw0J3stFR43IWzEk5GBOrTMow== +"@element-hq/element-web-module-api@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.0.0.tgz#df09108b0346a44ad2898c603d1a6cda5f50d80b" + integrity sha512-FYId5tYgaKvpqAXRXqs0pY4+7/A09bEl1mCxFqlS9jlZOCjlMZVvZuv8spbY8ZN9HaMvuVmx9J00Fn2gCJd0TQ== "@element-hq/element-web-playwright-common@^1.1.5": version "1.1.6" @@ -3765,15 +3765,16 @@ classnames "^2.5.1" vaul "^1.0.0" -"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm": +"@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": version "0.0.0" + uid "" "@vector-im/matrix-wysiwyg@2.38.3": version "2.38.3" resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.3.tgz#cc54d8b3e9472bcd8e622126ba364ee31952cd8a" integrity sha512-fqo8P55Vc/t0vxpFar9RDJN5gKEjJmzrLo+O4piDbFda6VrRoqrWAtiu0Au0g6B4hRDPKIuFupk8v9Ja7q8Hvg== dependencies: - "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm" + "@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" "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1"