// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { createPortal } from 'react-dom'; import { noop } from 'lodash'; import { useSpring, animated, to } from '@react-spring/web'; import type { ReadonlyDeep } from 'type-fest'; import type { ConversationType, SaveAttachmentActionCreatorType, } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; import type { MediaItemType, MediaItemMessageType } from '../types/MediaItem'; import * as GoogleChrome from '../util/GoogleChrome'; import * as log from '../logging/log'; import { Avatar, AvatarSize } from './Avatar'; import { IMAGE_PNG, isImage, isVideo } from '../types/MIME'; import { formatDateTimeForAttachment } from '../util/timestamp'; import { formatDuration } from '../util/formatDuration'; import { isGIF } from '../types/Attachment'; import { useRestoreFocus } from '../hooks/useRestoreFocus'; import { usePrevious } from '../hooks/usePrevious'; import { arrow } from '../util/keyboard'; export type PropsType = { children?: ReactNode; closeLightbox: () => unknown; getConversation?: (id: string) => ConversationType; i18n: LocalizerType; isViewOnce?: boolean; media: ReadonlyArray>; saveAttachment: SaveAttachmentActionCreatorType; selectedIndex: number; toggleForwardMessagesModal: (messageIds: ReadonlyArray) => unknown; onMediaPlaybackStart: () => void; onNextAttachment: () => void; onPrevAttachment: () => void; onSelectAttachment: (index: number) => void; hasPrevMessage?: boolean; hasNextMessage?: boolean; }; const ZOOM_SCALE = 3; const INITIAL_IMAGE_TRANSFORM = { scale: 1, translateX: 0, translateY: 0, config: { clamp: true, friction: 20, mass: 0.5, tension: 350, }, }; const THUMBNAIL_SPRING_CONFIG = { mass: 1, tension: 986, friction: 64, velocity: 0, }; const THUMBNAIL_WIDTH = 44; const THUMBNAIL_PADDING = 8; const THUMBNAIL_FULL_WIDTH = THUMBNAIL_WIDTH + THUMBNAIL_PADDING; export function Lightbox({ children, closeLightbox, getConversation, media, i18n, isViewOnce = false, saveAttachment, selectedIndex, toggleForwardMessagesModal, onMediaPlaybackStart, onNextAttachment, onPrevAttachment, onSelectAttachment, hasNextMessage, hasPrevMessage, }: PropsType): JSX.Element | null { const hasThumbnails = media.length > 1; const messageId = media.at(0)?.message.id; const prevMessageId = usePrevious(messageId, messageId); const needsAnimation = messageId !== prevMessageId; const [root, setRoot] = React.useState(); 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; } onPrevAttachment(); }, [isZoomed, onPrevAttachment] ); const onNext = useCallback( ( event: KeyboardEvent | React.MouseEvent ) => { event.preventDefault(); event.stopPropagation(); if (isZoomed) { return; } onNextAttachment(); }, [isZoomed, onNextAttachment] ); const onTimeUpdate = useCallback(() => { if (!videoElement) { return; } setVideoTime(videoElement.currentTime); }, [setVideoTime, videoElement]); const handleSave = ( event: React.MouseEvent ) => { if (isViewOnce) { return; } event.stopPropagation(); event.preventDefault(); const mediaItem = media[selectedIndex]; const { attachment, message, index } = mediaItem; saveAttachment(attachment, message.sent_at, index + 1); }; const handleForward = ( event: React.MouseEvent ) => { if (isViewOnce) { return; } event.preventDefault(); event.stopPropagation(); closeLightbox(); const mediaItem = media[selectedIndex]; toggleForwardMessagesModal([mediaItem.message.id]); }; const onKeyDown = useCallback( (event: KeyboardEvent) => { switch (event.key) { case 'Escape': { closeLightbox(); event.preventDefault(); event.stopPropagation(); break; } case arrow('start'): onPrevious(event); break; case arrow('end'): onNext(event); break; default: } }, [closeLightbox, onNext, onPrevious] ); const onClose = (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); closeLightbox(); }; const playVideo = useCallback(() => { if (!videoElement) { return; } if (videoElement.paused) { onMediaPlaybackStart(); void videoElement.play(); } else { videoElement.pause(); } }, [videoElement, onMediaPlaybackStart]); 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 thumbnailsMarginInlineStart = 0 - (selectedIndex * THUMBNAIL_FULL_WIDTH + THUMBNAIL_WIDTH / 2); const [thumbnailsStyle, thumbnailsAnimation] = useSpring( { config: THUMBNAIL_SPRING_CONFIG, to: { marginInlineStart: thumbnailsMarginInlineStart, opacity: hasThumbnails ? 1 : 0, }, }, [selectedIndex, hasThumbnails] ); useEffect(() => { if (!needsAnimation) { return; } thumbnailsAnimation.stop(); thumbnailsAnimation.set({ marginInlineStart: thumbnailsMarginInlineStart + (selectedIndex === 0 ? 1 : -1) * THUMBNAIL_FULL_WIDTH, opacity: 0, }); thumbnailsAnimation.start({ marginInlineStart: thumbnailsMarginInlineStart, opacity: 1, }); }, [ needsAnimation, selectedIndex, thumbnailsMarginInlineStart, thumbnailsAnimation, ]); 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 { maxX, maxY, screenWidth, screenHeight } = zoomCache; const shouldTranslateX = maxX * ZOOM_SCALE > screenWidth; const shouldTranslateY = maxY * ZOOM_SCALE > screenHeight; 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: shouldTranslateX ? x : undefined, translateY: shouldTranslateY ? y : undefined, }); }, [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) { const maxX = imageNode.offsetWidth; const maxY = imageNode.offsetHeight; const screenHeight = window.innerHeight; const screenWidth = window.innerWidth; zoomCacheRef.current = { maxX, maxY, screenHeight, screenWidth, }; const shouldTranslateX = maxX * ZOOM_SCALE > screenWidth; const shouldTranslateY = maxY * ZOOM_SCALE > screenHeight; 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: shouldTranslateX ? x : undefined, translateY: shouldTranslateY ? y : undefined, }); 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 = ( )) : undefined} , root ) : null; } function LightboxHeader({ getConversation, i18n, message, }: { getConversation: (id: string) => ConversationType; i18n: LocalizerType; message: ReadonlyDeep; }): JSX.Element { const conversation = getConversation(message.conversationId); const now = Date.now(); return (
{conversation.title}
{formatDateTimeForAttachment(i18n, message.received_at_ms ?? now)}
); }