Files
blap/apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts
T
Zack 1e7c9f672a 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>
2026-05-13 06:03:43 +00:00

675 lines
22 KiB
TypeScript

/*
* 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();
}
}