import type { FC } from '../../lib/teact/teact'; import React, { memo, useEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { ApiChat, ApiMediaAreaChannelPost, ApiPeer, ApiStealthMode, ApiStory, ApiTypeStory, } from '../../api/types'; import type { IDimensions } from '../../global/types'; import type { Signal } from '../../util/signals'; import { MAIN_THREAD_ID } from '../../api/types'; import { EDITABLE_STORY_INPUT_CSS_SELECTOR, EDITABLE_STORY_INPUT_ID } from '../../config'; import { getSenderTitle, isChatChannel, isUserId } from '../../global/helpers'; import { selectChat, selectIsCurrentUserPremium, selectPeer, selectPeerStory, selectPerformanceSettingsValue, selectTabState, selectUser, } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import captureKeyboardListeners from '../../util/captureKeyboardListeners'; import { formatMediaDuration, formatRelativeTime } from '../../util/dates/dateFormat'; import download from '../../util/download'; import { round } from '../../util/math'; import { getServerTime } from '../../util/serverTime'; import { IS_SAFARI } from '../../util/windowEnvironment'; import renderText from '../common/helpers/renderText'; import { BASE_STORY_HEIGHT, BASE_STORY_WIDTH } from './helpers/dimensions'; import { PRIMARY_VIDEO_MIME, SECONDARY_VIDEO_MIME } from './helpers/videoFormats'; import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia'; import useAppLayout, { getIsMobile } from '../../hooks/useAppLayout'; import useCanvasBlur from '../../hooks/useCanvasBlur'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; import useFlag from '../../hooks/useFlag'; import useLastCallback from '../../hooks/useLastCallback'; import useLongPress from '../../hooks/useLongPress'; import useMediaTransition from '../../hooks/useMediaTransition'; import useOldLang from '../../hooks/useOldLang'; import useShowTransition from '../../hooks/useShowTransition'; import { useStreaming } from '../../hooks/useStreaming'; import useBackgroundMode from '../../hooks/window/useBackgroundMode'; import useStoryPreloader from './hooks/useStoryPreloader'; import useStoryProps from './hooks/useStoryProps'; import Avatar from '../common/Avatar'; import Composer from '../common/Composer'; import Icon from '../common/icons/Icon'; import Button from '../ui/Button'; import DropdownMenu from '../ui/DropdownMenu'; import MenuItem from '../ui/MenuItem'; import OptimizedVideo from '../ui/OptimizedVideo'; import Skeleton from '../ui/placeholder/Skeleton'; import Transition from '../ui/Transition'; import MediaAreaOverlay from './mediaArea/MediaAreaOverlay'; import StoryCaption from './StoryCaption'; import StoryFooter from './StoryFooter'; import StoryProgress from './StoryProgress'; import styles from './StoryViewer.module.scss'; interface OwnProps { peerId: string; storyId: number; dimensions: IDimensions; // eslint-disable-next-line react/no-unused-prop-types isReportModalOpen?: boolean; // eslint-disable-next-line react/no-unused-prop-types isDeleteModalOpen?: boolean; isPrivateStories?: boolean; isArchivedStories?: boolean; isSingleStory?: boolean; getIsAnimating: Signal; onDelete: (story: ApiTypeStory) => void; onClose: NoneToVoidFunction; onReport: NoneToVoidFunction; } interface StateProps { peer: ApiPeer; forwardSender?: ApiPeer; fromPeer?: ApiPeer; story?: ApiTypeStory; isMuted: boolean; orderedIds?: number[]; shouldForcePause?: boolean; storyChangelogUserId?: string; viewersExpirePeriod: number; isChatExist?: boolean; areChatSettingsLoaded?: boolean; isCurrentUserPremium?: boolean; stealthMode: ApiStealthMode; withHeaderAnimation?: boolean; } const VIDEO_MIN_READY_STATE = IS_SAFARI ? 4 : 3; const SPACEBAR_CODE = 32; const STEALTH_MODE_NOTIFICATION_DURATION = 4000; function Story({ peerId, storyId, peer, forwardSender, fromPeer, isMuted, isArchivedStories, isPrivateStories, story, orderedIds, isSingleStory, dimensions, shouldForcePause, storyChangelogUserId, viewersExpirePeriod, isChatExist, areChatSettingsLoaded, getIsAnimating, isCurrentUserPremium, stealthMode, withHeaderAnimation, onDelete, onClose, onReport, }: OwnProps & StateProps) { const { viewStory, setStoryViewerMuted, openPreviousStory, openNextStory, loadPeerSkippedStories, openForwardMenu, copyStoryLink, toggleStoryInProfile, openChat, showNotification, openStoryPrivacyEditor, loadChatSettings, fetchChat, loadStoryViews, toggleStealthModal, } = getActions(); const serverTime = getServerTime(); const lang = useOldLang(); const { isMobile } = useAppLayout(); const [isComposerHasFocus, markComposerHasFocus, unmarkComposerHasFocus] = useFlag(false); const [isStoryPlaybackRequested, playStory, pauseStory] = useFlag(false); const [isStoryPlaying, markStoryPlaying, unmarkStoryPlaying] = useFlag(false); const [isAppFocused, markAppFocused, unmarkAppFocused] = useFlag(true); const [isCaptionExpanded, expandCaption, foldCaption] = useFlag(false); const [isPausedBySpacebar, setIsPausedBySpacebar] = useState(false); const [isPausedByLongPress, markIsPausedByLongPress, unmarkIsPausedByLongPress] = useFlag(false); const [isDropdownMenuOpen, markDropdownMenuOpen, unmarkDropdownMenuOpen] = useFlag(false); // eslint-disable-next-line no-null/no-null const videoRef = useRef(null); const { isDeletedStory, hasText, hasForwardInfo, thumbnail, previewBlobUrl, isVideo, noSound, fullMediaData, altMediaHash, altMediaData, hasFullData, hasThumb, canDownload, downloadMediaData, } = useStoryProps(story, isCurrentUserPremium, isDropdownMenuOpen); const isLoadedStory = story && 'content' in story; const isChangelog = peerId === storyChangelogUserId; const isUserStory = isUserId(peerId); const isChatStory = !isUserStory; const isChannelStory = isChatStory && isChatChannel(peer as ApiChat); const isOut = isLoadedStory && story.isOut; const canPinToProfile = useCurrentOrPrev( isOut ? !story.isInProfile : undefined, true, ); const canUnpinFromProfile = useCurrentOrPrev( isOut ? story.isInProfile : undefined, true, ); const areViewsExpired = Boolean( isOut && (story!.date + viewersExpirePeriod) < getServerTime(), ); const forwardSenderTitle = forwardSender ? getSenderTitle(lang, forwardSender) : (isLoadedStory && story.forwardInfo?.fromName); const canCopyLink = Boolean( isLoadedStory && story.isPublic && !isChangelog && peer?.usernames?.length, ); const canShare = Boolean( isLoadedStory && story.isPublic && !story.noForwards && !isChangelog && !isCaptionExpanded, ); const canPlayStory = Boolean( hasFullData && !shouldForcePause && isAppFocused && !isComposerHasFocus && !isCaptionExpanded && !isPausedBySpacebar && !isPausedByLongPress, ); const duration = isLoadedStory && story.content.video?.duration ? story.content.video.duration : undefined; const shouldShowComposer = !(isOut && isUserStory) && !isChangelog && !isChannelStory; const shouldShowFooter = isLoadedStory && !shouldShowComposer && (isOut || isChannelStory); const headerAnimation = isMobile && withHeaderAnimation ? 'slideFade' : 'none'; const { shouldRender: shouldRenderSkeleton, transitionClassNames: skeletonTransitionClassNames, } = useShowTransition(!hasFullData); const { transitionClassNames: mediaTransitionClassNames, } = useShowTransition(Boolean(fullMediaData)); const thumbRef = useCanvasBlur(thumbnail, !hasThumb); const previewTransitionClassNames = useMediaTransition(previewBlobUrl); const { shouldRender: shouldRenderComposer, transitionClassNames: composerAppearanceAnimationClassNames, } = useShowTransition(shouldShowComposer); const { shouldRender: shouldRenderCaptionBackdrop, transitionClassNames: captionBackdropTransitionClassNames, } = useShowTransition(hasText && isCaptionExpanded); const { transitionClassNames: appearanceAnimationClassNames } = useShowTransition(true); const { shouldRender: shouldRenderCaption, transitionClassNames: captionAppearanceAnimationClassNames, } = useShowTransition(hasText || hasForwardInfo); const isStreamingSupported = useStreaming(videoRef, fullMediaData, PRIMARY_VIDEO_MIME); useStoryPreloader(peerId, storyId); useEffect(() => { if (storyId) { viewStory({ peerId, storyId }); } }, [storyId, peerId]); useEffect(() => { loadPeerSkippedStories({ peerId }); }, [peerId]); // Fetching user privacy settings for use in Composer useEffect(() => { const canWrite = isUserId(peerId); if (!isChatExist && canWrite) { fetchChat({ chatId: peerId }); } }, [isChatExist, peerId]); useEffect(() => { if (isChatExist && !areChatSettingsLoaded) { loadChatSettings({ chatId: peerId }); } }, [areChatSettingsLoaded, isChatExist, peerId]); const handlePauseStory = useLastCallback(() => { if (isVideo) { videoRef.current?.pause(); } unmarkStoryPlaying(); pauseStory(); }); const handlePlayStory = useLastCallback(() => { if (!canPlayStory) return; playStory(); if (!isVideo) markStoryPlaying(); }); const handleLongPressStart = useLastCallback(() => { markIsPausedByLongPress(); }); const handleLongPressEnd = useLastCallback(() => { unmarkIsPausedByLongPress(); }); const handleDropdownMenuOpen = useLastCallback(() => { markDropdownMenuOpen(); handlePauseStory(); }); const handleDropdownMenuClose = useLastCallback(() => { unmarkDropdownMenuOpen(); handlePlayStory(); }); const { onMouseDown: handleLongPressMouseDown, onMouseUp: handleLongPressMouseUp, onMouseLeave: handleLongPressMouseLeave, onTouchStart: handleLongPressTouchStart, onTouchEnd: handleLongPressTouchEnd, } = useLongPress({ onStart: handleLongPressStart, onEnd: handleLongPressEnd, }); const isUnsupported = useUnsupportedMedia( videoRef, undefined, !isVideo || !fullMediaData || isStreamingSupported, ); const hasAllData = fullMediaData && (!altMediaHash || altMediaData); // Play story after media has been downloaded useEffect(() => { if (hasAllData && !isUnsupported) handlePlayStory(); }, [hasAllData, isUnsupported]); useBackgroundMode(unmarkAppFocused, markAppFocused); useEffect(() => { if (!hasAllData) return; videoRef.current?.load(); }, [hasAllData]); useEffect(() => { if (!isLoadedStory || isDeletedStory || areViewsExpired) return; if (!isOut && !isChannelStory) return; // Refresh counters each time loadStoryViews({ peerId, storyId }); }, [isDeletedStory, areViewsExpired, isLoadedStory, peerId, storyId, isOut, isChannelStory]); useEffect(() => { if ( shouldForcePause || !isAppFocused || isComposerHasFocus || isCaptionExpanded || isPausedBySpacebar || isPausedByLongPress ) { handlePauseStory(); } else { handlePlayStory(); } }, [ handlePlayStory, isAppFocused, isCaptionExpanded, isComposerHasFocus, shouldForcePause, isPausedBySpacebar, isPausedByLongPress, ]); useEffect(() => { if (isComposerHasFocus || shouldForcePause || isCaptionExpanded) { return undefined; } function handleKeyDown(e: KeyboardEvent) { if (e.keyCode === SPACEBAR_CODE) { e.preventDefault(); setIsPausedBySpacebar(!isPausedBySpacebar); } } document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isCaptionExpanded, isComposerHasFocus, isPausedBySpacebar, shouldForcePause]); // Reset the state of `isPausedBySpacebar` when closing the caption, losing focus by composer or disable forced pause useEffectWithPrevDeps(([ prevIsComposerHasFocus, prevIsCaptionExpanded, prevShouldForcePause, prevIsAppFocused, prevIsPausedByLongPress, ]) => { if ( !isPausedBySpacebar || isCaptionExpanded || isComposerHasFocus || shouldForcePause || !isAppFocused || isPausedByLongPress ) { return; } if ( prevIsCaptionExpanded !== isCaptionExpanded || prevIsComposerHasFocus !== isComposerHasFocus || prevShouldForcePause !== shouldForcePause || prevIsAppFocused !== isAppFocused || prevIsPausedByLongPress !== isPausedByLongPress ) { setIsPausedBySpacebar(false); } }, [isComposerHasFocus, isCaptionExpanded, shouldForcePause, isAppFocused, isPausedByLongPress, isPausedBySpacebar]); const handleOpenChat = useLastCallback(() => { onClose(); openChat({ id: peerId }); }); const handleForwardPeerClick = useLastCallback(() => { onClose(); openChat({ id: forwardSender!.id }); }); const handleFromPeerClick = useLastCallback(() => { onClose(); openChat({ id: fromPeer!.id }); }); const handleOpenPrevStory = useLastCallback(() => { openPreviousStory(); }); const handleOpenNextStory = useLastCallback(() => { openNextStory(); }); const handleVideoStoryTimeUpdate = useLastCallback((e: React.SyntheticEvent) => { const video = e.currentTarget; if (video.readyState >= VIDEO_MIN_READY_STATE) { markStoryPlaying(); } else { unmarkStoryPlaying(); } if (duration && round(video.currentTime, 2) >= round(duration, 2)) { handleOpenNextStory(); } }); useEffect(() => { return !getIsAnimating() && !isComposerHasFocus ? captureKeyboardListeners({ onRight: handleOpenNextStory, onLeft: handleOpenPrevStory, }) : undefined; }, [getIsAnimating, isComposerHasFocus]); const handleCopyStoryLink = useLastCallback(() => { copyStoryLink({ peerId, storyId }); }); const handlePinClick = useLastCallback(() => { toggleStoryInProfile({ peerId, storyId, isInProfile: true }); }); const handleUnpinClick = useLastCallback(() => { toggleStoryInProfile({ peerId, storyId, isInProfile: false }); }); const handleDeleteStoryClick = useLastCallback(() => { onDelete(story!); }); const handleReportStoryClick = useLastCallback(() => { onReport(); }); const handleForwardClick = useLastCallback(() => { openForwardMenu({ fromChatId: peerId, storyId }); }); const handleInfoPrivacyEdit = useLastCallback(() => { openStoryPrivacyEditor(); }); const handleInfoPrivacyClick = useLastCallback(() => { const visibility = !isLoadedStory || story.isPublic ? undefined : story.isForContacts ? 'contacts' : (story.isForCloseFriends ? 'closeFriends' : 'nobody'); let message; const myName = getSenderTitle(lang, peer); switch (visibility) { case 'nobody': message = lang('StorySelectedContactsHint', myName); break; case 'contacts': message = lang('StoryContactsHint', myName); break; case 'closeFriends': message = lang('StoryCloseFriendsHint', myName); break; default: return; } showNotification({ message }); }); const handleVolumeMuted = useLastCallback(() => { if (noSound) { showNotification({ message: lang('Story.TooltipVideoHasNoSound'), }); return; } // Browser requires explicit user interaction to keep video playing after unmuting videoRef.current!.muted = !videoRef.current!.muted; setStoryViewerMuted({ isMuted: !isMuted }); }); const handleOpenStealthModal = useLastCallback(() => { if (stealthMode.activeUntil && getServerTime() < stealthMode.activeUntil) { const diff = stealthMode.activeUntil - getServerTime(); showNotification({ title: lang('StealthModeOn'), message: lang('Story.ToastStealthModeActiveText', formatMediaDuration(diff)), duration: STEALTH_MODE_NOTIFICATION_DURATION, }); return; } toggleStealthModal({ isOpen: true }); }); const handleDownload = useLastCallback(() => { if (!downloadMediaData) return; download(downloadMediaData, `story-${peerId}-${storyId}.${isVideo ? 'mp4' : 'jpg'}`); }); useEffect(() => { if (!isDeletedStory) return; showNotification({ message: lang('StoryNotFound'), }); }, [lang, isDeletedStory]); const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { return ({ onTrigger, isOpen }) => { return ( ); }; }, [isMobile, lang]); function renderStoriesTabs() { return (
{(isSingleStory ? [storyId] : orderedIds ?? []).map((id) => ( story?.id : id < story?.id))} isPaused={!isStoryPlaying} duration={duration} onImageComplete={handleOpenNextStory} /> ))}
); } function renderStoryPrivacyButton() { if (!isUserStory) return undefined; let privacyIcon = 'channel-filled'; const gradient: Record = { 'channel-filled': ['#50ABFF', '#007AFF'], 'user-filled': ['#C36EFF', '#8B60FA'], 'favorite-filled': ['#88D93A', '#30B73B'], 'group-filled': ['#FFB743', '#F69A36'], }; if (isOut) { const { visibility } = (story && 'visibility' in story && story.visibility) || {}; switch (visibility) { case 'everybody': privacyIcon = 'channel-filled'; break; case 'contacts': privacyIcon = 'user-filled'; break; case 'closeFriends': privacyIcon = 'favorite-filled'; break; case 'nobody': privacyIcon = 'group-filled'; } } else { if (!story || !('content' in story) || story.isPublic) { return undefined; } privacyIcon = story.isForCloseFriends ? 'favorite-filled' : (story.isForContacts ? 'user-filled' : 'group-filled'); } return (
{isOut && }
); } function renderSenderInfo() { return (
{renderText(getSenderTitle(lang, peer) || '')}
{forwardSenderTitle && ( {renderText(forwardSenderTitle)} )} {fromPeer && ( {renderText(getSenderTitle(lang, fromPeer) || '')} )} {story && 'date' in story && ( {formatRelativeTime(lang, serverTime, story.date)} )} {isLoadedStory && story.isEdited && ( {lang('Story.HeaderEdited')} )}
); } function renderSender() { return (
{renderSenderInfo()}
{renderStoryPrivacyButton()} {isVideo && ( )} {canCopyLink && {lang('CopyLink')}} {canPinToProfile && ( {lang(isUserStory ? 'StorySave' : 'SaveToPosts')} )} {canUnpinFromProfile && ( {lang(isUserStory ? 'ArchiveStory' : 'RemoveFromPosts')} )} {canDownload && ( {lang('lng_media_download')} )} {!isOut && isUserStory && ( {lang('StealthMode')} )} {!isOut && {lang('lng_report_story')}} {isOut && {lang('Delete')}}
); } return (
{renderStoriesTabs()} {renderSender()}
{previewBlobUrl && ( )} {shouldRenderSkeleton && ( )} {!isVideo && fullMediaData && ( )} {isVideo && fullMediaData && ( {altMediaData && } )} {!isPausedByLongPress && !isComposerHasFocus && ( <>
{shouldShowFooter && ( )} {shouldRenderCaptionBackdrop && (
foldCaption()} aria-label={lang('Close')} /> )} {hasText &&
} {shouldRenderCaption && ( )} {shouldRenderComposer && ( )}
); } export default memo(withGlobal((global, { peerId, storyId, isReportModalOpen, isDeleteModalOpen, }): StateProps => { const { appConfig } = global; const user = selectUser(global, peerId); const chat = selectChat(global, peerId); const tabState = selectTabState(global); const { storyViewer: { isMuted, viewModal, isPrivacyModalOpen, isStealthModalOpen, storyList, }, forwardMessages: { storyId: forwardedStoryId }, premiumModal, safeLinkModalUrl, mapModal, } = tabState; const { isOpen: isPremiumModalOpen } = premiumModal || {}; const story = selectPeerStory(global, peerId, storyId); const isLoadedStory = story && 'content' in story; const shouldForcePause = Boolean( viewModal || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen || isPremiumModalOpen || isDeleteModalOpen || safeLinkModalUrl || isStealthModalOpen || mapModal, ); const forwardInfo = isLoadedStory ? story.forwardInfo : undefined; const mediaAreas = isLoadedStory ? story.mediaAreas : undefined; const forwardSenderId = forwardInfo?.fromPeerId || mediaAreas?.find((area): area is ApiMediaAreaChannelPost => area.type === 'channelPost')?.channelId; const forwardSender = forwardSenderId ? selectPeer(global, forwardSenderId) : undefined; const withHeaderAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations'); const fromPeer = isLoadedStory && story.fromId ? selectPeer(global, story.fromId) : undefined; return { peer: (user || chat)!, forwardSender, fromPeer, story, orderedIds: storyList?.storyIdsByPeerId[peerId], isMuted, isCurrentUserPremium: selectIsCurrentUserPremium(global), shouldForcePause, storyChangelogUserId: appConfig!.storyChangelogUserId, viewersExpirePeriod: appConfig!.storyExpirePeriod + appConfig!.storyViewersExpirePeriod, isChatExist: Boolean(chat), areChatSettingsLoaded: Boolean(chat?.settings), stealthMode: global.stories.stealthMode, withHeaderAnimation, }; })(Story));