Phase 1: Add shared MImageReplyBody view (#33517)

* Add shared image reply body view

* Added storybook screens

* Fix Prettier

* Add image reply body view coverage

* Show image reply banner in visual story

* fix annotation issue with stories

* stop blurhush from reanimating to pass CI image diff
This commit is contained in:
Zack
2026-06-03 10:08:21 +02:00
committed by GitHub
parent 536fae63ea
commit 9df1a616a1
13 changed files with 775 additions and 0 deletions
@@ -8,3 +8,8 @@ Please see LICENSE files in the repository root for full details.
.docs-story {
background: var(--cpd-color-bg-canvas-default);
}
[class*="blurhash"] > canvas {
animation: none !important;
opacity: 1 !important;
}
+1
View File
@@ -24,6 +24,7 @@ export * from "./room/timeline/event-tile/body/HiddenMediaPlaceholder";
export * from "./room/timeline/event-tile/body/RedactedBodyView";
export * from "./room/timeline/event-tile/body/MFileBodyView";
export * from "./room/timeline/event-tile/body/MImageBodyView";
export * from "./room/timeline/event-tile/body/MImageReplyBodyView";
export * from "./room/timeline/event-tile/body/MjolnirBodyView";
export * from "./room/timeline/event-tile/body/MVideoBodyView";
export * from "./room/timeline/event-tile/body/TextualBodyView";
@@ -0,0 +1,98 @@
/*
* 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.
*/
.root {
display: flex;
column-gap: 4px;
}
.measureImage {
display: none;
}
.thumbnailContainer {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
contain: paint;
border-radius: var(--MBody-border-radius);
}
.placeholder {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background-color: var(--cpd-color-bg-canvas-default);
}
.spinner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
flex: 1;
}
.blurhash > canvas {
animation: blurhashPulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1);
}
.mediaContent {
max-width: 100%;
max-height: 100%;
}
.image {
display: block;
height: 100%;
width: 100%;
}
.banner {
position: absolute;
bottom: 4px;
left: 4px;
padding: 4px;
border-radius: var(--MBody-border-radius);
font-size: 0.9375rem;
user-select: none;
pointer-events: none;
max-width: min(100%, 350px);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
background-color: rgb(0, 0, 0, 0.6);
color: #ffffff;
}
.gifLabel {
position: absolute;
display: block;
top: 0px;
left: 14px;
padding: 5px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.7);
border: 2px solid rgba(0, 0, 0, 0.2);
color: #ffffff;
pointer-events: none;
}
@keyframes blurhashPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.65;
}
}
@@ -0,0 +1,85 @@
/*
* 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 { expect, userEvent, within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import { ImageReplyBodyView, ImageReplyBodyViewPlaceholder, type ImageReplyBodyViewProps } from "./ImageReplyBodyView";
import imageSrc from "../../../../../../static/image-body/install-spinner.png";
const demoBlurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
type ImageReplyBodyViewWrapperProps = Omit<ImageReplyBodyViewProps, "aspectRatio"> & {
aspectRatio?: string;
};
const ImageReplyBodyViewWrapperImpl = (props: ImageReplyBodyViewWrapperProps): ReactNode => (
<ImageReplyBodyView {...props} />
);
const ImageReplyBodyViewWrapper = withViewDocs(ImageReplyBodyViewWrapperImpl, ImageReplyBodyView);
const meta = {
title: "Timeline/Timeline Body/ImageReplyBodyView",
component: ImageReplyBodyViewWrapper,
tags: ["autodocs"],
args: {
className: "",
src: imageSrc,
thumbnailSrc: imageSrc,
alt: "Reply preview",
maxWidth: 57,
maxHeight: 44,
aspectRatio: "57 / 43.95",
placeholder: ImageReplyBodyViewPlaceholder.NONE,
blurhash: demoBlurhash,
},
decorators: [
(Story) => (
<div style={{ maxWidth: 220 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof ImageReplyBodyViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithBanner: Story = {
args: {
bannerLabel: "image.png",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.hover(canvas.getByRole("img", { name: "Reply preview" }));
await expect(canvas.findByText("image.png")).resolves.toBeInTheDocument();
},
};
export const LoadingWithSpinner: Story = {
args: {
placeholder: ImageReplyBodyViewPlaceholder.SPINNER,
},
};
export const LoadingWithBlurhash: Story = {
args: {
placeholder: ImageReplyBodyViewPlaceholder.BLURHASH,
},
};
export const AnimatedPreview: Story = {
args: {
showAnimatedContentOnHover: true,
gifLabel: "GIF",
},
};
@@ -0,0 +1,164 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { fireEvent, render, screen } from "@test-utils";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { ImageReplyBodyView, ImageReplyBodyViewPlaceholder } from "./ImageReplyBodyView";
import * as stories from "./ImageReplyBodyView.stories";
const { Default, WithBanner, LoadingWithSpinner, LoadingWithBlurhash, AnimatedPreview } = composeStories(stories);
describe("ImageReplyBodyView", () => {
it.each([
["default", Default],
["with-banner", WithBanner],
["loading-with-spinner", LoadingWithSpinner],
["loading-with-blurhash", LoadingWithBlurhash],
["animated-preview", AnimatedPreview],
])("matches snapshot for %s story", (_name, Story) => {
const { container } = render(<Story />);
expect(container).toMatchSnapshot();
});
it("applies a custom className to the root element", () => {
render(
<ImageReplyBodyView
className="custom-reply-body"
src="https://example.org/image.png"
alt="Reply preview"
maxWidth={58}
maxHeight={44}
aspectRatio="4 / 3"
/>,
);
expect(screen.getByRole("img", { name: "Reply preview" }).closest(".custom-reply-body")).not.toBeNull();
});
it("renders a hidden measuring image when dimensions are not known yet", () => {
const onImageLoad = vi.fn();
const { container } = render(
<ImageReplyBodyView src="https://example.org/image.png" alt="Reply preview" onImageLoad={onImageLoad} />,
);
const image = container.querySelector("img");
expect(image).toHaveAttribute("alt", "Reply preview");
expect(image).toHaveStyle({ display: "none" });
fireEvent.load(image!);
expect(onImageLoad).toHaveBeenCalledTimes(1);
});
it("renders an empty root when no image source is available", () => {
const { container } = render(<ImageReplyBodyView className="empty-reply-body" />);
expect(container.querySelector(".empty-reply-body")).not.toBeNull();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
it("does not render the measuring image when dimensions and visible media are unavailable", () => {
const { container } = render(
<ImageReplyBodyView src="https://example.org/image.png" alt="Reply preview" showImage={false} />,
);
expect(container.querySelector("img")).toBeNull();
});
it("swaps animated preview content and overlays on hover", () => {
render(
<ImageReplyBodyView
src="https://example.org/full.gif"
thumbnailSrc="https://example.org/thumb.png"
alt="Animated reply preview"
maxWidth={58}
maxHeight={44}
aspectRatio="4 / 3"
showAnimatedContentOnHover
gifLabel="GIF"
bannerLabel="animated.gif"
/>,
);
const image = screen.getByRole("img", { name: "Animated reply preview" });
expect(image).toHaveAttribute("src", "https://example.org/thumb.png");
expect(screen.getByText("GIF")).toBeInTheDocument();
expect(screen.queryByText("animated.gif")).not.toBeInTheDocument();
fireEvent.mouseEnter(image);
expect(image).toHaveAttribute("src", "https://example.org/full.gif");
expect(screen.queryByText("GIF")).not.toBeInTheDocument();
expect(screen.getByText("animated.gif")).toBeInTheDocument();
fireEvent.mouseLeave(image);
expect(image).toHaveAttribute("src", "https://example.org/thumb.png");
expect(screen.getByText("GIF")).toBeInTheDocument();
expect(screen.queryByText("animated.gif")).not.toBeInTheDocument();
});
it("invokes visible image load and error handlers", () => {
const onImageLoad = vi.fn();
const onImageError = vi.fn();
render(
<ImageReplyBodyView
src="https://example.org/image.png"
alt="Reply preview"
maxWidth={58}
maxHeight={44}
aspectRatio="4 / 3"
onImageLoad={onImageLoad}
onImageError={onImageError}
/>,
);
const image = screen.getByRole("img", { name: "Reply preview" });
fireEvent.load(image);
fireEvent.error(image);
expect(onImageLoad).toHaveBeenCalledTimes(1);
expect(onImageError).toHaveBeenCalledTimes(1);
});
it("falls back to a spinner when the blurhash placeholder has no hash", () => {
render(
<ImageReplyBodyView
src="https://example.org/image.png"
alt="Reply preview"
maxWidth={58}
maxHeight={44}
aspectRatio="4 / 3"
placeholder={ImageReplyBodyViewPlaceholder.BLURHASH}
/>,
);
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("uses explicit width sizing for SVG images", () => {
render(
<ImageReplyBodyView
src="https://example.org/image.svg"
alt="SVG reply preview"
maxWidth={58}
maxHeight={44}
aspectRatio="4 / 3"
isSvg
/>,
);
expect(screen.getByRole("img", { name: "SVG reply preview" }).parentElement).toHaveStyle({
width: "58px",
maxWidth: "58px",
maxHeight: "44px",
});
});
});
@@ -0,0 +1,229 @@
/*
* 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 classNames from "classnames";
import React, { type CSSProperties, type JSX, type ReactEventHandler, type Ref, useState } from "react";
import { Blurhash } from "react-blurhash";
import { InlineSpinner } from "@vector-im/compound-web";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import styles from "./ImageReplyBodyView.module.css";
export const enum ImageReplyBodyViewPlaceholder {
NONE = "NONE",
SPINNER = "SPINNER",
BLURHASH = "BLURHASH",
}
export interface ImageReplyBodyViewProps {
/**
* CSS class names applied to the root element.
*/
className?: string;
/**
* Ref to the rendered image element.
*/
imageRef?: Ref<HTMLImageElement>;
/**
* Full-resolution image source.
*/
src?: string;
/**
* Thumbnail/static preview image source.
*/
thumbnailSrc?: string;
/**
* Image alt text.
*/
alt?: string;
/**
* Maximum rendered width for the media frame.
*/
maxWidth?: number;
/**
* Maximum rendered height for the media frame.
*/
maxHeight?: number;
/**
* Aspect ratio reserved for the media frame.
*/
aspectRatio?: CSSProperties["aspectRatio"];
/**
* Whether the displayed image is an SVG and should therefore use explicit width sizing.
*/
isSvg?: boolean;
/**
* Which placeholder to render over the image frame.
*/
placeholder?: ImageReplyBodyViewPlaceholder;
/**
* Blurhash string used when `placeholder` is `BLURHASH`.
*/
blurhash?: string;
/**
* Whether hovering the preview should swap to the full-resolution image.
*/
showAnimatedContentOnHover?: boolean;
/**
* Whether the image element should be rendered inside the reserved media frame.
*/
showImage?: boolean;
/**
* Optional badge shown for animated images when not hovered.
*/
gifLabel?: string;
/**
* Optional overlay banner shown while hovered.
*/
bannerLabel?: string;
/**
* Invoked when the rendered image loads.
*/
onImageLoad?: ReactEventHandler<HTMLImageElement>;
/**
* Invoked when the rendered image fails to load.
*/
onImageError?: ReactEventHandler<HTMLImageElement>;
}
function renderPlaceholder({
placeholder,
blurhash,
maxWidth,
maxHeight,
loadingLabel,
}: Pick<ImageReplyBodyViewProps, "placeholder" | "blurhash" | "maxWidth" | "maxHeight"> & {
loadingLabel: string;
}): JSX.Element | null {
switch (placeholder) {
case ImageReplyBodyViewPlaceholder.BLURHASH:
if (!blurhash) {
return (
<div className={styles.spinner}>
<InlineSpinner size={32} aria-label={loadingLabel} role="progressbar" />
</div>
);
}
return (
<Blurhash className={styles.blurhash} hash={blurhash} width={maxWidth ?? 58} height={maxHeight ?? 44} />
);
case ImageReplyBodyViewPlaceholder.SPINNER:
return (
<div className={styles.spinner}>
<InlineSpinner size={32} aria-label={loadingLabel} role="progressbar" />
</div>
);
case ImageReplyBodyViewPlaceholder.NONE:
default:
return null;
}
}
/**
* Presentational wrapper for the compact image preview used inside reply tiles.
*/
export function ImageReplyBodyView({
className,
imageRef,
src,
thumbnailSrc,
alt,
maxWidth,
maxHeight,
aspectRatio,
isSvg,
placeholder = ImageReplyBodyViewPlaceholder.NONE,
blurhash,
showAnimatedContentOnHover,
showImage = true,
gifLabel,
bannerLabel,
onImageLoad,
onImageError,
}: Readonly<ImageReplyBodyViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const [hover, setHover] = useState(false);
const resolvedThumbnailSrc = thumbnailSrc ?? src;
const resolvedImageSrc = hover && showAnimatedContentOnHover && src ? src : resolvedThumbnailSrc;
if (!resolvedImageSrc) {
return <div className={classNames(styles.root, className)} />;
}
if (maxWidth === undefined || maxHeight === undefined || aspectRatio === undefined) {
if (!showImage) {
return <div className={classNames(styles.root, className)} />;
}
return (
<div className={classNames(styles.root, className)}>
<img
className={styles.measureImage}
src={resolvedImageSrc}
ref={imageRef}
alt={alt}
onError={onImageError}
onLoad={onImageLoad}
/>
</div>
);
}
const containerStyle: CSSProperties = {
maxWidth,
maxHeight,
aspectRatio,
};
const mediaStyle: CSSProperties = isSvg
? {
width: maxWidth,
maxWidth,
maxHeight,
}
: {
width: "100%",
height: "100%",
maxWidth,
maxHeight,
};
const placeholderNode = renderPlaceholder({
placeholder,
blurhash,
maxWidth,
maxHeight,
loadingLabel: _t("common|loading"),
});
return (
<div className={classNames(styles.root, className)}>
<div className={styles.thumbnailContainer} style={containerStyle}>
{placeholderNode && <div className={styles.placeholder}>{placeholderNode}</div>}
<div className={styles.mediaContent} style={mediaStyle}>
{showImage ? (
<img
className={styles.image}
src={resolvedImageSrc}
ref={imageRef}
alt={alt}
onError={onImageError}
onLoad={onImageLoad}
onMouseEnter={(): void => setHover(true)}
onMouseLeave={(): void => setHover(false)}
/>
) : null}
{gifLabel && !hover ? <p className={styles.gifLabel}>{gifLabel}</p> : null}
{bannerLabel && hover ? <span className={styles.banner}>{bannerLabel}</span> : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,185 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ImageReplyBodyView > matches snapshot for animated-preview story 1`] = `
<div>
<div
style="max-width: 220px;"
>
<div
class="ImageReplyBodyView-module_root"
>
<div
class="ImageReplyBodyView-module_thumbnailContainer"
style="max-width: 57px; max-height: 44px; aspect-ratio: 57 / 43.95;"
>
<div
class="ImageReplyBodyView-module_mediaContent"
style="width: 100%; height: 100%; max-width: 57px; max-height: 44px;"
>
<img
alt="Reply preview"
class="ImageReplyBodyView-module_image"
src="/static/image-body/install-spinner.png"
/>
<p
class="ImageReplyBodyView-module_gifLabel"
>
GIF
</p>
</div>
</div>
</div>
</div>
</div>
`;
exports[`ImageReplyBodyView > matches snapshot for default story 1`] = `
<div>
<div
style="max-width: 220px;"
>
<div
class="ImageReplyBodyView-module_root"
>
<div
class="ImageReplyBodyView-module_thumbnailContainer"
style="max-width: 57px; max-height: 44px; aspect-ratio: 57 / 43.95;"
>
<div
class="ImageReplyBodyView-module_mediaContent"
style="width: 100%; height: 100%; max-width: 57px; max-height: 44px;"
>
<img
alt="Reply preview"
class="ImageReplyBodyView-module_image"
src="/static/image-body/install-spinner.png"
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`ImageReplyBodyView > matches snapshot for loading-with-blurhash story 1`] = `
<div>
<div
style="max-width: 220px;"
>
<div
class="ImageReplyBodyView-module_root"
>
<div
class="ImageReplyBodyView-module_thumbnailContainer"
style="max-width: 57px; max-height: 44px; aspect-ratio: 57 / 43.95;"
>
<div
class="ImageReplyBodyView-module_placeholder"
>
<div
class="ImageReplyBodyView-module_blurhash"
style="display: inline-block; height: 44px; width: 57px; position: relative;"
>
<canvas
height="32"
style="position: absolute; inset: 0px; width: 100%; height: 100%;"
width="32"
/>
</div>
</div>
<div
class="ImageReplyBodyView-module_mediaContent"
style="width: 100%; height: 100%; max-width: 57px; max-height: 44px;"
>
<img
alt="Reply preview"
class="ImageReplyBodyView-module_image"
src="/static/image-body/install-spinner.png"
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`ImageReplyBodyView > matches snapshot for loading-with-spinner story 1`] = `
<div>
<div
style="max-width: 220px;"
>
<div
class="ImageReplyBodyView-module_root"
>
<div
class="ImageReplyBodyView-module_thumbnailContainer"
style="max-width: 57px; max-height: 44px; aspect-ratio: 57 / 43.95;"
>
<div
class="ImageReplyBodyView-module_placeholder"
>
<div
class="ImageReplyBodyView-module_spinner"
>
<svg
aria-label="Loading…"
class="_icon_1855a_18"
fill="currentColor"
height="1em"
role="progressbar"
style="width: 32px; height: 32px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
</div>
</div>
<div
class="ImageReplyBodyView-module_mediaContent"
style="width: 100%; height: 100%; max-width: 57px; max-height: 44px;"
>
<img
alt="Reply preview"
class="ImageReplyBodyView-module_image"
src="/static/image-body/install-spinner.png"
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`ImageReplyBodyView > matches snapshot for with-banner story 1`] = `
<div>
<div
style="max-width: 220px;"
>
<div
class="ImageReplyBodyView-module_root"
>
<div
class="ImageReplyBodyView-module_thumbnailContainer"
style="max-width: 57px; max-height: 44px; aspect-ratio: 57 / 43.95;"
>
<div
class="ImageReplyBodyView-module_mediaContent"
style="width: 100%; height: 100%; max-width: 57px; max-height: 44px;"
>
<img
alt="Reply preview"
class="ImageReplyBodyView-module_image"
src="/static/image-body/install-spinner.png"
/>
</div>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* 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 { ImageReplyBodyView, ImageReplyBodyViewPlaceholder, type ImageReplyBodyViewProps } from "./ImageReplyBodyView";