diff --git a/apps/web/@types/url-preview.ts b/apps/web/@types/url-preview.ts new file mode 100644 index 00000000000..761f4c33bce --- /dev/null +++ b/apps/web/@types/url-preview.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { + type EncryptedFile, + type RoomMessageEventContent as SdkRoomMessageEventContent, +} from "matrix-js-sdk/src/types"; + +/** + * Bundled URL previews in MSC-4095 + * + * @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095 + */ +interface UnstableBundledUrlPreviews { + "com.beeper.linkpreviews"?: UnstableBundledUrlPreviewSingle[]; +} + +/** + * Single item in bundled URL previews in MSC4095 + * + * @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095 + */ +export interface UnstableBundledUrlPreviewSingle { + "matched_url": string; + "beeper:image:encryption"?: EncryptedFile; + "matrix:image:size"?: number; + "og:image"?: string; + "og:url"?: string; + "og:image:width"?: number; + "og:image:height"?: number; + "og:image:type"?: string; + "og:title"?: string; + "og:description"?: string; +} + +export type RoomMessageEventContent = SdkRoomMessageEventContent & UnstableBundledUrlPreviews; diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 86e577d1e20..faf41f15594 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, @@ -19,6 +19,7 @@ import { import { Tooltip } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/src/logger"; import { LockOffIcon, SendSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -50,12 +51,19 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { type VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysiwyg_composer/"; -import { type MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext"; +import { + type MatrixClientProps, + withMatrixClientHOC, + useMatrixClientContext, +} from "../../../contexts/MatrixClientContext"; import { UIFeature } from "../../../settings/UIFeature"; import { formatTimeLeft } from "../../../DateUtils"; import RoomReplacedSvg from "../../../../res/img/room_replaced.svg"; import { MessageComposerUrlPreviewWrapper } from "./MessageComposerUrlPreview"; -import { Type } from "../../../editor/parts"; +import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; +import PlatformPeg from "../../../PlatformPeg"; +import { useSettingValue } from "../../../hooks/useSettings"; // The prefix used when persisting editor drafts to localstorage. export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_"; @@ -88,6 +96,7 @@ interface IProps extends MatrixClientProps { relation?: IEventRelation; e2eStatus?: E2EStatus; compact?: boolean; + urlPreviewVm: MessageComposerUrlPreviewViewModel; } interface IState { @@ -103,8 +112,6 @@ interface IState { isWysiwygLabEnabled: boolean; isRichTextEnabled: boolean; initialComposerContent: string; - // Specifically for generating previews only. - urlPreviewComposerContent: string; } type WysiwygComposerState = { @@ -146,7 +153,6 @@ export class MessageComposer extends React.Component { this.state = { isComposerEmpty: initialComposerContent?.length === 0, composerContent: initialComposerContent, - urlPreviewComposerContent: initialComposerContent, haveRecording: false, recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast isMenuOpen: false, @@ -395,7 +401,7 @@ export class MessageComposer extends React.Component { }; 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. @@ -418,25 +424,25 @@ export class MessageComposer extends React.Component { roomContext: this.context, relation, replyToEvent, + urlPreviewSnapshot: this.props.urlPreviewVm.getSnapshot(), }); } }; private onChange = (model: EditorModel): void => { + this.props.urlPreviewVm.updateWithText({ + content: model.contentPlainText, + debounced: true, + }); this.setState({ - urlPreviewComposerContent: model - .serializeParts() - .filter((part) => part.type === Type.Plain) - .map((part) => part.text) - .join(" "), isComposerEmpty: model.isEmpty, }); }; private onWysiwygChange = (content: string): void => { + this.props.urlPreviewVm.updateWithText({ content, debounced: true }); this.setState({ composerContent: content, - urlPreviewComposerContent: content, isComposerEmpty: content?.length === 0, }); }; @@ -601,6 +607,7 @@ export class MessageComposer extends React.Component { onChange={this.onChange} disabled={this.state.haveRecording} toggleStickerPickerOpen={this.toggleStickerPickerOpen} + urlPreviewVm={this.props.urlPreviewVm} /> ); } @@ -686,7 +693,7 @@ export class MessageComposer extends React.Component { return (
- + { } const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); -export default MessageComposerWithMatrixClient; + +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, + }), + ); + + useEffect(() => { + void urlPreviewVm.updateUrlPreviewVisible(showUrlPreview); + }, [urlPreviewVm, showUrlPreview]); + + return ; +} diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.test.tsx index ebb9bda6944..9cfbf0386fe 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,6 +22,11 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import { CustomComponentsApi } from "../../../modules/customComponentApi"; +import { + DEBOUNCE_REQUEST_TIMEOUT_MS, + MessageComposerUrlPreviewViewModel, + type MessageComposerUrlPreviewViewModelProps, +} from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; // @vitest-environment happy-dom @@ -33,6 +38,26 @@ const BASIC_PREVIEW_OGDATA = { "og:site_name": "Example.org", }; +function getUrlPreviewVm(client: MatrixClient, content?: string): MessageComposerUrlPreviewViewModel { + const props: MessageComposerUrlPreviewViewModelProps = { + client, + visible: true, + showTooltips: false, + urlPreviewBundle: false, + }; + + if (content !== undefined) { + props.content = content; + } + + const vm = new MessageComposerUrlPreviewViewModel(props); + if (content !== undefined) { + // Mirror how MessageComposer drives the view model so previews are actually computed. + void vm.updateWithText({ content, debounced: false }); + } + return vm; +} + describe("MessageComposerUrlPreview", () => { let client: MatrixClient; let originalMxModuleApi: ModuleApi; @@ -66,11 +91,15 @@ describe("MessageComposerUrlPreview", () => { } test("to be empty without a link to preview", () => { - const { container } = wrapComponent(); + 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 +116,10 @@ describe("MessageComposerUrlPreview", () => { () => Fake preview, ); const { getByText } = wrapComponent( - , + , ); await waitFor( () => { @@ -105,12 +137,20 @@ describe("MessageComposerUrlPreview", () => { () => Fake preview, ); const { container, getByText, queryByText, rerender } = wrapComponent( - , + , ); await waitFor(() => { expect(getByText("Fake preview")).toBeDefined(); }); - rerender(); + rerender( + , + ); await waitFor(() => { expect(queryByText("Fake preview")).toBeNull(); }); diff --git a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx index a551e615493..e58f5efd56d 100644 --- a/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerUrlPreview.tsx @@ -5,62 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { useEffect, useState, type ReactNode } from "react"; -import { MessageComposerUrlPreviewView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; +import React, { type ReactNode } from "react"; +import { MessageComposerUrlPreviewView, useViewModel } from "@element-hq/web-shared-components"; -import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; +import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; -import { useDebouncedCallback } from "../../../hooks/spotlight/useDebouncedCallback"; -import PlatformPeg from "../../../PlatformPeg"; import { ModuleApi } from "../../../modules/Api"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; - -export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500; export function MessageComposerUrlPreviewWrapper({ - content, + urlPreviewVm: vm, moduleApi = ModuleApi.instance, }: { - content: string; + urlPreviewVm: MessageComposerUrlPreviewViewModel; moduleApi?: ModuleApi; }): ReactNode | null { - const { showUrlPreview, roomId } = useScopedRoomContext("showUrlPreview", "roomId"); - const [customComponent, setCustomComponent] = useState(null); - - const client = useMatrixClientContext(); - const vm = useCreateAutoDisposedViewModel( - () => - new MessageComposerUrlPreviewViewModel({ - client, - visible: showUrlPreview, - showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true, - }), - ); - - useEffect(() => { - void vm.updateUrlPreviewVisible(showUrlPreview); - }, [vm, showUrlPreview]); - - // Rather than checking each time the text changes, we only do a URL check every 500ms to avoid - // hitting the server too frequently. We also only check the module API for a custom component - // at this frequency to avoid expensive calculations downstream. - useDebouncedCallback<[MessageComposerUrlPreviewViewModel, string]>( - true, - (vm, content) => { - const customComponent = moduleApi.customComponents.renderComposerPreview( - { text: content, roomId: roomId! }, - () => , - ); - - setCustomComponent(customComponent); - // We still update the VM even if the custom component is used since - // the component may choose to render the original component. - void vm.updateWithText(content); - }, - [vm, content], - // Update instantly if content is empty (e.g. sent message or cleared input) - content ? DEBOUNCE_REQUEST_TIMEOUT_MS : 0, - ); + const { roomId } = useScopedRoomContext("showUrlPreview", "roomId"); + const { content } = useViewModel(vm); + const customComponent = moduleApi.customComponents.renderComposerPreview({ text: content, roomId: roomId! }, () => ( + + )); return customComponent ?? ; } diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 66288fbbaf8..dc0ce2d1002 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 { @@ -64,8 +64,9 @@ import { type Caret } from "../../../editor/caret"; import { type IDiff } from "../../../editor/diff"; import { getBlobSafeMimeType } from "../../../utils/blobs"; import { EMOJI_REGEX } from "../../../HtmlUtils"; -import { attachMentions, attachRelation } from "../../../utils/messages"; +import { attachMentions, attachRelation, attachUrlPreviews } from "../../../utils/messages"; import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel"; +import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel"; // The prefix used when persisting editor drafts to localstorage. export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_"; @@ -137,6 +138,7 @@ interface ISendMessageComposerProps extends MatrixClientProps { disabled?: boolean; onChange?(model: EditorModel): void; toggleStickerPickerOpen: () => void; + urlPreviewVm: MessageComposerUrlPreviewViewModel; } export class SendMessageComposer extends React.Component { @@ -410,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) { @@ -420,9 +437,14 @@ export class SendMessageComposer extends React.Component; + 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); + attachUrlPreviews(urlPreviewSnapshot, content); // TODO replace emotion end of message ? diff --git a/apps/web/src/editor/model.ts b/apps/web/src/editor/model.ts index d7118d8c5c7..44b5064d814 100644 --- a/apps/web/src/editor/model.ts +++ b/apps/web/src/editor/model.ts @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. import { diffAtCaret, diffDeletion, type IDiff } from "./diff"; import DocumentPosition, { type IPosition } from "./position"; import Range from "./range"; -import { type SerializedPart, type Part, type PartCreator } from "./parts"; +import { type SerializedPart, type Part, type PartCreator, Type } from "./parts"; import { type ICallback } from "./autocomplete"; import type AutocompleteWrapperModel from "./autocomplete"; import type DocumentOffset from "./offset"; @@ -49,6 +49,16 @@ export default class EditorModel { private autoCompletePartCount = 0; private transformCallback: TransformCallback | null = null; + /** + * Returns the plain text parts of the editor content only (skipping mentions) + */ + public get contentPlainText(): string { + return this.serializeParts() + .filter((part) => part.type === Type.Plain) + .map((part) => part.text) + .join(" "); + } + public constructor( parts: Part[], partCreator: PartCreator, diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 1421425956a..1671f797e01 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -1572,6 +1572,8 @@ "sliding_sync_disabled_notice": "Sign in again to disable", "sliding_sync_server_no_support": "Your server lacks support", "unrealiable_e2e": "Unreliable in encrypted rooms", + "url_preview_bundle": "URL Preview Bundle (MSC4095)", + "url_preview_bundle_description": "Send URL previews in message alongside message content", "video_rooms": "Video rooms", "video_rooms_a_new_way_to_chat": "A new way to chat over voice and video in %(brand)s.", "video_rooms_always_on_voip_channels": "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.", diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 4b137ae9016..41a85059a0e 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -229,6 +229,7 @@ export interface Settings { "feature_msc4362_encrypted_state_events": IFeature; "feature_user_status": IFeature; "feature_login_with_qr": IFeature; + "feature_msc4095_url_preview_bundle": IFeature; // These are in the feature namespace but aren't actually features "feature_hidebold": IBaseSetting; @@ -641,6 +642,15 @@ export const SETTINGS: Settings = { isFeature: true, default: false, }, + "feature_msc4095_url_preview_bundle": { + labsGroup: LabGroup.Messaging, + displayName: _td("labs|url_preview_bundle"), + description: _td("labs|url_preview_bundle_description"), + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, + supportedLevelsAreOrdered: true, + isFeature: true, + default: false, + }, /** * With the transition to Compound we are moving to a base font size * of 16px. We're taking the opportunity to move away from the `baseFontSize` diff --git a/apps/web/src/utils/UrlPreviewFetcher.ts b/apps/web/src/utils/UrlPreviewFetcher.ts index 3b6a4a21812..17bdb3ed173 100644 --- a/apps/web/src/utils/UrlPreviewFetcher.ts +++ b/apps/web/src/utils/UrlPreviewFetcher.ts @@ -160,11 +160,13 @@ export class UrlPreviewFetcher { let siteIcon: string | undefined; if (typeof response["og:image"] === "string" && loadMedia) { + const mxcImageFull = response["og:image"]; const media = mediaFromMxc(response["og:image"], this.client); const declaredHeight = UrlPreviewFetcher.getNumberFromOpenGraph(response["og:image:height"]); const declaredWidth = UrlPreviewFetcher.getNumberFromOpenGraph(response["og:image:width"]); const imageSize = UrlPreviewFetcher.getNumberFromOpenGraph(response["matrix:image:size"]); const alt = typeof response["og:image:alt"] === "string" ? response["og:image:alt"] : undefined; + const imageType = typeof response["og:image:type"] === "string" ? response["og:image:type"] : undefined; if (UrlPreviewFetcher.isImagePreview(declaredWidth, declaredHeight, imageSize)) { const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX); @@ -176,6 +178,8 @@ export class UrlPreviewFetcher { image = { imageThumb: thumb, imageFull: media.srcHttp ?? thumb, + mxcImageFull, + imageType, width, height, fileSize: UrlPreviewFetcher.getNumberFromOpenGraph(response["matrix:image:size"]), @@ -195,6 +199,7 @@ export class UrlPreviewFetcher { description, siteName, siteIcon, + ogUrl: response["og:url"], showTooltipOnLink: !!(link !== title && this.showTooltips), image, } satisfies UrlPreview; diff --git a/apps/web/src/utils/__snapshots__/UrlPreviewFetcher.test.ts.snap b/apps/web/src/utils/__snapshots__/UrlPreviewFetcher.test.ts.snap index c164f7642ff..8506879ae93 100644 --- a/apps/web/src/utils/__snapshots__/UrlPreviewFetcher.test.ts.snap +++ b/apps/web/src/utils/__snapshots__/UrlPreviewFetcher.test.ts.snap @@ -6,6 +6,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o "description": undefined, "image": undefined, "link": "https://example.org", + "ogUrl": "https://example.org", "showTooltipOnLink": false, "siteIcon": undefined, "siteName": "example.org", @@ -19,6 +20,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o "description": undefined, "image": undefined, "link": "https://example.org", + "ogUrl": "https://example.org", "showTooltipOnLink": false, "siteIcon": undefined, "siteName": "Site name", @@ -32,6 +34,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o "description": undefined, "image": undefined, "link": "https://example.org", + "ogUrl": "https://example.org", "showTooltipOnLink": false, "siteIcon": undefined, "siteName": "example.org", @@ -45,6 +48,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o "description": undefined, "image": undefined, "link": "https://example.org", + "ogUrl": "https://example.org", "showTooltipOnLink": false, "siteIcon": undefined, "siteName": "Cool site", @@ -62,10 +66,13 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o "height": 478, "imageFull": "https://example.org/image/src", "imageThumb": "https://example.org/image/thumb", + "imageType": undefined, + "mxcImageFull": "mxc://example.org/abc", "playable": false, "width": 478, }, "link": "https://example.org", + "ogUrl": "https://example.org", "showTooltipOnLink": false, "siteIcon": undefined, "siteName": "example.org", @@ -83,10 +90,13 @@ exports[`UrlPreviewFetcher > should preview a URL with media 1`] = ` "height": 128, "imageFull": "https://example.org/image/src", "imageThumb": "https://example.org/image/thumb", + "imageType": undefined, + "mxcImageFull": "mxc://example.org/abc", "playable": false, "width": 128, }, "link": "https://example.org", + "ogUrl": "https://example.org", "showTooltipOnLink": false, "siteIcon": undefined, "siteName": "example.org", diff --git a/apps/web/src/utils/messages.ts b/apps/web/src/utils/messages.ts index 2a5641f37a1..9ac9449262c 100644 --- a/apps/web/src/utils/messages.ts +++ b/apps/web/src/utils/messages.ts @@ -6,9 +6,12 @@ Please see LICENSE files in the repository root for full details. */ import { type MatrixEvent, type IContent, type IMentions, type IEventRelation } from "matrix-js-sdk/src/matrix"; +import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; import type EditorModel from "../editor/model"; import { Type } from "../editor/parts"; +import { type RoomMessageEventContent } from "../../@types/url-preview"; +import SettingsStore from "../settings/SettingsStore"; /** * Build the mentions information based on the editor model (and any related events): @@ -107,3 +110,25 @@ export function attachRelation(content: IContent, relation?: IEventRelation): vo }; } } +export function attachUrlPreviews( + urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot, + content: RoomMessageEventContent, +): void { + if (!SettingsStore.getValue("feature_msc4095_url_preview_bundle")) return; + + if (urlPreviewSnapshot.previews.length) { + content["com.beeper.linkpreviews"] = urlPreviewSnapshot.previews.map((preview) => { + return { + "matched_url": preview.link, + "og:url": preview.ogUrl, + "og:title": preview.title, + "og:description": preview.description, + "og:image": preview.image?.mxcImageFull, + "og:image:width": preview.image?.width, + "og:image:height": preview.image?.height, + "og:image:type": preview.image?.imageType, + "matrix:image:size": preview.image?.fileSize, + }; + }); + } +} diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts index 693ccf47d59..a93ff8dcd5b 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts @@ -27,9 +27,15 @@ function getViewModel({ visible } = { visible: true }): { getUrlPreview: vi.fn(), mxcUrlToHttp: vi.fn(), } as unknown as MatrixClient; - const vm = new MessageComposerUrlPreviewViewModel({ client, visible, showTooltips: false }); + const vm = new MessageComposerUrlPreviewViewModel({ + client, + visible, + showTooltips: false, + urlPreviewBundle: false, + }); return { vm, client: client as unknown as { getUrlPreview: Mock; mxcUrlToHttp: Mock } }; } + describe("MessageComposerUrlPreviewViewModel", () => { let originalDevicePixelRatio: Window["devicePixelRatio"]; beforeAll(() => { @@ -42,73 +48,78 @@ describe("MessageComposerUrlPreviewViewModel", () => { it("should return no preview by default", () => { expect(getViewModel().vm.getSnapshot()).toMatchInlineSnapshot(` -{ - "preview": null, -} -`); + { + "content": "", + "previews": [], + } + `); }); it("should preview a valid URL in text", async () => { const { vm, client } = getViewModel(); client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA); - await vm.updateWithText("Check out https://example.org today"); + await vm.updateWithText({ content: "Check out https://example.org today", debounced: false }); expect(vm.getSnapshot()).toMatchSnapshot(); }); - it("should return null when preview is not visible", async () => { + it("should return empty list when preview is not visible", async () => { const { vm, client } = getViewModel({ visible: false }); - await vm.updateWithText("https://example.org"); - expect(vm.getSnapshot().preview).toBeNull(); + await vm.updateWithText({ content: "https://example.org", debounced: false }); + expect(vm.getSnapshot().previews).toHaveLength(0); expect(client.getUrlPreview).not.toHaveBeenCalled(); }); - it("should return null when all URL fetches fail", async () => { + it("should return empty list when all URL fetches fail", async () => { const { vm, client } = getViewModel(); client.getUrlPreview.mockRejectedValue(new Error("Forced test failure")); - await vm.updateWithText("https://example.org"); - expect(vm.getSnapshot().preview).toBeNull(); + await vm.updateWithText({ content: "https://example.org", debounced: false }); + expect(vm.getSnapshot().previews).toHaveLength(0); }); - it("should use the first URL with a valid preview when multiple are given", async () => { + it("should use all URLs with a valid preview when multiple are given", async () => { const { vm, client } = getViewModel(); client.getUrlPreview .mockRejectedValueOnce(new Error("First URL failed")) .mockResolvedValueOnce(BASIC_PREVIEW_OGDATA); - await vm.updateWithText("https://example.org/one https://example.org/two"); - expect(vm.getSnapshot().preview?.link).toEqual("https://example.org/two"); + await vm.updateWithText({ content: "https://example.org/one https://example.org/two", debounced: false }); + expect(vm.getSnapshot().previews[0]?.link).toEqual("https://example.org/two"); + expect(vm.getSnapshot().previews).toHaveLength(1); }); it("should not re-fetch when text changes but the URL set does not", async () => { const { vm, client } = getViewModel(); client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA); - await vm.updateWithText("https://example.org"); - await vm.updateWithText("https://example.org some extra words"); + await vm.updateWithText({ content: "https://example.org", debounced: false }); + await vm.updateWithText({ content: "https://example.org some extra words", debounced: false }); expect(client.getUrlPreview).toHaveBeenCalledTimes(1); }); it("should deduplicate repeated URLs", async () => { const { vm, client } = getViewModel(); client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA); - await vm.updateWithText("https://example.org https://example.org https://example.org"); + await vm.updateWithText({ + content: "https://example.org https://example.org https://example.org", + debounced: false, + }); expect(client.getUrlPreview).toHaveBeenCalledTimes(1); }); it("should hide preview when made invisible", async () => { const { vm, client } = getViewModel(); client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA); - await vm.updateWithText("https://example.org"); - expect(vm.getSnapshot().preview).not.toBeNull(); + await vm.updateWithText({ content: "https://example.org", debounced: false }); + expect(vm.getSnapshot().previews).not.toHaveLength(0); await vm.updateUrlPreviewVisible(false); - expect(vm.getSnapshot().preview).toBeNull(); + expect(vm.getSnapshot().previews).toHaveLength(0); }); it("should restore preview when made visible again", async () => { const { vm, client } = getViewModel({ visible: false }); client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA); - await vm.updateWithText("https://example.org"); - expect(vm.getSnapshot().preview).toBeNull(); + await vm.updateWithText({ content: "https://example.org", debounced: false }); + expect(vm.getSnapshot().previews).toHaveLength(0); await vm.updateUrlPreviewVisible(true); - expect(vm.getSnapshot().preview).not.toBeNull(); + expect(vm.getSnapshot().previews).not.toHaveLength(0); }); it("should preview a URL with media", async () => { @@ -128,7 +139,7 @@ describe("MessageComposerUrlPreviewViewModel", () => { if (width) return "https://example.org/image/thumb"; return "https://example.org/image/src"; }); - await vm.updateWithText("https://example.org"); + await vm.updateWithText({ content: "https://example.org", debounced: false }); expect(vm.getSnapshot()).toMatchSnapshot(); }); }); diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts index 9329c46fa7f..78ae44876a6 100644 --- a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -8,15 +8,20 @@ import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { BaseViewModel, type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; +import { debounce } from "lodash"; import { UrlPreviewFetcher } from "../../utils/UrlPreviewFetcher"; const logger = rootLogger.getChild("MessageComposerUrlPreviewViewModel"); +export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500; + export interface MessageComposerUrlPreviewViewModelProps { client: MatrixClient; visible: boolean; showTooltips: boolean; + urlPreviewBundle: boolean; + content?: string; } export class MessageComposerUrlPreviewViewModel extends BaseViewModel< @@ -27,6 +32,9 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< /** * Calculated set of links from the message text. + * + * Links are inserted in the order they appear in the message text, + * which guarantees Array.from(this.links) to be in the same order. */ private links: Set = new Set(); @@ -35,52 +43,91 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< */ private urlPreviewVisible: boolean; + private content: string; + public constructor(props: MessageComposerUrlPreviewViewModelProps) { - super(props, { preview: null }); + super(props, { previews: [], content: props.content ?? "" }); this.urlPreviewVisible = props.visible; this.fetcher = new UrlPreviewFetcher(props.client, Date.now(), props.showTooltips); + this.content = this.snapshot.current.content; } - private async computeSnapshot(): Promise { + private async computeSnapshot(content: string): Promise { + const newLinksOrdered = content + .split(" ") + .map((w) => w.trim()) + .filter((word) => URL.canParse(word)); + const newLinks = new Set(newLinksOrdered); + if (!this.urlPreviewVisible) { - this.snapshot.set({ preview: null }); + // Clear any existing previews whenever previews are hidden, regardless of + // whether the URL set has changed (e.g. when toggled invisible). + this.snapshot.set({ previews: [], content }); + return; + } + + if (this.links.symmetricDifference(newLinks).size === 0) { + // Skip if the URL set hasn't changed 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; + + this.links = newLinks; + + let previews; + if (this.props.urlPreviewBundle) { + const previewRequests = Array.from(this.links).map(async (link) => { + try { + return await this.fetcher.fetchPreview(link, true); + } catch (ex) { + logger.warn("Fetching preview failed", ex); + return null; + } + }); + + // Fetch previews for all links in the message text, + // And remove the ones with erroneous responses + const previewResponses = await Promise.all(previewRequests); + previews = previewResponses.filter((res) => res !== null); + + this.snapshot.set({ previews, content }); + } else { + for (const link of this.links) { + try { + const preview = await this.fetcher.fetchPreview(link, true); + if (preview) { + this.snapshot.set({ previews: [preview], content }); + return; + } + } catch (ex) { + logger.warn("Fetching preview failed", ex); } - } catch (ex) { - logger.warn("Fetching preview failed", ex); } + + this.snapshot.set({ previews: [], content }); } - this.snapshot.set({ preview: null }); } /** * Trigger a recalculation of the links in the provided text. * @param content Plaintext from the message composer. */ - public async updateWithText(content: string): Promise { - const newLinks = new Set( - content - .split(" ") - .map((w) => w.trim()) - .filter((word) => URL.canParse(word)), - ); - if (this.links.symmetricDifference(newLinks).size === 0) { - // Skip if the URL set hasn't changed - return; + public async updateWithText({ content, debounced }: { content?: string; debounced: boolean }): Promise { + if (content !== undefined) { + this.content = content; + } + + if (debounced) { + return this.computeSnapshotDebounced(this.content); + } else { + return this.computeSnapshot(this.content); } - this.links = newLinks; - return this.computeSnapshot(); } + private computeSnapshotDebounced = debounce( + (content) => this.computeSnapshot(content), + DEBOUNCE_REQUEST_TIMEOUT_MS, + ); + /** * Update the view model about visible state of previews. * @param urlPreviewVisible Whether URL previews are hidden for this room. @@ -90,6 +137,6 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel< public readonly updateUrlPreviewVisible = (urlPreviewVisible: boolean): Promise => { this.urlPreviewVisible = urlPreviewVisible; this.fetcher.clearCache(); - return this.computeSnapshot(); + return this.computeSnapshot(this.content); }; } 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..9901ba4e50c 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,48 @@ exports[`MessageComposerUrlPreviewViewModel > should preview a URL with media 1`] = ` { - "preview": { - "author": undefined, - "description": undefined, - "image": { - "alt": undefined, - "fileSize": 10000, - "height": 128, - "imageFull": "https://example.org/image/src", - "imageThumb": "https://example.org/image/thumb", - "playable": false, - "width": 128, + "content": "https://example.org", + "previews": [ + { + "author": undefined, + "description": undefined, + "image": { + "alt": undefined, + "fileSize": 10000, + "height": 128, + "imageFull": "https://example.org/image/src", + "imageThumb": "https://example.org/image/thumb", + "imageType": undefined, + "mxcImageFull": "mxc://example.org/abc", + "playable": false, + "width": 128, + }, + "link": "https://example.org", + "ogUrl": "https://example.org", + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "example.org", + "title": "Media example", }, - "link": "https://example.org", - "showTooltipOnLink": false, - "siteIcon": undefined, - "siteName": "example.org", - "title": "Media example", - }, + ], } `; exports[`MessageComposerUrlPreviewViewModel > should preview a valid URL in text 1`] = ` { - "preview": { - "author": undefined, - "description": "This is a description", - "image": undefined, - "link": "https://example.org", - "showTooltipOnLink": false, - "siteIcon": undefined, - "siteName": "Example.org", - "title": "This is an example!", - }, + "content": "Check out https://example.org today", + "previews": [ + { + "author": undefined, + "description": "This is a description", + "image": undefined, + "link": "https://example.org", + "ogUrl": "https://example.org", + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "Example.org", + "title": "This is an example!", + }, + ], } `; 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 ef8d77e433b..b1f33d8d632 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 @@ -35,6 +35,7 @@ import { addTextToComposer } from "../../../../test-utils/composer"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass"; import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; +import { MessageComposerUrlPreviewViewModel } from "../../../../../src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts"; import { SDKContext } from "../../../../../src/contexts/SDKContext.ts"; jest.mock("../../../../../src/utils/local-room", () => ({ @@ -186,6 +187,12 @@ describe("", () => { spyDispatcher.mockReset(); }); + const urlPreviewVm = new MessageComposerUrlPreviewViewModel({ + client: mockClient, + visible: false, + showTooltips: false, + urlPreviewBundle: false, + }); const defaultProps = { room: mockRoom, toggleStickerPickerOpen: jest.fn(), @@ -194,7 +201,7 @@ describe("", () => { - + @@ -439,6 +446,13 @@ describe("", () => { it("should call prepareToEncrypt when the user is typing", async () => { const cli = stubClient(); + const urlPreviewVm = new MessageComposerUrlPreviewViewModel({ + client: cli, + visible: false, + showTooltips: false, + urlPreviewBundle: false, + }); + cli.isRoomEncrypted = jest.fn().mockReturnValue(true); const room = mkStubRoom("!roomId:server", "Room", cli); @@ -448,7 +462,11 @@ describe("", () => { - + , 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..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 @@ -65,7 +65,11 @@ describe("message", () => { describe("sendMessage", () => { it("Should not send empty html message", async () => { // When - await sendMessage("", true, { roomContext: defaultRoomContext, mxClient: mockClient }); + await sendMessage("", true, { + roomContext: defaultRoomContext, + mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, + }); // Then expect(mockClient.sendMessage).toHaveBeenCalledTimes(0); @@ -80,6 +84,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: mockRoomContextWithoutId, mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -93,6 +98,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -104,6 +110,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, relation: {}, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -118,6 +125,7 @@ describe("message", () => { event_id: "valid_id", rel_type: "m.does_not_match", }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -133,6 +141,7 @@ describe("message", () => { event_id: "valid_id", rel_type: "m.thread", }, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -145,6 +154,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -172,6 +182,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockReplyEvent, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -201,6 +212,7 @@ describe("message", () => { await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -212,7 +224,11 @@ describe("message", () => { it("Should handle emojis", async () => { // When - await sendMessage("🎉", false, { roomContext: defaultRoomContext, mxClient: mockClient }); + await sendMessage("🎉", false, { + roomContext: defaultRoomContext, + mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, + }); // Then expect(spyDispatcher).toHaveBeenCalledWith({ action: "effects.confetti" }); @@ -227,6 +243,7 @@ describe("message", () => { await sendMessage(validCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -239,6 +256,7 @@ describe("message", () => { await sendMessage(invalidPrefixCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -256,6 +274,7 @@ describe("message", () => { const result = await sendMessage(validCommand, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, }); // Then @@ -270,6 +289,7 @@ describe("message", () => { await sendMessage(inputText, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, }); expect(mockClient.sendMessage).toHaveBeenCalledWith( "myfakeroom", @@ -289,6 +309,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, relation: mockRelation, + urlPreviewSnapshot: { previews: [], content: "" }, }); expect(mockClient.sendMessage).toHaveBeenCalledWith( @@ -305,6 +326,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockEvent, + urlPreviewSnapshot: { previews: [], content: "" }, }); expect(addReplySpy).toHaveBeenCalledTimes(1); @@ -319,6 +341,7 @@ describe("message", () => { roomContext: defaultRoomContext, mxClient: mockClient, replyToEvent: mockEvent, + urlPreviewSnapshot: { previews: [], content: "" }, }); expect(result).toBeUndefined(); @@ -333,6 +356,7 @@ describe("message", () => { await sendMessage(invalidCommandInput, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, }); // we expect the message to have been sent @@ -353,6 +377,7 @@ describe("message", () => { const result = await sendMessage(invalidCommandInput, true, { roomContext: defaultRoomContext, mxClient: mockClient, + urlPreviewSnapshot: { previews: [], content: "" }, }); expect(result).toBeUndefined(); diff --git a/apps/web/test/unit-tests/utils/messages-test.ts b/apps/web/test/unit-tests/utils/messages-test.ts index 12d0242e3c8..daf3e035297 100644 --- a/apps/web/test/unit-tests/utils/messages-test.ts +++ b/apps/web/test/unit-tests/utils/messages-test.ts @@ -7,11 +7,72 @@ Please see LICENSE files in the repository root for full details. */ import { type IContent } from "matrix-js-sdk/src/matrix"; +import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components"; -import { attachMentions } from "../../../src/utils/messages"; +import { attachMentions, attachUrlPreviews } from "../../../src/utils/messages"; import EditorModel from "../../../src/editor/model"; import { mkEvent } from "../../test-utils"; import { createPartCreator } from "../editor/mock"; +import { type RoomMessageEventContent } from "../../../@types/url-preview"; + +describe("attachUrlPreviews", () => { + const makeContent = (): RoomMessageEventContent => + ({ msgtype: "m.text", body: "hi https://example.com" }) as RoomMessageEventContent; + + const snapshot = (image?: object): MessageComposerUrlPreviewSnapshot => ({ + previews: [ + { + link: "https://example.com", + showTooltipOnLink: false, + title: "Example", + siteName: "example.com", + description: "desc", + ...(image ? { image } : {}), + } as any, + ], + content: "https://example.com", + }); + + it("does nothing when there are no previews", () => { + const content = makeContent(); + attachUrlPreviews({ previews: [], content: "" }, content); + expect(content["com.beeper.linkpreviews"]).toBeUndefined(); + }); + + it("attaches a preview with no image", () => { + const content = makeContent(); + attachUrlPreviews(snapshot(), content); + expect(content["com.beeper.linkpreviews"]).toEqual([ + expect.objectContaining({ "og:title": "Example", "og:image": undefined }), + ]); + }); + + it("embeds the mxc url from the preview image", () => { + const content = makeContent(); + attachUrlPreviews( + snapshot({ + imageThumb: "", + imageFull: "https://example.com/full.png", + mxcImageFull: "mxc://server/img", + imageType: "image/png", + fileSize: 1234, + width: 100, + height: 50, + playable: false, + }), + content, + ); + + expect(content["com.beeper.linkpreviews"]![0]).toEqual( + expect.objectContaining({ + "og:image": "mxc://server/img", + "og:image:width": 100, + "og:image:type": "image/png", + "matrix:image:size": 1234, + }), + ); + }); +}); describe("attachMentions", () => { const partsCreator = createPartCreator(); diff --git a/docs/labs.md b/docs/labs.md index e6aa4c87697..79227004c81 100644 --- a/docs/labs.md +++ b/docs/labs.md @@ -130,3 +130,7 @@ Enables logging in to Element Web/Desktop by using Element X to scan a QR code. Requires server support, based on the v2024 edition of [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108). Can only be configured via config.json, as labs flags cannot be set until after login, and are cleared on log out. The UI option is disabled. Note, this does not affect the existing `Link new device` flow using the same MSC. + +## URL Preview Bundle (MSC4095) (`feature_msc4095_url_preview_bundle`) + +Attaches URL preview information generated by the homeserver on messages sent. 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..f32e835e03a 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,55 @@ 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, + mxcImageFull: "mxc://server/file", + }, }, - }, + ], }; export const WithImageAndSiteIcon = Template.bind({}); WithImageAndSiteIcon.args = { - preview: { - ...Default.args.preview!, - siteIcon: siteIconFile, - image: { - imageThumb: imagePreviewFile, - imageFull: imagePreviewFile, - alt: "The element logo", - playable: false, + previews: [ + { + ...Default.args.previews![0]!, + siteIcon: siteIconFile, + image: { + imageThumb: imagePreviewFile, + imageFull: imagePreviewFile, + alt: "The element logo", + playable: false, + mxcImageFull: "mxc://server/file", + }, }, - }, + ], }; export const WithImageAndLoadsOfText = Template.bind({}); WithImageAndLoadsOfText.args = { - preview: { - ...Default.args.preview!, - description: `Molestiae aliquam quos possimus molestiae id sit nulla rerum. Sunt cumque illum alias. Illo ipsa ut iure quia nulla magnam repellat. + previews: [ + { + ...Default.args.previews![0]!, + description: `Molestiae aliquam quos possimus molestiae id sit nulla rerum. Sunt cumque illum alias. Illo ipsa ut iure quia nulla magnam repellat. Esse velit corporis sapiente temporibus quia ipsam. Pariatur est rem veritatis. Inventore sit consequatur odio ipsa error non assumenda. Est eum ex dignissimos voluptatibus voluptatem delectus modi. Nisi quia eius ea quibusdam. Aut eveniet maxime non. @@ -100,11 +109,13 @@ WithImageAndLoadsOfText.args = { Incidunt ut ea quae nobis. Reiciendis inventore quas qui eum voluptatem ex et qui. Adipisci quibusdam dolores hic inventore et suscipit cupiditate consequuntur. Temporibus similique sint quo. Omnis tempora quidem explicabo in quidem magnam quia. Aut sunt accusantium ut et ut laborum debitis in. Enim nihil sit consectetur facilis quidem voluptatem. Quod impedit odit veritatis est laudantium tempore sit labore. Atque minima aliquam nostrum et.`, - image: { - imageThumb: imagePreviewFile, - imageFull: imagePreviewFile, - alt: "The element logo", - playable: false, + image: { + imageThumb: imagePreviewFile, + imageFull: imagePreviewFile, + alt: "The element logo", + playable: false, + mxcImageFull: "mxc://server/file", + }, }, - }, + ], }; diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx index 4e34927fd0c..4312cad8c21 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx @@ -17,7 +17,9 @@ import { useViewModel, type ViewModel } from "../../../core/viewmodel"; /** Snapshot data for rendering a URL preview attached to the composer. */ export interface MessageComposerUrlPreviewSnapshot { /** URL preview to render. */ - preview: UrlPreview | null; + previews: UrlPreview[]; + /** Content of the composer when the snapshot is computed */ + content: string; } /** Props for MessageComposerUrlPreviewView. */ @@ -33,15 +35,18 @@ export interface MessageComposerUrlPreviewProps { } /** - * MessageComposerUrlPreviewView renders a preview of a single URL above the messasge composer. + * MessageComposerUrlPreviewView renders a preview of all previewable URLs above the messasge composer. */ export function MessageComposerUrlPreviewView({ vm, className }: MessageComposerUrlPreviewProps): JSX.Element | null { - const { preview } = useViewModel(vm); - if (!preview) { + const { previews } = useViewModel(vm); + if (previews.length === 0) { return null; } - return ( -
+ + // 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 && ( {preview.image.alt} @@ -53,5 +58,7 @@ export function MessageComposerUrlPreviewView({ vm, className }: MessageComposer
- ); + )); + + return <>{previewViews}; } 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 06af4fe238b..263b188d9d7 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 @@ -96,6 +96,7 @@ Default.args = { imageFull: imageFile, alt: "The element logo", playable: false, + mxcImageFull: "mxc://server/file", }, }, ], @@ -123,6 +124,7 @@ MultiplePreviewsVisible.args = { imageFull: imageFile, alt: "The element logo", playable: false, + mxcImageFull: "mxc://server/file", }, }, // These images should appear the same size despite having different dimensions. @@ -137,6 +139,7 @@ MultiplePreviewsVisible.args = { imageFull: tallImageFile, alt: "A dog", playable: false, + mxcImageFull: "mxc://server/file", }, }, { @@ -150,6 +153,7 @@ MultiplePreviewsVisible.args = { imageFull: imageFile, alt: "The element logo", playable: false, + mxcImageFull: "mxc://server/file", }, }, ], 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..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,14 @@ export interface UrlPreview { * The HTTP URI of the full image. */ imageFull: string; + /** + * The mxc:// URI of the full image. + */ + mxcImageFull: string; + /** + * The type/subtype of the image format + */ + imageType?: string; /** * File size in bytes. */