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 {
|
.docs-story {
|
||||||
background: var(--cpd-color-bg-canvas-default);
|
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/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/MImageBodyView";
|
||||||
|
export * from "./room/timeline/event-tile/body/MImageReplyBodyView";
|
||||||
export * from "./room/timeline/event-tile/body/MjolnirBodyView";
|
export * from "./room/timeline/event-tile/body/MjolnirBodyView";
|
||||||
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";
|
||||||
|
|||||||
+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