Sending URL Preview Bundles (MSC 4095) (#34150)

* Fetches link previews for all links in message (instead of just the first one)

* message url component to display preview for multiple urls

* Revert visible behaviour back to only showing one URL preview

While fetching all link previews in the text message.

* Moved URL preview VM to MessageComposer

* Added com.beeper.linkpreviews to messages sent with the markdown compositor

* MSC 4452 implemented for the older (markdown) composer

* claude told me to use logger instead of console

* Previews generated with snapshot instead of vm

* moved attachPreviews to a separate file

* added attach URL previews to rich text editor

* don't let attachUrlPreviews block clearning the composer

* fixed linter errors

* moved url preview behind labs feature gate

* passed linters

* moved lab feature checking to where attach preview is used

* claude wrote some unit tests for url previewing

* fixed linter errors

* added feature to labs.md

* fixed linter errors

* fixed oxfmt error

* set previews to none if all previews failed

* resolved PR reviews besides the ones that requires a larger code change

* moved url preview VM creation to message composer

* removed delay when clearing composer url preview on message send

* minor changes to stop using globals everywhere

* moved debouncing responsibility to urlpreviewVM

* minor lint fixes

* added comment

* remove composer content from the state of urlpreview, moved it to the vm

* urlpreviewwrapper depends on the vm only

* added comments

* edited the comment

* updated snapshots

* claude fixed tests

* enable feature for tests that requires MSC4095

* updated snapshot
This commit is contained in:
Sirius
2026-07-13 15:31:18 +00:00
committed by GitHub
parent 6aeb5f552b
commit 84653bb416
25 changed files with 588 additions and 210 deletions
@@ -6,7 +6,7 @@ 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 React, { type JSX, createRef, type ReactNode } from "react";
import React, { type JSX, createRef, type ReactNode, useEffect } from "react";
import classNames from "classnames";
import {
type IEventRelation,
@@ -19,6 +19,7 @@ import {
import { Tooltip } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { LockOffIcon, SendSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -50,12 +51,19 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import { type VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysiwyg_composer/";
import { type MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext";
import {
type MatrixClientProps,
withMatrixClientHOC,
useMatrixClientContext,
} from "../../../contexts/MatrixClientContext";
import { UIFeature } from "../../../settings/UIFeature";
import { formatTimeLeft } from "../../../DateUtils";
import RoomReplacedSvg from "../../../../res/img/room_replaced.svg";
import { MessageComposerUrlPreviewWrapper } from "./MessageComposerUrlPreview";
import { Type } from "../../../editor/parts";
import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext";
import PlatformPeg from "../../../PlatformPeg";
import { useSettingValue } from "../../../hooks/useSettings";
// The prefix used when persisting editor drafts to localstorage.
export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_";
@@ -88,6 +96,7 @@ interface IProps extends MatrixClientProps {
relation?: IEventRelation;
e2eStatus?: E2EStatus;
compact?: boolean;
urlPreviewVm: MessageComposerUrlPreviewViewModel;
}
interface IState {
@@ -103,8 +112,6 @@ interface IState {
isWysiwygLabEnabled: boolean;
isRichTextEnabled: boolean;
initialComposerContent: string;
// Specifically for generating previews only.
urlPreviewComposerContent: string;
}
type WysiwygComposerState = {
@@ -146,7 +153,6 @@ export class MessageComposer extends React.Component<IProps, IState> {
this.state = {
isComposerEmpty: initialComposerContent?.length === 0,
composerContent: initialComposerContent,
urlPreviewComposerContent: initialComposerContent,
haveRecording: false,
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
isMenuOpen: false,
@@ -395,7 +401,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
};
private sendMessage = async (): Promise<void> => {
this.setState({ urlPreviewComposerContent: "" });
this.props.urlPreviewVm.updateWithText({ content: "", debounced: false });
if (this.state.haveRecording && this.voiceRecordingButton.current) {
// There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording.
@@ -418,25 +424,25 @@ export class MessageComposer extends React.Component<IProps, IState> {
roomContext: this.context,
relation,
replyToEvent,
urlPreviewSnapshot: this.props.urlPreviewVm.getSnapshot(),
});
}
};
private onChange = (model: EditorModel): void => {
this.props.urlPreviewVm.updateWithText({
content: model.contentPlainText,
debounced: true,
});
this.setState({
urlPreviewComposerContent: model
.serializeParts()
.filter((part) => part.type === Type.Plain)
.map((part) => part.text)
.join(" "),
isComposerEmpty: model.isEmpty,
});
};
private onWysiwygChange = (content: string): void => {
this.props.urlPreviewVm.updateWithText({ content, debounced: true });
this.setState({
composerContent: content,
urlPreviewComposerContent: content,
isComposerEmpty: content?.length === 0,
});
};
@@ -601,6 +607,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
onChange={this.onChange}
disabled={this.state.haveRecording}
toggleStickerPickerOpen={this.toggleStickerPickerOpen}
urlPreviewVm={this.props.urlPreviewVm}
/>
);
}
@@ -686,7 +693,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
return (
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
<div className="mx_MessageComposer_wrapper">
<MessageComposerUrlPreviewWrapper content={this.state.urlPreviewComposerContent} />
<MessageComposerUrlPreviewWrapper urlPreviewVm={this.props.urlPreviewVm} />
<UserIdentityWarning room={this.props.room} key={this.props.room.roomId} />
<ReplyPreview
replyToEvent={this.props.replyToEvent}
@@ -735,4 +742,24 @@ export class MessageComposer extends React.Component<IProps, IState> {
}
const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer);
export default MessageComposerWithMatrixClient;
export default function MessageComposerWrapper(props: Omit<IProps, "mxClient" | "urlPreviewVm">): JSX.Element {
const { showUrlPreview } = useScopedRoomContext("showUrlPreview");
const client = useMatrixClientContext();
const urlPreviewBundle = useSettingValue("feature_msc4095_url_preview_bundle");
const urlPreviewVm = useCreateAutoDisposedViewModel(
() =>
new MessageComposerUrlPreviewViewModel({
client,
visible: showUrlPreview,
showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true,
urlPreviewBundle,
}),
);
useEffect(() => {
void urlPreviewVm.updateUrlPreviewVisible(showUrlPreview);
}, [urlPreviewVm, showUrlPreview]);
return <MessageComposerWithMatrixClient {...props} urlPreviewVm={urlPreviewVm} />;
}
@@ -9,7 +9,7 @@ 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 { MessageComposerUrlPreviewWrapper } from "./MessageComposerUrlPreview";
import {
getMockClientWithEventEmitter,
getRoomContext,
@@ -22,6 +22,11 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { CustomComponentsApi } from "../../../modules/customComponentApi";
import {
DEBOUNCE_REQUEST_TIMEOUT_MS,
MessageComposerUrlPreviewViewModel,
type MessageComposerUrlPreviewViewModelProps,
} from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
// @vitest-environment happy-dom
@@ -33,6 +38,26 @@ const BASIC_PREVIEW_OGDATA = {
"og:site_name": "Example.org",
};
function getUrlPreviewVm(client: MatrixClient, content?: string): MessageComposerUrlPreviewViewModel {
const props: MessageComposerUrlPreviewViewModelProps = {
client,
visible: true,
showTooltips: false,
urlPreviewBundle: false,
};
if (content !== undefined) {
props.content = content;
}
const vm = new MessageComposerUrlPreviewViewModel(props);
if (content !== undefined) {
// Mirror how MessageComposer drives the view model so previews are actually computed.
void vm.updateWithText({ content, debounced: false });
}
return vm;
}
describe("MessageComposerUrlPreview", () => {
let client: MatrixClient;
let originalMxModuleApi: ModuleApi;
@@ -66,11 +91,15 @@ describe("MessageComposerUrlPreview", () => {
}
test("to be empty without a link to preview", () => {
const { container } = wrapComponent(<MessageComposerUrlPreviewWrapper content="Test a string" />);
const { container } = wrapComponent(
<MessageComposerUrlPreviewWrapper urlPreviewVm={getUrlPreviewVm(client, "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" />);
const { getByText } = wrapComponent(
<MessageComposerUrlPreviewWrapper urlPreviewVm={getUrlPreviewVm(client, "https://example.org")} />,
);
await waitFor(
() => {
expect(getByText("Example.org")).toBeDefined();
@@ -87,7 +116,10 @@ describe("MessageComposerUrlPreview", () => {
() => <strong>Fake preview</strong>,
);
const { getByText } = wrapComponent(
<MessageComposerUrlPreviewWrapper content="https://example.org" moduleApi={modApi} />,
<MessageComposerUrlPreviewWrapper
moduleApi={modApi}
urlPreviewVm={getUrlPreviewVm(client, "https://example.org")}
/>,
);
await waitFor(
() => {
@@ -105,12 +137,20 @@ describe("MessageComposerUrlPreview", () => {
() => <strong>Fake preview</strong>,
);
const { container, getByText, queryByText, rerender } = wrapComponent(
<MessageComposerUrlPreviewWrapper content="show-fake-preview" moduleApi={modApi} />,
<MessageComposerUrlPreviewWrapper
urlPreviewVm={getUrlPreviewVm(client, "show-fake-preview")}
moduleApi={modApi}
/>,
);
await waitFor(() => {
expect(getByText("Fake preview")).toBeDefined();
});
rerender(<MessageComposerUrlPreviewWrapper content="other-text" moduleApi={modApi} />);
rerender(
<MessageComposerUrlPreviewWrapper
urlPreviewVm={getUrlPreviewVm(client, "no-longer-matching")}
moduleApi={modApi}
/>,
);
await waitFor(() => {
expect(queryByText("Fake preview")).toBeNull();
});
@@ -5,62 +5,25 @@ 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 React, { useEffect, useState, type ReactNode } from "react";
import { MessageComposerUrlPreviewView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
import React, { type ReactNode } from "react";
import { MessageComposerUrlPreviewView, useViewModel } from "@element-hq/web-shared-components";
import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
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";
export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500;
export function MessageComposerUrlPreviewWrapper({
content,
urlPreviewVm: vm,
moduleApi = ModuleApi.instance,
}: {
content: string;
urlPreviewVm: MessageComposerUrlPreviewViewModel;
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,
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} />,
);
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],
// Update instantly if content is empty (e.g. sent message or cleared input)
content ? DEBOUNCE_REQUEST_TIMEOUT_MS : 0,
);
const { roomId } = useScopedRoomContext("showUrlPreview", "roomId");
const { content } = useViewModel(vm);
const customComponent = moduleApi.customComponents.renderComposerPreview({ text: content, roomId: roomId! }, () => (
<MessageComposerUrlPreviewView vm={vm} />
));
return customComponent ?? <MessageComposerUrlPreviewView vm={vm} />;
}
@@ -25,8 +25,8 @@ import {
import { type DebouncedFunc, throttle } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { type Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
import { type RoomMessageEventContent } from "matrix-js-sdk/src/types";
import { type RoomMessageEventContent } from "../../../../@types/url-preview";
import dis from "../../../dispatcher/dispatcher";
import EditorModel from "../../../editor/model";
import {
@@ -64,8 +64,9 @@ import { type Caret } from "../../../editor/caret";
import { type IDiff } from "../../../editor/diff";
import { getBlobSafeMimeType } from "../../../utils/blobs";
import { EMOJI_REGEX } from "../../../HtmlUtils";
import { attachMentions, attachRelation } from "../../../utils/messages";
import { attachMentions, attachRelation, attachUrlPreviews } from "../../../utils/messages";
import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel";
import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
// The prefix used when persisting editor drafts to localstorage.
export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_";
@@ -137,6 +138,7 @@ interface ISendMessageComposerProps extends MatrixClientProps {
disabled?: boolean;
onChange?(model: EditorModel): void;
toggleStickerPickerOpen: () => void;
urlPreviewVm: MessageComposerUrlPreviewViewModel;
}
export class SendMessageComposer extends React.Component<ISendMessageComposerProps> {
@@ -410,6 +412,21 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
this.sendQuickReaction();
}
const clearComposerAndPushHistory = (): void => {
this.sendHistoryManager.save(model, replyToEvent);
// clear composer
model.reset([]);
this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus();
this.clearStoredEditorState();
if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({
action: "scroll_to_bottom",
timelineRenderingType: this.context.timelineRenderingType,
});
}
};
if (shouldSend) {
const { roomId } = this.props.room;
if (!content) {
@@ -420,9 +437,14 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
this.props.relation,
);
}
// don't bother sending an empty message
if (!content.body.trim()) return;
// clear composer first so the user doesn't actually see the delay of attach URL preview image files
clearComposerAndPushHistory();
attachUrlPreviews(this.props.urlPreviewVm.getSnapshot(), content);
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}
@@ -460,19 +482,8 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
sendRoundTripMetric(this.props.mxClient, roomId, resp.event_id);
});
}
}
this.sendHistoryManager.save(model, replyToEvent);
// clear composer
model.reset([]);
this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus();
this.clearStoredEditorState();
if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({
action: "scroll_to_bottom",
timelineRenderingType: this.context.timelineRenderingType,
});
} else {
clearComposerAndPushHistory();
}
}
@@ -15,6 +15,7 @@ import {
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import { type RoomMessageEventContent } from "matrix-js-sdk/src/types";
import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components";
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
import SettingsStore from "../../../../../settings/SettingsStore";
@@ -33,19 +34,20 @@ import { CommandCategories, getCommand } from "../../../../../slash-commands/Sla
import { runSlashCommand, shouldSendAnyway } from "../../../../../editor/commands";
import { Action } from "../../../../../dispatcher/actions";
import { addReplyToMessageContent } from "../../../../../utils/Reply";
import { attachRelation } from "../../../../../utils/messages";
import { attachRelation, attachUrlPreviews } from "../../../../../utils/messages";
export interface SendMessageParams {
mxClient: MatrixClient;
relation?: IEventRelation;
replyToEvent?: MatrixEvent;
roomContext: Pick<IRoomState, "timelineRenderingType" | "room">;
urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot;
}
export async function sendMessage(
message: string,
isHTML: boolean,
{ roomContext, mxClient, ...params }: SendMessageParams,
{ roomContext, mxClient, urlPreviewSnapshot, ...params }: SendMessageParams,
): Promise<ISendEventResponse | undefined> {
const { relation, replyToEvent } = params;
const { room } = roomContext;
@@ -112,6 +114,7 @@ export async function sendMessage(
// if content is null, we haven't done any slash command processing, so generate some content
content ??= await createMessageContent(message, isHTML, params);
attachUrlPreviews(urlPreviewSnapshot, content);
// TODO replace emotion end of message ?
+11 -1
View File
@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { diffAtCaret, diffDeletion, type IDiff } from "./diff";
import DocumentPosition, { type IPosition } from "./position";
import Range from "./range";
import { type SerializedPart, type Part, type PartCreator } from "./parts";
import { type SerializedPart, type Part, type PartCreator, Type } from "./parts";
import { type ICallback } from "./autocomplete";
import type AutocompleteWrapperModel from "./autocomplete";
import type DocumentOffset from "./offset";
@@ -49,6 +49,16 @@ export default class EditorModel {
private autoCompletePartCount = 0;
private transformCallback: TransformCallback | null = null;
/**
* Returns the plain text parts of the editor content only (skipping mentions)
*/
public get contentPlainText(): string {
return this.serializeParts()
.filter((part) => part.type === Type.Plain)
.map((part) => part.text)
.join(" ");
}
public constructor(
parts: Part[],
partCreator: PartCreator,
+2
View File
@@ -1572,6 +1572,8 @@
"sliding_sync_disabled_notice": "Sign in again to disable",
"sliding_sync_server_no_support": "Your server lacks support",
"unrealiable_e2e": "Unreliable in encrypted rooms",
"url_preview_bundle": "URL Preview Bundle (MSC4095)",
"url_preview_bundle_description": "Send URL previews in message alongside message content",
"video_rooms": "Video rooms",
"video_rooms_a_new_way_to_chat": "A new way to chat over voice and video in %(brand)s.",
"video_rooms_always_on_voip_channels": "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.",
+10
View File
@@ -229,6 +229,7 @@ export interface Settings {
"feature_msc4362_encrypted_state_events": IFeature;
"feature_user_status": IFeature;
"feature_login_with_qr": IFeature;
"feature_msc4095_url_preview_bundle": IFeature;
// These are in the feature namespace but aren't actually features
"feature_hidebold": IBaseSetting<boolean>;
@@ -641,6 +642,15 @@ export const SETTINGS: Settings = {
isFeature: true,
default: false,
},
"feature_msc4095_url_preview_bundle": {
labsGroup: LabGroup.Messaging,
displayName: _td("labs|url_preview_bundle"),
description: _td("labs|url_preview_bundle_description"),
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
supportedLevelsAreOrdered: true,
isFeature: true,
default: false,
},
/**
* With the transition to Compound we are moving to a base font size
* of 16px. We're taking the opportunity to move away from the `baseFontSize`
+5
View File
@@ -160,11 +160,13 @@ export class UrlPreviewFetcher {
let siteIcon: string | undefined;
if (typeof response["og:image"] === "string" && loadMedia) {
const mxcImageFull = response["og:image"];
const media = mediaFromMxc(response["og:image"], this.client);
const declaredHeight = UrlPreviewFetcher.getNumberFromOpenGraph(response["og:image:height"]);
const declaredWidth = UrlPreviewFetcher.getNumberFromOpenGraph(response["og:image:width"]);
const imageSize = UrlPreviewFetcher.getNumberFromOpenGraph(response["matrix:image:size"]);
const alt = typeof response["og:image:alt"] === "string" ? response["og:image:alt"] : undefined;
const imageType = typeof response["og:image:type"] === "string" ? response["og:image:type"] : undefined;
if (UrlPreviewFetcher.isImagePreview(declaredWidth, declaredHeight, imageSize)) {
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX);
@@ -176,6 +178,8 @@ export class UrlPreviewFetcher {
image = {
imageThumb: thumb,
imageFull: media.srcHttp ?? thumb,
mxcImageFull,
imageType,
width,
height,
fileSize: UrlPreviewFetcher.getNumberFromOpenGraph(response["matrix:image:size"]),
@@ -195,6 +199,7 @@ export class UrlPreviewFetcher {
description,
siteName,
siteIcon,
ogUrl: response["og:url"],
showTooltipOnLink: !!(link !== title && this.showTooltips),
image,
} satisfies UrlPreview;
@@ -6,6 +6,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o
"description": undefined,
"image": undefined,
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
@@ -19,6 +20,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o
"description": undefined,
"image": undefined,
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "Site name",
@@ -32,6 +34,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o
"description": undefined,
"image": undefined,
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
@@ -45,6 +48,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o
"description": undefined,
"image": undefined,
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "Cool site",
@@ -62,10 +66,13 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o
"height": 478,
"imageFull": "https://example.org/image/src",
"imageThumb": "https://example.org/image/thumb",
"imageType": undefined,
"mxcImageFull": "mxc://example.org/abc",
"playable": false,
"width": 478,
},
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
@@ -83,10 +90,13 @@ exports[`UrlPreviewFetcher > should preview a URL with media 1`] = `
"height": 128,
"imageFull": "https://example.org/image/src",
"imageThumb": "https://example.org/image/thumb",
"imageType": undefined,
"mxcImageFull": "mxc://example.org/abc",
"playable": false,
"width": 128,
},
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
+25
View File
@@ -6,9 +6,12 @@ Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent, type IContent, type IMentions, type IEventRelation } from "matrix-js-sdk/src/matrix";
import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components";
import type EditorModel from "../editor/model";
import { Type } from "../editor/parts";
import { type RoomMessageEventContent } from "../../@types/url-preview";
import SettingsStore from "../settings/SettingsStore";
/**
* Build the mentions information based on the editor model (and any related events):
@@ -107,3 +110,25 @@ export function attachRelation(content: IContent, relation?: IEventRelation): vo
};
}
}
export function attachUrlPreviews(
urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot,
content: RoomMessageEventContent,
): void {
if (!SettingsStore.getValue("feature_msc4095_url_preview_bundle")) return;
if (urlPreviewSnapshot.previews.length) {
content["com.beeper.linkpreviews"] = urlPreviewSnapshot.previews.map((preview) => {
return {
"matched_url": preview.link,
"og:url": preview.ogUrl,
"og:title": preview.title,
"og:description": preview.description,
"og:image": preview.image?.mxcImageFull,
"og:image:width": preview.image?.width,
"og:image:height": preview.image?.height,
"og:image:type": preview.image?.imageType,
"matrix:image:size": preview.image?.fileSize,
};
});
}
}
@@ -27,9 +27,15 @@ function getViewModel({ visible } = { visible: true }): {
getUrlPreview: vi.fn(),
mxcUrlToHttp: vi.fn(),
} as unknown as MatrixClient;
const vm = new MessageComposerUrlPreviewViewModel({ client, visible, showTooltips: false });
const vm = new MessageComposerUrlPreviewViewModel({
client,
visible,
showTooltips: false,
urlPreviewBundle: false,
});
return { vm, client: client as unknown as { getUrlPreview: Mock; mxcUrlToHttp: Mock } };
}
describe("MessageComposerUrlPreviewViewModel", () => {
let originalDevicePixelRatio: Window["devicePixelRatio"];
beforeAll(() => {
@@ -42,73 +48,78 @@ describe("MessageComposerUrlPreviewViewModel", () => {
it("should return no preview by default", () => {
expect(getViewModel().vm.getSnapshot()).toMatchInlineSnapshot(`
{
"preview": null,
}
`);
{
"content": "",
"previews": [],
}
`);
});
it("should preview a valid URL in text", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
await vm.updateWithText("Check out https://example.org today");
await vm.updateWithText({ content: "Check out https://example.org today", debounced: false });
expect(vm.getSnapshot()).toMatchSnapshot();
});
it("should return null when preview is not visible", async () => {
it("should return empty list when preview is not visible", async () => {
const { vm, client } = getViewModel({ visible: false });
await vm.updateWithText("https://example.org");
expect(vm.getSnapshot().preview).toBeNull();
await vm.updateWithText({ content: "https://example.org", debounced: false });
expect(vm.getSnapshot().previews).toHaveLength(0);
expect(client.getUrlPreview).not.toHaveBeenCalled();
});
it("should return null when all URL fetches fail", async () => {
it("should return empty list when all URL fetches fail", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockRejectedValue(new Error("Forced test failure"));
await vm.updateWithText("https://example.org");
expect(vm.getSnapshot().preview).toBeNull();
await vm.updateWithText({ content: "https://example.org", debounced: false });
expect(vm.getSnapshot().previews).toHaveLength(0);
});
it("should use the first URL with a valid preview when multiple are given", async () => {
it("should use all URLs with a valid preview when multiple are given", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview
.mockRejectedValueOnce(new Error("First URL failed"))
.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
await vm.updateWithText("https://example.org/one https://example.org/two");
expect(vm.getSnapshot().preview?.link).toEqual("https://example.org/two");
await vm.updateWithText({ content: "https://example.org/one https://example.org/two", debounced: false });
expect(vm.getSnapshot().previews[0]?.link).toEqual("https://example.org/two");
expect(vm.getSnapshot().previews).toHaveLength(1);
});
it("should not re-fetch when text changes but the URL set does not", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA);
await vm.updateWithText("https://example.org");
await vm.updateWithText("https://example.org some extra words");
await vm.updateWithText({ content: "https://example.org", debounced: false });
await vm.updateWithText({ content: "https://example.org some extra words", debounced: false });
expect(client.getUrlPreview).toHaveBeenCalledTimes(1);
});
it("should deduplicate repeated URLs", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA);
await vm.updateWithText("https://example.org https://example.org https://example.org");
await vm.updateWithText({
content: "https://example.org https://example.org https://example.org",
debounced: false,
});
expect(client.getUrlPreview).toHaveBeenCalledTimes(1);
});
it("should hide preview when made invisible", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA);
await vm.updateWithText("https://example.org");
expect(vm.getSnapshot().preview).not.toBeNull();
await vm.updateWithText({ content: "https://example.org", debounced: false });
expect(vm.getSnapshot().previews).not.toHaveLength(0);
await vm.updateUrlPreviewVisible(false);
expect(vm.getSnapshot().preview).toBeNull();
expect(vm.getSnapshot().previews).toHaveLength(0);
});
it("should restore preview when made visible again", async () => {
const { vm, client } = getViewModel({ visible: false });
client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA);
await vm.updateWithText("https://example.org");
expect(vm.getSnapshot().preview).toBeNull();
await vm.updateWithText({ content: "https://example.org", debounced: false });
expect(vm.getSnapshot().previews).toHaveLength(0);
await vm.updateUrlPreviewVisible(true);
expect(vm.getSnapshot().preview).not.toBeNull();
expect(vm.getSnapshot().previews).not.toHaveLength(0);
});
it("should preview a URL with media", async () => {
@@ -128,7 +139,7 @@ describe("MessageComposerUrlPreviewViewModel", () => {
if (width) return "https://example.org/image/thumb";
return "https://example.org/image/src";
});
await vm.updateWithText("https://example.org");
await vm.updateWithText({ content: "https://example.org", debounced: false });
expect(vm.getSnapshot()).toMatchSnapshot();
});
});
@@ -8,15 +8,20 @@
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { BaseViewModel, type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components";
import { debounce } from "lodash";
import { UrlPreviewFetcher } from "../../utils/UrlPreviewFetcher";
const logger = rootLogger.getChild("MessageComposerUrlPreviewViewModel");
export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500;
export interface MessageComposerUrlPreviewViewModelProps {
client: MatrixClient;
visible: boolean;
showTooltips: boolean;
urlPreviewBundle: boolean;
content?: string;
}
export class MessageComposerUrlPreviewViewModel extends BaseViewModel<
@@ -27,6 +32,9 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel<
/**
* Calculated set of links from the message text.
*
* Links are inserted in the order they appear in the message text,
* which guarantees Array.from(this.links) to be in the same order.
*/
private links: Set<string> = new Set();
@@ -35,52 +43,91 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel<
*/
private urlPreviewVisible: boolean;
private content: string;
public constructor(props: MessageComposerUrlPreviewViewModelProps) {
super(props, { preview: null });
super(props, { previews: [], content: props.content ?? "" });
this.urlPreviewVisible = props.visible;
this.fetcher = new UrlPreviewFetcher(props.client, Date.now(), props.showTooltips);
this.content = this.snapshot.current.content;
}
private async computeSnapshot(): Promise<void> {
private async computeSnapshot(content: string): Promise<void> {
const newLinksOrdered = content
.split(" ")
.map((w) => w.trim())
.filter((word) => URL.canParse(word));
const newLinks = new Set(newLinksOrdered);
if (!this.urlPreviewVisible) {
this.snapshot.set({ preview: null });
// Clear any existing previews whenever previews are hidden, regardless of
// whether the URL set has changed (e.g. when toggled invisible).
this.snapshot.set({ previews: [], content });
return;
}
// We always select the *first* viable preview out of the message.
// Subsequent links are ignored.
for (const link of this.links) {
try {
const preview = await this.fetcher.fetchPreview(link, true);
if (preview) {
this.snapshot.set({ preview });
return;
if (this.links.symmetricDifference(newLinks).size === 0) {
// Skip if the URL set hasn't changed
return;
}
this.links = newLinks;
let previews;
if (this.props.urlPreviewBundle) {
const previewRequests = Array.from(this.links).map(async (link) => {
try {
return await this.fetcher.fetchPreview(link, true);
} catch (ex) {
logger.warn("Fetching preview failed", ex);
return null;
}
} catch (ex) {
logger.warn("Fetching preview failed", ex);
});
// Fetch previews for all links in the message text,
// And remove the ones with erroneous responses
const previewResponses = await Promise.all(previewRequests);
previews = previewResponses.filter((res) => res !== null);
this.snapshot.set({ previews, content });
} else {
for (const link of this.links) {
try {
const preview = await this.fetcher.fetchPreview(link, true);
if (preview) {
this.snapshot.set({ previews: [preview], content });
return;
}
} catch (ex) {
logger.warn("Fetching preview failed", ex);
}
}
this.snapshot.set({ previews: [], content });
}
this.snapshot.set({ preview: null });
}
/**
* Trigger a recalculation of the links in the provided text.
* @param content Plaintext from the message composer.
*/
public async updateWithText(content: string): Promise<void> {
const newLinks = new Set(
content
.split(" ")
.map((w) => w.trim())
.filter((word) => URL.canParse(word)),
);
if (this.links.symmetricDifference(newLinks).size === 0) {
// Skip if the URL set hasn't changed
return;
public async updateWithText({ content, debounced }: { content?: string; debounced: boolean }): Promise<void> {
if (content !== undefined) {
this.content = content;
}
if (debounced) {
return this.computeSnapshotDebounced(this.content);
} else {
return this.computeSnapshot(this.content);
}
this.links = newLinks;
return this.computeSnapshot();
}
private computeSnapshotDebounced = debounce(
(content) => this.computeSnapshot(content),
DEBOUNCE_REQUEST_TIMEOUT_MS,
);
/**
* Update the view model about visible state of previews.
* @param urlPreviewVisible Whether URL previews are hidden for this room.
@@ -90,6 +137,6 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel<
public readonly updateUrlPreviewVisible = (urlPreviewVisible: boolean): Promise<void> => {
this.urlPreviewVisible = urlPreviewVisible;
this.fetcher.clearCache();
return this.computeSnapshot();
return this.computeSnapshot(this.content);
};
}
@@ -2,38 +2,48 @@
exports[`MessageComposerUrlPreviewViewModel > should preview a URL with media 1`] = `
{
"preview": {
"author": undefined,
"description": undefined,
"image": {
"alt": undefined,
"fileSize": 10000,
"height": 128,
"imageFull": "https://example.org/image/src",
"imageThumb": "https://example.org/image/thumb",
"playable": false,
"width": 128,
"content": "https://example.org",
"previews": [
{
"author": undefined,
"description": undefined,
"image": {
"alt": undefined,
"fileSize": 10000,
"height": 128,
"imageFull": "https://example.org/image/src",
"imageThumb": "https://example.org/image/thumb",
"imageType": undefined,
"mxcImageFull": "mxc://example.org/abc",
"playable": false,
"width": 128,
},
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
"title": "Media example",
},
"link": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
"title": "Media example",
},
],
}
`;
exports[`MessageComposerUrlPreviewViewModel > should preview a valid URL in text 1`] = `
{
"preview": {
"author": undefined,
"description": "This is a description",
"image": undefined,
"link": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "Example.org",
"title": "This is an example!",
},
"content": "Check out https://example.org today",
"previews": [
{
"author": undefined,
"description": "This is a description",
"image": undefined,
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "Example.org",
"title": "This is an example!",
},
],
}
`;