diff --git a/apps/web/playwright/e2e/modules/custom-component.spec.ts b/apps/web/playwright/e2e/modules/custom-component.spec.ts index 5d2dc34aef3..ffbdc26e5ae 100644 --- a/apps/web/playwright/e2e/modules/custom-component.spec.ts +++ b/apps/web/playwright/e2e/modules/custom-component.spec.ts @@ -107,10 +107,14 @@ test.describe("Custom Component API", () => { await app.timeline.scrollToBottom(); const imgTile = page.locator(".mx_MImageBody").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, @@ -129,10 +133,14 @@ test.describe("Custom Component API", () => { await app.timeline.scrollToBottom(); const imgTile = page.locator(".mx_MImageBody").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", diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index bac006d7f5e..4ace64dcb17 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -227,7 +227,6 @@ @import "./views/messages/_LegacyCallEvent.pcss"; @import "./views/messages/_MEmoteBody.pcss"; @import "./views/messages/_MFileBody.pcss"; -@import "./views/messages/_MImageBody.pcss"; @import "./views/messages/_MImageReplyBody.pcss"; @import "./views/messages/_MJitsiWidgetEvent.pcss"; @import "./views/messages/_MLocationBody.pcss"; diff --git a/apps/web/res/css/views/messages/_MImageBody.pcss b/apps/web/res/css/views/messages/_MImageBody.pcss deleted file mode 100644 index 0e73c1d55c1..00000000000 --- a/apps/web/res/css/views/messages/_MImageBody.pcss +++ /dev/null @@ -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; -} diff --git a/apps/web/res/css/views/messages/_MImageReplyBody.pcss b/apps/web/res/css/views/messages/_MImageReplyBody.pcss index 9576e75fd81..e8c2c4047d0 100644 --- a/apps/web/res/css/views/messages/_MImageReplyBody.pcss +++ b/apps/web/res/css/views/messages/_MImageReplyBody.pcss @@ -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; diff --git a/apps/web/src/components/views/messages/MBodyFactory.tsx b/apps/web/src/components/views/messages/MBodyFactory.tsx index deb10cd8226..1dad779a51a 100644 --- a/apps/web/src/components/views/messages/MBodyFactory.tsx +++ b/apps/web/src/components/views/messages/MBodyFactory.tsx @@ -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; @@ -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(null); + const content = mxEvent.getContent(); + 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 ( + + ); + } + + return ( + + {showFileBody ? ( + + ) : null} + + ); +} + export function RedactedBodyFactory({ mxEvent, ref }: Pick): JSX.Element { const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent })); @@ -164,6 +284,7 @@ export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick([ + [MsgType.Image, ImageBodyFactory], [MsgType.File, FileBodyFactory], [MsgType.Video, VideoBodyFactory], ]); diff --git a/apps/web/src/components/views/messages/MImageBody.tsx b/apps/web/src/components/views/messages/MImageBody.tsx deleted file mode 100644 index 2d1b1402e57..00000000000 --- a/apps/web/src/components/views/messages/MImageBody.tsx +++ /dev/null @@ -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 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 { HiddenMediaPlaceholder } from "./HiddenMediaPlaceholder"; -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 { - public static contextType = RoomContext; - declare public context: React.ContextType; - - private unmounted = false; - private image = createRef(); - private placeholder = createRef(); - 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(); - - 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, "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(); - 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 { - 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(); - 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): 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 ( - - {presentableTextForFile(content, _t("common|image"), true, true)} - - ); - } - - 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 = ( - - {_t("timeline|m.image|show_image")} - - ); - } else { - imageElement = ( - {content.body} - ); - } - 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 , - // 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 = ( -
- {this.getPlaceholder(maxWidth, maxHeight)} -
- ); - } - - 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 = ( - {content.body} - ); - } - - if (!this.props.mediaVisible) { - img = ( -
- - {_t("timeline|m.image|show_image")} - -
- ); - 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 =

GIF

; - } - - let banner: ReactNode | undefined; - if (this.props.mediaVisible && hoverOrFocus) { - banner = this.getBanner(content); - } - - // many SVGs don't have an intrinsic size if used in 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 = ( - - - { - showPlaceholder ? ( - placeholder - ) : ( -
- ) /* Transition always expects a child */ - } - - - ); - } - - const tooltipProps = this.getTooltipProps(); - let thumbnail = ( -
- {placeholder} - -
- {img} - {gifLabel} - {banner} -
- - {/* HACK: This div fills out space while the image loads, to prevent scroll jumps */} - {!this.props.forExport && !this.state.imgLoaded && !placeholder && ( -
- )} -
- ); - - 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 = ( - - {thumbnail} - - ); - } - - return this.wrapImage(contentUrl, thumbnail); - } - - // Overridden by MStickerBody - protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode { - if (contentUrl) { - return ( - - {children} - - ); - } - 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 ; - } - } - return ; - } - - // Overridden by MStickerBody - protected getTooltipProps(): ComponentProps | 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(); - - // 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 ( - - {errorText} - - ); - } - - 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 ( -
- {thumbnail} - {fileBody} -
- ); - } -} - -// Wrap MImageBody component so we can use a hook here. -const MImageBody: React.FC = (props) => { - const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); - return ; -}; - -export default MImageBody; diff --git a/apps/web/src/components/views/messages/MImageReplyBody.tsx b/apps/web/src/components/views/messages/MImageReplyBody.tsx index 5f04df724da..3b21d272f8b 100644 --- a/apps/web/src/components/views/messages/MImageReplyBody.tsx +++ b/apps/web/src/components/views/messages/MImageReplyBody.tsx @@ -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 { 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 { HiddenMediaPlaceholder } from "./HiddenMediaPlaceholder"; 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 { + public static contextType = RoomContext; + declare public context: React.ContextType; + + private unmounted = false; + private image = createRef(); + private placeholder = createRef(); + 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(); + + 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, "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(); + 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 { + 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(); + 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): 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 ( + + {presentableTextForFile(content, _t("common|image"), true, true)} + + ); + } + + 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 = ( + + {_t("timeline|m.image|show_image")} + + ); + } else { + imageElement = ( + {content.body} + ); + } + 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 = ( +
+ {this.getPlaceholder(maxWidth, maxHeight)} +
+ ); + } + + 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 = ( + {content.body} + ); + } + + if (!this.props.mediaVisible) { + img = ( +
+ + {_t("timeline|m.image|show_image")} + +
+ ); + showPlaceholder = false; + } + + if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) { + gifLabel =

GIF

; + } + + 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 = ( + + + {showPlaceholder ? placeholder :
} + + + ); + } + + const tooltipProps = this.getTooltipProps(); + let thumbnail = ( +
+ {placeholder} + +
+ {img} + {gifLabel} + {banner} +
+ + {!this.props.forExport && !this.state.imgLoaded && !placeholder && ( +
+ )} +
+ ); + + if (tooltipProps) { + thumbnail = ( + + {thumbnail} + + ); + } + + return this.wrapImage(contentUrl, thumbnail); + } + + protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode { + if (contentUrl) { + return ( + + {children} + + ); + } + 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 ; + } + } + return ; + } + + protected getTooltipProps(): ComponentProps | 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(); + + 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 ( + + {errorText} + + ); + } + + 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 ( +
+ {thumbnail} + {fileBody} +
+ ); + } +} 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
{thumbnail}
; } } + const MImageReplyBody: React.FC = (props) => { const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); return ; diff --git a/apps/web/src/components/views/messages/MStickerBody.tsx b/apps/web/src/components/views/messages/MStickerBody.tsx index f0beea72aae..a9ae9ed92c2 100644 --- a/apps/web/src/components/views/messages/MStickerBody.tsx +++ b/apps/web/src/components/views/messages/MStickerBody.tsx @@ -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(); diff --git a/apps/web/src/components/views/messages/MessageEvent.tsx b/apps/web/src/components/views/messages/MessageEvent.tsx index 549f38226c6..09bb92e1942 100644 --- a/apps/web/src/components/views/messages/MessageEvent.tsx +++ b/apps/web/src/components/views/messages/MessageEvent.tsx @@ -26,7 +26,6 @@ import { type IMediaBody } from "./IMediaBody"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { type IBodyProps } from "./IBodyProps"; import TextualBody from "./TextualBody"; -import MImageBody from "./MImageBody"; import MVoiceOrAudioBody from "./MVoiceOrAudioBody"; import MStickerBody from "./MStickerBody"; import MPollBody from "./MPollBody"; @@ -37,6 +36,7 @@ import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTil import { DecryptionFailureBodyFactory, FileBodyFactory, + ImageBodyFactory, RedactedBodyFactory, VideoBodyFactory, renderMBody, @@ -67,7 +67,7 @@ const baseBodyTypes = new Map>([ [MsgType.Text, TextualBody], [MsgType.Notice, TextualBody], [MsgType.Emote, TextualBody], - [MsgType.Image, MImageBody], + [MsgType.Image, ImageBodyFactory], [MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!], [MsgType.Audio, MVoiceOrAudioBody], [MsgType.Video, VideoBodyFactory], @@ -265,7 +265,7 @@ export default class MessageEvent extends React.Component implements IMe } if ( - ((BodyType === MImageBody || BodyType === VideoBodyFactory) && + ((BodyType === ImageBodyFactory || BodyType === VideoBodyFactory) && !this.validateImageOrVideoMimetype(content)) || (BodyType === MStickerBody && !this.validateStickerMimetype(content)) ) { diff --git a/apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts b/apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts new file mode 100644 index 00000000000..33f55d317e8 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts @@ -0,0 +1,651 @@ +/* + * 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 { + mxEvent: MatrixEvent; + mediaEventHelper?: MediaEventHelper; + forExport?: boolean; + maxImageHeight?: number; + mediaVisible: boolean; + permalinkCreator?: RoomPermalinkCreator; + timelineRenderingType: TimelineRenderingType; + imageRef: RefObject; + 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 & { + "org.matrix.msc4230.is_animated"?: boolean; +}; + +export class ImageBodyViewModel + extends BaseViewModel + 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().info?.[BLURHASH_FIELD] + ? ImageBodyViewPlaceholder.NONE + : ImageBodyViewPlaceholder.SPINNER, + imageSize: SettingsStore.getValue("Images.size") as ImageSize, + generatedThumbnailUrl: null, + }; + } + + private static getImageDimensions( + props: ImageBodyViewModelProps, + state: InternalState, + ): Pick { + const content = props.mxEvent.getContent(); + 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(); + 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().url ?? + props.mxEvent.getContent().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().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(); + 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 { + 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()).srcHttp; + thumbUrl = this.getThumbUrl(); + } + + const content = this.props.mxEvent.getContent(); + 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((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): 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(); + + 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, "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): 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(); + } +} diff --git a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx index 4a25bcb57f9..03aa1bda00a 100644 --- a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx @@ -21,6 +21,7 @@ import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { FileBodyFactory, + ImageBodyFactory, VideoBodyFactory, renderMBody, } from "../../../../../src/components/views/messages/MBodyFactory"; @@ -102,6 +103,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(); }); diff --git a/apps/web/test/unit-tests/components/views/messages/MImageBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/MImageBody-test.tsx deleted file mode 100644 index 882e1fa5d89..00000000000 --- a/apps/web/test/unit-tests/components/views/messages/MImageBody-test.tsx +++ /dev/null @@ -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("", () => { - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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(); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx index 5559647bd33..9ac8da64eac 100644 --- a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx @@ -24,15 +24,11 @@ jest.mock("../../../../../src/components/views/messages/UnknownBody", () => ({ default: () =>
, })); -jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({ - __esModule: true, - default: () =>
, -})); - jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({ __esModule: true, DecryptionFailureBodyFactory: () =>
, FileBodyFactory: () =>
, + ImageBodyFactory: () =>
, RedactedBodyFactory: () =>
Message deleted by Moderator
, VideoBodyFactory: () =>