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:
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
-38
@@ -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
|
||||
|
||||
-78
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user