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:
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user