Support for custom message components via Module API (#30074)

* Add new custom component api.

* Remove context menu, refactor

* fix types

* Add a test for custom modules.

* tidy

* Rewrite for new API

* Update tests

* lint

* Allow passing in props to original component

* Add hinting

* Update tests to be complete

* lint a bit more

* update docstring

* update @element-hq/element-web-module-api to 1.1.0

* fix types

* updates

* hide jump to bottom button that was causing flakes

* lint

* lint

* Use module matrix event interface instead.

* update to new module sdk

* adapt custom module sample

* Issues caught by Sonar

* lint

* fix issues

* make the comment make sense

* fix import
This commit is contained in:
Will Hunt
2025-06-23 12:55:22 +01:00
committed by GitHub
parent ac9c6f11fb
commit 0edaef3f7c
13 changed files with 389 additions and 50 deletions

View File

@@ -6,8 +6,8 @@ Please see LICENSE files in the repository root for full details.
*/
import { createRoot, type Root } from "react-dom/client";
import { type Api, type RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
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";
@@ -21,6 +21,7 @@ import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissi
import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts";
import { ConfigApi } from "./ConfigApi.ts";
import { I18nApi } from "./I18nApi.ts";
import { CustomComponentsApi } from "./customComponentApi.ts";
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false;
@@ -58,6 +59,7 @@ class ModuleApi implements Api {
public readonly config = new ConfigApi();
public readonly i18n = new I18nApi();
public readonly customComponents = new CustomComponentsApi();
public readonly rootNode = document.getElementById("matrixchat")!;
public createRoot(element: Element): Root {

View File

@@ -0,0 +1,126 @@
/*
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 MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import type {
CustomComponentsApi as ICustomComponentsApi,
CustomMessageRenderFunction,
CustomMessageComponentProps as ModuleCustomMessageComponentProps,
OriginalComponentProps,
CustomMessageRenderHints,
MatrixEvent as ModuleMatrixEvent,
} from "@element-hq/element-web-module-api";
import type React from "react";
type EventTypeOrFilter = Parameters<ICustomComponentsApi["registerMessageRenderer"]>[0];
type EventRenderer = {
eventTypeOrFilter: EventTypeOrFilter;
renderer: CustomMessageRenderFunction;
hints: CustomMessageRenderHints;
};
interface CustomMessageComponentProps extends Omit<ModuleCustomMessageComponentProps, "mxEvent"> {
mxEvent: MatrixEvent;
}
export class CustomComponentsApi implements ICustomComponentsApi {
/**
* Convert a matrix-js-sdk event into a ModuleMatrixEvent.
* @param mxEvent
* @returns An event object, or `null` if the event was not a message event.
*/
private static getModuleMatrixEvent(mxEvent: MatrixEvent): ModuleMatrixEvent | null {
const eventId = mxEvent.getId();
const roomId = mxEvent.getRoomId();
const sender = mxEvent.sender;
// Typically we wouldn't expect messages without these keys to be rendered
// by the timeline, but for the sake of type safety.
if (!eventId || !roomId || !sender) {
// Not a message event.
return null;
}
return {
content: mxEvent.getContent(),
eventId,
originServerTs: mxEvent.getTs(),
roomId,
sender: sender.userId,
stateKey: mxEvent.getStateKey(),
type: mxEvent.getType(),
unsigned: mxEvent.getUnsigned(),
};
}
private readonly registeredMessageRenderers: EventRenderer[] = [];
public registerMessageRenderer(
eventTypeOrFilter: EventTypeOrFilter,
renderer: CustomMessageRenderFunction,
hints: CustomMessageRenderHints = {},
): void {
this.registeredMessageRenderers.push({ eventTypeOrFilter: eventTypeOrFilter, renderer, hints });
}
/**
* Select the correct renderer based on the event information.
* @param mxEvent The message event being rendered.
* @returns The registered renderer.
*/
private selectRenderer(mxEvent: ModuleMatrixEvent): EventRenderer | undefined {
return this.registeredMessageRenderers.find((renderer) => {
if (typeof renderer.eventTypeOrFilter === "string") {
return renderer.eventTypeOrFilter === mxEvent.type;
} else {
try {
return renderer.eventTypeOrFilter(mxEvent);
} catch (ex) {
logger.warn("Message renderer failed to process filter", ex);
return false; // Skip erroring renderers.
}
}
});
}
/**
* Render the component for a message event.
* @param props Props to be passed to the custom renderer.
* @param originalComponent Function that will be rendered if no custom renderers are present, or as a child of a custom component.
* @returns A component if a custom renderer exists, or originalComponent returns a value. Otherwise null.
*/
public renderMessage(
props: CustomMessageComponentProps,
originalComponent?: (props?: OriginalComponentProps) => React.JSX.Element,
): React.JSX.Element | null {
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(props.mxEvent);
const renderer = moduleEv && this.selectRenderer(moduleEv);
if (renderer) {
try {
return renderer.renderer({ ...props, mxEvent: moduleEv }, originalComponent);
} catch (ex) {
logger.warn("Message renderer failed to render", ex);
// Fall through to original component. If the module encounters an error we still want to display messages to the user!
}
}
return originalComponent?.() ?? null;
}
/**
* Get hints about an message before rendering it.
* @param mxEvent The message event being rendered.
* @returns A component if a custom renderer exists, or originalComponent returns a value. Otherwise null.
*/
public getHintsForMessage(mxEvent: MatrixEvent): CustomMessageRenderHints | null {
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(mxEvent);
const renderer = moduleEv && this.selectRenderer(moduleEv);
if (renderer) {
return renderer.hints;
}
return null;
}
}