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
This commit is contained in:
David Baker 2025-10-15 10:20:48 +01:00 committed by GitHub
parent 6cfe197a38
commit 146e4772ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 82 additions and 23 deletions

View File

@ -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<IProps, IState> {
}
const WrappedRoomPreviewBar = (props: IProps): JSX.Element => {
const moduleRenderer = ModuleApi.customComponents.roomPreviewBarRenderer;
const moduleRenderer = ModuleApi.instance.customComponents.roomPreviewBarRenderer;
if (moduleRenderer) {
return moduleRenderer(
{

View File

@ -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;
}

View File

@ -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<void>;
@ -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

View File

@ -39,7 +39,17 @@ const legacyCustomisationsFactory = <T extends object>(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<void> {
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;

View File

@ -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;
}

View File

@ -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<void> {
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

View File

@ -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("<RoomPreviewBar />", () => {
});
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(<RoomPreviewBar />);

View File

@ -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,
});
});
});