This commit is contained in:
Half-Shot 2026-02-23 17:21:26 +00:00
parent b90820c6c2
commit ba91e16201
5 changed files with 233 additions and 22 deletions

View File

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type MouseEventHandler, type JSX, useCallback, useId } from "react";
import React, { type MouseEventHandler, type JSX, useCallback } from "react";
import { IconButton, Tooltip } from "@vector-im/compound-web";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import classNames from "classnames";
@ -15,7 +15,7 @@ import styles from "./LinkPreview.module.css";
import type { UrlPreviewViewSnapshotPreview } from "./types";
export interface LinkPreviewActions {
onHideClick?: () => void;
onHideClick?: () => Promise<void>;
onImageClick: () => void;
}

View File

@ -27,7 +27,7 @@ export interface UrlPreviewGroupViewProps {
export interface UrlPreviewGroupViewActions {
onTogglePreviewLimit: () => void;
onHideClick: () => void;
onHideClick: () => Promise<void>;
onImageClick: (preview: UrlPreviewViewSnapshotPreview) => void;
}

View File

@ -95,7 +95,7 @@ class InnerTextualBody extends React.Component<IBodyProps & { urlPreviewViewMode
},
unhideWidget: () => {
this.props.urlPreviewViewModel.onShowClick();
void this.props.urlPreviewViewModel.onShowClick();
},
});

View File

@ -172,7 +172,7 @@ export class UrlPreviewViewModel
}
const media =
typeof preview["og:image"] === "string" && this.visibility >= PreviewVisibility.MediaHidden
typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden
? mediaFromMxc(preview["og:image"], this.client)
: undefined;
const needsTooltip = link !== title && PlatformPeg.get()?.needsUrlTooltips();
@ -319,7 +319,6 @@ export class UrlPreviewViewModel
previewsLimited: this.limitPreviews,
overPreviewLimit: this.links.length > MAX_PREVIEWS_WHEN_LIMITED,
});
console.log("SNAPSHOT", this.visibility, previews, this.snapshot.current);
}
/**
@ -350,22 +349,22 @@ export class UrlPreviewViewModel
* Called when the user has requsted previews be visible. The provided
* props `urlPreviewVisible` state will always override this.
*/
public readonly onShowClick = (): void => {
public readonly onShowClick = (): Promise<void> => {
// FIXME: persist this somewhere smarter than local storage
this.urlPreviewEnabledByUser = true;
global.localStorage?.removeItem(this.storageKey);
void this.computeSnapshot();
return this.computeSnapshot();
};
/**
* Called when the user has requsted previews be hidden. Will take precedence
* over other settings.
*/
public readonly onHideClick = (): void => {
public readonly onHideClick = (): Promise<void> => {
// FIXME: persist this somewhere smarter than local storage
global.localStorage?.setItem(this.storageKey, "1");
this.urlPreviewEnabledByUser = false;
void this.computeSnapshot();
return this.computeSnapshot();
};
/**

View File

@ -5,20 +5,38 @@
* Please see LICENSE files in the repository root for full details.
*/
import { expect } from "@jest/globals";
import type { MockedObject } from "jest-mock";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { UrlPreviewViewModel } from "../../../src/viewmodels/message-body/UrlPreviewViewModel";
import type { UrlPreviewViewSnapshotPreview } from "@element-hq/web-shared-components";
import { getMockClientWithEventEmitter, mkEvent } from "../../test-utils";
function getViewModel(): { vm: UrlPreviewViewModel; client: MockedObject<MatrixClient> } {
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({ mediaVisible, visible } = { mediaVisible: true, visible: true }): {
vm: UrlPreviewViewModel;
client: MockedObject<MatrixClient>;
onImageClicked: jest.Mock<void, [UrlPreviewViewSnapshotPreview]>;
} {
const client = getMockClientWithEventEmitter({
getUrlPreview: jest.fn(),
mxcUrlToHttp: jest.fn(),
});
const onImageClicked = jest.fn<void, [UrlPreviewViewSnapshotPreview]>();
const vm = new UrlPreviewViewModel({
client,
mediaVisible: true,
visible: true,
onImageClicked: jest.fn(),
mediaVisible,
visible,
onImageClicked,
mxEvent: mkEvent({
event: true,
user: "@foo:bar",
@ -27,7 +45,7 @@ function getViewModel(): { vm: UrlPreviewViewModel; client: MockedObject<MatrixC
id: "$id",
}),
});
return { vm, client };
return { vm, client, onImageClicked };
}
describe("UrlPreviewViewModel", () => {
@ -41,11 +59,97 @@ describe("UrlPreviewViewModel", () => {
});
});
it("should preview a single valid URL", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toEqual({
previews: [
{
link: "https://example.org",
title: "This is an example!",
siteName: "Example.org",
showTooltipOnLink: undefined,
description: "This is a description",
image: undefined,
},
],
compactLayout: false,
overPreviewLimit: false,
previewsLimited: true,
totalPreviewCount: 1,
});
});
it("should hide preview when invisible", async () => {
const { vm, client } = getViewModel({ visible: false, mediaVisible: true });
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toEqual({
previews: [],
compactLayout: false,
overPreviewLimit: false,
previewsLimited: true,
totalPreviewCount: 1,
});
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()).toEqual({
previews: [
{
link: "https://example.org",
title: "This is an example!",
siteName: undefined,
showTooltipOnLink: undefined,
description: undefined,
image: {
height: 100,
width: 100,
fileSize: 10000,
imageThumb: "https://example.org/image/thumb",
imageFull: "https://example.org/image/src",
},
},
],
compactLayout: false,
overPreviewLimit: false,
previewsLimited: true,
totalPreviewCount: 1,
});
});
it("should ignore media when mediaVisible is false", async () => {
const { vm, client } = getViewModel({ mediaVisible: false, visible: true });
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 msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
@ -66,12 +170,120 @@ describe("UrlPreviewViewModel", () => {
previewsLimited: true,
totalPreviewCount: 1,
});
// eslint-disable-next-line no-restricted-properties
expect(client.mxcUrlToHttp).not.toHaveBeenCalled();
});
it("should deduplicate multiple versions of the same URL", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
const msg = document.createElement("div");
msg.innerHTML =
'<a href="https://example.org">Test</a><a href="https://example.org">Test</a><a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toEqual({
previews: [
{
link: "https://example.org",
title: "This is an example!",
siteName: "Example.org",
showTooltipOnLink: undefined,
description: "This is a description",
image: undefined,
},
],
compactLayout: false,
overPreviewLimit: false,
previewsLimited: true,
totalPreviewCount: 1,
});
expect(client.getUrlPreview).toHaveBeenCalledTimes(1);
});
it("should ignore failed previews", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockRejectedValue(new Error("Forced test failure"));
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toEqual({
previews: [],
compactLayout: false,
overPreviewLimit: false,
previewsLimited: true,
totalPreviewCount: 1,
});
});
it("should handle image clicks", async () => {
const { vm, client, onImageClicked } = 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);
const { previews } = vm.getSnapshot();
vm.onImageClick(previews[0]);
expect(onImageClicked).toHaveBeenCalled();
});
it("should handle being hidden and shown by the user", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
await vm.onHideClick();
expect(vm.getSnapshot()).toEqual({
previews: [],
compactLayout: false,
overPreviewLimit: false,
previewsLimited: true,
totalPreviewCount: 1,
});
await vm.onShowClick();
expect(vm.getSnapshot()).toEqual({
previews: [
{
link: "https://example.org",
title: "This is an example!",
siteName: "Example.org",
showTooltipOnLink: undefined,
description: "This is a description",
image: undefined,
},
],
compactLayout: false,
overPreviewLimit: false,
previewsLimited: true,
totalPreviewCount: 1,
});
});
it.each([
{ text: "", href: "", hasPreview: false },
{ text: "test", href: "noprotocol.example.org", hasPreview: false },
{ text: "matrix link", href: "https://matrix.to", hasPreview: false },
{ text: "email", href: "mailto:example.org", hasPreview: false },
{ text: "", href: "https://example.org", hasPreview: true },
])("handles different kinds of links %s", async (item) => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
const msg = document.createElement("div");
msg.innerHTML = `<a href="${item.href}">${item.text}</a>`;
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews).toHaveLength(item.hasPreview ? 1 : 0);
});
it.todo("should preview a URL with media");
it.todo("should ignore media when mediaVisible is false");
it.todo("should deduplicate multiple versions of the same URL");
it.todo("should ignore failed previews");
it.todo("should handle image clicks");
it.todo("should handle being hidden by the user");
it.todo("should handle being shown by the user");
});