Update tests to be complete
@ -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",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -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() {}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.6 KiB |
@ -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;
|
||||
|
||||