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
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -63,6 +63,7 @@
|
|||||||
"linkifyjs": "4.3.2",
|
"linkifyjs": "4.3.2",
|
||||||
"lodash": "npm:lodash-es@^4.17.21",
|
"lodash": "npm:lodash-es@^4.17.21",
|
||||||
"matrix-web-i18n": "catalog:",
|
"matrix-web-i18n": "catalog:",
|
||||||
|
"react-blurhash": "^0.3.0",
|
||||||
"react-merge-refs": "^3.0.2",
|
"react-merge-refs": "^3.0.2",
|
||||||
"react-resizable-panels": "^4.6.5",
|
"react-resizable-panels": "^4.6.5",
|
||||||
"react-virtuoso": "^4.14.0",
|
"react-virtuoso": "^4.14.0",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"attachment": "Attachment",
|
"attachment": "Attachment",
|
||||||
"encryption_enabled": "Encryption enabled",
|
"encryption_enabled": "Encryption enabled",
|
||||||
|
"loading": "Loading…",
|
||||||
"options": "Options",
|
"options": "Options",
|
||||||
"preferences": "Preferences",
|
"preferences": "Preferences",
|
||||||
"state_encryption_enabled": "Experimental state encryption enabled"
|
"state_encryption_enabled": "Experimental state encryption enabled"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from "./room/timeline/ReadMarker";
|
|||||||
export * from "./room/timeline/event-tile/body/EventContentBodyView";
|
export * from "./room/timeline/event-tile/body/EventContentBodyView";
|
||||||
export * from "./room/timeline/event-tile/body/RedactedBodyView";
|
export * from "./room/timeline/event-tile/body/RedactedBodyView";
|
||||||
export * from "./room/timeline/event-tile/body/MFileBodyView";
|
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/MVideoBodyView";
|
||||||
export * from "./room/timeline/event-tile/body/TextualBodyView";
|
export * from "./room/timeline/event-tile/body/TextualBodyView";
|
||||||
export * from "./room/timeline/event-tile/EventTileView/TileErrorView";
|
export * from "./room/timeline/event-tile/EventTileView/TileErrorView";
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -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";
|
||||||
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1055,6 +1055,9 @@ importers:
|
|||||||
matrix-web-i18n:
|
matrix-web-i18n:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.6.0
|
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:
|
react-merge-refs:
|
||||||
specifier: ^3.0.2
|
specifier: ^3.0.2
|
||||||
version: 3.0.2(react@19.2.5)
|
version: 3.0.2(react@19.2.5)
|
||||||
|
|||||||