/* Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import React, { ComponentProps, createRef } from 'react'; import { Blurhash } from "react-blurhash"; import { SyncState } from 'matrix-js-sdk/src/sync'; import classNames from 'classnames'; import { CSSTransition, SwitchTransition } from 'react-transition-group'; import { logger } from "matrix-js-sdk/src/logger"; import MFileBody from './MFileBody'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import InlineSpinner from '../elements/InlineSpinner'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { Media, mediaFromContent } from "../../../customisations/Media"; import { BLURHASH_FIELD } from "../../../ContentMessages"; import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent'; import ImageView from '../elements/ImageView'; import { IBodyProps } from "./IBodyProps"; import { ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; interface IState { decryptedUrl?: string; decryptedThumbnailUrl?: string; decryptedBlob?: Blob; error; imgError: boolean; imgLoaded: boolean; loadedImageDimensions?: { naturalWidth: number; naturalHeight: number; }; hover: boolean; showImage: boolean; placeholder: 'no-image' | 'blurhash'; } @replaceableComponent("views.messages.MImageBody") export default class MImageBody extends React.Component { static contextType = RoomContext; public context!: React.ContextType; private unmounted = true; private image = createRef(); private timeout?: number; private sizeWatcher: string; constructor(props: IBodyProps) { super(props); this.state = { decryptedUrl: null, decryptedThumbnailUrl: null, decryptedBlob: null, error: null, imgError: false, imgLoaded: false, loadedImageDimensions: null, hover: false, showImage: SettingsStore.getValue("showImages"), placeholder: 'no-image', }; } // FIXME: factor this out and apply it to MVideoBody and MAudioBody too! private onClientSync = (syncState: SyncState, prevState: SyncState): void => { if (this.unmounted) return; // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. const reconnected = syncState !== "ERROR" && prevState !== syncState; if (reconnected && this.state.imgError) { // Load the image again this.setState({ imgError: false, }); } }; protected showImage(): void { localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true"); this.setState({ showImage: true }); this.downloadImage(); } protected onClick = (ev: React.MouseEvent): void => { if (ev.button === 0 && !ev.metaKey) { ev.preventDefault(); if (!this.state.showImage) { this.showImage(); return; } const content = this.props.mxEvent.getContent(); const httpUrl = this.getContentUrl(); const params: Omit, "onFinished"> = { src: httpUrl, name: content.body?.length > 0 ? content.body : _t('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", null, true); } }; private isGif = (): boolean => { const content = this.props.mxEvent.getContent(); return content.info?.mimetype === "image/gif"; }; private onImageEnter = (e: React.MouseEvent): void => { this.setState({ hover: true }); if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) { return; } const imgElement = e.currentTarget; imgElement.src = this.getContentUrl(); }; private onImageLeave = (e: React.MouseEvent): void => { this.setState({ hover: false }); if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) { return; } const imgElement = e.currentTarget; imgElement.src = this.getThumbUrl(); }; private onImageError = (): void => { this.clearBlurhashTimeout(); this.setState({ imgError: true, }); }; private onImageLoad = (): void => { this.clearBlurhashTimeout(); this.props.onHeightChanged(); let 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 }); }; protected getContentUrl(): string { const content: IMediaEventContent = this.props.mxEvent.getContent(); // During export, the content url will point to the MSC, which will later point to a local url if (this.props.forExport) return content.url || content.file?.url; if (this.media.isEncrypted) { return this.state.decryptedUrl; } else { return this.media.srcHttp; } } private get media(): Media { return mediaFromContent(this.props.mxEvent.getContent()); } protected getThumbUrl(): string { // 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); if (media.isEncrypted) { // Don't use the thumbnail for clients wishing to autoplay gifs. if (this.state.decryptedThumbnailUrl) { return this.state.decryptedThumbnailUrl; } return this.state.decryptedUrl; } else if (content.info && content.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'); } else { // 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 const info = content.info; if ( this.isGif() || window.devicePixelRatio === 1.0 || (!info || !info.w || !info.h || !info.size) ) { return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); } else { // 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 pregen 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 bytewise 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); } else { // download the original image otherwise, so we can scale it client side // to take pixelRatio into account. return media.srcHttp; } } } } private async downloadImage() { if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) { try { const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value; this.setState({ decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value, decryptedThumbnailUrl: thumbnailUrl, decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value, }); } catch (err) { if (this.unmounted) return; logger.warn("Unable to decrypt attachment: ", err); // Set a placeholder image when we can't decrypt the image. this.setState({ error: err, }); } } } private clearBlurhashTimeout() { if (this.timeout) { clearTimeout(this.timeout); this.timeout = undefined; } } componentDidMount() { this.unmounted = false; MatrixClientPeg.get().on('sync', this.onClientSync); const showImage = this.state.showImage || localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true"; if (showImage) { // noinspection JSIgnoredPromiseFromCall this.downloadImage(); this.setState({ showImage: true }); } // else don't download anything because we don't want to display anything. // Add a 150ms timer for blurhash to first appear. if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) { this.clearBlurhashTimeout(); this.timeout = setTimeout(() => { if (!this.state.imgLoaded || !this.state.imgError) { this.setState({ 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 }); } componentWillUnmount() { this.unmounted = true; MatrixClientPeg.get().removeListener('sync', this.onClientSync); this.clearBlurhashTimeout(); SettingsStore.unwatchSetting(this.sizeWatcher); } protected messageContent( contentUrl: string, thumbUrl: string, content: IMediaEventContent, forcedHeight?: number, ): JSX.Element { let infoWidth: number; let infoHeight: number; if (content && content.info && content.info.w && content.info.h) { infoWidth = content.info.w; infoHeight = content.info.h; } else { // 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; if (!this.state.showImage) { imageElement = ; } 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 // check for any height constraints const imageSize = SettingsStore.getValue("Images.size") as ImageSize; const isPortrait = infoWidth < infoHeight; const suggestedAndPossibleWidth = Math.min(suggestedImageSize(imageSize, isPortrait).w, infoWidth); const suggestedAndPossibleHeight = Math.min(suggestedImageSize(imageSize, isPortrait).h, infoHeight); const aspectRatio = infoWidth / infoHeight; let maxWidth: number; let maxHeight: number; const maxHeightConstraint = forcedHeight || this.props.maxImageHeight || suggestedAndPossibleHeight; if (maxHeightConstraint * aspectRatio < suggestedAndPossibleWidth || imageSize === ImageSize.Large) { // The width is dictated by the maximum height that was defined by the props or the function param `forcedHeight` // If the thumbnail size is set to Large, we always let the size be dictated by the height. maxWidth = maxHeightConstraint * aspectRatio; // there is no need to check for infoHeight here since this is done with `maxHeightConstraint * aspectRatio < suggestedAndPossibleWidth` maxHeight = maxHeightConstraint; } else { // height is dictated by suggestedWidth (based on the Image.size setting) maxWidth = suggestedAndPossibleWidth; maxHeight = suggestedAndPossibleWidth / aspectRatio; } let img = null; let placeholder = null; let gifLabel = null; if (!this.props.forExport && !this.state.imgLoaded) { placeholder = this.getPlaceholder(maxWidth, maxHeight); } let showPlaceholder = Boolean(placeholder); if (thumbUrl && !this.state.imgError) { // 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.state.showImage) { img = ; showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. } if (this.isGif() && !SettingsStore.getValue("autoplayGifs") && !this.state.hover) { gifLabel =

GIF

; } const classes = classNames({ 'mx_MImageBody_thumbnail': true, 'mx_MImageBody_thumbnail--blurhash': this.props.mxEvent.getContent().info?.[BLURHASH_FIELD], }); // This has incredibly broken types. const C = CSSTransition as any; const thumbnail = (
{ /* This weirdly looking div is necessary here, otherwise SwitchTransition fails */ }
{ showPlaceholder &&
{ placeholder }
}
{ img } { gifLabel }
{ this.state.hover && this.getTooltip() }
); return this.wrapImage(contentUrl, thumbnail); } // Overidden by MStickerBody protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element { return { children } ; } // Overidden by MStickerBody protected getPlaceholder(width: number, height: number): JSX.Element { const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]; if (blurhash) { if (this.state.placeholder === 'no-image') { return
; } else if (this.state.placeholder === 'blurhash') { return ; } } return ( ); } // Overidden by MStickerBody protected getTooltip(): JSX.Element { return null; } // Overidden by MStickerBody protected getFileBody(): string | JSX.Element { 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 fullfil 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 ; } } render() { const content = this.props.mxEvent.getContent(); if (this.state.error !== null) { return (
{ _t("Error decrypting image") }
); } const contentUrl = this.getContentUrl(); let thumbUrl; if (this.props.forExport || (this.isGif() && SettingsStore.getValue("autoplayGifs"))) { thumbUrl = contentUrl; } else { thumbUrl = this.getThumbUrl(); } const thumbnail = this.messageContent(contentUrl, thumbUrl, content); const fileBody = this.getFileBody(); return (
{ thumbnail } { fileBody }
); } } interface PlaceholderIProps { hover?: boolean; maxWidth?: number; } export class HiddenImagePlaceholder extends React.PureComponent { render() { const maxWidth = this.props.maxWidth ? this.props.maxWidth + "px" : null; let className = 'mx_HiddenImagePlaceholder'; if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover'; return (
{ _t("Show image") }
); } }