Add URL preview above message composer (#33964)
* Modify LinkPreview to export shared atomics * Implement MessageComposerUrlPreviewView * Create UrlPreviewFetcher utility function * Modify view models * Implement in composer * Support running tests in dom-less vitest environment * Add a playwright test * hide another one * fmt * cleanup * test rte too * fixup * Add back docstring * cleanup * off by one * remove description check * Cleanup hacks * Remove another hack * cleanup * one more * fixup window here too * whoops type * Rename to be cleaeer * Trim URLs first * fix bug
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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<IBodyProps>): JSX.Element {
|
||||
);
|
||||
},
|
||||
visible: props.showUrlPreview ?? false,
|
||||
showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -182,10 +184,16 @@ export function TextualBodyFactory(props: Readonly<IBodyProps>): 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) {
|
||||
|
||||
@@ -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<IProps, IState> {
|
||||
this.state = {
|
||||
isComposerEmpty: initialComposerContent?.length === 0,
|
||||
composerContent: initialComposerContent,
|
||||
urlPreviewComposerContent: initialComposerContent,
|
||||
haveRecording: false,
|
||||
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
|
||||
isMenuOpen: false,
|
||||
@@ -418,6 +423,11 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
|
||||
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<IProps, IState> {
|
||||
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<IProps, IState> {
|
||||
return (
|
||||
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
|
||||
<div className="mx_MessageComposer_wrapper">
|
||||
<MessageComposerUrlPreviewWrapper content={this.state.urlPreviewComposerContent} />
|
||||
<UserIdentityWarning room={this.props.room} key={this.props.room.roomId} />
|
||||
<ReplyPreview
|
||||
replyToEvent={this.props.replyToEvent}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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, { useEffect, type ReactNode } from "react";
|
||||
import { MessageComposerUrlPreviewView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
|
||||
|
||||
import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext";
|
||||
import { useDebouncedCallback } from "../../../hooks/spotlight/useDebouncedCallback";
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
|
||||
const DEBOUNCE_REQUEST_TIMEOUT_MS = 500;
|
||||
|
||||
export function MessageComposerUrlPreviewWrapper({ content }: { content: string }): ReactNode | null {
|
||||
const { showUrlPreview } = useScopedRoomContext("showUrlPreview");
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
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 <MessageComposerUrlPreviewView vm={vm} />;
|
||||
}
|
||||
@@ -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<T extends any[]>(
|
||||
enabled: boolean,
|
||||
callback: (...params: T) => unknown,
|
||||
params: T,
|
||||
timeout = DEFAULT_DEBOUNCE_TIMEOUT,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
let handle: number | null = null;
|
||||
let handle: ReturnType<typeof globalThis.setTimeout> | 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]);
|
||||
}
|
||||
|
||||
@@ -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<Partial<IPreviewUrlResponse>>([
|
||||
{ "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<string>(["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<IPreviewUrlResponse>([
|
||||
{ ...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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, UrlPreview>();
|
||||
|
||||
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<UrlPreview, "title" | "description" | "siteName"> {
|
||||
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<UrlPreview | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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!",
|
||||
}
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string> = 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<void> {
|
||||
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<void> {
|
||||
const newLinks = new Set(
|
||||
content
|
||||
.split(" ")
|
||||
.map((w) => w.trim())
|
||||
.filter((word) => URL.canParse(word)),
|
||||
);
|
||||
if (this.links.symmetricDifference(newLinks).size === 0) {
|
||||
// Skip if the URL set hasn't changed
|
||||
return;
|
||||
}
|
||||
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<void> => {
|
||||
this.urlPreviewVisible = urlPreviewVisible;
|
||||
this.fetcher.clearCache();
|
||||
return this.computeSnapshot();
|
||||
};
|
||||
}
|
||||
+39
@@ -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!",
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -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<UrlPreviewGroupViewSnapshot, UrlPreviewGroupViewModelProps>
|
||||
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<UrlPreview, "title" | "description" | "siteName"> {
|
||||
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<Element>): string[] {
|
||||
let links = new Set<string>();
|
||||
|
||||
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<string> = [];
|
||||
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<string, UrlPreview>();
|
||||
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<UrlPreview | null> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> => {
|
||||
public readonly updateUrlPreviewVisible = (urlPreviewVisible: boolean): Promise<void> => {
|
||||
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<void> => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
it.each<Partial<IPreviewUrlResponse>>([
|
||||
{ "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 = '<a href="https://example.org">Test</a>';
|
||||
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 = '<a href="https://example.org">Test</a>';
|
||||
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 = '<a href="https://example.org">Test</a>';
|
||||
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 = '<a href="https://example.org">Test</a>';
|
||||
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<IPreviewUrlResponse>([
|
||||
{ ...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 = `<a href="https://example.org">test</a>`;
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot().previews[0]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each<string>(["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 = `<a href="https://example.org">test</a>`;
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot().previews[0].image?.playable).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
-101
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user