diff --git a/apps/web/playwright/e2e/links/composer-url-preview.spec.ts b/apps/web/playwright/e2e/links/composer-url-preview.spec.ts new file mode 100644 index 0000000000..b3828aeb9c --- /dev/null +++ b/apps/web/playwright/e2e/links/composer-url-preview.spec.ts @@ -0,0 +1,77 @@ +/* + * 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 { test, expect } from "../../element-web-test"; + +const PREVIEW_URL_PATTERN = /.*\/_matrix\/(client\/v1\/media|media\/v3)\/preview_url.*/; + +test.describe("Composer URL preview", () => { + test.use({ + displayName: "Alice", + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ name: "Test room" }); + await use({ roomId }); + }, + }); + + for (const editor of ["cider", "rich text"]) { + test.describe(`in ${editor}`, () => { + test.use({ + labsFlags: editor === "rich_text" ? ["feature_wysiwyg_composer"] : [], + }); + + test("shows a preview when a URL is typed into the composer", async ({ page, app, room }) => { + await page.route(PREVIEW_URL_PATTERN, (route) => + route.fulfill({ + json: { + "og:title": "Example Site", + "og:description": "A great description", + "og:site_name": "example.org", + }, + }), + ); + + await page.goto(`#/room/${room.roomId}`); + const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" }); + await composer.pressSequentially("https://example.org/"); + + await expect(page.getByRole("link", { name: "Example Site" })).toBeVisible(); + }); + + test("does not show a preview when the server returns a 404", async ({ page, app, room }) => { + await page.route(PREVIEW_URL_PATTERN, (route) => route.fulfill({ status: 404 })); + + await page.goto(`#/room/${room.roomId}`); + const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" }); + await composer.pressSequentially("https://example.org/"); + + await expect(page.getByRole("button", { name: "Hide preview" })).not.toBeVisible(); + }); + + test("shows the second URL's preview if the first has no valid preview", async ({ page, app, room }) => { + await page.route(PREVIEW_URL_PATTERN, (route, request) => { + const url = new URL(request.url()).searchParams.get("url"); + if (url === "https://example.org/") { + return route.fulfill({ status: 404 }); + } + return route.fulfill({ + json: { + "og:title": "Fallback Site", + "og:site_name": "fallback.example.org", + }, + }); + }); + + await page.goto(`#/room/${room.roomId}`); + const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" }); + await composer.pressSequentially("https://example.org/ https://fallback.example.org/"); + + await expect(page.getByRole("link", { name: "Fallback Site" })).toBeVisible(); + }); + }); + } +}); diff --git a/apps/web/src/components/views/messages/TextualBodyFactory.tsx b/apps/web/src/components/views/messages/TextualBodyFactory.tsx index b51eebe78b..252d2f4e0e 100644 --- a/apps/web/src/components/views/messages/TextualBodyFactory.tsx +++ b/apps/web/src/components/views/messages/TextualBodyFactory.tsx @@ -24,7 +24,6 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useMediaVisible } from "../../../hooks/useMediaVisible"; import { TextualBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/TextualBodyViewModel"; import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel"; -import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPreviewGroupViewModel"; import { getParentEventId } from "../../../utils/Reply"; import Modal from "../../../Modal"; import SettingsStore from "../../../settings/SettingsStore"; @@ -32,6 +31,8 @@ import PosthogTrackers from "../../../PosthogTrackers"; import ImageView from "../elements/ImageView"; import EditMessageComposer from "../rooms/EditMessageComposer"; import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; +import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPreviewGroupViewModel"; +import PlatformPeg from "../../../PlatformPeg"; const logger = rootLogger.getChild("TextualBodyFactory"); @@ -119,6 +120,7 @@ export function TextualBodyFactory(props: Readonly): JSX.Element { ); }, visible: props.showUrlPreview ?? false, + showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true, }), ); @@ -182,10 +184,16 @@ export function TextualBodyFactory(props: Readonly): JSX.Element { ]); useEffect(() => { - void urlPreviewVm.updateHidden(props.showUrlPreview ?? false, mediaVisible).catch((error) => { - logger.warn("UrlPreviewViewModel failed to updateHidden", error); + void urlPreviewVm.updateUrlPreviewVisible(props.showUrlPreview ?? false).catch((error) => { + logger.warn("UrlPreviewViewModel failed to updateUrlPreviewVisible", error); }); - }, [props.showUrlPreview, mediaVisible, urlPreviewVm]); + }, [props.showUrlPreview, urlPreviewVm]); + + useEffect(() => { + void urlPreviewVm.updateMediaVisible(mediaVisible).catch((error) => { + logger.warn("UrlPreviewViewModel failed to updateMediaVisible", error); + }); + }, [mediaVisible, urlPreviewVm]); useEffect(() => { if (previews.length === 0) { diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 6329326efe..6c26d128a3 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -54,6 +54,8 @@ 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 { Type } from "../../../editor/parts"; // The prefix used when persisting editor drafts to localstorage. export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_"; @@ -101,6 +103,8 @@ interface IState { isWysiwygLabEnabled: boolean; isRichTextEnabled: boolean; initialComposerContent: string; + // Specifically for generating previews only. + urlPreviewComposerContent: string; } type WysiwygComposerState = { @@ -142,6 +146,7 @@ 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, @@ -418,6 +423,11 @@ export class MessageComposer extends React.Component { private onChange = (model: EditorModel): void => { this.setState({ + urlPreviewComposerContent: model + .serializeParts() + .filter((part) => part.type === Type.Plain) + .map((part) => part.text) + .join(" "), isComposerEmpty: model.isEmpty, }); }; @@ -425,6 +435,7 @@ export class MessageComposer extends React.Component { private onWysiwygChange = (content: string): void => { this.setState({ composerContent: content, + urlPreviewComposerContent: content, isComposerEmpty: content?.length === 0, }); }; @@ -674,6 +685,7 @@ export class MessageComposer extends React.Component { return (
+ + new MessageComposerUrlPreviewViewModel({ + client: MatrixClientPeg.safeGet(), + visible: showUrlPreview, + showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true, + }), + ); + + useDebouncedCallback<[MessageComposerUrlPreviewViewModel, string]>( + true, + (vm, content) => { + void vm.updateWithText(content); + }, + [vm, content], + DEBOUNCE_REQUEST_TIMEOUT_MS, + ); + + useEffect(() => { + void vm.updateUrlPreviewVisible(showUrlPreview); + }, [vm, showUrlPreview]); + + return ; +} diff --git a/apps/web/src/hooks/spotlight/useDebouncedCallback.ts b/apps/web/src/hooks/spotlight/useDebouncedCallback.ts index 26edb3c68c..8972dca1df 100644 --- a/apps/web/src/hooks/spotlight/useDebouncedCallback.ts +++ b/apps/web/src/hooks/spotlight/useDebouncedCallback.ts @@ -8,26 +8,27 @@ Please see LICENSE files in the repository root for full details. import { useEffect } from "react"; -const DEBOUNCE_TIMEOUT = 100; +const DEFAULT_DEBOUNCE_TIMEOUT = 100; export function useDebouncedCallback( enabled: boolean, callback: (...params: T) => unknown, params: T, + timeout = DEFAULT_DEBOUNCE_TIMEOUT, ): void { useEffect(() => { - let handle: number | null = null; + let handle: ReturnType | null = null; const doSearch = (): void => { handle = null; callback(...params); }; if (enabled !== false) { - handle = window.setTimeout(doSearch, DEBOUNCE_TIMEOUT); + handle = globalThis.setTimeout(doSearch, timeout); return () => { if (handle) { clearTimeout(handle); } }; } - }, [enabled, callback, params]); + }, [enabled, callback, params, timeout]); } diff --git a/apps/web/src/utils/UrlPreviewFetcher.test.ts b/apps/web/src/utils/UrlPreviewFetcher.test.ts new file mode 100644 index 0000000000..c4f0ebb59b --- /dev/null +++ b/apps/web/src/utils/UrlPreviewFetcher.test.ts @@ -0,0 +1,223 @@ +/* + * 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 { vi, describe, it, expect, beforeAll, afterAll, type Mock } from "vitest"; + +import type { IPreviewUrlResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { UrlPreviewFetcher } from "./UrlPreviewFetcher"; + +const IMAGE_MXC = "mxc://example.org/abc"; +const BASIC_PREVIEW_OGDATA = { + "og:title": "This is an example!", + "og:description": "This is a description", + "og:type": "document", + "og:url": "https://example.org", + "og:site_name": "Example.org", +}; + +function getFetcher(): { + fetcher: UrlPreviewFetcher; + client: { getUrlPreview: Mock; mxcUrlToHttp: Mock }; +} { + const client = { + getUrlPreview: vi.fn(), + mxcUrlToHttp: vi.fn(), + } as unknown as MatrixClient; + return { + fetcher: new UrlPreviewFetcher(client, 0, false), + client: client as unknown as { getUrlPreview: Mock; mxcUrlToHttp: Mock }, + }; +} + +describe("UrlPreviewFetcher", () => { + let originalDevicePixelRatio: Window["devicePixelRatio"]; + beforeAll(() => { + originalDevicePixelRatio = window.devicePixelRatio; + window.devicePixelRatio = 1; + }); + afterAll(() => { + window.devicePixelRatio = originalDevicePixelRatio; + }); + it("should return null when the fetch fails", async () => { + const { fetcher, client } = getFetcher(); + client.getUrlPreview.mockRejectedValue(new Error("Forced test failure")); + expect(await fetcher.fetchPreview("https://example.org", true)).toBeNull(); + }); + + it("should return null when title equals the URL and there is no image", async () => { + const { fetcher, client } = getFetcher(); + client.getUrlPreview.mockResolvedValueOnce({ + "og:title": "https://example.org", + "og:type": "document", + "og:url": "https://example.org", + }); + expect(await fetcher.fetchPreview("https://example.org", true)).toBeNull(); + }); + + it("should cache results and not re-fetch for the same URL", async () => { + const { fetcher, client } = getFetcher(); + client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA); + await fetcher.fetchPreview("https://example.org", true); + await fetcher.fetchPreview("https://example.org", true); + expect(client.getUrlPreview).toHaveBeenCalledTimes(1); + }); + + it("should re-fetch after clearCache is called", async () => { + const { fetcher, client } = getFetcher(); + client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA); + await fetcher.fetchPreview("https://example.org", true); + fetcher.clearCache(); + await fetcher.fetchPreview("https://example.org", true); + expect(client.getUrlPreview).toHaveBeenCalledTimes(2); + }); + + it("should not process media when loadMedia is false", async () => { + const { fetcher, client } = getFetcher(); + client.getUrlPreview.mockResolvedValueOnce({ + "og:title": "This is an 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, + }); + const preview = await fetcher.fetchPreview("https://example.org", false); + expect(preview?.image).toBeUndefined(); + // eslint-disable-next-line no-restricted-properties + expect(client.mxcUrlToHttp).not.toHaveBeenCalled(); + }); + + it("should preview a URL with media", async () => { + const { fetcher, client } = getFetcher(); + client.getUrlPreview.mockResolvedValueOnce({ + "og:title": "This is an 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); + if (width) return "https://example.org/image/thumb"; + return "https://example.org/image/src"; + }); + const preview = await fetcher.fetchPreview("https://example.org", true); + expect(preview).toMatchSnapshot(); + }); + + it.each>([ + { "matrix:image:size": 8191 }, + { "og:image:width": 95 }, + { "og:image:height": 95 }, + ])("should use a site icon for small images %s", async (extraResp) => { + const { fetcher, client } = getFetcher(); + client.getUrlPreview.mockResolvedValueOnce({ + "og:title": "This is an example!", + "og:type": "document", + "og:url": "https://example.org", + "og:image": IMAGE_MXC, + "og:image:height": 128, + "og:image:width": 128, + "matrix:image:size": 8193, + ...extraResp, + }); + // eslint-disable-next-line no-restricted-properties + client.mxcUrlToHttp.mockImplementation((url) => { + expect(url).toEqual(IMAGE_MXC); + return "https://example.org/image/src"; + }); + const preview = await fetcher.fetchPreview("https://example.org", true); + expect(preview?.siteIcon).toBeTruthy(); + expect(preview?.image).toBeUndefined(); + }); + + it.each(["og:video", "og:video:type", "og:audio"])("detects playable links via %s", async (property) => { + const { fetcher, client } = getFetcher(); + // eslint-disable-next-line no-restricted-properties + client.mxcUrlToHttp.mockImplementation((url, width) => { + if (width) return "https://example.org/image/thumb"; + return "https://example.org/image/src"; + }); + client.getUrlPreview.mockResolvedValueOnce({ + ...BASIC_PREVIEW_OGDATA, + "og:image": IMAGE_MXC, + "og:image:height": 128, + "og:image:width": 128, + "matrix:image:size": 10000, + [property]: "anything", + }); + const preview = await fetcher.fetchPreview("https://example.org", true); + expect(preview?.image?.playable).toBe(true); + }); + + describe("calculates author", () => { + it("should use the profile:username if provided", async () => { + const { fetcher, client } = getFetcher(); + client.getUrlPreview.mockResolvedValueOnce({ ...BASIC_PREVIEW_OGDATA, "profile:username": "my username" }); + const preview = await fetcher.fetchPreview("https://example.org", true); + expect(preview?.author).toEqual("my username"); + }); + + it("should use author if the og:type is an article", async () => { + const { fetcher, client } = getFetcher(); + client.getUrlPreview.mockResolvedValueOnce({ + ...BASIC_PREVIEW_OGDATA, + "og:type": "article", + "article:author": "my name", + }); + const preview = await fetcher.fetchPreview("https://example.org", true); + expect(preview?.author).toEqual("my name"); + }); + + it("should NOT use author if the author is a URL", async () => { + const { fetcher, client } = getFetcher(); + client.getUrlPreview.mockResolvedValueOnce({ + ...BASIC_PREVIEW_OGDATA, + "og:type": "article", + "article:author": "https://junk.example.org/foo", + }); + const preview = await fetcher.fetchPreview("https://example.org", true); + expect(preview?.author).toBeUndefined(); + }); + }); + + // og:url and og:type are not surfaced in the preview. + const baseOg = { + "og:url": "https://example.org", + "og:type": "document", + }; + + it.each([ + { ...baseOg, "og:title": "Basic title" }, + { ...baseOg, "og:site_name": "Site name", "og:title": "" }, + { ...baseOg, "og:description": "A description", "og:title": "" }, + { ...baseOg, "og:title": "Cool blog", "og:site_name": "Cool site" }, + { + ...baseOg, + "og:title": "Media test", + // API *may* return a string, so check we parse correctly. + "og:image:height": "500" as unknown as number, + "og:image:width": 500, + "matrix:image:size": 10000, + "og:image": IMAGE_MXC, + }, + ])("handles different kinds of opengraph responses %s", async (og) => { + const { fetcher, client } = getFetcher(); + // eslint-disable-next-line no-restricted-properties + client.mxcUrlToHttp.mockImplementation((url, width) => { + if (width) return "https://example.org/image/thumb"; + return "https://example.org/image/src"; + }); + client.getUrlPreview.mockResolvedValueOnce(og); + const preview = await fetcher.fetchPreview("https://example.org", true); + expect(preview).toMatchSnapshot(); + }); +}); diff --git a/apps/web/src/utils/UrlPreviewFetcher.ts b/apps/web/src/utils/UrlPreviewFetcher.ts new file mode 100644 index 0000000000..3b6a4a2181 --- /dev/null +++ b/apps/web/src/utils/UrlPreviewFetcher.ts @@ -0,0 +1,204 @@ +/* + * 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 { logger as rootLogger } from "matrix-js-sdk/src/logger"; +import { type IPreviewUrlResponse, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { decode } from "html-entities"; + +import type { UrlPreview } from "@element-hq/web-shared-components"; +import { mediaFromMxc } from "../customisations/Media"; +import { thumbHeight } from "../ImageUtils"; + +const logger = rootLogger.getChild("UrlPreviewFetcher"); + +export const PREVIEW_WIDTH_PX = 478; +export const PREVIEW_HEIGHT_PX = 200; +export const MIN_PREVIEW_PX = 96; +export const MIN_IMAGE_SIZE_BYTES = 8192; + +/** + * Handles fetching and parsing URL previews. + * Maintains a cache of previously fetched previews; call `clearCache` when + * media visibility changes so images are re-fetched with the correct visibility. + */ +export class UrlPreviewFetcher { + private readonly cache = new Map(); + + public constructor( + private readonly client: MatrixClient, + private readonly previewRequestTs: number, + private readonly showTooltips: boolean, + ) {} + + public clearCache(): void { + this.cache.clear(); + } + + /** + * Parse a numeric value from OpenGraph. The OpenGraph spec defines all values as strings + * although Synapse may return these values as numbers. To be compatible, test strings + * and numbers. + * @param value The numeric value + * @returns A number if the value parsed correctly, or undefined otherwise. + */ + private static getNumberFromOpenGraph(value: number | string | undefined): number | undefined { + if (typeof value === "number") { + return value; + } else if (typeof value === "string" && value) { + const i = Number.parseInt(value, 10); + if (!Number.isNaN(i)) return i; + } + return undefined; + } + + /** + * Calculate the best possible title from an opengraph response. + * @param response The opengraph response + * @param link The link being used to preview. + * @returns The title value. + */ + private static getBaseMetadataFromResponse( + response: IPreviewUrlResponse, + link: string, + ): Pick { + let title = + typeof response["og:title"] === "string" && response["og:title"].trim() + ? response["og:title"].trim() + : undefined; + let description = + typeof response["og:description"] === "string" && response["og:description"].trim() + ? response["og:description"].trim() + : undefined; + const siteName = + typeof response["og:site_name"] === "string" && response["og:site_name"].trim() + ? response["og:site_name"].trim() + : new URL(link).hostname; + + if (!title && description) { + title = description; + description = undefined; + } else if (!title && siteName) { + title = siteName; + } else if (!title) { + title = link; + } + + if (description && description.toLowerCase() === siteName.toLowerCase()) { + description = undefined; + } + + return { title, description: description && decode(description), siteName }; + } + + /** + * Calculate the best possible author from an opengraph response. + * @param response The opengraph response + * @returns The author value, or undefined if no valid author could be found. + */ + private static getAuthorFromResponse(response: IPreviewUrlResponse): UrlPreview["author"] { + let calculatedAuthor: string | undefined; + if (response["og:type"] === "article") { + if (typeof response["article:author"] === "string" && response["article:author"]) { + calculatedAuthor = response["article:author"]; + } + } + if (typeof response["profile:username"] === "string" && response["profile:username"]) { + calculatedAuthor = response["profile:username"]; + } + if (calculatedAuthor && URL.canParse(calculatedAuthor)) { + return undefined; + } + return calculatedAuthor; + } + + /** + * Calculate whether the provided image from the preview response is an full size preview or + * a site icon. + * @returns `true` if the image should be used as a preview, otherwise `false` + */ + private static isImagePreview(width?: number, height?: number, bytes?: number): boolean { + if (width && width < MIN_PREVIEW_PX) return false; + if (height && height < MIN_PREVIEW_PX) return false; + if (bytes && bytes < MIN_IMAGE_SIZE_BYTES) return false; + return true; + } + + /** + * Fetch a preview for a single URL, returning a cached result if available. + * @param link The URL to preview. + * @param loadMedia Whether to include the preview image. Pass false when media is hidden. + */ + public async fetchPreview(link: string, loadMedia: boolean): Promise { + const cached = this.cache.get(link); + if (cached) return cached; + + let response: IPreviewUrlResponse; + try { + response = await this.client.getUrlPreview(link, this.previewRequestTs); + } catch (error) { + if (error instanceof MatrixError && error.httpStatus === 404) { + logger.debug("Failed to get URL preview: ", error); + } else { + logger.error("Failed to get URL preview: ", error); + } + return null; + } + + const { title, description, siteName } = UrlPreviewFetcher.getBaseMetadataFromResponse(response, link); + const author = UrlPreviewFetcher.getAuthorFromResponse(response); + const hasImage = response["og:image"] && typeof response["og:image"] === "string"; + + if (title === link && !hasImage) { + return null; + } + + let image: UrlPreview["image"]; + let siteIcon: string | undefined; + + if (typeof response["og:image"] === "string" && loadMedia) { + 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; + + if (UrlPreviewFetcher.isImagePreview(declaredWidth, declaredHeight, imageSize)) { + const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX); + const height = + thumbHeight(width, declaredHeight, PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX) ?? PREVIEW_WIDTH_PX; + const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH_PX, PREVIEW_HEIGHT_PX, "scale"); + const playable = !!response["og:video"] || !!response["og:video:type"] || !!response["og:audio"]; + if (thumb) { + image = { + imageThumb: thumb, + imageFull: media.srcHttp ?? thumb, + width, + height, + fileSize: UrlPreviewFetcher.getNumberFromOpenGraph(response["matrix:image:size"]), + alt, + playable, + }; + } + } else if (media.srcHttp) { + siteIcon = media.srcHttp; + } + } + + const result = { + link, + title, + author, + description, + siteName, + siteIcon, + showTooltipOnLink: !!(link !== title && this.showTooltips), + image, + } satisfies UrlPreview; + this.cache.set(link, result); + return result; + } +} diff --git a/apps/web/src/utils/__snapshots__/UrlPreviewFetcher.test.ts.snap b/apps/web/src/utils/__snapshots__/UrlPreviewFetcher.test.ts.snap new file mode 100644 index 0000000000..c164f7642f --- /dev/null +++ b/apps/web/src/utils/__snapshots__/UrlPreviewFetcher.test.ts.snap @@ -0,0 +1,95 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'og:url': 'https://example.org', 'og:type': 'document', 'og:description': 'A description', 'og:title': '' } 1`] = ` +{ + "author": undefined, + "description": undefined, + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "example.org", + "title": "A description", +} +`; + +exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'og:url': 'https://example.org', 'og:type': 'document', 'og:site_name': 'Site name', 'og:title': '' } 1`] = ` +{ + "author": undefined, + "description": undefined, + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "Site name", + "title": "Site name", +} +`; + +exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'og:url': 'https://example.org', 'og:type': 'document', 'og:title': 'Basic title' } 1`] = ` +{ + "author": undefined, + "description": undefined, + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "example.org", + "title": "Basic title", +} +`; + +exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'og:url': 'https://example.org', 'og:type': 'document', 'og:title': 'Cool blog', 'og:site_name': 'Cool site' } 1`] = ` +{ + "author": undefined, + "description": undefined, + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "Cool site", + "title": "Cool blog", +} +`; + +exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'og:url': 'https://example.org', 'og:type': 'document', 'og:title': 'Media test', 'og:image:height': '500', 'og:image:width': 500, 'matrix:image:size': 10000, 'og:image': 'mxc://example.org/abc' } 1`] = ` +{ + "author": undefined, + "description": undefined, + "image": { + "alt": undefined, + "fileSize": 10000, + "height": 478, + "imageFull": "https://example.org/image/src", + "imageThumb": "https://example.org/image/thumb", + "playable": false, + "width": 478, + }, + "link": "https://example.org", + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "example.org", + "title": "Media test", +} +`; + +exports[`UrlPreviewFetcher > should preview a URL with media 1`] = ` +{ + "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": "This is an example!", +} +`; diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts new file mode 100644 index 0000000000..693ccf47d5 --- /dev/null +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { vi, describe, it, expect, type Mock, beforeAll, afterAll } from "vitest"; + +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { MessageComposerUrlPreviewViewModel } from "./MessageComposerUrlPreviewViewModel"; + +const IMAGE_MXC = "mxc://example.org/abc"; +const BASIC_PREVIEW_OGDATA = { + "og:title": "This is an example!", + "og:description": "This is a description", + "og:type": "document", + "og:url": "https://example.org", + "og:site_name": "Example.org", +}; + +function getViewModel({ visible } = { visible: true }): { + vm: MessageComposerUrlPreviewViewModel; + client: { getUrlPreview: Mock; mxcUrlToHttp: Mock }; +} { + const client = { + getUrlPreview: vi.fn(), + mxcUrlToHttp: vi.fn(), + } as unknown as MatrixClient; + 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(() => { + originalDevicePixelRatio = window.devicePixelRatio; + window.devicePixelRatio = 1; + }); + afterAll(() => { + window.devicePixelRatio = originalDevicePixelRatio; + }); + + it("should return no preview by default", () => { + expect(getViewModel().vm.getSnapshot()).toMatchInlineSnapshot(` +{ + "preview": null, +} +`); + }); + + 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"); + expect(vm.getSnapshot()).toMatchSnapshot(); + }); + + it("should return null when preview is not visible", async () => { + const { vm, client } = getViewModel({ visible: false }); + await vm.updateWithText("https://example.org"); + expect(vm.getSnapshot().preview).toBeNull(); + expect(client.getUrlPreview).not.toHaveBeenCalled(); + }); + + it("should return null 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(); + }); + + it("should use the first URL 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"); + }); + + 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"); + 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"); + 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.updateUrlPreviewVisible(false); + expect(vm.getSnapshot().preview).toBeNull(); + }); + + 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.updateUrlPreviewVisible(true); + expect(vm.getSnapshot().preview).not.toBeNull(); + }); + + 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, + }); + // eslint-disable-next-line no-restricted-properties + client.mxcUrlToHttp.mockImplementation((url, width) => { + expect(url).toEqual(IMAGE_MXC); + if (width) return "https://example.org/image/thumb"; + return "https://example.org/image/src"; + }); + await vm.updateWithText("https://example.org"); + expect(vm.getSnapshot()).toMatchSnapshot(); + }); +}); diff --git a/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts new file mode 100644 index 0000000000..9329c46fa7 --- /dev/null +++ b/apps/web/src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts @@ -0,0 +1,95 @@ +/* + * 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 { 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 { UrlPreviewFetcher } from "../../utils/UrlPreviewFetcher"; + +const logger = rootLogger.getChild("MessageComposerUrlPreviewViewModel"); + +export interface MessageComposerUrlPreviewViewModelProps { + client: MatrixClient; + visible: boolean; + showTooltips: boolean; +} + +export class MessageComposerUrlPreviewViewModel extends BaseViewModel< + MessageComposerUrlPreviewSnapshot, + MessageComposerUrlPreviewViewModelProps +> { + private readonly fetcher: UrlPreviewFetcher; + + /** + * Calculated set of links from the message text. + */ + private links: Set = new Set(); + + /** + * Should the URL preview render according to the application. + */ + private urlPreviewVisible: boolean; + + public constructor(props: MessageComposerUrlPreviewViewModelProps) { + super(props, { preview: null }); + 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 }); + 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; + } + } catch (ex) { + logger.warn("Fetching preview failed", ex); + } + } + 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; + } + this.links = newLinks; + return this.computeSnapshot(); + } + + /** + * Update the view model about visible state of previews. + * @param urlPreviewVisible Whether URL previews are hidden for this room. + * + * @returns A promise that completes when the snapshot has been recomputed. + */ + public readonly updateUrlPreviewVisible = (urlPreviewVisible: boolean): Promise => { + this.urlPreviewVisible = urlPreviewVisible; + this.fetcher.clearCache(); + return this.computeSnapshot(); + }; +} diff --git a/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap b/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap new file mode 100644 index 0000000000..7a41ca8b03 --- /dev/null +++ b/apps/web/src/viewmodels/composer/__snapshots__/MessageComposerUrlPreviewViewModel.test.ts.snap @@ -0,0 +1,39 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +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, + }, + "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!", + }, +} +`; diff --git a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts index 3ff1c40d3b..5b421d6455 100644 --- a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts +++ b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts @@ -5,175 +5,48 @@ * Please see LICENSE files in the repository root for full details. */ +import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { BaseViewModel, - type UrlPreviewGroupViewSnapshot, - type UrlPreviewGroupViewActions, type UrlPreview, + type UrlPreviewGroupViewActions, + type UrlPreviewGroupViewSnapshot, } from "@element-hq/web-shared-components"; -import { logger as rootLogger } from "matrix-js-sdk/src/logger"; -import { type IPreviewUrlResponse, type MatrixClient, MatrixError, type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { decode } from "html-entities"; import { type UrlPreviewVisibilityChanged } from "@matrix-org/analytics-events/types/typescript/UrlPreviewVisibilityChanged"; -import { isPermalinkHost } from "../../utils/permalinks/Permalinks"; -import { mediaFromMxc } from "../../customisations/Media"; -import PlatformPeg from "../../PlatformPeg"; -import { thumbHeight } from "../../ImageUtils"; import { PosthogAnalytics } from "../../PosthogAnalytics"; +import { isPermalinkHost } from "../../utils/permalinks/Permalinks"; +import { UrlPreviewFetcher } from "../../utils/UrlPreviewFetcher"; -const logger = rootLogger.getChild("UrlPreviewGroupViewModel"); +// From https://github.com/matrix-org/matrix-spec-proposals/pull/4095 +export const BUNDLED_LINK_PREVIEWS = "com.beeper.linkpreviews"; + +export const MAX_PREVIEWS_WHEN_LIMITED = 2; + +export enum PreviewVisibility { + /** Preview is entirely hidden and cannot be changed. */ + Hidden, + /** Preview is hidden by the user and may be shown again. */ + UserHidden, + /** Preview is visible but media should not be rendered. */ + MediaHidden, + /** Preview is fully visible including media. */ + Visible, +} export interface UrlPreviewGroupViewModelProps { client: MatrixClient; mxEvent: MatrixEvent; - mediaVisible: boolean; visible: boolean; + mediaVisible: boolean; + showTooltips: boolean; onImageClicked: (preview: UrlPreview) => void; } -export const MAX_PREVIEWS_WHEN_LIMITED = 2; -export const PREVIEW_WIDTH_PX = 478; -export const PREVIEW_HEIGHT_PX = 200; -export const MIN_PREVIEW_PX = 96; -export const MIN_IMAGE_SIZE_BYTES = 8192; -// From https://github.com/matrix-org/matrix-spec-proposals/pull/4095 -export const BUNDLED_LINK_PREVIEWS = "com.beeper.linkpreviews"; - -export enum PreviewVisibility { - /** - * Preview is entirely hidden from view and can not be changed. - */ - Hidden, - /** - * Preview is entirely hidden from view but the user may change this. - */ - UserHidden, - /** - * Preview is visible but media should not be rendered. - */ - MediaHidden, - /** - * Preview is visible and media should be rendered. - */ - Visible, -} - -/** - * ViewModel for fetching and rendering URL previews for an individual event. - */ export class UrlPreviewGroupViewModel extends BaseViewModel implements UrlPreviewGroupViewActions { - /** - * Parse a numeric value from OpenGraph. The OpenGraph spec defines all values as strings - * although Synapse may return these values as numbers. To be compatible, test strings - * and numbers. - * @param value The numeric value - * @returns A number if the value parsed correctly, or undefined otherwise. - */ - private static getNumberFromOpenGraph(value: number | string | undefined): number | undefined { - if (typeof value === "number") { - return value; - } else if (typeof value === "string" && value) { - const i = parseInt(value, 10); - if (!isNaN(i)) { - return i; - } - } - return undefined; - } - - /** - * Calculate the best possible title from an opengraph response. - * @param response The opengraph response - * @param link The link being used to preview. - * @returns The title value. - */ - private static getBaseMetadataFromResponse( - response: IPreviewUrlResponse, - link: string, - ): Pick { - let title = - typeof response["og:title"] === "string" && response["og:title"].trim() - ? response["og:title"].trim() - : undefined; - let description = - typeof response["og:description"] === "string" && response["og:description"].trim() - ? response["og:description"].trim() - : undefined; - const siteName = - typeof response["og:site_name"] === "string" && response["og:site_name"].trim() - ? response["og:site_name"].trim() - : new URL(link).hostname; - - // If there is no title, use the description as the title. - if (!title && description) { - title = description; - description = undefined; - } else if (!title && siteName) { - title = siteName; - } else if (!title) { - title = link; - } - - // If the description matches the site name, don't bother with a description. - if (description && description.toLowerCase() === siteName.toLowerCase()) { - description = undefined; - } - - return { - title, - description: description && decode(description), - siteName, - }; - } - - /** - * Calculate the best possible author from an opengraph response. - * @param response The opengraph response - * @returns The author value, or undefined if no valid author could be found. - */ - private static getAuthorFromResponse(response: IPreviewUrlResponse): UrlPreview["author"] { - let calculatedAuthor: string | undefined; - if (response["og:type"] === "article") { - if (typeof response["article:author"] === "string" && response["article:author"]) { - calculatedAuthor = response["article:author"]; - } - // Otherwise fall through to check the profile. - } - if (typeof response["profile:username"] === "string" && response["profile:username"]) { - calculatedAuthor = response["profile:username"]; - } - if (calculatedAuthor && URL.canParse(calculatedAuthor)) { - // Some sites return URLs as authors which doesn't look good in Element, so discard it. - return; - } - return calculatedAuthor; - } - - /** - * Calculate whether the provided image from the preview response is an full size preview or - * a site icon. - * @returns `true` if the image should be used as a preview, otherwise `false` - */ - private static isImagePreview(width?: number, height?: number, bytes?: number): boolean { - // We can't currently distinguish from a preview image and a favicon. Neither OpenGraph nor Matrix - // have a clear distinction, so we're using a heuristic here to check the dimensions & size of the file and - // deciding whether to render it as a full preview or icon. - if (width && width < MIN_PREVIEW_PX) { - return false; - } - if (height && height < MIN_PREVIEW_PX) { - return false; - } - if (bytes && bytes < MIN_IMAGE_SIZE_BYTES) { - return false; - } - return true; - } - /** * Determine if an anchor element can be rendered into a preview. * If it can, return the value of `href` @@ -181,36 +54,15 @@ export class UrlPreviewGroupViewModel * @returns The value of the `href` of the node, or null if this node cannot be previewed. */ private static getAnchorLink(node: HTMLAnchorElement): string | null { - // don't try to preview relative links const href = node.getAttribute("href"); - if (!href || !URL.canParse(href)) { - return null; - } + if (!href || !URL.canParse(href)) return null; const url = new URL(href); - if (!["http:", "https:"].includes(url.protocol)) { - return null; - } - // never preview permalinks (if anything we should give a smart - // preview of the room/user they point to: nobody needs to be reminded - // what the matrix.to site looks like). - if (isPermalinkHost(url.host)) { - return null; - } + if (!["http:", "https:"].includes(url.protocol)) return null; + if (isPermalinkHost(url.host)) return null; - // as a random heuristic to avoid highlighting things like "foo.pl" - // we require the linked text to either include a / (either from http:// - // or from a full foo.bar/baz style schemeless URL) - or be a markdown-style - // link, in which case we check the target text differs from the link value. - if (node.textContent?.includes("/")) { - return href; - } - - if (node.textContent?.toLowerCase().trim().startsWith(url.host.toLowerCase())) { - // it's a "foo.pl" style link - return null; - } - // it's a [foo bar](http://foo.com) style link + if (node.textContent?.includes("/")) return href; + if (node.textContent?.toLowerCase().trim().startsWith(url.host.toLowerCase())) return null; return href; } @@ -221,43 +73,26 @@ export class UrlPreviewGroupViewModel */ private static findLinks(nodes: Iterable): string[] { let links = new Set(); - for (const node of nodes) { if (node.tagName === "A") { const href = this.getAnchorLink(node as HTMLAnchorElement); - if (href) { - links.add(href); - } + if (href) links.add(href); } else if (node.tagName === "PRE" || node.tagName === "CODE" || node.tagName === "BLOCKQUOTE") { continue; - } else if (node.children && node.children.length) { + } else if (node.children?.length) { links = new Set([...links, ...this.findLinks(node.children)]); } } return [...links]; } - private readonly client: MatrixClient; private readonly storageKey: string; - private readonly eventSendTime: number; - - /** - * Should the URL preview render according to the application. - */ - private urlPreviewVisible: boolean; - /** - * Should media be rendered in the preview. - */ - private mediaVisible: boolean; - /** - * Has the user opted to render this individual preview, or hide it. - */ - private urlPreviewEnabledByUser: boolean; + private readonly fetcher: UrlPreviewFetcher; /** * Calculated set of links from the provided DOM element. */ - private links: Array = []; + private links: string[] = []; /** * Should the preview limit how many links are rendered. If `false`, all @@ -266,9 +101,19 @@ export class UrlPreviewGroupViewModel private limitPreviews = true; /** - * A cache containing all previously calculated previews. + * Should the URL preview render according to the application. */ - private readonly previewCache = new Map(); + private urlPreviewVisible: boolean; + + /** + * Should media be rendered in the preview. + */ + private mediaVisible: boolean; + + /** + * Has the user opted to render this individual preview, or hide it. + */ + private urlPreviewEnabledByUser: boolean; /** * Called when the user clicks on the preview thumbnail. @@ -276,111 +121,31 @@ export class UrlPreviewGroupViewModel public readonly onImageClick: (preview: UrlPreview) => void; public constructor(props: UrlPreviewGroupViewModelProps) { - const storageKey = `hide_preview_${props.mxEvent.getId()}`; super(props, { previews: [], totalPreviewCount: 0, previewsLimited: true, overPreviewLimit: false, }); - this.urlPreviewEnabledByUser = globalThis.localStorage.getItem(storageKey) !== "1"; + this.onImageClick = props.onImageClicked; + this.storageKey = `hide_preview_${props.mxEvent.getId()}`; this.urlPreviewVisible = props.visible; this.mediaVisible = props.mediaVisible; - this.storageKey = storageKey; - this.client = props.client; - this.eventSendTime = props.mxEvent.getTs(); - this.onImageClick = props.onImageClicked; + this.urlPreviewEnabledByUser = globalThis.localStorage.getItem(this.storageKey) !== "1"; + this.fetcher = new UrlPreviewFetcher(props.client, props.mxEvent.getTs(), props.showTooltips); } /** - * Fetch a complete preview of a given URL. - * Will always return a cached response if it was previously calculated. - * @param link A URL to be previewed. - * @returns A Promise that returns the snapshot needed to render the preview, or null - * if the resource could not be previewed. + * `true` only when the user has chosen to hide previews. */ - private async fetchPreview(link: string): Promise { - const cached = this.previewCache.get(link); - if (cached) { - return cached; - } - let preview: IPreviewUrlResponse; - - try { - preview = await this.client.getUrlPreview(link, this.eventSendTime); - } catch (error) { - if (error instanceof MatrixError && error.httpStatus === 404) { - // Quieten 404 Not found errors, not all URLs can have a preview generated - logger.debug("Failed to get URL preview: ", error); - } else { - logger.error("Failed to get URL preview: ", error); - } - return null; - } - - const { title, description, siteName } = UrlPreviewGroupViewModel.getBaseMetadataFromResponse(preview, link); - const author = UrlPreviewGroupViewModel.getAuthorFromResponse(preview); - const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string"; - // Ensure we have something relevant to render. - // The title must not just be the link, or we must have an image. - if (title === link && !hasImage) { - return null; - } - let image: UrlPreview["image"]; - let siteIcon: string | undefined; - if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) { - const media = mediaFromMxc(preview["og:image"], this.client); - const declaredHeight = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:height"]); - const declaredWidth = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:width"]); - const imageSize = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]); - const alt = typeof preview["og:image:alt"] === "string" ? preview["og:image:alt"] : undefined; - - const isImagePreview = UrlPreviewGroupViewModel.isImagePreview(declaredWidth, declaredHeight, imageSize); - if (isImagePreview) { - const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX); - const height = - thumbHeight(width, declaredHeight, PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX) ?? PREVIEW_WIDTH_PX; - const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH_PX, PREVIEW_HEIGHT_PX, "scale"); - const playable = !!preview["og:video"] || !!preview["og:video:type"] || !!preview["og:audio"]; - // No thumb, no preview. - if (thumb) { - image = { - imageThumb: thumb, - imageFull: media.srcHttp ?? thumb, - width, - height, - fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]), - alt, - playable, - }; - } - } else if (media.srcHttp) { - siteIcon = media.srcHttp; - } - } - - const result = { - link, - title, - author, - description, - siteName, - siteIcon, - showTooltipOnLink: !!(link !== title && PlatformPeg.get()?.needsUrlTooltips()), - image, - } satisfies UrlPreview; - this.previewCache.set(link, result); - return result; + public get isPreviewHiddenByUser(): boolean { + return this.visibility === PreviewVisibility.UserHidden; } private get visibility(): PreviewVisibility { - if (!this.urlPreviewVisible) { - return PreviewVisibility.Hidden; - } else if (!this.urlPreviewEnabledByUser) { - return PreviewVisibility.UserHidden; - } else if (!this.mediaVisible) { - return PreviewVisibility.MediaHidden; - } + if (!this.urlPreviewVisible) return PreviewVisibility.Hidden; + if (!this.urlPreviewEnabledByUser) return PreviewVisibility.UserHidden; + if (!this.mediaVisible) return PreviewVisibility.MediaHidden; return PreviewVisibility.Visible; } @@ -389,28 +154,29 @@ export class UrlPreviewGroupViewModel * for the previously-calculated links. */ private async computeSnapshot(): Promise { - // This uses MSC4095. If the sender has sent us an empty URL previews bundle - // then they do not want to have URL previews be visible. + // MSC4095: an empty bundled previews array means the sender opted out of previews. const bundledLinkPreviews = this.props.mxEvent.getContent()[BUNDLED_LINK_PREVIEWS]; if (Array.isArray(bundledLinkPreviews) && bundledLinkPreviews.length === 0) { - return this.snapshot.merge({ + this.snapshot.merge({ previews: [], totalPreviewCount: 0, previewsLimited: false, overPreviewLimit: false, }); - } // otherwise, we do not support bundled previews yet so will fallback to old behaviour. + return; + } + const loadMedia = this.visibility === PreviewVisibility.Visible; const previews = this.visibility <= PreviewVisibility.UserHidden ? [] : await Promise.all( this.links .slice(0, this.limitPreviews ? MAX_PREVIEWS_WHEN_LIMITED : undefined) - .map((link) => this.fetchPreview(link)), + .map((link) => this.fetcher.fetchPreview(link, loadMedia)), ); this.snapshot.merge({ - previews: previews.filter((m) => !!m), + previews: previews.filter((p) => !!p), totalPreviewCount: this.links.length, previewsLimited: this.limitPreviews, overPreviewLimit: this.links.length > MAX_PREVIEWS_WHEN_LIMITED, @@ -423,7 +189,6 @@ export class UrlPreviewGroupViewModel */ public async updateEventElement(eventElement: HTMLDivElement | HTMLSpanElement): Promise { const newLinks = UrlPreviewGroupViewModel.findLinks([eventElement]); - // Only recalculate if the set of links has changed. if (newLinks.some((x) => !this.links.includes(x)) || this.links.some((x) => !newLinks.includes(x))) { this.links = newLinks; return this.computeSnapshot(); @@ -431,19 +196,26 @@ export class UrlPreviewGroupViewModel } /** - * Update the view model about the status of whether the event should be - * viewable. + * Update the view model about visible state of previews. * @param urlPreviewVisible Whether URL previews are hidden for this room. - * @param mediaVisible Whether media is hidden for this room or event. * * @returns A promise that completes when the snapshot has been recomputed. */ - public readonly updateHidden = (urlPreviewVisible: boolean, mediaVisible: boolean): Promise => { + public readonly updateUrlPreviewVisible = (urlPreviewVisible: boolean): Promise => { this.urlPreviewVisible = urlPreviewVisible; + this.fetcher.clearCache(); + return this.computeSnapshot(); + }; + + /** + * Update the view model about visible state of media. + * @param urlPreviewVisible Whether media is hidden for this room or event. + * + * @returns A promise that completes when the snapshot has been recomputed. + */ + public readonly updateMediaVisible = (mediaVisible: boolean): Promise => { this.mediaVisible = mediaVisible; - // Changing the visibility here means we need to clear cache as we may need to load - // the media again. - this.previewCache.clear(); + this.fetcher.clearCache(); return this.computeSnapshot(); }; @@ -489,11 +261,4 @@ export class UrlPreviewGroupViewModel this.limitPreviews = !this.limitPreviews; return this.computeSnapshot(); }; - - /** - * `true` only when the user has chosen to hide previews. - */ - public get isPreviewHiddenByUser(): boolean { - return this.visibility === PreviewVisibility.UserHidden; - } } diff --git a/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts b/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts index e3e85a29b3..c508410dc2 100644 --- a/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts +++ b/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts @@ -8,7 +8,7 @@ import { expect } from "@jest/globals"; import type { MockedObject } from "jest-mock-vitest-adapter"; -import type { MatrixClient, IPreviewUrlResponse } from "matrix-js-sdk/src/matrix"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import { BUNDLED_LINK_PREVIEWS, UrlPreviewGroupViewModel, @@ -42,6 +42,7 @@ function getViewModel( mediaVisible, visible, onImageClicked, + showTooltips: false, mxEvent: mkEvent({ event: true, user: "@foo:bar", @@ -89,15 +90,9 @@ describe("UrlPreviewGroupViewModel", () => { const { previews } = vm.getSnapshot(); expect(previews).toHaveLength(3); expect(previews).toMatchObject([ - { - link: "https://example.org/1", - }, - { - link: "https://example.org/2", - }, - { - link: "https://example.org/3", - }, + { link: "https://example.org/1" }, + { link: "https://example.org/2" }, + { link: "https://example.org/3" }, ]); }); it("should hide preview when invisible", async () => { @@ -108,56 +103,6 @@ describe("UrlPreviewGroupViewModel", () => { expect(vm.getSnapshot()).toMatchSnapshot(); expect(client.getUrlPreview).not.toHaveBeenCalled(); }); - it("should preview a URL with media", async () => { - const { vm, client } = getViewModel(); - client.getUrlPreview.mockResolvedValueOnce({ - "og:title": "This is an 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); - if (width) { - return "https://example.org/image/thumb"; - } - return "https://example.org/image/src"; - }); - const msg = document.createElement("div"); - msg.innerHTML = 'Test'; - await vm.updateEventElement(msg); - expect(vm.getSnapshot()).toMatchSnapshot(); - }); - it.each>([ - { "matrix:image:size": 8191 }, - { "og:image:width": 95 }, - { "og:image:height": 95 }, - ])("should preview a URL with a site icon", async (extraResp) => { - const { vm, client } = getViewModel(); - client.getUrlPreview.mockResolvedValueOnce({ - "og:title": "This is an example!", - "og:type": "document", - "og:url": "https://example.org", - "og:image": IMAGE_MXC, - "og:image:height": 128, - "og:image:width": 128, - "matrix:image:size": 8193, - ...extraResp, - }); - // eslint-disable-next-line no-restricted-properties - client.mxcUrlToHttp.mockImplementation((url) => { - expect(url).toEqual(IMAGE_MXC); - return "https://example.org/image/src"; - }); - const msg = document.createElement("div"); - msg.innerHTML = 'Test'; - await vm.updateEventElement(msg); - expect(vm.getSnapshot().previews[0].siteIcon).toBeTruthy(); - }); it("should ignore media when mediaVisible is false", async () => { const { vm, client } = getViewModel({ mediaVisible: false, visible: true, showPreview: true }); client.getUrlPreview.mockResolvedValueOnce({ @@ -248,41 +193,6 @@ describe("UrlPreviewGroupViewModel", () => { `); }); - describe("calculates author", () => { - it("should use the profile:username if provided", async () => { - const { vm, client } = getViewModel(); - client.getUrlPreview.mockResolvedValueOnce({ ...BASIC_PREVIEW_OGDATA, "profile:username": "my username" }); - const msg = document.createElement("div"); - msg.innerHTML = 'Test'; - await vm.updateEventElement(msg); - expect(vm.getSnapshot().previews[0].author).toEqual("my username"); - }); - it("should use author if the og:type is an article", async () => { - const { vm, client } = getViewModel(); - client.getUrlPreview.mockResolvedValueOnce({ - ...BASIC_PREVIEW_OGDATA, - "og:type": "article", - "article:author": "my name", - }); - const msg = document.createElement("div"); - msg.innerHTML = 'Test'; - await vm.updateEventElement(msg); - expect(vm.getSnapshot().previews[0].author).toEqual("my name"); - }); - it("should NOT use author if the author is a URL", async () => { - const { vm, client } = getViewModel(); - client.getUrlPreview.mockResolvedValueOnce({ - ...BASIC_PREVIEW_OGDATA, - "og:type": "article", - "article:author": "https://junk.example.org/foo", - }); - const msg = document.createElement("div"); - msg.innerHTML = 'Test'; - await vm.updateEventElement(msg); - expect(vm.getSnapshot().previews[0].author).toBeUndefined(); - }); - }); - it.each([ { text: "", href: "", hasPreview: false }, { text: "test", href: "noprotocol.example.org", hasPreview: false }, @@ -297,62 +207,4 @@ describe("UrlPreviewGroupViewModel", () => { await vm.updateEventElement(msg); expect(vm.getSnapshot().previews).toHaveLength(item.hasPreview ? 1 : 0); }); - - // og:url, og:type are ignored. - const baseOg = { - "og:url": "https://example.org", - "og:type": "document", - }; - - it.each([ - { ...baseOg, "og:title": "Basic title" }, - { ...baseOg, "og:site_name": "Site name", "og:title": "" }, - { ...baseOg, "og:description": "A description", "og:title": "" }, - { ...baseOg, "og:title": "Cool blog", "og:site_name": "Cool site" }, - { - ...baseOg, - "og:title": "Media test", - // API *may* return a string, so check we parse correctly. - "og:image:height": "500" as unknown as number, - "og:image:width": 500, - "matrix:image:size": 10000, - "og:image": IMAGE_MXC, - }, - ])("handles different kinds of opengraph responses %s", async (og) => { - const { vm, client } = getViewModel(); - // eslint-disable-next-line no-restricted-properties - client.mxcUrlToHttp.mockImplementation((url, width) => { - expect(url).toEqual(IMAGE_MXC); - if (width) { - return "https://example.org/image/thumb"; - } - return "https://example.org/image/src"; - }); - client.getUrlPreview.mockResolvedValueOnce(og); - const msg = document.createElement("div"); - msg.innerHTML = `test`; - await vm.updateEventElement(msg); - expect(vm.getSnapshot().previews[0]).toMatchSnapshot(); - }); - - it.each(["og:video", "og:video:type", "og:audio"])("detects playable links via %s", async (property) => { - const { vm, client } = getViewModel(); - // eslint-disable-next-line no-restricted-properties - client.mxcUrlToHttp.mockImplementation((url, width) => { - expect(url).toEqual(IMAGE_MXC); - if (width) { - return "https://example.org/image/thumb"; - } - return "https://example.org/image/src"; - }); - client.getUrlPreview.mockResolvedValueOnce({ - ...BASIC_PREVIEW_OGDATA, - "og:image": IMAGE_MXC, - [property]: "anything", - }); - const msg = document.createElement("div"); - msg.innerHTML = `test`; - await vm.updateEventElement(msg); - expect(vm.getSnapshot().previews[0].image?.playable).toEqual(true); - }); }); diff --git a/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap b/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap index 0412afaba9..f925798734 100644 --- a/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap +++ b/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap @@ -1,78 +1,5 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:description': 'A description',\\n 'og:title': ''\\n} 1`] = ` -{ - "author": undefined, - "description": undefined, - "image": undefined, - "link": "https://example.org", - "showTooltipOnLink": false, - "siteIcon": undefined, - "siteName": "example.org", - "title": "A description", -} -`; - -exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:site_name': 'Site name',\\n 'og:title': ''\\n} 1`] = ` -{ - "author": undefined, - "description": undefined, - "image": undefined, - "link": "https://example.org", - "showTooltipOnLink": false, - "siteIcon": undefined, - "siteName": "Site name", - "title": "Site name", -} -`; - -exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Basic title'\\n} 1`] = ` -{ - "author": undefined, - "description": undefined, - "image": undefined, - "link": "https://example.org", - "showTooltipOnLink": false, - "siteIcon": undefined, - "siteName": "example.org", - "title": "Basic title", -} -`; - -exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Cool blog',\\n 'og:site_name': 'Cool site'\\n} 1`] = ` -{ - "author": undefined, - "description": undefined, - "image": undefined, - "link": "https://example.org", - "showTooltipOnLink": false, - "siteIcon": undefined, - "siteName": "Cool site", - "title": "Cool blog", -} -`; - -exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 10000,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = ` -{ - "author": undefined, - "description": undefined, - "image": { - "alt": undefined, - "fileSize": 10000, - "height": 478, - "imageFull": "https://example.org/image/src", - "imageThumb": "https://example.org/image/thumb", - "playable": false, - "width": 478, - }, - "link": "https://example.org", - "showTooltipOnLink": false, - "siteIcon": undefined, - "siteName": "example.org", - "title": "Media test", -} -`; - exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the same URL 1`] = ` { "overPreviewLimit": false, @@ -160,34 +87,6 @@ exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false } `; -exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = ` -{ - "overPreviewLimit": false, - "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": "This is an example!", - }, - ], - "previewsLimited": true, - "totalPreviewCount": 1, -} -`; - exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = ` { "overPreviewLimit": false, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 54db4d3be9..a567efa76d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -14,7 +14,7 @@ "outDir": "./lib", "declaration": true, "jsx": "react", - "lib": ["es2022", "es2024.promise", "dom", "dom.iterable"], + "lib": ["es2022", "es2024.promise", "dom", "dom.iterable", "ESNext.Collection"], "strict": true, "types": ["node", "modernizr"], "paths": { diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/default-auto.png new file mode 100644 index 0000000000..c6ca271a4b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/with-image-and-loads-of-text-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/with-image-and-loads-of-text-auto.png new file mode 100644 index 0000000000..0a8aeb3816 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/with-image-and-loads-of-text-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/with-image-and-site-icon-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/with-image-and-site-icon-auto.png new file mode 100644 index 0000000000..8fc269dfbb Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/with-image-and-site-icon-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/with-image-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/with-image-auto.png new file mode 100644 index 0000000000..64d38d1a49 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx/with-image-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 9c4106bf14..e45a914f36 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -14,6 +14,7 @@ export * from "./core/AvatarWithDetails"; export * from "./core/roving"; export * from "./room/composer/Banner"; export * from "./room/composer/UploadButton"; +export * from "./room/composer/MessageComposerUrlPreview/index.ts"; export * from "./crypto/SasEmoji"; export * from "./menus/UserMenu"; export * from "./notifications/NotificationBadgeView"; diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.module.css b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.module.css new file mode 100644 index 0000000000..4db55d85e0 --- /dev/null +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.module.css @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 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. + */ + +.container { + container-type: inline-size; + container-name: banner; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--cpd-space-4x); + padding: var(--cpd-space-3x) var(--cpd-space-4x); + border-top: 1px solid var(--cpd-color-gray-400); + + > div { + display: flex; + gap: var(--cpd-space-4x); + align-items: center; + } + + .text { + display: flex; + flex-direction: column; + > a { + color: var(--cpd-color-text-action-accent); + font-weight: 400; + font-size: 13px; + } + > .description { + margin: 0; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + line-clamp: 1; + overflow: hidden; + /* Cap the max width of a line so it doesn't go on forever. */ + max-width: 40em; + } + } +} + +.image { + width: 40px; + height: 40px; + border-radius: 6px; + object-fit: scale-down; +} diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx new file mode 100644 index 0000000000..557df63edb --- /dev/null +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.stories.tsx @@ -0,0 +1,110 @@ +/* + * 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 React, { type JSX } from "react"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import siteIconFile from "../../../../static/element.png"; +import imagePreviewFile from "../../../../static/wideImage.png"; +import { MessageComposerUrlPreviewView, type MessageComposerUrlPreviewSnapshot } from "./MessageComposerUrlPreview"; +import { useMockedViewModel } from "../../../core/viewmodel"; +import { LinkedTextContext } from "../../../core/utils/LinkedText"; +import { withViewDocs } from "../../../../.storybook/withViewDocs"; + +type MessageComposerUrlPreviewWrapperProps = MessageComposerUrlPreviewSnapshot; + +const MessageComposerUrlPreviewViewWrapperImpl = ({ + ...rest +}: MessageComposerUrlPreviewWrapperProps): JSX.Element | null => { + const vm = useMockedViewModel(rest, {}); + return ( + + + + ); +}; + +const MessageComposerUrlPreviewViewWrapper = withViewDocs( + MessageComposerUrlPreviewViewWrapperImpl, + MessageComposerUrlPreviewView, +); + +export default { + title: "Composer/MessageComposerUrlPreview", + component: MessageComposerUrlPreviewViewWrapper, + tags: ["autodocs"], + args: {}, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/sI9A2kV2K4xeiyqJsL7Ey3/Link-Previews?node-id=243-8476&t=NNrkR2fxEeVenjbU-0", + }, + }, +} satisfies Meta; + +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, + }, +}; + +export const WithImage = Template.bind({}); +WithImage.args = { + preview: { + ...Default.args.preview!, + 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, + }, + }, +}; + +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. + + 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. + + Impedit qui minus soluta cupiditate. Illo blanditiis sint et dolores rem consequuntur rerum ut. Delectus tempore dolorem veritatis odit enim ut dolores. Sit quae rerum explicabo consequatur tenetur. Labore in doloremque libero. + + 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, + }, + }, +}; diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.test.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.test.tsx new file mode 100644 index 0000000000..fc4022b2f8 --- /dev/null +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.test.tsx @@ -0,0 +1,26 @@ +/* + * 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 { render } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; +import React from "react"; + +import * as stories from "./MessageComposerUrlPreview.stories.tsx"; + +const { Default, WithImage } = composeStories(stories); + +describe("MessageComposerUrlPreview", () => { + it("renders a preview", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders a preview with an image", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx new file mode 100644 index 0000000000..4e34927fd0 --- /dev/null +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx @@ -0,0 +1,57 @@ +/* + * 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 React, { type JSX } from "react"; +import { Text } from "@vector-im/compound-web"; +import classNames from "classnames"; + +import { type UrlPreview } from "../../timeline/event-tile/UrlPreviewGroupView"; +import styles from "./MessageComposerUrlPreview.module.css"; +import { LinkSiteName, LinkTitle } from "../../timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview"; +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; +} + +/** Props for MessageComposerUrlPreviewView. */ +export interface MessageComposerUrlPreviewProps { + /** + * The view model for the component. + */ + vm: ViewModel; + /** + * Extra CSS classes to apply to the component. + */ + className?: string; +} + +/** + * MessageComposerUrlPreviewView renders a preview of a single URL above the messasge composer. + */ +export function MessageComposerUrlPreviewView({ vm, className }: MessageComposerUrlPreviewProps): JSX.Element | null { + const { preview } = useViewModel(vm); + if (!preview) { + return null; + } + return ( +
+
+ {preview?.image?.imageThumb && ( + {preview.image.alt} + )} +
+ + + {preview?.description} +
+
+
+ ); +} diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/__snapshots__/MessageComposerUrlPreview.test.tsx.snap b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/__snapshots__/MessageComposerUrlPreview.test.tsx.snap new file mode 100644 index 0000000000..3872de585c --- /dev/null +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/__snapshots__/MessageComposerUrlPreview.test.tsx.snap @@ -0,0 +1,80 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MessageComposerUrlPreview > renders a preview 1`] = ` +
+
+
+
+
+ + matrix.org + +
+ + A simple title + +

+ A simple description +

+
+
+
+
+`; + +exports[`MessageComposerUrlPreview > renders a preview with an image 1`] = ` +
+
+
+ The element logo +
+
+ + matrix.org + +
+ + A simple title + +

+ A simple description +

+
+
+
+
+`; diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/index.ts b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/index.ts new file mode 100644 index 0000000000..b1d28063ff --- /dev/null +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from "./MessageComposerUrlPreview"; diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css index 4027e8281a..ee2b667143 100644 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css @@ -87,31 +87,31 @@ button.preview { flex: 1; overflow: hidden; /* cause it to wrap rather than clip */ } +} - .title, - .description { - display: -webkit-box; - -webkit-box-orient: vertical; - overflow: hidden; - white-space: normal; - line-clamp: 2; - -webkit-line-clamp: 2; - margin: 0; - } - - .title { - color: var(--cpd-color-text-primary); - text-decoration-line: none; - } - - .siteName { - margin-top: var(--cpd-space-1x); - vertical-align: middle; - display: flex; - gap: var(--cpd-space-1-5x); - > * { - /* Center everything */ - margin: auto 0; - } +.siteName { + margin-top: var(--cpd-space-1x); + vertical-align: middle; + display: flex; + gap: var(--cpd-space-1-5x); + > * { + /* Center everything */ + margin: auto 0; } } + +.title, +.description { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; + line-clamp: 2; + -webkit-line-clamp: 2; + margin: 0; +} + +.title { + color: var(--cpd-color-text-primary); + text-decoration-line: none; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx index 4dc9406316..d8cfc6cec2 100644 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx @@ -21,7 +21,7 @@ export interface LinkPreviewActions { export type LinkPreviewProps = UrlPreview & LinkPreviewActions; -function LinkTitle({ +export function LinkTitle({ title, showTooltipOnLink, link, @@ -44,7 +44,7 @@ function LinkTitle({ return showTooltipOnLink ? {anchor} : anchor; } -function LinkSiteName({ siteIcon, siteName }: { siteIcon?: string; siteName: string }): JSX.Element { +export function LinkSiteName({ siteIcon, siteName }: { siteIcon?: string; siteName: string }): JSX.Element { return (
{siteIcon && } diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.tsx index 2a627fa20d..15699d1b06 100644 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.tsx @@ -100,7 +100,12 @@ export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps)
{previews.map((preview) => ( - vm.onImageClick(preview)} {...preview} /> + vm.onImageClick(preview)} + {...preview} + image={preview.image} + /> ))} {toggleButton}