Phase 2 Refactor MImageBody to MVVM and remove legacy component (#33212)

* MVVMing of MImageBody and removing legacy component + css

* Fix Prettier

* update small image to large image in test

* Update test

* Preserve MImageBody legacy class names

* Click image in custom component download test

* Update snapshots

* Update MBodyFactory snapshots

* Added new tests to pass coverage

* Fix prettier

* Remove legacy import that was removed

* Add MImageReplayBody test for coverage

* Remove legacy MImageBody selectors from image view

* Update image body selectors in Playwright tests

* Keep file panel image body spacing compact

* Update apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* added documentation to component

* Fix hidden media placeholder import

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
Zack 2026-05-13 08:03:43 +02:00 committed by GitHub
parent 13dd1a0b5e
commit 1e7c9f672a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 3389 additions and 1608 deletions

View File

@ -208,7 +208,7 @@ test.describe("Composer", () => {
});
await app.viewRoomByName("Bob");
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
await expect(page.locator(".mx_MImageBody")).toBeVisible();
await expect(page.locator(".mx_ImageBody")).toBeVisible();
});
});
});

View File

@ -198,7 +198,7 @@ test.describe("Composer", () => {
test("can paste a file", async ({ page, bot, app }) => {
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
await expect(page.locator(".mx_MImageBody")).toBeVisible();
await expect(page.locator(".mx_ImageBody")).toBeVisible();
});
test("can paste a file in a thread", async ({ page, app }) => {
@ -213,7 +213,7 @@ test.describe("Composer", () => {
await tile.getByRole("button", { name: "Reply in thread" }).click();
await app.composerDragAndPasteFile("thread", getSampleFilePath("riot.png"), "image/png");
await expect(page.locator(".mx_MImageBody")).toBeVisible();
await expect(page.locator(".mx_ImageBody")).toBeVisible();
});
test.describe("when Control+Enter is required to send", () => {

View File

@ -37,7 +37,7 @@ test.describe("Image Upload", () => {
test("should allow upload via drag and drop", { tag: "@screenshot" }, async ({ page, app }) => {
await app.composerDragAndUploadFiles("room", getSampleFilePath("riot.png"), "image/png");
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
const imgTile = page.locator(".mx_ImageBody").first();
await expect(imgTile).toBeVisible();
});
});

View File

@ -105,12 +105,16 @@ test.describe("Custom Component API", () => {
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
const imgTile = page.locator(".mx_ImageBody").first();
await expect(imgTile).toBeVisible();
const image = imgTile.getByRole("img", { name: "bad.png" });
await expect(image).toBeVisible();
await imgTile.hover();
await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible();
await imgTile.click();
await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible();
await image.click();
const imageView = page.getByLabel("Image view");
await expect(imageView).toBeVisible();
await expect(imageView.getByLabel("Download")).not.toBeVisible();
});
test("should allow downloading media when the allowDownloading hint is set to true", async ({
page,
@ -127,12 +131,16 @@ test.describe("Custom Component API", () => {
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
const imgTile = page.locator(".mx_ImageBody").first();
await expect(imgTile).toBeVisible();
const image = imgTile.getByRole("img", { name: "good.png" });
await expect(image).toBeVisible();
await imgTile.hover();
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
await imgTile.click();
await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible();
await image.click();
const imageView = page.getByLabel("Image view");
await expect(imageView).toBeVisible();
await expect(imageView.getByLabel("Download")).toBeVisible();
});
test(
"should render the next registered component if the filter function throws",

View File

@ -87,9 +87,9 @@ test.describe("FilePanel", () => {
await expect(filePanelMessageList.getByText(NAME)).toHaveCount(3);
// Detect the image file
const image = filePanelMessageList.locator(".mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody");
const image = filePanelMessageList.locator(".mx_EventTile_mediaLine.mx_EventTile_image .mx_ImageBody");
// Assert that the image is specified as thumbnail and has the alt string
await expect(image.locator("img[class='mx_MImageBody_thumbnail']")).toBeVisible();
await expect(image.locator("img.mx_ImageBody_image")).toBeVisible();
await expect(image.locator("img[alt='riot.png']")).toBeVisible();
// Detect the audio file
@ -113,7 +113,7 @@ test.describe("FilePanel", () => {
"flex-end",
);
// Assert that all of the file tiles are visible before taking a snapshot
await expect(filePanelMessageList.locator(".mx_MImageBody")).toBeVisible(); // top
await expect(filePanelMessageList.locator(".mx_ImageBody")).toBeVisible(); // top
await expect(filePanelMessageList.locator(".mx_MAudioBody")).toBeVisible(); // middle
const senderDetails = filePanelMessageList.locator(".mx_EventTile_last .mx_EventTile_senderDetails");
await expect(senderDetails.locator(".mx_DisambiguatedProfile")).toBeVisible();
@ -184,7 +184,7 @@ test.describe("FilePanel", () => {
// Detect the image file on the panel
const imageBody = page.locator(
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody",
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_ImageBody",
);
const link = imageBody.locator(".mx_MFileBody a");

View File

@ -907,7 +907,7 @@ test.describe("Timeline", () => {
await sendImage(bot, room.roomId, NEW_AVATAR);
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
const imgTile = page.locator(".mx_ImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await page.getByRole("button", { name: "Hide" }).click();
@ -1314,7 +1314,7 @@ test.describe("Timeline", () => {
await sendImage(app.client, room.roomId, NEW_AVATAR);
await app.timeline.scrollToBottom();
await expect(page.locator(".mx_MImageBody").first()).toBeVisible();
await expect(page.locator(".mx_ImageBody").first()).toBeVisible();
// Exclude timestamp and read marker from snapshot
const screenshotOptions = {

View File

@ -224,7 +224,6 @@
@import "./views/messages/_DisambiguatedProfile.pcss";
@import "./views/messages/_LegacyCallEvent.pcss";
@import "./views/messages/_MFileBody.pcss";
@import "./views/messages/_MImageBody.pcss";
@import "./views/messages/_MImageReplyBody.pcss";
@import "./views/messages/_MLocationBody.pcss";
@import "./views/messages/_MPollBody.pcss";

View File

@ -45,6 +45,10 @@ Please see LICENSE files in the repository root for full details.
margin-top: var(--cpd-space-4x);
}
.mx_ImageBody {
gap: 0;
}
/* anchor link as wrapper */
.mx_EventTile_senderDetailsLink {
text-decoration: none;

View File

@ -1,81 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016 OpenMarket 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.
*/
.mx_MImageBody_banner {
position: absolute;
bottom: $spacing-4;
left: $spacing-4;
padding: $spacing-4;
border-radius: var(--MBody-border-radius);
font-size: $font-15px;
user-select: none; /* prevent banner text from being selected */
pointer-events: none; /* let the cursor go through to the media underneath */
/* Trying to match the width of the image is surprisingly difficult, so arbitrarily break it off early. */
max-width: min(100%, 350px);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
/* Hardcoded colours because it's the same on all themes */
background-color: rgb(0, 0, 0, 0.6);
color: #ffffff;
}
.mx_MImageBody_placeholder {
/* Position the placeholder on top of the thumbnail, so that the reveal animation can work */
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background-color: $background;
.mx_Blurhash > canvas {
animation: mx--anim-pulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1);
}
}
.mx_MImageBody_thumbnail_container {
border-radius: var(--MBody-border-radius);
/* Necessary for the border radius to apply correctly to the placeholder */
overflow: hidden;
contain: paint;
}
.mx_MImageBody_thumbnail {
display: block;
/* Force the image to be the full size of the container, even if the */
/* pixel size is smaller. The problem here is that we don't know what */
/* thumbnail size the HS is going to give us, but we have to commit to */
/* a container size immediately and not change it when the image loads */
/* or we'll get a scroll jump (or have to leave blank space). */
/* This will obviously result in an upscaled image which will be a bit */
/* blurry. The best fix would be for the HS to advertise what size thumbnails */
/* it guarantees to produce. */
height: 100%;
width: 100%;
}
.mx_MImageBody_gifLabel {
position: absolute;
display: block;
top: 0px;
left: 14px;
padding: 5px;
border-radius: 5px;
background: $imagebody-giflabel;
border: 2px solid $imagebody-giflabel-border;
color: $imagebody-giflabel-color;
pointer-events: none;
}

View File

@ -6,6 +6,64 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
.mx_MImageReplyBody,
.mx_MStickerBody_wrapper {
.mx_MImageBody_banner {
position: absolute;
bottom: $spacing-4;
left: $spacing-4;
padding: $spacing-4;
border-radius: var(--MBody-border-radius);
font-size: $font-15px;
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;
}
.mx_MImageBody_placeholder {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background-color: $background;
.mx_Blurhash > canvas {
animation: mx--anim-pulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1);
}
}
.mx_MImageBody_thumbnail_container {
border-radius: var(--MBody-border-radius);
overflow: hidden;
contain: paint;
}
.mx_MImageBody_thumbnail {
display: block;
height: 100%;
width: 100%;
}
.mx_MImageBody_gifLabel {
position: absolute;
display: block;
top: 0px;
left: 14px;
padding: 5px;
border-radius: 5px;
background: $imagebody-giflabel;
border: 2px solid $imagebody-giflabel-border;
color: $imagebody-giflabel-color;
pointer-events: none;
}
}
.mx_MImageReplyBody {
display: flex;
column-gap: $spacing-4;

View File

@ -156,8 +156,8 @@ Please see LICENSE files in the repository root for full details.
padding-right: 48px !important;
}
.mx_MImageBody {
.mx_MImageBody_thumbnail_container {
.mx_ImageBody {
.mx_ImageBody_container {
justify-content: center;
min-height: calc(1.8rem + var(--gutterSize) + var(--gutterSize));
min-width: calc(1.8rem + var(--gutterSize) + var(--gutterSize));
@ -181,8 +181,8 @@ Please see LICENSE files in the repository root for full details.
.mx_EventTile_line {
border-bottom-right-radius: var(--cornerRadius);
.mx_MImageBody .mx_MImageBody_thumbnail_container,
.mx_MImageBody::before,
.mx_ImageBody .mx_ImageBody_container,
.mx_ImageBody::before,
.mx_MVideoBody .mx_MVideoBody_container,
.mx_MediaBody,
.mx_MLocationBody_map,
@ -220,8 +220,8 @@ Please see LICENSE files in the repository root for full details.
margin-inline-start: auto;
border-bottom-left-radius: var(--cornerRadius);
.mx_MImageBody .mx_MImageBody_thumbnail_container,
.mx_MImageBody::before,
.mx_ImageBody .mx_ImageBody_container,
.mx_ImageBody::before,
.mx_MVideoBody .mx_MVideoBody_container,
.mx_MediaBody,
.mx_MLocationBody_map,
@ -334,16 +334,12 @@ Please see LICENSE files in the repository root for full details.
}
}
.mx_MImageBody {
.mx_ImageBody {
width: 100%;
.mx_MImageBody_thumbnail.mx_MImageBody_thumbnail--blurhash {
position: unset;
}
}
/* noinspection CssReplaceWithShorthandSafely */
.mx_MImageBody .mx_MImageBody_thumbnail_container,
.mx_ImageBody .mx_ImageBody_container,
.mx_MVideoBody .mx_MVideoBody_container,
.mx_MediaBody {
border-radius: unset;
@ -375,9 +371,9 @@ Please see LICENSE files in the repository root for full details.
&.mx_EventTile_continuation[data-self="false"] .mx_EventTile_line {
border-top-left-radius: 0;
.mx_MImageBody .mx_MImageBody_thumbnail_container,
.mx_ImageBody .mx_ImageBody_container,
.mx_MVideoBody .mx_MVideoBody_container,
.mx_MImageBody::before,
.mx_ImageBody::before,
.mx_MediaBody,
.mx_MLocationBody_map,
.mx_MBeaconBody {
@ -387,9 +383,9 @@ Please see LICENSE files in the repository root for full details.
&.mx_EventTile_lastInSection[data-self="false"] .mx_EventTile_line {
border-bottom-left-radius: var(--cornerRadius);
.mx_MImageBody .mx_MImageBody_thumbnail_container,
.mx_ImageBody .mx_ImageBody_container,
.mx_MVideoBody .mx_MVideoBody_container,
.mx_MImageBody::before,
.mx_ImageBody::before,
.mx_MediaBody,
.mx_MLocationBody_map,
.mx_MBeaconBody {
@ -400,9 +396,9 @@ Please see LICENSE files in the repository root for full details.
&.mx_EventTile_continuation[data-self="true"] .mx_EventTile_line {
border-top-right-radius: 0;
.mx_MImageBody .mx_MImageBody_thumbnail_container,
.mx_ImageBody .mx_ImageBody_container,
.mx_MVideoBody .mx_MVideoBody_container,
.mx_MImageBody::before,
.mx_ImageBody::before,
.mx_MediaBody,
.mx_MLocationBody_map,
.mx_MBeaconBody {
@ -412,9 +408,9 @@ Please see LICENSE files in the repository root for full details.
&.mx_EventTile_lastInSection[data-self="true"] .mx_EventTile_line {
border-bottom-right-radius: var(--cornerRadius);
.mx_MImageBody .mx_MImageBody_thumbnail_container,
.mx_ImageBody .mx_ImageBody_container,
.mx_MVideoBody .mx_MVideoBody_container,
.mx_MImageBody::before,
.mx_ImageBody::before,
.mx_MediaBody,
.mx_MLocationBody_map,
.mx_MBeaconBody {

View File

@ -78,8 +78,8 @@ $left-gutter: 64px;
min-width: 100px;
}
.mx_MImageBody {
.mx_MImageBody_thumbnail_container {
.mx_ImageBody {
.mx_ImageBody_container {
display: flex;
align-items: center; /* on every layout */
}
@ -156,8 +156,8 @@ $left-gutter: 64px;
position: absolute;
}
.mx_MImageBody {
.mx_MImageBody_thumbnail_container {
.mx_ImageBody {
.mx_ImageBody_container {
justify-content: flex-start;
min-height: $font-44px;
min-width: $font-44px;

View File

@ -7,9 +7,11 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX, type RefObject, useContext, useEffect, useRef } from "react";
import { MsgType } from "matrix-js-sdk/src/matrix";
import { type ImageContent } from "matrix-js-sdk/src/types";
import {
DecryptionFailureBodyView,
FileBodyView,
ImageBodyView,
RedactedBodyView,
VideoBodyView,
useCreateAutoDisposedViewModel,
@ -21,8 +23,10 @@ import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDevi
import { useMediaVisible } from "../../../hooks/useMediaVisible";
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel";
import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel";
import { ImageBodyViewModel } from "../../../viewmodels/message-body/ImageBodyViewModel";
import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel";
import { VideoBodyViewModel } from "../../../viewmodels/message-body/VideoBodyViewModel";
import { isMimeTypeAllowed } from "../../../utils/blobs";
type MBodyComponent = React.ComponentType<IBodyProps>;
@ -134,6 +138,122 @@ export function VideoBodyFactory({
);
}
export function ImageBodyFactory({
mxEvent,
mediaEventHelper,
forExport,
maxImageHeight,
permalinkCreator,
showFileInfo,
}: Readonly<
Pick<
IBodyProps,
"mxEvent" | "mediaEventHelper" | "forExport" | "maxImageHeight" | "permalinkCreator" | "showFileInfo"
>
>): JSX.Element {
const { timelineRenderingType } = useContext(RoomContext);
const [mediaVisible, setMediaVisible] = useMediaVisible(mxEvent);
const imageRef = useRef<HTMLImageElement>(null);
const content = mxEvent.getContent<ImageContent>();
const shouldFallbackToFileBody =
mediaEventHelper?.media.isEncrypted === true &&
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
!content.info?.thumbnail_info;
const vm = useCreateAutoDisposedViewModel(
() =>
new ImageBodyViewModel({
mxEvent,
mediaEventHelper,
forExport,
maxImageHeight,
mediaVisible,
permalinkCreator,
timelineRenderingType,
imageRef,
setMediaVisible,
}),
);
useEffect(() => {
if (shouldFallbackToFileBody) return;
vm.loadInitialMediaIfVisible();
}, [shouldFallbackToFileBody, vm]);
useEffect(() => {
if (shouldFallbackToFileBody) return;
vm.setEvent(mxEvent, mediaEventHelper);
}, [mediaEventHelper, mxEvent, shouldFallbackToFileBody, vm]);
useEffect(() => {
if (shouldFallbackToFileBody) return;
vm.setForExport(forExport);
}, [forExport, shouldFallbackToFileBody, vm]);
useEffect(() => {
if (shouldFallbackToFileBody) return;
vm.setMaxImageHeight(maxImageHeight);
}, [maxImageHeight, shouldFallbackToFileBody, vm]);
useEffect(() => {
if (shouldFallbackToFileBody) return;
vm.setMediaVisible(mediaVisible);
}, [mediaVisible, shouldFallbackToFileBody, vm]);
useEffect(() => {
if (shouldFallbackToFileBody) return;
vm.setPermalinkCreator(permalinkCreator);
}, [permalinkCreator, shouldFallbackToFileBody, vm]);
useEffect(() => {
if (shouldFallbackToFileBody) return;
vm.setTimelineRenderingType(timelineRenderingType);
}, [shouldFallbackToFileBody, timelineRenderingType, vm]);
useEffect(() => {
if (shouldFallbackToFileBody) return;
vm.setSetMediaVisible(setMediaVisible);
}, [setMediaVisible, shouldFallbackToFileBody, vm]);
const showFileBody =
!forExport &&
timelineRenderingType !== TimelineRenderingType.Room &&
timelineRenderingType !== TimelineRenderingType.Pinned &&
timelineRenderingType !== TimelineRenderingType.Search &&
timelineRenderingType !== TimelineRenderingType.Thread &&
timelineRenderingType !== TimelineRenderingType.ThreadsList;
if (shouldFallbackToFileBody) {
return (
<FileBodyFactory
mxEvent={mxEvent}
mediaEventHelper={mediaEventHelper}
forExport={forExport}
showFileInfo={showFileInfo}
/>
);
}
return (
<ImageBodyView
vm={vm}
className="mx_ImageBody"
containerClassName="mx_ImageBody_container"
imageClassName="mx_ImageBody_image"
imageRef={imageRef}
>
{showFileBody ? (
<FileBodyFactory
mxEvent={mxEvent}
mediaEventHelper={mediaEventHelper}
forExport={forExport}
showFileInfo={false}
/>
) : null}
</ImageBodyView>
);
}
export function RedactedBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent }));
@ -164,6 +284,7 @@ export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick<IBodyProps,
// Message body factory registry for bodies that already route through view-model-backed wrappers.
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([
[MsgType.Image, ImageBodyFactory],
[MsgType.File, FileBodyFactory],
[MsgType.Video, VideoBodyFactory],
]);

View File

@ -1,714 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com>
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 JSX, type ComponentProps, createRef, type ReactNode } from "react";
import { Blurhash } from "react-blurhash";
import classNames from "classnames";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent } from "matrix-js-sdk/src/matrix";
import { type ImageContent } from "matrix-js-sdk/src/types";
import { Tooltip } from "@vector-im/compound-web";
import { ImageErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { HiddenMediaPlaceholder } from "@element-hq/web-shared-components";
import Modal from "../../../Modal";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import Spinner from "../elements/Spinner";
import { type Media, mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media";
import ImageView from "../elements/ImageView";
import { type IBodyProps } from "./IBodyProps";
import { type ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { blobIsAnimated, mayBeAnimated } from "../../../utils/Image";
import { presentableTextForFile } from "../../../utils/FileUtils";
import { createReconnectedListener } from "../../../utils/connection";
import MediaProcessingError from "./shared/MediaProcessingError";
import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
import { isMimeTypeAllowed } from "../../../utils/blobs.ts";
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
enum Placeholder {
NoImage,
Blurhash,
}
interface IState {
contentUrl: string | null;
thumbUrl: string | null;
isAnimated?: boolean;
error?: unknown;
imgError: boolean;
imgLoaded: boolean;
loadedImageDimensions?: {
naturalWidth: number;
naturalHeight: number;
};
hover: boolean;
focus: boolean;
placeholder: Placeholder;
}
interface IProps extends IBodyProps {
/**
* Should the media be behind a preview.
*/
mediaVisible: boolean;
/**
* Set the visibility of the media event.
* @param visible Should the event be visible.
*/
setMediaVisible: (visible: boolean) => void;
}
/**
* @private Only use for inheritance. Use the default export for presentation.
*/
export class MImageBodyInner extends React.Component<IProps, IState> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
private unmounted = false;
private image = createRef<HTMLImageElement>();
private placeholder = createRef<HTMLDivElement>();
private timeout?: number;
private sizeWatcher?: string;
public state: IState = {
contentUrl: null,
thumbUrl: null,
imgError: false,
imgLoaded: false,
hover: false,
focus: false,
placeholder: Placeholder.NoImage,
};
protected onClick = (ev: React.MouseEvent): void => {
if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault();
if (!this.props.mediaVisible) {
this.props.setMediaVisible(true);
return;
}
const content = this.props.mxEvent.getContent<ImageContent>();
let httpUrl = this.state.contentUrl;
if (
this.props.mediaEventHelper?.media.isEncrypted &&
!isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "")
) {
// contentUrl will be a blob URI mime-type=application/octet-stream so fall back to the thumbUrl instead
httpUrl = this.state.thumbUrl;
}
if (!httpUrl) return;
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
src: httpUrl,
name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"),
mxEvent: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
};
if (content.info) {
params.width = content.info.w;
params.height = content.info.h;
params.fileSize = content.info.size;
}
if (this.image.current) {
const clientRect = this.image.current.getBoundingClientRect();
params.thumbnailInfo = {
width: clientRect.width,
height: clientRect.height,
positionX: clientRect.x,
positionY: clientRect.y,
};
}
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
}
};
private get shouldAutoplay(): boolean {
return !(
!this.state.contentUrl ||
!this.props.mediaVisible ||
!this.state.isAnimated ||
SettingsStore.getValue("autoplayGifs")
);
}
protected onImageEnter = (): void => {
this.setState({ hover: true });
};
protected onImageLeave = (): void => {
this.setState({ hover: false });
};
private onFocus = (): void => {
this.setState({ focus: true });
};
private onBlur = (): void => {
this.setState({ focus: false });
};
private reconnectedListener = createReconnectedListener((): void => {
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
this.setState({ imgError: false });
});
private onImageError = (): void => {
// If the thumbnail failed to load then try again using the contentUrl
if (this.state.thumbUrl) {
this.setState({
thumbUrl: null,
});
return;
}
this.clearBlurhashTimeout();
this.setState({
imgError: true,
});
MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener);
};
private onImageLoad = (): void => {
this.clearBlurhashTimeout();
let loadedImageDimensions: IState["loadedImageDimensions"];
if (this.image.current) {
const { naturalWidth, naturalHeight } = this.image.current;
// this is only used as a fallback in case content.info.w/h is missing
loadedImageDimensions = { naturalWidth, naturalHeight };
}
this.setState({ imgLoaded: true, loadedImageDimensions });
};
private getContentUrl(): string | null {
// During export, the content url will point to the MSC, which will later point to a local url
if (this.props.forExport) return this.media.srcMxc;
return this.media.srcHttp;
}
private get media(): Media {
return mediaFromContent(this.props.mxEvent.getContent());
}
private getThumbUrl(): string | null {
// FIXME: we let images grow as wide as you like, rather than capped to 800x600.
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
// thumbnail resolution will be unnecessarily reduced.
// custom timeline widths seems preferable.
const thumbWidth = 800;
const thumbHeight = 600;
const content = this.props.mxEvent.getContent<ImageContent>();
const media = mediaFromContent(content);
const info = content.info;
if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) {
// Special-case to return clientside sender-generated thumbnails for SVGs, if any,
// given we deliberately don't thumbnail them serverside to prevent billion lol attacks and similar.
return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale");
}
// we try to download the correct resolution for hi-res images (like retina screenshots).
// Synapse only supports 800x600 thumbnails for now though,
// so we'll need to download the original image for this to work well for now.
// First, let's try a few cases that let us avoid downloading the original, including:
// - When displaying a GIF, we always want to thumbnail so that we can
// properly respect the user's GIF autoplay setting (which relies on
// thumbnailing to produce the static preview image)
// - On a low DPI device, always thumbnail to save bandwidth
// - If there's no sizing info in the event, default to thumbnail
if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) {
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
}
// We should only request thumbnails if the image is bigger than 800x600 (or 1600x1200 on retina) otherwise
// the image in the timeline will just end up resampled and de-retina'd for no good reason.
// Ideally the server would pre-gen 1600x1200 thumbnails in order to provide retina thumbnails,
// but we don't do this currently in synapse for fear of disk space.
// As a compromise, let's switch to non-retina thumbnails only if the original image is both
// physically too large and going to be massive to load in the timeline (e.g. >1MB).
const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight;
const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb
if (isLargeFileSize && isLargerThanThumbnail) {
// image is too large physically and byte-wise to clutter our timeline so,
// we ask for a thumbnail, despite knowing that it will be max 800x600
// despite us being retina (as synapse doesn't do 1600x1200 thumbs yet).
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
}
// download the original image otherwise, so we can scale it client side to take pixelRatio into account.
return media.srcHttp;
}
private async downloadImage(): Promise<void> {
if (this.state.contentUrl) return; // already downloaded
let thumbUrl: string | null;
let contentUrl: string | null;
if (this.props.mediaEventHelper?.media.isEncrypted) {
try {
[contentUrl, thumbUrl] = await Promise.all([
this.props.mediaEventHelper.sourceUrl.value,
this.props.mediaEventHelper.thumbnailUrl.value,
]);
} catch (error) {
if (this.unmounted) return;
if (error instanceof DecryptError) {
logger.error("Unable to decrypt attachment: ", error);
} else if (error instanceof DownloadError) {
logger.error("Unable to download attachment to decrypt it: ", error);
} else {
logger.error("Error encountered when downloading encrypted attachment: ", error);
}
// Set a placeholder image when we can't decrypt the image.
this.setState({ error });
return;
}
} else {
thumbUrl = this.getThumbUrl();
contentUrl = this.getContentUrl();
}
const content = this.props.mxEvent.getContent<ImageContent>();
let isAnimated = content.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(content.info?.mimetype);
// If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server
// because 1. encryption and 2. we can't ask the server specifically for a non-animated thumbnail.
if (isAnimated && !SettingsStore.getValue("autoplayGifs")) {
if (!thumbUrl || !content?.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) {
const img = document.createElement("img");
const loadPromise = new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
img.crossOrigin = "Anonymous"; // CORS allow canvas access
img.src = contentUrl ?? "";
try {
await loadPromise;
} catch (error) {
logger.error("Unable to download attachment: ", error);
this.setState({ error: error as Error });
return;
}
try {
// If we didn't receive the MSC4230 is_animated flag
// then we need to check if the image is animated by downloading it.
if (
content.info?.["org.matrix.msc4230.is_animated"] === false ||
(await blobIsAnimated(await this.props.mediaEventHelper!.sourceBlob.value)) === false
) {
isAnimated = false;
}
if (isAnimated) {
const thumb = await createThumbnail(
img,
img.width,
img.height,
content.info?.mimetype ?? "image/jpeg",
false,
);
thumbUrl = URL.createObjectURL(thumb.thumbnail);
}
} catch (error) {
// This is a non-critical failure, do not surface the error or bail the method here
logger.warn("Unable to generate thumbnail for animated image: ", error);
}
}
}
if (this.unmounted) return;
this.setState({
contentUrl,
thumbUrl,
isAnimated,
});
}
private clearBlurhashTimeout(): void {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
}
}
public componentDidMount(): void {
this.unmounted = false;
if (this.props.mediaVisible) {
// noinspection JSIgnoredPromiseFromCall
this.downloadImage();
}
// Add a 150ms timer for blurhash to first appear.
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
this.clearBlurhashTimeout();
this.timeout = window.setTimeout(() => {
if (!this.state.imgLoaded || !this.state.imgError) {
this.setState({
placeholder: Placeholder.Blurhash,
});
}
}, 150);
}
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing
});
}
public componentDidUpdate(prevProps: Readonly<IProps>): void {
if (!prevProps.mediaVisible && this.props.mediaVisible) {
// noinspection JSIgnoredPromiseFromCall
this.downloadImage();
}
}
public componentWillUnmount(): void {
this.unmounted = true;
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
this.clearBlurhashTimeout();
SettingsStore.unwatchSetting(this.sizeWatcher);
if (this.state.isAnimated && this.state.thumbUrl) {
URL.revokeObjectURL(this.state.thumbUrl);
}
}
protected getBanner(content: ImageContent): ReactNode {
// Hide it for the threads list & the file panel where we show it as text anyway.
if (
[TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType)
) {
return null;
}
return (
<span className="mx_MImageBody_banner">
{presentableTextForFile(content, _t("common|image"), true, true)}
</span>
);
}
protected messageContent(
contentUrl: string | null,
thumbUrl: string | null,
content: ImageContent,
forcedHeight?: number,
): ReactNode {
if (!thumbUrl) thumbUrl = contentUrl; // fallback
// magic number
// edge case for this not to be set by conditions below
let infoWidth = 500;
let infoHeight = 500;
let infoSvg = false;
if (content.info?.w && content.info?.h) {
infoWidth = content.info.w;
infoHeight = content.info.h;
infoSvg = content.info.mimetype === "image/svg+xml";
} else if (thumbUrl && contentUrl) {
// Whilst the image loads, display nothing. We also don't display a blurhash image
// because we don't really know what size of image we'll end up with.
//
// Once loaded, use the loaded image dimensions stored in `loadedImageDimensions`.
//
// By doing this, the image "pops" into the timeline, but is still restricted
// by the same width and height logic below.
if (!this.state.loadedImageDimensions) {
let imageElement: JSX.Element;
if (!this.props.mediaVisible) {
imageElement = (
<HiddenMediaPlaceholder onClick={this.onClick}>
{_t("timeline|m.image|show_image")}
</HiddenMediaPlaceholder>
);
} else {
imageElement = (
<img
style={{ display: "none" }}
src={thumbUrl}
ref={this.image}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
/>
);
}
return this.wrapImage(contentUrl, imageElement);
}
infoWidth = this.state.loadedImageDimensions.naturalWidth;
infoHeight = this.state.loadedImageDimensions.naturalHeight;
}
// The maximum size of the thumbnail as it is rendered as an <img>,
// accounting for any height constraints
const { w: maxWidth, h: maxHeight } = suggestedImageSize(
SettingsStore.getValue("Images.size") as ImageSize,
{ w: infoWidth, h: infoHeight },
forcedHeight ?? this.props.maxImageHeight,
);
let img: JSX.Element | undefined;
let placeholder: JSX.Element | undefined;
let gifLabel: JSX.Element | undefined;
if (!this.props.forExport && !this.state.imgLoaded) {
const classes = classNames("mx_MImageBody_placeholder", {
"mx_MImageBody_placeholder--blurhash": this.props.mxEvent.getContent().info?.[BLURHASH_FIELD],
});
placeholder = (
<div className={classes} ref={this.placeholder}>
{this.getPlaceholder(maxWidth, maxHeight)}
</div>
);
}
let showPlaceholder = Boolean(placeholder);
const hoverOrFocus = this.state.hover || this.state.focus;
if (thumbUrl && !this.state.imgError) {
let url = thumbUrl;
if (hoverOrFocus && this.shouldAutoplay) {
url = this.state.contentUrl!;
}
// Restrict the width of the thumbnail here, otherwise it will fill the container
// which has the same width as the timeline
// mx_MImageBody_thumbnail resizes img to exactly container size
img = (
<img
className="mx_MImageBody_thumbnail"
src={url}
ref={this.image}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave}
/>
);
}
if (!this.props.mediaVisible) {
img = (
<div style={{ width: maxWidth, height: maxHeight }}>
<HiddenMediaPlaceholder onClick={this.onClick}>
{_t("timeline|m.image|show_image")}
</HiddenMediaPlaceholder>
</div>
);
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder.
}
if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) {
// XXX: Arguably we may want a different label when the animated image is WEBP and not GIF
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
}
let banner: ReactNode | undefined;
if (this.props.mediaVisible && hoverOrFocus) {
banner = this.getBanner(content);
}
// many SVGs don't have an intrinsic size if used in <img> elements.
// due to this we have to set our desired width directly.
// this way if the image is forced to shrink, the height adapts appropriately.
const sizing = infoSvg ? { maxHeight, maxWidth, width: maxWidth } : { maxHeight, maxWidth };
if (!this.props.forExport) {
placeholder = (
<SwitchTransition mode="out-in">
<CSSTransition
classNames="mx_rtg--fade"
key={`img-${showPlaceholder}`}
timeout={300}
nodeRef={this.placeholder}
>
{
showPlaceholder ? (
placeholder
) : (
<div ref={this.placeholder} />
) /* Transition always expects a child */
}
</CSSTransition>
</SwitchTransition>
);
}
const tooltipProps = this.getTooltipProps();
let thumbnail = (
<div
className="mx_MImageBody_thumbnail_container"
style={{ maxHeight, maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}
tabIndex={tooltipProps ? 0 : undefined}
>
{placeholder}
<div style={sizing}>
{img}
{gifLabel}
{banner}
</div>
{/* HACK: This div fills out space while the image loads, to prevent scroll jumps */}
{!this.props.forExport && !this.state.imgLoaded && !placeholder && (
<div style={{ height: maxHeight, width: maxWidth }} />
)}
</div>
);
if (tooltipProps) {
// We specify isTriggerInteractive=true and make the div interactive manually as a workaround for
// https://github.com/element-hq/compound/issues/294
thumbnail = (
<Tooltip {...tooltipProps} isTriggerInteractive={true}>
{thumbnail}
</Tooltip>
);
}
return this.wrapImage(contentUrl, thumbnail);
}
// Overridden by MStickerBody
protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode {
if (contentUrl) {
return (
<a
href={contentUrl}
target={this.props.forExport ? "_blank" : undefined}
onClick={this.onClick}
onFocus={this.onFocus}
onBlur={this.onBlur}
>
{children}
</a>
);
}
return children;
}
// Overridden by MStickerBody
protected getPlaceholder(width: number, height: number): ReactNode {
const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD];
if (blurhash) {
if (this.state.placeholder === Placeholder.NoImage) {
return null;
} else if (this.state.placeholder === Placeholder.Blurhash) {
return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
}
}
return <Spinner size={32} />;
}
// Overridden by MStickerBody
protected getTooltipProps(): ComponentProps<typeof Tooltip> | null {
return null;
}
// Overridden by MStickerBody
protected getFileBody(): ReactNode {
if (this.props.forExport) return null;
/*
* In the room timeline or the thread context we don't need the download
* link as the message action bar will fulfill that
*/
const hasMessageActionBar =
this.context.timelineRenderingType === TimelineRenderingType.Room ||
this.context.timelineRenderingType === TimelineRenderingType.Pinned ||
this.context.timelineRenderingType === TimelineRenderingType.Search ||
this.context.timelineRenderingType === TimelineRenderingType.Thread ||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList;
if (!hasMessageActionBar) {
return renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
}
}
public render(): React.ReactNode {
const content = this.props.mxEvent.getContent<ImageContent>();
// Fall back to file-body view if we are unable to render this image e.g. in the case of a blob svg
if (
this.props.mediaEventHelper?.media.isEncrypted &&
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
!content.info?.thumbnail_info
) {
return renderMBody(this.props, FileBodyFactory);
}
if (this.state.error) {
let errorText = _t("timeline|m.image|error");
if (this.state.error instanceof DecryptError) {
errorText = _t("timeline|m.image|error_decrypting");
} else if (this.state.error instanceof DownloadError) {
errorText = _t("timeline|m.image|error_downloading");
}
return (
<MediaProcessingError className="mx_MImageBody" Icon={ImageErrorIcon}>
{errorText}
</MediaProcessingError>
);
}
let contentUrl = this.state.contentUrl;
let thumbUrl: string | null;
if (this.props.forExport) {
contentUrl = this.props.mxEvent.getContent().url ?? this.props.mxEvent.getContent().file?.url;
thumbUrl = contentUrl;
} else if (this.state.isAnimated && SettingsStore.getValue("autoplayGifs")) {
thumbUrl = contentUrl;
} else {
thumbUrl = this.state.thumbUrl ?? this.state.contentUrl;
}
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
const fileBody = this.getFileBody();
return (
<div className="mx_MImageBody">
{thumbnail}
{fileBody}
</div>
);
}
}
// Wrap MImageBody component so we can use a hook here.
const MImageBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
return <MImageBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};
export default MImageBody;

View File

@ -6,16 +6,621 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import React, { type JSX, type ComponentProps, createRef, type ReactNode } from "react";
import { Blurhash } from "react-blurhash";
import classNames from "classnames";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent } from "matrix-js-sdk/src/matrix";
import { type ImageContent } from "matrix-js-sdk/src/types";
import { Tooltip } from "@vector-im/compound-web";
import { ImageErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { HiddenMediaPlaceholder } from "@element-hq/web-shared-components";
import { MImageBodyInner } from "./MImageBody";
import Modal from "../../../Modal";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import Spinner from "../elements/Spinner";
import { type Media, mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media";
import ImageView from "../elements/ImageView";
import { type IBodyProps } from "./IBodyProps";
import { type ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { blobIsAnimated, mayBeAnimated } from "../../../utils/Image";
import { presentableTextForFile } from "../../../utils/FileUtils";
import { createReconnectedListener } from "../../../utils/connection";
import MediaProcessingError from "./shared/MediaProcessingError";
import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
import { isMimeTypeAllowed } from "../../../utils/blobs.ts";
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
enum Placeholder {
NoImage,
Blurhash,
}
interface IState {
contentUrl: string | null;
thumbUrl: string | null;
isAnimated?: boolean;
error?: unknown;
imgError: boolean;
imgLoaded: boolean;
loadedImageDimensions?: {
naturalWidth: number;
naturalHeight: number;
};
hover: boolean;
focus: boolean;
placeholder: Placeholder;
}
export interface ImageBodyBaseProps extends IBodyProps {
mediaVisible: boolean;
setMediaVisible: (visible: boolean) => void;
}
export class ImageBodyBaseInner extends React.Component<ImageBodyBaseProps, IState> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
private unmounted = false;
private image = createRef<HTMLImageElement>();
private placeholder = createRef<HTMLDivElement>();
private timeout?: number;
private sizeWatcher?: string;
public state: IState = {
contentUrl: null,
thumbUrl: null,
imgError: false,
imgLoaded: false,
hover: false,
focus: false,
placeholder: Placeholder.NoImage,
};
protected onClick = (ev: React.MouseEvent): void => {
if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault();
if (!this.props.mediaVisible) {
this.props.setMediaVisible(true);
return;
}
const content = this.props.mxEvent.getContent<ImageContent>();
let httpUrl = this.state.contentUrl;
if (
this.props.mediaEventHelper?.media.isEncrypted &&
!isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "")
) {
httpUrl = this.state.thumbUrl;
}
if (!httpUrl) return;
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
src: httpUrl,
name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"),
mxEvent: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
};
if (content.info) {
params.width = content.info.w;
params.height = content.info.h;
params.fileSize = content.info.size;
}
if (this.image.current) {
const clientRect = this.image.current.getBoundingClientRect();
params.thumbnailInfo = {
width: clientRect.width,
height: clientRect.height,
positionX: clientRect.x,
positionY: clientRect.y,
};
}
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
}
};
private get shouldAutoplay(): boolean {
return !(
!this.state.contentUrl ||
!this.props.mediaVisible ||
!this.state.isAnimated ||
SettingsStore.getValue("autoplayGifs")
);
}
protected onImageEnter = (): void => {
this.setState({ hover: true });
};
protected onImageLeave = (): void => {
this.setState({ hover: false });
};
private onFocus = (): void => {
this.setState({ focus: true });
};
private onBlur = (): void => {
this.setState({ focus: false });
};
private reconnectedListener = createReconnectedListener((): void => {
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
this.setState({ imgError: false });
});
private onImageError = (): void => {
if (this.state.thumbUrl) {
this.setState({
thumbUrl: null,
});
return;
}
this.clearBlurhashTimeout();
this.setState({
imgError: true,
});
MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener);
};
private onImageLoad = (): void => {
this.clearBlurhashTimeout();
let loadedImageDimensions: IState["loadedImageDimensions"];
if (this.image.current) {
const { naturalWidth, naturalHeight } = this.image.current;
loadedImageDimensions = { naturalWidth, naturalHeight };
}
this.setState({ imgLoaded: true, loadedImageDimensions });
};
private getContentUrl(): string | null {
if (this.props.forExport) return this.media.srcMxc;
return this.media.srcHttp;
}
private get media(): Media {
return mediaFromContent(this.props.mxEvent.getContent());
}
private getThumbUrl(): string | null {
const thumbWidth = 800;
const thumbHeight = 600;
const content = this.props.mxEvent.getContent<ImageContent>();
const media = mediaFromContent(content);
const info = content.info;
if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) {
return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale");
}
if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) {
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
}
const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight;
const isLargeFileSize = info.size > 1 * 1024 * 1024;
if (isLargeFileSize && isLargerThanThumbnail) {
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
}
return media.srcHttp;
}
private async downloadImage(): Promise<void> {
if (this.state.contentUrl) return;
let thumbUrl: string | null;
let contentUrl: string | null;
if (this.props.mediaEventHelper?.media.isEncrypted) {
try {
[contentUrl, thumbUrl] = await Promise.all([
this.props.mediaEventHelper.sourceUrl.value,
this.props.mediaEventHelper.thumbnailUrl.value,
]);
} catch (error) {
if (this.unmounted) return;
if (error instanceof DecryptError) {
logger.error("Unable to decrypt attachment: ", error);
} else if (error instanceof DownloadError) {
logger.error("Unable to download attachment to decrypt it: ", error);
} else {
logger.error("Error encountered when downloading encrypted attachment: ", error);
}
this.setState({ error });
return;
}
} else {
thumbUrl = this.getThumbUrl();
contentUrl = this.getContentUrl();
}
const content = this.props.mxEvent.getContent<ImageContent>();
let isAnimated = content.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(content.info?.mimetype);
if (isAnimated && !SettingsStore.getValue("autoplayGifs")) {
if (!thumbUrl || !content?.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) {
const img = document.createElement("img");
const loadPromise = new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
img.crossOrigin = "Anonymous";
img.src = contentUrl ?? "";
try {
await loadPromise;
} catch (error) {
logger.error("Unable to download attachment: ", error);
this.setState({ error: error as Error });
return;
}
try {
if (
content.info?.["org.matrix.msc4230.is_animated"] === false ||
(await blobIsAnimated(await this.props.mediaEventHelper!.sourceBlob.value)) === false
) {
isAnimated = false;
}
if (isAnimated) {
const thumb = await createThumbnail(
img,
img.width,
img.height,
content.info?.mimetype ?? "image/jpeg",
false,
);
thumbUrl = URL.createObjectURL(thumb.thumbnail);
}
} catch (error) {
logger.warn("Unable to generate thumbnail for animated image: ", error);
}
}
}
if (this.unmounted) return;
this.setState({
contentUrl,
thumbUrl,
isAnimated,
});
}
private clearBlurhashTimeout(): void {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
}
}
public componentDidMount(): void {
this.unmounted = false;
if (this.props.mediaVisible) {
void this.downloadImage();
}
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
this.clearBlurhashTimeout();
this.timeout = window.setTimeout(() => {
if (!this.state.imgLoaded || !this.state.imgError) {
this.setState({
placeholder: Placeholder.Blurhash,
});
}
}, 150);
}
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
this.forceUpdate();
});
}
public componentDidUpdate(prevProps: Readonly<ImageBodyBaseProps>): void {
if (!prevProps.mediaVisible && this.props.mediaVisible) {
void this.downloadImage();
}
}
public componentWillUnmount(): void {
this.unmounted = true;
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
this.clearBlurhashTimeout();
SettingsStore.unwatchSetting(this.sizeWatcher);
if (this.state.isAnimated && this.state.thumbUrl) {
URL.revokeObjectURL(this.state.thumbUrl);
}
}
protected getBanner(content: ImageContent): ReactNode {
if (
[TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType)
) {
return null;
}
return (
<span className="mx_MImageBody_banner">
{presentableTextForFile(content, _t("common|image"), true, true)}
</span>
);
}
protected messageContent(
contentUrl: string | null,
thumbUrl: string | null,
content: ImageContent,
forcedHeight?: number,
): ReactNode {
if (!thumbUrl) thumbUrl = contentUrl;
let infoWidth = 500;
let infoHeight = 500;
let infoSvg = false;
if (content.info?.w && content.info?.h) {
infoWidth = content.info.w;
infoHeight = content.info.h;
infoSvg = content.info.mimetype === "image/svg+xml";
} else if (thumbUrl && contentUrl) {
if (!this.state.loadedImageDimensions) {
let imageElement: JSX.Element;
if (!this.props.mediaVisible) {
imageElement = (
<HiddenMediaPlaceholder onClick={this.onClick}>
{_t("timeline|m.image|show_image")}
</HiddenMediaPlaceholder>
);
} else {
imageElement = (
<img
style={{ display: "none" }}
src={thumbUrl}
ref={this.image}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
/>
);
}
return this.wrapImage(contentUrl, imageElement);
}
infoWidth = this.state.loadedImageDimensions.naturalWidth;
infoHeight = this.state.loadedImageDimensions.naturalHeight;
}
const { w: maxWidth, h: maxHeight } = suggestedImageSize(
SettingsStore.getValue("Images.size") as ImageSize,
{ w: infoWidth, h: infoHeight },
forcedHeight ?? this.props.maxImageHeight,
);
let img: JSX.Element | undefined;
let placeholder: JSX.Element | undefined;
let gifLabel: JSX.Element | undefined;
if (!this.props.forExport && !this.state.imgLoaded) {
const classes = classNames("mx_MImageBody_placeholder", {
"mx_MImageBody_placeholder--blurhash": this.props.mxEvent.getContent().info?.[BLURHASH_FIELD],
});
placeholder = (
<div className={classes} ref={this.placeholder}>
{this.getPlaceholder(maxWidth, maxHeight)}
</div>
);
}
let showPlaceholder = Boolean(placeholder);
const hoverOrFocus = this.state.hover || this.state.focus;
if (thumbUrl && !this.state.imgError) {
let url = thumbUrl;
if (hoverOrFocus && this.shouldAutoplay) {
url = this.state.contentUrl!;
}
img = (
<img
className="mx_MImageBody_thumbnail"
src={url}
ref={this.image}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave}
/>
);
}
if (!this.props.mediaVisible) {
img = (
<div style={{ width: maxWidth, height: maxHeight }}>
<HiddenMediaPlaceholder onClick={this.onClick}>
{_t("timeline|m.image|show_image")}
</HiddenMediaPlaceholder>
</div>
);
showPlaceholder = false;
}
if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) {
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
}
let banner: ReactNode | undefined;
if (this.props.mediaVisible && hoverOrFocus) {
banner = this.getBanner(content);
}
const sizing = infoSvg ? { maxHeight, maxWidth, width: maxWidth } : { maxHeight, maxWidth };
if (!this.props.forExport) {
placeholder = (
<SwitchTransition mode="out-in">
<CSSTransition
classNames="mx_rtg--fade"
key={`img-${showPlaceholder}`}
timeout={300}
nodeRef={this.placeholder}
>
{showPlaceholder ? placeholder : <div ref={this.placeholder} />}
</CSSTransition>
</SwitchTransition>
);
}
const tooltipProps = this.getTooltipProps();
let thumbnail = (
<div
className="mx_MImageBody_thumbnail_container"
style={{ maxHeight, maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}
tabIndex={tooltipProps ? 0 : undefined}
>
{placeholder}
<div style={sizing}>
{img}
{gifLabel}
{banner}
</div>
{!this.props.forExport && !this.state.imgLoaded && !placeholder && (
<div style={{ height: maxHeight, width: maxWidth }} />
)}
</div>
);
if (tooltipProps) {
thumbnail = (
<Tooltip {...tooltipProps} isTriggerInteractive={true}>
{thumbnail}
</Tooltip>
);
}
return this.wrapImage(contentUrl, thumbnail);
}
protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode {
if (contentUrl) {
return (
<a
href={contentUrl}
target={this.props.forExport ? "_blank" : undefined}
onClick={this.onClick}
onFocus={this.onFocus}
onBlur={this.onBlur}
>
{children}
</a>
);
}
return children;
}
protected getPlaceholder(width: number, height: number): ReactNode {
const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD];
if (blurhash) {
if (this.state.placeholder === Placeholder.NoImage) {
return null;
} else if (this.state.placeholder === Placeholder.Blurhash) {
return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
}
}
return <Spinner size={32} />;
}
protected getTooltipProps(): ComponentProps<typeof Tooltip> | null {
return null;
}
protected getFileBody(): ReactNode {
if (this.props.forExport) return null;
const hasMessageActionBar =
this.context.timelineRenderingType === TimelineRenderingType.Room ||
this.context.timelineRenderingType === TimelineRenderingType.Pinned ||
this.context.timelineRenderingType === TimelineRenderingType.Search ||
this.context.timelineRenderingType === TimelineRenderingType.Thread ||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList;
if (!hasMessageActionBar) {
return renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
}
}
public render(): React.ReactNode {
const content = this.props.mxEvent.getContent<ImageContent>();
if (
this.props.mediaEventHelper?.media.isEncrypted &&
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
!content.info?.thumbnail_info
) {
return renderMBody(this.props, FileBodyFactory);
}
if (this.state.error) {
let errorText = _t("timeline|m.image|error");
if (this.state.error instanceof DecryptError) {
errorText = _t("timeline|m.image|error_decrypting");
} else if (this.state.error instanceof DownloadError) {
errorText = _t("timeline|m.image|error_downloading");
}
return (
<MediaProcessingError className="mx_MImageBody" Icon={ImageErrorIcon}>
{errorText}
</MediaProcessingError>
);
}
let contentUrl = this.state.contentUrl;
let thumbUrl: string | null;
if (this.props.forExport) {
contentUrl = this.props.mxEvent.getContent().url ?? this.props.mxEvent.getContent().file?.url;
thumbUrl = contentUrl;
} else if (this.state.isAnimated && SettingsStore.getValue("autoplayGifs")) {
thumbUrl = contentUrl;
} else {
thumbUrl = this.state.thumbUrl ?? this.state.contentUrl;
}
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
const fileBody = this.getFileBody();
return (
<div className="mx_MImageBody">
{thumbnail}
{fileBody}
</div>
);
}
}
const FORCED_IMAGE_HEIGHT = 44;
class MImageReplyBodyInner extends MImageBodyInner {
class MImageReplyBodyInner extends ImageBodyBaseInner {
public onClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
};
@ -37,6 +642,7 @@ class MImageReplyBodyInner extends MImageBodyInner {
return <div className="mx_MImageReplyBody">{thumbnail}</div>;
}
}
const MImageReplyBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
return <MImageReplyBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;

View File

@ -9,13 +9,13 @@ import React, { type JSX, type ComponentProps, type ReactNode } from "react";
import { type Tooltip } from "@vector-im/compound-web";
import { type MediaEventContent } from "matrix-js-sdk/src/types";
import { MImageBodyInner } from "./MImageBody";
import { ImageBodyBaseInner } from "./MImageReplyBody";
import { BLURHASH_FIELD } from "../../../utils/image-media";
import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg";
import { type IBodyProps } from "./IBodyProps";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
class MStickerBodyInner extends MImageBodyInner {
class MStickerBodyInner extends ImageBodyBaseInner {
// Mostly empty to prevent default behaviour of MImageBody
protected onClick = (ev: React.MouseEvent): void => {
ev.preventDefault();

View File

@ -25,7 +25,6 @@ import { Mjolnir } from "../../../mjolnir/Mjolnir";
import { type IMediaBody } from "./IMediaBody";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { type IBodyProps } from "./IBodyProps";
import MImageBody from "./MImageBody";
import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
import MStickerBody from "./MStickerBody";
import MPollBody from "./MPollBody";
@ -36,6 +35,7 @@ import { MjolnirBodyViewModel } from "../../../viewmodels/room/timeline/event-ti
import {
DecryptionFailureBodyFactory,
FileBodyFactory,
ImageBodyFactory,
RedactedBodyFactory,
VideoBodyFactory,
renderMBody,
@ -67,7 +67,7 @@ const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
[MsgType.Text, TextualBodyFactory],
[MsgType.Notice, TextualBodyFactory],
[MsgType.Emote, TextualBodyFactory],
[MsgType.Image, MImageBody],
[MsgType.Image, ImageBodyFactory],
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
[MsgType.Audio, MVoiceOrAudioBody],
[MsgType.Video, VideoBodyFactory],
@ -283,7 +283,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
}
if (
((BodyType === MImageBody || BodyType === VideoBodyFactory) &&
((BodyType === ImageBodyFactory || BodyType === VideoBodyFactory) &&
!this.validateImageOrVideoMimetype(content)) ||
(BodyType === MStickerBody && !this.validateStickerMimetype(content))
) {

View File

@ -0,0 +1,674 @@
/*
* 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 { type ComponentProps, type MouseEvent, type RefObject } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type ImageContent } from "matrix-js-sdk/src/types";
import {
BaseViewModel,
ImageBodyViewPlaceholder,
ImageBodyViewState,
type ImageBodyViewModel as ImageBodyViewModelInterface,
type ImageBodyViewSnapshot,
} from "@element-hq/web-shared-components";
import Modal from "../../Modal";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { _t } from "../../languageHandler";
import { mediaFromContent } from "../../customisations/Media";
import { TimelineRenderingType } from "../../contexts/RoomContext";
import SettingsStore from "../../settings/SettingsStore";
import { type ImageSize, suggestedSize as suggestedImageSize } from "../../settings/enums/ImageSize";
import { presentableTextForFile } from "../../utils/FileUtils";
import { type MediaEventHelper } from "../../utils/MediaEventHelper";
import { blobIsAnimated, mayBeAnimated } from "../../utils/Image";
import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import { createReconnectedListener } from "../../utils/connection";
import { DecryptError, DownloadError } from "../../utils/DecryptFile";
import { BLURHASH_FIELD, createThumbnail } from "../../utils/image-media";
import { isMimeTypeAllowed } from "../../utils/blobs";
import ImageView from "../../components/views/elements/ImageView";
export interface ImageBodyViewModelProps {
/**
* Image event being rendered.
*/
mxEvent: MatrixEvent;
/**
* Helper for resolving encrypted media sources.
*/
mediaEventHelper?: MediaEventHelper;
/**
* Whether the image is being rendered for export instead of the live timeline.
*/
forExport?: boolean;
/**
* Optional maximum height applied when computing the rendered image dimensions.
*/
maxImageHeight?: number;
/**
* Whether the media should currently be shown instead of the hidden-media preview.
*/
mediaVisible: boolean;
/**
* Permalink helper passed to the image lightbox.
*/
permalinkCreator?: RoomPermalinkCreator;
/**
* Timeline context used to decide which labels and supplemental content should be shown.
*/
timelineRenderingType: TimelineRenderingType;
/**
* Ref to the underlying image element used for load dimensions and lightbox animation.
*/
imageRef: RefObject<HTMLImageElement | null>;
/**
* Callback invoked when hidden media is revealed.
*/
setMediaVisible?: (visible: boolean) => void;
}
interface LoadedImageDimensions {
naturalWidth: number;
naturalHeight: number;
}
interface InternalState {
contentUrl: string | null;
thumbUrl: string | null;
isAnimated: boolean;
error: unknown | null;
imgError: boolean;
imgLoaded: boolean;
loadedImageDimensions?: LoadedImageDimensions;
placeholder: ImageBodyViewPlaceholder;
imageSize: ImageSize;
generatedThumbnailUrl: string | null;
}
type ImageInfoWithAnimationFlag = NonNullable<ImageContent["info"]> & {
"org.matrix.msc4230.is_animated"?: boolean;
};
/**
* View model for the image message body, encapsulating media loading, sizing,
* visibility, animated-image previews, and lightbox interactions.
*/
export class ImageBodyViewModel
extends BaseViewModel<ImageBodyViewSnapshot, ImageBodyViewModelProps>
implements ImageBodyViewModelInterface
{
private state: InternalState;
private blurhashTimeout?: number;
private readonly reconnectedListener = createReconnectedListener((): void => {
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
if (!this.state.imgError) {
return;
}
this.state = {
...this.state,
imgError: false,
};
this.updateSnapshotFromState();
});
public constructor(props: ImageBodyViewModelProps) {
const initialState = ImageBodyViewModel.createInitialState(props.mxEvent);
super(props, ImageBodyViewModel.computeSnapshot(props, initialState));
this.state = initialState;
const imageSizeWatcherRef = SettingsStore.watchSetting("Images.size", null, (_s, _r, _l, _nvl, value) => {
this.setImageSize(value as ImageSize);
});
this.disposables.track(() => SettingsStore.unwatchSetting(imageSizeWatcherRef));
}
private static createInitialState(mxEvent: MatrixEvent): InternalState {
return {
contentUrl: null,
thumbUrl: null,
isAnimated: false,
error: null,
imgError: false,
imgLoaded: false,
loadedImageDimensions: undefined,
placeholder: mxEvent.getContent<ImageContent>().info?.[BLURHASH_FIELD]
? ImageBodyViewPlaceholder.NONE
: ImageBodyViewPlaceholder.SPINNER,
imageSize: SettingsStore.getValue("Images.size") as ImageSize,
generatedThumbnailUrl: null,
};
}
private static getImageDimensions(
props: ImageBodyViewModelProps,
state: InternalState,
): Pick<ImageBodyViewSnapshot, "maxWidth" | "maxHeight" | "aspectRatio" | "isSvg"> {
const content = props.mxEvent.getContent<ImageContent>();
const info = content.info;
const naturalWidth = info?.w ?? state.loadedImageDimensions?.naturalWidth;
const naturalHeight = info?.h ?? state.loadedImageDimensions?.naturalHeight;
if (!naturalWidth || !naturalHeight) {
return {
maxWidth: undefined,
maxHeight: undefined,
aspectRatio: undefined,
isSvg: info?.mimetype === "image/svg+xml",
};
}
const { w: maxWidth, h: maxHeight } = suggestedImageSize(
state.imageSize,
{ w: naturalWidth, h: naturalHeight },
props.maxImageHeight,
);
return {
maxWidth,
maxHeight,
aspectRatio: `${naturalWidth}/${naturalHeight}`,
isSvg: info?.mimetype === "image/svg+xml",
};
}
private static computeErrorLabel(error: unknown, imgError: boolean): string {
if (error instanceof DecryptError) return _t("timeline|m.image|error_decrypting");
if (error instanceof DownloadError) return _t("timeline|m.image|error_downloading");
if (imgError || error) return _t("timeline|m.image|error");
return _t("timeline|m.image|error");
}
private static shouldShowBanner(timelineRenderingType: TimelineRenderingType): boolean {
return ![TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(timelineRenderingType);
}
private static computeSnapshot(props: ImageBodyViewModelProps, state: InternalState): ImageBodyViewSnapshot {
const content = props.mxEvent.getContent<ImageContent>();
const dimensions = ImageBodyViewModel.getImageDimensions(props, state);
const autoplayGifs = SettingsStore.getValue("autoplayGifs") as boolean;
const contentUrl = ImageBodyViewModel.getContentUrl(props, state);
const thumbnailSrc = props.forExport
? (contentUrl ?? undefined)
: state.isAnimated && autoplayGifs
? (contentUrl ?? undefined)
: (state.thumbUrl ?? contentUrl ?? undefined);
if (state.error || state.imgError) {
return {
state: ImageBodyViewState.ERROR,
errorLabel: ImageBodyViewModel.computeErrorLabel(state.error, state.imgError),
...dimensions,
};
}
if (!props.mediaVisible) {
return {
state: ImageBodyViewState.HIDDEN,
hiddenButtonLabel: _t("timeline|m.image|show_image"),
...dimensions,
};
}
return {
state: ImageBodyViewState.READY,
alt: content.body,
src: contentUrl ?? undefined,
thumbnailSrc,
showAnimatedContentOnHover: state.isAnimated && !autoplayGifs && !!contentUrl,
placeholder: !props.forExport && !state.imgLoaded ? state.placeholder : ImageBodyViewPlaceholder.NONE,
blurhash: content.info?.[BLURHASH_FIELD],
gifLabel: state.isAnimated && !autoplayGifs ? "GIF" : undefined,
bannerLabel: ImageBodyViewModel.shouldShowBanner(props.timelineRenderingType)
? presentableTextForFile(content, _t("common|image"), true, true)
: undefined,
linkUrl: contentUrl ?? undefined,
linkTarget: props.forExport ? "_blank" : undefined,
...dimensions,
};
}
private static getContentUrl(props: ImageBodyViewModelProps, state: InternalState): string | null {
if (props.forExport) {
return (
props.mxEvent.getContent<ImageContent>().url ??
props.mxEvent.getContent<ImageContent>().file?.url ??
null
);
}
return state.contentUrl;
}
public loadInitialMediaIfVisible(): void {
if (!this.props.mediaVisible) {
return;
}
this.scheduleBlurhashPlaceholder();
void this.downloadImage();
}
private updateSnapshotFromState(): void {
this.snapshot.set(ImageBodyViewModel.computeSnapshot(this.props, this.state));
}
private resetState(mxEvent: MatrixEvent): void {
this.clearBlurhashTimeout();
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
this.revokeGeneratedThumbnailUrl();
this.state = ImageBodyViewModel.createInitialState(mxEvent);
}
private revokeGeneratedThumbnailUrl(): void {
if (!this.state.generatedThumbnailUrl) {
return;
}
URL.revokeObjectURL(this.state.generatedThumbnailUrl);
this.state = {
...this.state,
generatedThumbnailUrl: null,
};
}
private clearBlurhashTimeout(): void {
if (!this.blurhashTimeout) {
return;
}
clearTimeout(this.blurhashTimeout);
this.blurhashTimeout = undefined;
}
private scheduleBlurhashPlaceholder(): void {
if (
!this.props.mxEvent.getContent<ImageContent>().info?.[BLURHASH_FIELD] ||
this.state.imgLoaded ||
this.state.imgError
) {
return;
}
this.clearBlurhashTimeout();
this.blurhashTimeout = window.setTimeout(() => {
if (this.isDisposed || this.state.imgLoaded || this.state.imgError) {
return;
}
this.state = {
...this.state,
placeholder: ImageBodyViewPlaceholder.BLURHASH,
};
this.snapshot.merge({ placeholder: ImageBodyViewPlaceholder.BLURHASH });
}, 150);
}
private getThumbUrl(): string | null {
const thumbWidth = 800;
const thumbHeight = 600;
const content = this.props.mxEvent.getContent<ImageContent>();
const media = mediaFromContent(content);
const info = content.info;
if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) {
return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale");
}
if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) {
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
}
const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight;
const isLargeFileSize = info.size > 1 * 1024 * 1024;
if (isLargeFileSize && isLargerThanThumbnail) {
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
}
return media.srcHttp;
}
private async downloadImage(): Promise<void> {
if (this.state.contentUrl || this.props.forExport) {
return;
}
let thumbUrl: string | null;
let contentUrl: string | null;
if (this.props.mediaEventHelper?.media.isEncrypted) {
try {
[contentUrl, thumbUrl] = await Promise.all([
this.props.mediaEventHelper.sourceUrl.value,
this.props.mediaEventHelper.thumbnailUrl.value,
]);
} catch (error) {
if (this.isDisposed) {
return;
}
if (error instanceof DecryptError) {
logger.error("Unable to decrypt attachment: ", error);
} else if (error instanceof DownloadError) {
logger.error("Unable to download attachment to decrypt it: ", error);
} else {
logger.error("Error encountered when downloading encrypted attachment: ", error);
}
this.state = {
...this.state,
error: error as Error,
};
this.updateSnapshotFromState();
return;
}
} else {
contentUrl = mediaFromContent(this.props.mxEvent.getContent<ImageContent>()).srcHttp;
thumbUrl = this.getThumbUrl();
}
const content = this.props.mxEvent.getContent<ImageContent>();
let generatedThumbnailUrl: string | null = null;
let isAnimated = (content.info as ImageInfoWithAnimationFlag | undefined)?.["org.matrix.msc4230.is_animated"];
if (isAnimated === undefined) {
isAnimated = mayBeAnimated(content.info?.mimetype);
}
const autoplayGifs = SettingsStore.getValue("autoplayGifs") as boolean;
if (isAnimated && !autoplayGifs) {
if (!thumbUrl || !content.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) {
const image = document.createElement("img");
const loadPromise = new Promise<void>((resolve, reject) => {
image.onload = (): void => resolve();
image.onerror = (): void => reject(new Error("Unable to load image"));
});
image.crossOrigin = "Anonymous";
image.src = contentUrl ?? "";
try {
await loadPromise;
} catch (error) {
logger.error("Unable to download attachment: ", error);
this.state = {
...this.state,
error: error as Error,
};
this.updateSnapshotFromState();
return;
}
try {
if (
(content.info as ImageInfoWithAnimationFlag | undefined)?.["org.matrix.msc4230.is_animated"] ===
false ||
(this.props.mediaEventHelper &&
(await blobIsAnimated(await this.props.mediaEventHelper.sourceBlob.value)) === false)
) {
isAnimated = false;
}
if (isAnimated) {
const thumbnail = await createThumbnail(
image,
image.width,
image.height,
content.info?.mimetype ?? "image/jpeg",
false,
);
generatedThumbnailUrl = URL.createObjectURL(thumbnail.thumbnail);
thumbUrl = generatedThumbnailUrl;
}
} catch (error) {
logger.warn("Unable to generate thumbnail for animated image: ", error);
}
}
}
if (this.isDisposed) {
if (generatedThumbnailUrl) {
URL.revokeObjectURL(generatedThumbnailUrl);
}
return;
}
this.revokeGeneratedThumbnailUrl();
this.state = {
...this.state,
contentUrl,
thumbUrl,
isAnimated,
error: null,
generatedThumbnailUrl,
};
this.updateSnapshotFromState();
}
private openImageViewer(event: MouseEvent<HTMLAnchorElement>): void {
if (event.button !== 0 || event.metaKey) {
return;
}
event.preventDefault();
if (!this.props.mediaVisible) {
this.props.setMediaVisible?.(true);
return;
}
const content = this.props.mxEvent.getContent<ImageContent>();
let httpUrl = this.state.contentUrl;
if (
this.props.mediaEventHelper?.media.isEncrypted &&
!isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "")
) {
httpUrl = this.state.thumbUrl;
}
if (!httpUrl) {
return;
}
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
src: httpUrl,
name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"),
mxEvent: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
};
if (content.info) {
params.width = content.info.w;
params.height = content.info.h;
params.fileSize = content.info.size;
}
if (this.props.imageRef.current) {
const clientRect = this.props.imageRef.current.getBoundingClientRect();
params.thumbnailInfo = {
width: clientRect.width,
height: clientRect.height,
positionX: clientRect.x,
positionY: clientRect.y,
};
}
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
}
public onLinkClick = (event: MouseEvent<HTMLAnchorElement>): void => {
this.openImageViewer(event);
};
public onHiddenButtonClick = (): void => {
this.props.setMediaVisible?.(true);
};
public onImageError = (): void => {
if (this.state.thumbUrl && this.state.thumbUrl !== this.state.contentUrl) {
this.state = {
...this.state,
thumbUrl: null,
};
this.updateSnapshotFromState();
return;
}
this.clearBlurhashTimeout();
if (this.state.imgError) {
return;
}
this.state = {
...this.state,
imgError: true,
};
MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener);
this.updateSnapshotFromState();
};
public onImageLoad = (): void => {
this.clearBlurhashTimeout();
let loadedImageDimensions: LoadedImageDimensions | undefined;
if (this.props.imageRef.current) {
const { naturalWidth, naturalHeight } = this.props.imageRef.current;
loadedImageDimensions = { naturalWidth, naturalHeight };
}
this.state = {
...this.state,
imgLoaded: true,
loadedImageDimensions,
placeholder: ImageBodyViewPlaceholder.NONE,
};
this.updateSnapshotFromState();
};
public setEvent(mxEvent: MatrixEvent, mediaEventHelper?: MediaEventHelper): void {
if (this.props.mxEvent === mxEvent && this.props.mediaEventHelper === mediaEventHelper) {
return;
}
const previousVisible = this.props.mediaVisible;
this.props = {
...this.props,
mxEvent,
mediaEventHelper,
};
this.resetState(mxEvent);
this.updateSnapshotFromState();
if (previousVisible) {
this.scheduleBlurhashPlaceholder();
void this.downloadImage();
}
}
public setForExport(forExport?: boolean): void {
if (this.props.forExport === forExport) {
return;
}
this.props = {
...this.props,
forExport,
};
this.updateSnapshotFromState();
}
public setMaxImageHeight(maxImageHeight?: number): void {
if (this.props.maxImageHeight === maxImageHeight) {
return;
}
this.props = {
...this.props,
maxImageHeight,
};
this.updateSnapshotFromState();
}
public setMediaVisible(mediaVisible: boolean): void {
if (this.props.mediaVisible === mediaVisible) {
return;
}
const wasVisible = this.props.mediaVisible;
this.props = {
...this.props,
mediaVisible,
};
this.updateSnapshotFromState();
if (!wasVisible && mediaVisible) {
this.scheduleBlurhashPlaceholder();
void this.downloadImage();
}
}
public setPermalinkCreator(permalinkCreator?: RoomPermalinkCreator): void {
if (this.props.permalinkCreator === permalinkCreator) {
return;
}
this.props = {
...this.props,
permalinkCreator,
};
}
public setTimelineRenderingType(timelineRenderingType: TimelineRenderingType): void {
if (this.props.timelineRenderingType === timelineRenderingType) {
return;
}
this.props = {
...this.props,
timelineRenderingType,
};
this.snapshot.merge(ImageBodyViewModel.computeSnapshot(this.props, this.state));
}
public setSetMediaVisible(setMediaVisible?: (visible: boolean) => void): void {
if (this.props.setMediaVisible === setMediaVisible) {
return;
}
this.props = {
...this.props,
setMediaVisible,
};
}
private setImageSize(imageSize: ImageSize): void {
if (this.state.imageSize === imageSize) {
return;
}
this.state = {
...this.state,
imageSize,
};
this.updateSnapshotFromState();
}
public dispose(): void {
this.clearBlurhashTimeout();
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
this.revokeGeneratedThumbnailUrl();
super.dispose();
}
}

View File

@ -20,17 +20,26 @@ import {
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import {
DecryptionFailureBodyFactory,
FileBodyFactory,
ImageBodyFactory,
RedactedBodyFactory,
VideoBodyFactory,
renderMBody,
} from "../../../../../src/components/views/messages/MBodyFactory";
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible";
jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn(),
}));
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
__esModule: true,
useMediaVisible: jest.fn(),
}));
describe("MBodyFactory", () => {
const userId = "@user:server";
const deviceId = "DEADB33F";
@ -59,7 +68,7 @@ describe("MBodyFactory", () => {
onMessageAllowed: jest.fn(),
permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)),
};
const mkEvent = (msgtype?: string): MatrixEvent =>
const mkEvent = (msgtype?: string, content: Record<string, unknown> = {}): MatrixEvent =>
new MatrixEvent({
room_id: "!room:server",
sender: userId,
@ -68,13 +77,26 @@ describe("MBodyFactory", () => {
body: "alt",
...(msgtype ? { msgtype } : {}),
url: "mxc://server/file",
...content,
},
});
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockRestore();
jest.mocked(useMediaVisible).mockReturnValue([true, jest.fn()]);
});
const encryptedImageHelper = (): MediaEventHelper =>
({
media: { isEncrypted: true },
sourceUrl: { value: Promise.resolve("blob:source") },
thumbnailUrl: { value: Promise.resolve("blob:thumbnail") },
sourceBlob: {
value: Promise.resolve(new Blob(["image"], { type: "image/jpeg" })),
cachedValue: new Blob(["image"], { type: "image/jpeg" }),
},
}) as unknown as MediaEventHelper;
describe("renderMBody", () => {
it("renders download button for m.file in file rendering type", () => {
const mediaEvent = mkEvent("m.file");
@ -102,6 +124,10 @@ describe("MBodyFactory", () => {
expect(renderMBody({ ...props, mxEvent: mkEvent("m.video") })?.type).toBe(VideoBodyFactory);
});
it("returns the image body factory for m.image", () => {
expect(renderMBody({ ...props, mxEvent: mkEvent("m.image") })?.type).toBe(ImageBodyFactory);
});
it("returns null when msgtype is missing", () => {
expect(renderMBody({ ...props, mxEvent: mkEvent() })).toBeNull();
});
@ -156,4 +182,160 @@ describe("MBodyFactory", () => {
expect(container).toMatchSnapshot();
},
);
describe("ImageBodyFactory", () => {
const imageContent = {
info: {
mimetype: "image/jpeg",
w: 320,
h: 240,
size: 48_000,
},
};
it("renders the shared image view in room timelines", () => {
const mediaEvent = mkEvent("m.image", imageContent);
const { container } = render(
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
<ImageBodyFactory
{...props}
mxEvent={mediaEvent}
mediaEventHelper={new MediaEventHelper(mediaEvent)}
/>
</ScopedRoomContextProvider>,
);
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
expect(container.querySelector(".mx_MFileBody")).toBeNull();
});
it("renders the file fallback child in notification timelines", () => {
const mediaEvent = mkEvent("m.image", imageContent);
const { container, getByRole } = render(
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Notification } as any)}>
<ImageBodyFactory
{...props}
mxEvent={mediaEvent}
mediaEventHelper={new MediaEventHelper(mediaEvent)}
/>
</ScopedRoomContextProvider>,
);
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
expect(getByRole("link", { name: /Download/ })).toBeInTheDocument();
});
it("renders only a file body for encrypted unsafe images without thumbnails", () => {
const mediaEvent = mkEvent("m.image", {
file: { url: "mxc://server/encrypted-file" },
url: undefined,
info: {
mimetype: "text/html",
},
});
const { container, getByRole } = render(
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
<ImageBodyFactory
{...props}
mxEvent={mediaEvent}
mediaEventHelper={{ media: { isEncrypted: true } } as MediaEventHelper}
/>
</ScopedRoomContextProvider>,
);
expect(container.querySelector(".mx_ImageBody")).toBeNull();
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
expect(getByRole("button", { name: "alt" })).toBeInTheDocument();
});
it("keeps the image body for encrypted unsafe images when a thumbnail is available", () => {
const mediaEvent = mkEvent("m.image", {
file: { url: "mxc://server/encrypted-file" },
url: undefined,
info: {
mimetype: "text/html",
thumbnail_info: { mimetype: "image/jpeg" },
},
});
const { container } = render(
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
<ImageBodyFactory {...props} mxEvent={mediaEvent} mediaEventHelper={encryptedImageHelper()} />
</ScopedRoomContextProvider>,
);
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
expect(container.querySelector(".mx_MFileBody")).toBeNull();
});
});
describe("VideoBodyFactory", () => {
const videoContent = {
info: {
mimetype: "video/mp4",
w: 320,
h: 240,
size: 48_000,
},
};
it("renders without a file fallback in room timelines", () => {
const mediaEvent = mkEvent("m.video", videoContent);
const { container } = render(
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
<VideoBodyFactory
mxEvent={mediaEvent}
mediaEventHelper={new MediaEventHelper(mediaEvent)}
forExport={false}
/>
</ScopedRoomContextProvider>,
);
expect(container.querySelector(".mx_MVideoBody")).not.toBeNull();
expect(container.querySelector(".mx_MFileBody")).toBeNull();
});
it("renders the file fallback child outside room timelines", () => {
const mediaEvent = mkEvent("m.video", videoContent);
const { container, getByRole } = render(
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Notification } as any)}>
<VideoBodyFactory
mxEvent={mediaEvent}
mediaEventHelper={new MediaEventHelper(mediaEvent)}
forExport={false}
/>
</ScopedRoomContextProvider>,
);
expect(container.querySelector(".mx_MVideoBody")).not.toBeNull();
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
expect(getByRole("link", { name: /Download/ })).toBeInTheDocument();
});
});
it("renders the redacted body wrapper", () => {
const mediaEvent = mkEvent("m.text");
const { container } = render(<RedactedBodyFactory mxEvent={mediaEvent} />);
expect(container.querySelector(".mx_RedactedBody")).not.toBeNull();
});
it("renders the decryption failure body wrapper", () => {
const mediaEvent = mkEvent("m.text");
Object.defineProperty(mediaEvent, "decryptionFailureReason", {
configurable: true,
value: "MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
});
const { container } = render(<DecryptionFailureBodyFactory mxEvent={mediaEvent} />);
expect(container.querySelector(".mx_DecryptionFailureBody")).not.toBeNull();
});
});

View File

@ -1,376 +0,0 @@
/*
Copyright 2024, 2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { fireEvent, render, screen, waitFor, waitForElementToBeRemoved, within } from "jest-matrix-react";
import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import fetchMock from "@fetch-mock/jest";
import encrypt from "matrix-encrypt-attachment";
import { mocked } from "jest-mock";
import fs from "fs";
import path from "path";
import userEvent from "@testing-library/user-event";
import MImageBody from "../../../../../src/components/views/messages/MImageBody";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import {
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsDevice,
mockClientMethodsServer,
mockClientMethodsUser,
withClientContextRenderOptions,
} from "../../../../test-utils";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn(),
}));
describe("<MImageBody/>", () => {
const ourUserId = "@user:server";
const senderUserId = "@other_use:server";
const deviceId = "DEADB33F";
const cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(ourUserId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(),
getRooms: jest.fn().mockReturnValue([]),
getRoom: jest.fn(),
getIgnoredUsers: jest.fn(),
getVersions: jest.fn().mockResolvedValue({
unstable_features: {
"org.matrix.msc3882": true,
"org.matrix.msc3886": true,
},
}),
});
const url = "https://server/_matrix/media/v3/download/server/encrypted-image";
// eslint-disable-next-line no-restricted-properties
cli.mxcUrlToHttp.mockImplementation(
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
},
);
const encryptedMediaEvent = new MatrixEvent({
event_id: "$foo:bar",
room_id: "!room:server",
sender: senderUserId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
info: {
w: 40,
h: 50,
mimetype: "image/png",
},
file: {
url: "mxc://server/encrypted-image",
},
},
});
const props = {
onMessageAllowed: jest.fn(),
permalinkCreator: new RoomPermalinkCreator(new Room(encryptedMediaEvent.getRoomId()!, cli, cli.getUserId()!)),
};
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockRestore();
fetchMock.mockReset();
});
afterEach(() => {
SettingsStore.reset();
mocked(encrypt.decryptAttachment).mockReset();
});
it("should show a thumbnail while image is being downloaded", async () => {
fetchMock.getOnce(url, { status: 200 });
const { container } = render(
<MImageBody
{...props}
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
withClientContextRenderOptions(cli),
);
// thumbnail with dimensions present
expect(container).toMatchSnapshot();
});
it("should show error when encrypted media cannot be downloaded", async () => {
fetchMock.getOnce(url, { status: 500 });
render(
<MImageBody
{...props}
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
withClientContextRenderOptions(cli),
);
expect(fetchMock).toHaveFetched(url);
await screen.findByText("Error downloading image");
});
it("should show error when encrypted media cannot be decrypted", async () => {
fetchMock.getOnce(url, "thisistotallyanencryptedpng");
mocked(encrypt.decryptAttachment).mockRejectedValue(new Error("Failed to decrypt"));
render(
<MImageBody
{...props}
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
withClientContextRenderOptions(cli),
);
await screen.findByText("Error decrypting image");
});
describe("with image previews/thumbnails disabled", () => {
beforeEach(() => {
const origFn = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
if (setting === "mediaPreviewConfig") {
return { invite_avatars: MediaPreviewValue.Off, media_previews: MediaPreviewValue.Off };
}
return origFn(setting, ...args);
});
});
it("should not download image", async () => {
fetchMock.getOnce(url, { status: 200 });
render(
<MImageBody
{...props}
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
withClientContextRenderOptions(cli),
);
expect(screen.getByText("Show image")).toBeInTheDocument();
expect(fetchMock).toHaveFetchedTimes(0, url);
});
it("should render hidden image placeholder", async () => {
fetchMock.getOnce(url, { status: 200 });
render(
<MImageBody
{...props}
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
withClientContextRenderOptions(cli),
);
expect(screen.getByText("Show image")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button"));
expect(fetchMock).toHaveFetched(url);
// Show image is asynchronous since it applies through a settings watcher hook, so
// be sure to wait here.
await waitFor(() => {
// spinner while downloading image
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
});
});
it("should fall back to /download/ if /thumbnail/ fails", async () => {
const thumbUrl = "https://server/_matrix/media/v3/thumbnail/server/image?width=800&height=600&method=scale";
const downloadUrl = "https://server/_matrix/media/v3/download/server/image";
const event = new MatrixEvent({
room_id: "!room:server",
sender: senderUserId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
info: {
w: 40,
h: 50,
},
url: "mxc://server/image",
},
});
const { container } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
withClientContextRenderOptions(cli),
);
const img = container.querySelector(".mx_MImageBody_thumbnail")!;
expect(img).toHaveProperty("src", thumbUrl);
fireEvent.error(img);
expect(img).toHaveProperty("src", downloadUrl);
});
it("should generate a thumbnail if one isn't included for animated media", async () => {
Object.defineProperty(global.Image.prototype, "src", {
set(src) {
window.setTimeout(() => this.onload?.());
},
});
Object.defineProperty(global.Image.prototype, "height", {
get() {
return 600;
},
});
Object.defineProperty(global.Image.prototype, "width", {
get() {
return 800;
},
});
mocked(global.URL.createObjectURL).mockReturnValue("blob:generated-thumb");
fetchMock.getOnce("https://server/_matrix/media/v3/download/server/image", {
body: fs.readFileSync(path.resolve(__dirname, "..", "..", "..", "images", "animated-logo.webp")),
});
const event = new MatrixEvent({
room_id: "!room:server",
sender: senderUserId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
info: {
w: 40,
h: 50,
mimetype: "image/webp",
},
url: "mxc://server/image",
},
});
const { container } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
withClientContextRenderOptions(cli),
);
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
// thumbnail with dimensions present
expect(container).toMatchSnapshot();
});
it("should show banner on hover", async () => {
const event = new MatrixEvent({
room_id: "!room:server",
sender: senderUserId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
info: {
w: 40,
h: 50,
},
url: "mxc://server/image",
},
});
const { container } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
withClientContextRenderOptions(cli),
);
const img = container.querySelector(".mx_MImageBody_thumbnail")!;
await userEvent.hover(img);
expect(container.querySelector(".mx_MImageBody_banner")).toHaveTextContent("...alt for a test image");
});
it("should render MFileBody for svg with no thumbnail", async () => {
const event = new MatrixEvent({
room_id: "!room:server",
sender: senderUserId,
type: EventType.RoomMessage,
content: {
info: {
w: 40,
h: 50,
mimetype: "image/svg+xml",
},
file: {
url: "mxc://server/encrypted-svg",
},
},
});
const { container, asFragment } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
withClientContextRenderOptions(cli),
);
expect(container.querySelector(".mx_MFileBody")).toHaveTextContent("Attachment");
expect(asFragment()).toMatchSnapshot();
});
it("should open ImageView using thumbnail for encrypted svg", async () => {
const url = "https://server/_matrix/media/v3/download/server/encrypted-svg";
fetchMock.getOnce(url, { status: 200 });
const thumbUrl = "https://server/_matrix/media/v3/download/server/svg-thumbnail";
fetchMock.getOnce(thumbUrl, { status: 200 });
const event = new MatrixEvent({
room_id: "!room:server",
sender: senderUserId,
type: EventType.RoomMessage,
origin_server_ts: 1234567890,
content: {
info: {
w: 40,
h: 50,
mimetype: "image/svg+xml",
thumbnail_file: {
url: "mxc://server/svg-thumbnail",
},
thumbnail_info: { mimetype: "image/png" },
},
file: {
url: "mxc://server/encrypted-svg",
},
},
});
const mediaEventHelper = new MediaEventHelper(event);
mediaEventHelper.thumbnailUrl["prom"] = Promise.resolve(thumbUrl);
mediaEventHelper.sourceUrl["prom"] = Promise.resolve(url);
const { findByRole } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={mediaEventHelper} />,
withClientContextRenderOptions(cli),
);
fireEvent.click(await findByRole("link"));
const dialog = await screen.findByRole("dialog");
await expect(within(dialog).findByRole("img")).resolves.toHaveAttribute(
"src",
"https://server/_matrix/media/v3/download/server/svg-thumbnail",
);
expect(dialog).toMatchSnapshot();
});
});

View File

@ -0,0 +1,620 @@
/*
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, { createRef } from "react";
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
import { ClientEvent, EventType, getHttpUriForMxc, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
import Modal from "../../../../../src/Modal";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { ImageSize } from "../../../../../src/settings/enums/ImageSize";
import { mediaFromContent } from "../../../../../src/customisations/Media";
import { BLURHASH_FIELD, createThumbnail } from "../../../../../src/utils/image-media";
import { blobIsAnimated } from "../../../../../src/utils/Image";
import { DecryptError, DownloadError } from "../../../../../src/utils/DecryptFile";
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
import MImageReplyBody, { ImageBodyBaseInner } from "../../../../../src/components/views/messages/MImageReplyBody";
import {
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsDevice,
mockClientMethodsServer,
mockClientMethodsUser,
} from "../../../../test-utils";
import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible";
jest.mock("../../../../../src/customisations/Media", () => ({
mediaFromContent: jest.fn(),
}));
jest.mock("../../../../../src/utils/Image", () => ({
...jest.requireActual("../../../../../src/utils/Image"),
blobIsAnimated: jest.fn(),
}));
jest.mock("../../../../../src/utils/image-media", () => ({
...jest.requireActual("../../../../../src/utils/image-media"),
createThumbnail: jest.fn(),
}));
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
__esModule: true,
useMediaVisible: jest.fn(),
}));
describe("<MImageReplyBody />", () => {
const userId = "@user:server";
const deviceId = "DEADB33F";
const cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(),
getRoom: jest.fn(),
getRooms: jest.fn().mockReturnValue([]),
getIgnoredUsers: jest.fn(),
getVersions: jest.fn().mockResolvedValue({
unstable_features: {
"org.matrix.msc3882": true,
"org.matrix.msc3886": true,
},
}),
});
// eslint-disable-next-line no-restricted-properties
cli.mxcUrlToHttp.mockImplementation(
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
},
);
const mockedMediaFromContent = jest.mocked(mediaFromContent);
const mockedUseMediaVisible = jest.mocked(useMediaVisible);
const mockedBlobIsAnimated = jest.mocked(blobIsAnimated);
const mockedCreateThumbnail = jest.mocked(createThumbnail);
const originalGetValue = SettingsStore.getValue.bind(SettingsStore);
const createEvent = ({
body = "demo image",
content = {},
}: {
body?: string;
content?: Record<string, unknown>;
} = {}): MatrixEvent => {
const { info: infoOverride, ...restContent } = content;
const info =
infoOverride === null
? undefined
: {
w: 320,
h: 240,
size: 48_000,
mimetype: "image/jpeg",
...(infoOverride as Record<string, unknown> | undefined),
};
return new MatrixEvent({
type: EventType.RoomMessage,
room_id: "!room:server",
event_id: "$image:server",
sender: userId,
content: {
msgtype: "m.image",
body,
url: "mxc://server/image",
...restContent,
...(info ? { info } : {}),
},
});
};
const createMockMedia = (content: Record<string, any>) => ({
isEncrypted: !!content.file,
srcMxc: content.url ?? content.file?.url ?? "mxc://server/image",
srcHttp: "https://server/full.png",
thumbnailMxc: content.info?.thumbnail_url ?? "mxc://server/thumb",
thumbnailHttp: "https://server/thumb.png",
hasThumbnail: content.info?.thumbnail_url !== null,
getThumbnailHttp: jest.fn().mockReturnValue("https://server/thumb.png"),
getThumbnailOfSourceHttp: jest.fn().mockReturnValue("https://server/thumb.png"),
getSquareThumbnailHttp: jest.fn(),
downloadSource: jest.fn(),
});
const createMediaEventHelper = ({
encrypted = true,
thumbnailUrl = "blob:thumbnail",
sourceUrl = "blob:source",
sourceBlob = new Blob(["image"], { type: "image/jpeg" }),
}: {
encrypted?: boolean;
thumbnailUrl?: string | null | Promise<string | null>;
sourceUrl?: string | null | Promise<string | null>;
sourceBlob?: Blob | Promise<Blob>;
} = {}): MediaEventHelper =>
({
media: { isEncrypted: encrypted },
thumbnailUrl: { value: Promise.resolve(thumbnailUrl) },
sourceUrl: { value: Promise.resolve(sourceUrl) },
sourceBlob: { value: Promise.resolve(sourceBlob), cachedValue: sourceBlob },
}) as unknown as MediaEventHelper;
const props = {
mxEvent: createEvent(),
mediaVisible: true,
setMediaVisible: jest.fn(),
onMessageAllowed: jest.fn(),
permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)),
};
const renderBase = ({
timelineRenderingType = TimelineRenderingType.Room,
overrides = {},
}: {
timelineRenderingType?: TimelineRenderingType;
overrides?: Partial<React.ComponentProps<typeof ImageBodyBaseInner>>;
} = {}) => {
const ref = createRef<ImageBodyBaseInner>();
const result = render(
<RoomContext.Provider value={{ timelineRenderingType } as any}>
<ImageBodyBaseInner ref={ref} {...props} {...overrides} />
</RoomContext.Provider>,
);
return { ...result, ref };
};
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(window, "devicePixelRatio", {
configurable: true,
value: 1,
});
mockedMediaFromContent.mockImplementation((content: Record<string, any>) => createMockMedia(content) as any);
mockedUseMediaVisible.mockReturnValue([true, jest.fn()]);
mockedBlobIsAnimated.mockResolvedValue(true);
mockedCreateThumbnail.mockResolvedValue({ thumbnail: new Blob(["thumbnail"], { type: "image/jpeg" }) } as any);
jest.spyOn(SettingsStore, "getValue").mockImplementation(((setting, ...args) => {
if (setting === "Images.size") return ImageSize.Normal;
if (setting === "autoplayGifs") return false;
return (originalGetValue as any)(setting, ...args);
}) as typeof SettingsStore.getValue);
jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("image-reply-watch");
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
});
afterEach(() => {
jest.useRealTimers();
});
afterAll(() => {
jest.restoreAllMocks();
});
it("renders a visible unencrypted image and file fallback outside room timelines", async () => {
const { container } = renderBase({ timelineRenderingType: TimelineRenderingType.Notification });
await waitFor(() => expect(screen.getAllByRole("img", { name: "demo image" })).toHaveLength(2));
expect(container.querySelector(".mx_MImageBody")).not.toBeNull();
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
expect(container.querySelector("a[href='https://server/full.png']")).not.toBeNull();
expect(container.querySelector("img.mx_MImageBody_thumbnail")).toHaveAttribute(
"src",
"https://server/thumb.png",
);
expect(screen.getByRole("link", { name: /Download/ })).toBeInTheDocument();
});
it("reveals hidden media through the supplied setter", () => {
const setMediaVisible = jest.fn();
renderBase({
overrides: {
mediaVisible: false,
setMediaVisible,
},
});
fireEvent.click(screen.getByRole("button", { name: "Show image" }));
expect(setMediaVisible).toHaveBeenCalledWith(true);
});
it("opens the image viewer with thumbnail geometry", async () => {
const { container } = renderBase();
await waitFor(() => expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument());
const image = container.querySelector("img.mx_MImageBody_thumbnail") as HTMLImageElement;
image.getBoundingClientRect = () => ({ width: 100, height: 80, x: 10, y: 20 }) as DOMRect;
jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
fireEvent.click(screen.getByRole("link", { name: "demo image" }), { button: 0 });
expect(Modal.createDialog).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
src: "https://server/full.png",
name: "demo image",
width: 320,
height: 240,
fileSize: 48_000,
thumbnailInfo: {
width: 100,
height: 80,
positionX: 10,
positionY: 20,
},
}),
"mx_Dialog_lightbox",
undefined,
true,
);
});
it("updates load dimensions and toggles hover/focus banner state", async () => {
const { container, ref } = renderBase();
await waitFor(() => expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument());
const image = container.querySelector("img.mx_MImageBody_thumbnail") as HTMLImageElement;
Object.defineProperty(image, "naturalWidth", { configurable: true, value: 640 });
Object.defineProperty(image, "naturalHeight", { configurable: true, value: 480 });
act(() => {
ref.current!["onImageLoad"]();
ref.current!.setState({ isAnimated: true, imgLoaded: true });
});
expect(ref.current!.state.loadedImageDimensions).toEqual({ naturalWidth: 640, naturalHeight: 480 });
fireEvent.mouseEnter(image);
expect(ref.current!.state.hover).toBe(true);
expect(container.querySelector(".mx_MImageBody_banner")).not.toBeNull();
expect(image).toHaveAttribute("src", "https://server/full.png");
fireEvent.mouseLeave(image);
expect(ref.current!.state.hover).toBe(false);
const link = screen.getByRole("link", { name: /demo image/ });
fireEvent.focus(link);
expect(ref.current!.state.focus).toBe(true);
fireEvent.blur(link);
expect(ref.current!.state.focus).toBe(false);
});
it("uses the decrypted thumbnail in the image viewer when the source mime type is unsafe", async () => {
renderBase({
overrides: {
mxEvent: createEvent({
body: "unsafe image",
content: {
file: { url: "mxc://server/encrypted-image" },
url: undefined,
info: {
mimetype: "image/svg+xml",
thumbnail_info: { mimetype: "image/jpeg" },
},
},
}),
mediaEventHelper: createMediaEventHelper({
sourceUrl: "blob:unsafe-source",
thumbnailUrl: "blob:safe-thumbnail",
sourceBlob: new Blob(["html"], { type: "text/html" }),
}),
},
});
jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
await waitFor(() => expect(screen.getByRole("img", { name: "unsafe image" })).toBeInTheDocument());
fireEvent.click(screen.getByRole("link", { name: "unsafe image" }), { button: 0 });
expect(Modal.createDialog).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
src: "blob:safe-thumbnail",
name: "unsafe image",
}),
"mx_Dialog_lightbox",
undefined,
true,
);
});
it("falls back from thumbnail errors and clears image errors after reconnecting", async () => {
const onSpy = jest.spyOn(cli, "on");
const offSpy = jest.spyOn(cli, "off");
const { ref } = renderBase();
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/thumb.png"));
act(() => {
ref.current!["onImageError"]();
});
expect(ref.current!.state.thumbUrl).toBeNull();
act(() => {
ref.current!["onImageError"]();
});
expect(ref.current!.state.imgError).toBe(true);
expect(onSpy).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function));
const listener = onSpy.mock.calls.at(-1)![1] as (...args: unknown[]) => void;
act(() => {
listener(SyncState.Syncing, SyncState.Error);
});
expect(offSpy).toHaveBeenCalledWith(ClientEvent.Sync, listener);
expect(ref.current!.state.imgError).toBe(false);
});
it.each([
[new DecryptError(new Error("decrypt failed")), "Error decrypting image"],
[new DownloadError(new Error("download failed")), "Error downloading image"],
[new Error("display failed"), "Unable to show image due to error"],
])("renders media processing errors for %s", async (error, label) => {
const { container, ref } = renderBase();
act(() => {
ref.current!.setState({ error });
});
expect(container.querySelector(".mx_MImageBody")).not.toBeNull();
expect(screen.getByText(label)).toBeInTheDocument();
});
it.each([
[new DecryptError(new Error("decrypt failed")), "Error decrypting image"],
[new DownloadError(new Error("download failed")), "Error downloading image"],
[new Error("download failed"), "Unable to show image due to error"],
])("renders encrypted download failures for %s", async (error, label) => {
renderBase({
overrides: {
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-image" },
url: undefined,
},
}),
mediaEventHelper: createMediaEventHelper({
sourceUrl: Promise.reject(error),
}),
},
});
await waitFor(() => expect(screen.getByText(label)).toBeInTheDocument());
});
it("renders export images directly from the event MXC URL", () => {
renderBase({
overrides: {
forExport: true,
mxEvent: createEvent({
content: {
url: undefined,
file: { url: "mxc://server/encrypted-image" },
},
}),
},
});
expect(screen.getByRole("link", { name: "demo image" })).toHaveAttribute(
"href",
"mxc://server/encrypted-image",
);
expect(screen.getByRole("link", { name: "demo image" })).toHaveAttribute("target", "_blank");
expect(screen.queryByRole("link", { name: /Download/ })).toBeNull();
});
it("switches blurhash placeholders on after the delay", () => {
jest.useFakeTimers();
const { container } = renderBase({
overrides: {
mxEvent: createEvent({
content: {
info: {
[BLURHASH_FIELD]: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
},
},
}),
},
});
expect(container.querySelector(".mx_Blurhash")).toBeNull();
act(() => {
jest.advanceTimersByTime(150);
});
expect(container.querySelector(".mx_Blurhash")).not.toBeNull();
});
it("downloads media when visibility changes after mount", async () => {
const ref = createRef<ImageBodyBaseInner>();
const mxEvent = createEvent();
const { rerender } = render(
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.Room } as any}>
<ImageBodyBaseInner
ref={ref}
{...props}
mxEvent={mxEvent}
mediaVisible={false}
setMediaVisible={jest.fn()}
/>
</RoomContext.Provider>,
);
expect(ref.current!.state.contentUrl).toBeNull();
rerender(
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.Room } as any}>
<ImageBodyBaseInner
ref={ref}
{...props}
mxEvent={mxEvent}
mediaVisible={true}
setMediaVisible={jest.fn()}
/>
</RoomContext.Provider>,
);
await waitFor(() => expect(ref.current!.state.contentUrl).toBe("https://server/full.png"));
});
it("renders missing-size media after loading natural dimensions", async () => {
const { container, ref } = renderBase({
overrides: {
mxEvent: createEvent({ content: { info: null } }),
},
});
await waitFor(() => expect(container.querySelector("img[style*='display: none']")).not.toBeNull());
const image = container.querySelector("img[style*='display: none']") as HTMLImageElement;
Object.defineProperty(image, "naturalWidth", { configurable: true, value: 640 });
Object.defineProperty(image, "naturalHeight", { configurable: true, value: 480 });
act(() => {
ref.current!["onImageLoad"]();
});
expect(ref.current!.state.loadedImageDimensions).toEqual({ naturalWidth: 640, naturalHeight: 480 });
expect(container.querySelector(".mx_MImageBody_thumbnail_container")).not.toBeNull();
});
it("generates a static thumbnail for animated images without a safe thumbnail", async () => {
let createdImage: any;
const originalCreateElement = document.createElement.bind(document);
const createElementSpy = jest.spyOn(document, "createElement").mockImplementation(((tagName: string) => {
if (tagName !== "img") {
return originalCreateElement(tagName);
}
createdImage = originalCreateElement(tagName) as HTMLImageElement;
Object.defineProperty(createdImage, "width", { configurable: true, value: 320 });
Object.defineProperty(createdImage, "height", { configurable: true, value: 240 });
return createdImage;
}) as typeof document.createElement);
const { ref } = renderBase({
overrides: {
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-image" },
url: undefined,
info: {
"mimetype": "image/gif",
"thumbnail_info": { mimetype: "image/gif" },
"org.matrix.msc4230.is_animated": true,
},
},
}),
mediaEventHelper: createMediaEventHelper({
sourceUrl: "blob:animated-source",
thumbnailUrl: null,
sourceBlob: new Blob(["gif"], { type: "image/gif" }),
}),
},
});
await waitFor(() => expect(createdImage).toBeDefined());
await act(async () => {
createdImage.onload();
await Promise.resolve();
});
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("blob"));
expect(mockedBlobIsAnimated).toHaveBeenCalled();
expect(mockedCreateThumbnail).toHaveBeenCalledWith(expect.any(HTMLImageElement), 320, 240, "image/gif", false);
expect(ref.current!.state.isAnimated).toBe(true);
createElementSpy.mockRestore();
});
it("uses SVG thumbnails when available", async () => {
const { ref } = renderBase({
overrides: {
mxEvent: createEvent({
content: {
info: {
mimetype: "image/svg+xml",
thumbnail_url: "mxc://server/thumb",
},
},
}),
},
});
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/thumb.png"));
expect(
mockedMediaFromContent.mock.results.some((result: any) =>
result.value.getThumbnailHttp.mock.calls.some(
(call: unknown[]) => call[0] === 800 && call[1] === 600 && call[2] === "scale",
),
),
).toBe(true);
});
it("uses the full source as thumbnail for small high-dpi images", async () => {
Object.defineProperty(window, "devicePixelRatio", {
configurable: true,
value: 2,
});
const { ref } = renderBase();
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/full.png"));
});
it("renders the file body instead of unsafe encrypted images without thumbnails", () => {
renderBase({
overrides: {
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-file" },
url: undefined,
info: {
mimetype: "text/html",
},
},
}),
mediaEventHelper: {
media: { isEncrypted: true },
sourceUrl: { value: Promise.resolve("blob:source") },
thumbnailUrl: { value: Promise.resolve(null) },
sourceBlob: {
value: Promise.resolve(new Blob(["html"], { type: "text/html" })),
cachedValue: new Blob(["html"], { type: "text/html" }),
},
} as unknown as MediaEventHelper,
mediaVisible: false,
},
});
expect(screen.getByRole("button", { name: /demo image/ })).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "demo image" })).toBeNull();
});
it("renders the compact reply body through the hook wrapper", async () => {
const setMediaVisible = jest.fn();
mockedUseMediaVisible.mockReturnValue([true, setMediaVisible]);
const { container } = render(<MImageReplyBody {...props} />);
await waitFor(() => expect(container.querySelector(".mx_MImageReplyBody")).not.toBeNull());
expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument();
});
it("cleans up settings watchers, listeners and generated animated thumbnails on unmount", async () => {
const offSpy = jest.spyOn(cli, "off");
const { ref, unmount } = renderBase();
await waitFor(() => expect(ref.current).not.toBeNull());
act(() => {
ref.current!.setState({
isAnimated: true,
thumbUrl: "blob:animated-thumbnail",
});
});
unmount();
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("image-reply-watch");
expect(offSpy).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function));
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:animated-thumbnail");
});
});

View File

@ -20,15 +20,11 @@ import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permal
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { Mjolnir } from "../../../../../src/mjolnir/Mjolnir";
jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({
__esModule: true,
default: () => <div data-testid="image-body" />,
}));
jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
__esModule: true,
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
FileBodyFactory: () => <div data-testid="file-body" />,
ImageBodyFactory: () => <div data-testid="image-body" />,
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
VideoBodyFactory: () => <video data-testid="video-body" />,
renderMBody: () => <div data-testid="file-body" />,

View File

@ -1,379 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<MImageBody/> should generate a thumbnail if one isn't included for animated media 1`] = `
<div>
<div
class="mx_MImageBody"
>
<a
href="https://server/_matrix/media/v3/download/server/image"
>
<div
class="mx_MImageBody_thumbnail_container"
style="max-height: 50px; max-width: 40px; aspect-ratio: 40/50;"
>
<div
class="mx_MImageBody_placeholder"
>
<div
class="mx_Spinner"
>
<svg
aria-label="Loading…"
class="_icon_1855a_18"
data-testid="spinner"
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
style="max-height: 50px; max-width: 40px;"
>
<img
alt="alt for a test image"
class="mx_MImageBody_thumbnail"
src="blob:generated-thumb"
/>
<p
class="mx_MImageBody_gifLabel"
>
GIF
</p>
</div>
</div>
</a>
</div>
</div>
`;
exports[`<MImageBody/> should open ImageView using thumbnail for encrypted svg 1`] = `
<div
aria-label="Image view"
class="mx_ImageView"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_ImageView_panel"
>
<div
class="mx_ImageView_info_wrapper"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_va14e_8 mx_BaseAvatar mx_Dialog_nonDialogButton _avatar-imageless_va14e_55"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 32px;"
>
o
</button>
<div
class="mx_ImageView_info"
>
<div
class="mx_ImageView_info_sender"
>
@other_use:server
</div>
<a
aria-live="off"
class="mx_MessageTimestamp _content_1r034_8"
href="https://matrix.to/#/!room:server/undefined"
>
Thu, Jan 15, 1970, 06:56
</a>
</div>
</div>
<div
class="mx_ImageView_title"
>
Image
</div>
<div
class="mx_ImageView_toolbar"
>
<div
aria-label="Zoom out"
class="mx_AccessibleButton mx_ImageView_button"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.5 9.5q.425 0 .713.287.288.288.287.713a.97.97 0 0 1-.287.713.97.97 0 0 1-.713.287h-6a.97.97 0 0 1-.713-.287.97.97 0 0 1-.287-.713q0-.425.287-.713A.97.97 0 0 1 7.5 9.5z"
/>
<path
clip-rule="evenodd"
d="M10.5 3a7.5 7.5 0 0 1 5.963 12.049l3.244 3.244a1 1 0 1 1-1.414 1.414l-3.244-3.244A7.5 7.5 0 1 1 10.5 3m0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11"
fill-rule="evenodd"
/>
</svg>
</div>
<div
aria-label="Zoom in"
class="mx_AccessibleButton mx_ImageView_button"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#cpd_ZoomInIcon_a)"
>
<path
d="M10.5 6.5q.425 0 .713.287.288.288.287.713v2h2q.425 0 .713.287.288.288.287.713a.97.97 0 0 1-.287.713.97.97 0 0 1-.713.287h-2v2a.97.97 0 0 1-.287.713.97.97 0 0 1-.713.287.97.97 0 0 1-.713-.287.97.97 0 0 1-.287-.713v-2h-2a.97.97 0 0 1-.713-.287.97.97 0 0 1-.287-.713q0-.425.287-.713A.97.97 0 0 1 7.5 9.5h2v-2q0-.425.287-.713A.97.97 0 0 1 10.5 6.5"
/>
<path
clip-rule="evenodd"
d="M10.5 3a7.5 7.5 0 0 1 5.963 12.049l3.244 3.244a1 1 0 1 1-1.414 1.414l-3.244-3.244A7.5 7.5 0 1 1 10.5 3m0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11"
fill-rule="evenodd"
/>
<path
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414zM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0"
/>
<path
d="M7.875 11.375h1.75v1.75q0 .372.252.623A.85.85 0 0 0 10.5 14a.85.85 0 0 0 .623-.252.85.85 0 0 0 .252-.623v-1.75h1.75a.85.85 0 0 0 .623-.252A.85.85 0 0 0 14 10.5a.85.85 0 0 0-.252-.623.85.85 0 0 0-.623-.252h-1.75v-1.75a.85.85 0 0 0-.252-.623A.85.85 0 0 0 10.5 7a.85.85 0 0 0-.623.252.85.85 0 0 0-.252.623v1.75h-1.75a.85.85 0 0 0-.623.252A.85.85 0 0 0 7 10.5q0 .372.252.623a.85.85 0 0 0 .623.252"
/>
</g>
<defs>
<clippath
id="cpd_ZoomInIcon_a"
>
<path
d="M0 0h24v24H0z"
/>
</clippath>
</defs>
</svg>
</div>
<div
aria-label="Rotate Left"
class="mx_AccessibleButton mx_ImageView_button"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.56 7.98C6.1 7.52 5.31 7.6 5 8.17c-.28.51-.5 1.03-.67 1.58-.19.63.31 1.25.96 1.25h.01c.43 0 .82-.28.94-.7q.18-.6.48-1.17c.22-.37.15-.84-.16-1.15M5.31 13h-.02c-.65 0-1.15.62-.96 1.25.16.54.38 1.07.66 1.58.31.57 1.11.66 1.57.2.3-.31.38-.77.17-1.15-.2-.37-.36-.76-.48-1.16a.97.97 0 0 0-.94-.72m2.85 6.02q.765.42 1.59.66c.62.18 1.24-.32 1.24-.96v-.03c0-.43-.28-.82-.7-.94-.4-.12-.78-.28-1.15-.48a.97.97 0 0 0-1.16.17l-.03.03c-.45.45-.36 1.24.21 1.55M13 4.07v-.66c0-.89-1.08-1.34-1.71-.71L9.17 4.83c-.4.4-.4 1.04 0 1.43l2.13 2.08c.63.62 1.7.17 1.7-.72V6.09c2.84.48 5 2.94 5 5.91 0 2.73-1.82 5.02-4.32 5.75a.97.97 0 0 0-.68.94v.02c0 .65.61 1.14 1.23.96A7.976 7.976 0 0 0 20 12c0-4.08-3.05-7.44-7-7.93"
/>
</svg>
</div>
<div
aria-label="Rotate Right"
class="mx_AccessibleButton mx_ImageView_button"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#cpd_RotateRightIcon_a)"
>
<path
d="M14.83 4.83 12.7 2.7c-.62-.62-1.7-.18-1.7.71v.66C7.06 4.56 4 7.92 4 12c0 3.64 2.43 6.71 5.77 7.68.62.18 1.23-.32 1.23-.96v-.03a.97.97 0 0 0-.68-.94A5.98 5.98 0 0 1 6 12c0-2.97 2.16-5.43 5-5.91v1.53c0 .89 1.07 1.33 1.7.71l2.13-2.08a.99.99 0 0 0 0-1.42m4.84 4.93q-.24-.825-.66-1.59c-.31-.57-1.1-.66-1.56-.2l-.01.01c-.31.31-.38.78-.17 1.16.2.37.36.76.48 1.16.12.42.51.7.94.7h.02c.65 0 1.15-.62.96-1.24M13 18.68v.02c0 .65.62 1.14 1.24.96q.825-.24 1.59-.66c.57-.31.66-1.1.2-1.56l-.02-.02a.97.97 0 0 0-1.16-.17c-.37.21-.76.37-1.16.49-.41.12-.69.51-.69.94m4.44-2.65c.46.46 1.25.37 1.56-.2.28-.51.5-1.04.67-1.59.18-.62-.31-1.24-.96-1.24h-.02c-.44 0-.82.28-.94.7q-.18.6-.48 1.17c-.21.38-.13.86.17 1.16"
/>
</g>
<defs>
<clippath
id="cpd_RotateRightIcon_a"
>
<path
d="M0 0h24v24H0z"
/>
</clippath>
</defs>
</svg>
</div>
<div
aria-label="Download"
class="mx_AccessibleButton mx_ImageView_button"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-3.6-3.6a.95.95 0 0 1-.275-.7q0-.425.275-.7.274-.275.712-.288t.713.263L11 12.15V5q0-.424.287-.713A.97.97 0 0 1 12 4q.424 0 .713.287Q13 4.576 13 5v7.15l1.875-1.875q.274-.274.713-.263.437.014.712.288a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-3.6 3.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063M6 20q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_more"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
<div
aria-label="Close"
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_close"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</div>
</div>
<div
class="mx_ImageView_image_wrapper"
>
<img
alt="Attachment"
class="mx_ImageView_image "
draggable="true"
src="https://server/_matrix/media/v3/download/server/svg-thumbnail"
style="transform: translateX(-512px)
translateY(NaNpx)
scale(0)
rotate(0deg); cursor: zoom-out;"
/>
</div>
</div>
`;
exports[`<MImageBody/> should render MFileBody for svg with no thumbnail 1`] = `
<DocumentFragment>
<span
class="_content_1t2mx_8 mx_MFileBody"
>
<div
class="mx_MediaBody _mediaBody_rgndh_8"
data-type="info"
>
<button
aria-label="Attachment"
class="_button_1nw83_8 _has-icon_1nw83_60"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
<span>
Attachment
</span>
</button>
</div>
</span>
</DocumentFragment>
`;
exports[`<MImageBody/> should show a thumbnail while image is being downloaded 1`] = `
<div>
<div
class="mx_MImageBody"
>
<div
class="mx_MImageBody_thumbnail_container"
style="max-height: 50px; max-width: 40px; aspect-ratio: 40/50;"
>
<div
class="mx_MImageBody_placeholder"
>
<div
class="mx_Spinner"
>
<svg
aria-label="Loading…"
class="_icon_1855a_18"
data-testid="spinner"
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
style="max-height: 50px; max-width: 40px;"
/>
</div>
</div>
</div>
`;

File diff suppressed because it is too large Load Diff

View File

@ -112,6 +112,38 @@ describe("ImageBodyView", () => {
expect(onLinkClick).toHaveBeenCalledTimes(1);
});
it("merges supplied class names with module classes", () => {
const vm = new TestImageBodyViewModel({
state: ImageBodyViewState.READY,
alt: "Custom class image",
src: "https://example.org/full.png",
thumbnailSrc: "https://example.org/thumb.png",
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
});
const { container } = render(
<ImageBodyView
vm={vm}
className="customRoot"
containerClassName="customContainer"
imageClassName="customImage"
/>,
);
const rootClassName = container.querySelector(".customRoot")?.className;
const containerClassName = container.querySelector(".customContainer")?.className;
const imageClassName = screen.getByRole("img", { name: "Custom class image" }).className;
expect(rootClassName).toContain("customRoot");
expect(rootClassName).not.toBe("customRoot");
expect(containerClassName).toContain("customContainer");
expect(containerClassName).not.toBe("customContainer");
expect(imageClassName).toContain("customImage");
expect(imageClassName).not.toBe("customImage");
});
it("swaps to the full source on hover for animated previews", async () => {
const user = userEvent.setup();
const vm = new TestImageBodyViewModel({

View File

@ -12,6 +12,7 @@ import React, {
type MouseEventHandler,
type PropsWithChildren,
type ReactEventHandler,
type Ref,
useState,
} from "react";
import classNames from "classnames";
@ -148,6 +149,18 @@ interface ImageBodyViewProps {
* Optional host CSS class.
*/
className?: string;
/**
* Optional CSS class applied to the media frame container.
*/
containerClassName?: string;
/**
* Optional CSS class applied to the rendered image element.
*/
imageClassName?: string;
/**
* Optional ref to the rendered image element.
*/
imageRef?: Ref<HTMLImageElement>;
/**
* Optional supplemental content rendered after the media frame.
*/
@ -202,7 +215,14 @@ function renderPlaceholder({
* </ImageBodyView>
* ```
*/
export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyViewProps>): JSX.Element {
export function ImageBodyView({
vm,
className,
containerClassName,
imageClassName,
imageRef,
children,
}: Readonly<ImageBodyViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const {
state,
@ -230,6 +250,8 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
const hoverOrFocus = hover || focus;
const rootClassName = classNames(className, styles.root);
const resolvedContainerClassName = classNames(containerClassName, styles.thumbnailContainer);
const resolvedImageClassName = classNames(imageClassName, styles.image);
if (state === ImageBodyViewState.ERROR) {
return (
@ -281,9 +303,10 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
</div>
) : resolvedImageSrc ? (
<img
className={styles.image}
className={resolvedImageClassName}
src={resolvedImageSrc}
alt={alt}
ref={imageRef}
onError={vm.onImageError}
onLoad={vm.onImageLoad}
onMouseEnter={(): void => setHover(true)}
@ -302,7 +325,7 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
) : null;
let frame = (
<div className={styles.thumbnailContainer} style={containerStyle}>
<div className={resolvedContainerClassName} style={containerStyle}>
{showPlaceholder && (
<div
className={classNames(styles.placeholder, {