You've already forked element-web
mirror of
https://github.com/element-hq/element-web.git
synced 2025-07-31 19:44:30 +03:00
Support for custom message components via Module API (#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
This commit is contained in:
@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@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/inconsolata": "^5",
|
||||||
"@fontsource/inter": "^5",
|
"@fontsource/inter": "^5",
|
||||||
"@formatjs/intl-segmenter": "^11.5.7",
|
"@formatjs/intl-segmenter": "^11.5.7",
|
||||||
|
112
playwright/e2e/modules/custom-component.spec.ts
Normal file
112
playwright/e2e/modules/custom-component.spec.ts
Normal 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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
55
playwright/sample-files/custom-component-module.js
Normal file
55
playwright/sample-files/custom-component-module.js
Normal 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 |
@ -43,6 +43,7 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
|
|||||||
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
|
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
|
||||||
import { ElementCall } from "../models/Call";
|
import { ElementCall } from "../models/Call";
|
||||||
import { type IBodyProps } from "../components/views/messages/IBodyProps";
|
import { type IBodyProps } from "../components/views/messages/IBodyProps";
|
||||||
|
import ModuleApi from "../modules/Api";
|
||||||
|
|
||||||
// Subset of EventTile's IProps plus some mixins
|
// Subset of EventTile's IProps plus some mixins
|
||||||
export interface EventTileTypeProps
|
export interface EventTileTypeProps
|
||||||
@ -257,7 +258,14 @@ export function renderTile(
|
|||||||
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
|
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
|
||||||
|
|
||||||
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
|
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
|
// 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
|
// 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.File:
|
||||||
case TimelineRenderingType.Notification:
|
case TimelineRenderingType.Notification:
|
||||||
case TimelineRenderingType.Thread:
|
case TimelineRenderingType.Thread:
|
||||||
|
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.
|
// We only want a subset of props, so we don't end up causing issues for downstream components.
|
||||||
return factory(props.ref, {
|
|
||||||
mxEvent,
|
mxEvent,
|
||||||
highlights,
|
highlights,
|
||||||
highlightLink,
|
highlightLink,
|
||||||
showUrlPreview,
|
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
|
||||||
editState,
|
editState,
|
||||||
replacingEventId,
|
replacingEventId,
|
||||||
getRelationsForEvent,
|
getRelationsForEvent,
|
||||||
isSeeingThroughMessageHiddenForModeration,
|
isSeeingThroughMessageHiddenForModeration,
|
||||||
permalinkCreator,
|
permalinkCreator,
|
||||||
inhibitInteraction,
|
inhibitInteraction,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
|
return ModuleApi.customComponents.renderMessage(
|
||||||
|
{
|
||||||
|
mxEvent: props.mxEvent,
|
||||||
|
},
|
||||||
|
(origProps) =>
|
||||||
|
factory(ref, {
|
||||||
// NEARLY ALL THE OPTIONS!
|
// NEARLY ALL THE OPTIONS!
|
||||||
return factory(ref, {
|
|
||||||
mxEvent,
|
mxEvent,
|
||||||
forExport,
|
forExport,
|
||||||
replacingEventId,
|
replacingEventId,
|
||||||
editState,
|
editState,
|
||||||
highlights,
|
highlights,
|
||||||
highlightLink,
|
highlightLink,
|
||||||
showUrlPreview,
|
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
|
||||||
permalinkCreator,
|
permalinkCreator,
|
||||||
callEventGrouper,
|
callEventGrouper,
|
||||||
getRelationsForEvent,
|
getRelationsForEvent,
|
||||||
isSeeingThroughMessageHiddenForModeration,
|
isSeeingThroughMessageHiddenForModeration,
|
||||||
timestamp,
|
timestamp,
|
||||||
inhibitInteraction,
|
inhibitInteraction,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -332,7 +352,14 @@ export function renderReplyTile(
|
|||||||
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
|
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
|
||||||
|
|
||||||
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
|
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
|
// See renderTile() for why we split off so much
|
||||||
const {
|
const {
|
||||||
@ -350,11 +377,16 @@ export function renderReplyTile(
|
|||||||
permalinkCreator,
|
permalinkCreator,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return factory(ref, {
|
return ModuleApi.customComponents.renderMessage(
|
||||||
|
{
|
||||||
|
mxEvent: props.mxEvent,
|
||||||
|
},
|
||||||
|
(origProps) =>
|
||||||
|
factory(ref, {
|
||||||
mxEvent,
|
mxEvent,
|
||||||
highlights,
|
highlights,
|
||||||
highlightLink,
|
highlightLink,
|
||||||
showUrlPreview,
|
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
|
||||||
overrideBodyTypes,
|
overrideBodyTypes,
|
||||||
overrideEventTypes,
|
overrideEventTypes,
|
||||||
replacingEventId,
|
replacingEventId,
|
||||||
@ -362,7 +394,8 @@ export function renderReplyTile(
|
|||||||
getRelationsForEvent,
|
getRelationsForEvent,
|
||||||
isSeeingThroughMessageHiddenForModeration,
|
isSeeingThroughMessageHiddenForModeration,
|
||||||
permalinkCreator,
|
permalinkCreator,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: this'll eventually be dynamic based on the fields once we have extensible event types
|
// 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;
|
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
|
// No tile for replacement events since they update the original tile
|
||||||
if (mxEvent.isRelation(RelationType.Replace)) return false;
|
if (mxEvent.isRelation(RelationType.Replace)) return false;
|
||||||
|
|
||||||
|
@ -6,8 +6,8 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
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 { ModuleRunner } from "./ModuleRunner.ts";
|
||||||
import AliasCustomisations from "../customisations/Alias.ts";
|
import AliasCustomisations from "../customisations/Alias.ts";
|
||||||
import { RoomListCustomisations } from "../customisations/RoomList.ts";
|
import { RoomListCustomisations } from "../customisations/RoomList.ts";
|
||||||
@ -21,6 +21,7 @@ import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissi
|
|||||||
import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts";
|
import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts";
|
||||||
import { ConfigApi } from "./ConfigApi.ts";
|
import { ConfigApi } from "./ConfigApi.ts";
|
||||||
import { I18nApi } from "./I18nApi.ts";
|
import { I18nApi } from "./I18nApi.ts";
|
||||||
|
import { CustomComponentsApi } from "./customComponentApi.ts";
|
||||||
|
|
||||||
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
|
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
|
||||||
let used = false;
|
let used = false;
|
||||||
@ -58,6 +59,7 @@ class ModuleApi implements Api {
|
|||||||
|
|
||||||
public readonly config = new ConfigApi();
|
public readonly config = new ConfigApi();
|
||||||
public readonly i18n = new I18nApi();
|
public readonly i18n = new I18nApi();
|
||||||
|
public readonly customComponents = new CustomComponentsApi();
|
||||||
public readonly rootNode = document.getElementById("matrixchat")!;
|
public readonly rootNode = document.getElementById("matrixchat")!;
|
||||||
|
|
||||||
public createRoot(element: Element): Root {
|
public createRoot(element: Element): Root {
|
||||||
|
126
src/modules/customComponentApi.ts
Normal file
126
src/modules/customComponentApi.ts
Normal file
@ -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<ICustomComponentsApi["registerMessageRenderer"]>[0];
|
||||||
|
|
||||||
|
type EventRenderer = {
|
||||||
|
eventTypeOrFilter: EventTypeOrFilter;
|
||||||
|
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: 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;
|
||||||
|
}
|
||||||
|
}
|
@ -30,6 +30,7 @@ import { type TimelineRenderingType } from "../contexts/RoomContext";
|
|||||||
import { launchPollEditor } from "../components/views/messages/MPollBody";
|
import { launchPollEditor } from "../components/views/messages/MPollBody";
|
||||||
import { Action } from "../dispatcher/actions";
|
import { Action } from "../dispatcher/actions";
|
||||||
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
|
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
|
||||||
|
import ModuleApi from "../modules/Api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether an event should allow actions like reply, reactions, edit, etc.
|
* 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ModuleApi.customComponents.getHintsForMessage(mxEvent)?.allowEditingEvent === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const { msgtype, body } = mxEvent.getOriginalContent();
|
const { msgtype, body } = mxEvent.getOriginalContent();
|
||||||
return (
|
return (
|
||||||
M_POLL_START.matches(mxEvent.getType()) ||
|
M_POLL_START.matches(mxEvent.getType()) ||
|
||||||
|
@ -1672,10 +1672,10 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.12.2.tgz#b6b6b7df69369b3088960b79591ce1bfd2b84a1a"
|
resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.12.2.tgz#b6b6b7df69369b3088960b79591ce1bfd2b84a1a"
|
||||||
integrity sha512-2u5/bOARcjc5TFq4929x1R0tvsNbeVA58FBtiW05GlIJCapxzPSOeeGhbqEcJ1TW3/hLGpiKMcw0QwRBQVNzQA==
|
integrity sha512-2u5/bOARcjc5TFq4929x1R0tvsNbeVA58FBtiW05GlIJCapxzPSOeeGhbqEcJ1TW3/hLGpiKMcw0QwRBQVNzQA==
|
||||||
|
|
||||||
"@element-hq/element-web-module-api@1.0.0":
|
"@element-hq/element-web-module-api@1.2.0":
|
||||||
version "1.0.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.0.0.tgz#df09108b0346a44ad2898c603d1a6cda5f50d80b"
|
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.2.0.tgz#4d91c890a74f808a82759dcb00a8e47dcf131236"
|
||||||
integrity sha512-FYId5tYgaKvpqAXRXqs0pY4+7/A09bEl1mCxFqlS9jlZOCjlMZVvZuv8spbY8ZN9HaMvuVmx9J00Fn2gCJd0TQ==
|
integrity sha512-+2fjShcuFLWVWzhRVlveg4MHevcT7XiXie6JB2SIS89FoJWAnsr41eiSbUORAIHndBCrznU8a/lYz9Pf8BXYVA==
|
||||||
|
|
||||||
"@element-hq/element-web-playwright-common@^1.4.2":
|
"@element-hq/element-web-playwright-common@^1.4.2":
|
||||||
version "1.4.2"
|
version "1.4.2"
|
||||||
|
Reference in New Issue
Block a user