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:
@@ -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;
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
@@ -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";
|
||||
|
||||
+98
@@ -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;
|
||||
}
|
||||
}
|
||||
+85
@@ -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",
|
||||
},
|
||||
};
|
||||
+164
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
+229
@@ -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>
|
||||
);
|
||||
}
|
||||
+185
@@ -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>
|
||||
`;
|
||||
+8
@@ -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";
|
||||
Reference in New Issue
Block a user