diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 023322476e..a7ff42a37f 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -7,7 +7,17 @@ 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, type ComponentProps, createRef, type ReactNode } from "react"; +import React, { + type JSX, + type ComponentProps, + type ReactNode, + useState, + useEffect, + useRef, + useCallback, + useContext, + useMemo, +} from "react"; import { Blurhash } from "react-blurhash"; import classNames from "classnames"; import { CSSTransition, SwitchTransition } from "react-transition-group"; @@ -21,7 +31,7 @@ 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 { mediaFromContent } from "../../../customisations/Media"; import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media"; import ImageView from "../elements/ImageView"; import { type IBodyProps } from "./IBodyProps"; @@ -41,7 +51,19 @@ enum Placeholder { Blurhash, } -interface IState { +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; +} + +interface MImageBodyInnerState { contentUrl: string | null; thumbUrl: string | null; isAnimated?: boolean; @@ -57,32 +79,28 @@ interface IState { 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; +interface MImageBodyInnerOptions { + onClick?: (ev: React.MouseEvent) => void; + wrapImage?: (contentUrl: string | null | undefined, children: JSX.Element) => ReactNode; + getPlaceholder?: (width: number, height: number) => ReactNode; + getTooltipProps?: () => ComponentProps | null; + getFileBody?: () => ReactNode; + getBanner?: (content: ImageContent) => ReactNode; } /** * @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; +export const MImageBodyInner: React.FC = (props) => { + const context = useContext(RoomContext); + const unmountedRef = useRef(false); + const imageRef = useRef(null); + const placeholderRef = useRef(null); + const timeoutRef = useRef(undefined); + const sizeWatcherRef = useRef(undefined); + const reconnectedListenerRef = useRef<((syncState: any, prevState: any) => void) | undefined>(undefined); - private unmounted = false; - private image = createRef(); - private placeholder = createRef(); - private timeout?: number; - private sizeWatcher?: string; - - public state: IState = { + const [state, setState] = useState({ contentUrl: null, thumbUrl: null, imgError: false, @@ -90,117 +108,77 @@ export class MImageBodyInner extends React.Component { 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(); - const httpUrl = this.state.contentUrl; - 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 => { + const media = useMemo(() => mediaFromContent(props.mxEvent.getContent()), [props.mxEvent]); + + const getContentUrl = useCallback((): string | null => { + // During export, the content url will point to the MSC, which will later point to a local url + if (props.forExport) return media.srcMxc; + return media.srcHttp; + }, [props.forExport, media]); + + const shouldAutoplay = useMemo((): boolean => { + return !( + !state.contentUrl || + !props.mediaVisible || + !state.isAnimated || + SettingsStore.getValue("autoplayGifs") + ); + }, [state.contentUrl, props.mediaVisible, state.isAnimated]); + + const clearBlurhashTimeout = useCallback((): void => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = undefined; + } + }, []); + + const onImageEnter = useCallback((): void => { + setState((prev) => ({ ...prev, hover: true })); + }, []); + + const onImageLeave = useCallback((): void => { + setState((prev) => ({ ...prev, hover: false })); + }, []); + + const onFocus = useCallback((): void => { + setState((prev) => ({ ...prev, focus: true })); + }, []); + + const onBlur = useCallback((): void => { + setState((prev) => ({ ...prev, focus: false })); + }, []); + + const onImageError = useCallback((): void => { // If the thumbnail failed to load then try again using the contentUrl - if (this.state.thumbUrl) { - this.setState({ - thumbUrl: null, - }); + if (state.thumbUrl) { + setState((prev) => ({ ...prev, thumbUrl: null })); return; } - this.clearBlurhashTimeout(); - this.setState({ - imgError: true, - }); - MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener); - }; + clearBlurhashTimeout(); + setState((prev) => ({ ...prev, imgError: true })); - private onImageLoad = (): void => { - this.clearBlurhashTimeout(); + if (reconnectedListenerRef.current) { + MatrixClientPeg.safeGet().on(ClientEvent.Sync, reconnectedListenerRef.current); + } + }, [state.thumbUrl, clearBlurhashTimeout]); - let loadedImageDimensions: IState["loadedImageDimensions"]; + const onImageLoad = useCallback((): void => { + clearBlurhashTimeout(); - if (this.image.current) { - const { naturalWidth, naturalHeight } = this.image.current; + let loadedImageDimensions: MImageBodyInnerState["loadedImageDimensions"]; + + if (imageRef.current) { + const { naturalWidth, naturalHeight } = imageRef.current; // this is only used as a fallback in case content.info.w/h is missing loadedImageDimensions = { naturalWidth, naturalHeight }; } - this.setState({ imgLoaded: true, loadedImageDimensions }); - }; + setState((prev) => ({ ...prev, imgLoaded: true, loadedImageDimensions })); + }, [clearBlurhashTimeout]); - 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 { + const getThumbUrl = useCallback((): 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. @@ -208,7 +186,7 @@ export class MImageBodyInner extends React.Component { const thumbWidth = 800; const thumbHeight = 600; - const content = this.props.mxEvent.getContent(); + const content = props.mxEvent.getContent(); const media = mediaFromContent(content); const info = content.info; @@ -227,7 +205,7 @@ export class MImageBodyInner extends React.Component { // 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) { + if (state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) { return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); } @@ -250,436 +228,524 @@ export class MImageBodyInner extends React.Component { // download the original image otherwise, so we can scale it client side to take pixelRatio into account. return media.srcHttp; - } + }, [props.mxEvent, state.isAnimated]); - private async downloadImage(): Promise { - if (this.state.contentUrl) return; // already downloaded + const downloadImage = useCallback(async (): Promise => { + let dlThumbUrl: string | null; + let dlContentUrl: string | null; - 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, + try { + if (props.mediaEventHelper?.media.isEncrypted) { + [dlContentUrl, dlThumbUrl] = await Promise.all([ + props.mediaEventHelper.sourceUrl.value, + 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 { + dlThumbUrl = getThumbUrl(); + dlContentUrl = getContentUrl(); } - } 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 (unmountedRef.current) return; - // 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 ?? ""; + const eventContent = props.mxEvent.getContent(); + let isAnimated = + eventContent.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(eventContent.info?.mimetype); - try { - await loadPromise; - } catch (error) { - logger.error("Unable to download attachment: ", error); - this.setState({ error: error as Error }); - return; - } + // 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 ( + !dlThumbUrl || + !eventContent?.info?.thumbnail_info || + mayBeAnimated(eventContent.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 = dlContentUrl ?? ""; - 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; + try { + await loadPromise; + } catch (error) { + // Non-animated thumbnail generation failed, fall back to the original thumbnail. + logger.warn("Failed to generate thumbnail for animation", error); + // Continue with the original contentUrl if loading fails. } - if (isAnimated) { - const thumb = await createThumbnail( - img, - img.width, - img.height, - content.info?.mimetype ?? "image/jpeg", - false, - ); - thumbUrl = URL.createObjectURL(thumb.thumbnail); + try { + if (props.mediaEventHelper) { + const sourceBlob = await props.mediaEventHelper.sourceBlob.value; + if (await blobIsAnimated(sourceBlob)) { + const thumbnail = await createThumbnail( + img, + img.width, + img.height, + "image/jpeg", + false, + ); + if (thumbnail.thumbnail) { + dlThumbUrl = URL.createObjectURL(thumbnail.thumbnail); + } + } else { + isAnimated = false; + } + } + } catch (error) { + logger.warn("Failed to thumbnail image", error); + // Continue with the original thumbUrl if thumbnailing fails. } - } 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, + if (unmountedRef.current) return; + setState((prev) => ({ + ...prev, + contentUrl: dlContentUrl, + thumbUrl: dlThumbUrl, + isAnimated, + })); + } catch (error) { + if (unmountedRef.current) return; + + if (error instanceof DecryptError) { + logger.error("Error decrypting image", error); + } else if (error instanceof DownloadError) { + logger.error("Error downloading image", error); + } else { + logger.error("Unknown error loading image", error); + } + + // Set a placeholder image when we can't decrypt the image. + setState((prev) => ({ ...prev, error })); + } + }, [props.mediaEventHelper, getThumbUrl, getContentUrl, props.mxEvent]); + + useEffect(() => { + reconnectedListenerRef.current = createReconnectedListener(() => { + setState((prev) => ({ ...prev, imgError: false })); }); - } + }, []); - private clearBlurhashTimeout(): void { - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = undefined; - } - } + useEffect(() => { + unmountedRef.current = false; - public componentDidMount(): void { - this.unmounted = false; - - if (this.props.mediaVisible) { - // noinspection JSIgnoredPromiseFromCall - this.downloadImage(); + if (props.mediaVisible && !state.contentUrl) { + 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, - }); - } + if (props.mxEvent.getContent().info?.[BLURHASH_FIELD]) { + clearBlurhashTimeout(); + timeoutRef.current = window.setTimeout(() => { + setState((prev) => { + if (!prev.imgLoaded && !prev.imgError) { + return { ...prev, placeholder: Placeholder.Blurhash }; + } + return prev; + }); }, 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 + sizeWatcherRef.current = SettingsStore.watchSetting("Images.size", null, () => { + // Force update since we don't really have a reliable thing to update + setState((prev) => ({ ...prev })); }); - } - public componentDidUpdate(prevProps: Readonly): void { - if (!prevProps.mediaVisible && this.props.mediaVisible) { - // noinspection JSIgnoredPromiseFromCall - this.downloadImage(); - } - } + return () => { + unmountedRef.current = true; + MatrixClientPeg.get()?.off(ClientEvent.Sync, reconnectedListenerRef.current!); + clearBlurhashTimeout(); + if (sizeWatcherRef.current) { + SettingsStore.unwatchSetting(sizeWatcherRef.current); + } + if (state.isAnimated && state.thumbUrl) { + URL.revokeObjectURL(state.thumbUrl); + } + }; + }, [ + props.mxEvent, + props.mediaVisible, + downloadImage, + clearBlurhashTimeout, + state.isAnimated, + state.thumbUrl, + state.contentUrl, + ]); - 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} - ); + const defaultOnClick = useCallback( + (ev: React.MouseEvent): void => { + if (ev.button === 0 && !ev.metaKey) { + ev.preventDefault(); + if (!props.mediaVisible) { + props.setMediaVisible(true); + return; } - return this.wrapImage(contentUrl, imageElement); + + const content = props.mxEvent.getContent(); + const httpUrl = state.contentUrl; + if (!httpUrl) return; + const params: Omit, "onFinished"> = { + src: httpUrl, + name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"), + mxEvent: props.mxEvent, + permalinkCreator: props.permalinkCreator, + }; + + if (content.info) { + params.width = content.info.w; + params.height = content.info.h; + params.fileSize = content.info.size; + } + + if (imageRef.current) { + const clientRect = 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); } - infoWidth = this.state.loadedImageDimensions.naturalWidth; - infoHeight = this.state.loadedImageDimensions.naturalHeight; - } + }, + [props, state.contentUrl], + ); - // 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, - ); + const onClick = props.onClick || defaultOnClick; - 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) { + const getBanner = useCallback( + (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(context.timelineRenderingType) + ) { return null; - } else if (this.state.placeholder === Placeholder.Blurhash) { - return ; } + + return ( + + {presentableTextForFile(content, _t("common|image"), true, true)} + + ); + }, + [context.timelineRenderingType], + ); + + const getPlaceholder = useCallback( + (width: number, height: number): ReactNode => { + if (props.getPlaceholder) { + return props.getPlaceholder(width, height); + } + + const blurhash = props.mxEvent.getContent().info?.[BLURHASH_FIELD]; + + if (blurhash) { + if (state.placeholder === Placeholder.NoImage) { + return null; + } else if (state.placeholder === Placeholder.Blurhash) { + return ; + } + } + return ; + }, + [props, state.placeholder], + ); + + const getTooltipProps = useCallback((): ComponentProps | null => { + if (props.getTooltipProps) { + return props.getTooltipProps(); } - return ; - } - - // Overridden by MStickerBody - protected getTooltipProps(): ComponentProps | null { return null; - } + }, [props]); - // Overridden by MStickerBody - protected getFileBody(): ReactNode { - if (this.props.forExport) return null; + const getFileBody = useCallback((): ReactNode => { + if (props.getFileBody) { + return props.getFileBody(); + } + + if (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; + context.timelineRenderingType === TimelineRenderingType.Room || + context.timelineRenderingType === TimelineRenderingType.Pinned || + context.timelineRenderingType === TimelineRenderingType.Search || + context.timelineRenderingType === TimelineRenderingType.Thread || + context.timelineRenderingType === TimelineRenderingType.ThreadsList; if (!hasMessageActionBar) { - return ; + return ; } - } + }, [props, context.timelineRenderingType]); - public render(): React.ReactNode { - const content = this.props.mxEvent.getContent(); - - 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"); + const wrapImage = useCallback( + (contentUrl: string | null | undefined, children: JSX.Element): ReactNode => { + if (props.wrapImage) { + return props.wrapImage(contentUrl, children); } - return {errorText}; + if (contentUrl) { + return ( + + {children} + + ); + } + return children; + }, + [props, onClick, onFocus, onBlur], + ); + + const messageContent = useCallback( + ( + 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 (!state.loadedImageDimensions) { + let imageElement: JSX.Element; + if (!props.mediaVisible) { + imageElement = ( + + {_t("timeline|m.image|show_image")} + + ); + } else { + imageElement = ( + {content.body} + ); + } + return wrapImage(contentUrl, imageElement); + } + infoWidth = state.loadedImageDimensions.naturalWidth; + infoHeight = 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 ?? props.maxImageHeight, + ); + + let img: JSX.Element | undefined; + let placeholder: JSX.Element | undefined; + let gifLabel: JSX.Element | undefined; + + if (!props.forExport && !state.imgLoaded) { + const classes = classNames("mx_MImageBody_placeholder", { + "mx_MImageBody_placeholder--blurhash": props.mxEvent.getContent().info?.[BLURHASH_FIELD], + }); + + placeholder = ( +
+ {getPlaceholder(maxWidth, maxHeight)} +
+ ); + } + + let showPlaceholder = Boolean(placeholder); + + const hoverOrFocus = state.hover || state.focus; + if (thumbUrl && !state.imgError) { + let url = thumbUrl; + if (hoverOrFocus && shouldAutoplay) { + url = 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 (!props.mediaVisible) { + img = ( +
+ + {_t("timeline|m.image|show_image")} + +
+ ); + showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. + } + + if (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 (props.mediaVisible && hoverOrFocus) { + banner = 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 (!props.forExport) { + placeholder = ( + + + { + showPlaceholder ? ( + placeholder + ) : ( +
+ ) /* Transition always expects a child */ + } + + + ); + } + + const tooltipProps = getTooltipProps(); + let thumbnail = ( +
+ {placeholder} + +
+ {img} + {gifLabel} + {banner} +
+ + {/* HACK: This div fills out space while the image loads, to prevent scroll jumps */} + {!props.forExport && !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 wrapImage(contentUrl, thumbnail); + }, + [ + state, + props, + shouldAutoplay, + imageRef, + placeholderRef, + onImageError, + onImageLoad, + onImageEnter, + onImageLeave, + wrapImage, + getPlaceholder, + getBanner, + onClick, + getTooltipProps, + ], + ); + + // Render + const content = props.mxEvent.getContent(); + + if (state.error) { + let errorText = _t("timeline|m.image|error"); + if (state.error instanceof DecryptError) { + errorText = _t("timeline|m.image|error_decrypting"); + } else if (state.error instanceof DownloadError) { + errorText = _t("timeline|m.image|error_downloading"); } - 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} -
- ); + return {errorText}; } -} + + let contentUrl = state.contentUrl; + let thumbUrl: string | null; + if (props.forExport) { + contentUrl = props.mxEvent.getContent().url ?? props.mxEvent.getContent().file?.url; + thumbUrl = contentUrl; + } else if (state.isAnimated && SettingsStore.getValue("autoplayGifs")) { + thumbUrl = contentUrl; + } else { + thumbUrl = state.thumbUrl ?? state.contentUrl; + } + + const thumbnail = messageContent(contentUrl, thumbUrl, content); + const fileBody = getFileBody(); + + return ( +
+ {thumbnail} + {fileBody} +
+ ); +}; // Wrap MImageBody component so we can use a hook here. const MImageBody: React.FC = (props) => { diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx index 5f04df724d..4c369ce567 100644 --- a/src/components/views/messages/MImageReplyBody.tsx +++ b/src/components/views/messages/MImageReplyBody.tsx @@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details. */ import React, { type JSX } from "react"; -import { type ImageContent } from "matrix-js-sdk/src/types"; import { MImageBodyInner } from "./MImageBody"; import { type IBodyProps } from "./IBodyProps"; @@ -15,31 +14,29 @@ import { useMediaVisible } from "../../../hooks/useMediaVisible"; const FORCED_IMAGE_HEIGHT = 44; -class MImageReplyBodyInner extends MImageBodyInner { - public onClick = (ev: React.MouseEvent): void => { +const MImageReplyBody: React.FC = (props) => { + const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); + + const onClick = (ev: React.MouseEvent): void => { ev.preventDefault(); }; - public wrapImage(contentUrl: string, children: JSX.Element): JSX.Element { + const wrapImage = (contentUrl: string | null | undefined, children: JSX.Element): JSX.Element => { return children; - } + }; - public render(): React.ReactNode { - if (this.state.error) { - return super.render(); - } - - const content = this.props.mxEvent.getContent(); - const thumbnail = this.state.contentUrl - ? this.messageContent(this.state.contentUrl, this.state.thumbUrl, content, FORCED_IMAGE_HEIGHT) - : undefined; - - return
{thumbnail}
; - } -} -const MImageReplyBody: React.FC = (props) => { - const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); - return ; + return ( +
+ +
+ ); }; export default MImageReplyBody; diff --git a/src/components/views/messages/MStickerBody.tsx b/src/components/views/messages/MStickerBody.tsx index f0beea72aa..c40d033cf4 100644 --- a/src/components/views/messages/MStickerBody.tsx +++ b/src/components/views/messages/MStickerBody.tsx @@ -15,33 +15,37 @@ import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg"; import { type IBodyProps } from "./IBodyProps"; import { useMediaVisible } from "../../../hooks/useMediaVisible"; -class MStickerBodyInner extends MImageBodyInner { - // Mostly empty to prevent default behaviour of MImageBody - protected onClick = (ev: React.MouseEvent): void => { +const MStickerBody: React.FC = (props) => { + const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); + + const onClick = (ev: React.MouseEvent): void => { ev.preventDefault(); - if (!this.props.mediaVisible) { - this.props.setMediaVisible(true); + if (!mediaVisible) { + setVisible(true); } }; // MStickerBody doesn't need a wrapping ``, but it does need extra padding // which is added by mx_MStickerBody_wrapper - protected wrapImage(contentUrl: string, children: React.ReactNode): JSX.Element { - let onClick: React.MouseEventHandler | undefined; - if (!this.props.mediaVisible) { - onClick = this.onClick; + const wrapImage = (contentUrl: string | null | undefined, children: React.ReactNode): JSX.Element => { + let onClickHandler: React.MouseEventHandler | undefined; + if (!mediaVisible) { + onClickHandler = onClick; } return ( -
+
{" "} {children}{" "}
); - } + }; // Placeholder to show in place of the sticker image if img onLoad hasn't fired yet. - protected getPlaceholder(width: number, height: number): ReactNode { - if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) return super.getPlaceholder(width, height); + const getPlaceholder = (width: number, height: number): ReactNode => { + if (props.mxEvent.getContent().info?.[BLURHASH_FIELD]) { + // Use default blurhash placeholder + return null; + } return ( ); - } + }; // Tooltip to show on mouse over - protected getTooltipProps(): ComponentProps | null { - const content = this.props.mxEvent && this.props.mxEvent.getContent(); + const getTooltipProps = (): ComponentProps | null => { + const content = props.mxEvent && props.mxEvent.getContent(); if (!content?.body || !content.info?.w) return null; @@ -66,21 +68,30 @@ class MStickerBodyInner extends MImageBodyInner { placement: "right", description: content.body, }; - } + }; // Don't show "Download this_file.png ..." - protected getFileBody(): ReactNode { + const getFileBody = (): ReactNode => { return null; - } + }; - protected getBanner(content: MediaEventContent): ReactNode { + const getBanner = (content: MediaEventContent): ReactNode => { return null; // we don't need a banner, we have a tooltip - } -} + }; -const MStickerBody: React.FC = (props) => { - const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); - return ; + return ( + + ); }; export default MStickerBody;