Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7d5158d
Fetches link previews for all links in message (instead of just the f…
Siriusmart Jul 2, 2026
aba88dd
message url component to display preview for multiple urls
Siriusmart Jul 2, 2026
c683459
Revert visible behaviour back to only showing one URL preview
Siriusmart Jul 3, 2026
95b9402
Moved URL preview VM to MessageComposer
Siriusmart Jul 3, 2026
659ac5c
Added com.beeper.linkpreviews to messages sent with the markdown comp…
Siriusmart Jul 3, 2026
e934938
MSC 4452 implemented for the older (markdown) composer
Siriusmart Jul 3, 2026
7da1589
claude told me to use logger instead of console
Siriusmart Jul 3, 2026
2575cdf
Previews generated with snapshot instead of vm
Siriusmart Jul 6, 2026
4a67924
moved attachPreviews to a separate file
Siriusmart Jul 6, 2026
baa2159
added attach URL previews to rich text editor
Siriusmart Jul 6, 2026
24fd7da
don't let attachUrlPreviews block clearning the composer
Siriusmart Jul 6, 2026
c297cd0
fixed linter errors
Siriusmart Jul 6, 2026
e1160f1
moved url preview behind labs feature gate
Siriusmart Jul 6, 2026
e9bdfd7
passed linters
Siriusmart Jul 6, 2026
8c9e9e6
moved lab feature checking to where attach preview is used
Siriusmart Jul 6, 2026
9bb8e9b
claude wrote some unit tests for url previewing
Siriusmart Jul 6, 2026
9ee43a4
fixed linter errors
Siriusmart Jul 6, 2026
58874c3
added feature to labs.md
Siriusmart Jul 6, 2026
33507b3
Merge branch 'develop' into url-preview-bundle
Siriusmart Jul 6, 2026
2231506
fixed linter errors
Siriusmart Jul 6, 2026
3d5bc42
fixed oxfmt error
Siriusmart Jul 6, 2026
77fe2ab
set previews to none if all previews failed
Siriusmart Jul 6, 2026
4530182
resolved PR reviews besides the ones that requires a larger code change
Siriusmart Jul 6, 2026
ca37033
moved url preview VM creation to message composer
Siriusmart Jul 7, 2026
1965934
removed delay when clearing composer url preview on message send
Siriusmart Jul 7, 2026
3b5d067
minor changes to stop using globals everywhere
Siriusmart Jul 8, 2026
5d2b324
moved debouncing responsibility to urlpreviewVM
Siriusmart Jul 8, 2026
1a677e8
minor lint fixes
Siriusmart Jul 8, 2026
20e95b2
added comment
Siriusmart Jul 8, 2026
4d8a0b3
remove composer content from the state of urlpreview, moved it to the vm
Siriusmart Jul 9, 2026
a137e1b
Merge branch 'develop' into url-preview-bundle
Siriusmart Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/web/@types/url-preview.ts
Original file line number Diff line number Diff line change
@@ -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;
60 changes: 45 additions & 15 deletions apps/web/src/components/views/rooms/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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,
Expand All @@ -19,6 +19,7 @@
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";
Expand Down Expand Up @@ -50,12 +51,19 @@
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_";
Expand Down Expand Up @@ -88,6 +96,7 @@
relation?: IEventRelation;
e2eStatus?: E2EStatus;
compact?: boolean;
urlPreviewVm: MessageComposerUrlPreviewViewModel;
}

interface IState {
Expand All @@ -103,8 +112,6 @@
isWysiwygLabEnabled: boolean;
isRichTextEnabled: boolean;
initialComposerContent: string;
// Specifically for generating previews only.
urlPreviewComposerContent: string;
}

type WysiwygComposerState = {
Expand Down Expand Up @@ -146,7 +153,6 @@
this.state = {
isComposerEmpty: initialComposerContent?.length === 0,
composerContent: initialComposerContent,
urlPreviewComposerContent: initialComposerContent,
haveRecording: false,
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
isMenuOpen: false,
Expand Down Expand Up @@ -395,7 +401,7 @@
};

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.
Expand All @@ -418,25 +424,25 @@
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,
});
};
Expand Down Expand Up @@ -601,6 +607,7 @@
onChange={this.onChange}
disabled={this.state.haveRecording}
toggleStickerPickerOpen={this.toggleStickerPickerOpen}
urlPreviewVm={this.props.urlPreviewVm}
/>
);
}
Expand Down Expand Up @@ -686,7 +693,10 @@
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
Comment thread
Siriusmart marked this conversation as resolved.
content={this.props.urlPreviewVm.getSnapshot().content}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer to use const { content} = useViewModel(vm) inside the component, as this won't be reactive.

urlPreviewVm={this.props.urlPreviewVm}
/>
<UserIdentityWarning room={this.props.room} key={this.props.room.roomId} />
<ReplyPreview
replyToEvent={this.props.replyToEvent}
Expand Down Expand Up @@ -735,4 +745,24 @@
}

const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer);
export default MessageComposerWithMatrixClient;

export default function MessageComposerWrapper(props: Omit<IProps, "mxClient" | "urlPreviewVm">): JSX.Element {

Check warning on line 749 in apps/web/src/components/views/rooms/MessageComposer.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ83pTRNGdAAPTWaVXFL&open=AZ83pTRNGdAAPTWaVXFL&pullRequest=34150
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);

Check failure on line 764 in apps/web/src/components/views/rooms/MessageComposer.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this use of the "void" operator.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ88EFPD5DMvPGQs3R_Y&open=AZ88EFPD5DMvPGQs3R_Y&pullRequest=34150
}, [urlPreviewVm, showUrlPreview]);

return <MessageComposerWithMatrixClient {...props} urlPreviewVm={urlPreviewVm} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +22,10 @@ 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,
} from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";

// @vitest-environment happy-dom

Expand All @@ -33,6 +37,15 @@ const BASIC_PREVIEW_OGDATA = {
"og:site_name": "Example.org",
};

function getUrlPreviewVm(client: MatrixClient): MessageComposerUrlPreviewViewModel {
return new MessageComposerUrlPreviewViewModel({
client,
visible: true,
showTooltips: false,
urlPreviewBundle: false,
});
}

describe("MessageComposerUrlPreview", () => {
let client: MatrixClient;
let originalMxModuleApi: ModuleApi;
Expand Down Expand Up @@ -66,11 +79,15 @@ describe("MessageComposerUrlPreview", () => {
}

test("to be empty without a link to preview", () => {
const { container } = wrapComponent(<MessageComposerUrlPreviewWrapper content="Test a string" />);
const { container } = wrapComponent(
<MessageComposerUrlPreviewWrapper content="Test a string" urlPreviewVm={getUrlPreviewVm(client)} />,
);
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 content="https://example.org" urlPreviewVm={getUrlPreviewVm(client)} />,
);
await waitFor(
() => {
expect(getByText("Example.org")).toBeDefined();
Expand All @@ -87,7 +104,11 @@ describe("MessageComposerUrlPreview", () => {
() => <strong>Fake preview</strong>,
);
const { getByText } = wrapComponent(
<MessageComposerUrlPreviewWrapper content="https://example.org" moduleApi={modApi} />,
<MessageComposerUrlPreviewWrapper
content="https://example.org"
moduleApi={modApi}
urlPreviewVm={getUrlPreviewVm(client)}
/>,
);
await waitFor(
() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,62 +5,26 @@ 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 } 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();
Comment thread
Siriusmart marked this conversation as resolved.
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 customComponent = moduleApi.customComponents.renderComposerPreview({ text: content, roomId: roomId! }, () => (
<MessageComposerUrlPreviewView vm={vm} />
));

return customComponent ?? <MessageComposerUrlPreviewView vm={vm} />;
}
Loading
Loading