diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/animated-preview-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/animated-preview-auto.png new file mode 100644 index 0000000000..996f5ec6d1 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/animated-preview-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..62ea65c997 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/error-state-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/error-state-auto.png new file mode 100644 index 0000000000..a61b7cdf2c Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/error-state-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/hidden-auto.png new file mode 100644 index 0000000000..4f09d381a5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/hidden-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-blurhash-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-blurhash-auto.png new file mode 100644 index 0000000000..9934c59d57 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-blurhash-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-spinner-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-spinner-auto.png new file mode 100644 index 0000000000..6653fe161a Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-spinner-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/with-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/with-tooltip-auto.png new file mode 100644 index 0000000000..866d94ddf9 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/with-tooltip-auto.png differ diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 2cfe4a0fab..72b458751a 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -63,6 +63,7 @@ "linkifyjs": "4.3.2", "lodash": "npm:lodash-es@^4.17.21", "matrix-web-i18n": "catalog:", + "react-blurhash": "^0.3.0", "react-merge-refs": "^3.0.2", "react-resizable-panels": "^4.6.5", "react-virtuoso": "^4.14.0", diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 7cd70dbb0d..0ab6da3a94 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -34,6 +34,7 @@ "common": { "attachment": "Attachment", "encryption_enabled": "Encryption enabled", + "loading": "Loading…", "options": "Options", "preferences": "Preferences", "state_encryption_enabled": "Experimental state encryption enabled" diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 64c504c5fb..081ccf0d83 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -17,6 +17,7 @@ export * from "./room/timeline/ReadMarker"; export * from "./room/timeline/event-tile/body/EventContentBodyView"; export * from "./room/timeline/event-tile/body/RedactedBodyView"; export * from "./room/timeline/event-tile/body/MFileBodyView"; +export * from "./room/timeline/event-tile/body/MImageBodyView"; export * from "./room/timeline/event-tile/body/MVideoBodyView"; export * from "./room/timeline/event-tile/body/TextualBodyView"; export * from "./room/timeline/event-tile/EventTileView/TileErrorView"; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/.gitkeep b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/.gitkeep deleted file mode 100644 index 8b13789179..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.module.css b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.module.css new file mode 100644 index 0000000000..05d56d3987 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.module.css @@ -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; + } +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx new file mode 100644 index 0000000000..3b6c66cf38 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx @@ -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 ( + + {children} + + ); +}; + +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:
File body slot
, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.test.tsx new file mode 100644 index 0000000000..2eaeeba41f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.test.tsx @@ -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 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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const image = screen.getByRole("img", { name: "Loaded image" }); + fireEvent.load(image); + fireEvent.error(image); + + expect(onImageLoad).toHaveBeenCalledTimes(1); + expect(onImageError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.tsx new file mode 100644 index 0000000000..7a4ea96acb --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.tsx @@ -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; + /** + * Invoked when the user chooses to reveal hidden media. + */ + onHiddenButtonClick?: MouseEventHandler; + /** + * Invoked when the visible image loads. + */ + onImageLoad?: ReactEventHandler; + /** + * Invoked when the visible image fails to load. + */ + onImageError?: ReactEventHandler; +} + +export type ImageBodyViewModel = ViewModel; + +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 & { + loadingLabel: string; +}): JSX.Element | null { + switch (placeholder) { + case ImageBodyViewPlaceholder.BLURHASH: + if (!blurhash) { + return ; + } + + return ( + + ); + + case ImageBodyViewPlaceholder.SPINNER: + return ; + + 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 + * + *
File body slot
+ *
+ * ``` + */ +export function ImageBodyView({ vm, className, children }: Readonly): 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 ( + + + {errorLabel} + + ); + } + + 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 ? ( +
+ +
+ ) : resolvedImageSrc ? ( + {alt} setHover(true)} + onMouseLeave={(): void => setHover(false)} + /> + ) : null; + + const banner = + state === ImageBodyViewState.READY && bannerLabel && hoverOrFocus ? ( + {bannerLabel} + ) : null; + + const gifBadge = + state === ImageBodyViewState.READY && gifLabel && !hoverOrFocus ? ( +

{gifLabel}

+ ) : null; + + let frame = ( +
+ {showPlaceholder && ( +
+ {placeholderNode} +
+ )} + +
+ {media} + {gifBadge} + {banner} +
+
+ ); + + if (tooltipLabel) { + frame = ( + + {frame} + + ); + } + + if (state === ImageBodyViewState.READY && linkUrl) { + frame = ( + setFocus(true)} + onBlur={(): void => setFocus(false)} + > + {frame} + + ); + } + + return ( +
+ {frame} + {children} +
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap new file mode 100644 index 0000000000..e01e97e094 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap @@ -0,0 +1,238 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ImageBodyView > matches snapshot for animated-preview story 1`] = ` +
+
+ +
+
+ Element logo +

+ GIF +

+
+
+
+
+ File body slot +
+
+
+`; + +exports[`ImageBodyView > matches snapshot for default story 1`] = ` +
+
+ +
+
+ Element logo +
+
+
+
+ File body slot +
+
+
+`; + +exports[`ImageBodyView > matches snapshot for error story 1`] = ` +
+ + + + + + Unable to show image due to error + +
+`; + +exports[`ImageBodyView > matches snapshot for hidden story 1`] = ` +
+
+
+
+
+ +
+
+
+
+ File body slot +
+
+
+`; + +exports[`ImageBodyView > matches snapshot for loading-with-blurhash story 1`] = ` +
+
+ +
+
+
+ +
+
+
+ Element logo +
+
+
+
+ File body slot +
+
+
+`; + +exports[`ImageBodyView > matches snapshot for loading-with-spinner story 1`] = ` +
+
+ +
+
+ + + +
+
+ Element logo +
+
+
+
+ File body slot +
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx new file mode 100644 index 0000000000..a71a80c047 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx @@ -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"; diff --git a/packages/shared-components/static/image-body/install-spinner.gif b/packages/shared-components/static/image-body/install-spinner.gif new file mode 100644 index 0000000000..ce6c17844a Binary files /dev/null and b/packages/shared-components/static/image-body/install-spinner.gif differ diff --git a/packages/shared-components/static/image-body/install-spinner.png b/packages/shared-components/static/image-body/install-spinner.png new file mode 100644 index 0000000000..75c3f48ad8 Binary files /dev/null and b/packages/shared-components/static/image-body/install-spinner.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b76736e75d..4f9f199841 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1055,6 +1055,9 @@ importers: matrix-web-i18n: specifier: 'catalog:' version: 3.6.0 + react-blurhash: + specifier: ^0.3.0 + version: 0.3.0(patch_hash=58bc7f075478017ce27bcc252e8509876390db106246bd5b0a7446642cc4b505)(blurhash@2.0.5)(react@19.2.5) react-merge-refs: specifier: ^3.0.2 version: 3.0.2(react@19.2.5)