Story Viewer: Ghost animation (#3765)

This commit is contained in:
Alexander Zinchuk 2023-09-04 04:05:40 +02:00
parent 3a56c041cd
commit cd1f293e4b
19 changed files with 529 additions and 84 deletions

View File

@ -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<OwnProps> = ({
withStory,
withStoryGap,
withStorySolid,
storyViewerOrigin,
storyViewerMode = 'single-user',
loopIndefinitely,
noPersonalPhoto,
@ -220,7 +224,11 @@ const Avatar: FC<OwnProps> = ({
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<OwnProps> = ({
<div
ref={ref}
className={fullClassName}
id={peer?.id && withStory ? getUserStoryHtmlId(peer.id) : undefined}
data-test-sender-id={IS_TEST ? peer?.id : undefined}
aria-label={typeof content === 'string' ? author : undefined}
onClick={handleClick}

View File

@ -5,13 +5,10 @@ import type { FC } from '../../lib/teact/teact';
import type {
ApiUser, ApiTypingStatus, ApiUserStatus, ApiChatMember,
} from '../../api/types';
import type { StoryViewerOrigin } from '../../types';
import { MediaViewerOrigin } from '../../types';
import {
selectChatMessages,
selectUser,
selectUserStatus,
} from '../../global/selectors';
import { selectChatMessages, selectUser, selectUserStatus } from '../../global/selectors';
import { getMainUsername, getUserStatus, isUserOnline } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import renderText from './helpers/renderText';
@ -39,6 +36,7 @@ type OwnProps = {
withStory?: boolean;
withFullInfo?: boolean;
withUpdatingStatus?: boolean;
storyViewerOrigin?: StoryViewerOrigin;
noEmojiStatus?: boolean;
emojiStatusSize?: number;
noStatusOrTyping?: boolean;
@ -77,6 +75,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
adminMember,
ripple,
onEmojiStatusClick,
storyViewerOrigin,
}) => {
const {
loadFullUser,
@ -187,6 +186,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
peer={user}
isSavedMessages={isSavedMessages}
withStory={withStory}
storyViewerOrigin={storyViewerOrigin}
storyViewerMode="single-user"
onClick={withMediaViewer ? handleAvatarViewerOpen : undefined}
/>

View File

@ -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<OwnProps & StateProps> = ({
isSavedMessages={user?.isSelf}
withStory={user && !user?.isSelf}
withStoryGap={isAvatarOnlineShown}
storyViewerOrigin={StoryViewerOrigin.ChatList}
storyViewerMode="single-user"
/>
<div className="avatar-badge-wrapper">

View File

@ -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<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleClick(id)}
>
<PrivateChatInfo userId={id} forceShowSelf avatarSize="large" withStory ripple={!isMobile} />
<PrivateChatInfo
userId={id}
forceShowSelf
avatarSize="large"
withStory
storyViewerOrigin={StoryViewerOrigin.ChatList}
ripple={!isMobile}
/>
</ListItem>
))
) : viewportIds && !viewportIds.length ? (

View File

@ -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<OwnProps & StateProps> = ({
buttonRef={buttonRef}
>
{isUserId(chatId) ? (
<PrivateChatInfo userId={chatId} withUsername={withUsername} withStory avatarSize="large" />
<PrivateChatInfo
userId={chatId}
withUsername={withUsername}
withStory
avatarSize="large"
storyViewerOrigin={StoryViewerOrigin.SearchResult}
/>
) : (
<GroupChatInfo chatId={chatId} withUsername={withUsername} avatarSize="large" />
)}

View File

@ -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<OwnProps & StateProps> = ({
withMediaViewer
withStory={!isChatWithSelf}
withUpdatingStatus
storyViewerOrigin={StoryViewerOrigin.MiddleHeaderAvatar}
emojiStatusSize={EMOJI_STATUS_SIZE}
noRtl
onEmojiStatusClick={handleStatusClick}

View File

@ -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<HTMLVideoElement>(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<OwnProps>((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,

View File

@ -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 (
<div
className={styles.slideInner}
onClick={() => { openStoryViewer({ userId: story.userId, storyId: story.id }); }}
onClick={() => { openStoryViewer({ userId: story.userId, storyId: story.id, origin }); }}
>
{thumbUrl && (
<img src={thumbUrl} alt="" className={styles.media} draggable={false} />
@ -83,10 +85,12 @@ export default memo(withGlobal<OwnProps>((global, { user }): StateProps => {
const {
storyViewer: {
lastViewedByUserIds,
origin,
},
} = selectTabState(global);
return {
lastViewedId: user?.id ? lastViewedByUserIds?.[user.id] : undefined,
origin,
};
})(StoryPreview));

View File

@ -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<HTMLElement>) => {
@ -103,6 +104,7 @@ function StoryRibbonButton({ user, isArchived }: OwnProps) {
<Avatar
peer={user}
withStory
storyViewerOrigin={StoryViewerOrigin.StoryRibbon}
storyViewerMode="full"
/>
<div className={buildClassName(styles.name, user.hasUnreadStories && styles.name_hasUnreadStory)}>

View File

@ -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<Record<string, { current: HTMLDivElement | null }>>({});
const [getIsAnimating, setIsAnimating] = useSignal(false);

View File

@ -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;
}
}

View File

@ -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 (
<ShowTransition
id="StoryViewer"
@ -137,13 +191,22 @@ function StoryViewer({
}
export default memo(withGlobal((global): StateProps => {
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));

View File

@ -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<HTMLImageElement>(
`.${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<HTMLElement>(containerSelector)!;
const mediaEls = container && container.querySelectorAll<HTMLImageElement>(`${mediaSelector} img`);
return {
container,
mediaEl: mediaEls?.[0],
};
}
function applyStyles(element: HTMLElement, css: Record<string, string>) {
Object.assign(element.style, css);
}

View File

@ -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]);
};

View File

@ -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,
};
};

View File

@ -21,7 +21,7 @@ import * as langProvider from '../../../util/langProvider';
addActionHandler('openStoryViewer', async (global, actions, payload): Promise<void> => {
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<vo
isPrivate,
isArchive,
isSingleStory,
origin,
storyIdSeenBy: undefined,
},
}, tabId);
@ -60,7 +61,7 @@ addActionHandler('openStoryViewer', async (global, actions, payload): Promise<vo
addActionHandler('openStoryViewerByUsername', async (global, actions, payload): Promise<void> => {
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,
});
});

View File

@ -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}`;
}

View File

@ -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<string, number>;
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;

View File

@ -292,6 +292,13 @@ export enum MediaViewerOrigin {
SuggestedAvatar,
}
export enum StoryViewerOrigin {
StoryRibbon,
MiddleHeaderAvatar,
ChatList,
SearchResult,
}
export enum AudioOrigin {
Inline,
SharedMedia,