diff --git a/docs/config.md b/docs/config.md index 8ca4ba4eb8b..6376b55f829 100644 --- a/docs/config.md +++ b/docs/config.md @@ -592,3 +592,4 @@ The following are undocumented or intended for developer use only. 2. `sync_timeline_limit` 3. `dangerously_allow_unsafe_and_insecure_passwords` 4. `latex_maths_delims`: An optional setting to override the default delimiters used for maths parsing. See https://github.com/matrix-org/matrix-react-sdk/pull/5939 for details. Only used when `feature_latex_maths` is enabled. +5. `modules`: An optional list of modules to load. This is used for testing and development purposes only. diff --git a/jest.config.ts b/jest.config.ts index b70b21bc979..ad31f2fecc4 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -38,6 +38,8 @@ const config: Config = { "^!!raw-loader!.*": "jest-raw-loader", "recorderWorkletFactory": "/__mocks__/empty.js", "^fetch-mock$": "/node_modules/fetch-mock", + // Requires ESM which is incompatible with our current Jest setup + "^@element-hq/element-web-module-api$": "/__mocks__/empty.js", }, transformIgnorePatterns: ["/node_modules/(?!(mime|matrix-js-sdk)).+$"], collectCoverageFrom: [ diff --git a/package.json b/package.json index 471cd928e47..d886ead129c 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", + "@element-hq/element-web-module-api": "^0.1.1", "@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 new file mode 100644 index 00000000000..e21b5c2d92b --- /dev/null +++ b/playwright/e2e/modules/loader.spec.ts @@ -0,0 +1,35 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("Module loading", () => { + test.use({ + displayName: "Manny", + }); + + test.describe("Example Module", () => { + test.use({ + config: { + modules: ["/modules/example-module.js"], + }, + page: async ({ page }, use) => { + await page.route("/modules/example-module.js", async (route) => { + await route.fulfill({ path: "playwright/sample-files/example-module.js" }); + }); + await use(page); + }, + }); + + 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!"); + }); + }); +}); diff --git a/playwright/sample-files/example-module.js b/playwright/sample-files/example-module.js new file mode 100644 index 00000000000..cb9b80a93bb --- /dev/null +++ b/playwright/sample-files/example-module.js @@ -0,0 +1,16 @@ +/* +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. +*/ + +export default class ExampleModule { + static moduleApiVersion = "^0.1.0"; + constructor(api) { + this.api = api; + } + async load() { + alert("Testing module loading successful!"); + } +} diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index c76c43f8298..40ac7a11616 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details. import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first import "@types/modernizr"; +import type { ModuleLoader } from "@element-hq/element-web-module-api"; import type { logger } from "matrix-js-sdk/src/logger"; import ContentMessages from "../ContentMessages"; import { IMatrixClientPeg } from "../MatrixClientPeg"; @@ -45,6 +46,7 @@ import { MatrixDispatcher } from "../dispatcher/dispatcher"; import { DeepReadonly } from "./common"; import MatrixChat from "../components/structures/MatrixChat"; import { InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; +import { ModuleApiType } from "../modules/Api.ts"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -122,6 +124,8 @@ declare global { mxRoomScrollStateStore?: RoomScrollStateStore; mxActiveWidgetStore?: ActiveWidgetStore; mxOnRecaptchaLoaded?: () => void; + mxModuleLoader: ModuleLoader; + mxModuleApi: ModuleApiType; // electron-only electron?: Electron; diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index bbb377e07b7..ad01dde930d 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -206,6 +206,8 @@ export interface IConfigOptions { policy_uri?: string; contacts?: string[]; }; + + modules?: string[]; } export interface ISsoRedirectOptions { diff --git a/src/customisations/Alias.ts b/src/customisations/Alias.ts index 6e5c60be586..742de9cd45b 100644 --- a/src/customisations/Alias.ts +++ b/src/customisations/Alias.ts @@ -6,19 +6,8 @@ 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. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function getDisplayAliasForAliasSet(canonicalAlias: string | null, altAliases: string[]): string | null { - // E.g. prefer one of the aliases over another - return null; -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IAliasCustomisations { - getDisplayAliasForAliasSet?: typeof getDisplayAliasForAliasSet; -} +import type { AliasCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the -// customisation points that make up `IAliasCustomisations`. -export default {} as IAliasCustomisations; +// customisation points that make up `AliasCustomisations`. +export default {} as AliasCustomisations; diff --git a/src/customisations/ChatExport.ts b/src/customisations/ChatExport.ts index ae979d4273c..59fcc09607d 100644 --- a/src/customisations/ChatExport.ts +++ b/src/customisations/ChatExport.ts @@ -6,20 +6,12 @@ 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 { ChatExportCustomisations } from "@element-hq/element-web-module-api"; import { ExportFormat, ExportType } from "../utils/exportUtils/exportUtils"; -export type ForceChatExportParameters = { - format?: ExportFormat; - range?: ExportType; - // must be < 10**8 - // only used when range is 'LastNMessages' - // default is 100 - numberOfMessages?: number; - includeAttachments?: boolean; - // maximum size of exported archive - // must be > 0 and < 8000 - sizeMb?: number; -}; +export type ForceChatExportParameters = ReturnType< + ChatExportCustomisations["getForceChatExportParameters"] +>; /** * Force parameters in room chat export @@ -30,15 +22,8 @@ const getForceChatExportParameters = (): ForceChatExportParameters => { return {}; }; -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IChatExportCustomisations { - getForceChatExportParameters: typeof getForceChatExportParameters; -} - // A real customisation module will define and export one or more of the // customisation points that make up `IChatExportCustomisations`. export default { getForceChatExportParameters, -} as IChatExportCustomisations; +} as ChatExportCustomisations; diff --git a/src/customisations/ComponentVisibility.ts b/src/customisations/ComponentVisibility.ts index c69b6ee185b..3468fd7fdbf 100644 --- a/src/customisations/ComponentVisibility.ts +++ b/src/customisations/ComponentVisibility.ts @@ -12,29 +12,7 @@ Please see LICENSE files in the repository root for full details. // Populate this class with the details of your customisations when copying it. -import { UIComponent } from "../settings/UIFeature"; - -/** - * Determines whether or not the active MatrixClient user should be able to use - * the given UI component. If shown, the user might still not be able to use the - * component depending on their contextual permissions. For example, invite options - * might be shown to the user but they won't have permission to invite users to - * the current room: the button will appear disabled. - * @param {UIComponent} component The component to check visibility for. - * @returns {boolean} True (default) if the user is able to see the component, false - * otherwise. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function shouldShowComponent(component: UIComponent): boolean { - return true; // default to visible -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IComponentVisibilityCustomisations { - shouldShowComponent?: typeof shouldShowComponent; -} +import type { ComponentVisibilityCustomisations as IComponentVisibilityCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the // customisation points that make up the interface above. diff --git a/src/customisations/Directory.ts b/src/customisations/Directory.ts index c5536801258..a85283774fb 100644 --- a/src/customisations/Directory.ts +++ b/src/customisations/Directory.ts @@ -6,19 +6,8 @@ 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. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function requireCanonicalAliasAccessToPublish(): boolean { - // Some environments may not care about this requirement and could return false - return true; -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IDirectoryCustomisations { - requireCanonicalAliasAccessToPublish?: typeof requireCanonicalAliasAccessToPublish; -} +import type { DirectoryCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the // customisation points that make up `IDirectoryCustomisations`. -export default {} as IDirectoryCustomisations; +export default {} as DirectoryCustomisations; diff --git a/src/customisations/Lifecycle.ts b/src/customisations/Lifecycle.ts index 2873093414e..e8e3cca38ad 100644 --- a/src/customisations/Lifecycle.ts +++ b/src/customisations/Lifecycle.ts @@ -6,18 +6,8 @@ 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. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function onLoggedOutAndStorageCleared(): void { - // E.g. redirect user or call other APIs after logout -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface ILifecycleCustomisations { - onLoggedOutAndStorageCleared?: typeof onLoggedOutAndStorageCleared; -} +import type { LifecycleCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the // customisation points that make up `ILifecycleCustomisations`. -export default {} as ILifecycleCustomisations; +export default {} as LifecycleCustomisations; diff --git a/src/customisations/Media.ts b/src/customisations/Media.ts index d7b367c5843..2fa92b02e0b 100644 --- a/src/customisations/Media.ts +++ b/src/customisations/Media.ts @@ -10,6 +10,7 @@ import { MatrixClient, parseErrorResponse, ResizeMethod } from "matrix-js-sdk/sr import { MediaEventContent } from "matrix-js-sdk/src/types"; import { Optional } from "matrix-events-sdk"; +import type { MediaCustomisations, Media } from "@element-hq/element-web-module-api"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { IPreparedMedia, prepEventContentAsMedia } from "./models/IMediaEventContent"; import { UserFriendlyError } from "../languageHandler"; @@ -25,7 +26,7 @@ import { UserFriendlyError } from "../languageHandler"; * A media object is a representation of a "source media" and an optional * "thumbnail media", derived from event contents or external sources. */ -export class Media { +class MediaImplementation implements Media { private client: MatrixClient; // Per above, this constructor signature can be whatever is helpful for you. @@ -149,22 +150,27 @@ export class Media { } } +export type { Media }; + +type BaseMedia = MediaCustomisations, MatrixClient, IPreparedMedia>; + /** * Creates a media object from event content. * @param {MediaEventContent} content The event content. - * @param {MatrixClient} client? Optional client to use. - * @returns {Media} The media object. + * @param {MatrixClient} client Optional client to use. + * @returns {MediaImplementation} The media object. */ -export function mediaFromContent(content: Partial, client?: MatrixClient): Media { - return new Media(prepEventContentAsMedia(content), client); -} +export const mediaFromContent: BaseMedia["mediaFromContent"] = ( + content: Partial, + client?: MatrixClient, +): Media => new MediaImplementation(prepEventContentAsMedia(content), client); /** * Creates a media object from an MXC URI. * @param {string} mxc The MXC URI. - * @param {MatrixClient} client? Optional client to use. - * @returns {Media} The media object. + * @param {MatrixClient} client Optional client to use. + * @returns {MediaImplementation} The media object. */ -export function mediaFromMxc(mxc?: string, client?: MatrixClient): Media { +export const mediaFromMxc: BaseMedia["mediaFromMxc"] = (mxc?: string, client?: MatrixClient): Media => { return mediaFromContent({ url: mxc }, client); -} +}; diff --git a/src/customisations/RoomList.ts b/src/customisations/RoomList.ts index a62e1bf6fca..2a3d4e6c410 100644 --- a/src/customisations/RoomList.ts +++ b/src/customisations/RoomList.ts @@ -8,31 +8,10 @@ import { Room } from "matrix-js-sdk/src/matrix"; -// Populate this file with the details of your customisations when copying it. +import type { RoomListCustomisations as IRoomListCustomisations } from "@element-hq/element-web-module-api"; -/** - * Determines if a room is visible in the room list or not. By default, - * all rooms are visible. Where special handling is performed by Element, - * those rooms will not be able to override their visibility in the room - * list - Element will make the decision without calling this function. - * - * This function should be as fast as possible to avoid slowing down the - * client. - * @param {Room} room The room to check the visibility of. - * @returns {boolean} True if the room should be visible, false otherwise. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function isRoomVisible(room: Room): boolean { - return true; -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IRoomListCustomisations { - isRoomVisible?: typeof isRoomVisible; -} +// Populate this file with the details of your customisations when copying it. // A real customisation module will define and export one or more of the // customisation points that make up the interface above. -export const RoomListCustomisations: IRoomListCustomisations = {}; +export const RoomListCustomisations: IRoomListCustomisations = {}; diff --git a/src/customisations/UserIdentifier.ts b/src/customisations/UserIdentifier.ts index cc36a1d8c7f..9c96a80fadf 100644 --- a/src/customisations/UserIdentifier.ts +++ b/src/customisations/UserIdentifier.ts @@ -6,6 +6,8 @@ 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 { UserIdentifierCustomisations } from "@element-hq/element-web-module-api"; + /** * Customise display of the user identifier * hide userId for guests, display 3pid @@ -19,15 +21,8 @@ function getDisplayUserIdentifier( return userId; } -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IUserIdentifierCustomisations { - getDisplayUserIdentifier: typeof getDisplayUserIdentifier; -} - // A real customisation module will define and export one or more of the // customisation points that make up `IUserIdentifierCustomisations`. export default { getDisplayUserIdentifier, -} as IUserIdentifierCustomisations; +} as UserIdentifierCustomisations; diff --git a/src/customisations/WidgetPermissions.ts b/src/customisations/WidgetPermissions.ts index 9734fee9d01..b751dcddcff 100644 --- a/src/customisations/WidgetPermissions.ts +++ b/src/customisations/WidgetPermissions.ts @@ -9,33 +9,8 @@ // Populate this class with the details of your customisations when copying it. import { Capability, Widget } from "matrix-widget-api"; -/** - * Approves the widget for capabilities that it requested, if any can be - * approved. Typically this will be used to give certain widgets capabilities - * without having to prompt the user to approve them. This cannot reject - * capabilities that Element will be automatically granting, such as the - * ability for Jitsi widgets to stay on screen - those will be approved - * regardless. - * @param {Widget} widget The widget to approve capabilities for. - * @param {Set} requestedCapabilities The capabilities the widget requested. - * @returns {Set} Resolves to the capabilities that are approved for use - * by the widget. If none are approved, this should return an empty Set. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function preapproveCapabilities( - widget: Widget, - requestedCapabilities: Set, -): Promise> { - return new Set(); // no additional capabilities approved -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IWidgetPermissionCustomisations { - preapproveCapabilities?: typeof preapproveCapabilities; -} +import type { WidgetPermissionsCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the // customisation points that make up the interface above. -export const WidgetPermissionCustomisations: IWidgetPermissionCustomisations = {}; +export const WidgetPermissionCustomisations: WidgetPermissionsCustomisations = {}; diff --git a/src/customisations/WidgetVariables.ts b/src/customisations/WidgetVariables.ts index 6a6651dd102..067d780ecda 100644 --- a/src/customisations/WidgetVariables.ts +++ b/src/customisations/WidgetVariables.ts @@ -7,41 +7,8 @@ */ // Populate this class with the details of your customisations when copying it. -import { ITemplateParams } from "matrix-widget-api"; - -/** - * Provides a partial set of the variables needed to render any widget. If - * variables are missing or not provided then they will be filled with the - * application-determined defaults. - * - * This will not be called until after isReady() resolves. - * @returns {Partial>} The variables. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function provideVariables(): Partial> { - return {}; -} - -/** - * Resolves to whether or not the customisation point is ready for variables - * to be provided. This will block widgets being rendered. - * @returns {Promise} Resolves when ready. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function isReady(): Promise { - return; // default no waiting -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IWidgetVariablesCustomisations { - provideVariables?: typeof provideVariables; - - // If not provided, the app will assume that the customisation is always ready. - isReady?: typeof isReady; -} +import type { WidgetVariablesCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the // customisation points that make up the interface above. -export const WidgetVariableCustomisations: IWidgetVariablesCustomisations = {}; +export const WidgetVariableCustomisations: WidgetVariablesCustomisations = {}; diff --git a/src/modules/Api.ts b/src/modules/Api.ts new file mode 100644 index 00000000000..ad870888401 --- /dev/null +++ b/src/modules/Api.ts @@ -0,0 +1,75 @@ +/* +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 { Api, RuntimeModuleConstructor, Config } from "@element-hq/element-web-module-api"; +import { ModuleRunner } from "./ModuleRunner.ts"; +import AliasCustomisations from "../customisations/Alias.ts"; +import { RoomListCustomisations } from "../customisations/RoomList.ts"; +import ChatExportCustomisations from "../customisations/ChatExport.ts"; +import { ComponentVisibilityCustomisations } from "../customisations/ComponentVisibility.ts"; +import DirectoryCustomisations from "../customisations/Directory.ts"; +import LifecycleCustomisations from "../customisations/Lifecycle.ts"; +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"; + +const legacyCustomisationsFactory = (baseCustomisations: T) => { + let used = false; + return (customisations: T) => { + if (used) throw new Error("Legacy customisations can only be registered by one module"); + Object.assign(baseCustomisations, customisations); + used = true; + }; +}; + +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. + */ +class ModuleApi implements Api { + /* eslint-disable @typescript-eslint/naming-convention */ + public async _registerLegacyModule(LegacyModule: RuntimeModuleConstructor): Promise { + ModuleRunner.instance.registerModule((api) => new LegacyModule(api)); + } + public readonly _registerLegacyAliasCustomisations = legacyCustomisationsFactory(AliasCustomisations); + public readonly _registerLegacyChatExportCustomisations = legacyCustomisationsFactory(ChatExportCustomisations); + public readonly _registerLegacyComponentVisibilityCustomisations = legacyCustomisationsFactory( + ComponentVisibilityCustomisations, + ); + public readonly _registerLegacyDirectoryCustomisations = legacyCustomisationsFactory(DirectoryCustomisations); + public readonly _registerLegacyLifecycleCustomisations = legacyCustomisationsFactory(LifecycleCustomisations); + public readonly _registerLegacyMediaCustomisations = legacyCustomisationsFactory(MediaCustomisations); + public readonly _registerLegacyRoomListCustomisations = legacyCustomisationsFactory(RoomListCustomisations); + public readonly _registerLegacyUserIdentifierCustomisations = + legacyCustomisationsFactory(UserIdentifierCustomisations); + public readonly _registerLegacyWidgetPermissionsCustomisations = + legacyCustomisationsFactory(WidgetPermissionCustomisations); + public readonly _registerLegacyWidgetVariablesCustomisations = + legacyCustomisationsFactory(WidgetVariableCustomisations); + /* eslint-enable @typescript-eslint/naming-convention */ + + public readonly config = new ConfigApi(); +} + +export type ModuleApiType = ModuleApi; + +if (!window.mxModuleApi) { + window.mxModuleApi = new ModuleApi(); +} +export default window.mxModuleApi; diff --git a/src/vector/app.tsx b/src/vector/app.tsx index 2ae9e6fa03f..b30496bacb6 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -30,10 +30,6 @@ import { parseQs } from "./url_utils"; import { getInitialScreenAfterLogin, getScreenFromLocation, init as initRouting, onNewScreen } from "./routing"; import { UserFriendlyError } from "../languageHandler"; -// add React and ReactPerf to the global namespace, to make them easier to access via the console -// this incidentally means we can forget our React imports in JSX files without penalty. -window.React = React; - logger.log(`Application is running in ${process.env.NODE_ENV} mode`); window.matrixLogger = logger; diff --git a/src/vector/index.ts b/src/vector/index.ts index c398c0b7886..af557c241bd 100644 --- a/src/vector/index.ts +++ b/src/vector/index.ts @@ -114,6 +114,7 @@ async function start(): Promise { loadTheme, loadApp, loadModules, + loadPlugins, showError, showIncompatibleBrowser, _t, @@ -159,10 +160,12 @@ async function start(): Promise { // now that the config is ready, try to persist logs const persistLogsPromise = setupLogStorage(); - // Load modules before language to ensure any custom translations are respected, and any app + // 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(); @@ -215,6 +218,7 @@ async function start(): Promise { // app load critical path starts here // assert things started successfully // ################################## + await loadPluginsPromise; await loadModulesPromise; await loadThemePromise; await loadLanguagePromise; diff --git a/src/vector/init.tsx b/src/vector/init.tsx index bb4a128d80e..c73504dd226 100644 --- a/src/vector/init.tsx +++ b/src/vector/init.tsx @@ -11,6 +11,7 @@ Please see LICENSE files in the repository root for full details. import { createRoot } from "react-dom/client"; import React, { StrictMode } from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import { ModuleLoader } from "@element-hq/element-web-module-api"; import * as languageHandler from "../languageHandler"; import SettingsStore from "../settings/SettingsStore"; @@ -23,6 +24,7 @@ import ElectronPlatform from "./platform/ElectronPlatform"; import PWAPlatform from "./platform/PWAPlatform"; import WebPlatform from "./platform/WebPlatform"; import { initRageshake, initRageshakeStore } from "./rageshakesetup"; +import ModuleApi from "../modules/Api.ts"; export const rageshakePromise = initRageshake(); @@ -124,6 +126,9 @@ export async function showIncompatibleBrowser(onAccept: () => void): Promise { const { INSTALLED_MODULES } = await import("../modules.js"); for (const InstalledModule of INSTALLED_MODULES) { @@ -131,6 +136,24 @@ export async function loadModules(): Promise { } } +export async function loadPlugins(): Promise { + // Add React to the global namespace, this is part of the new Module API contract to avoid needing + // every single module to ship its own copy of React. This also makes it easier to access via the console + // and incidentally means we can forget our React imports in JSX files without penalty. + window.React = React; + + const modules = SdkConfig.get("modules"); + if (!modules?.length) return; + const moduleLoader = new ModuleLoader(ModuleApi); + window.mxModuleLoader = moduleLoader; + for (const src of modules) { + // We need to instruct webpack to not mangle this import as it is not available at compile time + const module = await import(/* webpackIgnore: true */ src); + await moduleLoader.load(module); + } + await moduleLoader.start(); +} + export { _t } from "../languageHandler"; export { extractErrorMessageFromError } from "../components/views/dialogs/ErrorDialog"; diff --git a/yarn.lock b/yarn.lock index 9fdb3d55c7b..13e7c3c573f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1533,6 +1533,11 @@ resolved "https://registry.yarnpkg.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz#519c1549b0e147759e7825701ecffd25e5819f7b" integrity sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg== +"@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== + "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"