Phase 1 Refactor MImageBody to shared component (#33093)

* Phase 1 Refactor MImageBody to shared component

* blurhash package added into shared components

* Added image snapshots

* Correct the GIF label css design

* Update snapshot for GIF

* crop image correctly

* Update snapshot to reflect updates

* typo fix

* update css to compund variables

* Added tsdoc to function

* Fix inaccurate css and text values in stories

* use const enum instead

* Add i18n to shared components + added string

* Correct css comment information

* Update lockfile
This commit is contained in:
Zack
2026-04-16 10:59:32 +02:00
committed by GitHub
parent aa0abdb43a
commit d4aea25600
20 changed files with 1095 additions and 1 deletions
+1
View File
@@ -63,6 +63,7 @@
"linkifyjs": "4.3.2",
"lodash": "npm:lodash-es@^4.17.21",
"matrix-web-i18n": "catalog:",
"react-blurhash": "^0.3.0",
"react-merge-refs": "^3.0.2",
"react-resizable-panels": "^4.6.5",
"react-virtuoso": "^4.14.0",
@@ -34,6 +34,7 @@
"common": {
"attachment": "Attachment",
"encryption_enabled": "Encryption enabled",
"loading": "Loading…",
"options": "Options",
"preferences": "Preferences",
"state_encryption_enabled": "Experimental state encryption enabled"
+1
View File
@@ -17,6 +17,7 @@ export * from "./room/timeline/ReadMarker";
export * from "./room/timeline/event-tile/body/EventContentBodyView";
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/MVideoBodyView";
export * from "./room/timeline/event-tile/body/TextualBodyView";
export * from "./room/timeline/event-tile/EventTileView/TileErrorView";
@@ -0,0 +1,144 @@
/*
* 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;
flex-direction: column;
gap: var(--cpd-space-2x);
min-width: var(--cpd-space-0x);
}
.link {
display: block;
width: fit-content;
color: inherit;
text-decoration: none;
}
.thumbnailContainer {
position: relative;
overflow: hidden;
contain: paint;
border-radius: var(--MBody-border-radius);
}
.placeholder {
position: absolute;
inset: var(--cpd-space-0x);
display: flex;
align-items: center;
justify-content: center;
background-color: var(--cpd-color-bg-canvas-default);
z-index: 1;
}
.placeholderBlurhash {
background-color: transparent;
}
.blurhash {
width: 100%;
height: 100%;
}
.blurhash > canvas {
width: 100%;
height: 100%;
animation: blurhashPulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1);
}
.mediaContent {
position: relative;
max-width: 100%;
max-height: 100%;
}
.image {
display: block;
width: 100%;
height: 100%;
}
.banner {
position: absolute;
bottom: var(--cpd-space-2x);
left: var(--cpd-space-2x);
max-width: min(100%, 350px);
overflow: hidden;
padding: var(--cpd-space-1x);
border-radius: var(--MBody-border-radius);
background-color: rgb(0 0 0 / 0.6);
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--cpd-font-body-sm-regular);
user-select: none;
pointer-events: none;
}
.gifLabel {
position: absolute;
display: block;
top: var(--cpd-space-0x);
left: 14px; /* Preserve the original GIF badge offset from _MImageBody.pcss. */
padding: 5px; /* Preserve the original GIF badge padding from _MImageBody.pcss. */
border-radius: 5px; /* Preserve the original GIF badge corner radius from _MImageBody.pcss. */
background: rgba(0, 0, 0, 0.7);
border: 2px solid rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 1);
pointer-events: none;
}
.hiddenButton {
border: none;
width: 100%;
height: 100%;
padding: var(--cpd-space-0x);
inset: var(--cpd-space-0x);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
background-color: var(--cpd-color-bg-subtle-secondary);
}
.hiddenButton:hover,
.hiddenButton:focus-visible {
background-color: var(--cpd-color-bg-canvas-default);
}
.hiddenButtonContent {
display: flex;
color: var(--cpd-color-text-action-accent);
}
.hiddenButtonContent > svg {
margin-top: auto;
margin-bottom: auto;
}
.error {
display: block;
color: var(--cpd-color-text-critical-primary);
}
.errorIcon {
margin-right: var(--cpd-space-1x);
vertical-align: text-top;
}
@keyframes blurhashPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.65;
}
}
@@ -0,0 +1,157 @@
/*
* 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, fn, userEvent, within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../../../../core/viewmodel/useMockedViewModel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import {
ImageBodyView,
ImageBodyViewPlaceholder,
ImageBodyViewState,
type ImageBodyViewActions,
type ImageBodyViewSnapshot,
} from "./ImageBodyView";
const imageSrc = new URL("../../../../../../static/image-body/install-spinner.png", import.meta.url).href;
const thumbnailSrc = new URL("../../../../../../static/image-body/install-spinner.png", import.meta.url).href;
const animatedGifSrc = new URL("../../../../../../static/image-body/install-spinner.gif", import.meta.url).href;
const demoBlurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
const imageBodyViewStateOptions = [ImageBodyViewState.ERROR, ImageBodyViewState.HIDDEN, ImageBodyViewState.READY];
const imageBodyViewPlaceholderOptions = [
ImageBodyViewPlaceholder.NONE,
ImageBodyViewPlaceholder.SPINNER,
ImageBodyViewPlaceholder.BLURHASH,
];
type ImageBodyViewProps = ImageBodyViewSnapshot &
ImageBodyViewActions & {
className?: string;
children?: ReactNode;
};
const ImageBodyViewWrapperImpl = ({
onLinkClick,
onHiddenButtonClick,
onImageLoad,
onImageError,
className,
children,
...snapshotProps
}: ImageBodyViewProps): ReactNode => {
const vm = useMockedViewModel(snapshotProps, {
onLinkClick: onLinkClick ?? fn(),
onHiddenButtonClick: onHiddenButtonClick ?? fn(),
onImageLoad: onImageLoad ?? fn(),
onImageError: onImageError ?? fn(),
});
return (
<ImageBodyView vm={vm} className={className}>
{children}
</ImageBodyView>
);
};
const ImageBodyViewWrapper = withViewDocs(ImageBodyViewWrapperImpl, ImageBodyView);
const meta = {
title: "MessageBody/ImageBodyView",
component: ImageBodyViewWrapper,
tags: ["autodocs"],
argTypes: {
state: {
options: imageBodyViewStateOptions,
control: { type: "select" },
},
placeholder: {
options: imageBodyViewPlaceholderOptions,
control: { type: "select" },
},
className: { control: "text" },
},
args: {
state: ImageBodyViewState.READY,
alt: "Element logo",
hiddenButtonLabel: "Show image",
errorLabel: "Unable to show image due to error",
src: imageSrc,
thumbnailSrc,
showAnimatedContentOnHover: false,
placeholder: ImageBodyViewPlaceholder.NONE,
blurhash: demoBlurhash,
maxWidth: 320,
maxHeight: 320,
aspectRatio: "1 / 1",
isSvg: false,
gifLabel: undefined,
bannerLabel: "install-spinner.png",
tooltipLabel: undefined,
linkUrl: imageSrc,
linkTarget: undefined,
className: undefined,
children: <div>File body slot</div>,
},
} satisfies Meta<typeof ImageBodyViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Hidden: Story = {
args: {
state: ImageBodyViewState.HIDDEN,
linkUrl: undefined,
tooltipLabel: undefined,
},
};
export const LoadingWithSpinner: Story = {
args: {
placeholder: ImageBodyViewPlaceholder.SPINNER,
},
};
export const LoadingWithBlurhash: Story = {
args: {
placeholder: ImageBodyViewPlaceholder.BLURHASH,
},
};
export const AnimatedPreview: Story = {
args: {
src: animatedGifSrc,
thumbnailSrc,
linkUrl: animatedGifSrc,
showAnimatedContentOnHover: true,
gifLabel: "GIF",
},
};
export const ErrorState: Story = {
args: {
state: ImageBodyViewState.ERROR,
linkUrl: undefined,
children: undefined,
},
};
export const WithTooltip: Story = {
args: {
tooltipLabel: "Tooltip image name",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.hover(canvas.getByRole("img", { name: "Element logo" }));
await expect(
within(canvasElement.ownerDocument.body).findByText("Tooltip image name"),
).resolves.toBeInTheDocument();
},
};
@@ -0,0 +1,181 @@
/*
* 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 userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MockViewModel } from "../../../../../core/viewmodel/MockViewModel";
import * as stories from "./ImageBodyView.stories";
import {
ImageBodyView,
ImageBodyViewPlaceholder,
ImageBodyViewState,
type ImageBodyViewActions,
type ImageBodyViewSnapshot,
} from "./ImageBodyView";
const { Default, Hidden, LoadingWithSpinner, LoadingWithBlurhash, AnimatedPreview, ErrorState } =
composeStories(stories);
class TestImageBodyViewModel extends MockViewModel<ImageBodyViewSnapshot> implements ImageBodyViewActions {
public onLinkClick?: ImageBodyViewActions["onLinkClick"];
public onHiddenButtonClick?: ImageBodyViewActions["onHiddenButtonClick"];
public onImageLoad?: ImageBodyViewActions["onImageLoad"];
public onImageError?: ImageBodyViewActions["onImageError"];
public constructor(snapshot: ImageBodyViewSnapshot, actions: ImageBodyViewActions = {}) {
super(snapshot);
this.onLinkClick = actions.onLinkClick;
this.onHiddenButtonClick = actions.onHiddenButtonClick;
this.onImageLoad = actions.onImageLoad;
this.onImageError = actions.onImageError;
}
}
describe("ImageBodyView", () => {
it.each([
["default", Default],
["hidden", Hidden],
["loading-with-spinner", LoadingWithSpinner],
["loading-with-blurhash", LoadingWithBlurhash],
["animated-preview", AnimatedPreview],
["error", ErrorState],
])("matches snapshot for %s story", (_name, Story) => {
const { container } = render(<Story />);
expect(container).toMatchSnapshot();
});
it("renders the hidden preview button and invokes the click handler", async () => {
const user = userEvent.setup();
const onHiddenButtonClick = vi.fn();
const vm = new TestImageBodyViewModel(
{
state: ImageBodyViewState.HIDDEN,
hiddenButtonLabel: "Show image",
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
},
{ onHiddenButtonClick },
);
render(<ImageBodyView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Show image" }));
expect(onHiddenButtonClick).toHaveBeenCalledTimes(1);
});
it("renders an error label when the media cannot be displayed", () => {
const vm = new TestImageBodyViewModel({
state: ImageBodyViewState.ERROR,
errorLabel: "Error decrypting image",
});
render(<ImageBodyView vm={vm} />);
expect(screen.getByText("Error decrypting image")).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
it("renders a link wrapper and forwards the click handler", () => {
const onLinkClick = vi.fn();
const vm = new TestImageBodyViewModel(
{
state: ImageBodyViewState.READY,
alt: "Linked image",
src: "https://example.org/full.png",
thumbnailSrc: "https://example.org/thumb.png",
linkUrl: "https://example.org/full.png",
linkTarget: "_blank",
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
},
{ onLinkClick },
);
render(<ImageBodyView vm={vm} />);
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "https://example.org/full.png");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noreferrer noopener");
fireEvent.click(link);
expect(onLinkClick).toHaveBeenCalledTimes(1);
});
it("swaps to the full source on hover for animated previews", async () => {
const user = userEvent.setup();
const vm = new TestImageBodyViewModel({
state: ImageBodyViewState.READY,
alt: "Animated image",
src: "https://example.org/full.gif",
thumbnailSrc: "https://example.org/thumb.png",
showAnimatedContentOnHover: true,
gifLabel: "GIF",
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
});
render(<ImageBodyView vm={vm} />);
const image = screen.getByRole("img", { name: "Animated image" }) as HTMLImageElement;
expect(image).toHaveAttribute("src", "https://example.org/thumb.png");
expect(screen.getByText("GIF")).toBeInTheDocument();
await user.hover(image);
expect(image).toHaveAttribute("src", "https://example.org/full.gif");
expect(screen.queryByText("GIF")).not.toBeInTheDocument();
});
it("renders the configured placeholder state", () => {
const vm = new TestImageBodyViewModel({
state: ImageBodyViewState.READY,
alt: "Loading image",
src: "https://example.org/full.png",
placeholder: ImageBodyViewPlaceholder.SPINNER,
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
});
render(<ImageBodyView vm={vm} />);
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("invokes image load and error handlers", () => {
const onImageLoad = vi.fn();
const onImageError = vi.fn();
const vm = new TestImageBodyViewModel(
{
state: ImageBodyViewState.READY,
alt: "Loaded image",
src: "https://example.org/full.png",
thumbnailSrc: "https://example.org/thumb.png",
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
},
{ onImageLoad, onImageError },
);
render(<ImageBodyView vm={vm} />);
const image = screen.getByRole("img", { name: "Loaded image" });
fireEvent.load(image);
fireEvent.error(image);
expect(onImageLoad).toHaveBeenCalledTimes(1);
expect(onImageError).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,354 @@
/*
* 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 HTMLAttributeAnchorTarget,
type JSX,
type MouseEventHandler,
type PropsWithChildren,
type ReactEventHandler,
useState,
} from "react";
import classNames from "classnames";
import { Blurhash } from "react-blurhash";
import { ImageErrorIcon, VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { InlineSpinner, Tooltip } from "@vector-im/compound-web";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import styles from "./ImageBodyView.module.css";
/**
* High-level rendering state for the shared image body view.
*/
export const enum ImageBodyViewState {
ERROR = "ERROR",
HIDDEN = "HIDDEN",
READY = "READY",
}
/**
* Placeholder variant shown over the media frame while the image is still settling.
*/
export const enum ImageBodyViewPlaceholder {
NONE = "NONE",
SPINNER = "SPINNER",
BLURHASH = "BLURHASH",
}
export interface ImageBodyViewSnapshot {
/**
* Controls whether the component renders an error state, a hidden-preview state,
* or a visible image frame.
*/
state: ImageBodyViewState;
/**
* Image alt text.
*/
alt?: string;
/**
* Label shown when media processing fails.
*/
errorLabel?: string;
/**
* Label used by the hidden-media reveal button.
*/
hiddenButtonLabel?: string;
/**
* Full-resolution image source.
*/
src?: string;
/**
* Thumbnail/static preview image source.
* Falls back to `src` when omitted.
*/
thumbnailSrc?: string;
/**
* Whether hovering or focusing the link should swap to the full-resolution image.
*/
showAnimatedContentOnHover?: boolean;
/**
* Which placeholder to render over the image frame.
*/
placeholder?: ImageBodyViewPlaceholder;
/**
* Blurhash string used when `placeholder` is `BLURHASH`.
*/
blurhash?: 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;
/**
* Optional badge shown for animated images when not hovered/focused.
*/
gifLabel?: string;
/**
* Optional overlay banner shown while hovered/focused.
*/
bannerLabel?: string;
/**
* Optional tooltip shown on the media frame.
*/
tooltipLabel?: string;
/**
* Optional link target for the media frame.
*/
linkUrl?: string;
/**
* Optional anchor target applied when `linkUrl` is provided.
*/
linkTarget?: HTMLAttributeAnchorTarget;
}
export interface ImageBodyViewActions {
/**
* Invoked when the linked image is activated.
*/
onLinkClick?: MouseEventHandler<HTMLAnchorElement>;
/**
* Invoked when the user chooses to reveal hidden media.
*/
onHiddenButtonClick?: MouseEventHandler<HTMLButtonElement>;
/**
* Invoked when the visible image loads.
*/
onImageLoad?: ReactEventHandler<HTMLImageElement>;
/**
* Invoked when the visible image fails to load.
*/
onImageError?: ReactEventHandler<HTMLImageElement>;
}
export type ImageBodyViewModel = ViewModel<ImageBodyViewSnapshot, ImageBodyViewActions>;
interface ImageBodyViewProps {
/**
* The view model for the component.
*/
vm: ImageBodyViewModel;
/**
* Optional host CSS class.
*/
className?: string;
/**
* Optional supplemental content rendered after the media frame.
*/
children?: PropsWithChildren["children"];
}
function renderPlaceholder({
placeholder,
blurhash,
maxWidth,
maxHeight,
loadingLabel,
}: Pick<ImageBodyViewSnapshot, "placeholder" | "blurhash" | "maxWidth" | "maxHeight"> & {
loadingLabel: string;
}): JSX.Element | null {
switch (placeholder) {
case ImageBodyViewPlaceholder.BLURHASH:
if (!blurhash) {
return <InlineSpinner aria-label={loadingLabel} role="progressbar" />;
}
return (
<Blurhash
className={styles.blurhash}
hash={blurhash}
width={maxWidth ?? 320}
height={maxHeight ?? 240}
/>
);
case ImageBodyViewPlaceholder.SPINNER:
return <InlineSpinner aria-label={loadingLabel} role="progressbar" />;
case ImageBodyViewPlaceholder.NONE:
default:
return null;
}
}
/**
* Renders the body of an image message with ready, hidden, and error states.
*
* The media frame supports thumbnail fallbacks, optional loading placeholders,
* animated-content preview on hover/focus, and optional tooltip/banner labels.
* Supplemental content such as a file body row can be rendered after the image
* through `children`.
*
* @example
* ```tsx
* <ImageBodyView vm={imageBodyViewModel}>
* <div>File body slot</div>
* </ImageBodyView>
* ```
*/
export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const {
state,
alt,
errorLabel,
hiddenButtonLabel,
src,
thumbnailSrc,
showAnimatedContentOnHover,
placeholder = ImageBodyViewPlaceholder.NONE,
blurhash,
maxWidth,
maxHeight,
aspectRatio,
isSvg,
gifLabel,
bannerLabel,
tooltipLabel,
linkUrl,
linkTarget,
} = useViewModel(vm);
const [hover, setHover] = useState(false);
const [focus, setFocus] = useState(false);
const hoverOrFocus = hover || focus;
const rootClassName = classNames(className, styles.root);
if (state === ImageBodyViewState.ERROR) {
return (
<span className={classNames(rootClassName, styles.error)}>
<ImageErrorIcon className={styles.errorIcon} width="16" height="16" />
{errorLabel}
</span>
);
}
const resolvedThumbnailSrc = thumbnailSrc ?? src;
const resolvedImageSrc = hoverOrFocus && showAnimatedContentOnHover && src ? src : resolvedThumbnailSrc;
// Reserve the media box on the container itself so the timeline doesn't jump
// while the image element or loading state is still settling.
const resolvedWidth = maxWidth === undefined ? undefined : `min(100%, ${maxWidth}px)`;
const containerStyle: CSSProperties = {
width: resolvedWidth,
maxWidth,
maxHeight,
aspectRatio,
};
const mediaStyle: CSSProperties | undefined = isSvg
? {
width: resolvedWidth,
maxWidth,
maxHeight,
}
: undefined;
const placeholderNode = renderPlaceholder({
placeholder,
blurhash,
maxWidth,
maxHeight,
loadingLabel: _t("common|loading"),
});
const showPlaceholder = placeholderNode !== null;
const media =
state === ImageBodyViewState.HIDDEN ? (
<div style={{ width: maxWidth, height: maxHeight }}>
<button type="button" className={styles.hiddenButton} onClick={vm.onHiddenButtonClick}>
<div className={styles.hiddenButtonContent}>
<VisibilityOnIcon />
<span>{hiddenButtonLabel}</span>
</div>
</button>
</div>
) : resolvedImageSrc ? (
<img
className={styles.image}
src={resolvedImageSrc}
alt={alt}
onError={vm.onImageError}
onLoad={vm.onImageLoad}
onMouseEnter={(): void => setHover(true)}
onMouseLeave={(): void => setHover(false)}
/>
) : null;
const banner =
state === ImageBodyViewState.READY && bannerLabel && hoverOrFocus ? (
<span className={styles.banner}>{bannerLabel}</span>
) : null;
const gifBadge =
state === ImageBodyViewState.READY && gifLabel && !hoverOrFocus ? (
<p className={styles.gifLabel}>{gifLabel}</p>
) : null;
let frame = (
<div className={styles.thumbnailContainer} style={containerStyle}>
{showPlaceholder && (
<div
className={classNames(styles.placeholder, {
[styles.placeholderBlurhash]: placeholder === ImageBodyViewPlaceholder.BLURHASH && !!blurhash,
})}
>
{placeholderNode}
</div>
)}
<div className={styles.mediaContent} style={mediaStyle}>
{media}
{gifBadge}
{banner}
</div>
</div>
);
if (tooltipLabel) {
frame = (
<Tooltip description={tooltipLabel} placement="right" isTriggerInteractive={true}>
{frame}
</Tooltip>
);
}
if (state === ImageBodyViewState.READY && linkUrl) {
frame = (
<a
href={linkUrl}
target={linkTarget}
rel={linkTarget === "_blank" ? "noreferrer noopener" : undefined}
className={styles.link}
onClick={vm.onLinkClick}
onFocus={(): void => setFocus(true)}
onBlur={(): void => setFocus(false)}
>
{frame}
</a>
);
}
return (
<div className={rootClassName}>
{frame}
{children}
</div>
);
}
@@ -0,0 +1,238 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ImageBodyView > matches snapshot for animated-preview story 1`] = `
<div>
<div
class="ImageBodyView-module_root"
>
<a
class="ImageBodyView-module_link"
href="http://localhost:63315/static/image-body/install-spinner.gif"
>
<div
class="ImageBodyView-module_thumbnailContainer"
style="width: min(100%, 320px); max-width: 320px; max-height: 320px; aspect-ratio: 1 / 1;"
>
<div
class="ImageBodyView-module_mediaContent"
>
<img
alt="Element logo"
class="ImageBodyView-module_image"
src="http://localhost:63315/static/image-body/install-spinner.png"
/>
<p
class="ImageBodyView-module_gifLabel"
>
GIF
</p>
</div>
</div>
</a>
<div>
File body slot
</div>
</div>
</div>
`;
exports[`ImageBodyView > matches snapshot for default story 1`] = `
<div>
<div
class="ImageBodyView-module_root"
>
<a
class="ImageBodyView-module_link"
href="http://localhost:63315/static/image-body/install-spinner.png"
>
<div
class="ImageBodyView-module_thumbnailContainer"
style="width: min(100%, 320px); max-width: 320px; max-height: 320px; aspect-ratio: 1 / 1;"
>
<div
class="ImageBodyView-module_mediaContent"
>
<img
alt="Element logo"
class="ImageBodyView-module_image"
src="http://localhost:63315/static/image-body/install-spinner.png"
/>
</div>
</div>
</a>
<div>
File body slot
</div>
</div>
</div>
`;
exports[`ImageBodyView > matches snapshot for error story 1`] = `
<div>
<span
class="ImageBodyView-module_root ImageBodyView-module_error"
>
<svg
class="ImageBodyView-module_errorIcon"
fill="currentColor"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7.803a6 6 0 0 1-.72-2H5v-3.172l4-4 3.585 3.585a6 6 0 0 1 1.172-1.656l-3.343-3.343a2 2 0 0 0-2.828 0L5 13V5h14v7.083c.718.12 1.393.368 2 .72V5a2 2 0 0 0-2-2z"
/>
<path
d="M17 9a2 2 0 1 1-4 0 2 2 0 0 1 4 0m1 5a1 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>
Unable to show image due to error
</span>
</div>
`;
exports[`ImageBodyView > matches snapshot for hidden story 1`] = `
<div>
<div
class="ImageBodyView-module_root"
>
<div
class="ImageBodyView-module_thumbnailContainer"
style="width: min(100%, 320px); max-width: 320px; max-height: 320px; aspect-ratio: 1 / 1;"
>
<div
class="ImageBodyView-module_mediaContent"
>
<div
style="width: 320px; height: 320px;"
>
<button
class="ImageBodyView-module_hiddenButton"
type="button"
>
<div
class="ImageBodyView-module_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 image
</span>
</div>
</button>
</div>
</div>
</div>
<div>
File body slot
</div>
</div>
</div>
`;
exports[`ImageBodyView > matches snapshot for loading-with-blurhash story 1`] = `
<div>
<div
class="ImageBodyView-module_root"
>
<a
class="ImageBodyView-module_link"
href="http://localhost:63315/static/image-body/install-spinner.png"
>
<div
class="ImageBodyView-module_thumbnailContainer"
style="width: min(100%, 320px); max-width: 320px; max-height: 320px; aspect-ratio: 1 / 1;"
>
<div
class="ImageBodyView-module_placeholder ImageBodyView-module_placeholderBlurhash"
>
<div
class="ImageBodyView-module_blurhash"
style="display: inline-block; height: 320px; width: 320px; position: relative;"
>
<canvas
height="32"
style="position: absolute; inset: 0px; width: 100%; height: 100%;"
width="32"
/>
</div>
</div>
<div
class="ImageBodyView-module_mediaContent"
>
<img
alt="Element logo"
class="ImageBodyView-module_image"
src="http://localhost:63315/static/image-body/install-spinner.png"
/>
</div>
</div>
</a>
<div>
File body slot
</div>
</div>
</div>
`;
exports[`ImageBodyView > matches snapshot for loading-with-spinner story 1`] = `
<div>
<div
class="ImageBodyView-module_root"
>
<a
class="ImageBodyView-module_link"
href="http://localhost:63315/static/image-body/install-spinner.png"
>
<div
class="ImageBodyView-module_thumbnailContainer"
style="width: min(100%, 320px); max-width: 320px; max-height: 320px; aspect-ratio: 1 / 1;"
>
<div
class="ImageBodyView-module_placeholder"
>
<svg
aria-label="Loading…"
class="_icon_11k6c_18"
fill="currentColor"
height="1em"
role="progressbar"
style="width: 20px; height: 20px;"
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
class="ImageBodyView-module_mediaContent"
>
<img
alt="Element logo"
class="ImageBodyView-module_image"
src="http://localhost:63315/static/image-body/install-spinner.png"
/>
</div>
</div>
</a>
<div>
File body slot
</div>
</div>
</div>
`;
@@ -0,0 +1,15 @@
/*
* 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 {
ImageBodyView,
ImageBodyViewPlaceholder,
ImageBodyViewState,
type ImageBodyViewActions,
type ImageBodyViewModel,
type ImageBodyViewSnapshot,
} from "./ImageBodyView";
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+3
View File
@@ -1055,6 +1055,9 @@ importers:
matrix-web-i18n:
specifier: 'catalog:'
version: 3.6.0
react-blurhash:
specifier: ^0.3.0
version: 0.3.0(patch_hash=58bc7f075478017ce27bcc252e8509876390db106246bd5b0a7446642cc4b505)(blurhash@2.0.5)(react@19.2.5)
react-merge-refs:
specifier: ^3.0.2
version: 3.0.2(react@19.2.5)