diff --git a/packages/shared-components/.storybook/preview.css b/packages/shared-components/.storybook/preview.css index 9f49585937..842351b5e0 100644 --- a/packages/shared-components/.storybook/preview.css +++ b/packages/shared-components/.storybook/preview.css @@ -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; +} diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/animated-preview-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/animated-preview-auto.png new file mode 100644 index 0000000000..ac9f34b7b1 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/animated-preview-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..ddbe126ba8 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/loading-with-blurhash-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/loading-with-blurhash-auto.png new file mode 100644 index 0000000000..16ea6246ae Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/loading-with-blurhash-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/loading-with-spinner-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/loading-with-spinner-auto.png new file mode 100644 index 0000000000..0f1746df79 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/loading-with-spinner-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/with-banner-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/with-banner-auto.png new file mode 100644 index 0000000000..6b0893d537 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/with-banner-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index a8e7c805b5..4e758912c9 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.module.css b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.module.css new file mode 100644 index 0000000000..52dcbe4dd1 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.module.css @@ -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; + } +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx new file mode 100644 index 0000000000..be54397237 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx @@ -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 & { + aspectRatio?: string; +}; + +const ImageReplyBodyViewWrapperImpl = (props: ImageReplyBodyViewWrapperProps): ReactNode => ( + +); + +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) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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", + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.test.tsx new file mode 100644 index 0000000000..74c3b5de0f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.test.tsx @@ -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(); + + expect(container).toMatchSnapshot(); + }); + + it("applies a custom className to the root element", () => { + render( + , + ); + + 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( + , + ); + + 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(); + + 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( + , + ); + + expect(container.querySelector("img")).toBeNull(); + }); + + it("swaps animated preview content and overlays on hover", () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + + it("uses explicit width sizing for SVG images", () => { + render( + , + ); + + expect(screen.getByRole("img", { name: "SVG reply preview" }).parentElement).toHaveStyle({ + width: "58px", + maxWidth: "58px", + maxHeight: "44px", + }); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.tsx new file mode 100644 index 0000000000..0ff18f2e48 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.tsx @@ -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; + /** + * 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; + /** + * Invoked when the rendered image fails to load. + */ + onImageError?: ReactEventHandler; +} + +function renderPlaceholder({ + placeholder, + blurhash, + maxWidth, + maxHeight, + loadingLabel, +}: Pick & { + loadingLabel: string; +}): JSX.Element | null { + switch (placeholder) { + case ImageReplyBodyViewPlaceholder.BLURHASH: + if (!blurhash) { + return ( +
+ +
+ ); + } + + return ( + + ); + + case ImageReplyBodyViewPlaceholder.SPINNER: + return ( +
+ +
+ ); + + 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): 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
; + } + + if (maxWidth === undefined || maxHeight === undefined || aspectRatio === undefined) { + if (!showImage) { + return
; + } + + return ( +
+ {alt} +
+ ); + } + + 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 ( +
+
+ {placeholderNode &&
{placeholderNode}
} + +
+ {showImage ? ( + {alt} setHover(true)} + onMouseLeave={(): void => setHover(false)} + /> + ) : null} + {gifLabel && !hover ?

{gifLabel}

: null} + {bannerLabel && hover ? {bannerLabel} : null} +
+
+
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/__snapshots__/ImageReplyBodyView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/__snapshots__/ImageReplyBodyView.test.tsx.snap new file mode 100644 index 0000000000..858f34ad2e --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/__snapshots__/ImageReplyBodyView.test.tsx.snap @@ -0,0 +1,185 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ImageReplyBodyView > matches snapshot for animated-preview story 1`] = ` +
+
+
+
+
+ Reply preview +

+ GIF +

+
+
+
+
+
+`; + +exports[`ImageReplyBodyView > matches snapshot for default story 1`] = ` +
+
+
+
+
+ Reply preview +
+
+
+
+
+`; + +exports[`ImageReplyBodyView > matches snapshot for loading-with-blurhash story 1`] = ` +
+
+
+
+
+
+ +
+
+
+ Reply preview +
+
+
+
+
+`; + +exports[`ImageReplyBodyView > matches snapshot for loading-with-spinner story 1`] = ` +
+
+
+
+
+
+ + + +
+
+
+ Reply preview +
+
+
+
+
+`; + +exports[`ImageReplyBodyView > matches snapshot for with-banner story 1`] = ` +
+
+
+
+
+ Reply preview +
+
+
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/index.tsx new file mode 100644 index 0000000000..e76209c905 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageReplyBodyView/index.tsx @@ -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";