diff --git a/playwright/e2e/modules/custom-component.spec.ts b/playwright/e2e/modules/custom-component.spec.ts index 7d91816231..8299c0de11 100644 --- a/playwright/e2e/modules/custom-component.spec.ts +++ b/playwright/e2e/modules/custom-component.spec.ts @@ -6,7 +6,8 @@ Please see LICENSE files in the repository root for full details. */ import { test, expect } from "../../element-web-test"; -test.describe("Custom Component Module", () => { + +test.describe("Custom Component API", () => { test.use({ displayName: "Manny", config: { @@ -23,31 +24,111 @@ test.describe("Custom Component Module", () => { await use({ roomId }); }, }); - 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.getByText("Simple message")).toMatchScreenshot("custom-component-tile.png"); + 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", { + // Exclude timestamp and read marker from snapshot + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }); + }, + ); + 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", + { + // Exclude timestamp and read marker from snapshot + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }, + ); + }, + ); + 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", + { + // Exclude timestamp and read marker from snapshot + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }, + ); + }, + ); + 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", + { + // Exclude timestamp and read marker from snapshot + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }, + ); + }, + ); + 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", + { + // Exclude timestamp and read marker from snapshot + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }, + ); + }, + ); }); - 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.getByText("Fall through here")).toMatchScreenshot( - "custom-component-tile-fall-through.png", - ); - }, - ); - 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.getByText("Do not replace me")).toMatchScreenshot( - "custom-component-tile-original.png", - ); - }, - ); }); diff --git a/playwright/sample-files/custom-component-module.js b/playwright/sample-files/custom-component-module.js index 0b547a69ab..563b4123c5 100644 --- a/playwright/sample-files/custom-component-module.js +++ b/playwright/sample-files/custom-component-module.js @@ -9,6 +9,38 @@ export default class CustomComponentModule { static moduleApiVersion = "^1.0.0"; constructor(api) { this.api = api; + this.api.customComponents.registerMessageRenderer( + (evt) => evt.getContent().body === "Do not show edits", + (_props, originalComponent) => { + return originalComponent(); + }, + { allowEditingEvent: false }, + ); + this.api.customComponents.registerMessageRenderer( + (evt) => evt.getContent().body === "Fall through here", + (props) => { + const body = props.mxEvent.getContent().body; + return `Fallthrough text for ${body}`; + }, + ); + this.api.customComponents.registerMessageRenderer( + (evt) => { + if (evt.getContent().body === "Crash the filter!") { + throw new Error("Fail test!"); + } + return false; + }, + () => { + return `Should not render!`; + }, + ); + this.api.customComponents.registerMessageRenderer( + (evt) => evt.getContent().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.getContent().body; if (body === "Do not replace me") { @@ -18,13 +50,6 @@ export default class CustomComponentModule { } return `Custom text for ${body}`; }); - this.api.customComponents.registerMessageRenderer(/m\.room\.message/, (props) => { - const body = props.mxEvent.getContent().body; - if (body !== "Fall through here") { - return null; - } - return `Fallthrough 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..e7d4241dca 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 index 952b0110b6..0fe98072a0 100644 Binary files a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png 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 index 410365982b..7c5d6b66e6 100644 Binary files a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png 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 index 45c2279583..9a00a3b04b 100644 Binary files a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png differ diff --git a/src/modules/customComponentApi.tsx b/src/modules/customComponentApi.tsx index 487ab1c898..8e6eda2263 100644 --- a/src/modules/customComponentApi.tsx +++ b/src/modules/customComponentApi.tsx @@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; + import type { CustomComponentsApi as ICustomComponentsApi, CustomMessageRenderFunction, @@ -12,25 +14,39 @@ import type { OriginalComponentProps, CustomMessageRenderHints, } from "@element-hq/element-web-module-api"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; import type React from "react"; type EventRenderer = { eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean); renderer: CustomMessageRenderFunction; - hints: CustomMessageRenderHints, -} + hints: CustomMessageRenderHints; +}; export class CustomComponentsApi implements ICustomComponentsApi { private readonly registeredMessageRenderers: EventRenderer[] = []; - - public registerMessageRenderer(eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean), renderer: CustomMessageRenderFunction, hints: CustomMessageRenderHints = {}): void { + public registerMessageRenderer( + eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean), + renderer: CustomMessageRenderFunction, + hints: CustomMessageRenderHints = {}, + ): void { this.registeredMessageRenderers.push({ eventTypeOrFilter: eventTypeOrFilter, renderer, hints }); } - private selectRenderer(mxEvent: MatrixEvent): EventRenderer|undefined { - return this.registeredMessageRenderers.find((rdr) => typeof rdr.eventTypeOrFilter === "string" ? mxEvent.getType().match(rdr.eventTypeOrFilter) : rdr.eventTypeOrFilter(mxEvent)); + private selectRenderer(mxEvent: MatrixEvent): EventRenderer | undefined { + return this.registeredMessageRenderers.find((rdr) => { + if (typeof rdr.eventTypeOrFilter === "string") { + return rdr.eventTypeOrFilter === mxEvent.getType(); + } else { + try { + return rdr.eventTypeOrFilter(mxEvent); + } catch (ex) { + logger.warn("Message renderer failed to process filter", ex); + return false; // Skip erroring renderers. + } + } + }); } /** @@ -45,7 +61,12 @@ export class CustomComponentsApi implements ICustomComponentsApi { ): React.JSX.Element | null { const renderer = this.selectRenderer(props.mxEvent); if (renderer) { - return renderer.renderer(props, originalComponent); + try { + return renderer.renderer(props, 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; } @@ -56,9 +77,7 @@ export class CustomComponentsApi implements ICustomComponentsApi { * @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 getHintsForMessage( - mxEvent: MatrixEvent, - ): CustomMessageRenderHints { + public getHintsForMessage(mxEvent: MatrixEvent): CustomMessageRenderHints { const renderer = this.selectRenderer(mxEvent); if (renderer) { return renderer.hints;