From 7d5158d82c9d8d17a8ffddbf463a93869a827068 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Thu, 2 Jul 2026 16:23:06 +0100 Subject: [PATCH 01/29] Fetches link previews for all links in message (instead of just the first one) --- ...MessageComposerUrlPreviewViewModel.test.ts | 44 +++++++------- .../MessageComposerUrlPreviewViewModel.ts | 47 ++++++++------- ...geComposerUrlPreviewViewModel.test.ts.snap | 58 ++++++++++--------- 3 files changed, 81 insertions(+), 68 deletions(-) diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts index 693ccf47d59..b2252eb32cd 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts @@ -43,7 +43,7 @@ describe("MessageComposerUrlPreviewViewModel", () => { it("should return no preview by default", () => { expect(getViewModel().vm.getSnapshot()).toMatchInlineSnapshot(` { - "preview": null, + "previews": [], } `); }); @@ -55,27 +55,29 @@ describe("MessageComposerUrlPreviewViewModel", () => { 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(); + 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(); + 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"); + console.log(vm.getSnapshot().previews) + 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 () => { @@ -97,31 +99,33 @@ describe("MessageComposerUrlPreviewViewModel", () => { const { vm, client } = getViewModel(); client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA); await vm.updateWithText("https://example.org"); - expect(vm.getSnapshot().preview).not.toBeNull(); + 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(); + 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 () => { const { vm, client } = getViewModel(); - client.getUrlPreview.mockResolvedValueOnce({ - "og:title": "Media example", - "og:type": "document", - "og:url": "https://example.org", - "og:image": IMAGE_MXC, - "og:image:height": 128, - "og:image:width": 128, - "matrix:image:size": 10000, - }); + client.getUrlPreview.mockResolvedValueOnce( + { + "og:title": "Media example", + "og:type": "document", + "og:url": "https://example.org", + "og:image": IMAGE_MXC, + "og:image:height": 128, + "og:image:width": 128, + "matrix:image:size": 10000, + } + ); // eslint-disable-next-line no-restricted-properties client.mxcUrlToHttp.mockImplementation((url, width) => { expect(url).toEqual(IMAGE_MXC); diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index 9329c46fa7f..fbb1dce430c 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -27,6 +27,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 = new Set(); @@ -36,30 +39,33 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< private urlPreviewVisible: boolean; public constructor(props: MessageComposerUrlPreviewViewModelProps) { - super(props, { preview: null }); + super(props, { previews: [] }); this.urlPreviewVisible = props.visible; this.fetcher = new UrlPreviewFetcher(props.client, Date.now(), props.showTooltips); } private async computeSnapshot(): Promise { if (!this.urlPreviewVisible) { - this.snapshot.set({ preview: null }); + this.snapshot.set({ previews: [] }); 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; + + // Fetch previews for all links in the message text, + // And remove the ones with erroneous responses + let 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); - } - } - this.snapshot.set({ preview: null }); + }); + + let previewResponses = await Promise.all(previewRequests); + let previews = previewResponses.filter(res => res !== null); + + this.snapshot.set({ previews }); } /** @@ -67,12 +73,11 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< * @param content Plaintext from the message composer. */ public async updateWithText(content: string): Promise { - const newLinks = new Set( - content - .split(" ") - .map((w) => w.trim()) - .filter((word) => URL.canParse(word)), - ); + const newLinksOrdered = content + .split(" ") + .map((w) => w.trim()) + .filter((word) => URL.canParse(word)); + const newLinks = new Set(newLinksOrdered); if (this.links.symmetricDifference(newLinks).size === 0) { // Skip if the URL set hasn't changed return; diff --git a/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap b/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap index 7a41ca8b03f..5ef7f70f54b 100644 --- a/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap +++ b/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap @@ -2,38 +2,42 @@ 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, + "previews": [ + { + "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, + }, + "link": "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!", - }, + "previews": [ + { + "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!", + }, + ], } `; From aba88dd0f63f7e62935794d9d7fb626e581b741d Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Thu, 2 Jul 2026 17:19:31 +0100 Subject: [PATCH 02/29] message url component to display preview for multiple urls --- .../MessageComposerUrlPreview.stories.tsx | 78 ++++++++++--------- .../MessageComposerUrlPreview.tsx | 17 ++-- 2 files changed, 53 insertions(+), 42 deletions(-) diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx index 557df63edbb..142402b7593 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx @@ -52,46 +52,53 @@ const Template: StoryFn = (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, + }, + } + ], }; 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, + }, }, - }, + ] }; 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 +107,12 @@ 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, + }, + } + ], }; diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx index 4e34927fd0c..2dba5ebd418 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx @@ -17,7 +17,7 @@ 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[]; } /** Props for MessageComposerUrlPreviewView. */ @@ -33,15 +33,16 @@ 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 ( -
+ + const previewViews = previews.map(preview => ( +
{preview?.image?.imageThumb && ( {preview.image.alt} @@ -53,5 +54,7 @@ export function MessageComposerUrlPreviewView({ vm, className }: MessageComposer
- ); + )); + + return (<>{previewViews}) } From c683459f63b0dfe95a575695771bf122feccd44c Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Fri, 3 Jul 2026 10:19:32 +0100 Subject: [PATCH 03/29] Revert visible behaviour back to only showing one URL preview While fetching all link previews in the text message. --- .../MessageComposerUrlPreview/MessageComposerUrlPreview.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx index 2dba5ebd418..35d3861ffb0 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx @@ -41,7 +41,9 @@ export function MessageComposerUrlPreviewView({ vm, className }: MessageComposer return null; } - const previewViews = previews.map(preview => ( + // 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 => (
{preview?.image?.imageThumb && ( From 95b940215166a56b8fc4e0a1a97c96c34f88bdcc Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Fri, 3 Jul 2026 11:49:54 +0100 Subject: [PATCH 04/29] Moved URL preview VM to MessageComposer --- .../components/views/rooms/MessageComposer.tsx | 17 ++++++++++++++--- .../views/rooms/MessageComposerUrlPreview.tsx | 17 ++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 6c26d128a35..f76245ad3c1 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -54,8 +54,9 @@ import { type MatrixClientProps, withMatrixClientHOC } from "../../../contexts/M import { UIFeature } from "../../../settings/UIFeature"; import { formatTimeLeft } from "../../../DateUtils"; import RoomReplacedSvg from "../../../../res/img/room_replaced.svg"; -import { MessageComposerUrlPreviewWrapper } from "./MessageComposerUrlPreview"; +import { MessageComposerUrlPreviewWrapper, useMessageComposerUrlPreviewViewModel } from "./MessageComposerUrlPreview"; import { Type } from "../../../editor/parts"; +import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; // The prefix used when persisting editor drafts to localstorage. export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_"; @@ -88,6 +89,7 @@ interface IProps extends MatrixClientProps { relation?: IEventRelation; e2eStatus?: E2EStatus; compact?: boolean; + urlPreviewVm: MessageComposerUrlPreviewViewModel; } interface IState { @@ -685,7 +687,10 @@ export class MessageComposer extends React.Component { return (
- + { } const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); -export default MessageComposerWithMatrixClient; + +export function MessageComposerWrapper(props: Omit): JSX.Element { + const urlPreviewVm = useMessageComposerUrlPreviewViewModel(); + return ; +} + +export default MessageComposerWrapper; diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index 071fc4ce2b7..c61c60c8d19 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -17,8 +17,9 @@ import PlatformPeg from "../../../PlatformPeg"; const DEBOUNCE_REQUEST_TIMEOUT_MS = 500; -export function MessageComposerUrlPreviewWrapper({ content }: { content: string }): ReactNode | null { +export function useMessageComposerUrlPreviewViewModel(): MessageComposerUrlPreviewViewModel { const { showUrlPreview } = useScopedRoomContext("showUrlPreview"); + const vm = useCreateAutoDisposedViewModel( () => new MessageComposerUrlPreviewViewModel({ @@ -28,6 +29,16 @@ export function MessageComposerUrlPreviewWrapper({ content }: { content: string }), ); + useEffect(() => { + void vm.updateUrlPreviewVisible(showUrlPreview); + }, [vm, showUrlPreview]); + + return vm; +} + +export function MessageComposerUrlPreviewWrapper( + { content, urlPreviewVm: vm }: { content: string; urlPreviewVm: MessageComposerUrlPreviewViewModel } +): ReactNode | null { useDebouncedCallback<[MessageComposerUrlPreviewViewModel, string]>( true, (vm, content) => { @@ -37,9 +48,5 @@ export function MessageComposerUrlPreviewWrapper({ content }: { content: string DEBOUNCE_REQUEST_TIMEOUT_MS, ); - useEffect(() => { - void vm.updateUrlPreviewVisible(showUrlPreview); - }, [vm, showUrlPreview]); - return ; } From 659ac5cbf8e747826883be1300b0b6fac43aba9b Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Fri, 3 Jul 2026 15:58:04 +0100 Subject: [PATCH 05/29] Added com.beeper.linkpreviews to messages sent with the markdown compositor --- apps/web/@types/url-preview.ts | 39 +++++++++++++++++++ .../views/rooms/MessageComposer.tsx | 3 +- .../views/rooms/SendMessageComposer.tsx | 38 +++++++++++++++--- .../MessageComposerUrlPreviewViewModel.ts | 6 +-- .../views/rooms/SendMessageComposer-test.tsx | 16 +++++++- 5 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 apps/web/@types/url-preview.ts diff --git a/apps/web/@types/url-preview.ts b/apps/web/@types/url-preview.ts new file mode 100644 index 00000000000..1f8d34dd3e4 --- /dev/null +++ b/apps/web/@types/url-preview.ts @@ -0,0 +1,39 @@ +/* + * 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 + * @see https://github.com/gomuks/gomuks/pull/560/changes + */ +interface UnstableBundledUrlPreviews { + "com.beeper.linkpreviews"?: UnstableBundledUrlPreviewSingle[] +} + +/** + * Single item in bundled URL previews in MSC-4095 + * + * @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095 + * @see https://github.com/gomuks/gomuks/pull/560/changes + */ +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; diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index f76245ad3c1..0764be19f6a 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -56,7 +56,7 @@ import { formatTimeLeft } from "../../../DateUtils"; import RoomReplacedSvg from "../../../../res/img/room_replaced.svg"; import { MessageComposerUrlPreviewWrapper, useMessageComposerUrlPreviewViewModel } from "./MessageComposerUrlPreview"; import { Type } from "../../../editor/parts"; -import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; +import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; // The prefix used when persisting editor drafts to localstorage. export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_"; @@ -602,6 +602,7 @@ export class MessageComposer extends React.Component { onChange={this.onChange} disabled={this.state.haveRecording} toggleStickerPickerOpen={this.toggleStickerPickerOpen} + urlPreviewVm={this.props.urlPreviewVm} /> ); } diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 66288fbbaf8..94123aacc09 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -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 { @@ -66,16 +66,19 @@ import { getBlobSafeMimeType } from "../../../utils/blobs"; import { EMOJI_REGEX } from "../../../HtmlUtils"; import { attachMentions, attachRelation } 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_"; + // exported for tests export function createMessageContent( sender: string, model: EditorModel, replyToEvent: MatrixEvent | undefined, relation: IEventRelation | undefined, + urlPreviewVm?: MessageComposerUrlPreviewViewModel, ): RoomMessageEventContent { const isEmote = containsEmote(model); if (isEmote) { @@ -108,6 +111,29 @@ export function createMessageContent( addReplyToMessageContent(content, replyToEvent); } + const previewsSnapshot = urlPreviewVm?.getSnapshot(); + if (previewsSnapshot !== undefined && previewsSnapshot.previews.length !== 0) { + content["com.beeper.linkpreviews"] = previewsSnapshot.previews.map(preview => ({ + matched_url: preview.link, + "og:url": preview.link, // TODO: og:url may be different from the URL requested + "og:title": preview.title, + "og:description": preview.description, + // TODO: populate these fields + /* + ...(if_exists ? { + "og:image": "TODO", + "og:image:width": 0, + "og:image:height": 0, + "og:image:type": "TODO" + } : {}), + ...(if_exists ? { + "beeper:image:encryption": undefined, + "matrix:image:size": 0 + } : {}) + */ + })) + } + return content; } @@ -137,6 +163,7 @@ interface ISendMessageComposerProps extends MatrixClientProps { disabled?: boolean; onChange?(model: EditorModel): void; toggleStickerPickerOpen: () => void; + urlPreviewVm: MessageComposerUrlPreviewViewModel; } export class SendMessageComposer extends React.Component { @@ -225,10 +252,10 @@ export class SendMessageComposer extends React.Component { try { return await this.fetcher.fetchPreview(link, true); @@ -62,8 +62,8 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< } }); - let previewResponses = await Promise.all(previewRequests); - let previews = previewResponses.filter(res => res !== null); + const previewResponses = await Promise.all(previewRequests); + const previews = previewResponses.filter(res => res !== null); this.snapshot.set({ previews }); } diff --git a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index 96199f5ad94..8a3fb7ddba7 100644 --- a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -32,6 +32,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"; jest.mock("../../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), @@ -182,6 +183,11 @@ describe("", () => { spyDispatcher.mockReset(); }); + const urlPreviewVm = new MessageComposerUrlPreviewViewModel({ + client: mockClient, + visible: false, + showTooltips: false, + }); const defaultProps = { room: mockRoom, toggleStickerPickerOpen: jest.fn(), @@ -190,7 +196,7 @@ describe("", () => { - + @@ -431,6 +437,12 @@ describe("", () => { it("should call prepareToEncrypt when the user is typing", async () => { const cli = stubClient(); + const urlPreviewVm = new MessageComposerUrlPreviewViewModel({ + client: cli, + visible: false, + showTooltips: false, + }); + cli.isRoomEncrypted = jest.fn().mockReturnValue(true); const room = mkStubRoom("!roomId:server", "Room", cli); @@ -440,7 +452,7 @@ describe("", () => { - + , From e9349385ad399482da3f808a0bac72248d587bb1 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Fri, 3 Jul 2026 17:11:50 +0100 Subject: [PATCH 06/29] MSC 4452 implemented for the older (markdown) composer --- apps/web/@types/url-preview.ts | 27 +++--- .../views/rooms/MessageComposerUrlPreview.tsx | 10 ++- .../views/rooms/SendMessageComposer.tsx | 85 +++++++++++++------ apps/web/src/utils/UrlPreviewFetcher.ts | 2 + ...MessageComposerUrlPreviewViewModel.test.ts | 21 ++--- .../MessageComposerUrlPreviewViewModel.ts | 19 ++--- .../views/rooms/SendMessageComposer-test.tsx | 6 +- .../MessageComposerUrlPreview.stories.tsx | 8 +- .../MessageComposerUrlPreview.tsx | 4 +- .../event-tile/UrlPreviewGroupView/types.ts | 4 + 10 files changed, 114 insertions(+), 72 deletions(-) diff --git a/apps/web/@types/url-preview.ts b/apps/web/@types/url-preview.ts index 1f8d34dd3e4..3ef22a76593 100644 --- a/apps/web/@types/url-preview.ts +++ b/apps/web/@types/url-preview.ts @@ -5,7 +5,10 @@ * Please see LICENSE files in the repository root for full details. */ -import { type EncryptedFile, type RoomMessageEventContent as SdkRoomMessageEventContent } from "matrix-js-sdk/src/types"; +import { + type EncryptedFile, + type RoomMessageEventContent as SdkRoomMessageEventContent, +} from "matrix-js-sdk/src/types"; /** * Bundled URL previews in MSC-4095 @@ -14,7 +17,7 @@ import { type EncryptedFile, type RoomMessageEventContent as SdkRoomMessageEvent * @see https://github.com/gomuks/gomuks/pull/560/changes */ interface UnstableBundledUrlPreviews { - "com.beeper.linkpreviews"?: UnstableBundledUrlPreviewSingle[] + "com.beeper.linkpreviews"?: UnstableBundledUrlPreviewSingle[]; } /** @@ -24,16 +27,16 @@ interface UnstableBundledUrlPreviews { * @see https://github.com/gomuks/gomuks/pull/560/changes */ 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 + "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; diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index c61c60c8d19..07679c03c43 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -36,9 +36,13 @@ export function useMessageComposerUrlPreviewViewModel(): MessageComposerUrlPrevi return vm; } -export function MessageComposerUrlPreviewWrapper( - { content, urlPreviewVm: vm }: { content: string; urlPreviewVm: MessageComposerUrlPreviewViewModel } -): ReactNode | null { +export function MessageComposerUrlPreviewWrapper({ + content, + urlPreviewVm: vm, +}: { + content: string; + urlPreviewVm: MessageComposerUrlPreviewViewModel; +}): ReactNode | null { useDebouncedCallback<[MessageComposerUrlPreviewViewModel, string]>( true, (vm, content) => { diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 94123aacc09..719f1067db7 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -21,6 +21,7 @@ import { MsgType, RelationType, THREAD_RELATION_TYPE, + MatrixClient, } from "matrix-js-sdk/src/matrix"; import { type DebouncedFunc, throttle } from "lodash"; import { logger } from "matrix-js-sdk/src/logger"; @@ -67,18 +68,18 @@ import { EMOJI_REGEX } from "../../../HtmlUtils"; import { attachMentions, attachRelation } from "../../../utils/messages"; import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel"; import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; +import { uploadFile } from "../../../ContentMessages"; +import { EncryptedFile } from "matrix-js-sdk/src/types"; // The prefix used when persisting editor drafts to localstorage. export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_"; - // exported for tests export function createMessageContent( sender: string, model: EditorModel, replyToEvent: MatrixEvent | undefined, relation: IEventRelation | undefined, - urlPreviewVm?: MessageComposerUrlPreviewViewModel, ): RoomMessageEventContent { const isEmote = containsEmote(model); if (isEmote) { @@ -111,30 +112,57 @@ export function createMessageContent( addReplyToMessageContent(content, replyToEvent); } + return content; +} + +export async function attachUrlPreviews( + mxClient: MatrixClient, + roomId: string, + urlPreviewVm: MessageComposerUrlPreviewViewModel, + content: RoomMessageEventContent, +): Promise { const previewsSnapshot = urlPreviewVm?.getSnapshot(); if (previewsSnapshot !== undefined && previewsSnapshot.previews.length !== 0) { - content["com.beeper.linkpreviews"] = previewsSnapshot.previews.map(preview => ({ - matched_url: preview.link, - "og:url": preview.link, // TODO: og:url may be different from the URL requested - "og:title": preview.title, - "og:description": preview.description, - // TODO: populate these fields - /* - ...(if_exists ? { - "og:image": "TODO", - "og:image:width": 0, - "og:image:height": 0, - "og:image:type": "TODO" - } : {}), - ...(if_exists ? { - "beeper:image:encryption": undefined, - "matrix:image:size": 0 - } : {}) - */ - })) - } + content["com.beeper.linkpreviews"] = await Promise.all( + previewsSnapshot.previews.map(async (preview) => { + // upload the files to produce the mxc:// url for the images + let imageUploaded: { url?: string; file?: EncryptedFile } | null = null; + + if (preview.image) { + let imageBlob: Blob | null = null; + + try { + imageBlob = await (await fetch(preview.image.imageFull)).blob(); + } catch (e) { + console.error(`Failed to fetch image from ${preview.image.imageFull}`, e); + } - return content; + if (imageBlob !== null) { + try { + imageUploaded = await uploadFile(mxClient, roomId, imageBlob); + } catch (e) { + console.error("Failed to upload image", e); + } + } + } + + return { + "matched_url": preview.link, + // TODO: og:url may be different from the URL requested, but is not present in the response of the url_preview + "og:url": preview.link, + "og:title": preview.title, + "og:description": preview.description, + "og:image": imageUploaded?.url, + // TODO: should we trust the declared size? or should we calculate it ourselves + "og:image:width": preview.image?.width, + "og:image:height": preview.image?.height, + "og:image:type": preview.image?.imageType, + "matrix:image:size": preview.image?.fileSize, + "beeper:image:encryption": imageUploaded?.file, + }; + }), + ); + } } // exported for tests @@ -252,10 +280,10 @@ export class SendMessageComposer extends React.Component { .mockRejectedValueOnce(new Error("First URL failed")) .mockResolvedValueOnce(BASIC_PREVIEW_OGDATA); await vm.updateWithText("https://example.org/one https://example.org/two"); - console.log(vm.getSnapshot().previews) expect(vm.getSnapshot().previews[0]?.link).toEqual("https://example.org/two"); expect(vm.getSnapshot().previews).toHaveLength(1); }); @@ -115,17 +114,15 @@ describe("MessageComposerUrlPreviewViewModel", () => { it("should preview a URL with media", async () => { const { vm, client } = getViewModel(); - client.getUrlPreview.mockResolvedValueOnce( - { - "og:title": "Media example", - "og:type": "document", - "og:url": "https://example.org", - "og:image": IMAGE_MXC, - "og:image:height": 128, - "og:image:width": 128, - "matrix:image:size": 10000, - } - ); + client.getUrlPreview.mockResolvedValueOnce({ + "og:title": "Media example", + "og:type": "document", + "og:url": "https://example.org", + "og:image": IMAGE_MXC, + "og:image:height": 128, + "og:image:width": 128, + "matrix:image:size": 10000, + }); // eslint-disable-next-line no-restricted-properties client.mxcUrlToHttp.mockImplementation((url, width) => { expect(url).toEqual(IMAGE_MXC); diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index 64dd6e808e2..9ed688cb886 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -52,18 +52,17 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< // Fetch previews for all links in the message text, // And remove the ones with erroneous responses - 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; - } - }); + 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; + } + }); const previewResponses = await Promise.all(previewRequests); - const previews = previewResponses.filter(res => res !== null); + const previews = previewResponses.filter((res) => res !== null); this.snapshot.set({ previews }); } diff --git a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index 8a3fb7ddba7..01ebd435f94 100644 --- a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -452,7 +452,11 @@ describe("", () => { - + , diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx index 142402b7593..fb4b3ca9545 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx @@ -59,7 +59,7 @@ Default.args = { link: "https://matrix.org", siteName: "matrix.org", showTooltipOnLink: false, - } + }, ], }; @@ -74,7 +74,7 @@ WithImage.args = { alt: "The element logo", playable: false, }, - } + }, ], }; export const WithImageAndSiteIcon = Template.bind({}); @@ -90,7 +90,7 @@ WithImageAndSiteIcon.args = { playable: false, }, }, - ] + ], }; export const WithImageAndLoadsOfText = Template.bind({}); @@ -113,6 +113,6 @@ WithImageAndLoadsOfText.args = { alt: "The element logo", playable: false, }, - } + }, ], }; diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx index 35d3861ffb0..5219a93b982 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx @@ -43,7 +43,7 @@ export function MessageComposerUrlPreviewView({ vm, className }: MessageComposer // 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 => ( + const previewViews = previews.slice(0, 1).map((preview) => (
{preview?.image?.imageThumb && ( @@ -58,5 +58,5 @@ export function MessageComposerUrlPreviewView({ vm, className }: MessageComposer
)); - return (<>{previewViews}) + return <>{previewViews}; } diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts index 8d26743987d..950ac704350 100644 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts @@ -42,6 +42,10 @@ export interface UrlPreview { * The HTTP URI of the full image. */ imageFull: string; + /** + * The type/subtype of the image format + */ + imageType?: string; /** * File size in bytes. */ From 7da1589c5f38e154b671655004b00281e722c501 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Fri, 3 Jul 2026 17:17:27 +0100 Subject: [PATCH 07/29] claude told me to use logger instead of console --- apps/web/src/components/views/rooms/SendMessageComposer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 719f1067db7..960bfb3ef2a 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -134,14 +134,14 @@ export async function attachUrlPreviews( try { imageBlob = await (await fetch(preview.image.imageFull)).blob(); } catch (e) { - console.error(`Failed to fetch image from ${preview.image.imageFull}`, e); + logger.error(`Failed to fetch image from ${preview.image.imageFull}`, e); } if (imageBlob !== null) { try { imageUploaded = await uploadFile(mxClient, roomId, imageBlob); } catch (e) { - console.error("Failed to upload image", e); + logger.error("Failed to upload image", e); } } } From 2575cdf57bb8b77cb169b55ddc3b75089b434ef0 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 09:35:09 +0100 Subject: [PATCH 08/29] Previews generated with snapshot instead of vm --- .../views/rooms/SendMessageComposer.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 960bfb3ef2a..54625ba236e 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -70,6 +70,7 @@ import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewm import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; import { uploadFile } from "../../../ContentMessages"; import { EncryptedFile } from "matrix-js-sdk/src/types"; +import { MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; // The prefix used when persisting editor drafts to localstorage. export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_"; @@ -118,13 +119,12 @@ export function createMessageContent( export async function attachUrlPreviews( mxClient: MatrixClient, roomId: string, - urlPreviewVm: MessageComposerUrlPreviewViewModel, + urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot, content: RoomMessageEventContent, ): Promise { - const previewsSnapshot = urlPreviewVm?.getSnapshot(); - if (previewsSnapshot !== undefined && previewsSnapshot.previews.length !== 0) { + if (urlPreviewSnapshot.previews.length) { content["com.beeper.linkpreviews"] = await Promise.all( - previewsSnapshot.previews.map(async (preview) => { + urlPreviewSnapshot.previews.map(async (preview) => { // upload the files to produce the mxc:// url for the images let imageUploaded: { url?: string; file?: EncryptedFile } | null = null; @@ -280,10 +280,10 @@ export class SendMessageComposer extends React.Component Date: Mon, 6 Jul 2026 09:56:36 +0100 Subject: [PATCH 09/29] moved attachPreviews to a separate file --- .../views/rooms/SendMessageComposer.tsx | 55 +----------------- apps/web/src/utils/messages.ts | 56 ++++++++++++++++++- 2 files changed, 56 insertions(+), 55 deletions(-) diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 54625ba236e..e1e4b82e376 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -21,7 +21,6 @@ import { MsgType, RelationType, THREAD_RELATION_TYPE, - MatrixClient, } from "matrix-js-sdk/src/matrix"; import { type DebouncedFunc, throttle } from "lodash"; import { logger } from "matrix-js-sdk/src/logger"; @@ -65,12 +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"; -import { uploadFile } from "../../../ContentMessages"; -import { EncryptedFile } from "matrix-js-sdk/src/types"; -import { MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; // The prefix used when persisting editor drafts to localstorage. export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_"; @@ -116,55 +112,6 @@ export function createMessageContent( return content; } -export async function attachUrlPreviews( - mxClient: MatrixClient, - roomId: string, - urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot, - content: RoomMessageEventContent, -): Promise { - if (urlPreviewSnapshot.previews.length) { - content["com.beeper.linkpreviews"] = await Promise.all( - urlPreviewSnapshot.previews.map(async (preview) => { - // upload the files to produce the mxc:// url for the images - let imageUploaded: { url?: string; file?: EncryptedFile } | null = null; - - if (preview.image) { - let imageBlob: Blob | null = null; - - try { - imageBlob = await (await fetch(preview.image.imageFull)).blob(); - } catch (e) { - logger.error(`Failed to fetch image from ${preview.image.imageFull}`, e); - } - - if (imageBlob !== null) { - try { - imageUploaded = await uploadFile(mxClient, roomId, imageBlob); - } catch (e) { - logger.error("Failed to upload image", e); - } - } - } - - return { - "matched_url": preview.link, - // TODO: og:url may be different from the URL requested, but is not present in the response of the url_preview - "og:url": preview.link, - "og:title": preview.title, - "og:description": preview.description, - "og:image": imageUploaded?.url, - // TODO: should we trust the declared size? or should we calculate it ourselves - "og:image:width": preview.image?.width, - "og:image:height": preview.image?.height, - "og:image:type": preview.image?.imageType, - "matrix:image:size": preview.image?.fileSize, - "beeper:image:encryption": imageUploaded?.file, - }; - }), - ); - } -} - // exported for tests export function isQuickReaction(model: EditorModel): boolean { const parts = model.parts; diff --git a/apps/web/src/utils/messages.ts b/apps/web/src/utils/messages.ts index b6040b536ba..8dd02561042 100644 --- a/apps/web/src/utils/messages.ts +++ b/apps/web/src/utils/messages.ts @@ -5,10 +5,14 @@ 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 { type MatrixEvent, type IContent, type IMentions, type IEventRelation } from "matrix-js-sdk/src/matrix"; +import { type MatrixEvent, type IContent, type IMentions, type IEventRelation, MatrixClient } from "matrix-js-sdk/src/matrix"; import type EditorModel from "../editor/model"; import { Type } from "../editor/parts"; +import { MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; +import { RoomMessageEventContent } from "../../@types/url-preview"; +import { EncryptedFile } from "matrix-js-sdk/src/types"; +import { uploadFile } from "../ContentMessages"; /** * Build the mentions information based on the editor model (and any related events): @@ -107,3 +111,53 @@ export function attachRelation(content: IContent, relation?: IEventRelation): vo }; } } + +export async function attachUrlPreviews( + mxClient: MatrixClient, + roomId: string, + urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot, + content: RoomMessageEventContent, +): Promise { + if (urlPreviewSnapshot.previews.length) { + content["com.beeper.linkpreviews"] = await Promise.all( + urlPreviewSnapshot.previews.map(async (preview) => { + // upload the files to produce the mxc:// url for the images + let imageUploaded: { url?: string; file?: EncryptedFile } | null = null; + + if (preview.image) { + let imageBlob: Blob | null = null; + + try { + imageBlob = await (await fetch(preview.image.imageFull)).blob(); + } catch (e) { + console.error(`Failed to fetch image from ${preview.image.imageFull}`, e); + } + + if (imageBlob !== null) { + try { + imageUploaded = await uploadFile(mxClient, roomId, imageBlob); + } catch (e) { + console.error("Failed to upload image", e); + } + } + } + + return { + "matched_url": preview.link, + // TODO: og:url may be different from the URL requested, but is not present in the response of the url_preview + "og:url": preview.link, + "og:title": preview.title, + "og:description": preview.description, + "og:image": imageUploaded?.url, + // TODO: should we trust the declared size? or should we calculate it ourselves + "og:image:width": preview.image?.width, + "og:image:height": preview.image?.height, + "og:image:type": preview.image?.imageType, + "matrix:image:size": preview.image?.fileSize, + "beeper:image:encryption": imageUploaded?.file, + }; + }), + ); + } +} + From baa2159624e4e0049c8fbd714e83b97dbe0e4890 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 10:13:21 +0100 Subject: [PATCH 10/29] added attach URL previews to rich text editor --- apps/web/src/components/views/rooms/MessageComposer.tsx | 1 + .../views/rooms/wysiwyg_composer/utils/message.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 0764be19f6a..89509a0d03b 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -419,6 +419,7 @@ export class MessageComposer extends React.Component { roomContext: this.context, relation, replyToEvent, + urlPreviewSnapshot: this.props.urlPreviewVm.getSnapshot() }); } }; diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts index 2594ac4841e..336262ce0e0 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -33,19 +33,21 @@ 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"; +import { MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; export interface SendMessageParams { mxClient: MatrixClient; relation?: IEventRelation; replyToEvent?: MatrixEvent; roomContext: Pick; + urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot; } export async function sendMessage( message: string, isHTML: boolean, - { roomContext, mxClient, ...params }: SendMessageParams, + { roomContext, mxClient, urlPreviewSnapshot, ...params }: SendMessageParams, ): Promise { 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); + await attachUrlPreviews(mxClient, roomId, urlPreviewSnapshot, content); // TODO replace emotion end of message ? From 24fd7da718732d581c04ace2b3c1816635fdbced Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 11:32:45 +0100 Subject: [PATCH 11/29] don't let attachUrlPreviews block clearning the composer --- .../views/rooms/SendMessageComposer.tsx | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index e1e4b82e376..797c4950a0b 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -412,6 +412,21 @@ export class SendMessageComposer extends React.Component { + 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) { @@ -421,9 +436,11 @@ export class SendMessageComposer extends React.Component Date: Mon, 6 Jul 2026 11:50:51 +0100 Subject: [PATCH 12/29] fixed linter errors --- .../views/rooms/SendMessageComposer.tsx | 2 +- .../rooms/wysiwyg_composer/utils/message.ts | 2 +- apps/web/src/utils/messages.ts | 8 +++---- .../wysiwyg_composer/utils/message-test.ts | 21 +++++++++++++++++-- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 797c4950a0b..1556a2032c5 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -412,7 +412,7 @@ export class SendMessageComposer extends React.Component { + const clearComposerAndPushHistory = (): void => { this.sendHistoryManager.save(model, replyToEvent); // clear composer model.reset([]); diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts index 336262ce0e0..81c1be7469f 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -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"; @@ -34,7 +35,6 @@ import { runSlashCommand, shouldSendAnyway } from "../../../../../editor/command import { Action } from "../../../../../dispatcher/actions"; import { addReplyToMessageContent } from "../../../../../utils/Reply"; import { attachRelation, attachUrlPreviews } from "../../../../../utils/messages"; -import { MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; export interface SendMessageParams { mxClient: MatrixClient; diff --git a/apps/web/src/utils/messages.ts b/apps/web/src/utils/messages.ts index 8dd02561042..7f5739fc4e0 100644 --- a/apps/web/src/utils/messages.ts +++ b/apps/web/src/utils/messages.ts @@ -5,13 +5,13 @@ 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 { type MatrixEvent, type IContent, type IMentions, type IEventRelation, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MatrixEvent, type IContent, type IMentions, type IEventRelation, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; +import { type EncryptedFile } from "matrix-js-sdk/src/types"; import type EditorModel from "../editor/model"; import { Type } from "../editor/parts"; -import { MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; -import { RoomMessageEventContent } from "../../@types/url-preview"; -import { EncryptedFile } from "matrix-js-sdk/src/types"; +import { type RoomMessageEventContent } from "../../@types/url-preview"; import { uploadFile } from "../ContentMessages"; /** diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts index c53db5de35a..327086e65fe 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts @@ -65,7 +65,7 @@ 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: [] } }); // Then expect(mockClient.sendMessage).toHaveBeenCalledTimes(0); @@ -80,6 +80,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: mockRoomContextWithoutId, mxClient: mockClient, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -93,6 +94,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -104,6 +106,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, relation: {}, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -118,6 +121,7 @@ describe("message", () => { event_id: "valid_id", rel_type: "m.does_not_match", }, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -133,6 +137,7 @@ describe("message", () => { event_id: "valid_id", rel_type: "m.thread", }, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -145,6 +150,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -172,6 +178,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockReplyEvent, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -201,6 +208,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -212,7 +220,7 @@ describe("message", () => { it("Should handle emojis", async () => { // When - await sendMessage("🎉", false, { roomContext: defaultRoomContext, mxClient: mockClient }); + await sendMessage("🎉", false, { roomContext: defaultRoomContext, mxClient: mockClient, urlPreviewSnapshot: { previews: [] } }); // Then expect(spyDispatcher).toHaveBeenCalledWith({ action: "effects.confetti" }); @@ -227,6 +235,7 @@ describe("message", () => { await sendMessage(validCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -239,6 +248,7 @@ describe("message", () => { await sendMessage(invalidPrefixCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -256,6 +266,7 @@ describe("message", () => { const result = await sendMessage(validCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [] } }); // Then @@ -270,6 +281,7 @@ describe("message", () => { await sendMessage(inputText, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [] } }); expect(mockClient.sendMessage).toHaveBeenCalledWith( "myfakeroom", @@ -289,6 +301,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, relation: mockRelation, + urlPreviewSnapshot: { previews: [] } }); expect(mockClient.sendMessage).toHaveBeenCalledWith( @@ -305,6 +318,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockEvent, + urlPreviewSnapshot: { previews: [] } }); expect(addReplySpy).toHaveBeenCalledTimes(1); @@ -319,6 +333,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockEvent, + urlPreviewSnapshot: { previews: [] } }); expect(result).toBeUndefined(); @@ -333,6 +348,7 @@ describe("message", () => { await sendMessage(invalidCommandInput, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [] } }); // we expect the message to have been sent @@ -353,6 +369,7 @@ describe("message", () => { const result = await sendMessage(invalidCommandInput, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [] } }); expect(result).toBeUndefined(); From e1160f16e3342db3e62436b7c908ee5766c07191 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 12:35:53 +0100 Subject: [PATCH 13/29] moved url preview behind labs feature gate --- apps/web/src/i18n/strings/en_EN.json | 2 ++ apps/web/src/settings/Settings.tsx | 17 +++++++++++++---- apps/web/src/utils/messages.ts | 3 +++ .../MessageComposerUrlPreviewViewModel.ts | 11 ++++++++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index afbfecaf258..f3c39027b3b 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -1574,6 +1574,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 (MSC4452)", + "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.", diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 3a655970ee4..ce44281f4fd 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -129,10 +129,10 @@ export interface IBaseSetting { // Display names are strongly recommended for clarity. // Display name can also be an object for different levels. displayName?: - | TranslationKey - | Partial<{ - [level in SettingLevel]: TranslationKey; - }>; + | TranslationKey + | Partial<{ + [level in SettingLevel]: TranslationKey; + }>; // Optional description which will be shown as microCopy under SettingsFlags description?: TranslationKey | (() => ReactNode); @@ -231,6 +231,7 @@ export interface Settings { "feature_msc4362_encrypted_state_events": IFeature; "feature_user_status": IFeature; "feature_login_with_qr": IFeature; + "feature_msc4452_url_preview_bundle": IFeature; // These are in the feature namespace but aren't actually features "feature_hidebold": IBaseSetting; @@ -662,6 +663,14 @@ export const SETTINGS: Settings = { isFeature: true, default: false, }, + "feature_msc4452_url_preview_bundle": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, + labsGroup: LabGroup.Messaging, + displayName: _td("labs|url_preview_bundle"), + description: _td("labs|url_preview_bundle_description"), + 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` diff --git a/apps/web/src/utils/messages.ts b/apps/web/src/utils/messages.ts index 7f5739fc4e0..35454e5646c 100644 --- a/apps/web/src/utils/messages.ts +++ b/apps/web/src/utils/messages.ts @@ -13,6 +13,7 @@ import type EditorModel from "../editor/model"; import { Type } from "../editor/parts"; import { type RoomMessageEventContent } from "../../@types/url-preview"; import { uploadFile } from "../ContentMessages"; +import SettingsStore from "../settings/SettingsStore"; /** * Build the mentions information based on the editor model (and any related events): @@ -118,6 +119,8 @@ export async function attachUrlPreviews( urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot, content: RoomMessageEventContent, ): Promise { + if (!SettingsStore.getValue("feature_msc4452_url_preview_bundle")) { return; } + if (urlPreviewSnapshot.previews.length) { content["com.beeper.linkpreviews"] = await Promise.all( urlPreviewSnapshot.previews.map(async (preview) => { diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index 9ed688cb886..b2e767f5b32 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -10,6 +10,7 @@ import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { BaseViewModel, type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; import { UrlPreviewFetcher } from "../../utils/UrlPreviewFetcher"; +import SettingsStore from "../../settings/SettingsStore"; const logger = rootLogger.getChild("MessageComposerUrlPreviewViewModel"); @@ -50,9 +51,17 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< return; } + let links: Set; + + if (SettingsStore.getValue("feature_msc4452_url_preview_bundle")) { + links = this.links; + } else { + links = new Set(Array.from(this.links).slice(0, 1)); // only preview first item + } + // Fetch previews for all links in the message text, // And remove the ones with erroneous responses - const previewRequests = Array.from(this.links).map(async (link) => { + const previewRequests = Array.from(links).map(async (link) => { try { return await this.fetcher.fetchPreview(link, true); } catch (ex) { From e9bdfd79ed7489c63b248cd7942e150415784c55 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 13:13:16 +0100 Subject: [PATCH 14/29] passed linters --- .../views/rooms/MessageComposer.tsx | 6 +-- .../views/rooms/SendMessageComposer.tsx | 17 +++---- apps/web/src/settings/Settings.tsx | 11 +++-- apps/web/src/utils/messages.ts | 19 ++++++-- .../wysiwyg_composer/utils/message-test.ts | 46 +++++++++++-------- 5 files changed, 58 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 89509a0d03b..065fffc5116 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -419,7 +419,7 @@ export class MessageComposer extends React.Component { roomContext: this.context, relation, replyToEvent, - urlPreviewSnapshot: this.props.urlPreviewVm.getSnapshot() + urlPreviewSnapshot: this.props.urlPreviewVm.getSnapshot(), }); } }; @@ -742,9 +742,7 @@ export class MessageComposer extends React.Component { const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); -export function MessageComposerWrapper(props: Omit): JSX.Element { +export default function MessageComposerWrapper(props: Omit): JSX.Element { const urlPreviewVm = useMessageComposerUrlPreviewViewModel(); return ; } - -export default MessageComposerWrapper; diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 1556a2032c5..a52015839d6 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -227,10 +227,10 @@ export class SendMessageComposer extends React.Component { // Display names are strongly recommended for clarity. // Display name can also be an object for different levels. displayName?: - | TranslationKey - | Partial<{ - [level in SettingLevel]: TranslationKey; - }>; + | TranslationKey + | Partial<{ + [level in SettingLevel]: TranslationKey; + }>; // Optional description which will be shown as microCopy under SettingsFlags description?: TranslationKey | (() => ReactNode); @@ -664,10 +664,11 @@ export const SETTINGS: Settings = { default: false, }, "feature_msc4452_url_preview_bundle": { - supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, 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, }, diff --git a/apps/web/src/utils/messages.ts b/apps/web/src/utils/messages.ts index 35454e5646c..711e423ff36 100644 --- a/apps/web/src/utils/messages.ts +++ b/apps/web/src/utils/messages.ts @@ -5,9 +5,16 @@ 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 { type MatrixEvent, type IContent, type IMentions, type IEventRelation, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { + type MatrixEvent, + type IContent, + type IMentions, + type IEventRelation, + type MatrixClient, +} from "matrix-js-sdk/src/matrix"; import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; import { type EncryptedFile } from "matrix-js-sdk/src/types"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import type EditorModel from "../editor/model"; import { Type } from "../editor/parts"; @@ -112,6 +119,7 @@ export function attachRelation(content: IContent, relation?: IEventRelation): vo }; } } +const logger = rootLogger.getChild("attachUrlPreviews"); export async function attachUrlPreviews( mxClient: MatrixClient, @@ -119,7 +127,9 @@ export async function attachUrlPreviews( urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot, content: RoomMessageEventContent, ): Promise { - if (!SettingsStore.getValue("feature_msc4452_url_preview_bundle")) { return; } + if (!SettingsStore.getValue("feature_msc4452_url_preview_bundle")) { + return; + } if (urlPreviewSnapshot.previews.length) { content["com.beeper.linkpreviews"] = await Promise.all( @@ -133,14 +143,14 @@ export async function attachUrlPreviews( try { imageBlob = await (await fetch(preview.image.imageFull)).blob(); } catch (e) { - console.error(`Failed to fetch image from ${preview.image.imageFull}`, e); + logger.error(`Failed to fetch image from ${preview.image.imageFull}`, e); } if (imageBlob !== null) { try { imageUploaded = await uploadFile(mxClient, roomId, imageBlob); } catch (e) { - console.error("Failed to upload image", e); + logger.error("Failed to upload image", e); } } } @@ -163,4 +173,3 @@ export async function attachUrlPreviews( ); } } - diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts index 327086e65fe..bf50604a5e6 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts @@ -65,7 +65,11 @@ describe("message", () => { describe("sendMessage", () => { it("Should not send empty html message", async () => { // When - await sendMessage("", true, { roomContext: defaultRoomContext, mxClient: mockClient, urlPreviewSnapshot: { previews: [] } }); + await sendMessage("", true, { + roomContext: defaultRoomContext, + mxClient: mockClient, + urlPreviewSnapshot: { previews: [] }, + }); // Then expect(mockClient.sendMessage).toHaveBeenCalledTimes(0); @@ -80,7 +84,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: mockRoomContextWithoutId, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -94,7 +98,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -106,7 +110,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, relation: {}, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -121,7 +125,7 @@ describe("message", () => { event_id: "valid_id", rel_type: "m.does_not_match", }, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -137,7 +141,7 @@ describe("message", () => { event_id: "valid_id", rel_type: "m.thread", }, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -150,7 +154,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -178,7 +182,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockReplyEvent, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -208,7 +212,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -220,7 +224,11 @@ describe("message", () => { it("Should handle emojis", async () => { // When - await sendMessage("🎉", false, { roomContext: defaultRoomContext, mxClient: mockClient, urlPreviewSnapshot: { previews: [] } }); + await sendMessage("🎉", false, { + roomContext: defaultRoomContext, + mxClient: mockClient, + urlPreviewSnapshot: { previews: [] }, + }); // Then expect(spyDispatcher).toHaveBeenCalledWith({ action: "effects.confetti" }); @@ -235,7 +243,7 @@ describe("message", () => { await sendMessage(validCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -248,7 +256,7 @@ describe("message", () => { await sendMessage(invalidPrefixCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -266,7 +274,7 @@ describe("message", () => { const result = await sendMessage(validCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // Then @@ -281,7 +289,7 @@ describe("message", () => { await sendMessage(inputText, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); expect(mockClient.sendMessage).toHaveBeenCalledWith( "myfakeroom", @@ -301,7 +309,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, relation: mockRelation, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); expect(mockClient.sendMessage).toHaveBeenCalledWith( @@ -318,7 +326,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockEvent, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); expect(addReplySpy).toHaveBeenCalledTimes(1); @@ -333,7 +341,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockEvent, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); expect(result).toBeUndefined(); @@ -348,7 +356,7 @@ describe("message", () => { await sendMessage(invalidCommandInput, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); // we expect the message to have been sent @@ -369,7 +377,7 @@ describe("message", () => { const result = await sendMessage(invalidCommandInput, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] } + urlPreviewSnapshot: { previews: [] }, }); expect(result).toBeUndefined(); From 8c9e9e6aec38b7e8db120ff70ac8cf3d030fb984 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 13:19:54 +0100 Subject: [PATCH 15/29] moved lab feature checking to where attach preview is used --- .../components/views/rooms/SendMessageComposer.tsx | 13 ++++++++----- .../views/rooms/wysiwyg_composer/utils/message.ts | 4 +++- apps/web/src/utils/messages.ts | 4 ---- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index a52015839d6..9092c7d74b4 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -227,10 +227,10 @@ export class SendMessageComposer extends React.Component { - if (!SettingsStore.getValue("feature_msc4452_url_preview_bundle")) { - return; - } - if (urlPreviewSnapshot.previews.length) { content["com.beeper.linkpreviews"] = await Promise.all( urlPreviewSnapshot.previews.map(async (preview) => { From 9bb8e9b173d635d1a61f4147b26dd54581867262 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 13:45:35 +0100 Subject: [PATCH 16/29] claude wrote some unit tests for url previewing --- apps/web/src/utils/messages.ts | 1 - .../test/unit-tests/utils/messages-test.ts | 109 +++++++++++++++++- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/apps/web/src/utils/messages.ts b/apps/web/src/utils/messages.ts index d51963f5fef..e9be56c476b 100644 --- a/apps/web/src/utils/messages.ts +++ b/apps/web/src/utils/messages.ts @@ -20,7 +20,6 @@ import type EditorModel from "../editor/model"; import { Type } from "../editor/parts"; import { type RoomMessageEventContent } from "../../@types/url-preview"; import { uploadFile } from "../ContentMessages"; -import SettingsStore from "../settings/SettingsStore"; /** * Build the mentions information based on the editor model (and any related events): diff --git a/apps/web/test/unit-tests/utils/messages-test.ts b/apps/web/test/unit-tests/utils/messages-test.ts index 12d0242e3c8..4489dc8e98f 100644 --- a/apps/web/test/unit-tests/utils/messages-test.ts +++ b/apps/web/test/unit-tests/utils/messages-test.ts @@ -6,12 +6,115 @@ 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 { type IContent } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, type IContent } from "matrix-js-sdk/src/matrix"; -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 { createTestClient, mkEvent } from "../../test-utils"; import { createPartCreator } from "../editor/mock"; +import { RoomMessageEventContent } from "../../../@types/url-preview"; +import { MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; +import { mocked } from "jest-mock"; +import { uploadFile } from "../../../src/ContentMessages"; + +jest.mock("../../../src/ContentMessages", () => ({ + uploadFile: jest.fn(), +})); + +// Typed mock reference so the mock-only matchers (toHaveBeenCalled etc.) resolve. +const uploadFileMock = mocked(uploadFile); + +describe("attachUrlPreviews", () => { + let client: MatrixClient; + let fetchMock: jest.Mock; + + 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, + ], + }); + + beforeEach(() => { + jest.clearAllMocks(); + client = createTestClient(); + // fake fetch → returns something with a .blob(). Keep the mock reference + // around so we can assert on it (global.fetch is typed as native fetch). + fetchMock = jest.fn().mockResolvedValue({ + blob: async () => new Blob(["imgdata"], { type: "image/png" }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + it("does nothing when there are no previews", async () => { + const content = makeContent(); + await attachUrlPreviews(client, "!r:s", { previews: [] }, content); + expect(content["com.beeper.linkpreviews"]).toBeUndefined(); + expect(uploadFileMock).not.toHaveBeenCalled(); + }); + + it("attaches a preview with no image (no fetch/upload)", async () => { + const content = makeContent(); + await attachUrlPreviews(client, "!r:s", snapshot(), content); + expect(fetchMock).not.toHaveBeenCalled(); + expect(uploadFileMock).not.toHaveBeenCalled(); + expect(content["com.beeper.linkpreviews"]).toEqual([ + expect.objectContaining({ "og:title": "Example", "og:image": undefined }), + ]); + }); + + it("fetches + uploads the image, embeds the mxc url", async () => { + uploadFileMock.mockResolvedValue({ url: "mxc://server/img" }); + const content = makeContent(); + await attachUrlPreviews( + client, + "!r:s", + snapshot({ + imageThumb: "", + imageFull: "https://example.com/full.png", + imageType: "image/png", + fileSize: 1234, + width: 100, + height: 50, + playable: false, + }), + content, + ); + + expect(fetchMock).toHaveBeenCalledWith("https://example.com/full.png"); + expect(uploadFileMock).toHaveBeenCalledWith(client, "!r:s", expect.any(Blob)); + 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, + }), + ); + }); + + it("still attaches the preview when upload fails", async () => { + uploadFileMock.mockRejectedValue(new Error("boom")); + const content = makeContent(); + await attachUrlPreviews( + client, + "!r:s", + snapshot({ imageThumb: "", imageFull: "https://x/f.png", playable: false }), + content, + ); + // error is swallowed by logger.error → og:image just stays undefined + expect(content["com.beeper.linkpreviews"]![0]["og:image"]).toBeUndefined(); + }); +}); describe("attachMentions", () => { const partsCreator = createPartCreator(); From 9ee43a47ba0c140283d855319a59672714a4337c Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 14:21:03 +0100 Subject: [PATCH 17/29] fixed linter errors --- .../views/rooms/SendMessageComposer.tsx | 8 ++-- .../UrlPreviewFetcher.test.ts.snap | 2 + .../MessageComposerUrlPreviewViewModel.ts | 47 +++++++++++-------- ...geComposerUrlPreviewViewModel.test.ts.snap | 1 + .../test/unit-tests/utils/messages-test.ts | 8 ++-- 5 files changed, 38 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 9092c7d74b4..178ee6bbf7b 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -227,10 +227,10 @@ export class SendMessageComposer extends React.Component handles different kinds of opengraph responses { 'o "height": 478, "imageFull": "https://example.org/image/src", "imageThumb": "https://example.org/image/thumb", + "imageType": undefined, "playable": false, "width": 478, }, @@ -83,6 +84,7 @@ 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, "playable": false, "width": 128, }, diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index b2e767f5b32..9d06610043f 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -51,29 +51,36 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< return; } - let links: Set; - + let previews; if (SettingsStore.getValue("feature_msc4452_url_preview_bundle")) { - links = this.links; + 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; + } + }); + + // 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 }); } else { - links = new Set(Array.from(this.links).slice(0, 1)); // only preview first item - } - - // Fetch previews for all links in the message text, - // And remove the ones with erroneous responses - const previewRequests = Array.from(links).map(async (link) => { - try { - return await this.fetcher.fetchPreview(link, true); - } catch (ex) { - logger.warn("Fetching preview failed", ex); - return null; + for (const link of this.links) { + try { + const preview = await this.fetcher.fetchPreview(link, true); + if (preview) { + this.snapshot.set({ previews: [preview] }); + return; + } + } catch (ex) { + logger.warn("Fetching preview failed", ex); + } } - }); - - const previewResponses = await Promise.all(previewRequests); - const previews = previewResponses.filter((res) => res !== null); - - this.snapshot.set({ previews }); + } } /** diff --git a/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap b/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap index 5ef7f70f54b..0d89893727c 100644 --- a/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap +++ b/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap @@ -12,6 +12,7 @@ exports[`MessageComposerUrlPreviewViewModel > should preview a URL with media 1` "height": 128, "imageFull": "https://example.org/image/src", "imageThumb": "https://example.org/image/thumb", + "imageType": undefined, "playable": false, "width": 128, }, diff --git a/apps/web/test/unit-tests/utils/messages-test.ts b/apps/web/test/unit-tests/utils/messages-test.ts index 4489dc8e98f..8cebe243cfe 100644 --- a/apps/web/test/unit-tests/utils/messages-test.ts +++ b/apps/web/test/unit-tests/utils/messages-test.ts @@ -6,15 +6,15 @@ 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 { MatrixClient, type IContent } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient, type IContent } from "matrix-js-sdk/src/matrix"; +import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; +import { mocked } from "jest-mock"; import { attachMentions, attachUrlPreviews } from "../../../src/utils/messages"; import EditorModel from "../../../src/editor/model"; import { createTestClient, mkEvent } from "../../test-utils"; import { createPartCreator } from "../editor/mock"; -import { RoomMessageEventContent } from "../../../@types/url-preview"; -import { MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; -import { mocked } from "jest-mock"; +import { type RoomMessageEventContent } from "../../../@types/url-preview"; import { uploadFile } from "../../../src/ContentMessages"; jest.mock("../../../src/ContentMessages", () => ({ From 58874c3c4cf4e7226193a4886bbf44ad8cf85b33 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 14:27:34 +0100 Subject: [PATCH 18/29] added feature to labs.md --- docs/labs.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/labs.md b/docs/labs.md index 7abf108266f..d75e8826c34 100644 --- a/docs/labs.md +++ b/docs/labs.md @@ -136,3 +136,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 (MSC4452) (`feature_msc4452_url_preview_bundle`) + +Attaches URL preview information on messages sent. From 22315062285472ad4e3e9328d4fa7cbed21ce3c5 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 14:59:12 +0100 Subject: [PATCH 19/29] fixed linter errors --- .../rooms/MessageComposerUrlPreview.test.tsx | 15 ++++++++++++--- .../views/rooms/MessageComposerUrlPreview.tsx | 4 ++-- .../MessageComposerUrlPreviewViewModel.test.ts | 1 + 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx index 344836f994f..d743567ec44 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx @@ -22,6 +22,7 @@ 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 { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; // @vitest-environment happy-dom @@ -33,6 +34,14 @@ const BASIC_PREVIEW_OGDATA = { "og:site_name": "Example.org", }; +function getUrlPreviewVm(client: MatrixClient): MessageComposerUrlPreviewViewModel { + return new MessageComposerUrlPreviewViewModel({ + client, + visible: true, + showTooltips: false, + }); +} + describe("MessageComposerUrlPreview", () => { let client: MatrixClient; let originalMxModuleApi: ModuleApi; @@ -66,11 +75,11 @@ describe("MessageComposerUrlPreview", () => { } test("to be empty without a link to preview", () => { - const { container } = wrapComponent(); + const { container } = wrapComponent(); expect(container).toMatchInlineSnapshot(`
`); }); test("to contain a link when there is a URL", async () => { - const { getByText } = wrapComponent(); + const { getByText } = wrapComponent(); await waitFor( () => { expect(getByText("Example.org")).toBeDefined(); @@ -87,7 +96,7 @@ describe("MessageComposerUrlPreview", () => { () => Fake preview, ); const { getByText } = wrapComponent( - , + , ); await waitFor( () => { diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index 8126e5ad33d..3832a405e3f 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -13,7 +13,7 @@ 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"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500; @@ -45,7 +45,7 @@ export function MessageComposerUrlPreviewWrapper({ urlPreviewVm: MessageComposerUrlPreviewViewModel; moduleApi?: ModuleApi; }): ReactNode | null { - const { showUrlPreview, roomId } = useScopedRoomContext("showUrlPreview", "roomId"); + const { roomId } = useScopedRoomContext("showUrlPreview", "roomId"); const [customComponent, setCustomComponent] = useState(null); // Rather than checking each time the text changes, we only do a URL check every 500ms to avoid diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts index 29358280fe4..52eec01264a 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts @@ -30,6 +30,7 @@ function getViewModel({ visible } = { visible: true }): { const vm = new MessageComposerUrlPreviewViewModel({ client, visible, showTooltips: false }); return { vm, client: client as unknown as { getUrlPreview: Mock; mxcUrlToHttp: Mock } }; } + describe("MessageComposerUrlPreviewViewModel", () => { let originalDevicePixelRatio: Window["devicePixelRatio"]; beforeAll(() => { From 3d5bc42b1be12439aacc8cfe4937094be3299c0c Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 15:10:37 +0100 Subject: [PATCH 20/29] fixed oxfmt error --- .../views/rooms/MessageComposerUrlPreview.test.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx index d743567ec44..f39fc42c037 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx @@ -75,11 +75,15 @@ describe("MessageComposerUrlPreview", () => { } test("to be empty without a link to preview", () => { - const { container } = wrapComponent(); + const { container } = wrapComponent( + , + ); expect(container).toMatchInlineSnapshot(`
`); }); test("to contain a link when there is a URL", async () => { - const { getByText } = wrapComponent(); + const { getByText } = wrapComponent( + , + ); await waitFor( () => { expect(getByText("Example.org")).toBeDefined(); @@ -96,7 +100,11 @@ describe("MessageComposerUrlPreview", () => { () => Fake preview, ); const { getByText } = wrapComponent( - , + , ); await waitFor( () => { From 77fe2ab250477e9f9faf0bd61cc2cf74352b29d8 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 15:11:03 +0100 Subject: [PATCH 21/29] set previews to none if all previews failed --- .../viewmodels/composer/MessageComposerUrlPreviewViewModel.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index 9d06610043f..7a9f07b0ee4 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -80,6 +80,8 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< logger.warn("Fetching preview failed", ex); } } + + this.snapshot.set({ previews: [] }); } } From 45301829da7708e58212c033e68d45592f74119f Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Mon, 6 Jul 2026 19:32:42 +0100 Subject: [PATCH 22/29] resolved PR reviews besides the ones that requires a larger code change --- apps/web/@types/url-preview.ts | 4 +- .../views/rooms/MessageComposerUrlPreview.tsx | 5 +- .../views/rooms/SendMessageComposer.tsx | 4 +- .../rooms/wysiwyg_composer/utils/message.ts | 4 +- apps/web/src/i18n/strings/en_EN.json | 2 +- apps/web/src/settings/Settings.tsx | 4 +- apps/web/src/utils/UrlPreviewFetcher.ts | 3 + apps/web/src/utils/messages.ts | 74 +++++-------------- .../MessageComposerUrlPreviewViewModel.ts | 2 +- .../test/unit-tests/utils/messages-test.ts | 61 +++------------ docs/labs.md | 4 +- .../MessageComposerUrlPreview.stories.tsx | 3 + .../LinkPreview/LinkPreview.stories.tsx | 6 ++ .../UrlPreviewGroupView.stories.tsx | 4 + .../event-tile/UrlPreviewGroupView/types.ts | 8 ++ 15 files changed, 65 insertions(+), 123 deletions(-) diff --git a/apps/web/@types/url-preview.ts b/apps/web/@types/url-preview.ts index 3ef22a76593..761f4c33bce 100644 --- a/apps/web/@types/url-preview.ts +++ b/apps/web/@types/url-preview.ts @@ -14,17 +14,15 @@ import { * Bundled URL previews in MSC-4095 * * @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095 - * @see https://github.com/gomuks/gomuks/pull/560/changes */ interface UnstableBundledUrlPreviews { "com.beeper.linkpreviews"?: UnstableBundledUrlPreviewSingle[]; } /** - * Single item in bundled URL previews in MSC-4095 + * Single item in bundled URL previews in MSC4095 * * @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095 - * @see https://github.com/gomuks/gomuks/pull/560/changes */ export interface UnstableBundledUrlPreviewSingle { "matched_url": string; diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index 3832a405e3f..3ea15e19f5f 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -13,17 +13,18 @@ import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; import { useDebouncedCallback } from "../../../hooks/spotlight/useDebouncedCallback"; import PlatformPeg from "../../../PlatformPeg"; import { ModuleApi } from "../../../modules/Api"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500; export function useMessageComposerUrlPreviewViewModel(): MessageComposerUrlPreviewViewModel { const { showUrlPreview } = useScopedRoomContext("showUrlPreview"); + const client = useMatrixClientContext(); const vm = useCreateAutoDisposedViewModel( () => new MessageComposerUrlPreviewViewModel({ - client: MatrixClientPeg.safeGet(), + client, visible: showUrlPreview, showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true, }), diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 178ee6bbf7b..dbbd2c615b5 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -444,8 +444,8 @@ export class SendMessageComposer extends React.Component; @@ -651,7 +651,7 @@ export const SETTINGS: Settings = { isFeature: true, default: false, }, - "feature_msc4452_url_preview_bundle": { + "feature_msc4095_url_preview_bundle": { labsGroup: LabGroup.Messaging, displayName: _td("labs|url_preview_bundle"), description: _td("labs|url_preview_bundle_description"), diff --git a/apps/web/src/utils/UrlPreviewFetcher.ts b/apps/web/src/utils/UrlPreviewFetcher.ts index 284a0c60fe8..17bdb3ed173 100644 --- a/apps/web/src/utils/UrlPreviewFetcher.ts +++ b/apps/web/src/utils/UrlPreviewFetcher.ts @@ -160,6 +160,7 @@ 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"]); @@ -177,6 +178,7 @@ export class UrlPreviewFetcher { image = { imageThumb: thumb, imageFull: media.srcHttp ?? thumb, + mxcImageFull, imageType, width, height, @@ -197,6 +199,7 @@ export class UrlPreviewFetcher { description, siteName, siteIcon, + ogUrl: response["og:url"], showTooltipOnLink: !!(link !== title && this.showTooltips), image, } satisfies UrlPreview; diff --git a/apps/web/src/utils/messages.ts b/apps/web/src/utils/messages.ts index e9be56c476b..5cf0eb7a78a 100644 --- a/apps/web/src/utils/messages.ts +++ b/apps/web/src/utils/messages.ts @@ -5,21 +5,13 @@ 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 { - type MatrixEvent, - type IContent, - type IMentions, - type IEventRelation, - type MatrixClient, -} from "matrix-js-sdk/src/matrix"; +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 EncryptedFile } from "matrix-js-sdk/src/types"; -import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import type EditorModel from "../editor/model"; import { Type } from "../editor/parts"; import { type RoomMessageEventContent } from "../../@types/url-preview"; -import { uploadFile } from "../ContentMessages"; +import SettingsStore from "../settings/SettingsStore"; /** * Build the mentions information based on the editor model (and any related events): @@ -118,53 +110,25 @@ export function attachRelation(content: IContent, relation?: IEventRelation): vo }; } } -const logger = rootLogger.getChild("attachUrlPreviews"); - -export async function attachUrlPreviews( - mxClient: MatrixClient, - roomId: string, +export function attachUrlPreviews( urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot, content: RoomMessageEventContent, -): Promise { +): void { + if (!SettingsStore.getValue("feature_msc4095_url_preview_bundle")) return; + if (urlPreviewSnapshot.previews.length) { - content["com.beeper.linkpreviews"] = await Promise.all( - urlPreviewSnapshot.previews.map(async (preview) => { - // upload the files to produce the mxc:// url for the images - let imageUploaded: { url?: string; file?: EncryptedFile } | null = null; - - if (preview.image) { - let imageBlob: Blob | null = null; - - try { - imageBlob = await (await fetch(preview.image.imageFull)).blob(); - } catch (e) { - logger.error(`Failed to fetch image from ${preview.image.imageFull}`, e); - } - - if (imageBlob !== null) { - try { - imageUploaded = await uploadFile(mxClient, roomId, imageBlob); - } catch (e) { - logger.error("Failed to upload image", e); - } - } - } - - return { - "matched_url": preview.link, - // TODO: og:url may be different from the URL requested, but is not present in the response of the url_preview - "og:url": preview.link, - "og:title": preview.title, - "og:description": preview.description, - "og:image": imageUploaded?.url, - // TODO: should we trust the declared size? or should we calculate it ourselves - "og:image:width": preview.image?.width, - "og:image:height": preview.image?.height, - "og:image:type": preview.image?.imageType, - "matrix:image:size": preview.image?.fileSize, - "beeper:image:encryption": imageUploaded?.file, - }; - }), - ); + 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, + }; + }); } } diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index 7a9f07b0ee4..112079f9c3d 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -52,7 +52,7 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< } let previews; - if (SettingsStore.getValue("feature_msc4452_url_preview_bundle")) { + if (SettingsStore.getValue("feature_msc4095_url_preview_bundle")) { const previewRequests = Array.from(this.links).map(async (link) => { try { return await this.fetcher.fetchPreview(link, true); diff --git a/apps/web/test/unit-tests/utils/messages-test.ts b/apps/web/test/unit-tests/utils/messages-test.ts index 8cebe243cfe..9fd646afb25 100644 --- a/apps/web/test/unit-tests/utils/messages-test.ts +++ b/apps/web/test/unit-tests/utils/messages-test.ts @@ -6,28 +6,16 @@ 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 { type MatrixClient, type IContent } from "matrix-js-sdk/src/matrix"; +import { type IContent } from "matrix-js-sdk/src/matrix"; import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; -import { mocked } from "jest-mock"; import { attachMentions, attachUrlPreviews } from "../../../src/utils/messages"; import EditorModel from "../../../src/editor/model"; -import { createTestClient, mkEvent } from "../../test-utils"; +import { mkEvent } from "../../test-utils"; import { createPartCreator } from "../editor/mock"; import { type RoomMessageEventContent } from "../../../@types/url-preview"; -import { uploadFile } from "../../../src/ContentMessages"; - -jest.mock("../../../src/ContentMessages", () => ({ - uploadFile: jest.fn(), -})); - -// Typed mock reference so the mock-only matchers (toHaveBeenCalled etc.) resolve. -const uploadFileMock = mocked(uploadFile); describe("attachUrlPreviews", () => { - let client: MatrixClient; - let fetchMock: jest.Mock; - const makeContent = (): RoomMessageEventContent => ({ msgtype: "m.text", body: "hi https://example.com" }) as RoomMessageEventContent; @@ -44,43 +32,27 @@ describe("attachUrlPreviews", () => { ], }); - beforeEach(() => { - jest.clearAllMocks(); - client = createTestClient(); - // fake fetch → returns something with a .blob(). Keep the mock reference - // around so we can assert on it (global.fetch is typed as native fetch). - fetchMock = jest.fn().mockResolvedValue({ - blob: async () => new Blob(["imgdata"], { type: "image/png" }), - }); - global.fetch = fetchMock as unknown as typeof fetch; - }); - - it("does nothing when there are no previews", async () => { + it("does nothing when there are no previews", () => { const content = makeContent(); - await attachUrlPreviews(client, "!r:s", { previews: [] }, content); + attachUrlPreviews({ previews: [] }, content); expect(content["com.beeper.linkpreviews"]).toBeUndefined(); - expect(uploadFileMock).not.toHaveBeenCalled(); }); - it("attaches a preview with no image (no fetch/upload)", async () => { + it("attaches a preview with no image", () => { const content = makeContent(); - await attachUrlPreviews(client, "!r:s", snapshot(), content); - expect(fetchMock).not.toHaveBeenCalled(); - expect(uploadFileMock).not.toHaveBeenCalled(); + attachUrlPreviews(snapshot(), content); expect(content["com.beeper.linkpreviews"]).toEqual([ expect.objectContaining({ "og:title": "Example", "og:image": undefined }), ]); }); - it("fetches + uploads the image, embeds the mxc url", async () => { - uploadFileMock.mockResolvedValue({ url: "mxc://server/img" }); + it("embeds the mxc url from the preview image", () => { const content = makeContent(); - await attachUrlPreviews( - client, - "!r:s", + attachUrlPreviews( snapshot({ imageThumb: "", imageFull: "https://example.com/full.png", + mxcImageFull: "mxc://server/img", imageType: "image/png", fileSize: 1234, width: 100, @@ -90,8 +62,6 @@ describe("attachUrlPreviews", () => { content, ); - expect(fetchMock).toHaveBeenCalledWith("https://example.com/full.png"); - expect(uploadFileMock).toHaveBeenCalledWith(client, "!r:s", expect.any(Blob)); expect(content["com.beeper.linkpreviews"]![0]).toEqual( expect.objectContaining({ "og:image": "mxc://server/img", @@ -101,19 +71,6 @@ describe("attachUrlPreviews", () => { }), ); }); - - it("still attaches the preview when upload fails", async () => { - uploadFileMock.mockRejectedValue(new Error("boom")); - const content = makeContent(); - await attachUrlPreviews( - client, - "!r:s", - snapshot({ imageThumb: "", imageFull: "https://x/f.png", playable: false }), - content, - ); - // error is swallowed by logger.error → og:image just stays undefined - expect(content["com.beeper.linkpreviews"]![0]["og:image"]).toBeUndefined(); - }); }); describe("attachMentions", () => { diff --git a/docs/labs.md b/docs/labs.md index d75e8826c34..f7d775b0af4 100644 --- a/docs/labs.md +++ b/docs/labs.md @@ -137,6 +137,6 @@ Requires server support, based on the v2024 edition of [MSC4108](https://github. 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 (MSC4452) (`feature_msc4452_url_preview_bundle`) +## URL Preview Bundle (MSC4095) (`feature_msc4095_url_preview_bundle`) -Attaches URL preview information on messages sent. +Attaches URL preview information generated by the homeserver on messages sent. diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx index fb4b3ca9545..f32e835e03a 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx @@ -73,6 +73,7 @@ WithImage.args = { imageFull: imagePreviewFile, alt: "The element logo", playable: false, + mxcImageFull: "mxc://server/file", }, }, ], @@ -88,6 +89,7 @@ WithImageAndSiteIcon.args = { imageFull: imagePreviewFile, alt: "The element logo", playable: false, + mxcImageFull: "mxc://server/file", }, }, ], @@ -112,6 +114,7 @@ WithImageAndLoadsOfText.args = { imageFull: imagePreviewFile, alt: "The element logo", playable: false, + mxcImageFull: "mxc://server/file", }, }, ], diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx index 4ab9d130ec0..a8b8418e65c 100644 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx @@ -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", }, }; diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx index 79b7e1336ff..5fcbadab83d 100644 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx @@ -75,6 +75,7 @@ Default.args = { imageFull: imageFile, alt: "The element logo", playable: false, + mxcImageFull: "mxc://server/file", }, }, ], @@ -102,6 +103,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. @@ -116,6 +118,7 @@ MultiplePreviewsVisible.args = { imageFull: tallImageFile, alt: "A dog", playable: false, + mxcImageFull: "mxc://server/file", }, }, { @@ -129,6 +132,7 @@ MultiplePreviewsVisible.args = { imageFull: imageFile, alt: "The element logo", playable: false, + mxcImageFull: "mxc://server/file", }, }, ], diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts index 950ac704350..aa5f5a554ed 100644 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts @@ -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,10 @@ 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 */ From ca37033a285edf9793e49c643535b045f9c46be7 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Tue, 7 Jul 2026 10:45:42 +0100 Subject: [PATCH 23/29] moved url preview VM creation to message composer --- .../views/rooms/MessageComposer.tsx | 25 +++++++++++++++--- .../views/rooms/MessageComposerUrlPreview.tsx | 26 ++----------------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index ab0667a54f7..20ba21f414c 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -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, @@ -50,13 +50,16 @@ 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, useMessageComposerUrlPreviewViewModel } from "./MessageComposerUrlPreview"; import { Type } from "../../../editor/parts"; -import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; +import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; +import { useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; +import PlatformPeg from "../../../PlatformPeg"; // The prefix used when persisting editor drafts to localstorage. export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_"; @@ -744,6 +747,20 @@ export class MessageComposer extends React.Component { const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); export default function MessageComposerWrapper(props: Omit): JSX.Element { - const urlPreviewVm = useMessageComposerUrlPreviewViewModel(); + const { showUrlPreview } = useScopedRoomContext("showUrlPreview"); + const client = useMatrixClientContext(); + const urlPreviewVm = useCreateAutoDisposedViewModel( + () => + new MessageComposerUrlPreviewViewModel({ + client, + visible: showUrlPreview, + showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true, + }), + ); + + useEffect(() => { + void urlPreviewVm.updateUrlPreviewVisible(showUrlPreview); + }, [urlPreviewVm, showUrlPreview]); + return ; } diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index 3ea15e19f5f..650427abc1c 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -5,38 +5,16 @@ 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, { useState, type ReactNode } from "react"; +import { MessageComposerUrlPreviewView } from "@element-hq/web-shared-components"; import { 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 useMessageComposerUrlPreviewViewModel(): MessageComposerUrlPreviewViewModel { - const { showUrlPreview } = useScopedRoomContext("showUrlPreview"); - - const client = useMatrixClientContext(); - const vm = useCreateAutoDisposedViewModel( - () => - new MessageComposerUrlPreviewViewModel({ - client, - visible: showUrlPreview, - showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true, - }), - ); - - useEffect(() => { - void vm.updateUrlPreviewVisible(showUrlPreview); - }, [vm, showUrlPreview]); - - return vm; -} - export function MessageComposerUrlPreviewWrapper({ content, urlPreviewVm: vm, From 1965934f57bbbba052fa30b95bc2bde090f60734 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Tue, 7 Jul 2026 11:10:22 +0100 Subject: [PATCH 24/29] removed delay when clearing composer url preview on message send --- .../src/components/views/rooms/MessageComposer.tsx | 11 ++++++++--- .../views/rooms/MessageComposerUrlPreview.tsx | 2 +- .../components/views/rooms/SendMessageComposer.tsx | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 20ba21f414c..272fafca528 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -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,15 +51,18 @@ 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, useMatrixClientContext } 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, useMessageComposerUrlPreviewViewModel } from "./MessageComposerUrlPreview"; +import { MessageComposerUrlPreviewWrapper } from "./MessageComposerUrlPreview"; import { Type } from "../../../editor/parts"; import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; -import { useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; import PlatformPeg from "../../../PlatformPeg"; // The prefix used when persisting editor drafts to localstorage. @@ -414,6 +418,7 @@ export class MessageComposer extends React.Component { const { relation, replyToEvent } = this.props; const composerContent = this.state.composerContent; this.setState({ composerContent: "", initialComposerContent: "" }); + this.props.urlPreviewVm.updateWithText(""); dis.dispatch({ action: Action.ClearAndFocusSendMessageComposer, timelineRenderingType: this.context.timelineRenderingType, diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index 650427abc1c..128c90e030c 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { useState, 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 { ModuleApi } from "../../../modules/Api"; diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index dbbd2c615b5..c11a430b2d5 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -416,6 +416,7 @@ export class SendMessageComposer extends React.Component Date: Wed, 8 Jul 2026 10:51:31 +0100 Subject: [PATCH 25/29] minor changes to stop using globals everywhere --- apps/web/src/components/views/rooms/MessageComposer.tsx | 3 +++ apps/web/src/components/views/rooms/SendMessageComposer.tsx | 5 +---- .../composer/MessageComposerUrlPreviewViewModel.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 272fafca528..3096fa58fa2 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -64,6 +64,7 @@ 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_"; @@ -754,12 +755,14 @@ const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); export default function MessageComposerWrapper(props: Omit): 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, }), ); diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index c11a430b2d5..4408d554865 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -444,10 +444,7 @@ export class SendMessageComposer extends React.Component { try { return await this.fetcher.fetchPreview(link, true); From 5d2b3240a0907b24ae6be813e3cdc3a63f10f547 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Wed, 8 Jul 2026 14:16:17 +0100 Subject: [PATCH 26/29] moved debouncing responsibility to urlpreviewVM --- .../views/rooms/MessageComposerUrlPreview.tsx | 29 ++++------------- .../MessageComposerUrlPreviewViewModel.ts | 32 +++++++++++++------ 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index 128c90e030c..d063f43efa6 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -25,30 +25,13 @@ export function MessageComposerUrlPreviewWrapper({ moduleApi?: ModuleApi; }): ReactNode | null { const { roomId } = useScopedRoomContext("showUrlPreview", "roomId"); - const [customComponent, setCustomComponent] = useState(null); + const customComponent = moduleApi.customComponents.renderComposerPreview({ text: content, roomId: roomId! }, () => ( + + )); - // Rather than checking each time the text changes, we only do a URL check every 500ms to avoid - // hitting the server too frequently. We also only check the module API for a custom component - // at this frequency to avoid expensive calculations downstream. - useDebouncedCallback<[MessageComposerUrlPreviewViewModel, string]>( - true, - (vm, content) => { - const customComponent = moduleApi.customComponents.renderComposerPreview( - { text: content, roomId: roomId! }, - () => , - ); - - if (customComponent) { - setCustomComponent(customComponent); - } - // We still update the VM even if the custom component is used since - // the component may choose to render the original component. - void vm.updateWithText(content); - }, - [vm, content], - // Update instantly if content is empty (e.g. sent message or cleared input) - content ? DEBOUNCE_REQUEST_TIMEOUT_MS : 0, - ); + // 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); return customComponent ?? ; } diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index 2d3edc88480..bcada8fc1f2 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -10,6 +10,7 @@ import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { BaseViewModel, type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; import { UrlPreviewFetcher } from "../../utils/UrlPreviewFetcher"; +import { debounce } from "lodash"; const logger = rootLogger.getChild("MessageComposerUrlPreviewViewModel"); @@ -39,6 +40,8 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< */ private urlPreviewVisible: boolean; + private content: string = ""; + public constructor(props: MessageComposerUrlPreviewViewModelProps) { super(props, { previews: [] }); this.urlPreviewVisible = props.visible; @@ -46,6 +49,17 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< } private async computeSnapshot(): Promise { + const newLinksOrdered = this.content + .split(" ") + .map((w) => w.trim()) + .filter((word) => URL.canParse(word)); + const newLinks = new Set(newLinksOrdered); + if (this.links.symmetricDifference(newLinks).size === 0) { + // Skip if the URL set hasn't changed + return; + } + this.links = newLinks; + if (!this.urlPreviewVisible) { this.snapshot.set({ previews: [] }); return; @@ -90,19 +104,17 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< * @param content Plaintext from the message composer. */ public async updateWithText(content: string): Promise { - const newLinksOrdered = content - .split(" ") - .map((w) => w.trim()) - .filter((word) => URL.canParse(word)); - const newLinks = new Set(newLinksOrdered); - if (this.links.symmetricDifference(newLinks).size === 0) { - // Skip if the URL set hasn't changed - return; + this.content = content; + + if (content === "") { + return this.computeSnapshot(); + } else { + return this.computeSnapshotDebounced(); } - this.links = newLinks; - return this.computeSnapshot(); } + private computeSnapshotDebounced = debounce(this.computeSnapshot, 500); + /** * Update the view model about visible state of previews. * @param urlPreviewVisible Whether URL previews are hidden for this room. From 1a677e8ef74ac0d2baaf8bae45f906db1dee9a88 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Wed, 8 Jul 2026 14:17:42 +0100 Subject: [PATCH 27/29] minor lint fixes --- .../src/components/views/rooms/MessageComposerUrlPreview.tsx | 5 +---- .../composer/MessageComposerUrlPreviewViewModel.ts | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index d063f43efa6..48d3df4ad85 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -5,16 +5,13 @@ 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, { useState, type ReactNode } from "react"; +import React, { type ReactNode } from "react"; import { MessageComposerUrlPreviewView } from "@element-hq/web-shared-components"; import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; -import { useDebouncedCallback } from "../../../hooks/spotlight/useDebouncedCallback"; import { ModuleApi } from "../../../modules/Api"; -export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500; - export function MessageComposerUrlPreviewWrapper({ content, urlPreviewVm: vm, diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index bcada8fc1f2..e9c191b1320 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -14,6 +14,8 @@ import { debounce } from "lodash"; const logger = rootLogger.getChild("MessageComposerUrlPreviewViewModel"); +export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500; + export interface MessageComposerUrlPreviewViewModelProps { client: MatrixClient; visible: boolean; @@ -113,7 +115,7 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< } } - private computeSnapshotDebounced = debounce(this.computeSnapshot, 500); + private computeSnapshotDebounced = debounce(this.computeSnapshot, DEBOUNCE_REQUEST_TIMEOUT_MS); /** * Update the view model about visible state of previews. From 20e95b2406ff1b06a4ebb088b588f006423f9323 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Wed, 8 Jul 2026 14:18:59 +0100 Subject: [PATCH 28/29] added comment --- .../viewmodels/composer/MessageComposerUrlPreviewViewModel.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index e9c191b1320..4d61d52acdf 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -42,6 +42,9 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< */ private urlPreviewVisible: boolean; + /** + * Content at the latest call of updateWithText + */ private content: string = ""; public constructor(props: MessageComposerUrlPreviewViewModelProps) { From 4d8a0b389e84f8178b3aadf545add5cb0570ee53 Mon Sep 17 00:00:00 2001 From: Siriusmart Date: Thu, 9 Jul 2026 14:09:28 +0100 Subject: [PATCH 29/29] remove composer content from the state of urlpreview, moved it to the vm --- .../views/rooms/MessageComposer.tsx | 20 ++++----- .../rooms/MessageComposerUrlPreview.test.tsx | 8 +++- .../views/rooms/MessageComposerUrlPreview.tsx | 4 -- .../views/rooms/SendMessageComposer.tsx | 1 - apps/web/src/editor/model.ts | 9 +++- ...MessageComposerUrlPreviewViewModel.test.ts | 30 +++++++++----- .../MessageComposerUrlPreviewViewModel.ts | 41 ++++++++++--------- .../views/rooms/SendMessageComposer-test.tsx | 2 + .../wysiwyg_composer/utils/message-test.ts | 38 ++++++++--------- .../test/unit-tests/utils/messages-test.ts | 3 +- .../MessageComposerUrlPreview.tsx | 2 + 11 files changed, 87 insertions(+), 71 deletions(-) diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 3096fa58fa2..69a40342d6f 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -60,7 +60,6 @@ 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"; @@ -113,8 +112,6 @@ interface IState { isWysiwygLabEnabled: boolean; isRichTextEnabled: boolean; initialComposerContent: string; - // Specifically for generating previews only. - urlPreviewComposerContent: string; } type WysiwygComposerState = { @@ -156,7 +153,6 @@ export class MessageComposer extends React.Component { this.state = { isComposerEmpty: initialComposerContent?.length === 0, composerContent: initialComposerContent, - urlPreviewComposerContent: initialComposerContent, haveRecording: false, recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast isMenuOpen: false, @@ -405,7 +401,7 @@ export class MessageComposer extends React.Component { }; private sendMessage = async (): Promise => { - 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. @@ -419,7 +415,6 @@ export class MessageComposer extends React.Component { const { relation, replyToEvent } = this.props; const composerContent = this.state.composerContent; this.setState({ composerContent: "", initialComposerContent: "" }); - this.props.urlPreviewVm.updateWithText(""); dis.dispatch({ action: Action.ClearAndFocusSendMessageComposer, timelineRenderingType: this.context.timelineRenderingType, @@ -435,20 +430,19 @@ export class MessageComposer extends React.Component { }; 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, }); }; @@ -700,7 +694,7 @@ export class MessageComposer extends React.Component {
diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx index f39fc42c037..e4027f1664e 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx @@ -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,7 +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 { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; +import { + DEBOUNCE_REQUEST_TIMEOUT_MS, + MessageComposerUrlPreviewViewModel, +} from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; // @vitest-environment happy-dom @@ -39,6 +42,7 @@ function getUrlPreviewVm(client: MatrixClient): MessageComposerUrlPreviewViewMod client, visible: true, showTooltips: false, + urlPreviewBundle: false, }); } diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index 48d3df4ad85..81e9d436f42 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -26,9 +26,5 @@ export function MessageComposerUrlPreviewWrapper({ )); - // 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); - return customComponent ?? ; } diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 4408d554865..dc0ce2d1002 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -416,7 +416,6 @@ export class SendMessageComposer extends React.Component part.type === Type.Plain) + .map((part) => part.text) + .join(" "); + } + public constructor( parts: Part[], partCreator: PartCreator, diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts index 52eec01264a..809b7e073c6 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts @@ -27,7 +27,12 @@ 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 } }; } @@ -52,13 +57,13 @@ describe("MessageComposerUrlPreviewViewModel", () => { 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 empty list when preview is not visible", async () => { const { vm, client } = getViewModel({ visible: false }); - await vm.updateWithText("https://example.org"); + await vm.updateWithText({ content: "https://example.org", debounced: false }); expect(vm.getSnapshot().previews).toHaveLength(0); expect(client.getUrlPreview).not.toHaveBeenCalled(); }); @@ -66,7 +71,7 @@ describe("MessageComposerUrlPreviewViewModel", () => { 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"); + await vm.updateWithText({ content: "https://example.org", debounced: false }); expect(vm.getSnapshot().previews).toHaveLength(0); }); @@ -75,7 +80,7 @@ describe("MessageComposerUrlPreviewViewModel", () => { client.getUrlPreview .mockRejectedValueOnce(new Error("First URL failed")) .mockResolvedValueOnce(BASIC_PREVIEW_OGDATA); - await vm.updateWithText("https://example.org/one 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); }); @@ -83,22 +88,25 @@ describe("MessageComposerUrlPreviewViewModel", () => { 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"); + await vm.updateWithText({ content: "https://example.org", debounced: false }); expect(vm.getSnapshot().previews).not.toHaveLength(0); await vm.updateUrlPreviewVisible(false); expect(vm.getSnapshot().previews).toHaveLength(0); @@ -107,7 +115,7 @@ describe("MessageComposerUrlPreviewViewModel", () => { 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"); + await vm.updateWithText({ content: "https://example.org", debounced: false }); expect(vm.getSnapshot().previews).toHaveLength(0); await vm.updateUrlPreviewVisible(true); expect(vm.getSnapshot().previews).not.toHaveLength(0); @@ -130,7 +138,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(); }); }); diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index 4d61d52acdf..b499a5df0c7 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -8,9 +8,9 @@ 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"; -import { debounce } from "lodash"; const logger = rootLogger.getChild("MessageComposerUrlPreviewViewModel"); @@ -42,19 +42,16 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< */ private urlPreviewVisible: boolean; - /** - * Content at the latest call of updateWithText - */ private content: string = ""; public constructor(props: MessageComposerUrlPreviewViewModelProps) { - super(props, { previews: [] }); + super(props, { previews: [], content: "" }); this.urlPreviewVisible = props.visible; this.fetcher = new UrlPreviewFetcher(props.client, Date.now(), props.showTooltips); } - private async computeSnapshot(): Promise { - const newLinksOrdered = this.content + private async computeSnapshot(content: string): Promise { + const newLinksOrdered = content .split(" ") .map((w) => w.trim()) .filter((word) => URL.canParse(word)); @@ -63,13 +60,14 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< // Skip if the URL set hasn't changed return; } - this.links = newLinks; if (!this.urlPreviewVisible) { - this.snapshot.set({ previews: [] }); + this.snapshot.set({ previews: [], content }); return; } + this.links = newLinks; + let previews; if (this.props.urlPreviewBundle) { const previewRequests = Array.from(this.links).map(async (link) => { @@ -86,13 +84,13 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< const previewResponses = await Promise.all(previewRequests); previews = previewResponses.filter((res) => res !== null); - this.snapshot.set({ previews }); + 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] }); + this.snapshot.set({ previews: [preview], content }); return; } } catch (ex) { @@ -100,7 +98,7 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< } } - this.snapshot.set({ previews: [] }); + this.snapshot.set({ previews: [], content }); } } @@ -108,17 +106,22 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< * Trigger a recalculation of the links in the provided text. * @param content Plaintext from the message composer. */ - public async updateWithText(content: string): Promise { - this.content = content; + public async updateWithText({ content, debounced }: { content?: string; debounced: boolean }): Promise { + if (content !== undefined) { + this.content = content; + } - if (content === "") { - return this.computeSnapshot(); + if (debounced) { + return this.computeSnapshotDebounced(this.content); } else { - return this.computeSnapshotDebounced(); + return this.computeSnapshot(this.content); } } - private computeSnapshotDebounced = debounce(this.computeSnapshot, DEBOUNCE_REQUEST_TIMEOUT_MS); + private computeSnapshotDebounced = debounce( + (content) => this.computeSnapshot(content), + DEBOUNCE_REQUEST_TIMEOUT_MS, + ); /** * Update the view model about visible state of previews. @@ -129,6 +132,6 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< public readonly updateUrlPreviewVisible = (urlPreviewVisible: boolean): Promise => { this.urlPreviewVisible = urlPreviewVisible; this.fetcher.clearCache(); - return this.computeSnapshot(); + return this.computeSnapshot(this.content); }; } diff --git a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index 01ebd435f94..375bd9498cc 100644 --- a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -187,6 +187,7 @@ describe("", () => { client: mockClient, visible: false, showTooltips: false, + urlPreviewBundle: false, }); const defaultProps = { room: mockRoom, @@ -441,6 +442,7 @@ describe("", () => { client: cli, visible: false, showTooltips: false, + urlPreviewBundle: false, }); cli.isRoomEncrypted = jest.fn().mockReturnValue(true); diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts index bf50604a5e6..3fc64101080 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts @@ -68,7 +68,7 @@ describe("message", () => { await sendMessage("", true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -84,7 +84,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: mockRoomContextWithoutId, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -98,7 +98,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -110,7 +110,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, relation: {}, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -125,7 +125,7 @@ describe("message", () => { event_id: "valid_id", rel_type: "m.does_not_match", }, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -141,7 +141,7 @@ describe("message", () => { event_id: "valid_id", rel_type: "m.thread", }, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -154,7 +154,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -182,7 +182,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockReplyEvent, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -212,7 +212,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -227,7 +227,7 @@ describe("message", () => { await sendMessage("🎉", false, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -243,7 +243,7 @@ describe("message", () => { await sendMessage(validCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -256,7 +256,7 @@ describe("message", () => { await sendMessage(invalidPrefixCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -274,7 +274,7 @@ describe("message", () => { const result = await sendMessage(validCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -289,7 +289,7 @@ describe("message", () => { await sendMessage(inputText, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); expect(mockClient.sendMessage).toHaveBeenCalledWith( "myfakeroom", @@ -309,7 +309,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, relation: mockRelation, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); expect(mockClient.sendMessage).toHaveBeenCalledWith( @@ -326,7 +326,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockEvent, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); expect(addReplySpy).toHaveBeenCalledTimes(1); @@ -341,7 +341,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockEvent, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); expect(result).toBeUndefined(); @@ -356,7 +356,7 @@ describe("message", () => { await sendMessage(invalidCommandInput, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // we expect the message to have been sent @@ -377,7 +377,7 @@ describe("message", () => { const result = await sendMessage(invalidCommandInput, true, { roomContext: defaultRoomContext, mxClient: mockClient, - urlPreviewSnapshot: { previews: [] }, + urlPreviewSnapshot: { previews: [], content: "" }, }); expect(result).toBeUndefined(); diff --git a/apps/web/test/unit-tests/utils/messages-test.ts b/apps/web/test/unit-tests/utils/messages-test.ts index 9fd646afb25..daf3e035297 100644 --- a/apps/web/test/unit-tests/utils/messages-test.ts +++ b/apps/web/test/unit-tests/utils/messages-test.ts @@ -30,11 +30,12 @@ describe("attachUrlPreviews", () => { ...(image ? { image } : {}), } as any, ], + content: "https://example.com", }); it("does nothing when there are no previews", () => { const content = makeContent(); - attachUrlPreviews({ previews: [] }, content); + attachUrlPreviews({ previews: [], content: "" }, content); expect(content["com.beeper.linkpreviews"]).toBeUndefined(); }); diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx index 5219a93b982..4312cad8c21 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx @@ -18,6 +18,8 @@ import { useViewModel, type ViewModel } from "../../../core/viewmodel"; export interface MessageComposerUrlPreviewSnapshot { /** URL preview to render. */ previews: UrlPreview[]; + /** Content of the composer when the snapshot is computed */ + content: string; } /** Props for MessageComposerUrlPreviewView. */