From 0edaef3f7cf1fe33e4aa5126c63f47cae5e3dc75 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Mon, 23 Jun 2025 12:55:22 +0100 Subject: [PATCH] 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 --- package.json | 2 +- .../e2e/modules/custom-component.spec.ts | 112 +++++++++++++++ .../sample-files/custom-component-module.js | 55 ++++++++ ...om-component-crash-handle-filter-linux.png | Bin 0 -> 5872 bytes ...-component-crash-handle-renderer-linux.png | Bin 0 -> 4772 bytes ...stom-component-tile-fall-through-linux.png | Bin 0 -> 5930 bytes .../custom-component-tile-linux.png | Bin 0 -> 6174 bytes .../custom-component-tile-original-linux.png | Bin 0 -> 4717 bytes src/events/EventTileFactory.tsx | 127 ++++++++++++------ src/modules/Api.ts | 4 +- src/modules/customComponentApi.ts | 126 +++++++++++++++++ src/utils/EventUtils.ts | 5 + yarn.lock | 8 +- 13 files changed, 389 insertions(+), 50 deletions(-) create mode 100644 playwright/e2e/modules/custom-component.spec.ts create mode 100644 playwright/sample-files/custom-component-module.js create mode 100644 playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-filter-linux.png create mode 100644 playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-renderer-linux.png create mode 100644 playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png create mode 100644 playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png create mode 100644 playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png create mode 100644 src/modules/customComponentApi.ts diff --git a/package.json b/package.json index fe08074cae..da69d64e73 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", - "@element-hq/element-web-module-api": "1.0.0", + "@element-hq/element-web-module-api": "1.2.0", "@fontsource/inconsolata": "^5", "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", diff --git a/playwright/e2e/modules/custom-component.spec.ts b/playwright/e2e/modules/custom-component.spec.ts new file mode 100644 index 0000000000..b0cdfe5855 --- /dev/null +++ b/playwright/e2e/modules/custom-component.spec.ts @@ -0,0 +1,112 @@ +/* +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 Page } from "@playwright/test"; + +import { test, expect } from "../../element-web-test"; + +const screenshotOptions = (page: Page) => ({ + mask: [page.locator(".mx_MessageTimestamp")], + // Hide the jump to bottom button in the timeline to avoid flakiness + // Exclude timestamp and read marker from snapshot + css: ` + .mx_JumpToBottomButton { + display: none !important; + } + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, +}); +test.describe("Custom Component API", () => { + test.use({ + displayName: "Manny", + config: { + modules: ["/modules/custom-component-module.js"], + }, + page: async ({ page }, use) => { + await page.route("/modules/custom-component-module.js", async (route) => { + await route.fulfill({ path: "playwright/sample-files/custom-component-module.js" }); + }); + await use(page); + }, + room: async ({ page, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name: "TestRoom" }); + await use({ roomId }); + }, + }); + test.describe("basic functionality", () => { + test( + "should replace the render method of a textual event", + { tag: "@screenshot" }, + async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Simple message"); + await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot( + "custom-component-tile.png", + screenshotOptions(page), + ); + }, + ); + test( + "should fall through if one module does not render a component", + { tag: "@screenshot" }, + async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Fall through here"); + await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot( + "custom-component-tile-fall-through.png", + screenshotOptions(page), + ); + }, + ); + test( + "should render the original content of a textual event conditionally", + { tag: "@screenshot" }, + async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Do not replace me"); + await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot( + "custom-component-tile-original.png", + screenshotOptions(page), + ); + }, + ); + test("should disallow editing when the allowEditingEvent hint is set to false", async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Do not show edits"); + await page.getByText("Do not show edits").hover(); + await expect( + await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }), + ).not.toBeVisible(); + }); + test( + "should render the next registered component if the filter function throws", + { tag: "@screenshot" }, + async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Crash the filter!"); + await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot( + "custom-component-crash-handle-filter.png", + screenshotOptions(page), + ); + }, + ); + test( + "should render original component if the render function throws", + { tag: "@screenshot" }, + async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Crash the renderer!"); + await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot( + "custom-component-crash-handle-renderer.png", + screenshotOptions(page), + ); + }, + ); + }); +}); diff --git a/playwright/sample-files/custom-component-module.js b/playwright/sample-files/custom-component-module.js new file mode 100644 index 0000000000..8d4d1b3c1f --- /dev/null +++ b/playwright/sample-files/custom-component-module.js @@ -0,0 +1,55 @@ +/* +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 CustomComponentModule { + static moduleApiVersion = "^1.2.0"; + constructor(api) { + this.api = api; + this.api.customComponents.registerMessageRenderer( + (evt) => evt.content.body === "Do not show edits", + (_props, originalComponent) => { + return originalComponent(); + }, + { allowEditingEvent: false }, + ); + this.api.customComponents.registerMessageRenderer( + (evt) => evt.content.body === "Fall through here", + (props) => { + const body = props.mxEvent.content.body; + return `Fallthrough text for ${body}`; + }, + ); + this.api.customComponents.registerMessageRenderer( + (evt) => { + if (evt.content.body === "Crash the filter!") { + throw new Error("Fail test!"); + } + return false; + }, + () => { + return `Should not render!`; + }, + ); + this.api.customComponents.registerMessageRenderer( + (evt) => evt.content.body === "Crash the renderer!", + () => { + throw new Error("Fail test!"); + }, + ); + // Order is specific here to avoid this overriding the other renderers + this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => { + const body = props.mxEvent.content.body; + if (body === "Do not replace me") { + return originalComponent(); + } else if (body === "Fall through here") { + return null; + } + return `Custom text for ${body}`; + }); + } + async load() {} +} diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-filter-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-filter-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..b144ca6a5ecd5a82b5437f939b9bdd40e257f43e GIT binary patch literal 5872 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 Q{(0l3EUy8qkTVbdAEtwtegFUf literal 0 HcmV?d00001 diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..7c5d6b66e6d2d753dbeee10cceba0445dd1e6782 GIT binary patch literal 6174 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 KD|+(s?f(EcI1Dxb literal 0 HcmV?d00001 diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index f1fc224471..0d91ded160 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -43,6 +43,7 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { ElementCall } from "../models/Call"; import { type IBodyProps } from "../components/views/messages/IBodyProps"; +import ModuleApi from "../modules/Api"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps @@ -257,7 +258,14 @@ export function renderTile( cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing const factory = pickFactory(props.mxEvent, cli, showHiddenEvents); - if (!factory) return undefined; + if (!factory) { + // 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({ + mxEvent: props.mxEvent, + }); + } // Note that we split off the ones we actually care about here just to be sure that we're // not going to accidentally send things we shouldn't from lazy callers. Eg: EventTile's @@ -284,36 +292,48 @@ export function renderTile( case TimelineRenderingType.File: case TimelineRenderingType.Notification: case TimelineRenderingType.Thread: - // We only want a subset of props, so we don't end up causing issues for downstream components. - return factory(props.ref, { - mxEvent, - highlights, - highlightLink, - showUrlPreview, - editState, - replacingEventId, - getRelationsForEvent, - isSeeingThroughMessageHiddenForModeration, - permalinkCreator, - inhibitInteraction, - }); + return ModuleApi.customComponents.renderMessage( + { + mxEvent: props.mxEvent, + }, + (origProps) => + factory(props.ref, { + // We only want a subset of props, so we don't end up causing issues for downstream components. + mxEvent, + highlights, + highlightLink, + showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview, + editState, + replacingEventId, + getRelationsForEvent, + isSeeingThroughMessageHiddenForModeration, + permalinkCreator, + inhibitInteraction, + }), + ); default: - // NEARLY ALL THE OPTIONS! - return factory(ref, { - mxEvent, - forExport, - replacingEventId, - editState, - highlights, - highlightLink, - showUrlPreview, - permalinkCreator, - callEventGrouper, - getRelationsForEvent, - isSeeingThroughMessageHiddenForModeration, - timestamp, - inhibitInteraction, - }); + return ModuleApi.customComponents.renderMessage( + { + mxEvent: props.mxEvent, + }, + (origProps) => + factory(ref, { + // NEARLY ALL THE OPTIONS! + mxEvent, + forExport, + replacingEventId, + editState, + highlights, + highlightLink, + showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview, + permalinkCreator, + callEventGrouper, + getRelationsForEvent, + isSeeingThroughMessageHiddenForModeration, + timestamp, + inhibitInteraction, + }), + ); } } @@ -332,7 +352,14 @@ export function renderReplyTile( cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing const factory = pickFactory(props.mxEvent, cli, showHiddenEvents); - if (!factory) return undefined; + if (!factory) { + // 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({ + mxEvent: props.mxEvent, + }); + } // See renderTile() for why we split off so much const { @@ -350,19 +377,25 @@ export function renderReplyTile( permalinkCreator, } = props; - return factory(ref, { - mxEvent, - highlights, - highlightLink, - showUrlPreview, - overrideBodyTypes, - overrideEventTypes, - replacingEventId, - maxImageHeight, - getRelationsForEvent, - isSeeingThroughMessageHiddenForModeration, - permalinkCreator, - }); + return ModuleApi.customComponents.renderMessage( + { + mxEvent: props.mxEvent, + }, + (origProps) => + factory(ref, { + mxEvent, + highlights, + highlightLink, + showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview, + overrideBodyTypes, + overrideEventTypes, + replacingEventId, + maxImageHeight, + getRelationsForEvent, + isSeeingThroughMessageHiddenForModeration, + permalinkCreator, + }), + ); } // XXX: this'll eventually be dynamic based on the fields once we have extensible event types @@ -386,6 +419,12 @@ export function haveRendererForEvent( return false; } + // 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)) { + return true; + } + // No tile for replacement events since they update the original tile if (mxEvent.isRelation(RelationType.Replace)) return false; diff --git a/src/modules/Api.ts b/src/modules/Api.ts index 23abadf529..db7dd80334 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -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 = (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 { diff --git a/src/modules/customComponentApi.ts b/src/modules/customComponentApi.ts new file mode 100644 index 0000000000..ce75a70507 --- /dev/null +++ b/src/modules/customComponentApi.ts @@ -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[0]; + +type EventRenderer = { + eventTypeOrFilter: EventTypeOrFilter; + renderer: CustomMessageRenderFunction; + hints: CustomMessageRenderHints; +}; + +interface CustomMessageComponentProps extends Omit { + 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; + } +} diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 9c387b16b0..ecaa7e06ec 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -30,6 +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"; /** * Returns whether an event should allow actions like reply, reactions, edit, etc. @@ -77,6 +78,10 @@ export function canEditContent(matrixClient: MatrixClient, mxEvent: MatrixEvent) return false; } + if (ModuleApi.customComponents.getHintsForMessage(mxEvent)?.allowEditingEvent === false) { + return false; + } + const { msgtype, body } = mxEvent.getOriginalContent(); return ( M_POLL_START.matches(mxEvent.getType()) || diff --git a/yarn.lock b/yarn.lock index c8d095309b..3704b73d60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1672,10 +1672,10 @@ resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.12.2.tgz#b6b6b7df69369b3088960b79591ce1bfd2b84a1a" integrity sha512-2u5/bOARcjc5TFq4929x1R0tvsNbeVA58FBtiW05GlIJCapxzPSOeeGhbqEcJ1TW3/hLGpiKMcw0QwRBQVNzQA== -"@element-hq/element-web-module-api@1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.0.0.tgz#df09108b0346a44ad2898c603d1a6cda5f50d80b" - integrity sha512-FYId5tYgaKvpqAXRXqs0pY4+7/A09bEl1mCxFqlS9jlZOCjlMZVvZuv8spbY8ZN9HaMvuVmx9J00Fn2gCJd0TQ== +"@element-hq/element-web-module-api@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.2.0.tgz#4d91c890a74f808a82759dcb00a8e47dcf131236" + integrity sha512-+2fjShcuFLWVWzhRVlveg4MHevcT7XiXie6JB2SIS89FoJWAnsr41eiSbUORAIHndBCrznU8a/lYz9Pf8BXYVA== "@element-hq/element-web-playwright-common@^1.4.2": version "1.4.2"