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
+40
View File
@@ -0,0 +1,40 @@
/*
* 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 {
type EncryptedFile,
type RoomMessageEventContent as SdkRoomMessageEventContent,
} from "matrix-js-sdk/src/types";
/**
* Bundled URL previews in MSC-4095
*
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095
*/
interface UnstableBundledUrlPreviews {
"com.beeper.linkpreviews"?: UnstableBundledUrlPreviewSingle[];
}
/**
* Single item in bundled URL previews in MSC4095
*
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095
*/
export interface UnstableBundledUrlPreviewSingle {
"matched_url": string;
"beeper:image:encryption"?: EncryptedFile;
"matrix:image:size"?: number;
"og:image"?: string;
"og:url"?: string;
"og:image:width"?: number;
"og:image:height"?: number;
"og:image:type"?: string;
"og:title"?: string;
"og:description"?: string;
}
export type RoomMessageEventContent = SdkRoomMessageEventContent & UnstableBundledUrlPreviews;
@@ -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!",
},
],
}
`;
@@ -35,6 +35,7 @@ import { addTextToComposer } from "../../../../test-utils/composer";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass";
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
import { MessageComposerUrlPreviewViewModel } from "../../../../../src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts";
import { SDKContext } from "../../../../../src/contexts/SDKContext.ts";
jest.mock("../../../../../src/utils/local-room", () => ({
@@ -186,6 +187,12 @@ describe("<SendMessageComposer/>", () => {
spyDispatcher.mockReset();
});
const urlPreviewVm = new MessageComposerUrlPreviewViewModel({
client: mockClient,
visible: false,
showTooltips: false,
urlPreviewBundle: false,
});
const defaultProps = {
room: mockRoom,
toggleStickerPickerOpen: jest.fn(),
@@ -194,7 +201,7 @@ describe("<SendMessageComposer/>", () => {
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider room={mockRoom} {...roomContext}>
<RoomUploadContextProvider>
<SendMessageComposer {...defaultProps} {...props} />
<SendMessageComposer {...defaultProps} {...props} urlPreviewVm={urlPreviewVm} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
@@ -439,6 +446,13 @@ describe("<SendMessageComposer/>", () => {
it("should call prepareToEncrypt when the user is typing", async () => {
const cli = stubClient();
const urlPreviewVm = new MessageComposerUrlPreviewViewModel({
client: cli,
visible: false,
showTooltips: false,
urlPreviewBundle: false,
});
cli.isRoomEncrypted = jest.fn().mockReturnValue(true);
const room = mkStubRoom("!roomId:server", "Room", cli);
@@ -448,7 +462,11 @@ describe("<SendMessageComposer/>", () => {
<MatrixClientContext.Provider value={cli}>
<ScopedRoomContextProvider {...({ room } as unknown as RoomContextType)}>
<RoomUploadContextProvider>
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
<SendMessageComposer
room={room}
toggleStickerPickerOpen={jest.fn()}
urlPreviewVm={urlPreviewVm}
/>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
@@ -65,7 +65,11 @@ describe("message", () => {
describe("sendMessage", () => {
it("Should not send empty html message", async () => {
// When
await sendMessage("", true, { roomContext: defaultRoomContext, mxClient: mockClient });
await sendMessage("", true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
expect(mockClient.sendMessage).toHaveBeenCalledTimes(0);
@@ -80,6 +84,7 @@ describe("message", () => {
await sendMessage(message, true, {
roomContext: mockRoomContextWithoutId,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -93,6 +98,7 @@ describe("message", () => {
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -104,6 +110,7 @@ describe("message", () => {
roomContext: defaultRoomContext,
mxClient: mockClient,
relation: {},
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -118,6 +125,7 @@ describe("message", () => {
event_id: "valid_id",
rel_type: "m.does_not_match",
},
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -133,6 +141,7 @@ describe("message", () => {
event_id: "valid_id",
rel_type: "m.thread",
},
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -145,6 +154,7 @@ describe("message", () => {
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -172,6 +182,7 @@ describe("message", () => {
roomContext: defaultRoomContext,
mxClient: mockClient,
replyToEvent: mockReplyEvent,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -201,6 +212,7 @@ describe("message", () => {
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -212,7 +224,11 @@ describe("message", () => {
it("Should handle emojis", async () => {
// When
await sendMessage("🎉", false, { roomContext: defaultRoomContext, mxClient: mockClient });
await sendMessage("🎉", false, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
expect(spyDispatcher).toHaveBeenCalledWith({ action: "effects.confetti" });
@@ -227,6 +243,7 @@ describe("message", () => {
await sendMessage(validCommand, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -239,6 +256,7 @@ describe("message", () => {
await sendMessage(invalidPrefixCommand, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -256,6 +274,7 @@ describe("message", () => {
const result = await sendMessage(validCommand, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -270,6 +289,7 @@ describe("message", () => {
await sendMessage(inputText, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"myfakeroom",
@@ -289,6 +309,7 @@ describe("message", () => {
roomContext: defaultRoomContext,
mxClient: mockClient,
relation: mockRelation,
urlPreviewSnapshot: { previews: [], content: "" },
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
@@ -305,6 +326,7 @@ describe("message", () => {
roomContext: defaultRoomContext,
mxClient: mockClient,
replyToEvent: mockEvent,
urlPreviewSnapshot: { previews: [], content: "" },
});
expect(addReplySpy).toHaveBeenCalledTimes(1);
@@ -319,6 +341,7 @@ describe("message", () => {
roomContext: defaultRoomContext,
mxClient: mockClient,
replyToEvent: mockEvent,
urlPreviewSnapshot: { previews: [], content: "" },
});
expect(result).toBeUndefined();
@@ -333,6 +356,7 @@ describe("message", () => {
await sendMessage(invalidCommandInput, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// we expect the message to have been sent
@@ -353,6 +377,7 @@ describe("message", () => {
const result = await sendMessage(invalidCommandInput, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
expect(result).toBeUndefined();
@@ -7,11 +7,84 @@ Please see LICENSE files in the repository root for full details.
*/
import { type IContent } from "matrix-js-sdk/src/matrix";
import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components";
import { attachMentions } from "../../../src/utils/messages";
import { attachMentions, attachUrlPreviews } from "../../../src/utils/messages";
import EditorModel from "../../../src/editor/model";
import { mkEvent } from "../../test-utils";
import { createPartCreator } from "../editor/mock";
import { type RoomMessageEventContent } from "../../../@types/url-preview";
import SettingsStore from "../../../src/settings/SettingsStore";
describe("attachUrlPreviews", () => {
beforeEach(() => {
const original = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(setting) => setting === "feature_msc4095_url_preview_bundle" || original(setting),
);
});
afterEach(() => {
jest.restoreAllMocks();
});
const makeContent = (): RoomMessageEventContent =>
({ msgtype: "m.text", body: "hi https://example.com" }) as RoomMessageEventContent;
const snapshot = (image?: object): MessageComposerUrlPreviewSnapshot => ({
previews: [
{
link: "https://example.com",
showTooltipOnLink: false,
title: "Example",
siteName: "example.com",
description: "desc",
...(image ? { image } : {}),
} as any,
],
content: "https://example.com",
});
it("does nothing when there are no previews", () => {
const content = makeContent();
attachUrlPreviews({ previews: [], content: "" }, content);
expect(content["com.beeper.linkpreviews"]).toBeUndefined();
});
it("attaches a preview with no image", () => {
const content = makeContent();
attachUrlPreviews(snapshot(), content);
expect(content["com.beeper.linkpreviews"]).toEqual([
expect.objectContaining({ "og:title": "Example", "og:image": undefined }),
]);
});
it("embeds the mxc url from the preview image", () => {
const content = makeContent();
attachUrlPreviews(
snapshot({
imageThumb: "",
imageFull: "https://example.com/full.png",
mxcImageFull: "mxc://server/img",
imageType: "image/png",
fileSize: 1234,
width: 100,
height: 50,
playable: false,
}),
content,
);
expect(content["com.beeper.linkpreviews"]![0]).toEqual(
expect.objectContaining({
"og:image": "mxc://server/img",
"og:image:width": 100,
"og:image:type": "image/png",
"matrix:image:size": 1234,
}),
);
});
});
describe("attachMentions", () => {
const partsCreator = createPartCreator();
@@ -9,6 +9,7 @@ exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the sa
"description": "This is a description",
"image": undefined,
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "Example.org",
@@ -38,6 +39,7 @@ exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the us
"description": "This is a description",
"image": undefined,
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "Example.org",
@@ -76,6 +78,7 @@ exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false
"description": undefined,
"image": undefined,
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
@@ -96,6 +99,7 @@ exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = `
"description": "This is a description",
"image": undefined,
"link": "https://example.org",
"ogUrl": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "Example.org",
+4
View File
@@ -130,3 +130,7 @@ Enables logging in to Element Web/Desktop by using Element X to scan a QR code.
Requires server support, based on the v2024 edition of [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108).
Can only be configured via config.json, as labs flags cannot be set until after login, and are cleared on log out. The UI option is disabled.
Note, this does not affect the existing `Link new device` flow using the same MSC.
## URL Preview Bundle (MSC4095) (`feature_msc4095_url_preview_bundle`)
Attaches URL preview information generated by the homeserver on messages sent.
@@ -52,46 +52,55 @@ const Template: StoryFn<typeof MessageComposerUrlPreviewViewWrapper> = (args) =>
export const Default = Template.bind({});
Default.args = {
preview: {
title: "A simple title",
description: "A simple description",
link: "https://matrix.org",
siteName: "matrix.org",
showTooltipOnLink: false,
},
previews: [
{
title: "A simple title",
description: "A simple description",
link: "https://matrix.org",
siteName: "matrix.org",
showTooltipOnLink: false,
},
],
};
export const WithImage = Template.bind({});
WithImage.args = {
preview: {
...Default.args.preview!,
image: {
imageThumb: imagePreviewFile,
imageFull: imagePreviewFile,
alt: "The element logo",
playable: false,
previews: [
{
...Default.args.previews![0]!,
image: {
imageThumb: imagePreviewFile,
imageFull: imagePreviewFile,
alt: "The element logo",
playable: false,
mxcImageFull: "mxc://server/file",
},
},
},
],
};
export const WithImageAndSiteIcon = Template.bind({});
WithImageAndSiteIcon.args = {
preview: {
...Default.args.preview!,
siteIcon: siteIconFile,
image: {
imageThumb: imagePreviewFile,
imageFull: imagePreviewFile,
alt: "The element logo",
playable: false,
previews: [
{
...Default.args.previews![0]!,
siteIcon: siteIconFile,
image: {
imageThumb: imagePreviewFile,
imageFull: imagePreviewFile,
alt: "The element logo",
playable: false,
mxcImageFull: "mxc://server/file",
},
},
},
],
};
export const WithImageAndLoadsOfText = Template.bind({});
WithImageAndLoadsOfText.args = {
preview: {
...Default.args.preview!,
description: `Molestiae aliquam quos possimus molestiae id sit nulla rerum. Sunt cumque illum alias. Illo ipsa ut iure quia nulla magnam repellat.
previews: [
{
...Default.args.previews![0]!,
description: `Molestiae aliquam quos possimus molestiae id sit nulla rerum. Sunt cumque illum alias. Illo ipsa ut iure quia nulla magnam repellat.
Esse velit corporis sapiente temporibus quia ipsam. Pariatur est rem veritatis. Inventore sit consequatur odio ipsa error non assumenda. Est eum ex dignissimos voluptatibus voluptatem delectus modi. Nisi quia eius ea quibusdam. Aut eveniet maxime non.
@@ -100,11 +109,13 @@ WithImageAndLoadsOfText.args = {
Incidunt ut ea quae nobis. Reiciendis inventore quas qui eum voluptatem ex et qui. Adipisci quibusdam dolores hic inventore et suscipit cupiditate consequuntur.
Temporibus similique sint quo. Omnis tempora quidem explicabo in quidem magnam quia. Aut sunt accusantium ut et ut laborum debitis in. Enim nihil sit consectetur facilis quidem voluptatem. Quod impedit odit veritatis est laudantium tempore sit labore. Atque minima aliquam nostrum et.`,
image: {
imageThumb: imagePreviewFile,
imageFull: imagePreviewFile,
alt: "The element logo",
playable: false,
image: {
imageThumb: imagePreviewFile,
imageFull: imagePreviewFile,
alt: "The element logo",
playable: false,
mxcImageFull: "mxc://server/file",
},
},
},
],
};
@@ -17,7 +17,9 @@ import { useViewModel, type ViewModel } from "../../../core/viewmodel";
/** Snapshot data for rendering a URL preview attached to the composer. */
export interface MessageComposerUrlPreviewSnapshot {
/** URL preview to render. */
preview: UrlPreview | null;
previews: UrlPreview[];
/** Content of the composer when the snapshot is computed */
content: string;
}
/** Props for MessageComposerUrlPreviewView. */
@@ -33,15 +35,18 @@ export interface MessageComposerUrlPreviewProps {
}
/**
* MessageComposerUrlPreviewView renders a preview of a single URL above the messasge composer.
* MessageComposerUrlPreviewView renders a preview of all previewable URLs above the messasge composer.
*/
export function MessageComposerUrlPreviewView({ vm, className }: MessageComposerUrlPreviewProps): JSX.Element | null {
const { preview } = useViewModel(vm);
if (!preview) {
const { previews } = useViewModel(vm);
if (previews.length === 0) {
return null;
}
return (
<div className={classNames(className, styles.container)}>
// Show only the first preview to revert back to previous behaviour
// But have previews fetch all URL previews in the message text
const previewViews = previews.slice(0, 1).map((preview) => (
<div key={preview.link} className={classNames(className, styles.container)}>
<div>
{preview?.image?.imageThumb && (
<img className={styles.image} src={preview.image?.imageThumb} alt={preview.image.alt} />
@@ -53,5 +58,7 @@ export function MessageComposerUrlPreviewView({ vm, className }: MessageComposer
</div>
</div>
</div>
);
));
return <>{previewViews}</>;
}
@@ -57,6 +57,7 @@ Default.args = {
imageFull: imageFile,
alt: "Element logo",
playable: false,
mxcImageFull: "mxc://server/file",
},
};
@@ -112,6 +113,7 @@ Article.args = {
imageFull: imageFileWide,
alt: "A dog",
playable: false,
mxcImageFull: "mxc://server/file",
},
};
@@ -127,6 +129,7 @@ Video.args = {
imageFull: imageFileWide,
alt: "A dog",
playable: true,
mxcImageFull: "mxc://server/file",
},
};
@@ -151,6 +154,7 @@ SocialWithImage.args = {
imageFull: imageFileWide,
alt: "A dog",
playable: false,
mxcImageFull: "mxc://server/file",
},
};
@@ -166,6 +170,7 @@ WithVeryLongText.args = {
imageFull: imageFile,
alt: "Element logo",
playable: false,
mxcImageFull: "mxc://server/file",
},
};
@@ -180,5 +185,6 @@ WithTallImage.args = {
imageFull: imageFileTall,
alt: "Element logo",
playable: false,
mxcImageFull: "mxc://server/file",
},
};
@@ -96,6 +96,7 @@ Default.args = {
imageFull: imageFile,
alt: "The element logo",
playable: false,
mxcImageFull: "mxc://server/file",
},
},
],
@@ -123,6 +124,7 @@ MultiplePreviewsVisible.args = {
imageFull: imageFile,
alt: "The element logo",
playable: false,
mxcImageFull: "mxc://server/file",
},
},
// These images should appear the same size despite having different dimensions.
@@ -137,6 +139,7 @@ MultiplePreviewsVisible.args = {
imageFull: tallImageFile,
alt: "A dog",
playable: false,
mxcImageFull: "mxc://server/file",
},
},
{
@@ -150,6 +153,7 @@ MultiplePreviewsVisible.args = {
imageFull: imageFile,
alt: "The element logo",
playable: false,
mxcImageFull: "mxc://server/file",
},
},
],
@@ -22,6 +22,10 @@ export interface UrlPreview {
* The site name to be displayed alongside the title.
*/
siteName: string;
/**
* The og:url value of the page, could be different from link
*/
ogUrl?: string;
/**
* The HTTP URI of the the sites icon.
*/
@@ -42,6 +46,14 @@ export interface UrlPreview {
* The HTTP URI of the full image.
*/
imageFull: string;
/**
* The mxc:// URI of the full image.
*/
mxcImageFull: string;
/**
* The type/subtype of the image format
*/
imageType?: string;
/**
* File size in bytes.
*/