import type { FC } from '../../lib/teact/teact'; import React, { memo, useEffect, useMemo, useRef, } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { ApiMessage, ApiPeer, ApiPhoto, ApiUser, } from '../../api/types'; import { MediaViewerOrigin, type ThreadId } from '../../types'; import { ANIMATION_END_DELAY } from '../../config'; import { getChatMediaMessageIds, isChatAdmin, isUserId } from '../../global/helpers'; import { selectChat, selectChatMessage, selectChatMessages, selectChatScheduledMessages, selectCurrentChatMediaSearch, selectCurrentSharedMediaSearch, selectIsChatWithSelf, selectListedIds, selectOutlyingListByMessageId, selectPerformanceSettingsValue, selectScheduledMessage, selectTabState, selectUser, selectUserFullInfo, } from '../../global/selectors'; import { stopCurrentAudio } from '../../util/audioPlayer'; import captureEscKeyListener from '../../util/captureEscKeyListener'; import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager'; import { MEDIA_VIEWER_MEDIA_QUERY } from '../common/helpers/mediaDimensions'; import { renderMessageText } from '../common/helpers/renderMessageText'; import { animateClosing, animateOpening } from './helpers/ghostAnimation'; import useAppLayout from '../../hooks/useAppLayout'; import useElectronDrag from '../../hooks/useElectronDrag'; import useFlag from '../../hooks/useFlag'; import useForceUpdate from '../../hooks/useForceUpdate'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; import { exitPictureInPictureIfNeeded, usePictureInPictureSignal } from '../../hooks/usePictureInPicture'; import usePrevious from '../../hooks/usePrevious'; import { dispatchPriorityPlaybackEvent } from '../../hooks/usePriorityPlaybackCheck'; import { useStateRef } from '../../hooks/useStateRef'; import { useMediaProps } from './hooks/useMediaProps'; import ReportModal from '../common/ReportModal'; import Button from '../ui/Button'; import ShowTransition from '../ui/ShowTransition'; import Transition from '../ui/Transition'; import MediaViewerActions from './MediaViewerActions'; import MediaViewerSlides from './MediaViewerSlides'; import SenderInfo from './SenderInfo'; import './MediaViewer.scss'; type StateProps = { chatId?: string; threadId?: ThreadId; mediaId?: number; senderId?: string; isChatWithSelf?: boolean; canUpdateMedia?: boolean; origin?: MediaViewerOrigin; avatarOwner?: ApiPeer; avatarOwnerFallbackPhoto?: ApiPhoto; message?: ApiMessage; chatMessages?: Record; collectionIds?: number[]; isHidden?: boolean; withAnimation?: boolean; shouldSkipHistoryAnimations?: boolean; withDynamicLoading?: boolean; isLoadingMoreMedia?: boolean; isSynced?: boolean; }; const ANIMATION_DURATION = 250; const MediaViewer: FC = ({ chatId, threadId, mediaId, senderId, isChatWithSelf, canUpdateMedia, origin, avatarOwner, avatarOwnerFallbackPhoto, message, chatMessages, collectionIds, withAnimation, isHidden, shouldSkipHistoryAnimations, withDynamicLoading, isLoadingMoreMedia, isSynced, }) => { const { openMediaViewer, closeMediaViewer, openForwardMenu, focusMessage, toggleChatInfo, searchChatMediaMessages, } = getActions(); const isOpen = Boolean(avatarOwner || mediaId); const { isMobile } = useAppLayout(); /* Animation */ const animationKey = useRef(); const prevSenderId = usePrevious(senderId); const headerAnimation = withAnimation ? 'slideFade' : 'none'; const isGhostAnimation = Boolean(withAnimation && !shouldSkipHistoryAnimations); /* Controls */ const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(); const { webPagePhoto, webPageVideo, isVideo, actionPhoto, isPhoto, bestImageData, bestData, dimensions, isGif, isFromSharedMedia, avatarPhoto, fileName, } = useMediaProps({ message, avatarOwner, mediaId, origin, delay: isGhostAnimation && ANIMATION_DURATION, }); const canReport = !!avatarPhoto && !isChatWithSelf; const isVisible = !isHidden && isOpen; /* Navigation */ const singleMediaId = webPagePhoto || webPageVideo || actionPhoto || isGif ? mediaId : undefined; const mediaIds = useMemo(() => { if (singleMediaId) return [singleMediaId]; if (avatarOwner) return avatarOwner.photos?.map((p, i) => i) || []; if (withDynamicLoading) return collectionIds || []; return getChatMediaMessageIds(chatMessages || {}, collectionIds || [], isFromSharedMedia); }, [singleMediaId, avatarOwner, chatMessages, collectionIds, isFromSharedMedia, withDynamicLoading]); const selectedMediaIndex = mediaId ? mediaIds.indexOf(mediaId) : -1; if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) { animationKey.current = selectedMediaIndex; } const [getIsPictureInPicture] = usePictureInPictureSignal(); useEffect(() => { if (!isOpen || getIsPictureInPicture()) { return undefined; } disableDirectTextInput(); const stopPriorityPlayback = dispatchPriorityPlaybackEvent(); return () => { stopPriorityPlayback(); enableDirectTextInput(); }; }, [isOpen, getIsPictureInPicture]); useEffect(() => { if (isVisible) { exitPictureInPictureIfNeeded(); } }, [isVisible]); useEffect(() => { if (isMobile) { document.body.classList.toggle('is-media-viewer-open', isOpen); } }, [isMobile, isOpen]); // eslint-disable-next-line no-null/no-null const headerRef = useRef(null); useElectronDrag(headerRef); const forceUpdate = useForceUpdate(); useEffect(() => { const mql = window.matchMedia(MEDIA_VIEWER_MEDIA_QUERY); mql.addEventListener('change', forceUpdate); return () => { mql.removeEventListener('change', forceUpdate); }; }, [forceUpdate]); const prevMessage = usePrevious(message); const prevIsHidden = usePrevious(isHidden); const prevOrigin = usePrevious(origin); const prevMediaId = usePrevious(mediaId); const prevAvatarOwner = usePrevious(avatarOwner); const prevBestImageData = usePrevious(bestImageData); const textParts = message ? renderMessageText({ message, forcePlayback: true, isForMediaViewer: true }) : undefined; const hasFooter = Boolean(textParts); const shouldAnimateOpening = prevIsHidden && prevMediaId !== mediaId; useEffect(() => { if (isGhostAnimation && isOpen && (!prevMessage || shouldAnimateOpening) && !prevAvatarOwner) { dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); animateOpening(hasFooter, origin!, bestImageData!, dimensions!, isVideo, message); } if (isGhostAnimation && !isOpen && (prevMessage || prevAvatarOwner)) { dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); animateClosing(prevOrigin!, prevBestImageData!, prevMessage || undefined); } }, [ isGhostAnimation, isOpen, shouldAnimateOpening, origin, prevOrigin, message, prevMessage, prevAvatarOwner, bestImageData, prevBestImageData, dimensions, isVideo, hasFooter, ]); const handleClose = useLastCallback(() => closeMediaViewer()); const mediaIdRef = useStateRef(mediaId); const handleFooterClick = useLastCallback(() => { handleClose(); const currentMediaId = mediaIdRef.current; if (!chatId || !currentMediaId) return; if (isMobile) { setTimeout(() => { toggleChatInfo({ force: false }, { forceSyncOnIOs: true }); focusMessage({ chatId, threadId, messageId: currentMediaId }); }, ANIMATION_DURATION); } else { focusMessage({ chatId, threadId, messageId: currentMediaId }); } }); const handleForward = useLastCallback(() => { openForwardMenu({ fromChatId: chatId!, messageIds: [mediaId!], }); }); const selectMedia = useLastCallback((id?: number) => { openMediaViewer({ chatId, threadId, mediaId: id, avatarOwnerId: avatarOwner?.id, origin: origin!, withDynamicLoading, }, { forceOnHeavyAnimation: true, }); }); useEffect(() => (isOpen ? captureEscKeyListener(() => { handleClose(); }) : undefined), [handleClose, isOpen]); useEffect(() => { if (isVideo && !isGif) { stopCurrentAudio(); } }, [isGif, isVideo]); const mediaIdsRef = useStateRef(mediaIds); const loadMoreMediaIfNeeded = useLastCallback((activeMediaId?: number) => { if (!activeMediaId || !withDynamicLoading || isLoadingMoreMedia) return; searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: activeMediaId }); }); const getMediaId = useLastCallback((fromId?: number, direction?: number): number | undefined => { if (fromId === undefined) return undefined; const mIds = mediaIdsRef.current; const index = mIds.indexOf(fromId); if ((direction === -1 && index > 0) || (direction === 1 && index < mIds.length - 1)) { return mIds[index + direction]; } // Fallback if (isVisible) loadMoreMediaIfNeeded(fromId); return undefined; }); const handleBeforeDelete = useLastCallback(() => { if (mediaIds.length <= 1) { handleClose(); return; } let index = mediaId ? mediaIds.indexOf(mediaId) : -1; // Before deleting, select previous media or the first one index = index > 0 ? index - 1 : 0; selectMedia(mediaIds[index]); }); const lang = useOldLang(); function renderSenderInfo() { return avatarOwner ? ( ) : ( ); } return (
{isMobile && ( )} {renderSenderInfo()}
); }; export default memo(withGlobal( (global): StateProps => { const { mediaViewer, shouldSkipHistoryAnimations } = selectTabState(global); const { chatId, threadId, mediaId, avatarOwnerId, origin, isHidden, withDynamicLoading, } = mediaViewer; const withAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations'); const { currentUserId, isSynced } = global; let isChatWithSelf = !!chatId && selectIsChatWithSelf(global, chatId); if (origin === MediaViewerOrigin.SearchResult) { if (!(chatId && mediaId)) { return { withAnimation, shouldSkipHistoryAnimations }; } const message = selectChatMessage(global, chatId, mediaId); if (!message) { return { withAnimation, shouldSkipHistoryAnimations }; } return { chatId, mediaId, senderId: message.senderId, isChatWithSelf, origin, message, withAnimation, isHidden, shouldSkipHistoryAnimations, }; } if (avatarOwnerId) { const user = selectUser(global, avatarOwnerId); const chat = selectChat(global, avatarOwnerId); let canUpdateMedia = false; if (user) { canUpdateMedia = avatarOwnerId === currentUserId; } else if (chat) { canUpdateMedia = isChatAdmin(chat); } isChatWithSelf = selectIsChatWithSelf(global, avatarOwnerId); return { mediaId, senderId: avatarOwnerId, avatarOwner: user || chat, avatarOwnerFallbackPhoto: user ? selectUserFullInfo(global, avatarOwnerId)?.fallbackPhoto : undefined, isChatWithSelf, canUpdateMedia, withAnimation, origin, shouldSkipHistoryAnimations, isHidden, }; } if (!(chatId && threadId && mediaId)) { return { withAnimation, shouldSkipHistoryAnimations }; } let message: ApiMessage | undefined; if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) { message = selectScheduledMessage(global, chatId, mediaId); } else { message = selectChatMessage(global, chatId, mediaId); } if (!message) { return { withAnimation, shouldSkipHistoryAnimations }; } let chatMessages: Record | undefined; if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) { chatMessages = selectChatScheduledMessages(global, chatId); } else { chatMessages = selectChatMessages(global, chatId); } let isLoadingMoreMedia = false; const isOriginInline = origin === MediaViewerOrigin.Inline; const isOriginAlbum = origin === MediaViewerOrigin.Album; let collectionIds: number[] | undefined; if (withDynamicLoading && (isOriginInline || isOriginAlbum)) { const currentSearch = selectCurrentChatMediaSearch(global); isLoadingMoreMedia = Boolean(currentSearch?.isLoading); const { foundIds } = (currentSearch?.currentSegment) || {}; collectionIds = foundIds; } else if (origin === MediaViewerOrigin.SharedMedia) { const currentSearch = selectCurrentSharedMediaSearch(global); const { foundIds } = (currentSearch && currentSearch.resultsByType && currentSearch.resultsByType.media) || {}; collectionIds = foundIds; } else if (isOriginInline || isOriginAlbum) { const outlyingList = selectOutlyingListByMessageId(global, chatId, threadId, message.id); collectionIds = outlyingList || selectListedIds(global, chatId, threadId); } return { chatId, threadId, mediaId, senderId: message.senderId, isChatWithSelf, origin, message, chatMessages, collectionIds, withAnimation, isHidden, shouldSkipHistoryAnimations, withDynamicLoading, isLoadingMoreMedia, isSynced, }; }, )(MediaViewer));