// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { CSSProperties, ReactNode, useCallback, useEffect, useRef, useState, } from 'react'; import classNames from 'classnames'; import moment from 'moment'; import { createPortal } from 'react-dom'; import { noop } from 'lodash'; import * as GoogleChrome from '../util/GoogleChrome'; import { AttachmentType, isGIF } from '../types/Attachment'; import { Avatar, AvatarSize } from './Avatar'; import { ConversationType } from '../state/ducks/conversations'; import { IMAGE_PNG, isImage, isVideo } from '../types/MIME'; import { LocalizerType } from '../types/Util'; import { MediaItemType, MessageAttributesType } from '../types/MediaItem'; import { formatDuration } from '../util/formatDuration'; import { useRestoreFocus } from '../hooks/useRestoreFocus'; import * as log from '../logging/log'; export type PropsType = { children?: ReactNode; close: () => void; getConversation?: (id: string) => ConversationType; i18n: LocalizerType; isViewOnce?: boolean; media: Array; onForward?: (messageId: string) => void; onSave?: (options: { attachment: AttachmentType; message: MessageAttributesType; index: number; }) => void; selectedIndex?: number; }; enum ZoomType { None, FillScreen, ZoomAndPan, } export function Lightbox({ children, close, getConversation, media, i18n, isViewOnce = false, onForward, onSave, selectedIndex: initialSelectedIndex = 0, }: PropsType): JSX.Element | null { const [root, setRoot] = React.useState(); const [selectedIndex, setSelectedIndex] = useState( initialSelectedIndex ); const [videoElement, setVideoElement] = useState( null ); const [videoTime, setVideoTime] = useState(); const [zoomType, setZoomType] = useState(ZoomType.None); const containerRef = useRef(null); const [focusRef] = useRestoreFocus(); const imageRef = useRef(null); const [imagePanStyle, setImagePanStyle] = useState({}); const zoomCoordsRef = useRef< | { initX: number; initY: number; screenWidth: number; screenHeight: number; x: number; y: number; } | undefined >(); const isZoomed = zoomType !== ZoomType.None; const onPrevious = useCallback( ( event: KeyboardEvent | React.MouseEvent ) => { event.preventDefault(); event.stopPropagation(); if (isZoomed) { return; } setSelectedIndex(prevSelectedIndex => Math.max(prevSelectedIndex - 1, 0)); }, [isZoomed] ); const onNext = useCallback( ( event: KeyboardEvent | React.MouseEvent ) => { event.preventDefault(); event.stopPropagation(); if (isZoomed) { return; } setSelectedIndex(prevSelectedIndex => Math.min(prevSelectedIndex + 1, media.length - 1) ); }, [isZoomed, media] ); const onTimeUpdate = useCallback(() => { if (!videoElement) { return; } setVideoTime(videoElement.currentTime); }, [setVideoTime, videoElement]); const handleSave = ( event: React.MouseEvent ) => { event.stopPropagation(); event.preventDefault(); const mediaItem = media[selectedIndex]; const { attachment, message, index } = mediaItem; onSave?.({ attachment, message, index }); }; const handleForward = ( event: React.MouseEvent ) => { event.preventDefault(); event.stopPropagation(); close(); const mediaItem = media[selectedIndex]; onForward?.(mediaItem.message.id); }; const onKeyDown = useCallback( (event: KeyboardEvent) => { switch (event.key) { case 'Escape': { close(); event.preventDefault(); event.stopPropagation(); break; } case 'ArrowLeft': onPrevious(event); break; case 'ArrowRight': onNext(event); break; default: } }, [close, onNext, onPrevious] ); const onClose = (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); close(); }; const playVideo = useCallback(() => { if (!videoElement) { return; } if (videoElement.paused) { videoElement.play(); } else { videoElement.pause(); } }, [videoElement]); useEffect(() => { const div = document.createElement('div'); document.body.appendChild(div); setRoot(div); return () => { document.body.removeChild(div); setRoot(undefined); }; }, []); useEffect(() => { const useCapture = true; document.addEventListener('keydown', onKeyDown, useCapture); return () => { document.removeEventListener('keydown', onKeyDown, useCapture); }; }, [onKeyDown]); const { attachment, contentType, loop = false, objectURL, message } = media[selectedIndex] || {}; const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined); useEffect(() => { playVideo(); if (!videoElement || !isViewOnce) { return noop; } if (isAttachmentGIF) { return noop; } videoElement.addEventListener('timeupdate', onTimeUpdate); return () => { videoElement.removeEventListener('timeupdate', onTimeUpdate); }; }, [isViewOnce, isAttachmentGIF, onTimeUpdate, playVideo, videoElement]); const positionImage = useCallback( (ev?: { clientX: number; clientY: number }) => { const imageNode = imageRef.current; const zoomCoords = zoomCoordsRef.current; if (!imageNode || !zoomCoords) { return; } if (ev) { zoomCoords.x = ev.clientX; zoomCoords.y = ev.clientY; } const shouldTransformX = imageNode.naturalWidth > zoomCoords.screenWidth; const shouldTransformY = imageNode.naturalHeight > zoomCoords.screenHeight; const nextImagePanStyle: CSSProperties = { left: '50%', top: '50%', }; let translateX = '-50%'; let translateY = '-50%'; if (shouldTransformX) { const offset = imageNode.offsetWidth - zoomCoords.screenWidth; const scaleX = (-1 / zoomCoords.screenWidth) * offset; const posX = Math.max( 0, Math.min(zoomCoords.screenWidth, zoomCoords.x) ); translateX = `${posX * scaleX}px`; nextImagePanStyle.left = 0; } if (shouldTransformY) { const offset = imageNode.offsetHeight - zoomCoords.screenHeight; const scaleY = (-1 / zoomCoords.screenHeight) * offset; const posY = Math.max( 0, Math.min(zoomCoords.screenHeight, zoomCoords.y) ); translateY = `${posY * scaleY}px`; nextImagePanStyle.top = 0; } setImagePanStyle({ ...nextImagePanStyle, transform: `translate(${translateX}, ${translateY})`, }); }, [] ); function canPanImage(): boolean { const imageNode = imageRef.current; return Boolean( imageNode && (imageNode.naturalWidth > document.documentElement.clientWidth || imageNode.naturalHeight > document.documentElement.clientHeight) ); } const handleTouchMove = useCallback( (ev: TouchEvent) => { const imageNode = imageRef.current; const zoomCoords = zoomCoordsRef.current; ev.preventDefault(); ev.stopPropagation(); if (!imageNode || !zoomCoords) { return; } const [touch] = ev.touches; const { initX, initY } = zoomCoords; positionImage({ clientX: initX + (initX - touch.clientX), clientY: initY + (initY - touch.clientY), }); }, [positionImage] ); useEffect(() => { const imageNode = imageRef.current; let hasListener = false; if (imageNode && zoomType !== ZoomType.None && canPanImage()) { hasListener = true; document.addEventListener('mousemove', positionImage); document.addEventListener('touchmove', handleTouchMove); } return () => { if (hasListener) { document.removeEventListener('mousemove', positionImage); document.removeEventListener('touchmove', handleTouchMove); } }; }, [handleTouchMove, positionImage, zoomType]); const caption = attachment?.caption; let content: JSX.Element; let shadowImage: JSX.Element | undefined; if (!contentType) { content = <>{children}; } else { const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType); const isVideoTypeSupported = GoogleChrome.isVideoTypeSupported(contentType); const isUnsupportedImageType = !isImageTypeSupported && isImage(contentType); const isUnsupportedVideoType = !isVideoTypeSupported && isVideo(contentType); if (isImageTypeSupported) { if (objectURL) { shadowImage = (
{i18n('lightboxImageAlt')}
); content = ( ); } else { content = ( ))} )} )} , root ) : null; } function LightboxHeader({ getConversation, i18n, message, }: { getConversation: (id: string) => ConversationType; i18n: LocalizerType; message: MessageAttributesType; }): JSX.Element { const conversation = getConversation(message.conversationId); return (
{conversation.title}
{moment(message.received_at_ms).format('L LT')}
); }