import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; import { withGlobal } from '../../lib/teact/teactn'; import { GlobalActions } from '../../global/types'; import { ApiChat, ApiMediaFormat, ApiMessage, ApiUser, } from '../../api/types'; import { MediaViewerOrigin } from '../../types'; import { ANIMATION_END_DELAY } from '../../config'; import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment'; import { AVATAR_FULL_DIMENSIONS, MEDIA_VIEWER_MEDIA_QUERY, calculateMediaViewerDimensions, } from '../common/helpers/mediaDimensions'; import { selectChat, selectChatMessage, selectChatMessages, selectCurrentMediaSearch, selectListedIds, selectOutlyingIds, selectScheduledMessage, selectScheduledMessages, selectUser, } from '../../modules/selectors'; import { getChatAvatarHash, getChatMediaMessageIds, getMessageMediaFilename, getMessageMediaFormat, getMessageMediaHash, getMessageMediaThumbDataUri, getMessagePhoto, getMessageVideo, getMessageWebPagePhoto, getPhotoFullDimensions, getVideoDimensions, IDimensions, } from '../../modules/helpers'; import { pick } from '../../util/iteratees'; import { captureEvents, SwipeDirection } from '../../util/captureEvents'; import captureEscKeyListener from '../../util/captureEscKeyListener'; import { stopCurrentAudio } from '../../util/audioPlayer'; import useForceUpdate from '../../hooks/useForceUpdate'; import useMedia from '../../hooks/useMedia'; import useMediaWithDownloadProgress from '../../hooks/useMediaWithDownloadProgress'; import useBlurSync from '../../hooks/useBlurSync'; import usePrevious from '../../hooks/usePrevious'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; import { renderMessageText } from '../common/helpers/renderMessageText'; import { animateClosing, animateOpening } from './helpers/ghostAnimation'; import useLang from '../../hooks/useLang'; import Spinner from '../ui/Spinner'; import ShowTransition from '../ui/ShowTransition'; import Transition from '../ui/Transition'; import Button from '../ui/Button'; import SenderInfo from './SenderInfo'; import MediaViewerActions from './MediaViewerActions'; import MediaViewerFooter from './MediaViewerFooter'; import VideoPlayer from './VideoPlayer'; import ZoomControls from './ZoomControls'; import PanZoom from './PanZoom'; import './MediaViewer.scss'; type StateProps = { chatId?: number; threadId?: number; messageId?: number; senderId?: number; origin?: MediaViewerOrigin; avatarOwner?: ApiChat | ApiUser; profilePhotoIndex?: number; message?: ApiMessage; chatMessages?: Record; collectionIds?: number[]; animationLevel: 0 | 1 | 2; }; type DispatchProps = Pick; const ANIMATION_DURATION = 350; const MediaViewer: FC = ({ chatId, threadId, messageId, senderId, origin, avatarOwner, profilePhotoIndex, message, chatMessages, collectionIds, openMediaViewer, closeMediaViewer, openForwardMenu, focusMessage, animationLevel, }) => { // eslint-disable-next-line no-null/no-null const animationKey = useRef(null); const isOpen = Boolean(avatarOwner || messageId); const webPagePhoto = message ? getMessageWebPagePhoto(message) : undefined; const photo = message ? getMessagePhoto(message) : undefined; const video = message ? getMessageVideo(message) : undefined; const isWebPagePhoto = Boolean(webPagePhoto); const isPhoto = Boolean(photo || webPagePhoto); const isVideo = Boolean(video); const isGif = video ? video.isGif : undefined; const isFromSharedMedia = origin === MediaViewerOrigin.SharedMedia; const isFromSearch = origin === MediaViewerOrigin.SearchResult; const slideAnimation = animationLevel >= 1 ? 'mv-slide' : 'none'; const headerAnimation = animationLevel === 2 ? 'slide-fade' : 'none'; const isGhostAnimation = animationLevel === 2; const fileName = avatarOwner ? `avatar${avatarOwner.id}-${profilePhotoIndex}.jpg` : message && getMessageMediaFilename(message); const prevSenderId = usePrevious(senderId); const [canPanZoomWrap, setCanPanZoomWrap] = useState(false); const [isZoomed, setIsZoomed] = useState(false); const [zoomLevel, setZoomLevel] = useState(1); const [panDelta, setPanDelta] = useState({ x: 0, y: 0 }); const [isFooterHidden, setIsFooterHidden] = useState(false); const messageIds = useMemo(() => { return isWebPagePhoto && messageId ? [messageId] : getChatMediaMessageIds(chatMessages || {}, collectionIds || [], isFromSharedMedia); }, [isWebPagePhoto, messageId, chatMessages, collectionIds, isFromSharedMedia]); const selectedMediaMessageIndex = messageId ? messageIds.indexOf(messageId) : -1; const isFirst = selectedMediaMessageIndex === 0 || selectedMediaMessageIndex === -1; const isLast = selectedMediaMessageIndex === messageIds.length - 1 || selectedMediaMessageIndex === -1; if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) { animationKey.current = selectedMediaMessageIndex; } function getMediaHash(full?: boolean) { if (avatarOwner && profilePhotoIndex !== undefined) { const { photos } = avatarOwner; return photos && photos[profilePhotoIndex] ? `photo${photos[profilePhotoIndex].id}?size=c` : getChatAvatarHash(avatarOwner, full ? 'big' : 'normal'); } return message && getMessageMediaHash(message, full ? 'viewerFull' : 'viewerPreview'); } const blobUrlPictogram = useMedia( message && (isFromSharedMedia || isFromSearch) && getMessageMediaHash(message, 'pictogram'), undefined, ApiMediaFormat.BlobUrl, undefined, isGhostAnimation && ANIMATION_DURATION, ); const previewMediaHash = getMediaHash(); const blobUrlPreview = useMedia( previewMediaHash, undefined, avatarOwner && previewMediaHash && previewMediaHash.startsWith('profilePhoto') ? ApiMediaFormat.DataUri : ApiMediaFormat.BlobUrl, undefined, isGhostAnimation && ANIMATION_DURATION, ); const { mediaData: fullMediaData, downloadProgress } = useMediaWithDownloadProgress( getMediaHash(true), undefined, message && getMessageMediaFormat(message, 'viewerFull'), undefined, isGhostAnimation && ANIMATION_DURATION, ); const localBlobUrl = (photo || video) ? (photo || video)!.blobUrl : undefined; let bestImageData = (!isVideo && (localBlobUrl || fullMediaData)) || blobUrlPreview || blobUrlPictogram; const thumbDataUri = useBlurSync(!bestImageData && message && getMessageMediaThumbDataUri(message)); if (!bestImageData && origin !== MediaViewerOrigin.SearchResult) { bestImageData = thumbDataUri; } const photoDimensions = isPhoto ? getPhotoFullDimensions(( isWebPagePhoto ? getMessageWebPagePhoto(message!) : getMessagePhoto(message!) )!) : undefined; const videoDimensions = isVideo ? getVideoDimensions(getMessageVideo(message!)!) : undefined; useEffect(() => { if (!IS_SINGLE_COLUMN_LAYOUT) { return; } document.body.classList.toggle('is-media-viewer-open', isOpen); }, [isOpen]); const forceUpdate = useForceUpdate(); useEffect(() => { const mql = window.matchMedia(MEDIA_VIEWER_MEDIA_QUERY); if (typeof mql.addEventListener === 'function') { mql.addEventListener('change', forceUpdate); } else if (typeof mql.addListener === 'function') { mql.addListener(forceUpdate); } return () => { if (typeof mql.removeEventListener === 'function') { mql.removeEventListener('change', forceUpdate); } else if (typeof mql.removeListener === 'function') { mql.removeListener(forceUpdate); } }; }, [forceUpdate]); const prevMessage = usePrevious(message); const prevOrigin = usePrevious(origin); const prevAvatarOwner = usePrevious(avatarOwner); const prevBestImageData = usePrevious(bestImageData); useEffect(() => { if (isGhostAnimation && isOpen && !prevMessage && !prevAvatarOwner) { dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); const textParts = message ? renderMessageText(message) : undefined; const hasFooter = Boolean(textParts); animateOpening(hasFooter, origin!, bestImageData!, message); } if (isGhostAnimation && !isOpen && (prevMessage || prevAvatarOwner)) { dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); animateClosing(prevOrigin!, prevBestImageData!, prevMessage || undefined); } }, [ isGhostAnimation, isOpen, origin, prevOrigin, message, prevMessage, prevAvatarOwner, bestImageData, prevBestImageData, ]); useEffect(() => { let timer: number | undefined; if (isZoomed) { setCanPanZoomWrap(true); } else { timer = window.setTimeout(() => { setCanPanZoomWrap(false); }, ANIMATION_DURATION); } return () => { if (timer) { window.clearTimeout(timer); } }; }, [isZoomed]); const closeZoom = () => { setIsZoomed(false); setZoomLevel(1); setPanDelta({ x: 0, y: 0 }); }; const handleZoomToggle = useCallback(() => { setIsZoomed(!isZoomed); setZoomLevel(!isZoomed ? 1.5 : 1); if (isZoomed) { setPanDelta({ x: 0, y: 0 }); } }, [isZoomed]); const handleZoomValue = useCallback((level: number, canCloseZoom = false) => { setZoomLevel(level); if (level === 1 && canCloseZoom) { closeZoom(); } }, []); const close = useCallback(() => { closeMediaViewer(); closeZoom(); }, [closeMediaViewer]); const handleFooterClick = useCallback(() => { close(); focusMessage({ chatId, threadId, messageId }); }, [close, chatId, threadId, focusMessage, messageId]); const handleForward = useCallback(() => { openForwardMenu({ fromChatId: chatId, messageIds: [messageId] }); closeZoom(); }, [openForwardMenu, chatId, messageId]); useEffect(() => (isOpen ? captureEscKeyListener(() => { if (isZoomed) { closeZoom(); } else { close(); } }) : undefined), [close, isOpen, isZoomed]); useEffect(() => { if (isVideo && !isGif) { stopCurrentAudio(); } }, [isGif, isVideo]); const getMessageId = useCallback((fromId: number, direction: number): number => { let index = messageIds.indexOf(fromId); if ((direction === -1 && index > 0) || (direction === 1 && index < messageIds.length - 1)) { index += direction; } return messageIds[index]; }, [messageIds]); const selectPreviousMedia = useCallback(() => { if (isFirst) { return; } openMediaViewer({ chatId, threadId, messageId: messageId ? getMessageId(messageId, -1) : undefined, origin, }); }, [chatId, threadId, getMessageId, isFirst, messageId, openMediaViewer, origin]); const selectNextMedia = useCallback(() => { if (isLast) { return; } openMediaViewer({ chatId, threadId, messageId: messageId ? getMessageId(messageId, 1) : undefined, origin, }); }, [chatId, threadId, getMessageId, isLast, messageId, openMediaViewer, origin]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'Left': // IE/Edge specific value case 'ArrowLeft': selectPreviousMedia(); break; case 'Right': // IE/Edge specific value case 'ArrowRight': selectNextMedia(); break; } }; document.addEventListener('keydown', handleKeyDown, false); return () => { document.removeEventListener('keydown', handleKeyDown, false); }; }); // Support for swipe gestures and closing on click useEffect(() => { const element = document.querySelector('.slide-container > .active, .slide-container > .to'); if (!element) { return undefined; } const shouldCloseOnVideo = isGif && !IS_IOS; return captureEvents(element, { // eslint-disable-next-line max-len excludedClosestSelector: `.backdrop, .navigation, .media-viewer-head, .media-viewer-footer${!shouldCloseOnVideo ? ', .VideoPlayer' : ''}`, onClick: () => { if (!isZoomed && !IS_TOUCH_ENV) { close(); } }, onSwipe: IS_TOUCH_ENV ? (e, direction) => { if (direction === SwipeDirection.Right) { selectPreviousMedia(); } else if (direction === SwipeDirection.Left) { selectNextMedia(); } else if (!(e.target && (e.target as HTMLElement).closest('.MediaViewerFooter'))) { close(); } } : undefined, }); }, [close, isFooterHidden, isGif, isPhoto, isZoomed, selectNextMedia, selectPreviousMedia]); const handlePan = useCallback((x: number, y: number) => { setPanDelta({ x, y }); }, []); const handleToggleFooterVisibility = useCallback(() => { if (IS_TOUCH_ENV && (isPhoto || isGif)) { setIsFooterHidden(!isFooterHidden); } }, [isFooterHidden, isGif, isPhoto]); const lang = useLang(); function renderSlide(isActive: boolean) { if (avatarOwner) { return (
{renderPhoto( fullMediaData || blobUrlPreview, calculateMediaViewerDimensions(AVATAR_FULL_DIMENSIONS, false), !IS_SINGLE_COLUMN_LAYOUT && !isZoomed, )}
); } else if (message) { const textParts = renderMessageText(message); const hasFooter = Boolean(textParts); return (
{isPhoto && renderPhoto( localBlobUrl || fullMediaData || blobUrlPreview || blobUrlPictogram, message && calculateMediaViewerDimensions(photoDimensions!, hasFooter), !IS_SINGLE_COLUMN_LAYOUT && !isZoomed, )} {isVideo && ( )} {textParts && ( )}
); } return undefined; } function renderSenderInfo() { return ( ); } return ( {() => ( <>
{IS_SINGLE_COLUMN_LAYOUT && ( )} {renderSenderInfo}
{renderSlide} {!isFirst && (