Add a module API for overriding the composer preview. (#33978)

* Modify LinkPreview to export shared atomics

* Implement MessageComposerUrlPreviewView

* Create UrlPreviewFetcher utility function

* Modify view models

* Implement in composer

* Support running tests in dom-less vitest environment

* Add a playwright test

* hide another one

* fmt

* cleanup

* test rte too

* fixup

* Add back docstring

* cleanup

* off by one

* remove description check

* Cleanup hacks

* Remove another hack

* cleanup

* one more

* fixup window here too

* whoops type

* Rename to be cleaeer

* Trim URLs first

* fix bug

* Add a module API for overriding the composer preview.

* tests + cleanup

* fixup comment

* revert accidental change

* fixup

* happy tests

* cleanup
This commit is contained in:
Will Hunt
2026-07-06 12:32:07 +00:00
committed by GitHub
parent 6027ec5142
commit b6cbc3a9d8
5 changed files with 246 additions and 16 deletions
@@ -0,0 +1,99 @@
/*
Copyright 2026 Element Creations 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 React from "react";
import { render, waitFor } from "test-utils-rtl";
import { test, describe, beforeEach, expect, vi, afterEach } from "vitest";
import { MessageComposerUrlPreviewWrapper, DEBOUNCE_REQUEST_TIMEOUT_MS } from "./MessageComposerUrlPreview";
import {
getMockClientWithEventEmitter,
getRoomContext,
mkRoom,
mockClientMethodsUser,
} from "../../../../test/test-utils";
import type { I18nApi } from "@element-hq/element-web-module-api";
import type { ModuleApi } from "../../../modules/Api";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { CustomComponentsApi } from "../../../modules/customComponentApi";
// @vitest-environment happy-dom
const BASIC_PREVIEW_OGDATA = {
"og:title": "This is an example!",
"og:description": "This is a description",
"og:type": "document",
"og:url": "https://example.org",
"og:site_name": "Example.org",
};
describe("MessageComposerUrlPreview", () => {
let client: MatrixClient;
let originalMxModuleApi: ModuleApi;
beforeEach(() => {
originalMxModuleApi = window.mxModuleApi;
window.mxModuleApi = {
i18n: {} as I18nApi,
} as ModuleApi;
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
getUrlPreview: vi.fn().mockResolvedValue(BASIC_PREVIEW_OGDATA),
});
});
afterEach(() => {
window.mxModuleApi = originalMxModuleApi;
});
function wrapComponent(component: Parameters<typeof render>[0]): ReturnType<typeof render> {
return render(component, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider
roomId="!foo:bar"
{...getRoomContext(mkRoom(client, "!foo:bar"), { showUrlPreview: true })}
>
{children}
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
),
});
}
test("to be empty without a link to preview", () => {
const { container } = wrapComponent(<MessageComposerUrlPreviewWrapper content="Test a string" />);
expect(container).toMatchInlineSnapshot(`<div />`);
});
test("to contain a link when there is a URL", async () => {
const { getByText } = wrapComponent(<MessageComposerUrlPreviewWrapper content="https://example.org" />);
await waitFor(
() => {
expect(getByText("Example.org")).toBeDefined();
},
{ timeout: DEBOUNCE_REQUEST_TIMEOUT_MS },
);
});
test("to allow overriding with a module component", async () => {
const modApi = {
customComponents: new CustomComponentsApi(),
} as ModuleApi;
modApi.customComponents.registerComposerPreview(
() => true,
() => <strong>Fake preview</strong>,
);
const { getByText } = wrapComponent(
<MessageComposerUrlPreviewWrapper content="https://example.org" moduleApi={modApi} />,
);
await waitFor(
() => {
expect(getByText("Fake preview")).toBeDefined();
},
{ timeout: DEBOUNCE_REQUEST_TIMEOUT_MS },
);
});
});
@@ -1,36 +1,62 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
Copyright 2026 Element Creations 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 React, { useEffect, type ReactNode } from "react";
import React, { useEffect, useState, type ReactNode } from "react";
import { MessageComposerUrlPreviewView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext";
import { useDebouncedCallback } from "../../../hooks/spotlight/useDebouncedCallback";
import PlatformPeg from "../../../PlatformPeg";
import { ModuleApi } from "../../../modules/Api";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
const DEBOUNCE_REQUEST_TIMEOUT_MS = 500;
export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500;
export function MessageComposerUrlPreviewWrapper({ content }: { content: string }): ReactNode | null {
const { showUrlPreview } = useScopedRoomContext("showUrlPreview");
export function MessageComposerUrlPreviewWrapper({
content,
moduleApi = ModuleApi.instance,
}: {
content: string;
moduleApi?: ModuleApi;
}): ReactNode | null {
const { showUrlPreview, roomId } = useScopedRoomContext("showUrlPreview", "roomId");
const [customComponent, setCustomComponent] = useState<React.JSX.Element | null>(null);
const client = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(
() =>
new MessageComposerUrlPreviewViewModel({
client: MatrixClientPeg.safeGet(),
client,
visible: showUrlPreview,
showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true,
}),
);
useEffect(() => {
void vm.updateUrlPreviewVisible(showUrlPreview);
}, [vm, showUrlPreview]);
// Rather than checking each time the text changes, we only do a URL check every 500ms to avoid
// hitting the server too frequently. We also only check the module API for a custom component
// at this frequency to avoid expensive calculations downstream.
useDebouncedCallback<[MessageComposerUrlPreviewViewModel, string]>(
true,
(vm, content) => {
const customComponent = moduleApi.customComponents.renderComposerPreview(
{ text: content, roomId: roomId! },
() => <MessageComposerUrlPreviewView vm={vm} />,
);
if (customComponent) {
setCustomComponent(customComponent);
}
// We still update the VM even if the custom component is used since
// the component may choose to render the original component.
void vm.updateWithText(content);
},
[vm, content],
@@ -38,9 +64,5 @@ export function MessageComposerUrlPreviewWrapper({ content }: { content: string
content ? DEBOUNCE_REQUEST_TIMEOUT_MS : 0,
);
useEffect(() => {
void vm.updateUrlPreviewVisible(showUrlPreview);
}, [vm, showUrlPreview]);
return <MessageComposerUrlPreviewView vm={vm} />;
return customComponent ?? <MessageComposerUrlPreviewView vm={vm} />;
}
+39 -3
View File
@@ -17,10 +17,13 @@ import type {
MatrixEvent as ModuleMatrixEvent,
CustomRoomPreviewBarRenderFunction,
CustomLoginRenderFunction,
CustomComposerPreviewRenderFunction,
CustomComposerPreviewComponentProps,
} from "@element-hq/element-web-module-api";
import type React from "react";
type EventTypeOrFilter = Parameters<ICustomComponentsApi["registerMessageRenderer"]>[0];
type ComposerPreviewFilterFn = Parameters<ICustomComponentsApi["registerComposerPreview"]>[0];
type EventRenderer = {
eventTypeOrFilter: EventTypeOrFilter;
@@ -37,6 +40,11 @@ interface CustomMessageRenderHints extends Omit<ModuleCustomCustomMessageRenderH
allowDownloadingMedia?: () => Promise<boolean>;
}
type ComposerPreviewRenderer = {
filter: ComposerPreviewFilterFn;
renderer: CustomComposerPreviewRenderFunction;
};
export class CustomComponentsApi implements ICustomComponentsApi {
/**
* Convert a matrix-js-sdk event into a ModuleMatrixEvent.
@@ -66,6 +74,7 @@ export class CustomComponentsApi implements ICustomComponentsApi {
}
private readonly registeredMessageRenderers: EventRenderer[] = [];
private readonly registeredComposerPreviewRenderers: ComposerPreviewRenderer[] = [];
public registerMessageRenderer(
eventTypeOrFilter: EventTypeOrFilter,
@@ -80,7 +89,7 @@ export class CustomComponentsApi implements ICustomComponentsApi {
* @param mxEvent The message event being rendered.
* @returns The registered renderer.
*/
private selectRenderer(mxEvent: ModuleMatrixEvent): EventRenderer | undefined {
private selectMessageRenderer(mxEvent: ModuleMatrixEvent): EventRenderer | undefined {
return this.registeredMessageRenderers.find((renderer) => {
if (typeof renderer.eventTypeOrFilter === "string") {
return renderer.eventTypeOrFilter === mxEvent.type;
@@ -106,7 +115,7 @@ export class CustomComponentsApi implements ICustomComponentsApi {
originalComponent?: (props?: OriginalMessageComponentProps) => React.JSX.Element,
): React.JSX.Element | null {
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(props.mxEvent);
const renderer = moduleEv && this.selectRenderer(moduleEv);
const renderer = moduleEv && this.selectMessageRenderer(moduleEv);
if (renderer) {
try {
return renderer.renderer({ ...props, mxEvent: moduleEv }, originalComponent);
@@ -125,7 +134,7 @@ export class CustomComponentsApi implements ICustomComponentsApi {
*/
public getHintsForMessage(mxEvent: MatrixEvent): CustomMessageRenderHints | null {
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(mxEvent);
const renderer = moduleEv && this.selectRenderer(moduleEv);
const renderer = moduleEv && this.selectMessageRenderer(moduleEv);
if (renderer) {
return {
...renderer.hints,
@@ -171,4 +180,31 @@ export class CustomComponentsApi implements ICustomComponentsApi {
public registerLoginComponent(renderer: CustomLoginRenderFunction): void {
this._loginRenderer = renderer;
}
public registerComposerPreview(
filter: ComposerPreviewFilterFn,
renderer: CustomComposerPreviewRenderFunction,
): void {
this.registeredComposerPreviewRenderers.push({ filter, renderer });
}
/**
* Render the component for a composer preview.
* @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 was found. Otherwise null.
*/
public renderComposerPreview(
props: CustomComposerPreviewComponentProps,
originalComponent: (props?: CustomComposerPreviewComponentProps) => React.JSX.Element,
): React.JSX.Element | null {
const renderer = this.registeredComposerPreviewRenderers.find(({ filter }) => filter(props.text, props.roomId));
if (renderer) {
try {
return renderer.renderer({ ...props }, originalComponent);
} catch (ex) {
logger.warn("Composer preview failed to render", ex);
}
}
return null;
}
}
@@ -146,11 +146,26 @@ export type Container = "top" | "right" | "center";
// @alpha
export interface CustomComponentsApi {
registerComposerPreview(filterFn: (composerText: string, roomId: string) => boolean, renderer: CustomComposerPreviewRenderFunction): void;
registerLoginComponent(renderer: CustomLoginRenderFunction): void;
registerMessageRenderer(eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean), renderer: CustomMessageRenderFunction, hints?: CustomMessageRenderHints): void;
registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void;
}
// @alpha
export type CustomComposerPreviewComponentProps = {
text: string;
roomId: string;
target?: ComposerApiTarget;
relation?: {
inReplyToEventId?: string;
relType?: string;
};
};
// @alpha
export type CustomComposerPreviewRenderFunction = ExtendablePropsRenderFunction<CustomComposerPreviewComponentProps>;
// @alpha
export interface CustomisationsApi {
registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void;
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import type { JSX, ReactNode } from "react";
import type { MatrixEvent } from "../models/event";
import type { AccountAuthInfo } from "./auth.ts";
import { ComposerApiTarget } from "./composer.ts";
/**
* Properties for all message components.
@@ -157,6 +158,38 @@ export type ExtendablePropsRenderFunction<BaseProps> = <P extends BaseProps>(
*/
export type CustomLoginRenderFunction = ExtendablePropsRenderFunction<CustomLoginComponentProps>;
/**
* Properties for composer preview.
* @alpha Subject to change.
*/
export type CustomComposerPreviewComponentProps = {
/**
* The plain text currently inside the composer.
*/
text: string;
/**
* The ID of the room currently in focus.
*/
roomId: string;
/**
* Details about the target of the composer.
*/
target?: ComposerApiTarget;
/**
* Details about what relation the user may be responding to.
*/
relation?: {
inReplyToEventId?: string;
relType?: string;
};
};
/**
* Function used to render a composer preview.
* @alpha Unlikely to change
*/
export type CustomComposerPreviewRenderFunction = ExtendablePropsRenderFunction<CustomComposerPreviewComponentProps>;
/**
* API for inserting custom components into Element.
* @alpha Subject to change.
@@ -224,4 +257,29 @@ export interface CustomComponentsApi {
* ```
*/
registerLoginComponent(renderer: CustomLoginRenderFunction): void;
/**
* Register a renderer for replacing the preview above the message composer.
*
* The render function should return a rendered component.
*
* Multiple render function may be registered, however the first matching result will be used.
* If no events match or are registered then the originalComponent is rendered.
*
* @param filterFn - A filter function to determine if this renderer should be used.
* @param renderer - The render function.
* @example
* ```
* customComponents.registerComposerPreview(
* (composerText) => composerText === "hello!",
* (props, originalComponent) => {
* return <b>You've said hello!</b>;
* }
* );
* ```
*/
registerComposerPreview(
filterFn: (composerText: string, roomId: string) => boolean,
renderer: CustomComposerPreviewRenderFunction,
): void;
}