// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { 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 { useSpring, animated, to } from '@react-spring/web'; 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; }; const ZOOM_SCALE = 3; const INITIAL_IMAGE_TRANSFORM = { scale: 1, translateX: 0, translateY: 0, }; 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 [isZoomed, setIsZoomed] = useState(false); const containerRef = useRef(null); const [focusRef] = useRestoreFocus(); const animateRef = useRef(null); const dragCacheRef = useRef< | { startX: number; startY: number; translateX: number; translateY: number; } | undefined >(); const imageRef = useRef(null); const zoomCacheRef = useRef< | { maxX: number; maxY: number; screenWidth: number; screenHeight: number; } | undefined >(); 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 [{ scale, translateX, translateY }, springApi] = useSpring( () => INITIAL_IMAGE_TRANSFORM ); const maxBoundsLimiter = useCallback((x: number, y: number): [ number, number ] => { const zoomCache = zoomCacheRef.current; if (!zoomCache) { return [0, 0]; } const { maxX, maxY } = zoomCache; const posX = Math.min(maxX, Math.max(-maxX, x)); const posY = Math.min(maxY, Math.max(-maxY, y)); return [posX, posY]; }, []); const positionImage = useCallback( (ev: MouseEvent) => { const zoomCache = zoomCacheRef.current; if (!zoomCache) { return; } const { screenWidth, screenHeight } = zoomCache; const offsetX = screenWidth / 2 - ev.clientX; const offsetY = screenHeight / 2 - ev.clientY; const posX = offsetX * ZOOM_SCALE; const posY = offsetY * ZOOM_SCALE; const [x, y] = maxBoundsLimiter(posX, posY); springApi.start({ scale: ZOOM_SCALE, translateX: x, translateY: y, }); }, [maxBoundsLimiter, springApi] ); const handleTouchStart = useCallback( (ev: TouchEvent) => { const [touch] = ev.touches; dragCacheRef.current = { startX: touch.clientX, startY: touch.clientY, translateX: translateX.get(), translateY: translateY.get(), }; }, [translateY, translateX] ); const handleTouchMove = useCallback( (ev: TouchEvent) => { const dragCache = dragCacheRef.current; if (!dragCache) { return; } const [touch] = ev.touches; const deltaX = touch.clientX - dragCache.startX; const deltaY = touch.clientY - dragCache.startY; const x = dragCache.translateX + deltaX; const y = dragCache.translateY + deltaY; springApi.start({ scale: ZOOM_SCALE, translateX: x, translateY: y, }); }, [springApi] ); const zoomButtonHandler = useCallback( (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); const imageNode = imageRef.current; const animateNode = animateRef.current; if (!imageNode || !animateNode) { return; } if (!isZoomed) { zoomCacheRef.current = { maxX: imageNode.offsetWidth, maxY: imageNode.offsetHeight, screenHeight: window.innerHeight, screenWidth: window.innerWidth, }; const { height, left, top, width, } = animateNode.getBoundingClientRect(); const offsetX = ev.clientX - left - width / 2; const offsetY = ev.clientY - top - height / 2; const posX = -offsetX * ZOOM_SCALE + translateX.get(); const posY = -offsetY * ZOOM_SCALE + translateY.get(); const [x, y] = maxBoundsLimiter(posX, posY); springApi.start({ scale: ZOOM_SCALE, translateX: x, translateY: y, }); setIsZoomed(true); } else { springApi.start(INITIAL_IMAGE_TRANSFORM); setIsZoomed(false); } }, [isZoomed, maxBoundsLimiter, translateX, translateY, springApi] ); useEffect(() => { const animateNode = animateRef.current; let hasListener = false; if (animateNode && isZoomed) { hasListener = true; document.addEventListener('mousemove', positionImage); document.addEventListener('touchmove', handleTouchMove); document.addEventListener('touchstart', handleTouchStart); } return () => { if (hasListener) { document.removeEventListener('mousemove', positionImage); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchstart', handleTouchStart); } }; }, [handleTouchMove, handleTouchStart, isZoomed, positionImage]); const caption = attachment?.caption; let content: JSX.Element; 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) { 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')}
); }