Refactor and move MVideoBody to shared components (#32849)

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

* fix prettier and other warnings

* move video message body to shared view + app viewmodel

* Fix prettier warnings and masking spinner for tests

* stabilize VideoBodyView screenshots with local media asset

* Disable spinner from changing image all the time

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

* Update prettier fix

* Update snapshot

* Add tests to cover different states of Video

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

* Update snapshot

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

* adde documentation snippets

* refactor: move m.video rendering into body factory

* docs: add tsdoc for video body view model

* docs: add thumbnail tsdoc for video body view model

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

* docs: add dimensions tsdoc for video body view model

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

* docs: add tsdoc for video body view state

* refactor: replace video body view state enum

* refactor: remove duplicate video body state init

* refactor: drop unused video body view state attribute

* Fix Prettier

* Update snapshot screenshot

* test: restore video story screenshot mask

* chore: refresh PR head

* Add mask to screenshot to pass CI tests

* test: narrow video story mask hook

* Fix easy Sonar warnings in video body components

* Move shared message body views into event-tile layout

* Move shared message body visual baselines

* Revert unrelated shared message body moves
This commit is contained in:
Zack
2026-04-01 09:48:22 +00:00
committed by GitHub
parent 3e04b24d1e
commit 0391543bbc
27 changed files with 1763 additions and 524 deletions
@@ -25,6 +25,24 @@ vis.setup({
*, *::before, *::after {
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 */
.sb-wrapper {
visibility: hidden !important;
+1
View File
@@ -16,6 +16,7 @@ export * from "./event-tiles/UrlPreviewGroupView";
export * from "./message-body/EventContentBody";
export * from "./message-body/RedactedBodyView";
export * from "./message-body/FileBodyView";
export * from "./room/timeline/event-tile/body/MVideoBodyView";
export * from "./core/pill-input/Pill";
export * from "./core/pill-input/PillInput";
export * from "./room/RoomStatusBar";
@@ -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);
}
@@ -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,
},
};
@@ -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);
});
});
@@ -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>
);
}
@@ -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.