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 0000000000..b144ca6a5e Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-filter-linux.png differ diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-renderer-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-renderer-linux.png new file mode 100644 index 0000000000..b3fa5e0f57 Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-renderer-linux.png differ diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png new file mode 100644 index 0000000000..0fe98072a0 Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png differ 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 0000000000..7c5d6b66e6 Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png differ diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png new file mode 100644 index 0000000000..9a00a3b04b Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png differ 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"