From b6cbc3a9d89f5fe6df4f6d85ce6272f0b084605f Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:32:07 +0100 Subject: [PATCH] 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 --- .../rooms/MessageComposerUrlPreview.test.tsx | 99 +++++++++++++++++++ .../views/rooms/MessageComposerUrlPreview.tsx | 48 ++++++--- apps/web/src/modules/customComponentApi.ts | 42 +++++++- .../module-api/element-web-module-api.api.md | 15 +++ .../module-api/src/api/custom-components.ts | 58 +++++++++++ 5 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx new file mode 100644 index 0000000000..344836f994 --- /dev/null +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx @@ -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[0]): ReturnType { + return render(component, { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + } + + test("to be empty without a link to preview", () => { + const { container } = wrapComponent(); + expect(container).toMatchInlineSnapshot(`
`); + }); + test("to contain a link when there is a URL", async () => { + const { getByText } = wrapComponent(); + 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, + () => Fake preview, + ); + const { getByText } = wrapComponent( + , + ); + await waitFor( + () => { + expect(getByText("Fake preview")).toBeDefined(); + }, + { timeout: DEBOUNCE_REQUEST_TIMEOUT_MS }, + ); + }); +}); diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index 4b8dd21233..18030de994 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -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(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! }, + () => , + ); + + 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 ; + return customComponent ?? ; } diff --git a/apps/web/src/modules/customComponentApi.ts b/apps/web/src/modules/customComponentApi.ts index 7fd297b94b..a6ead66cb5 100644 --- a/apps/web/src/modules/customComponentApi.ts +++ b/apps/web/src/modules/customComponentApi.ts @@ -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[0]; +type ComposerPreviewFilterFn = Parameters[0]; type EventRenderer = { eventTypeOrFilter: EventTypeOrFilter; @@ -37,6 +40,11 @@ interface CustomMessageRenderHints extends Omit Promise; } +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; + } } diff --git a/packages/module-api/element-web-module-api.api.md b/packages/module-api/element-web-module-api.api.md index 47ef6a5828..c23218f60e 100644 --- a/packages/module-api/element-web-module-api.api.md +++ b/packages/module-api/element-web-module-api.api.md @@ -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; + // @alpha export interface CustomisationsApi { registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void; diff --git a/packages/module-api/src/api/custom-components.ts b/packages/module-api/src/api/custom-components.ts index 0265c9c63d..0f45681500 100644 --- a/packages/module-api/src/api/custom-components.ts +++ b/packages/module-api/src/api/custom-components.ts @@ -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 =

( */ export type CustomLoginRenderFunction = ExtendablePropsRenderFunction; +/** + * 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; + /** * 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 You've said hello!; + * } + * ); + * ``` + */ + registerComposerPreview( + filterFn: (composerText: string, roomId: string) => boolean, + renderer: CustomComposerPreviewRenderFunction, + ): void; }