Refactor and move MVideoBody to shared components (#32849)

* init MVideoBody to shared components, including test, stories and view

* fix prettier and other warnings

* move video message body to shared view + app viewmodel

* Fix prettier warnings and masking spinner for tests

* stabilize VideoBodyView screenshots with local media asset

* Disable spinner from changing image all the time

* Added mask over video spinner to prevent issues with new generated images on playwright tests

* Update prettier fix

* Update snapshot

* Add tests to cover different states of Video

* Update code to prevent the previous component Hack fix regarding jumps on the timeline.

* Update snapshot

* Update code to improve code quality for Sonar + update snapshot

* adde documentation snippets

* refactor: move m.video rendering into body factory

* docs: add tsdoc for video body view model

* docs: add thumbnail tsdoc for video body view model

* docs: add content-url tsdoc for video body view model

* docs: add dimensions tsdoc for video body view model

* docs: add aspect-ratio tsdoc for video body view model

* docs: add tsdoc for video body view state

* refactor: replace video body view state enum

* refactor: remove duplicate video body state init

* refactor: drop unused video body view state attribute

* Fix Prettier

* Update snapshot screenshot

* test: restore video story screenshot mask

* chore: refresh PR head

* Add mask to screenshot to pass CI tests

* test: narrow video story mask hook

* Fix easy Sonar warnings in video body components

* Move shared message body views into event-tile layout

* Move shared message body visual baselines

* Revert unrelated shared message body moves
This commit is contained in:
Zack
2026-04-01 09:48:22 +00:00
committed by GitHub
parent 3e04b24d1e
commit 0391543bbc
27 changed files with 1763 additions and 524 deletions
@@ -19,7 +19,11 @@ import {
} from "../../../../test-utils";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { FileBodyFactory, renderMBody } from "../../../../../src/components/views/messages/MBodyFactory";
import {
FileBodyFactory,
VideoBodyFactory,
renderMBody,
} from "../../../../../src/components/views/messages/MBodyFactory";
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
@@ -90,10 +94,14 @@ describe("MBodyFactory", () => {
expect(container).toMatchSnapshot();
});
it.each(["m.audio", "m.video", "m.text"])("returns null for unsupported msgtype %s", (msgtype) => {
it.each(["m.audio", "m.text"])("returns null for unsupported msgtype %s", (msgtype) => {
expect(renderMBody({ ...props, mxEvent: mkEvent(msgtype) })).toBeNull();
});
it("returns the video body factory for m.video", () => {
expect(renderMBody({ ...props, mxEvent: mkEvent("m.video") })?.type).toBe(VideoBodyFactory);
});
it("returns null when msgtype is missing", () => {
expect(renderMBody({ ...props, mxEvent: mkEvent() })).toBeNull();
});
@@ -116,7 +124,7 @@ describe("MBodyFactory", () => {
});
});
it.each(["m.file", "m.audio", "m.video"])(
it.each(["m.file", "m.audio"])(
"renderMBody fallback shows %s generic placeholder when showFileInfo is true",
async (msgtype) => {
const mediaEvent = new MatrixEvent({
@@ -23,7 +23,7 @@ import {
mockClientMethodsUser,
withClientContextRenderOptions,
} from "../../../../test-utils";
import MVideoBody from "../../../../../src/components/views/messages/MVideoBody";
import { VideoBodyFactory } from "../../../../../src/components/views/messages/MBodyFactory";
import type { IBodyProps } from "../../../../../src/components/views/messages/IBodyProps";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
@@ -33,7 +33,7 @@ jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn(),
}));
describe("MVideoBody", () => {
describe("VideoBodyFactory", () => {
const ourUserId = "@user:server";
const senderUserId = "@other_use:server";
const deviceId = "DEADB33F";
@@ -122,23 +122,25 @@ describe("MVideoBody", () => {
mediaEventHelper: { media: { isEncrypted: false } } as MediaEventHelper,
};
const { asFragment } = render(
const { container } = render(
<MatrixClientContext.Provider value={cli}>
<MVideoBody {...defaultProps} />
<VideoBodyFactory {...defaultProps} />
</MatrixClientContext.Provider>,
withClientContextRenderOptions(cli),
);
expect(asFragment()).toMatchSnapshot();
// If we get here, we did not crash.
expect(container.querySelector("video")).not.toBeNull();
});
it("should show poster for encrypted media before downloading it", async () => {
fetchMock.getOnce(thumbUrl, { status: 200 });
const { asFragment } = render(
<MVideoBody mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />,
render(
<VideoBodyFactory
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
withClientContextRenderOptions(cli),
);
expect(asFragment()).toMatchSnapshot();
expect(await screen.findByLabelText("alt for a test video")).toHaveAttribute("poster");
});
describe("with video previews/thumbnails disabled", () => {
@@ -161,7 +163,7 @@ describe("MVideoBody", () => {
fetchMock.getOnce(thumbUrl, { status: 200 });
render(
<MVideoBody
<VideoBodyFactory
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
@@ -177,7 +179,7 @@ describe("MVideoBody", () => {
fetchMock.getOnce(thumbUrl, { status: 200 });
render(
<MVideoBody
<VideoBodyFactory
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
@@ -189,6 +191,7 @@ describe("MVideoBody", () => {
expect(placeholderButton).toBeInTheDocument();
fireEvent.click(placeholderButton);
await screen.findByLabelText("alt for a test video");
expect(fetchMock).toHaveFetched(thumbUrl);
});
@@ -214,16 +217,16 @@ describe("MVideoBody", () => {
},
},
});
const { asFragment } = render(
<MVideoBody
render(
<VideoBodyFactory
mxEvent={ourEncryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(ourEncryptedMediaEvent)}
/>,
withClientContextRenderOptions(cli),
);
expect(await screen.findByLabelText("alt for a test video")).toBeInTheDocument();
expect(fetchMock).toHaveFetched(thumbUrl);
expect(asFragment()).toMatchSnapshot();
});
});
});
@@ -29,16 +29,12 @@ jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({
default: () => <div data-testid="image-body" />,
}));
jest.mock("../../../../../src/components/views/messages/MVideoBody", () => ({
__esModule: true,
default: () => <div data-testid="video-body" />,
}));
jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
__esModule: true,
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
FileBodyFactory: () => <div data-testid="file-body" />,
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
VideoBodyFactory: () => <video data-testid="video-body" />,
renderMBody: () => <div data-testid="file-body" />,
}));
@@ -47,6 +43,11 @@ jest.mock("../../../../../src/components/views/messages/MImageReplyBody", () =>
default: () => <div data-testid="image-reply-body" />,
}));
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
__esModule: true,
useMediaVisible: () => [true, jest.fn()],
}));
jest.mock("../../../../../src/components/views/messages/MStickerBody", () => ({
__esModule: true,
default: () => <div data-testid="sticker-body" />,
@@ -164,11 +165,11 @@ describe("MessageEvent", () => {
result.getByTestId("textual-body");
});
it("should render a TextualBody and an VideoBody", () => {
it("should render a TextualBody and a video element", () => {
event = createEvent("video/mp4", "video.mp4", MsgType.Video);
result = renderMessageEvent();
mockMedia();
result.getByTestId("video-body");
expect(result.container.querySelector("video")).not.toBeNull();
result.getByTestId("textual-body");
});
@@ -79,44 +79,6 @@ exports[`MBodyFactory renderMBody fallback shows m.file generic placeholder when
</div>
`;
exports[`MBodyFactory renderMBody fallback shows m.video generic placeholder when showFileInfo is true 1`] = `
<div>
<span
class="_content_f1s5h_8 mx_MFileBody"
>
<div
class="mx_MediaBody _mediaBody_rgndh_8"
data-type="info"
>
<button
aria-label="alt"
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
<span>
alt
</span>
</button>
</div>
</span>
</div>
`;
exports[`MBodyFactory renderMBody renders download button for m.file in file rendering type 1`] = `
<div>
<span
@@ -1,78 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`MVideoBody does not crash when given portrait dimensions 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
>
<div
class="mx_MVideoBody_container"
style="max-width: 182px; max-height: 324px; aspect-ratio: 720/1280;"
>
<video
class="mx_MVideoBody"
controls=""
controlslist="nodownload"
crossorigin="anonymous"
poster="data:image/png;base64,00"
preload="none"
/>
<div
style="width: 182px; height: 324px;"
/>
</div>
</span>
</DocumentFragment>
`;
exports[`MVideoBody should show poster for encrypted media before downloading it 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
>
<div
class="mx_MVideoBody_container"
style="max-width: 40px; max-height: 50px; aspect-ratio: 40/50;"
>
<video
class="mx_MVideoBody"
controls=""
controlslist="nodownload"
crossorigin="anonymous"
poster="https://server/_matrix/media/v3/download/server/encrypted-poster"
preload="none"
title="alt for a test video"
/>
<div
style="width: 40px; height: 50px;"
/>
</div>
</span>
</DocumentFragment>
`;
exports[`MVideoBody with video previews/thumbnails disabled should download video if we were the sender 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
>
<div
class="mx_MVideoBody_container"
style="max-width: 40px; max-height: 50px; aspect-ratio: 40/50;"
>
<video
class="mx_MVideoBody"
controls=""
controlslist="nodownload"
crossorigin="anonymous"
poster="https://server/_matrix/media/v3/download/server/encrypted-poster"
preload="none"
title="alt for a test video"
/>
<div
style="width: 40px; height: 50px;"
/>
</div>
</span>
</DocumentFragment>
`;
@@ -0,0 +1,464 @@
/*
* 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 { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { VideoBodyViewState } from "@element-hq/web-shared-components";
import { decode } from "blurhash";
import { type Media } from "@element-hq/element-web-module-api";
import SettingsStore from "../../../src/settings/SettingsStore";
import { ImageSize } from "../../../src/settings/enums/ImageSize";
import { mediaFromContent } from "../../../src/customisations/Media";
import { BLURHASH_FIELD } from "../../../src/utils/image-media";
import { type MediaEventHelper } from "../../../src/utils/MediaEventHelper";
import { VideoBodyViewModel } from "../../../src/viewmodels/message-body/VideoBodyViewModel";
jest.mock("../../../src/customisations/Media", () => ({
mediaFromContent: jest.fn(),
}));
jest.mock("blurhash", () => ({
decode: jest.fn(),
}));
describe("VideoBodyViewModel", () => {
const mockedMediaFromContent = jest.mocked(mediaFromContent);
const mockedDecode = jest.mocked(decode);
const videoRef = { current: null };
let imageSizeWatcher: ((...args: [unknown, unknown, unknown, unknown, ImageSize]) => void) | undefined;
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
};
const createEvent = ({
body = "demo video",
content = {},
}: {
body?: string;
content?: Record<string, unknown>;
} = {}): MatrixEvent => {
const { info: infoOverride, ...restContent } = content;
return new MatrixEvent({
type: EventType.RoomMessage,
room_id: "!room:server",
event_id: "$video:server",
sender: "@alice:server",
content: {
msgtype: "m.video",
body,
url: "https://server/video.mp4",
...restContent,
info: {
w: 320,
h: 180,
mimetype: "video/mp4",
...(infoOverride as Record<string, unknown> | undefined),
},
},
});
};
const createMediaEventHelper = ({
encrypted,
thumbnailUrl = "blob:thumbnail",
sourceUrl = "blob:video",
sourceBlob = new Blob(["video"], { type: "video/mp4" }),
}: {
encrypted: boolean;
thumbnailUrl?: string | null | Promise<string | null>;
sourceUrl?: string | null | Promise<string | null>;
sourceBlob?: Blob | Promise<Blob>;
}): MediaEventHelper =>
({
media: { isEncrypted: encrypted },
thumbnailUrl: { value: Promise.resolve(thumbnailUrl) },
sourceUrl: { value: Promise.resolve(sourceUrl) },
sourceBlob: { value: Promise.resolve(sourceBlob) },
}) as unknown as MediaEventHelper;
const createVm = (overrides?: Partial<ConstructorParameters<typeof VideoBodyViewModel>[0]>): VideoBodyViewModel =>
new VideoBodyViewModel({
mxEvent: createEvent(),
mediaVisible: false,
videoRef,
...overrides,
});
const createMockMedia = (content: Record<string, any>): Media =>
({
isEncrypted: !!content.file,
srcMxc: content.url ?? "mxc://server/video",
thumbnailMxc: content.info?.thumbnail_url ?? undefined,
srcHttp: content.url ?? "https://server/video.mp4",
thumbnailHttp:
content.info?.thumbnail_url === null
? null
: (content.info?.thumbnail_url ?? "https://server/poster.jpg"),
hasThumbnail: content.info?.thumbnail_url !== null,
getThumbnailHttp: jest.fn(),
getThumbnailOfSourceHttp: jest.fn(),
getSquareThumbnailHttp: jest.fn(),
downloadSource: jest.fn(),
}) as unknown as Media;
beforeEach(() => {
const originalGetValue = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
if (setting === "Images.size") {
return ImageSize.Normal;
}
if (setting === "autoplayVideo") {
return false;
}
return originalGetValue(setting, ...args);
});
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_name, _roomId, callback) => {
imageSizeWatcher = callback as (...args: [unknown, unknown, unknown, unknown, ImageSize]) => void;
return "video-body-test-watch";
});
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
mockedMediaFromContent.mockImplementation((content) => createMockMedia(content));
mockedDecode.mockReturnValue(new Uint8ClampedArray(320 * 180 * 4));
});
afterEach(() => {
jest.restoreAllMocks();
imageSizeWatcher = undefined;
});
it("computes the initial hidden snapshot from props", () => {
const vm = createVm();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.HIDDEN);
expect(vm.getSnapshot().hiddenButtonLabel).toBeTruthy();
expect(vm.getSnapshot().maxWidth).toBe(320);
expect(vm.getSnapshot().maxHeight).toBe(180);
});
it("updates to ready when media becomes visible", () => {
const vm = createVm();
vm.setMediaVisible(true);
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.READY);
expect(vm.getSnapshot().src).toBe("https://server/video.mp4");
expect(vm.getSnapshot().poster).toBe("https://server/poster.jpg");
});
it("uses the export urls directly when rendering for export", () => {
const vm = createVm({
mxEvent: createEvent({
content: {
url: "https://server/fallback.mp4",
file: {
url: "mxc://server/export-video",
},
},
}),
mediaVisible: true,
forExport: true,
});
vm.loadInitialMediaIfVisible();
expect(vm.getSnapshot()).toMatchObject({
state: VideoBodyViewState.READY,
src: "mxc://server/export-video",
preload: "metadata",
poster: undefined,
});
});
it("updates controls and autoplay flags when interaction is inhibited", () => {
const vm = createVm({ mediaVisible: true });
vm.setInhibitInteraction(true);
expect(vm.getSnapshot().controls).toBe(false);
expect(vm.getSnapshot().muted).toBe(false);
expect(vm.getSnapshot().autoPlay).toBe(false);
});
it("forwards preview clicks", () => {
const onPreviewClick = jest.fn();
const vm = createVm({ onPreviewClick });
vm.onPreviewClick();
expect(onPreviewClick).toHaveBeenCalledTimes(1);
});
it("preloads encrypted video when autoplay is enabled", async () => {
const originalGetValue = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
if (setting === "Images.size") return ImageSize.Normal;
if (setting === "autoplayVideo") return true;
return originalGetValue(setting, ...args);
});
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: "blob:encrypted-poster",
sourceUrl: "blob:encrypted-video",
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.LOADING);
await flushPromises();
expect(vm.getSnapshot()).toMatchObject({
state: VideoBodyViewState.READY,
src: "blob:encrypted-video",
poster: "blob:encrypted-poster",
muted: true,
autoPlay: true,
});
});
it("keeps encrypted video lazy-loadable when autoplay is disabled", async () => {
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
info: {
mimetype: "video/quicktime",
},
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
expect(vm.getSnapshot()).toMatchObject({
state: VideoBodyViewState.READY,
src: "data:video/mp4,",
poster: "data:video/mp4,",
preload: "none",
autoPlay: false,
});
});
it("switches to the error state when encrypted preload fails", async () => {
jest.spyOn(logger, "warn").mockImplementation(jest.fn());
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: Promise.reject(new Error("decrypt failed")),
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.ERROR);
expect(vm.getSnapshot().errorLabel).toBeTruthy();
});
it("loads the encrypted source on play when only a placeholder url is present", async () => {
const play = jest.fn();
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
sourceUrl: "blob:played-video",
}),
mediaVisible: true,
videoRef: { current: { play } } as any,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
await vm.onPlay();
expect(vm.getSnapshot().src).toBe("blob:played-video");
expect(play).toHaveBeenCalledTimes(1);
});
it("shows an error when play is requested without encrypted media data", async () => {
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await vm.onPlay();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.ERROR);
});
it("recomputes dimensions when the image-size setting changes", () => {
const vm = createVm({
mxEvent: createEvent({
content: {
info: {
w: 1280,
h: 720,
},
},
}),
mediaVisible: false,
});
expect(vm.getSnapshot().maxWidth).toBe(324);
expect(vm.getSnapshot().maxHeight).toBe(182);
imageSizeWatcher?.(undefined, undefined, undefined, undefined, ImageSize.Large);
expect(vm.getSnapshot().maxWidth).toBe(800);
expect(vm.getSnapshot().maxHeight).toBe(450);
});
it("uses the blurhash poster while the thumbnail image is loading", () => {
const originalCreateElement = document.createElement.bind(document);
const originalImage = global.Image;
let imageOnLoad: (() => void) | undefined;
const context = {
createImageData: jest.fn((width: number, height: number) => ({
data: new Uint8ClampedArray(width * height * 4),
})),
putImageData: jest.fn(),
};
const canvas = {
width: 0,
height: 0,
getContext: jest.fn(() => context),
toDataURL: jest.fn(() => "data:image/png;base64,blurhash"),
};
jest.spyOn(document, "createElement").mockImplementation(((tagName: string) => {
if (tagName === "canvas") {
return canvas as any;
}
return originalCreateElement(tagName);
}) as typeof document.createElement);
class MockImage {
public onload?: () => void;
public set src(_value: string) {
imageOnLoad = this.onload;
}
}
global.Image = MockImage as unknown as typeof Image;
const vm = createVm({
mxEvent: createEvent({
content: {
info: {
[BLURHASH_FIELD]: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
},
},
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
expect(vm.getSnapshot().poster).toBe("data:image/png;base64,blurhash");
imageOnLoad?.();
expect(vm.getSnapshot().poster).toBe("https://server/poster.jpg");
global.Image = originalImage;
});
it("resets encrypted media state when the event changes", async () => {
const vm = createVm({
mxEvent: createEvent({
content: {
body: "first video",
file: { url: "mxc://server/video-a" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
sourceUrl: "blob:first-video",
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
expect(vm.getSnapshot().src).toBe("data:video/mp4,");
vm.setEvent(
createEvent({
body: "second video",
content: {
file: { url: "mxc://server/video-b" },
},
}),
createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
sourceUrl: "blob:second-video",
}),
);
await flushPromises();
expect(vm.getSnapshot().videoLabel).toBe("second video");
expect(vm.getSnapshot().src).toBe("data:video/mp4,");
});
it("does not emit for unchanged targeted setters", () => {
const event = createEvent();
const onPreviewClick = jest.fn();
const vm = createVm({
mxEvent: event,
mediaVisible: false,
onPreviewClick,
});
const listener = jest.fn();
vm.subscribe(listener);
vm.setEvent(event, undefined);
vm.setForExport(undefined);
vm.setInhibitInteraction(undefined);
vm.setMediaVisible(false);
vm.setOnPreviewClick(onPreviewClick);
expect(listener).not.toHaveBeenCalled();
});
});