Element Module API v1.0 support (#29934)
This commit is contained in:
committed by
GitHub
parent
c9548ec1d0
commit
785a12a029
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,55 +454,35 @@ type Languages = {
|
||||
[lang: string]: string;
|
||||
};
|
||||
|
||||
export function setLanguage(preferredLangs: string | string[]): Promise<void> {
|
||||
if (!Array.isArray(preferredLangs)) {
|
||||
preferredLangs = [preferredLangs];
|
||||
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 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<ICounterpartTranslation | undefined> => {
|
||||
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<void> => {
|
||||
if (langData) counterpart.registerTranslations("en", langData);
|
||||
await registerCustomTranslations();
|
||||
});
|
||||
await registerCustomTranslations();
|
||||
}
|
||||
|
||||
type Language = {
|
||||
|
||||
@@ -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 = <T extends object>(baseCustomisations: T) => {
|
||||
let used = false;
|
||||
@@ -28,17 +31,6 @@ const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) =>
|
||||
};
|
||||
};
|
||||
|
||||
class ConfigApi {
|
||||
public get(): Config;
|
||||
public get<K extends keyof Config>(key: K): Config[K];
|
||||
public get<K extends keyof Config = never>(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;
|
||||
|
||||
20
src/modules/ConfigApi.ts
Normal file
20
src/modules/ConfigApi.ts
Normal file
@@ -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<K extends keyof Config>(key: K): Config[K];
|
||||
public get<K extends keyof Config = never>(key?: K): Config | Config[K] {
|
||||
if (key === undefined) {
|
||||
return SdkConfig.get() as Config;
|
||||
}
|
||||
return SdkConfig.get(key);
|
||||
}
|
||||
}
|
||||
47
src/modules/I18nApi.ts
Normal file
47
src/modules/I18nApi.ts
Normal file
@@ -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<Translations>): void {
|
||||
const langs: Record<string, Record<string, string>> = {};
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -162,21 +162,18 @@ async function start(): Promise<void> {
|
||||
// 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"));
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function loadLanguage(): Promise<void> {
|
||||
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);
|
||||
|
||||
13
yarn.lock
13
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"
|
||||
|
||||
Reference in New Issue
Block a user