From cd1f293e4b0af6ea7761793957c9f5eccc403838 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Mon, 4 Sep 2023 04:05:40 +0200 Subject: [PATCH] Story Viewer: Ghost animation (#3765) --- src/components/common/Avatar.tsx | 11 +- src/components/common/PrivateChatInfo.tsx | 10 +- src/components/left/main/Chat.tsx | 8 +- src/components/left/main/ContactList.tsx | 10 +- .../left/search/LeftSearchResultChat.tsx | 9 +- src/components/middle/MiddleHeader.tsx | 9 +- src/components/story/Story.tsx | 57 ++--- src/components/story/StoryPreview.tsx | 8 +- src/components/story/StoryRibbonButton.tsx | 4 +- src/components/story/StorySlides.tsx | 10 +- src/components/story/StoryViewer.module.scss | 93 +++++-- src/components/story/StoryViewer.tsx | 67 ++++- .../story/helpers/ghostAnimation.ts | 237 ++++++++++++++++++ src/components/story/hooks/useSlideSizes.ts | 8 + src/components/story/hooks/useStoryProps.ts | 51 ++++ src/global/actions/ui/stories.ts | 6 +- src/global/helpers/users.ts | 4 + src/global/types.ts | 4 + src/types/index.ts | 7 + 19 files changed, 529 insertions(+), 84 deletions(-) create mode 100644 src/components/story/helpers/ghostAnimation.ts create mode 100644 src/components/story/hooks/useSlideSizes.ts create mode 100644 src/components/story/hooks/useStoryProps.ts diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 2a84801db..57d902794 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -5,6 +5,7 @@ import { getActions } from '../../global'; import type { FC, TeactNode } from '../../lib/teact/teact'; import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import type { StoryViewerOrigin } from '../../types'; import { ApiMediaFormat } from '../../api/types'; import { IS_TEST } from '../../config'; @@ -16,6 +17,7 @@ import { isUserId, isChatWithRepliesBot, isDeletedUser, + getUserStoryHtmlId, } from '../../global/helpers'; import { getFirstLetters } from '../../util/textFormat'; import buildClassName, { createClassNameBuilder } from '../../util/buildClassName'; @@ -51,6 +53,7 @@ type OwnProps = { withStory?: boolean; withStoryGap?: boolean; withStorySolid?: boolean; + storyViewerOrigin?: StoryViewerOrigin; storyViewerMode?: 'full' | 'single-user' | 'disabled'; loopIndefinitely?: boolean; noPersonalPhoto?: boolean; @@ -69,6 +72,7 @@ const Avatar: FC = ({ withStory, withStoryGap, withStorySolid, + storyViewerOrigin, storyViewerMode = 'single-user', loopIndefinitely, noPersonalPhoto, @@ -220,7 +224,11 @@ const Avatar: FC = ({ if (withStory && storyViewerMode !== 'disabled' && user?.hasStories) { e.stopPropagation(); - openStoryViewer({ userId: user.id, isSingleUser: storyViewerMode === 'single-user' }); + openStoryViewer({ + userId: user.id, + isSingleUser: storyViewerMode === 'single-user', + origin: storyViewerOrigin, + }); return; } @@ -233,6 +241,7 @@ const Avatar: FC = ({
= ({ adminMember, ripple, onEmojiStatusClick, + storyViewerOrigin, }) => { const { loadFullUser, @@ -187,6 +186,7 @@ const PrivateChatInfo: FC = ({ peer={user} isSavedMessages={isSavedMessages} withStory={withStory} + storyViewerOrigin={storyViewerOrigin} storyViewerMode="single-user" onClick={withMediaViewer ? handleAvatarViewerOpen : undefined} /> diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index f6b517f52..f4c7f8c19 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -1,8 +1,11 @@ +import type { FC } from '../../../lib/teact/teact'; import React, { memo, useEffect } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import type { FC } from '../../../lib/teact/teact'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; +import { StoryViewerOrigin } from '../../../types'; +import type { ChatAnimationTypes } from './hooks'; +import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import type { ApiChat, ApiFormattedText, @@ -13,7 +16,6 @@ import type { ApiUser, ApiUserStatus, } from '../../../api/types'; -import type { ChatAnimationTypes } from './hooks'; import { MAIN_THREAD_ID } from '../../../api/types'; import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../util/windowEnvironment'; @@ -49,7 +51,6 @@ import useSelectorSignal from '../../../hooks/useSelectorSignal'; import useChatContextActions from '../../../hooks/useChatContextActions'; import useFlag from '../../../hooks/useFlag'; import useChatListEntry from './hooks/useChatListEntry'; -import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import useAppLayout from '../../../hooks/useAppLayout'; import useShowTransition from '../../../hooks/useShowTransition'; @@ -265,6 +266,7 @@ const Chat: FC = ({ isSavedMessages={user?.isSelf} withStory={user && !user?.isSelf} withStoryGap={isAvatarOnlineShown} + storyViewerOrigin={StoryViewerOrigin.ChatList} storyViewerMode="single-user" />
diff --git a/src/components/left/main/ContactList.tsx b/src/components/left/main/ContactList.tsx index 33cdc40de..e799a994b 100644 --- a/src/components/left/main/ContactList.tsx +++ b/src/components/left/main/ContactList.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useMemo, memo } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiUser, ApiUserStatus } from '../../../api/types'; +import { StoryViewerOrigin } from '../../../types'; import { filterUsersByName, sortUserIds } from '../../../global/helpers'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; @@ -75,7 +76,14 @@ const ContactList: FC = ({ // eslint-disable-next-line react/jsx-no-bind onClick={() => handleClick(id)} > - + )) ) : viewportIds && !viewportIds.length ? ( diff --git a/src/components/left/search/LeftSearchResultChat.tsx b/src/components/left/search/LeftSearchResultChat.tsx index 68c17e879..069de3993 100644 --- a/src/components/left/search/LeftSearchResultChat.tsx +++ b/src/components/left/search/LeftSearchResultChat.tsx @@ -3,6 +3,7 @@ import React, { memo, useCallback } from '../../../lib/teact/teact'; import { withGlobal } from '../../../global'; import type { ApiChat, ApiUser } from '../../../api/types'; +import { StoryViewerOrigin } from '../../../types'; import useChatContextActions from '../../../hooks/useChatContextActions'; import useFlag from '../../../hooks/useFlag'; @@ -85,7 +86,13 @@ const LeftSearchResultChat: FC = ({ buttonRef={buttonRef} > {isUserId(chatId) ? ( - + ) : ( )} diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index 40bd53488..ae93be809 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -10,6 +10,7 @@ import type { Signal } from '../../util/signals'; import type { ApiChat, ApiMessage, ApiTypingStatus, ApiUser, } from '../../api/types'; +import { StoryViewerOrigin } from '../../types'; import { MAIN_THREAD_ID } from '../../api/types'; import { @@ -22,7 +23,12 @@ import { SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN, } from '../../config'; import { - getChatTitle, getMessageKey, getSenderTitle, isChatChannel, isChatSuperGroup, isUserId, + getChatTitle, + getMessageKey, + getSenderTitle, + isChatChannel, + isChatSuperGroup, + isUserId, } from '../../global/helpers'; import { selectAllowedMessageActions, @@ -379,6 +385,7 @@ const MiddleHeader: FC = ({ withMediaViewer withStory={!isChatWithSelf} withUpdatingStatus + storyViewerOrigin={StoryViewerOrigin.MiddleHeaderAvatar} emojiStatusSize={EMOJI_STATUS_SIZE} noRtl onEmojiStatusClick={handleStatusClick} diff --git a/src/components/story/Story.tsx b/src/components/story/Story.tsx index 640f78da1..1609baeb8 100644 --- a/src/components/story/Story.tsx +++ b/src/components/story/Story.tsx @@ -8,20 +8,19 @@ import type { ApiStory, ApiTypeStory, ApiUser } from '../../api/types'; import type { IDimensions } from '../../global/types'; import type { Signal } from '../../util/signals'; -import { ApiMediaFormat, MAIN_THREAD_ID } from '../../api/types'; +import { MAIN_THREAD_ID } from '../../api/types'; import buildClassName from '../../util/buildClassName'; import renderText from '../common/helpers/renderText'; -import { - getStoryMediaHash, getUserFirstOrLastName, hasMessageText, -} from '../../global/helpers'; +import { getUserFirstOrLastName } from '../../global/helpers'; import { formatRelativeTime } from '../../util/dateFormat'; import { getServerTime } from '../../util/serverTime'; -import { selectChat, selectIsCurrentUserPremium, selectTabState } from '../../global/selectors'; +import { + selectChat, selectTabState, selectUserStory, selectUserStories, selectIsCurrentUserPremium, +} from '../../global/selectors'; import captureKeyboardListeners from '../../util/captureKeyboardListeners'; import useAppLayout, { getIsMobile } from '../../hooks/useAppLayout'; import useLang from '../../hooks/useLang'; -import useMedia from '../../hooks/useMedia'; import useStoryPreloader from './hooks/useStoryPreloader'; import useBackgroundMode from '../../hooks/useBackgroundMode'; import useShowTransition from '../../hooks/useShowTransition'; @@ -34,6 +33,7 @@ import useLongPress from '../../hooks/useLongPress'; import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia'; import useCanvasBlur from '../../hooks/useCanvasBlur'; import useMediaTransition from '../../hooks/useMediaTransition'; +import { useStoryProps } from './hooks/useStoryProps'; import Button from '../ui/Button'; import Avatar from '../common/Avatar'; @@ -139,9 +139,22 @@ function Story({ const [isPausedByLongPress, markIsPausedByLongPress, unmarkIsPausedByLongPress] = useFlag(false); // eslint-disable-next-line no-null/no-null const videoRef = useRef(null); + const { + isDeletedStory, + hasText, + thumbnail, + previewBlobUrl, + isVideo, + noSound, + fullMediaData, + altMediaHash, + altMediaData, + hasFullData, + hasThumb, + } = useStoryProps(story); + const isLoadedStory = story && 'content' in story; - const isDeletedStory = story && 'isDeleted' in story; - const hasText = isLoadedStory ? hasMessageText(story) : false; + const canPinToProfile = useCurrentOrPrev( isSelf && isLoadedStory ? !story.isPinned : undefined, true, @@ -159,6 +172,7 @@ function Story({ && userId !== storyChangelogUserId && user?.usernames?.length, ); + const canShare = Boolean( isLoadedStory && story.isPublic @@ -167,26 +181,6 @@ function Story({ && !isCaptionExpanded, ); - let thumbnail: string | undefined; - if (isLoadedStory) { - if (story.content.photo?.thumbnail) { - thumbnail = story.content.photo.thumbnail.dataUri; - } - if (story.content.video?.thumbnail?.dataUri) { - thumbnail = story.content.video.thumbnail.dataUri; - } - } - - const previewHash = isLoadedStory ? getStoryMediaHash(story) : undefined; - const previewBlobUrl = useMedia(previewHash); - const isVideo = Boolean(isLoadedStory && story.content.video); - const noSound = isLoadedStory && story.content.video?.noSound; - const fullMediaHash = isLoadedStory ? getStoryMediaHash(story, 'full') : undefined; - const fullMediaData = useMedia(fullMediaHash, !story, isVideo ? ApiMediaFormat.Progressive : ApiMediaFormat.BlobUrl); - const altMediaHash = isVideo && isLoadedStory ? getStoryMediaHash(story, 'full', true) : undefined; - const altMediaData = useMedia(altMediaHash, !story, ApiMediaFormat.Progressive); - - const hasFullData = Boolean(fullMediaData || altMediaData); const canPlayStory = Boolean( hasFullData && !shouldForcePause && isAppFocused && !isComposerHasFocus && !isCaptionExpanded && !isPausedBySpacebar && !isPausedByLongPress, @@ -199,7 +193,6 @@ function Story({ transitionClassNames: mediaTransitionClassNames, } = useShowTransition(Boolean(fullMediaData)); - const hasThumb = !previewBlobUrl && !hasFullData; const thumbRef = useCanvasBlur(thumbnail, !hasThumb); const previewTransitionClassNames = useMediaTransition(previewBlobUrl); @@ -777,10 +770,8 @@ export default memo(withGlobal((global, { premiumModal, } = tabState; const { isOpen: isPremiumModalOpen } = premiumModal || {}; - const { - byId, orderedIds, pinnedIds, archiveIds, - } = global.stories.byUserId[userId] || {}; - const story = byId && storyId ? byId[storyId] : undefined; + const { orderedIds, pinnedIds, archiveIds } = selectUserStories(global, userId) || {}; + const story = selectUserStory(global, userId, storyId); const shouldForcePause = Boolean( storyIdSeenBy || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen || isPremiumModalOpen || isDeleteModalOpen, diff --git a/src/components/story/StoryPreview.tsx b/src/components/story/StoryPreview.tsx index 4194bc8b6..ac325bde9 100644 --- a/src/components/story/StoryPreview.tsx +++ b/src/components/story/StoryPreview.tsx @@ -2,6 +2,7 @@ import React, { memo, useEffect, useMemo } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { ApiTypeStory, ApiUser, ApiUserStories } from '../../api/types'; +import type { StoryViewerOrigin } from '../../types'; import { selectTabState } from '../../global/selectors'; import renderText from '../common/helpers/renderText'; @@ -19,10 +20,11 @@ interface OwnProps { interface StateProps { lastViewedId?: number; + origin?: StoryViewerOrigin; } function StoryPreview({ - user, userStories, lastViewedId, + user, userStories, lastViewedId, origin, }: OwnProps & StateProps) { const { openStoryViewer, loadUserSkippedStories } = getActions(); @@ -61,7 +63,7 @@ function StoryPreview({ return (
{ openStoryViewer({ userId: story.userId, storyId: story.id }); }} + onClick={() => { openStoryViewer({ userId: story.userId, storyId: story.id, origin }); }} > {thumbUrl && ( @@ -83,10 +85,12 @@ export default memo(withGlobal((global, { user }): StateProps => { const { storyViewer: { lastViewedByUserIds, + origin, }, } = selectTabState(global); return { lastViewedId: user?.id ? lastViewedByUserIds?.[user.id] : undefined, + origin, }; })(StoryPreview)); diff --git a/src/components/story/StoryRibbonButton.tsx b/src/components/story/StoryRibbonButton.tsx index 2d3dd0cfa..51fd16cfb 100644 --- a/src/components/story/StoryRibbonButton.tsx +++ b/src/components/story/StoryRibbonButton.tsx @@ -2,6 +2,7 @@ import React, { memo, useRef } from '../../lib/teact/teact'; import { getActions } from '../../global'; import type { ApiUser } from '../../api/types'; +import { StoryViewerOrigin } from '../../types'; import buildClassName from '../../util/buildClassName'; import { getUserFirstOrLastName } from '../../global/helpers'; @@ -62,7 +63,7 @@ function StoryRibbonButton({ user, isArchived }: OwnProps) { const handleClick = useLastCallback(() => { if (isContextMenuOpen) return; - openStoryViewer({ userId: user.id }); + openStoryViewer({ userId: user.id, origin: StoryViewerOrigin.StoryRibbon }); }); const handleMouseDown = useLastCallback((e: React.MouseEvent) => { @@ -103,6 +104,7 @@ function StoryRibbonButton({ user, isArchived }: OwnProps) {
diff --git a/src/components/story/StorySlides.tsx b/src/components/story/StorySlides.tsx index 97cf3a6f9..e7830a5db 100644 --- a/src/components/story/StorySlides.tsx +++ b/src/components/story/StorySlides.tsx @@ -8,15 +8,15 @@ import type { ApiUserStories } from '../../api/types'; import { IS_FIREFOX, IS_SAFARI } from '../../util/windowEnvironment'; import { ANIMATION_END_DELAY } from '../../config'; import { selectIsStoryViewerOpen, selectTabState, selectUser } from '../../global/selectors'; -import { calculateOffsetX, calculateSlideSizes } from './helpers/dimensions'; +import { calculateOffsetX } from './helpers/dimensions'; import buildClassName from '../../util/buildClassName'; import buildStyle from '../../util/buildStyle'; import useLastCallback from '../../hooks/useLastCallback'; import usePrevious from '../../hooks/usePrevious'; -import useWindowSize from '../../hooks/useWindowSize'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useSignal from '../../hooks/useSignal'; +import { useSlideSizes } from './hooks/useSlideSizes'; import Story from './Story'; import StoryPreview from './StoryPreview'; @@ -59,10 +59,8 @@ function StorySlides({ const renderingIsPrivate = useCurrentOrPrev(isPrivate, true); const renderingIsSingleUser = useCurrentOrPrev(isSingleUser, true); const renderingIsSingleStory = useCurrentOrPrev(isSingleStory, true); - const { width: windowWidth, height: windowHeight } = useWindowSize(); - const slideSizes = useMemo(() => { - return calculateSlideSizes(windowWidth, windowHeight); - }, [windowWidth, windowHeight]); + const slideSizes = useSlideSizes(); + const rendersRef = useRef>({}); const [getIsAnimating, setIsAnimating] = useSignal(false); diff --git a/src/components/story/StoryViewer.module.scss b/src/components/story/StoryViewer.module.scss index bc3c4beed..20c32d564 100644 --- a/src/components/story/StoryViewer.module.scss +++ b/src/components/story/StoryViewer.module.scss @@ -19,11 +19,8 @@ transform: scale(0); } - :global(.opacity-transition) { - transition: opacity 350ms; - @media (max-width: 600px) { - transition: none; - } + &:global(.opacity-transition) { + transition: opacity 200ms; } :global(.text-entity-link) { @@ -35,18 +32,6 @@ text-decoration: none !important; } } - - @media (max-width: 600px) { - transition: transform var(--layer-transition); - - :global(body.enable-symbol-menu-transforms) & { - transform: translate3d(0, 0, 0); - } - - :global(body.is-symbol-menu-open) & { - transform: translate3d(0, calc(-1 * (var(--symbol-menu-height))), 0); - } - } } .fullSize, .backdrop, .captionBackdrop { @@ -63,6 +48,7 @@ .backdrop { background-color: rgba(0, 0, 0, 0.9); + z-index: 0; } .backdropNonInteractive { @@ -78,10 +64,10 @@ position: absolute; right: 1rem; top: 1rem; - z-index: 2; + z-index: 3; @media (max-width: 600px) { - top: 1.125rem; + top: 1.25rem; } } @@ -94,6 +80,7 @@ overflow: hidden; transform: translateX(-50%); max-width: calc(73.5rem * var(--story-viewer-scale)); + z-index: 2; @media (max-width: 600px) { max-width: 100%; @@ -101,7 +88,7 @@ } .slideAnimation { - transition: transform 350ms ease-in-out; + transition: transform 350ms ease-in-out !important; } .slideAnimationToActive { @@ -145,8 +132,7 @@ calc(var(--slide-x, -50%) - var(--slide-translate-x, 0px)), calc(-50% - var(--slide-translate-y, 0px)), 0 - ) - scale(var(--slide-translate-scale, 1)); + ) scale(var(--slide-translate-scale, 1)); transform-origin: 0 50%; border-radius: var(--border-radius-default-small); @@ -173,6 +159,7 @@ .slidePreview { overflow: hidden; + transition: opacity 200ms ease-in-out; &::before { pointer-events: none; @@ -187,7 +174,12 @@ } &.slideAnimationToActive::before { - transition: opacity 350ms ease-in-out; + transition: opacity 350ms ease-in-out !important; + opacity: 0; + } + + .root:global(.not-open) &, + :global(body.ghost-animating) & { opacity: 0; } } @@ -224,7 +216,7 @@ @media (min-width: 600.001px) { &.slideAnimationFromActive::before { - transition: opacity 350ms ease-in-out; + transition: opacity 350ms ease-in-out !important; opacity: 1; } } @@ -253,6 +245,10 @@ height: calc(100% - 4rem) !important; border-radius: 0; } + + :global(body.ghost-animating) & { + display: none; + } } .media { @@ -272,6 +268,10 @@ border-radius: 0; object-fit: contain; } + + :global(body.ghost-animating) .activeSlide & { + display: none; + } } .content { @@ -317,7 +317,8 @@ } } -.storyHeader { +// Shared styles for the header that are also used in ghost animation +@mixin story-header { position: absolute; width: 100%; content: ""; @@ -330,6 +331,14 @@ border-radius: var(--border-radius-default-small) var(--border-radius-default-small) 0 0; } +.storyHeader { + @include story-header; + + :global(body.ghost-animating) & { + background: none; + } +} + .storyIndicators { position: absolute; width: 100%; @@ -459,7 +468,7 @@ .caption { position: absolute; - bottom: 3.5rem; + bottom: calc(3.5rem - 1px); left: 0; width: 100%; display: flex; @@ -690,3 +699,35 @@ text-align: center; margin-block: auto; } + +.ghost { + position: absolute; + z-index: 1; + overflow: hidden; + transition: transform 200ms ease; + border-radius: var(--border-radius-default-small); + + &:before { + @include story-header; + } +} + +.ghost2 { + position: absolute; + z-index: 1; + overflow: hidden; + border-radius: 50%; + opacity: 0; + transition: transform 200ms ease, opacity 200ms ease; +} + +.ghostImage { + width: 100%; + height: 100%; + user-select: none; + -webkit-user-select: none; + object-fit: cover; + @media (max-width: 600px) { + object-fit: contain; + } +} diff --git a/src/components/story/StoryViewer.tsx b/src/components/story/StoryViewer.tsx index a66c739f2..aa9244368 100644 --- a/src/components/story/StoryViewer.tsx +++ b/src/components/story/StoryViewer.tsx @@ -3,13 +3,28 @@ import React, { } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import { selectIsStoryViewerOpen, selectTabState } from '../../global/selectors'; +import type { ApiTypeStory } from '../../api/types'; +import type { StoryViewerOrigin } from '../../types'; + +import { + selectIsStoryViewerOpen, + selectTabState, + selectUserStory, + selectPerformanceSettingsValue, +} from '../../global/selectors'; import captureEscKeyListener from '../../util/captureEscKeyListener'; import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager'; +import { ANIMATION_END_DELAY } from '../../config'; + import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; import useHistoryBack from '../../hooks/useHistoryBack'; +import usePrevious from '../../hooks/usePrevious'; +import { useStoryProps } from './hooks/useStoryProps'; +import { useSlideSizes } from './hooks/useSlideSizes'; +import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; +import { animateOpening, animateClosing } from './helpers/ghostAnimation'; import { dispatchPriorityPlaybackEvent } from '../../hooks/usePriorityPlaybackCheck'; import ShowTransition from '../ui/ShowTransition'; @@ -22,11 +37,16 @@ import StorySettings from './StorySettings'; import styles from './StoryViewer.module.scss'; +const ANIMATION_DURATION = 350; + interface StateProps { isOpen: boolean; userId?: string; storyId?: number; + story?: ApiTypeStory; + origin?: StoryViewerOrigin; shouldSkipHistoryAnimations?: boolean; + withAnimation?: boolean; isPrivacyModalOpen?: boolean; } @@ -34,7 +54,10 @@ function StoryViewer({ isOpen, userId, storyId, + story, + origin, shouldSkipHistoryAnimations, + withAnimation, isPrivacyModalOpen, }: StateProps) { const { closeStoryViewer, closeStoryPrivacyEditor } = getActions(); @@ -44,6 +67,14 @@ function StoryViewer({ const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(false); const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(false); + const { bestImageData, thumbnail } = useStoryProps(story); + const slideSizes = useSlideSizes(); + const isPrevOpen = usePrevious(isOpen); + const prevBestImageData = usePrevious(bestImageData); + const prevUserId = usePrevious(userId); + const prevOrigin = usePrevious(origin); + const isGhostAnimation = Boolean(withAnimation && !shouldSkipHistoryAnimations); + useEffect(() => { if (!isOpen) { setIdStoryForDelete(undefined); @@ -90,6 +121,29 @@ function StoryViewer({ handleClose(); }) : undefined), [handleClose, isOpen]); + useEffect(() => { + if (isGhostAnimation && !isPrevOpen && isOpen && userId && thumbnail && origin !== undefined) { + dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); + animateOpening(userId, origin, thumbnail, bestImageData, slideSizes.activeSlide); + } + if (isGhostAnimation && isPrevOpen && !isOpen && prevUserId && prevBestImageData && prevOrigin !== undefined) { + dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); + animateClosing(prevUserId, prevOrigin, prevBestImageData); + } + }, [ + isGhostAnimation, + bestImageData, + prevBestImageData, + isOpen, + isPrevOpen, + slideSizes.activeSlide, + thumbnail, + userId, + prevUserId, + origin, + prevOrigin, + ]); + return ( { - const { shouldSkipHistoryAnimations, storyViewer: { storyId, userId, isPrivacyModalOpen } } = selectTabState(global); + const { + shouldSkipHistoryAnimations, storyViewer: { + storyId, userId, isPrivacyModalOpen, origin, + }, + } = selectTabState(global); + const story = userId && storyId ? selectUserStory(global, userId, storyId) : undefined; + const withAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations'); return { isOpen: selectIsStoryViewerOpen(global), shouldSkipHistoryAnimations, userId, storyId, + story, + origin, + withAnimation, isPrivacyModalOpen, }; })(StoryViewer)); diff --git a/src/components/story/helpers/ghostAnimation.ts b/src/components/story/helpers/ghostAnimation.ts new file mode 100644 index 000000000..05a77829e --- /dev/null +++ b/src/components/story/helpers/ghostAnimation.ts @@ -0,0 +1,237 @@ +import type { IDimensions } from '../../../global/types'; +import { StoryViewerOrigin } from '../../../types'; +import { getUserStoryHtmlId } from '../../../global/helpers'; +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; +import fastBlur from '../../../lib/fastBlur'; +import windowSize from '../../../util/windowSize'; +import stopEvent from '../../../util/stopEvent'; + +import { ANIMATION_END_DELAY } from '../../../config'; +import { REM } from '../../common/helpers/mediaDimensions'; +import { IS_CANVAS_FILTER_SUPPORTED } from '../../../util/windowEnvironment'; + +import styles from '../StoryViewer.module.scss'; +import storyRibbonStyles from '../StoryRibbon.module.scss'; + +const ANIMATION_DURATION = 200; +const OFFSET_DESKTOP = 3.5 * REM; +const OFFSET_MOBILE = 4 * REM; +const MOBILE_WIDTH = 600; + +export function animateOpening( + userId: string, + origin: StoryViewerOrigin, + thumb: string, + bestImageData: string | undefined, + dimensions: IDimensions, +) { + const { mediaEl: fromImage } = getNodes(origin, userId); + if (!fromImage) { + return; + } + const { width: windowWidth, height: windowHeight } = windowSize.get(); + let { width: toWidth, height: toHeight } = dimensions; + + const isMobile = windowWidth <= MOBILE_WIDTH; + + if (isMobile) { + toWidth = windowWidth; + toHeight = windowHeight - OFFSET_MOBILE; + } + + const toLeft = isMobile ? 0 : (windowWidth - toWidth) / 2; + const toTop = isMobile ? 0 : (windowHeight - (toHeight + OFFSET_DESKTOP)) / 2; + + const { + top: fromTop, left: fromLeft, width: fromWidth, height: fromHeight, + } = fromImage.getBoundingClientRect(); + + const fromTranslateX = (fromLeft + fromWidth / 2) - (toLeft + toWidth / 2); + const fromTranslateY = (fromTop + fromHeight / 2) - (toTop + toHeight / 2); + const fromScaleX = fromWidth / toWidth; + const fromScaleY = fromHeight / toHeight; + + requestMutation(() => { + const ghost = createGhost(bestImageData || thumb, !bestImageData); + applyStyles(ghost, { + top: `${toTop}px`, + left: `${toLeft}px`, + width: `${toWidth}px`, + height: `${toHeight}px`, + transform: `translate3d(${fromTranslateX}px, ${fromTranslateY}px, 0) scale(${fromScaleX}, ${fromScaleY})`, + }); + + const container = document.getElementById('StoryViewer')!; + container.appendChild(ghost); + document.body.classList.add('ghost-animating'); + + requestMutation(() => { + applyStyles(ghost, { + transform: '', + }); + + setTimeout(() => { + requestMutation(() => { + if (container.contains(ghost)) { + container.removeChild(ghost); + } + document.body.classList.remove('ghost-animating'); + }); + }, ANIMATION_DURATION + ANIMATION_END_DELAY); + }); + }); +} + +export function animateClosing( + userId: string, + origin: StoryViewerOrigin, + bestImageData: string, +) { + const { mediaEl: toImage } = getNodes(origin, userId); + + const fromImage = document.getElementById('StoryViewer')!.querySelector( + `.${styles.activeSlide} .${styles.media}`, + ); + if (!fromImage || !toImage) { + return; + } + const { + top: fromTop, left: fromLeft, width: fromWidth, height: fromHeight, + } = fromImage.getBoundingClientRect(); + const { + top: toTop, left: toLeft, width: toWidth, height: toHeight, + } = toImage.getBoundingClientRect(); + + const toTranslateX = (toLeft + toWidth / 2) - (fromLeft + fromWidth / 2); + const toTranslateY = (toTop + toHeight / 2) - (fromTop + fromHeight / 2); + const toScaleX = toWidth / fromWidth; + const toScaleY = toHeight / fromHeight; + + requestMutation(() => { + const ghost = createGhost(bestImageData); + applyStyles(ghost, { + top: `${fromTop}px`, + left: `${fromLeft}px`, + width: `${fromWidth}px`, + height: `${fromHeight}px`, + }); + + const ghost2 = createGhost(toImage.src, undefined, true); + const ghost2Top = (fromTop + fromHeight / 2) - fromWidth / 2; + applyStyles(ghost2, { + top: `${ghost2Top}px`, + left: `${fromLeft}px`, + width: `${fromWidth}px`, + height: `${fromWidth}px`, + }); + + const container = document.getElementById('StoryViewer')!; + container.appendChild(ghost); + document.body.appendChild(ghost2); + document.body.classList.add('ghost-animating'); + + requestMutation(() => { + applyStyles(ghost, { + transform: `translate3d(${toTranslateX}px, ${toTranslateY}px, 0) scale(${toScaleX}, ${toScaleY})`, + }); + + applyStyles(ghost2, { + transform: `translate3d(${toTranslateX}px, ${toTranslateY}px, 0) scale(${toScaleX})`, + opacity: '1', + }); + + setTimeout(() => { + requestMutation(() => { + if (container.contains(ghost)) { + container.removeChild(ghost); + } + + if (document.body.contains(ghost2)) { + document.body.removeChild(ghost2); + } + + document.body.classList.remove('ghost-animating'); + }); + }, ANIMATION_DURATION + ANIMATION_END_DELAY); + }); + }); +} + +const RADIUS = 2; +const ITERATIONS = 2; + +function createGhost(source: string, hasBlur = false, isGhost2 = false) { + const ghost = document.createElement('div'); + ghost.classList.add(!isGhost2 ? styles.ghost : styles.ghost2); + + const img = new Image(); + img.draggable = false; + img.oncontextmenu = stopEvent; + img.classList.add(styles.ghostImage); + + if (hasBlur) { + const canvas = document.createElement('canvas'); + canvas.classList.add(styles.thumbnail); + img.onload = () => { + const ctx = canvas.getContext('2d', { alpha: false })!; + const { + width, + height, + } = img; + + requestMutation(() => { + canvas.width = width; + canvas.height = height; + + if (IS_CANVAS_FILTER_SUPPORTED) { + ctx.filter = `blur(${RADIUS}px)`; + } + + ctx.drawImage(img, -RADIUS * 2, -RADIUS * 2, width + RADIUS * 4, height + RADIUS * 4); + + if (!IS_CANVAS_FILTER_SUPPORTED) { + fastBlur(ctx, 0, 0, width, height, RADIUS, ITERATIONS); + } + }); + }; + img.src = source; + ghost.appendChild(canvas); + } else { + img.src = source; + ghost.appendChild(img); + } + + return ghost; +} + +function getNodes(origin: StoryViewerOrigin, userId: string) { + let containerSelector; + const mediaSelector = `#${getUserStoryHtmlId(userId)}`; + + switch (origin) { + case StoryViewerOrigin.StoryRibbon: + containerSelector = `#LeftColumn .${storyRibbonStyles.root}`; + break; + case StoryViewerOrigin.MiddleHeaderAvatar: + containerSelector = '.MiddleHeader .Transition_slide-active .ChatInfo'; + break; + case StoryViewerOrigin.ChatList: + containerSelector = '#LeftColumn .chat-list'; + break; + case StoryViewerOrigin.SearchResult: + containerSelector = '#LeftColumn .LeftSearch'; + break; + } + + const container = document.querySelector(containerSelector)!; + const mediaEls = container && container.querySelectorAll(`${mediaSelector} img`); + + return { + container, + mediaEl: mediaEls?.[0], + }; +} + +function applyStyles(element: HTMLElement, css: Record) { + Object.assign(element.style, css); +} diff --git a/src/components/story/hooks/useSlideSizes.ts b/src/components/story/hooks/useSlideSizes.ts new file mode 100644 index 000000000..cd70dc439 --- /dev/null +++ b/src/components/story/hooks/useSlideSizes.ts @@ -0,0 +1,8 @@ +import useWindowSize from '../../../hooks/useWindowSize'; +import { useMemo } from '../../../lib/teact/teact'; +import { calculateSlideSizes } from '../helpers/dimensions'; + +export const useSlideSizes = () => { + const { width: windowWidth, height: windowHeight } = useWindowSize(); + return useMemo(() => calculateSlideSizes(windowWidth, windowHeight), [windowWidth, windowHeight]); +}; diff --git a/src/components/story/hooks/useStoryProps.ts b/src/components/story/hooks/useStoryProps.ts new file mode 100644 index 000000000..a18e7b8c3 --- /dev/null +++ b/src/components/story/hooks/useStoryProps.ts @@ -0,0 +1,51 @@ +import type { ApiTypeStory } from '../../../api/types'; +import { hasMessageText, getStoryMediaHash } from '../../../global/helpers'; +import useMedia from '../../../hooks/useMedia'; +import { ApiMediaFormat } from '../../../api/types'; + +export const useStoryProps = (story?: ApiTypeStory) => { + const isLoadedStory = story && 'content' in story; + const isDeletedStory = story && 'isDeleted' in story; + const hasText = isLoadedStory ? hasMessageText(story) : false; + + let thumbnail: string | undefined; + if (isLoadedStory) { + if (story.content.photo?.thumbnail) { + thumbnail = story.content.photo.thumbnail.dataUri; + } + if (story.content.video?.thumbnail?.dataUri) { + thumbnail = story.content.video.thumbnail.dataUri; + } + } + + const previewHash = isLoadedStory ? getStoryMediaHash(story) : undefined; + const previewBlobUrl = useMedia(previewHash); + const isVideo = Boolean(isLoadedStory && story.content.video); + const noSound = isLoadedStory && story.content.video?.noSound; + const fullMediaHash = isLoadedStory ? getStoryMediaHash(story, 'full') : undefined; + const fullMediaData = useMedia(fullMediaHash, !story, isVideo ? ApiMediaFormat.Progressive : ApiMediaFormat.BlobUrl); + const altMediaHash = isVideo && isLoadedStory ? getStoryMediaHash(story, 'full', true) : undefined; + const altMediaData = useMedia(altMediaHash, !story, ApiMediaFormat.Progressive); + + const hasFullData = Boolean(fullMediaData || altMediaData); + const bestImageData = isVideo ? previewBlobUrl : fullMediaData || previewBlobUrl; + const hasThumb = !previewBlobUrl && !hasFullData; + + return { + isLoadedStory, + isDeletedStory, + hasText, + thumbnail, + previewHash, + previewBlobUrl, + isVideo, + noSound, + fullMediaHash, + fullMediaData, + altMediaHash, + altMediaData, + hasFullData, + bestImageData, + hasThumb, + }; +}; diff --git a/src/global/actions/ui/stories.ts b/src/global/actions/ui/stories.ts index 2055954d6..011059a33 100644 --- a/src/global/actions/ui/stories.ts +++ b/src/global/actions/ui/stories.ts @@ -21,7 +21,7 @@ import * as langProvider from '../../../util/langProvider'; addActionHandler('openStoryViewer', async (global, actions, payload): Promise => { const { - userId, storyId, isSingleUser, isSingleStory, isPrivate, isArchive, tabId = getCurrentTabId(), + userId, storyId, isSingleUser, isSingleStory, isPrivate, isArchive, origin, tabId = getCurrentTabId(), } = payload; const user = selectUser(global, userId); @@ -52,6 +52,7 @@ addActionHandler('openStoryViewer', async (global, actions, payload): Promise => { const { - username, storyId, tabId = getCurrentTabId(), + username, storyId, origin, tabId = getCurrentTabId(), } = payload; const chat = await fetchChatByUsername(global, username); @@ -74,6 +75,7 @@ addActionHandler('openStoryViewerByUsername', async (global, actions, payload): storyId, isSingleUser: true, isSingleStory: true, + origin, tabId, }); }); diff --git a/src/global/helpers/users.ts b/src/global/helpers/users.ts index ca00f35bb..9277584c0 100644 --- a/src/global/helpers/users.ts +++ b/src/global/helpers/users.ts @@ -280,3 +280,7 @@ export function getUserColorKey(peer: ApiUser | ApiChat | undefined) { export function getMainUsername(userOrChat: ApiUser | ApiChat) { return userOrChat.usernames?.find((u) => u.isActive)?.username; } + +export function getUserStoryHtmlId(userId: string) { + return `user-story${userId}`; +} diff --git a/src/global/types.ts b/src/global/types.ts index 7384da973..6596c64f4 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -94,6 +94,7 @@ import type { ShippingOption, ThemeKey, ProfileTabType, + StoryViewerOrigin, } from '../types'; import type { P2pMessage } from '../lib/secret-sauce'; import type { ApiCredentials } from '../components/payment/PaymentModal'; @@ -361,6 +362,7 @@ export type TabState = { // Used for better switch animation between users. lastViewedByUserIds?: Record; isPrivacyModalOpen?: boolean; + origin?: StoryViewerOrigin; }; mediaViewer: { @@ -1970,10 +1972,12 @@ export interface ActionPayloads { isSingleStory?: boolean; isPrivate?: boolean; isArchive?: boolean; + origin?: StoryViewerOrigin; } & WithTabId; openStoryViewerByUsername: { username: string; storyId: number; + origin?: StoryViewerOrigin; } & WithTabId; openPreviousStory: WithTabId | undefined; openNextStory: WithTabId | undefined; diff --git a/src/types/index.ts b/src/types/index.ts index 8ae9444ef..ec87f298c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -292,6 +292,13 @@ export enum MediaViewerOrigin { SuggestedAvatar, } +export enum StoryViewerOrigin { + StoryRibbon, + MiddleHeaderAvatar, + ChatList, + SearchResult, +} + export enum AudioOrigin { Inline, SharedMedia,