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:
@@ -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 {
|
||||
|
||||
126
src/modules/customComponentApi.ts
Normal file
126
src/modules/customComponentApi.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user