import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import { Badge, Box, Button, Chip, Icon, Icons, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as, } from 'folds'; import classNames from 'classnames'; import { BlurhashCanvas } from 'react-blurhash'; import FocusTrap from 'focus-trap-react'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import * as css from './style.css'; import { bytesToSize } from '../../../utils/common'; import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes'; import { stopPropagation } from '../../../utils/keyboard'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { ModalWide } from '../../../styles/Modal.css'; import { validBlurHash } from '../../../utils/blurHash'; import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext'; import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer'; type RenderViewerProps = { src: string; alt: string; requestClose: () => void; }; type RenderImageProps = { alt: string; title: string; src: string; onLoad: () => void; onError: () => void; onClick: () => void; onKeyDown: (e: React.KeyboardEvent) => void; tabIndex: number; // `role="button"` so assistive tech announces the clickable // image as a button rather than a plain image. Paired with // `aria-label` and an Enter/Space `onKeyDown` to make the // affordance keyboard-activatable per WAI-ARIA. Element-Web // wraps in `` — we keep the bare `` to // avoid relayout, which works because folds' `Image` is // `as<'img'>` and just spreads these props onto the DOM node. role: 'button'; 'aria-label': string; }; export type ImageContentProps = { body: string; mimeType?: string; url: string; info?: IImageInfo; encInfo?: EncryptedAttachmentInfo; autoPlay?: boolean; markedAsSpoiler?: boolean; spoilerReason?: string; // When provided AND the `MediaViewerHostContext` is non-null, // clicking the thumbnail opens the atom-driven horseshoe viewer // (mobile bottom-up sheet / desktop right pane) instead of the // legacy full-screen `` viewer. Non-Room surfaces // (pin-menu, message search) leave the host context as `null` and // therefore keep the legacy modal even if they pass `eventId`. eventId?: string; renderViewer: (props: RenderViewerProps) => ReactNode; renderImage: (props: RenderImageProps) => ReactNode; }; export const ImageContent = as<'div', ImageContentProps>( ( { className, body, mimeType, url, info, encInfo, autoPlay, markedAsSpoiler, spoilerReason, eventId, renderViewer, renderImage, ...props }, ref ) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const host = useMediaViewerHost(); const openMediaViewer = useOpenMediaViewer(); const useAtomViewer = !!(host && eventId); const [load, setLoad] = useState(false); const [error, setError] = useState(false); const [viewer, setViewer] = useState(false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const handleOpen = () => { if (useAtomViewer && host && eventId) { // The viewer body re-resolves + decrypts the media itself, // owning the blob-URL lifecycle so it can revoke on close. // We deliberately don't pass `srcState.data` here even when // it's available — pinning a blob URL into the atom would // leak it (the atom outlives the timeline thumbnail). openMediaViewer({ roomId: host.roomId, eventId, kind: 'image', url, body, info, encInfo, mimeType, }); return; } setViewer(true); }; const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); if (!mediaUrl) throw new Error('Invalid media URL'); if (encInfo) { const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) ); return URL.createObjectURL(fileContent); } return mediaUrl; }, [mx, url, useAuthentication, mimeType, encInfo]) ); const handleLoad = () => { setLoad(true); }; const handleError = () => { setLoad(false); setError(true); }; const handleRetry = () => { setError(false); loadSrc(); }; useEffect(() => { if (autoPlay) loadSrc(); }, [autoPlay, loadSrc]); return ( {!useAtomViewer && srcState.status === AsyncStatus.Success && ( }> setViewer(false), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > evt.stopPropagation()} > {renderViewer({ src: srcState.data, alt: body, requestClose: () => setViewer(false), })} )} {typeof blurHash === 'string' && !load && ( )} {!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && ( )} {srcState.status === AsyncStatus.Success && ( {renderImage({ alt: body, title: body, src: srcState.data, onLoad: handleLoad, onError: handleError, onClick: handleOpen, onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleOpen(); } }, tabIndex: 0, role: 'button', 'aria-label': body || 'Open media', })} )} {blurred && !error && srcState.status !== AsyncStatus.Error && ( {spoilerReason} ) } position="Top" align="Center" > {(triggerRef) => ( { setBlurred(false); if (srcState.status === AsyncStatus.Idle) { loadSrc(); } }} > Spoiler )} )} {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && !load && !blurred && ( )} {(error || srcState.status === AsyncStatus.Error) && ( Failed to load image! } position="Top" align="Center" > {(triggerRef) => ( )} )} {!load && typeof info?.size === 'number' && ( {bytesToSize(info.size)} )} ); } );