Compare commits

...

27 Commits

Author SHA1 Message Date
Half-Shot
9eb5e8965a lint 2025-06-19 10:16:11 +01:00
Half-Shot
6d2f1c2e9a Issues caught by Sonar 2025-06-19 08:26:45 +01:00
Will Hunt
f44308b9a9 Merge branch 'develop' into hs/add-custom-component-modules 2025-06-17 16:30:31 +01:00
Half-Shot
bba40ca706 adapt custom module sample 2025-06-17 16:16:56 +01:00
Half-Shot
706b33fcf4 update to new module sdk 2025-06-17 16:02:44 +01:00
Half-Shot
66e73818a8 Use module matrix event interface instead. 2025-06-17 13:36:08 +01:00
Half-Shot
d97d999ef2 lint 2025-06-17 12:12:58 +01:00
Half-Shot
294857209d lint 2025-06-17 09:22:13 +01:00
Half-Shot
391bd15258 hide jump to bottom button that was causing flakes 2025-06-16 13:51:09 +01:00
Half-Shot
42edbab715 updates 2025-06-16 13:46:03 +01:00
Half-Shot
5aee224169 fix types 2025-06-16 13:43:27 +01:00
Half-Shot
ff986e4317 update @element-hq/element-web-module-api to 1.1.0 2025-06-16 12:50:53 +01:00
Half-Shot
c6d4f38a04 update docstring 2025-06-12 16:39:11 +01:00
Half-Shot
62f62601ef Merge remote-tracking branch 'origin/develop' into hs/add-custom-component-modules 2025-06-12 16:33:17 +01:00
Half-Shot
e60a68ea1a lint a bit more 2025-06-12 16:27:58 +01:00
Half-Shot
ec13bdc910 Update tests to be complete 2025-06-12 16:25:23 +01:00
Half-Shot
9136d841ee Add hinting 2025-06-10 11:28:15 +01:00
Half-Shot
f740dc3829 Allow passing in props to original component 2025-06-05 09:57:50 +01:00
Half-Shot
ce428b5e2d lint 2025-06-03 14:00:46 +01:00
Half-Shot
757e4e1395 Update tests 2025-06-03 13:57:57 +01:00
Half-Shot
1b2d9b392c Rewrite for new API 2025-06-03 13:42:17 +01:00
Half-Shot
1c5bc4a7be Merge remote-tracking branch 'origin/develop' into hs/add-custom-component-modules 2025-05-30 10:38:10 +01:00
Half-Shot
1ed3f205f3 tidy 2025-05-20 14:27:27 +01:00
Half-Shot
afab6c29dc Add a test for custom modules. 2025-05-20 13:27:22 +01:00
Half-Shot
4d81b36270 fix types 2025-05-20 12:40:46 +01:00
Half-Shot
3281a4128f Remove context menu, refactor 2025-05-20 12:10:45 +01:00
Half-Shot
213a191b8c Add new custom component api. 2025-05-19 09:26:22 +01:00
13 changed files with 372 additions and 49 deletions

View File

@@ -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",

View File

@@ -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),
);
},
);
});
});

View File

@@ -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() {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -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,10 @@ 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)
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
@@ -285,35 +289,47 @@ export function renderTile(
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, {
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 +348,10 @@ 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)
return ModuleApi.customComponents.renderMessage({
mxEvent: props.mxEvent,
});
// See renderTile() for why we split off so much
const {
@@ -350,19 +369,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

View File

@@ -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.tsx";
const legacyCustomisationsFactory = <T extends object>(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 {

View File

@@ -0,0 +1,124 @@
/*
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 EventRenderer = {
eventTypeOrFilter: string | ((mxEvent: ModuleMatrixEvent) => boolean);
renderer: CustomMessageRenderFunction;
hints: CustomMessageRenderHints;
};
interface CustomMessageComponentProps extends Omit<ModuleCustomMessageComponentProps, "mxEvent"> {
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: string | ((mxEvent: ModuleMatrixEvent) => boolean),
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((rdr) => {
if (typeof rdr.eventTypeOrFilter === "string") {
return rdr.eventTypeOrFilter === mxEvent.type;
} else {
try {
return rdr.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 {
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(mxEvent);
const renderer = moduleEv && this.selectRenderer(moduleEv);
if (renderer) {
return renderer.hints;
}
return {};
}
}

View File

@@ -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()) ||

View File

@@ -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.1.5":
version "1.3.0"