/* * 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 => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }; const createEvent = ({ body = "demo video", content = {}, }: { body?: string; content?: Record; } = {}): 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 | undefined), }, }, }); }; const createMediaEventHelper = ({ encrypted, thumbnailUrl = "blob:thumbnail", sourceUrl = "blob:video", sourceBlob = new Blob(["video"], { type: "video/mp4" }), }: { encrypted: boolean; thumbnailUrl?: string | null | Promise; sourceUrl?: string | null | Promise; sourceBlob?: Blob | Promise; }): 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[0]>): VideoBodyViewModel => new VideoBodyViewModel({ mxEvent: createEvent(), mediaVisible: false, videoRef, ...overrides, }); const createMockMedia = (content: Record): 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(); }); });