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:
@@ -232,7 +232,6 @@
|
|||||||
@import "./views/messages/_MPollBody.pcss";
|
@import "./views/messages/_MPollBody.pcss";
|
||||||
@import "./views/messages/_MStickerBody.pcss";
|
@import "./views/messages/_MStickerBody.pcss";
|
||||||
@import "./views/messages/_MTextBody.pcss";
|
@import "./views/messages/_MTextBody.pcss";
|
||||||
@import "./views/messages/_MVideoBody.pcss";
|
|
||||||
@import "./views/messages/_MediaBody.pcss";
|
@import "./views/messages/_MediaBody.pcss";
|
||||||
@import "./views/messages/_MessageActionBar.pcss";
|
@import "./views/messages/_MessageActionBar.pcss";
|
||||||
@import "./views/messages/_MjolnirBody.pcss";
|
@import "./views/messages/_MjolnirBody.pcss";
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
span.mx_MVideoBody {
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.mx_MVideoBody_container {
|
|
||||||
border-radius: var(--MBody-border-radius);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
video {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,15 +11,18 @@ import {
|
|||||||
DecryptionFailureBodyView,
|
DecryptionFailureBodyView,
|
||||||
FileBodyView,
|
FileBodyView,
|
||||||
RedactedBodyView,
|
RedactedBodyView,
|
||||||
|
VideoBodyView,
|
||||||
useCreateAutoDisposedViewModel,
|
useCreateAutoDisposedViewModel,
|
||||||
} from "@element-hq/web-shared-components";
|
} from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import { type IBodyProps } from "./IBodyProps";
|
import { type IBodyProps } from "./IBodyProps";
|
||||||
import RoomContext from "../../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||||
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
|
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
|
||||||
|
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||||
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel";
|
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel";
|
||||||
import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel";
|
import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel";
|
||||||
import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel";
|
import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel";
|
||||||
|
import { VideoBodyViewModel } from "../../../viewmodels/message-body/VideoBodyViewModel";
|
||||||
|
|
||||||
type MBodyComponent = React.ComponentType<IBodyProps>;
|
type MBodyComponent = React.ComponentType<IBodyProps>;
|
||||||
|
|
||||||
@@ -59,6 +62,78 @@ export function FileBodyFactory({
|
|||||||
return <FileBodyView vm={vm} refIFrame={refIFrame} refLink={refLink} className="mx_MFileBody" />;
|
return <FileBodyView vm={vm} refIFrame={refIFrame} refLink={refLink} className="mx_MFileBody" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function VideoBodyFactory({
|
||||||
|
mxEvent,
|
||||||
|
mediaEventHelper,
|
||||||
|
forExport,
|
||||||
|
inhibitInteraction,
|
||||||
|
}: Readonly<Pick<IBodyProps, "mxEvent" | "mediaEventHelper" | "forExport" | "inhibitInteraction">>): JSX.Element {
|
||||||
|
const { timelineRenderingType } = useContext(RoomContext);
|
||||||
|
const [mediaVisible, setMediaVisible] = useMediaVisible(mxEvent);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
const vm = useCreateAutoDisposedViewModel(
|
||||||
|
() =>
|
||||||
|
new VideoBodyViewModel({
|
||||||
|
mxEvent,
|
||||||
|
mediaEventHelper,
|
||||||
|
forExport,
|
||||||
|
inhibitInteraction,
|
||||||
|
mediaVisible,
|
||||||
|
onPreviewClick: (): void => setMediaVisible(true),
|
||||||
|
videoRef,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.loadInitialMediaIfVisible();
|
||||||
|
}, [vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setEvent(mxEvent, mediaEventHelper);
|
||||||
|
}, [mxEvent, mediaEventHelper, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setForExport(forExport);
|
||||||
|
}, [forExport, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setInhibitInteraction(inhibitInteraction);
|
||||||
|
}, [inhibitInteraction, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setMediaVisible(mediaVisible);
|
||||||
|
}, [mediaVisible, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setOnPreviewClick((): void => setMediaVisible(true));
|
||||||
|
}, [setMediaVisible, vm]);
|
||||||
|
|
||||||
|
const showFileBody =
|
||||||
|
!forExport &&
|
||||||
|
timelineRenderingType !== TimelineRenderingType.Room &&
|
||||||
|
timelineRenderingType !== TimelineRenderingType.Pinned &&
|
||||||
|
timelineRenderingType !== TimelineRenderingType.Search;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VideoBodyView
|
||||||
|
vm={vm}
|
||||||
|
className="mx_MVideoBody"
|
||||||
|
containerClassName="mx_MVideoBody_container"
|
||||||
|
videoRef={videoRef}
|
||||||
|
>
|
||||||
|
{showFileBody ? (
|
||||||
|
<FileBodyFactory
|
||||||
|
mxEvent={mxEvent}
|
||||||
|
mediaEventHelper={mediaEventHelper}
|
||||||
|
forExport={forExport}
|
||||||
|
showFileInfo={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</VideoBodyView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RedactedBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
|
export function RedactedBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
|
||||||
const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent }));
|
const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent }));
|
||||||
|
|
||||||
@@ -87,9 +162,11 @@ export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick<IBodyProps,
|
|||||||
return <DecryptionFailureBodyView vm={vm} ref={ref} className="mx_DecryptionFailureBody mx_EventTile_content" />;
|
return <DecryptionFailureBodyView vm={vm} ref={ref} className="mx_DecryptionFailureBody mx_EventTile_content" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message body factory registry.
|
// Message body factory registry for bodies that already route through view-model-backed wrappers.
|
||||||
// Start small: only m.file currently routes to the new FileBodyView path.
|
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([
|
||||||
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([[MsgType.File, FileBodyFactory]]);
|
[MsgType.File, FileBodyFactory],
|
||||||
|
[MsgType.Video, VideoBodyFactory],
|
||||||
|
]);
|
||||||
|
|
||||||
// Render a body using the picked factory.
|
// Render a body using the picked factory.
|
||||||
// Falls back to the provided factory when msgtype has no specific handler.
|
// Falls back to the provided factory when msgtype has no specific handler.
|
||||||
|
|||||||
@@ -1,353 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
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 React, { type ReactNode } from "react";
|
|
||||||
import { decode } from "blurhash";
|
|
||||||
import { type MediaEventContent } from "matrix-js-sdk/src/types";
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
|
||||||
import InlineSpinner from "../elements/InlineSpinner";
|
|
||||||
import { mediaFromContent } from "../../../customisations/Media";
|
|
||||||
import { BLURHASH_FIELD } from "../../../utils/image-media";
|
|
||||||
import { type IBodyProps } from "./IBodyProps";
|
|
||||||
import { type ImageSize, suggestedSize as suggestedVideoSize } from "../../../settings/enums/ImageSize";
|
|
||||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
|
||||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
|
||||||
import { HiddenMediaPlaceholder } from "./HiddenMediaPlaceholder";
|
|
||||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
|
||||||
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
|
|
||||||
|
|
||||||
interface IState {
|
|
||||||
decryptedUrl: string | null;
|
|
||||||
decryptedThumbnailUrl: string | null;
|
|
||||||
decryptedBlob: Blob | null;
|
|
||||||
error?: any;
|
|
||||||
fetchingData: boolean;
|
|
||||||
posterLoading: boolean;
|
|
||||||
blurhashUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IProps extends IBodyProps {
|
|
||||||
/**
|
|
||||||
* Should the media be behind a preview.
|
|
||||||
*/
|
|
||||||
mediaVisible: boolean;
|
|
||||||
/**
|
|
||||||
* Set the visibility of the media event.
|
|
||||||
* @param visible Should the event be visible.
|
|
||||||
*/
|
|
||||||
setMediaVisible: (visible: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MVideoBodyInner extends React.PureComponent<IProps, IState> {
|
|
||||||
public static contextType = RoomContext;
|
|
||||||
declare public context: React.ContextType<typeof RoomContext>;
|
|
||||||
|
|
||||||
private videoRef = React.createRef<HTMLVideoElement>();
|
|
||||||
private sizeWatcher?: string;
|
|
||||||
|
|
||||||
public state = {
|
|
||||||
fetchingData: false,
|
|
||||||
decryptedUrl: null,
|
|
||||||
decryptedThumbnailUrl: null,
|
|
||||||
decryptedBlob: null,
|
|
||||||
error: null,
|
|
||||||
posterLoading: false,
|
|
||||||
blurhashUrl: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
private onClick = (): void => {
|
|
||||||
this.props.setMediaVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
private getContentUrl(): string | undefined {
|
|
||||||
const content = this.props.mxEvent.getContent<MediaEventContent>();
|
|
||||||
// During export, the content url will point to the MSC, which will later point to a local url
|
|
||||||
if (this.props.forExport) return content.file?.url ?? content.url;
|
|
||||||
const media = mediaFromContent(content);
|
|
||||||
if (media.isEncrypted) {
|
|
||||||
return this.state.decryptedUrl ?? undefined;
|
|
||||||
} else {
|
|
||||||
return media.srcHttp ?? undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasContentUrl(): boolean {
|
|
||||||
const url = this.getContentUrl();
|
|
||||||
return !!url && !url.startsWith("data:");
|
|
||||||
}
|
|
||||||
|
|
||||||
private getThumbUrl(): string | null {
|
|
||||||
// there's no need of thumbnail when the content is local
|
|
||||||
if (this.props.forExport) return null;
|
|
||||||
|
|
||||||
const content = this.props.mxEvent.getContent<MediaEventContent>();
|
|
||||||
const media = mediaFromContent(content);
|
|
||||||
|
|
||||||
if (media.isEncrypted && this.state.decryptedThumbnailUrl) {
|
|
||||||
return this.state.decryptedThumbnailUrl;
|
|
||||||
} else if (this.state.posterLoading) {
|
|
||||||
return this.state.blurhashUrl;
|
|
||||||
} else if (media.hasThumbnail) {
|
|
||||||
return media.thumbnailHttp;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadBlurhash(): void {
|
|
||||||
const info = this.props.mxEvent.getContent()?.info;
|
|
||||||
if (!info[BLURHASH_FIELD]) return;
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
|
|
||||||
const { w: width, h: height } = suggestedVideoSize(SettingsStore.getValue("Images.size") as ImageSize, {
|
|
||||||
w: info.w,
|
|
||||||
h: info.h,
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
|
|
||||||
const pixels = decode(info[BLURHASH_FIELD], width, height);
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
const imgData = ctx.createImageData(width, height);
|
|
||||||
imgData.data.set(pixels);
|
|
||||||
ctx.putImageData(imgData, 0, 0);
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
blurhashUrl: canvas.toDataURL(),
|
|
||||||
posterLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = this.props.mxEvent.getContent<MediaEventContent>();
|
|
||||||
const media = mediaFromContent(content);
|
|
||||||
if (media.hasThumbnail) {
|
|
||||||
const image = new Image();
|
|
||||||
image.onload = () => {
|
|
||||||
this.setState({ posterLoading: false });
|
|
||||||
};
|
|
||||||
image.src = media.thumbnailHttp!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async downloadVideo(): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.loadBlurhash();
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Failed to load blurhash", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.mediaEventHelper?.media.isEncrypted && this.state.decryptedUrl === null) {
|
|
||||||
try {
|
|
||||||
const autoplay = SettingsStore.getValue("autoplayVideo") as boolean;
|
|
||||||
const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
|
|
||||||
if (autoplay) {
|
|
||||||
logger.log("Preloading video");
|
|
||||||
this.setState({
|
|
||||||
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
|
|
||||||
decryptedThumbnailUrl: thumbnailUrl,
|
|
||||||
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.log("NOT preloading video");
|
|
||||||
const content = this.props.mxEvent.getContent<MediaEventContent>();
|
|
||||||
|
|
||||||
let mimetype = content?.info?.mimetype;
|
|
||||||
|
|
||||||
// clobber quicktime muxed files to be considered MP4 so browsers
|
|
||||||
// are willing to play them
|
|
||||||
if (mimetype == "video/quicktime") {
|
|
||||||
mimetype = "video/mp4";
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
// For Chrome and Electron, we need to set some non-empty `src` to
|
|
||||||
// enable the play button. Firefox does not seem to care either
|
|
||||||
// way, so it's fine to do for all browsers.
|
|
||||||
decryptedUrl: `data:${mimetype},`,
|
|
||||||
decryptedThumbnailUrl: thumbnailUrl || `data:${mimetype},`,
|
|
||||||
decryptedBlob: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn("Unable to decrypt attachment: ", err);
|
|
||||||
// Set a placeholder image when we can't decrypt the image.
|
|
||||||
this.setState({
|
|
||||||
error: err,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async componentDidMount(): Promise<void> {
|
|
||||||
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
|
|
||||||
this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing
|
|
||||||
});
|
|
||||||
|
|
||||||
// Do not attempt to load the media if we do not want to show previews here.
|
|
||||||
if (this.props.mediaVisible) {
|
|
||||||
await this.downloadVideo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async componentDidUpdate(prevProps: Readonly<IProps>): Promise<void> {
|
|
||||||
if (!prevProps.mediaVisible && this.props.mediaVisible) {
|
|
||||||
await this.downloadVideo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
|
||||||
SettingsStore.unwatchSetting(this.sizeWatcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
private videoOnPlay = async (): Promise<void> => {
|
|
||||||
if (this.hasContentUrl() || this.state.fetchingData || this.state.error) {
|
|
||||||
// We have the file, we are fetching the file, or there is an error.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setState({
|
|
||||||
// To stop subsequent download attempts
|
|
||||||
fetchingData: true,
|
|
||||||
});
|
|
||||||
if (!this.props.mediaEventHelper!.media.isEncrypted) {
|
|
||||||
this.setState({
|
|
||||||
error: "No file given in content",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
decryptedUrl: await this.props.mediaEventHelper!.sourceUrl.value,
|
|
||||||
decryptedBlob: await this.props.mediaEventHelper!.sourceBlob.value,
|
|
||||||
fetchingData: false,
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (!this.videoRef.current) return;
|
|
||||||
this.videoRef.current.play();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
protected get showFileBody(): boolean {
|
|
||||||
return (
|
|
||||||
this.context.timelineRenderingType !== TimelineRenderingType.Room &&
|
|
||||||
this.context.timelineRenderingType !== TimelineRenderingType.Pinned &&
|
|
||||||
this.context.timelineRenderingType !== TimelineRenderingType.Search
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFileBody = (): ReactNode => {
|
|
||||||
if (this.props.forExport) return null;
|
|
||||||
return this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
|
|
||||||
};
|
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
|
||||||
const content = this.props.mxEvent.getContent();
|
|
||||||
const autoplay = !this.props.inhibitInteraction && SettingsStore.getValue("autoplayVideo");
|
|
||||||
|
|
||||||
let aspectRatio;
|
|
||||||
if (content.info?.w && content.info?.h) {
|
|
||||||
aspectRatio = `${content.info.w}/${content.info.h}`;
|
|
||||||
}
|
|
||||||
const { w: maxWidth, h: maxHeight } = suggestedVideoSize(SettingsStore.getValue("Images.size") as ImageSize, {
|
|
||||||
w: content.info?.w,
|
|
||||||
h: content.info?.h,
|
|
||||||
});
|
|
||||||
|
|
||||||
// HACK: This div fills out space while the video loads, to prevent scroll jumps
|
|
||||||
const spaceFiller = <div style={{ width: maxWidth, height: maxHeight }} />;
|
|
||||||
|
|
||||||
if (this.state.error !== null) {
|
|
||||||
return (
|
|
||||||
<MediaProcessingError className="mx_MVideoBody">
|
|
||||||
{_t("timeline|m.video|error_decrypting")}
|
|
||||||
</MediaProcessingError>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users may not even want to show a poster, so instead show a preview button.
|
|
||||||
if (!this.props.mediaVisible) {
|
|
||||||
return (
|
|
||||||
<span className="mx_MVideoBody">
|
|
||||||
<div
|
|
||||||
className="mx_MVideoBody_container"
|
|
||||||
style={{ width: maxWidth, height: maxHeight, aspectRatio }}
|
|
||||||
>
|
|
||||||
<HiddenMediaPlaceholder onClick={this.onClick}>
|
|
||||||
{_t("timeline|m.video|show_video")}
|
|
||||||
</HiddenMediaPlaceholder>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Important: If we aren't autoplaying and we haven't decrypted it yet, show a video with a poster.
|
|
||||||
if (!this.props.forExport && content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
|
|
||||||
// Need to decrypt the attachment
|
|
||||||
// The attachment is decrypted in componentDidMount.
|
|
||||||
// For now show a spinner.
|
|
||||||
return (
|
|
||||||
<span className="mx_MVideoBody">
|
|
||||||
<div className="mx_MVideoBody_container" style={{ maxWidth, maxHeight, aspectRatio }}>
|
|
||||||
<InlineSpinner />
|
|
||||||
</div>
|
|
||||||
{spaceFiller}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentUrl = this.getContentUrl();
|
|
||||||
const thumbUrl = this.getThumbUrl();
|
|
||||||
let poster: string | undefined;
|
|
||||||
let preload = "metadata";
|
|
||||||
if (content.info && thumbUrl) {
|
|
||||||
poster = thumbUrl;
|
|
||||||
preload = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileBody = this.getFileBody();
|
|
||||||
return (
|
|
||||||
<span className="mx_MVideoBody">
|
|
||||||
<div className="mx_MVideoBody_container" style={{ maxWidth, maxHeight, aspectRatio }}>
|
|
||||||
<video
|
|
||||||
className="mx_MVideoBody"
|
|
||||||
ref={this.videoRef}
|
|
||||||
src={contentUrl}
|
|
||||||
title={content.body}
|
|
||||||
controls={!this.props.inhibitInteraction}
|
|
||||||
// Disable downloading as it doesn't work with e2ee video,
|
|
||||||
// users should use the dedicated Download button in the Message Action Bar
|
|
||||||
controlsList="nodownload"
|
|
||||||
// The video uses a cross-origin request.
|
|
||||||
// Firefox explicitly bypasses services workers for crossorigin
|
|
||||||
// video elements without crossorigin attribute.
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
preload={preload}
|
|
||||||
muted={autoplay}
|
|
||||||
autoPlay={autoplay}
|
|
||||||
poster={poster}
|
|
||||||
onPlay={this.videoOnPlay}
|
|
||||||
/>
|
|
||||||
{spaceFiller}
|
|
||||||
</div>
|
|
||||||
{fileBody}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap MVideoBody component so we can use a hook here.
|
|
||||||
const MVideoBody: React.FC<IBodyProps> = (props) => {
|
|
||||||
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
|
|
||||||
return <MVideoBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MVideoBody;
|
|
||||||
@@ -28,14 +28,19 @@ import { type IBodyProps } from "./IBodyProps";
|
|||||||
import TextualBody from "./TextualBody";
|
import TextualBody from "./TextualBody";
|
||||||
import MImageBody from "./MImageBody";
|
import MImageBody from "./MImageBody";
|
||||||
import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
|
import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
|
||||||
import MVideoBody from "./MVideoBody";
|
|
||||||
import MStickerBody from "./MStickerBody";
|
import MStickerBody from "./MStickerBody";
|
||||||
import MPollBody from "./MPollBody";
|
import MPollBody from "./MPollBody";
|
||||||
import MLocationBody from "./MLocationBody";
|
import MLocationBody from "./MLocationBody";
|
||||||
import MjolnirBody from "./MjolnirBody";
|
import MjolnirBody from "./MjolnirBody";
|
||||||
import MBeaconBody from "./MBeaconBody";
|
import MBeaconBody from "./MBeaconBody";
|
||||||
import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile";
|
import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile";
|
||||||
import { DecryptionFailureBodyFactory, FileBodyFactory, RedactedBodyFactory, renderMBody } from "./MBodyFactory";
|
import {
|
||||||
|
DecryptionFailureBodyFactory,
|
||||||
|
FileBodyFactory,
|
||||||
|
RedactedBodyFactory,
|
||||||
|
VideoBodyFactory,
|
||||||
|
renderMBody,
|
||||||
|
} from "./MBodyFactory";
|
||||||
|
|
||||||
// onMessageAllowed is handled internally
|
// onMessageAllowed is handled internally
|
||||||
interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> {
|
interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> {
|
||||||
@@ -65,7 +70,7 @@ const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
|
|||||||
[MsgType.Image, MImageBody],
|
[MsgType.Image, MImageBody],
|
||||||
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
|
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
|
||||||
[MsgType.Audio, MVoiceOrAudioBody],
|
[MsgType.Audio, MVoiceOrAudioBody],
|
||||||
[MsgType.Video, MVideoBody],
|
[MsgType.Video, VideoBodyFactory],
|
||||||
]);
|
]);
|
||||||
const baseEvTypes = new Map<string, React.ComponentType<IBodyProps>>([
|
const baseEvTypes = new Map<string, React.ComponentType<IBodyProps>>([
|
||||||
[EventType.Sticker, MStickerBody],
|
[EventType.Sticker, MStickerBody],
|
||||||
@@ -260,7 +265,8 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
((BodyType === MImageBody || BodyType == MVideoBody) && !this.validateImageOrVideoMimetype(content)) ||
|
((BodyType === MImageBody || BodyType === VideoBodyFactory) &&
|
||||||
|
!this.validateImageOrVideoMimetype(content)) ||
|
||||||
(BodyType === MStickerBody && !this.validateStickerMimetype(content))
|
(BodyType === MStickerBody && !this.validateStickerMimetype(content))
|
||||||
) {
|
) {
|
||||||
BodyType = this.bodyTypes.get(MsgType.File)!;
|
BodyType = this.bodyTypes.get(MsgType.File)!;
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
/*
|
||||||
|
* 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 { decode } from "blurhash";
|
||||||
|
import { type RefObject } from "react";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { type MediaEventContent, type VideoInfo } from "matrix-js-sdk/src/types";
|
||||||
|
import {
|
||||||
|
BaseViewModel,
|
||||||
|
VideoBodyViewState,
|
||||||
|
type VideoBodyViewModel as VideoBodyViewModelInterface,
|
||||||
|
type VideoBodyViewSnapshot,
|
||||||
|
} from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import { _t } from "../../languageHandler";
|
||||||
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
|
import { mediaFromContent } from "../../customisations/Media";
|
||||||
|
import { BLURHASH_FIELD } from "../../utils/image-media";
|
||||||
|
import { type ImageSize, suggestedSize as suggestedVideoSize } from "../../settings/enums/ImageSize";
|
||||||
|
import { type MediaEventHelper } from "../../utils/MediaEventHelper";
|
||||||
|
|
||||||
|
export interface VideoBodyViewModelProps {
|
||||||
|
/**
|
||||||
|
* Video event being rendered.
|
||||||
|
*/
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/**
|
||||||
|
* Helper for resolving encrypted and unencrypted media sources.
|
||||||
|
*/
|
||||||
|
mediaEventHelper?: MediaEventHelper;
|
||||||
|
/**
|
||||||
|
* Whether the video is being rendered for export instead of live playback.
|
||||||
|
*/
|
||||||
|
forExport?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether playback controls and autoplay should be disabled.
|
||||||
|
*/
|
||||||
|
inhibitInteraction?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the media should currently be shown instead of the preview button.
|
||||||
|
*/
|
||||||
|
mediaVisible: boolean;
|
||||||
|
/**
|
||||||
|
* Callback invoked when the hidden-media preview is revealed.
|
||||||
|
*/
|
||||||
|
onPreviewClick?: () => void;
|
||||||
|
/**
|
||||||
|
* Ref to the underlying video element used for replay after lazy decryption.
|
||||||
|
*/
|
||||||
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalState {
|
||||||
|
/**
|
||||||
|
* Decrypted playable media URL for encrypted videos.
|
||||||
|
*/
|
||||||
|
decryptedUrl: string | null;
|
||||||
|
/**
|
||||||
|
* Decrypted thumbnail URL for encrypted videos.
|
||||||
|
*/
|
||||||
|
decryptedThumbnailUrl: string | null;
|
||||||
|
/**
|
||||||
|
* Decrypted media blob cached for download or replay.
|
||||||
|
*/
|
||||||
|
decryptedBlob: Blob | null;
|
||||||
|
/**
|
||||||
|
* Last media-processing error, if any.
|
||||||
|
*/
|
||||||
|
error: unknown | null;
|
||||||
|
/**
|
||||||
|
* Whether an on-demand media fetch is in progress.
|
||||||
|
*/
|
||||||
|
fetchingData: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the blurhash poster is being shown while the real poster loads.
|
||||||
|
*/
|
||||||
|
posterLoading: boolean;
|
||||||
|
/**
|
||||||
|
* Data URL generated from the blurhash placeholder.
|
||||||
|
*/
|
||||||
|
blurhashUrl: string | null;
|
||||||
|
/**
|
||||||
|
* Current media sizing preference from settings.
|
||||||
|
*/
|
||||||
|
imageSize: ImageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoInfoWithBlurhash = VideoInfo & {
|
||||||
|
[BLURHASH_FIELD]?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View model for the video message body, encapsulating media-loading and playback state.
|
||||||
|
*/
|
||||||
|
export class VideoBodyViewModel
|
||||||
|
extends BaseViewModel<VideoBodyViewSnapshot, VideoBodyViewModelProps>
|
||||||
|
implements VideoBodyViewModelInterface
|
||||||
|
{
|
||||||
|
private state: InternalState;
|
||||||
|
|
||||||
|
public constructor(props: VideoBodyViewModelProps) {
|
||||||
|
const initialState = VideoBodyViewModel.createInitialState();
|
||||||
|
super(props, VideoBodyViewModel.computeSnapshot(props, initialState));
|
||||||
|
this.state = initialState;
|
||||||
|
|
||||||
|
const imageSizeWatcherRef = SettingsStore.watchSetting("Images.size", null, (_s, _r, _l, _nvl, value) => {
|
||||||
|
this.setImageSize(value as ImageSize);
|
||||||
|
});
|
||||||
|
this.disposables.track(() => SettingsStore.unwatchSetting(imageSizeWatcherRef));
|
||||||
|
}
|
||||||
|
|
||||||
|
public loadInitialMediaIfVisible(): void {
|
||||||
|
if (this.props.mediaVisible) {
|
||||||
|
void this.downloadVideo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createInitialState(): InternalState {
|
||||||
|
return {
|
||||||
|
fetchingData: false,
|
||||||
|
decryptedUrl: null,
|
||||||
|
decryptedThumbnailUrl: null,
|
||||||
|
decryptedBlob: null,
|
||||||
|
error: null,
|
||||||
|
posterLoading: false,
|
||||||
|
blurhashUrl: null,
|
||||||
|
imageSize: SettingsStore.getValue("Images.size") as ImageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the aspect ratio for the video frame from the event metadata, when available.
|
||||||
|
*/
|
||||||
|
private static getAspectRatio(mxEvent: MatrixEvent): string | undefined {
|
||||||
|
const { w, h } = (mxEvent.getContent<MediaEventContent>().info as VideoInfoWithBlurhash | undefined) ?? {};
|
||||||
|
if (!w || !h) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${w}/${h}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the rendered video dimensions from the event metadata and current image-size setting.
|
||||||
|
*/
|
||||||
|
private static getDimensions(mxEvent: MatrixEvent, imageSize: ImageSize): Required<{ w?: number; h?: number }> {
|
||||||
|
const { w, h } = (mxEvent.getContent<MediaEventContent>().info as VideoInfoWithBlurhash | undefined) ?? {};
|
||||||
|
return suggestedVideoSize(imageSize, { w, h });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current playable video source URL for the event.
|
||||||
|
*/
|
||||||
|
private static getContentUrl(props: VideoBodyViewModelProps, state: InternalState): string | undefined {
|
||||||
|
const content = props.mxEvent.getContent<MediaEventContent>();
|
||||||
|
if (props.forExport) {
|
||||||
|
return content.file?.url ?? content.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = mediaFromContent(content);
|
||||||
|
if (media.isEncrypted) {
|
||||||
|
return state.decryptedUrl ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return media.srcHttp ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the best thumbnail or poster URL for the current video state.
|
||||||
|
*/
|
||||||
|
private static getThumbnailUrl(props: VideoBodyViewModelProps, state: InternalState): string | null {
|
||||||
|
if (props.forExport) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = props.mxEvent.getContent<MediaEventContent>();
|
||||||
|
const media = mediaFromContent(content);
|
||||||
|
|
||||||
|
if (media.isEncrypted && state.decryptedThumbnailUrl) {
|
||||||
|
return state.decryptedThumbnailUrl;
|
||||||
|
}
|
||||||
|
if (state.posterLoading) {
|
||||||
|
return state.blurhashUrl;
|
||||||
|
}
|
||||||
|
if (media.hasThumbnail) {
|
||||||
|
return media.thumbnailHttp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static computeSnapshot(props: VideoBodyViewModelProps, state: InternalState): VideoBodyViewSnapshot {
|
||||||
|
const content = props.mxEvent.getContent<MediaEventContent>();
|
||||||
|
const autoplay = !props.inhibitInteraction && (SettingsStore.getValue("autoplayVideo") as boolean);
|
||||||
|
const aspectRatio = VideoBodyViewModel.getAspectRatio(props.mxEvent);
|
||||||
|
const { w: maxWidth, h: maxHeight } = VideoBodyViewModel.getDimensions(props.mxEvent, state.imageSize);
|
||||||
|
|
||||||
|
if (state.error !== null) {
|
||||||
|
return {
|
||||||
|
state: VideoBodyViewState.ERROR,
|
||||||
|
errorLabel: _t("timeline|m.video|error_decrypting"),
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
aspectRatio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.mediaVisible) {
|
||||||
|
return {
|
||||||
|
state: VideoBodyViewState.HIDDEN,
|
||||||
|
hiddenButtonLabel: _t("timeline|m.video|show_video"),
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
aspectRatio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.forExport && content.file !== undefined && state.decryptedUrl === null && autoplay) {
|
||||||
|
return {
|
||||||
|
state: VideoBodyViewState.LOADING,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
aspectRatio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailUrl = VideoBodyViewModel.getThumbnailUrl(props, state);
|
||||||
|
let preload: VideoBodyViewSnapshot["preload"] = "metadata";
|
||||||
|
let poster: string | undefined;
|
||||||
|
if (content.info && thumbnailUrl) {
|
||||||
|
preload = "none";
|
||||||
|
poster = thumbnailUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: VideoBodyViewState.READY,
|
||||||
|
videoLabel: content.body,
|
||||||
|
videoTitle: content.body,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
aspectRatio,
|
||||||
|
src: VideoBodyViewModel.getContentUrl(props, state),
|
||||||
|
poster,
|
||||||
|
preload,
|
||||||
|
controls: !props.inhibitInteraction,
|
||||||
|
muted: autoplay,
|
||||||
|
autoPlay: autoplay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSnapshotFromState(): void {
|
||||||
|
this.snapshot.set(VideoBodyViewModel.computeSnapshot(this.props, this.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasContentUrl(): boolean {
|
||||||
|
const url = VideoBodyViewModel.getContentUrl(this.props, this.state);
|
||||||
|
return !!url && !url.startsWith("data:");
|
||||||
|
}
|
||||||
|
|
||||||
|
private setImageSize(imageSize: ImageSize): void {
|
||||||
|
if (this.state.imageSize === imageSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
imageSize,
|
||||||
|
};
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetMediaState(): void {
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
decryptedUrl: null,
|
||||||
|
decryptedThumbnailUrl: null,
|
||||||
|
decryptedBlob: null,
|
||||||
|
error: null,
|
||||||
|
fetchingData: false,
|
||||||
|
posterLoading: false,
|
||||||
|
blurhashUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadBlurhash(): void {
|
||||||
|
const info = this.props.mxEvent.getContent<MediaEventContent>().info as VideoInfoWithBlurhash | undefined;
|
||||||
|
const blurhash = info?.[BLURHASH_FIELD];
|
||||||
|
if (!blurhash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const { w: width, h: height } = VideoBodyViewModel.getDimensions(this.props.mxEvent, this.state.imageSize);
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const pixels = decode(blurhash, width, height);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgData = ctx.createImageData(width, height);
|
||||||
|
imgData.data.set(pixels);
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
blurhashUrl: canvas.toDataURL(),
|
||||||
|
posterLoading: true,
|
||||||
|
};
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
|
||||||
|
const media = mediaFromContent(this.props.mxEvent.getContent<MediaEventContent>());
|
||||||
|
if (!media.hasThumbnail || !media.thumbnailHttp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEvent = this.props.mxEvent;
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = (): void => {
|
||||||
|
if (this.isDisposed || currentEvent !== this.props.mxEvent || !this.state.posterLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
posterLoading: false,
|
||||||
|
};
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
};
|
||||||
|
image.src = media.thumbnailHttp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadVideo(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.loadBlurhash();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to load blurhash", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.props.mediaEventHelper?.media.isEncrypted || this.state.decryptedUrl !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEvent = this.props.mxEvent;
|
||||||
|
const currentHelper = this.props.mediaEventHelper;
|
||||||
|
try {
|
||||||
|
const autoplay = !this.props.inhibitInteraction && (SettingsStore.getValue("autoplayVideo") as boolean);
|
||||||
|
const thumbnailUrl = await currentHelper.thumbnailUrl.value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.isDisposed ||
|
||||||
|
currentEvent !== this.props.mxEvent ||
|
||||||
|
currentHelper !== this.props.mediaEventHelper
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoplay) {
|
||||||
|
logger.log("Preloading video");
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
decryptedUrl: await currentHelper.sourceUrl.value,
|
||||||
|
decryptedThumbnailUrl: thumbnailUrl,
|
||||||
|
decryptedBlob: await currentHelper.sourceBlob.value,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.log("NOT preloading video");
|
||||||
|
const content = currentEvent.getContent<MediaEventContent>();
|
||||||
|
let mimetype = content.info?.mimetype ?? "application/octet-stream";
|
||||||
|
if (mimetype === "video/quicktime") {
|
||||||
|
mimetype = "video/mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
decryptedUrl: `data:${mimetype},`,
|
||||||
|
decryptedThumbnailUrl: thumbnailUrl || `data:${mimetype},`,
|
||||||
|
decryptedBlob: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
this.isDisposed ||
|
||||||
|
currentEvent !== this.props.mxEvent ||
|
||||||
|
currentHelper !== this.props.mediaEventHelper
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("Unable to decrypt attachment: ", error);
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEvent(mxEvent: MatrixEvent, mediaEventHelper?: MediaEventHelper): void {
|
||||||
|
if (this.props.mxEvent === mxEvent && this.props.mediaEventHelper === mediaEventHelper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props = {
|
||||||
|
...this.props,
|
||||||
|
mxEvent,
|
||||||
|
mediaEventHelper,
|
||||||
|
};
|
||||||
|
this.resetMediaState();
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
|
||||||
|
if (this.props.mediaVisible) {
|
||||||
|
void this.downloadVideo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public setForExport(forExport?: boolean): void {
|
||||||
|
if (this.props.forExport === forExport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props = {
|
||||||
|
...this.props,
|
||||||
|
forExport,
|
||||||
|
};
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setInhibitInteraction(inhibitInteraction?: boolean): void {
|
||||||
|
if (this.props.inhibitInteraction === inhibitInteraction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props = {
|
||||||
|
...this.props,
|
||||||
|
inhibitInteraction,
|
||||||
|
};
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setMediaVisible(mediaVisible: boolean): void {
|
||||||
|
if (this.props.mediaVisible === mediaVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props = {
|
||||||
|
...this.props,
|
||||||
|
mediaVisible,
|
||||||
|
};
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
|
||||||
|
if (mediaVisible) {
|
||||||
|
void this.downloadVideo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public setOnPreviewClick(onPreviewClick?: () => void): void {
|
||||||
|
if (this.props.onPreviewClick === onPreviewClick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props = {
|
||||||
|
...this.props,
|
||||||
|
onPreviewClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public onPreviewClick = (): void => {
|
||||||
|
this.props.onPreviewClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
public onPlay = async (): Promise<void> => {
|
||||||
|
if (this.hasContentUrl() || this.state.fetchingData || this.state.error !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
fetchingData: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.props.mediaEventHelper?.media.isEncrypted) {
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
error: "No file given in content",
|
||||||
|
fetchingData: false,
|
||||||
|
};
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEvent = this.props.mxEvent;
|
||||||
|
const currentHelper = this.props.mediaEventHelper;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decryptedUrl = await currentHelper.sourceUrl.value;
|
||||||
|
const decryptedBlob = await currentHelper.sourceBlob.value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.isDisposed ||
|
||||||
|
currentEvent !== this.props.mxEvent ||
|
||||||
|
currentHelper !== this.props.mediaEventHelper
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
decryptedUrl,
|
||||||
|
decryptedBlob,
|
||||||
|
fetchingData: false,
|
||||||
|
};
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
this.props.videoRef.current?.play();
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
this.isDisposed ||
|
||||||
|
currentEvent !== this.props.mxEvent ||
|
||||||
|
currentHelper !== this.props.mediaEventHelper
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("Unable to decrypt attachment: ", error);
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
error,
|
||||||
|
fetchingData: false,
|
||||||
|
};
|
||||||
|
this.updateSnapshotFromState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
} from "../../../../test-utils";
|
} from "../../../../test-utils";
|
||||||
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
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 { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts";
|
||||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||||
|
|
||||||
@@ -90,10 +94,14 @@ describe("MBodyFactory", () => {
|
|||||||
expect(container).toMatchSnapshot();
|
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();
|
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", () => {
|
it("returns null when msgtype is missing", () => {
|
||||||
expect(renderMBody({ ...props, mxEvent: mkEvent() })).toBeNull();
|
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",
|
"renderMBody fallback shows %s generic placeholder when showFileInfo is true",
|
||||||
async (msgtype) => {
|
async (msgtype) => {
|
||||||
const mediaEvent = new MatrixEvent({
|
const mediaEvent = new MatrixEvent({
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
mockClientMethodsUser,
|
mockClientMethodsUser,
|
||||||
withClientContextRenderOptions,
|
withClientContextRenderOptions,
|
||||||
} from "../../../../test-utils";
|
} 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 type { IBodyProps } from "../../../../../src/components/views/messages/IBodyProps";
|
||||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||||
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
|
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
|
||||||
@@ -33,7 +33,7 @@ jest.mock("matrix-encrypt-attachment", () => ({
|
|||||||
decryptAttachment: jest.fn(),
|
decryptAttachment: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("MVideoBody", () => {
|
describe("VideoBodyFactory", () => {
|
||||||
const ourUserId = "@user:server";
|
const ourUserId = "@user:server";
|
||||||
const senderUserId = "@other_use:server";
|
const senderUserId = "@other_use:server";
|
||||||
const deviceId = "DEADB33F";
|
const deviceId = "DEADB33F";
|
||||||
@@ -122,23 +122,25 @@ describe("MVideoBody", () => {
|
|||||||
mediaEventHelper: { media: { isEncrypted: false } } as MediaEventHelper,
|
mediaEventHelper: { media: { isEncrypted: false } } as MediaEventHelper,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { asFragment } = render(
|
const { container } = render(
|
||||||
<MatrixClientContext.Provider value={cli}>
|
<MatrixClientContext.Provider value={cli}>
|
||||||
<MVideoBody {...defaultProps} />
|
<VideoBodyFactory {...defaultProps} />
|
||||||
</MatrixClientContext.Provider>,
|
</MatrixClientContext.Provider>,
|
||||||
withClientContextRenderOptions(cli),
|
withClientContextRenderOptions(cli),
|
||||||
);
|
);
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(container.querySelector("video")).not.toBeNull();
|
||||||
// If we get here, we did not crash.
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show poster for encrypted media before downloading it", async () => {
|
it("should show poster for encrypted media before downloading it", async () => {
|
||||||
fetchMock.getOnce(thumbUrl, { status: 200 });
|
fetchMock.getOnce(thumbUrl, { status: 200 });
|
||||||
const { asFragment } = render(
|
render(
|
||||||
<MVideoBody mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />,
|
<VideoBodyFactory
|
||||||
|
mxEvent={encryptedMediaEvent}
|
||||||
|
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||||
|
/>,
|
||||||
withClientContextRenderOptions(cli),
|
withClientContextRenderOptions(cli),
|
||||||
);
|
);
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(await screen.findByLabelText("alt for a test video")).toHaveAttribute("poster");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("with video previews/thumbnails disabled", () => {
|
describe("with video previews/thumbnails disabled", () => {
|
||||||
@@ -161,7 +163,7 @@ describe("MVideoBody", () => {
|
|||||||
fetchMock.getOnce(thumbUrl, { status: 200 });
|
fetchMock.getOnce(thumbUrl, { status: 200 });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MVideoBody
|
<VideoBodyFactory
|
||||||
mxEvent={encryptedMediaEvent}
|
mxEvent={encryptedMediaEvent}
|
||||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||||
/>,
|
/>,
|
||||||
@@ -177,7 +179,7 @@ describe("MVideoBody", () => {
|
|||||||
fetchMock.getOnce(thumbUrl, { status: 200 });
|
fetchMock.getOnce(thumbUrl, { status: 200 });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MVideoBody
|
<VideoBodyFactory
|
||||||
mxEvent={encryptedMediaEvent}
|
mxEvent={encryptedMediaEvent}
|
||||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||||
/>,
|
/>,
|
||||||
@@ -189,6 +191,7 @@ describe("MVideoBody", () => {
|
|||||||
expect(placeholderButton).toBeInTheDocument();
|
expect(placeholderButton).toBeInTheDocument();
|
||||||
fireEvent.click(placeholderButton);
|
fireEvent.click(placeholderButton);
|
||||||
|
|
||||||
|
await screen.findByLabelText("alt for a test video");
|
||||||
expect(fetchMock).toHaveFetched(thumbUrl);
|
expect(fetchMock).toHaveFetched(thumbUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,16 +217,16 @@ describe("MVideoBody", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { asFragment } = render(
|
render(
|
||||||
<MVideoBody
|
<VideoBodyFactory
|
||||||
mxEvent={ourEncryptedMediaEvent}
|
mxEvent={ourEncryptedMediaEvent}
|
||||||
mediaEventHelper={new MediaEventHelper(ourEncryptedMediaEvent)}
|
mediaEventHelper={new MediaEventHelper(ourEncryptedMediaEvent)}
|
||||||
/>,
|
/>,
|
||||||
withClientContextRenderOptions(cli),
|
withClientContextRenderOptions(cli),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText("alt for a test video")).toBeInTheDocument();
|
||||||
expect(fetchMock).toHaveFetched(thumbUrl);
|
expect(fetchMock).toHaveFetched(thumbUrl);
|
||||||
expect(asFragment()).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,16 +29,12 @@ jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({
|
|||||||
default: () => <div data-testid="image-body" />,
|
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", () => ({
|
jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
|
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
|
||||||
FileBodyFactory: () => <div data-testid="file-body" />,
|
FileBodyFactory: () => <div data-testid="file-body" />,
|
||||||
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
|
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
|
||||||
|
VideoBodyFactory: () => <video data-testid="video-body" />,
|
||||||
renderMBody: () => <div data-testid="file-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" />,
|
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", () => ({
|
jest.mock("../../../../../src/components/views/messages/MStickerBody", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: () => <div data-testid="sticker-body" />,
|
default: () => <div data-testid="sticker-body" />,
|
||||||
@@ -164,11 +165,11 @@ describe("MessageEvent", () => {
|
|||||||
result.getByTestId("textual-body");
|
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);
|
event = createEvent("video/mp4", "video.mp4", MsgType.Video);
|
||||||
result = renderMessageEvent();
|
result = renderMessageEvent();
|
||||||
mockMedia();
|
mockMedia();
|
||||||
result.getByTestId("video-body");
|
expect(result.container.querySelector("video")).not.toBeNull();
|
||||||
result.getByTestId("textual-body");
|
result.getByTestId("textual-body");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
-38
@@ -79,44 +79,6 @@ exports[`MBodyFactory renderMBody fallback shows m.file generic placeholder when
|
|||||||
</div>
|
</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`] = `
|
exports[`MBodyFactory renderMBody renders download button for m.file in file rendering type 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<span
|
<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,24 @@ vis.setup({
|
|||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* Mask spinner for video overlay during screenshot generation on playwright tests.
|
||||||
|
*/
|
||||||
|
[data-video-body-mask-target] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
[data-video-body-mask-target]::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 50%;
|
||||||
|
inset-block-start: 50%;
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ff4fcf;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
/* Hide all storybook elements */
|
/* Hide all storybook elements */
|
||||||
.sb-wrapper {
|
.sb-wrapper {
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -16,6 +16,7 @@ export * from "./event-tiles/UrlPreviewGroupView";
|
|||||||
export * from "./message-body/EventContentBody";
|
export * from "./message-body/EventContentBody";
|
||||||
export * from "./message-body/RedactedBodyView";
|
export * from "./message-body/RedactedBodyView";
|
||||||
export * from "./message-body/FileBodyView";
|
export * from "./message-body/FileBodyView";
|
||||||
|
export * from "./room/timeline/event-tile/body/MVideoBodyView";
|
||||||
export * from "./core/pill-input/Pill";
|
export * from "./core/pill-input/Pill";
|
||||||
export * from "./core/pill-input/PillInput";
|
export * from "./core/pill-input/PillInput";
|
||||||
export * from "./room/RoomStatusBar";
|
export * from "./room/RoomStatusBar";
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
.root {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--MBody-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenButton {
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenButtonContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenButtonContent > svg {
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--cpd-space-1x);
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* 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 React, { type ReactNode } from "react";
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import posterImage from "../../../../../../static/element.png";
|
||||||
|
import {
|
||||||
|
VideoBodyView,
|
||||||
|
VideoBodyViewState,
|
||||||
|
type VideoBodyViewActions,
|
||||||
|
type VideoBodyViewSnapshot,
|
||||||
|
} from "./VideoBodyView";
|
||||||
|
import { useMockedViewModel } from "../../../../../core/viewmodel/useMockedViewModel";
|
||||||
|
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
|
||||||
|
|
||||||
|
const demoVideo = new URL("../../../../../../static/videoBodyDemo.webm", import.meta.url).href;
|
||||||
|
|
||||||
|
type VideoBodyViewProps = VideoBodyViewSnapshot &
|
||||||
|
VideoBodyViewActions & {
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoBodyViewWrapperImpl = ({
|
||||||
|
onPreviewClick,
|
||||||
|
onPlay,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...snapshotProps
|
||||||
|
}: VideoBodyViewProps): ReactNode => {
|
||||||
|
const vm = useMockedViewModel(snapshotProps, { onPreviewClick, onPlay });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VideoBodyView vm={vm} className={className}>
|
||||||
|
{children}
|
||||||
|
</VideoBodyView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoBodyViewWrapper = withViewDocs(VideoBodyViewWrapperImpl, VideoBodyView);
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "MessageBody/VideoBodyView",
|
||||||
|
component: VideoBodyViewWrapper,
|
||||||
|
tags: ["autodocs"],
|
||||||
|
argTypes: {
|
||||||
|
state: {
|
||||||
|
options: Object.entries(VideoBodyViewState)
|
||||||
|
.filter(([key, value]) => key === value)
|
||||||
|
.map(([key]) => key),
|
||||||
|
control: { type: "select" },
|
||||||
|
},
|
||||||
|
className: { control: "text" },
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
state: VideoBodyViewState.READY,
|
||||||
|
videoLabel: "Product demo video",
|
||||||
|
hiddenButtonLabel: "Show video",
|
||||||
|
errorLabel: "Error decrypting video",
|
||||||
|
maxWidth: 320,
|
||||||
|
maxHeight: 180,
|
||||||
|
aspectRatio: "16/9",
|
||||||
|
src: demoVideo,
|
||||||
|
poster: posterImage,
|
||||||
|
preload: "none",
|
||||||
|
controls: true,
|
||||||
|
muted: false,
|
||||||
|
autoPlay: false,
|
||||||
|
className: undefined,
|
||||||
|
children: <div>File body slot</div>,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof VideoBodyViewWrapper>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Ready: Story = {};
|
||||||
|
|
||||||
|
export const Hidden: Story = {
|
||||||
|
args: {
|
||||||
|
state: VideoBodyViewState.HIDDEN,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
args: {
|
||||||
|
state: VideoBodyViewState.LOADING,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ErrorState: Story = {
|
||||||
|
args: {
|
||||||
|
state: VideoBodyViewState.ERROR,
|
||||||
|
},
|
||||||
|
};
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
* 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 React from "react";
|
||||||
|
import { composeStories } from "@storybook/react-vite";
|
||||||
|
import { fireEvent, render, screen } from "@test-utils";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { MockViewModel } from "../../../../../core/viewmodel/MockViewModel";
|
||||||
|
import * as stories from "./VideoBodyView.stories";
|
||||||
|
import {
|
||||||
|
VideoBodyView,
|
||||||
|
VideoBodyViewState,
|
||||||
|
type VideoBodyViewActions,
|
||||||
|
type VideoBodyViewSnapshot,
|
||||||
|
} from "./VideoBodyView";
|
||||||
|
|
||||||
|
const { Ready, Hidden, ErrorState } = composeStories(stories);
|
||||||
|
|
||||||
|
class TestVideoBodyViewModel extends MockViewModel<VideoBodyViewSnapshot> implements VideoBodyViewActions {
|
||||||
|
public onPreviewClick?: VideoBodyViewActions["onPreviewClick"];
|
||||||
|
public onPlay?: VideoBodyViewActions["onPlay"];
|
||||||
|
|
||||||
|
public constructor(snapshot: VideoBodyViewSnapshot, actions: VideoBodyViewActions = {}) {
|
||||||
|
super(snapshot);
|
||||||
|
this.onPreviewClick = actions.onPreviewClick;
|
||||||
|
this.onPlay = actions.onPlay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VideoBodyView", () => {
|
||||||
|
it.each([
|
||||||
|
["ready", Ready],
|
||||||
|
["hidden", Hidden],
|
||||||
|
["error", ErrorState],
|
||||||
|
])("matches snapshot for %s story", (_name, Story) => {
|
||||||
|
const { container } = render(<Story />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the hidden preview button and wires the click handler", () => {
|
||||||
|
const onPreviewClick = vi.fn();
|
||||||
|
const vm = new TestVideoBodyViewModel(
|
||||||
|
{
|
||||||
|
state: VideoBodyViewState.HIDDEN,
|
||||||
|
hiddenButtonLabel: "Show video",
|
||||||
|
maxWidth: 320,
|
||||||
|
maxHeight: 180,
|
||||||
|
aspectRatio: "16/9",
|
||||||
|
},
|
||||||
|
{ onPreviewClick },
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<VideoBodyView vm={vm} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Show video" }));
|
||||||
|
expect(onPreviewClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a loading spinner while the media is being prepared", () => {
|
||||||
|
const vm = new TestVideoBodyViewModel({
|
||||||
|
state: VideoBodyViewState.LOADING,
|
||||||
|
maxWidth: 320,
|
||||||
|
maxHeight: 180,
|
||||||
|
aspectRatio: "16/9",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<VideoBodyView vm={vm} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an error message when media processing fails", () => {
|
||||||
|
const vm = new TestVideoBodyViewModel({
|
||||||
|
state: VideoBodyViewState.ERROR,
|
||||||
|
errorLabel: "Error decrypting video",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<VideoBodyView vm={vm} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Error decrypting video")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a video element with the expected attributes and file body content", () => {
|
||||||
|
const onPlay = vi.fn();
|
||||||
|
const vm = new TestVideoBodyViewModel(
|
||||||
|
{
|
||||||
|
state: VideoBodyViewState.READY,
|
||||||
|
videoLabel: "Product demo video",
|
||||||
|
maxWidth: 320,
|
||||||
|
maxHeight: 180,
|
||||||
|
aspectRatio: "16/9",
|
||||||
|
src: "https://example.org/demo.mp4",
|
||||||
|
poster: "https://example.org/demo-poster.jpg",
|
||||||
|
preload: "none",
|
||||||
|
controls: true,
|
||||||
|
muted: true,
|
||||||
|
autoPlay: true,
|
||||||
|
},
|
||||||
|
{ onPlay },
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoRef = React.createRef<HTMLVideoElement>();
|
||||||
|
render(
|
||||||
|
<VideoBodyView vm={vm} videoRef={videoRef}>
|
||||||
|
<div>File body slot</div>
|
||||||
|
</VideoBodyView>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const video = screen.getByLabelText("Product demo video") as HTMLVideoElement;
|
||||||
|
expect(video).toHaveAttribute("src", "https://example.org/demo.mp4");
|
||||||
|
expect(video).toHaveAttribute("poster", "https://example.org/demo-poster.jpg");
|
||||||
|
expect(video).toHaveAttribute("preload", "none");
|
||||||
|
expect(video).toHaveAttribute("controlslist", "nodownload");
|
||||||
|
expect(video).toHaveAttribute("crossorigin", "anonymous");
|
||||||
|
expect(video.muted).toBe(true);
|
||||||
|
expect(video.autoplay).toBe(true);
|
||||||
|
expect(videoRef.current).toBe(video);
|
||||||
|
expect(screen.getByText("File body slot")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.play(video);
|
||||||
|
expect(onPlay).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+227
@@ -0,0 +1,227 @@
|
|||||||
|
/*
|
||||||
|
* 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 React, {
|
||||||
|
type CSSProperties,
|
||||||
|
type JSX,
|
||||||
|
type MouseEventHandler,
|
||||||
|
type PropsWithChildren,
|
||||||
|
type ReactEventHandler,
|
||||||
|
type Ref,
|
||||||
|
} from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { FileErrorIcon, VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
import { InlineSpinner } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
|
||||||
|
import styles from "./VideoBodyView.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render states for the shared video body view.
|
||||||
|
*/
|
||||||
|
export const VideoBodyViewState = {
|
||||||
|
ERROR: "ERROR",
|
||||||
|
HIDDEN: "HIDDEN",
|
||||||
|
LOADING: "LOADING",
|
||||||
|
READY: "READY",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type VideoBodyViewState = (typeof VideoBodyViewState)[keyof typeof VideoBodyViewState];
|
||||||
|
|
||||||
|
export interface VideoBodyViewSnapshot {
|
||||||
|
/**
|
||||||
|
* The current render state of the component.
|
||||||
|
*/
|
||||||
|
state: VideoBodyViewState;
|
||||||
|
/**
|
||||||
|
* Accessible label applied to the video element.
|
||||||
|
*/
|
||||||
|
videoLabel?: string;
|
||||||
|
/**
|
||||||
|
* Title applied to the video element.
|
||||||
|
*/
|
||||||
|
videoTitle?: string;
|
||||||
|
/**
|
||||||
|
* Label shown in the hidden-preview placeholder.
|
||||||
|
*/
|
||||||
|
hiddenButtonLabel?: string;
|
||||||
|
/**
|
||||||
|
* Label rendered when media cannot be processed.
|
||||||
|
*/
|
||||||
|
errorLabel?: string;
|
||||||
|
/**
|
||||||
|
* Optional width constraint for the media frame.
|
||||||
|
*/
|
||||||
|
maxWidth?: number;
|
||||||
|
/**
|
||||||
|
* Optional height constraint for the media frame.
|
||||||
|
*/
|
||||||
|
maxHeight?: number;
|
||||||
|
/**
|
||||||
|
* Optional aspect ratio for the media frame.
|
||||||
|
*/
|
||||||
|
aspectRatio?: CSSProperties["aspectRatio"];
|
||||||
|
/**
|
||||||
|
* Video source URL.
|
||||||
|
*/
|
||||||
|
src?: string;
|
||||||
|
/**
|
||||||
|
* Poster image URL.
|
||||||
|
*/
|
||||||
|
poster?: string;
|
||||||
|
/**
|
||||||
|
* Preload mode for the video.
|
||||||
|
*/
|
||||||
|
preload?: "none" | "metadata" | "auto";
|
||||||
|
/**
|
||||||
|
* Whether native controls are visible.
|
||||||
|
*/
|
||||||
|
controls?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the video is muted.
|
||||||
|
*/
|
||||||
|
muted?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the video should autoplay.
|
||||||
|
*/
|
||||||
|
autoPlay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoBodyViewActions {
|
||||||
|
/**
|
||||||
|
* Invoked when the user chooses to reveal hidden media.
|
||||||
|
*/
|
||||||
|
onPreviewClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
/**
|
||||||
|
* Invoked when the video starts playing.
|
||||||
|
*/
|
||||||
|
onPlay?: ReactEventHandler<HTMLVideoElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VideoBodyViewModel = ViewModel<VideoBodyViewSnapshot, VideoBodyViewActions>;
|
||||||
|
|
||||||
|
interface VideoBodyViewProps {
|
||||||
|
/**
|
||||||
|
* View model providing render state and actions.
|
||||||
|
*/
|
||||||
|
vm: VideoBodyViewModel;
|
||||||
|
/**
|
||||||
|
* Optional host CSS class.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Optional CSS class applied to the media frame container.
|
||||||
|
*/
|
||||||
|
containerClassName?: string;
|
||||||
|
/**
|
||||||
|
* Optional ref to the rendered video element.
|
||||||
|
*/
|
||||||
|
videoRef?: Ref<HTMLVideoElement>;
|
||||||
|
/**
|
||||||
|
* Optional supplemental content rendered after the video frame.
|
||||||
|
*/
|
||||||
|
children?: PropsWithChildren["children"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoBodyView({
|
||||||
|
vm,
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
videoRef,
|
||||||
|
children,
|
||||||
|
}: Readonly<VideoBodyViewProps>): JSX.Element {
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
videoLabel,
|
||||||
|
videoTitle,
|
||||||
|
hiddenButtonLabel,
|
||||||
|
errorLabel,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
aspectRatio,
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
preload,
|
||||||
|
controls,
|
||||||
|
muted,
|
||||||
|
autoPlay,
|
||||||
|
} = useViewModel(vm);
|
||||||
|
|
||||||
|
const rootClassName = classNames(className, styles.root);
|
||||||
|
const resolvedContainerClassName = classNames(containerClassName, styles.container);
|
||||||
|
|
||||||
|
// Reserve the media box on the container itself so the timeline doesn't jump
|
||||||
|
// while the video element or loading state is still settling.
|
||||||
|
const resolvedWidth = maxWidth === undefined ? undefined : `min(100%, ${maxWidth}px)`;
|
||||||
|
const containerStyle: CSSProperties = {
|
||||||
|
width: resolvedWidth,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
aspectRatio,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state === VideoBodyViewState.ERROR) {
|
||||||
|
return (
|
||||||
|
<span className={classNames(rootClassName, styles.error)}>
|
||||||
|
<FileErrorIcon width="16" height="16" />
|
||||||
|
{errorLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === VideoBodyViewState.HIDDEN) {
|
||||||
|
return (
|
||||||
|
<span className={rootClassName}>
|
||||||
|
<div className={resolvedContainerClassName} style={containerStyle}>
|
||||||
|
<button type="button" onClick={vm.onPreviewClick} className={styles.hiddenButton}>
|
||||||
|
<div className={styles.hiddenButtonContent}>
|
||||||
|
<VisibilityOnIcon />
|
||||||
|
<span>{hiddenButtonLabel}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === VideoBodyViewState.LOADING) {
|
||||||
|
return (
|
||||||
|
<span className={rootClassName}>
|
||||||
|
<div className={resolvedContainerClassName} style={containerStyle}>
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<InlineSpinner aria-label="Loading..." role="progressbar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={rootClassName}>
|
||||||
|
<div className={resolvedContainerClassName} style={containerStyle} data-video-body-mask-target="">
|
||||||
|
{/* Captions will be supplied from app-side data once the VM wiring is in place. */}
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
|
<video
|
||||||
|
className={styles.video}
|
||||||
|
ref={videoRef}
|
||||||
|
src={src}
|
||||||
|
aria-label={videoLabel}
|
||||||
|
title={videoTitle}
|
||||||
|
controls={controls}
|
||||||
|
controlsList="nodownload"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
preload={preload}
|
||||||
|
muted={muted}
|
||||||
|
autoPlay={autoPlay}
|
||||||
|
poster={poster}
|
||||||
|
onPlay={vm.onPlay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`VideoBodyView > matches snapshot for error story 1`] = `
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="root error"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V4q0-.824.588-1.412A1.93 1.93 0 0 1 6 2h7.175a1.98 1.98 0 0 1 1.4.575l4.85 4.85q.275.275.425.638.15.361.15.762v3.516A6 6 0 0 0 18 12V9h-4a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 8V4H6v16h6.341c.264.745.67 1.423 1.187 2z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18 14a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1m-1 7a1 1 0 1 1 2 0 1 1 0 0 1-2 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Error decrypting video
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`VideoBodyView > matches snapshot for hidden story 1`] = `
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="root"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
style="width: min(100%, 320px); max-width: 320px; max-height: 180px; aspect-ratio: 16 / 9;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="hiddenButton"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="hiddenButtonContent"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 16q1.875 0 3.188-1.312Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7 8.813 8.313 7.5 11.5t1.313 3.188T12 16m0-1.8q-1.125 0-1.912-.787A2.6 2.6 0 0 1 9.3 11.5q0-1.125.787-1.912A2.6 2.6 0 0 1 12 8.8q1.125 0 1.912.787.788.788.788 1.913t-.787 1.912A2.6 2.6 0 0 1 12 14.2m0 4.8q-3.475 0-6.35-1.837Q2.775 15.324 1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.775.8.8 0 0 1 .1-.313q1.475-3.125 4.35-4.962Q8.525 4 12 4t6.35 1.838T22.7 10.8a.8.8 0 0 1 .1.313 3 3 0 0 1 0 .774.8.8 0 0 1-.1.313q-1.475 3.125-4.35 4.963Q15.475 19 12 19m0-2a9.54 9.54 0 0 0 5.188-1.488A9.77 9.77 0 0 0 20.8 11.5a9.77 9.77 0 0 0-3.613-4.012A9.54 9.54 0 0 0 12 6a9.55 9.55 0 0 0-5.187 1.487A9.77 9.77 0 0 0 3.2 11.5a9.77 9.77 0 0 0 3.613 4.012A9.54 9.54 0 0 0 12 17"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
Show video
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`VideoBodyView > matches snapshot for ready story 1`] = `
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="root"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
data-video-body-mask-target=""
|
||||||
|
style="width: min(100%, 320px); max-width: 320px; max-height: 180px; aspect-ratio: 16 / 9;"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
aria-label="Product demo video"
|
||||||
|
class="video"
|
||||||
|
controls=""
|
||||||
|
controlslist="nodownload"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
poster="/static/element.png"
|
||||||
|
preload="none"
|
||||||
|
src="http://localhost:63315/static/videoBodyDemo.webm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
File body slot
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
VideoBodyView,
|
||||||
|
VideoBodyViewState,
|
||||||
|
type VideoBodyViewActions,
|
||||||
|
type VideoBodyViewModel,
|
||||||
|
type VideoBodyViewSnapshot,
|
||||||
|
} from "./VideoBodyView";
|
||||||
Binary file not shown.
Reference in New Issue
Block a user