Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
213a191
Add new custom component api.
Half-Shot May 19, 2025
3281a41
Remove context menu, refactor
Half-Shot May 20, 2025
4d81b36
fix types
Half-Shot May 20, 2025
afab6c2
Add a test for custom modules.
Half-Shot May 20, 2025
1ed3f20
tidy
Half-Shot May 20, 2025
1c5bc4a
Merge remote-tracking branch 'origin/develop' into hs/add-custom-comp…
Half-Shot May 30, 2025
1b2d9b3
Rewrite for new API
Half-Shot Jun 3, 2025
757e4e1
Update tests
Half-Shot Jun 3, 2025
ce428b5
lint
Half-Shot Jun 3, 2025
f740dc3
Allow passing in props to original component
Half-Shot Jun 5, 2025
9136d84
Add hinting
Half-Shot Jun 10, 2025
ec13bdc
Update tests to be complete
Half-Shot Jun 12, 2025
e60a68e
lint a bit more
Half-Shot Jun 12, 2025
62f6260
Merge remote-tracking branch 'origin/develop' into hs/add-custom-comp…
Half-Shot Jun 12, 2025
c6d4f38
update docstring
Half-Shot Jun 12, 2025
ff986e4
update @element-hq/element-web-module-api to 1.1.0
Half-Shot Jun 16, 2025
5aee224
fix types
Half-Shot Jun 16, 2025
42edbab
updates
Half-Shot Jun 16, 2025
391bd15
hide jump to bottom button that was causing flakes
Half-Shot Jun 16, 2025
2948572
lint
Half-Shot Jun 17, 2025
d97d999
lint
Half-Shot Jun 17, 2025
66e7381
Use module matrix event interface instead.
Half-Shot Jun 17, 2025
706b33f
update to new module sdk
Half-Shot Jun 17, 2025
bba40ca
adapt custom module sample
Half-Shot Jun 17, 2025
f44308b
Merge branch 'develop' into hs/add-custom-component-modules
Half-Shot Jun 17, 2025
6d2f1c2
Issues caught by Sonar
Half-Shot Jun 19, 2025
9eb5e89
lint
Half-Shot Jun 19, 2025
9f7359e
fix issues
Half-Shot Jun 23, 2025
3f39741
make the comment make sense
Half-Shot Jun 23, 2025
bb0f2d6
fix import
Half-Shot Jun 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
112 changes: 112 additions & 0 deletions playwright/e2e/modules/custom-component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
Comment thread
t3chguy marked this conversation as resolved.
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),
);
},
);
});
});
55 changes: 55 additions & 0 deletions playwright/sample-files/custom-component-module.js
Original file line number Diff line number Diff line change
@@ -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() {}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
127 changes: 83 additions & 44 deletions src/events/EventTileFactory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
}),
);
}
}

Expand All @@ -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({
Comment thread
Half-Shot marked this conversation as resolved.
mxEvent: props.mxEvent,
});
}

// See renderTile() for why we split off so much
const {
Expand All @@ -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
Expand All @@ -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;

Expand Down
4 changes: 3 additions & 1 deletion src/modules/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading