From 146e4772ac12f240a54f969a2beec3f96b8dc44b Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 15 Oct 2025 10:20:48 +0100 Subject: [PATCH] Change module API to be an instance getter (#31025) * Change module API to be an instance getter Helps with circular dependencies by not instantating the module API on the initial evaluation of the files. * Add basic test * add another test --- src/components/views/rooms/RoomPreviewBar.tsx | 4 +- src/events/EventTileFactory.tsx | 14 ++--- src/hooks/useDownloadMedia.ts | 4 +- src/modules/Api.ts | 17 +++--- src/utils/EventUtils.ts | 4 +- src/vector/init.tsx | 4 +- .../views/rooms/RoomPreviewBar-test.tsx | 4 +- .../events/EventTileFactory-test.ts | 54 +++++++++++++++++++ 8 files changed, 82 insertions(+), 23 deletions(-) diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index b5c7e08154..fe0a64b162 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -31,7 +31,7 @@ import { UIFeature } from "../../../settings/UIFeature"; import { ModuleRunner } from "../../../modules/ModuleRunner"; import { Icon as AskToJoinIcon } from "../../../../res/img/element-icons/ask-to-join.svg"; import Field from "../elements/Field"; -import ModuleApi from "../../../modules/Api.ts"; +import { ModuleApi } from "../../../modules/Api.ts"; const MemberEventHtmlReasonField = "io.element.html_reason"; @@ -750,7 +750,7 @@ class RoomPreviewBar extends React.Component { } const WrappedRoomPreviewBar = (props: IProps): JSX.Element => { - const moduleRenderer = ModuleApi.customComponents.roomPreviewBarRenderer; + const moduleRenderer = ModuleApi.instance.customComponents.roomPreviewBarRenderer; if (moduleRenderer) { return moduleRenderer( { diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index d732a38e49..e964325573 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -41,7 +41,7 @@ import HiddenBody from "../components/views/messages/HiddenBody"; import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { type IBodyProps } from "../components/views/messages/IBodyProps"; -import ModuleApi from "../modules/Api"; +import { ModuleApi } from "../modules/Api"; import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel"; import { TextualEventView } from "../../packages/shared-components/src/event-tiles/TextualEventView"; import { ElementCallEventType } from "../call-types"; @@ -266,7 +266,7 @@ export function renderTile( // If we don't have a factory for this event, attempt // to find a custom component that can render it. // Will return null if no custom component can render it. - return ModuleApi.customComponents.renderMessage({ + return ModuleApi.instance.customComponents.renderMessage({ mxEvent: props.mxEvent, }); } @@ -297,7 +297,7 @@ export function renderTile( case TimelineRenderingType.File: case TimelineRenderingType.Notification: case TimelineRenderingType.Thread: - return ModuleApi.customComponents.renderMessage( + return ModuleApi.instance.customComponents.renderMessage( { mxEvent: props.mxEvent, }, @@ -318,7 +318,7 @@ export function renderTile( }), ); default: - return ModuleApi.customComponents.renderMessage( + return ModuleApi.instance.customComponents.renderMessage( { mxEvent: props.mxEvent, }, @@ -363,7 +363,7 @@ export function renderReplyTile( // If we don't have a factory for this event, attempt // to find a custom component that can render it. // Will return null if no custom component can render it. - return ModuleApi.customComponents.renderMessage({ + return ModuleApi.instance.customComponents.renderMessage({ mxEvent: props.mxEvent, }); } @@ -384,7 +384,7 @@ export function renderReplyTile( permalinkCreator, } = props; - return ModuleApi.customComponents.renderMessage( + return ModuleApi.instance.customComponents.renderMessage( { mxEvent: props.mxEvent, }, @@ -429,7 +429,7 @@ export function haveRendererForEvent( // Check to see if we have any hints for this message, which indicates // there is a custom renderer for the event. - if (ModuleApi.customComponents.getHintsForMessage(mxEvent)) { + if (ModuleApi.instance.customComponents.getHintsForMessage(mxEvent)) { return true; } diff --git a/src/hooks/useDownloadMedia.ts b/src/hooks/useDownloadMedia.ts index eb0af954e0..ae2ca6c3ce 100644 --- a/src/hooks/useDownloadMedia.ts +++ b/src/hooks/useDownloadMedia.ts @@ -15,7 +15,7 @@ import { _t } from "../languageHandler"; import Modal from "../Modal"; import { FileDownloader } from "../utils/FileDownloader"; import { MediaEventHelper } from "../utils/MediaEventHelper"; -import ModuleApi from "../modules/Api"; +import { ModuleApi } from "../modules/Api"; export interface UseDownloadMediaReturn { download: () => Promise; @@ -34,7 +34,7 @@ export function useDownloadMedia(url: string, fileName?: string, mxEvent?: Matri useEffect(() => { if (!mxEvent) return; - const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent); + const hints = ModuleApi.instance.customComponents.getHintsForMessage(mxEvent); if (hints?.allowDownloadingMedia) { setCanDownload(false); hints diff --git a/src/modules/Api.ts b/src/modules/Api.ts index 1f72784bd6..49082daef2 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -39,7 +39,17 @@ const legacyCustomisationsFactory = (baseCustomisations: T) => /** * Implementation of the @element-hq/element-web-module-api runtime module API. */ -class ModuleApi implements Api { +export class ModuleApi implements Api { + private static _instance: ModuleApi; + + public static get instance(): ModuleApi { + if (!ModuleApi._instance) { + ModuleApi._instance = new ModuleApi(); + window.mxModuleApi = ModuleApi._instance; + } + return ModuleApi._instance; + } + /* eslint-disable @typescript-eslint/naming-convention */ public async _registerLegacyModule(LegacyModule: RuntimeModuleConstructor): Promise { ModuleRunner.instance.registerModule((api) => new LegacyModule(api)); @@ -77,8 +87,3 @@ class ModuleApi implements Api { } export type ModuleApiType = ModuleApi; - -if (!window.mxModuleApi) { - window.mxModuleApi = new ModuleApi(); -} -export default window.mxModuleApi; diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index ecaa7e06ec..b3ba86a914 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -30,7 +30,7 @@ import { type TimelineRenderingType } from "../contexts/RoomContext"; import { launchPollEditor } from "../components/views/messages/MPollBody"; import { Action } from "../dispatcher/actions"; import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; -import ModuleApi from "../modules/Api"; +import { ModuleApi } from "../modules/Api"; /** * Returns whether an event should allow actions like reply, reactions, edit, etc. @@ -78,7 +78,7 @@ export function canEditContent(matrixClient: MatrixClient, mxEvent: MatrixEvent) return false; } - if (ModuleApi.customComponents.getHintsForMessage(mxEvent)?.allowEditingEvent === false) { + if (ModuleApi.instance.customComponents.getHintsForMessage(mxEvent)?.allowEditingEvent === false) { return false; } diff --git a/src/vector/init.tsx b/src/vector/init.tsx index e481e34b97..6706b75672 100644 --- a/src/vector/init.tsx +++ b/src/vector/init.tsx @@ -25,7 +25,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"; +import { ModuleApi } from "../modules/Api.ts"; export const rageshakePromise = initRageshake(); @@ -145,7 +145,7 @@ export async function loadPlugins(): Promise { const modules = SdkConfig.get("modules"); if (!modules?.length) return; - const moduleLoader = new ModuleLoader(ModuleApi); + const moduleLoader = new ModuleLoader(ModuleApi.instance); 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 diff --git a/test/unit-tests/components/views/rooms/RoomPreviewBar-test.tsx b/test/unit-tests/components/views/rooms/RoomPreviewBar-test.tsx index a0c07a5b3f..5ee9d51cbb 100644 --- a/test/unit-tests/components/views/rooms/RoomPreviewBar-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomPreviewBar-test.tsx @@ -16,7 +16,7 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import RoomPreviewBar from "../../../../../src/components/views/rooms/RoomPreviewBar"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; -import ModuleApi from "../../../../../src/modules/Api.ts"; +import { ModuleApi } from "../../../../../src/modules/Api.ts"; jest.mock("../../../../../src/IdentityAuthClient", () => { return jest.fn().mockImplementation(() => { @@ -500,7 +500,7 @@ describe("", () => { }); it("should render Module roomPreviewBarRenderer if specified", () => { - jest.spyOn(ModuleApi.customComponents, "roomPreviewBarRenderer", "get").mockReturnValue(() => ( + jest.spyOn(ModuleApi.instance.customComponents, "roomPreviewBarRenderer", "get").mockReturnValue(() => ( <>Test component )); const { getByText } = render(); diff --git a/test/unit-tests/events/EventTileFactory-test.ts b/test/unit-tests/events/EventTileFactory-test.ts index 6a4b5fa355..b1ff4c57bf 100644 --- a/test/unit-tests/events/EventTileFactory-test.ts +++ b/test/unit-tests/events/EventTileFactory-test.ts @@ -13,10 +13,13 @@ import { JSONEventFactory, MessageEventFactory, pickFactory, + renderTile, RoomCreateEventFactory, } from "../../../src/events/EventTileFactory"; import SettingsStore from "../../../src/settings/SettingsStore"; import { createTestClient, mkEvent } from "../../test-utils"; +import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; +import { ModuleApi } from "../../../src/modules/Api"; const roomId = "!room:example.com"; @@ -205,3 +208,54 @@ describe("pickFactory", () => { }); }); }); + +describe("renderTile", () => { + let client: MatrixClient; + + beforeEach(() => { + client = createTestClient(); + }); + + it("rendering a tile defers to the module API", () => { + ModuleApi.instance.customComponents.renderMessage = jest.fn(); + + const messageEvent = mkEvent({ + event: true, + type: EventType.RoomMessage, + user: client.getUserId()!, + room: roomId, + content: { + msgtype: MsgType.Text, + }, + }); + + renderTile(TimelineRenderingType.Room, { mxEvent: messageEvent, showHiddenEvents: false }, client); + + expect(ModuleApi.instance.customComponents.renderMessage).toHaveBeenCalledWith( + { + mxEvent: messageEvent, + }, + expect.any(Function), + ); + }); + + it("rendering a tile for a message of unknown type defers to the module API", () => { + ModuleApi.instance.customComponents.renderMessage = jest.fn(); + + const messageEvent = mkEvent({ + event: true, + type: "weird.type", + user: client.getUserId()!, + room: roomId, + content: { + msgtype: MsgType.Text, + }, + }); + + renderTile(TimelineRenderingType.Room, { mxEvent: messageEvent, showHiddenEvents: false }, client); + + expect(ModuleApi.instance.customComponents.renderMessage).toHaveBeenCalledWith({ + mxEvent: messageEvent, + }); + }); +});