Skip to content

Commit 2596690

Browse files
Half-ShotDileep Bandla
authored andcommitted
Support for custom message components via Module API (element-hq#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
1 parent 0b37893 commit 2596690

13 files changed

Lines changed: 389 additions & 50 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
},
8282
"dependencies": {
8383
"@babel/runtime": "^7.12.5",
84-
"@element-hq/element-web-module-api": "1.0.0",
84+
"@element-hq/element-web-module-api": "1.2.0",
8585
"@fontsource/inconsolata": "^5",
8686
"@fontsource/inter": "^5",
8787
"@formatjs/intl-segmenter": "^11.5.7",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type Page } from "@playwright/test";
9+
10+
import { test, expect } from "../../element-web-test";
11+
12+
const screenshotOptions = (page: Page) => ({
13+
mask: [page.locator(".mx_MessageTimestamp")],
14+
// Hide the jump to bottom button in the timeline to avoid flakiness
15+
// Exclude timestamp and read marker from snapshot
16+
css: `
17+
.mx_JumpToBottomButton {
18+
display: none !important;
19+
}
20+
.mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
21+
display: none !important;
22+
}
23+
`,
24+
});
25+
test.describe("Custom Component API", () => {
26+
test.use({
27+
displayName: "Manny",
28+
config: {
29+
modules: ["/modules/custom-component-module.js"],
30+
},
31+
page: async ({ page }, use) => {
32+
await page.route("/modules/custom-component-module.js", async (route) => {
33+
await route.fulfill({ path: "playwright/sample-files/custom-component-module.js" });
34+
});
35+
await use(page);
36+
},
37+
room: async ({ page, app, user, bot }, use) => {
38+
const roomId = await app.client.createRoom({ name: "TestRoom" });
39+
await use({ roomId });
40+
},
41+
});
42+
test.describe("basic functionality", () => {
43+
test(
44+
"should replace the render method of a textual event",
45+
{ tag: "@screenshot" },
46+
async ({ page, room, app }) => {
47+
await app.viewRoomById(room.roomId);
48+
await app.client.sendMessage(room.roomId, "Simple message");
49+
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
50+
"custom-component-tile.png",
51+
screenshotOptions(page),
52+
);
53+
},
54+
);
55+
test(
56+
"should fall through if one module does not render a component",
57+
{ tag: "@screenshot" },
58+
async ({ page, room, app }) => {
59+
await app.viewRoomById(room.roomId);
60+
await app.client.sendMessage(room.roomId, "Fall through here");
61+
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
62+
"custom-component-tile-fall-through.png",
63+
screenshotOptions(page),
64+
);
65+
},
66+
);
67+
test(
68+
"should render the original content of a textual event conditionally",
69+
{ tag: "@screenshot" },
70+
async ({ page, room, app }) => {
71+
await app.viewRoomById(room.roomId);
72+
await app.client.sendMessage(room.roomId, "Do not replace me");
73+
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
74+
"custom-component-tile-original.png",
75+
screenshotOptions(page),
76+
);
77+
},
78+
);
79+
test("should disallow editing when the allowEditingEvent hint is set to false", async ({ page, room, app }) => {
80+
await app.viewRoomById(room.roomId);
81+
await app.client.sendMessage(room.roomId, "Do not show edits");
82+
await page.getByText("Do not show edits").hover();
83+
await expect(
84+
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }),
85+
).not.toBeVisible();
86+
});
87+
test(
88+
"should render the next registered component if the filter function throws",
89+
{ tag: "@screenshot" },
90+
async ({ page, room, app }) => {
91+
await app.viewRoomById(room.roomId);
92+
await app.client.sendMessage(room.roomId, "Crash the filter!");
93+
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
94+
"custom-component-crash-handle-filter.png",
95+
screenshotOptions(page),
96+
);
97+
},
98+
);
99+
test(
100+
"should render original component if the render function throws",
101+
{ tag: "@screenshot" },
102+
async ({ page, room, app }) => {
103+
await app.viewRoomById(room.roomId);
104+
await app.client.sendMessage(room.roomId, "Crash the renderer!");
105+
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
106+
"custom-component-crash-handle-renderer.png",
107+
screenshotOptions(page),
108+
);
109+
},
110+
);
111+
});
112+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
export default class CustomComponentModule {
9+
static moduleApiVersion = "^1.2.0";
10+
constructor(api) {
11+
this.api = api;
12+
this.api.customComponents.registerMessageRenderer(
13+
(evt) => evt.content.body === "Do not show edits",
14+
(_props, originalComponent) => {
15+
return originalComponent();
16+
},
17+
{ allowEditingEvent: false },
18+
);
19+
this.api.customComponents.registerMessageRenderer(
20+
(evt) => evt.content.body === "Fall through here",
21+
(props) => {
22+
const body = props.mxEvent.content.body;
23+
return `Fallthrough text for ${body}`;
24+
},
25+
);
26+
this.api.customComponents.registerMessageRenderer(
27+
(evt) => {
28+
if (evt.content.body === "Crash the filter!") {
29+
throw new Error("Fail test!");
30+
}
31+
return false;
32+
},
33+
() => {
34+
return `Should not render!`;
35+
},
36+
);
37+
this.api.customComponents.registerMessageRenderer(
38+
(evt) => evt.content.body === "Crash the renderer!",
39+
() => {
40+
throw new Error("Fail test!");
41+
},
42+
);
43+
// Order is specific here to avoid this overriding the other renderers
44+
this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => {
45+
const body = props.mxEvent.content.body;
46+
if (body === "Do not replace me") {
47+
return originalComponent();
48+
} else if (body === "Fall through here") {
49+
return null;
50+
}
51+
return `Custom text for ${body}`;
52+
});
53+
}
54+
async load() {}
55+
}
5.73 KB
Loading
4.66 KB
Loading
5.79 KB
Loading
6.03 KB
Loading
4.61 KB
Loading

src/events/EventTileFactory.tsx

Lines changed: 83 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
4343
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
4444
import { ElementCall } from "../models/Call";
4545
import { type IBodyProps } from "../components/views/messages/IBodyProps";
46+
import ModuleApi from "../modules/Api";
4647

4748
// Subset of EventTile's IProps plus some mixins
4849
export interface EventTileTypeProps
@@ -257,7 +258,14 @@ export function renderTile(
257258
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
258259

259260
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
260-
if (!factory) return undefined;
261+
if (!factory) {
262+
// If we don't have a factory for this event, attempt
263+
// to find a custom component that can render it.
264+
// Will return null if no custom component can render it.
265+
return ModuleApi.customComponents.renderMessage({
266+
mxEvent: props.mxEvent,
267+
});
268+
}
261269

262270
// Note that we split off the ones we actually care about here just to be sure that we're
263271
// not going to accidentally send things we shouldn't from lazy callers. Eg: EventTile's
@@ -284,36 +292,48 @@ export function renderTile(
284292
case TimelineRenderingType.File:
285293
case TimelineRenderingType.Notification:
286294
case TimelineRenderingType.Thread:
287-
// We only want a subset of props, so we don't end up causing issues for downstream components.
288-
return factory(props.ref, {
289-
mxEvent,
290-
highlights,
291-
highlightLink,
292-
showUrlPreview,
293-
editState,
294-
replacingEventId,
295-
getRelationsForEvent,
296-
isSeeingThroughMessageHiddenForModeration,
297-
permalinkCreator,
298-
inhibitInteraction,
299-
});
295+
return ModuleApi.customComponents.renderMessage(
296+
{
297+
mxEvent: props.mxEvent,
298+
},
299+
(origProps) =>
300+
factory(props.ref, {
301+
// We only want a subset of props, so we don't end up causing issues for downstream components.
302+
mxEvent,
303+
highlights,
304+
highlightLink,
305+
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
306+
editState,
307+
replacingEventId,
308+
getRelationsForEvent,
309+
isSeeingThroughMessageHiddenForModeration,
310+
permalinkCreator,
311+
inhibitInteraction,
312+
}),
313+
);
300314
default:
301-
// NEARLY ALL THE OPTIONS!
302-
return factory(ref, {
303-
mxEvent,
304-
forExport,
305-
replacingEventId,
306-
editState,
307-
highlights,
308-
highlightLink,
309-
showUrlPreview,
310-
permalinkCreator,
311-
callEventGrouper,
312-
getRelationsForEvent,
313-
isSeeingThroughMessageHiddenForModeration,
314-
timestamp,
315-
inhibitInteraction,
316-
});
315+
return ModuleApi.customComponents.renderMessage(
316+
{
317+
mxEvent: props.mxEvent,
318+
},
319+
(origProps) =>
320+
factory(ref, {
321+
// NEARLY ALL THE OPTIONS!
322+
mxEvent,
323+
forExport,
324+
replacingEventId,
325+
editState,
326+
highlights,
327+
highlightLink,
328+
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
329+
permalinkCreator,
330+
callEventGrouper,
331+
getRelationsForEvent,
332+
isSeeingThroughMessageHiddenForModeration,
333+
timestamp,
334+
inhibitInteraction,
335+
}),
336+
);
317337
}
318338
}
319339

@@ -332,7 +352,14 @@ export function renderReplyTile(
332352
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
333353

334354
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
335-
if (!factory) return undefined;
355+
if (!factory) {
356+
// If we don't have a factory for this event, attempt
357+
// to find a custom component that can render it.
358+
// Will return null if no custom component can render it.
359+
return ModuleApi.customComponents.renderMessage({
360+
mxEvent: props.mxEvent,
361+
});
362+
}
336363

337364
// See renderTile() for why we split off so much
338365
const {
@@ -350,19 +377,25 @@ export function renderReplyTile(
350377
permalinkCreator,
351378
} = props;
352379

353-
return factory(ref, {
354-
mxEvent,
355-
highlights,
356-
highlightLink,
357-
showUrlPreview,
358-
overrideBodyTypes,
359-
overrideEventTypes,
360-
replacingEventId,
361-
maxImageHeight,
362-
getRelationsForEvent,
363-
isSeeingThroughMessageHiddenForModeration,
364-
permalinkCreator,
365-
});
380+
return ModuleApi.customComponents.renderMessage(
381+
{
382+
mxEvent: props.mxEvent,
383+
},
384+
(origProps) =>
385+
factory(ref, {
386+
mxEvent,
387+
highlights,
388+
highlightLink,
389+
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
390+
overrideBodyTypes,
391+
overrideEventTypes,
392+
replacingEventId,
393+
maxImageHeight,
394+
getRelationsForEvent,
395+
isSeeingThroughMessageHiddenForModeration,
396+
permalinkCreator,
397+
}),
398+
);
366399
}
367400

368401
// XXX: this'll eventually be dynamic based on the fields once we have extensible event types
@@ -386,6 +419,12 @@ export function haveRendererForEvent(
386419
return false;
387420
}
388421

422+
// Check to see if we have any hints for this message, which indicates
423+
// there is a custom renderer for the event.
424+
if (ModuleApi.customComponents.getHintsForMessage(mxEvent)) {
425+
return true;
426+
}
427+
389428
// No tile for replacement events since they update the original tile
390429
if (mxEvent.isRelation(RelationType.Replace)) return false;
391430

src/modules/Api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ Please see LICENSE files in the repository root for full details.
66
*/
77

88
import { createRoot, type Root } from "react-dom/client";
9+
import { type Api, type RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
910

10-
import type { Api, RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
1111
import { ModuleRunner } from "./ModuleRunner.ts";
1212
import AliasCustomisations from "../customisations/Alias.ts";
1313
import { RoomListCustomisations } from "../customisations/RoomList.ts";
@@ -21,6 +21,7 @@ import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissi
2121
import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts";
2222
import { ConfigApi } from "./ConfigApi.ts";
2323
import { I18nApi } from "./I18nApi.ts";
24+
import { CustomComponentsApi } from "./customComponentApi.ts";
2425

2526
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
2627
let used = false;
@@ -58,6 +59,7 @@ class ModuleApi implements Api {
5859

5960
public readonly config = new ConfigApi();
6061
public readonly i18n = new I18nApi();
62+
public readonly customComponents = new CustomComponentsApi();
6163
public readonly rootNode = document.getElementById("matrixchat")!;
6264

6365
public createRoot(element: Element): Root {

0 commit comments

Comments
 (0)