diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.tsx b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.tsx index 3b70673779..6a4149143d 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.tsx +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.tsx @@ -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; onImageClick: () => void; } diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.tsx b/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.tsx index 1dcacd2262..a22bfe5371 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.tsx +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.tsx @@ -27,7 +27,7 @@ export interface UrlPreviewGroupViewProps { export interface UrlPreviewGroupViewActions { onTogglePreviewLimit: () => void; - onHideClick: () => void; + onHideClick: () => Promise; onImageClick: (preview: UrlPreviewViewSnapshotPreview) => void; } diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 61228ac061..ca4ac8b9a0 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -95,7 +95,7 @@ class InnerTextualBody extends React.Component { - this.props.urlPreviewViewModel.onShowClick(); + void this.props.urlPreviewViewModel.onShowClick(); }, }); diff --git a/src/viewmodels/message-body/UrlPreviewViewModel.ts b/src/viewmodels/message-body/UrlPreviewViewModel.ts index 3e69c18fb6..08a933ee41 100644 --- a/src/viewmodels/message-body/UrlPreviewViewModel.ts +++ b/src/viewmodels/message-body/UrlPreviewViewModel.ts @@ -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 => { // 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 => { // FIXME: persist this somewhere smarter than local storage global.localStorage?.setItem(this.storageKey, "1"); this.urlPreviewEnabledByUser = false; - void this.computeSnapshot(); + return this.computeSnapshot(); }; /** diff --git a/test/viewmodels/message-body/UrlPreviewViewModel-test.ts b/test/viewmodels/message-body/UrlPreviewViewModel-test.ts index f953f4aa51..184d633971 100644 --- a/test/viewmodels/message-body/UrlPreviewViewModel-test.ts +++ b/test/viewmodels/message-body/UrlPreviewViewModel-test.ts @@ -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 } { +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; + onImageClicked: jest.Mock; +} { const client = getMockClientWithEventEmitter({ getUrlPreview: jest.fn(), + mxcUrlToHttp: jest.fn(), }); + const onImageClicked = jest.fn(); 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 { @@ -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 = 'Test'; + 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 = 'Test'; + 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 = 'Test'; + 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 = 'Test'; @@ -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 = + 'TestTestTest'; + 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 = 'Test'; + 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 = 'Test'; + 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 = 'Test'; + 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 = `${item.text}`; + 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"); });