Story Viewer: Ghost animation (#3765)
This commit is contained in:
parent
3a56c041cd
commit
cd1f293e4b
@ -5,6 +5,7 @@ import { getActions } from '../../global';
|
|||||||
import type { FC, TeactNode } from '../../lib/teact/teact';
|
import type { FC, TeactNode } from '../../lib/teact/teact';
|
||||||
import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types';
|
import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types';
|
||||||
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
|
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
|
||||||
|
import type { StoryViewerOrigin } from '../../types';
|
||||||
import { ApiMediaFormat } from '../../api/types';
|
import { ApiMediaFormat } from '../../api/types';
|
||||||
|
|
||||||
import { IS_TEST } from '../../config';
|
import { IS_TEST } from '../../config';
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
isUserId,
|
isUserId,
|
||||||
isChatWithRepliesBot,
|
isChatWithRepliesBot,
|
||||||
isDeletedUser,
|
isDeletedUser,
|
||||||
|
getUserStoryHtmlId,
|
||||||
} from '../../global/helpers';
|
} from '../../global/helpers';
|
||||||
import { getFirstLetters } from '../../util/textFormat';
|
import { getFirstLetters } from '../../util/textFormat';
|
||||||
import buildClassName, { createClassNameBuilder } from '../../util/buildClassName';
|
import buildClassName, { createClassNameBuilder } from '../../util/buildClassName';
|
||||||
@ -51,6 +53,7 @@ type OwnProps = {
|
|||||||
withStory?: boolean;
|
withStory?: boolean;
|
||||||
withStoryGap?: boolean;
|
withStoryGap?: boolean;
|
||||||
withStorySolid?: boolean;
|
withStorySolid?: boolean;
|
||||||
|
storyViewerOrigin?: StoryViewerOrigin;
|
||||||
storyViewerMode?: 'full' | 'single-user' | 'disabled';
|
storyViewerMode?: 'full' | 'single-user' | 'disabled';
|
||||||
loopIndefinitely?: boolean;
|
loopIndefinitely?: boolean;
|
||||||
noPersonalPhoto?: boolean;
|
noPersonalPhoto?: boolean;
|
||||||
@ -69,6 +72,7 @@ const Avatar: FC<OwnProps> = ({
|
|||||||
withStory,
|
withStory,
|
||||||
withStoryGap,
|
withStoryGap,
|
||||||
withStorySolid,
|
withStorySolid,
|
||||||
|
storyViewerOrigin,
|
||||||
storyViewerMode = 'single-user',
|
storyViewerMode = 'single-user',
|
||||||
loopIndefinitely,
|
loopIndefinitely,
|
||||||
noPersonalPhoto,
|
noPersonalPhoto,
|
||||||
@ -220,7 +224,11 @@ const Avatar: FC<OwnProps> = ({
|
|||||||
if (withStory && storyViewerMode !== 'disabled' && user?.hasStories) {
|
if (withStory && storyViewerMode !== 'disabled' && user?.hasStories) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
openStoryViewer({ userId: user.id, isSingleUser: storyViewerMode === 'single-user' });
|
openStoryViewer({
|
||||||
|
userId: user.id,
|
||||||
|
isSingleUser: storyViewerMode === 'single-user',
|
||||||
|
origin: storyViewerOrigin,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,6 +241,7 @@ const Avatar: FC<OwnProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={fullClassName}
|
className={fullClassName}
|
||||||
|
id={peer?.id && withStory ? getUserStoryHtmlId(peer.id) : undefined}
|
||||||
data-test-sender-id={IS_TEST ? peer?.id : undefined}
|
data-test-sender-id={IS_TEST ? peer?.id : undefined}
|
||||||
aria-label={typeof content === 'string' ? author : undefined}
|
aria-label={typeof content === 'string' ? author : undefined}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@ -5,13 +5,10 @@ import type { FC } from '../../lib/teact/teact';
|
|||||||
import type {
|
import type {
|
||||||
ApiUser, ApiTypingStatus, ApiUserStatus, ApiChatMember,
|
ApiUser, ApiTypingStatus, ApiUserStatus, ApiChatMember,
|
||||||
} from '../../api/types';
|
} from '../../api/types';
|
||||||
|
import type { StoryViewerOrigin } from '../../types';
|
||||||
import { MediaViewerOrigin } from '../../types';
|
import { MediaViewerOrigin } from '../../types';
|
||||||
|
|
||||||
import {
|
import { selectChatMessages, selectUser, selectUserStatus } from '../../global/selectors';
|
||||||
selectChatMessages,
|
|
||||||
selectUser,
|
|
||||||
selectUserStatus,
|
|
||||||
} from '../../global/selectors';
|
|
||||||
import { getMainUsername, getUserStatus, isUserOnline } from '../../global/helpers';
|
import { getMainUsername, getUserStatus, isUserOnline } from '../../global/helpers';
|
||||||
import buildClassName from '../../util/buildClassName';
|
import buildClassName from '../../util/buildClassName';
|
||||||
import renderText from './helpers/renderText';
|
import renderText from './helpers/renderText';
|
||||||
@ -39,6 +36,7 @@ type OwnProps = {
|
|||||||
withStory?: boolean;
|
withStory?: boolean;
|
||||||
withFullInfo?: boolean;
|
withFullInfo?: boolean;
|
||||||
withUpdatingStatus?: boolean;
|
withUpdatingStatus?: boolean;
|
||||||
|
storyViewerOrigin?: StoryViewerOrigin;
|
||||||
noEmojiStatus?: boolean;
|
noEmojiStatus?: boolean;
|
||||||
emojiStatusSize?: number;
|
emojiStatusSize?: number;
|
||||||
noStatusOrTyping?: boolean;
|
noStatusOrTyping?: boolean;
|
||||||
@ -77,6 +75,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
|
|||||||
adminMember,
|
adminMember,
|
||||||
ripple,
|
ripple,
|
||||||
onEmojiStatusClick,
|
onEmojiStatusClick,
|
||||||
|
storyViewerOrigin,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
loadFullUser,
|
loadFullUser,
|
||||||
@ -187,6 +186,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
|
|||||||
peer={user}
|
peer={user}
|
||||||
isSavedMessages={isSavedMessages}
|
isSavedMessages={isSavedMessages}
|
||||||
withStory={withStory}
|
withStory={withStory}
|
||||||
|
storyViewerOrigin={storyViewerOrigin}
|
||||||
storyViewerMode="single-user"
|
storyViewerMode="single-user"
|
||||||
onClick={withMediaViewer ? handleAvatarViewerOpen : undefined}
|
onClick={withMediaViewer ? handleAvatarViewerOpen : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
|
import type { FC } from '../../../lib/teact/teact';
|
||||||
import React, { memo, useEffect } from '../../../lib/teact/teact';
|
import React, { memo, useEffect } from '../../../lib/teact/teact';
|
||||||
import { getActions, withGlobal } from '../../../global';
|
import { getActions, withGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { FC } from '../../../lib/teact/teact';
|
|
||||||
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
|
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
|
||||||
|
import { StoryViewerOrigin } from '../../../types';
|
||||||
|
import type { ChatAnimationTypes } from './hooks';
|
||||||
|
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
|
||||||
import type {
|
import type {
|
||||||
ApiChat,
|
ApiChat,
|
||||||
ApiFormattedText,
|
ApiFormattedText,
|
||||||
@ -13,7 +16,6 @@ import type {
|
|||||||
ApiUser,
|
ApiUser,
|
||||||
ApiUserStatus,
|
ApiUserStatus,
|
||||||
} from '../../../api/types';
|
} from '../../../api/types';
|
||||||
import type { ChatAnimationTypes } from './hooks';
|
|
||||||
|
|
||||||
import { MAIN_THREAD_ID } from '../../../api/types';
|
import { MAIN_THREAD_ID } from '../../../api/types';
|
||||||
import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../util/windowEnvironment';
|
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 useChatContextActions from '../../../hooks/useChatContextActions';
|
||||||
import useFlag from '../../../hooks/useFlag';
|
import useFlag from '../../../hooks/useFlag';
|
||||||
import useChatListEntry from './hooks/useChatListEntry';
|
import useChatListEntry from './hooks/useChatListEntry';
|
||||||
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
|
|
||||||
import useAppLayout from '../../../hooks/useAppLayout';
|
import useAppLayout from '../../../hooks/useAppLayout';
|
||||||
import useShowTransition from '../../../hooks/useShowTransition';
|
import useShowTransition from '../../../hooks/useShowTransition';
|
||||||
|
|
||||||
@ -265,6 +266,7 @@ const Chat: FC<OwnProps & StateProps> = ({
|
|||||||
isSavedMessages={user?.isSelf}
|
isSavedMessages={user?.isSelf}
|
||||||
withStory={user && !user?.isSelf}
|
withStory={user && !user?.isSelf}
|
||||||
withStoryGap={isAvatarOnlineShown}
|
withStoryGap={isAvatarOnlineShown}
|
||||||
|
storyViewerOrigin={StoryViewerOrigin.ChatList}
|
||||||
storyViewerMode="single-user"
|
storyViewerMode="single-user"
|
||||||
/>
|
/>
|
||||||
<div className="avatar-badge-wrapper">
|
<div className="avatar-badge-wrapper">
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import React, { useCallback, useMemo, memo } from '../../../lib/teact/teact';
|
|||||||
import { getActions, withGlobal } from '../../../global';
|
import { getActions, withGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { ApiUser, ApiUserStatus } from '../../../api/types';
|
import type { ApiUser, ApiUserStatus } from '../../../api/types';
|
||||||
|
import { StoryViewerOrigin } from '../../../types';
|
||||||
|
|
||||||
import { filterUsersByName, sortUserIds } from '../../../global/helpers';
|
import { filterUsersByName, sortUserIds } from '../../../global/helpers';
|
||||||
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
|
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
|
||||||
@ -75,7 +76,14 @@ const ContactList: FC<OwnProps & StateProps> = ({
|
|||||||
// eslint-disable-next-line react/jsx-no-bind
|
// eslint-disable-next-line react/jsx-no-bind
|
||||||
onClick={() => handleClick(id)}
|
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>
|
</ListItem>
|
||||||
))
|
))
|
||||||
) : viewportIds && !viewportIds.length ? (
|
) : viewportIds && !viewportIds.length ? (
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import React, { memo, useCallback } from '../../../lib/teact/teact';
|
|||||||
import { withGlobal } from '../../../global';
|
import { withGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { ApiChat, ApiUser } from '../../../api/types';
|
import type { ApiChat, ApiUser } from '../../../api/types';
|
||||||
|
import { StoryViewerOrigin } from '../../../types';
|
||||||
|
|
||||||
import useChatContextActions from '../../../hooks/useChatContextActions';
|
import useChatContextActions from '../../../hooks/useChatContextActions';
|
||||||
import useFlag from '../../../hooks/useFlag';
|
import useFlag from '../../../hooks/useFlag';
|
||||||
@ -85,7 +86,13 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
|
|||||||
buttonRef={buttonRef}
|
buttonRef={buttonRef}
|
||||||
>
|
>
|
||||||
{isUserId(chatId) ? (
|
{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" />
|
<GroupChatInfo chatId={chatId} withUsername={withUsername} avatarSize="large" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { Signal } from '../../util/signals';
|
|||||||
import type {
|
import type {
|
||||||
ApiChat, ApiMessage, ApiTypingStatus, ApiUser,
|
ApiChat, ApiMessage, ApiTypingStatus, ApiUser,
|
||||||
} from '../../api/types';
|
} from '../../api/types';
|
||||||
|
import { StoryViewerOrigin } from '../../types';
|
||||||
import { MAIN_THREAD_ID } from '../../api/types';
|
import { MAIN_THREAD_ID } from '../../api/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -22,7 +23,12 @@ import {
|
|||||||
SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN,
|
SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN,
|
||||||
} from '../../config';
|
} from '../../config';
|
||||||
import {
|
import {
|
||||||
getChatTitle, getMessageKey, getSenderTitle, isChatChannel, isChatSuperGroup, isUserId,
|
getChatTitle,
|
||||||
|
getMessageKey,
|
||||||
|
getSenderTitle,
|
||||||
|
isChatChannel,
|
||||||
|
isChatSuperGroup,
|
||||||
|
isUserId,
|
||||||
} from '../../global/helpers';
|
} from '../../global/helpers';
|
||||||
import {
|
import {
|
||||||
selectAllowedMessageActions,
|
selectAllowedMessageActions,
|
||||||
@ -379,6 +385,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
|
|||||||
withMediaViewer
|
withMediaViewer
|
||||||
withStory={!isChatWithSelf}
|
withStory={!isChatWithSelf}
|
||||||
withUpdatingStatus
|
withUpdatingStatus
|
||||||
|
storyViewerOrigin={StoryViewerOrigin.MiddleHeaderAvatar}
|
||||||
emojiStatusSize={EMOJI_STATUS_SIZE}
|
emojiStatusSize={EMOJI_STATUS_SIZE}
|
||||||
noRtl
|
noRtl
|
||||||
onEmojiStatusClick={handleStatusClick}
|
onEmojiStatusClick={handleStatusClick}
|
||||||
|
|||||||
@ -8,20 +8,19 @@ import type { ApiStory, ApiTypeStory, ApiUser } from '../../api/types';
|
|||||||
import type { IDimensions } from '../../global/types';
|
import type { IDimensions } from '../../global/types';
|
||||||
import type { Signal } from '../../util/signals';
|
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 buildClassName from '../../util/buildClassName';
|
||||||
import renderText from '../common/helpers/renderText';
|
import renderText from '../common/helpers/renderText';
|
||||||
import {
|
import { getUserFirstOrLastName } from '../../global/helpers';
|
||||||
getStoryMediaHash, getUserFirstOrLastName, hasMessageText,
|
|
||||||
} from '../../global/helpers';
|
|
||||||
import { formatRelativeTime } from '../../util/dateFormat';
|
import { formatRelativeTime } from '../../util/dateFormat';
|
||||||
import { getServerTime } from '../../util/serverTime';
|
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 captureKeyboardListeners from '../../util/captureKeyboardListeners';
|
||||||
|
|
||||||
import useAppLayout, { getIsMobile } from '../../hooks/useAppLayout';
|
import useAppLayout, { getIsMobile } from '../../hooks/useAppLayout';
|
||||||
import useLang from '../../hooks/useLang';
|
import useLang from '../../hooks/useLang';
|
||||||
import useMedia from '../../hooks/useMedia';
|
|
||||||
import useStoryPreloader from './hooks/useStoryPreloader';
|
import useStoryPreloader from './hooks/useStoryPreloader';
|
||||||
import useBackgroundMode from '../../hooks/useBackgroundMode';
|
import useBackgroundMode from '../../hooks/useBackgroundMode';
|
||||||
import useShowTransition from '../../hooks/useShowTransition';
|
import useShowTransition from '../../hooks/useShowTransition';
|
||||||
@ -34,6 +33,7 @@ import useLongPress from '../../hooks/useLongPress';
|
|||||||
import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia';
|
import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia';
|
||||||
import useCanvasBlur from '../../hooks/useCanvasBlur';
|
import useCanvasBlur from '../../hooks/useCanvasBlur';
|
||||||
import useMediaTransition from '../../hooks/useMediaTransition';
|
import useMediaTransition from '../../hooks/useMediaTransition';
|
||||||
|
import { useStoryProps } from './hooks/useStoryProps';
|
||||||
|
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
@ -139,9 +139,22 @@ function Story({
|
|||||||
const [isPausedByLongPress, markIsPausedByLongPress, unmarkIsPausedByLongPress] = useFlag(false);
|
const [isPausedByLongPress, markIsPausedByLongPress, unmarkIsPausedByLongPress] = useFlag(false);
|
||||||
// eslint-disable-next-line no-null/no-null
|
// eslint-disable-next-line no-null/no-null
|
||||||
const videoRef = useRef<HTMLVideoElement>(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 isLoadedStory = story && 'content' in story;
|
||||||
const isDeletedStory = story && 'isDeleted' in story;
|
|
||||||
const hasText = isLoadedStory ? hasMessageText(story) : false;
|
|
||||||
const canPinToProfile = useCurrentOrPrev(
|
const canPinToProfile = useCurrentOrPrev(
|
||||||
isSelf && isLoadedStory ? !story.isPinned : undefined,
|
isSelf && isLoadedStory ? !story.isPinned : undefined,
|
||||||
true,
|
true,
|
||||||
@ -159,6 +172,7 @@ function Story({
|
|||||||
&& userId !== storyChangelogUserId
|
&& userId !== storyChangelogUserId
|
||||||
&& user?.usernames?.length,
|
&& user?.usernames?.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
const canShare = Boolean(
|
const canShare = Boolean(
|
||||||
isLoadedStory
|
isLoadedStory
|
||||||
&& story.isPublic
|
&& story.isPublic
|
||||||
@ -167,26 +181,6 @@ function Story({
|
|||||||
&& !isCaptionExpanded,
|
&& !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(
|
const canPlayStory = Boolean(
|
||||||
hasFullData && !shouldForcePause && isAppFocused && !isComposerHasFocus && !isCaptionExpanded
|
hasFullData && !shouldForcePause && isAppFocused && !isComposerHasFocus && !isCaptionExpanded
|
||||||
&& !isPausedBySpacebar && !isPausedByLongPress,
|
&& !isPausedBySpacebar && !isPausedByLongPress,
|
||||||
@ -199,7 +193,6 @@ function Story({
|
|||||||
transitionClassNames: mediaTransitionClassNames,
|
transitionClassNames: mediaTransitionClassNames,
|
||||||
} = useShowTransition(Boolean(fullMediaData));
|
} = useShowTransition(Boolean(fullMediaData));
|
||||||
|
|
||||||
const hasThumb = !previewBlobUrl && !hasFullData;
|
|
||||||
const thumbRef = useCanvasBlur(thumbnail, !hasThumb);
|
const thumbRef = useCanvasBlur(thumbnail, !hasThumb);
|
||||||
const previewTransitionClassNames = useMediaTransition(previewBlobUrl);
|
const previewTransitionClassNames = useMediaTransition(previewBlobUrl);
|
||||||
|
|
||||||
@ -777,10 +770,8 @@ export default memo(withGlobal<OwnProps>((global, {
|
|||||||
premiumModal,
|
premiumModal,
|
||||||
} = tabState;
|
} = tabState;
|
||||||
const { isOpen: isPremiumModalOpen } = premiumModal || {};
|
const { isOpen: isPremiumModalOpen } = premiumModal || {};
|
||||||
const {
|
const { orderedIds, pinnedIds, archiveIds } = selectUserStories(global, userId) || {};
|
||||||
byId, orderedIds, pinnedIds, archiveIds,
|
const story = selectUserStory(global, userId, storyId);
|
||||||
} = global.stories.byUserId[userId] || {};
|
|
||||||
const story = byId && storyId ? byId[storyId] : undefined;
|
|
||||||
const shouldForcePause = Boolean(
|
const shouldForcePause = Boolean(
|
||||||
storyIdSeenBy || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen
|
storyIdSeenBy || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen
|
||||||
|| isPremiumModalOpen || isDeleteModalOpen,
|
|| isPremiumModalOpen || isDeleteModalOpen,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React, { memo, useEffect, useMemo } from '../../lib/teact/teact';
|
|||||||
import { getActions, withGlobal } from '../../global';
|
import { getActions, withGlobal } from '../../global';
|
||||||
|
|
||||||
import type { ApiTypeStory, ApiUser, ApiUserStories } from '../../api/types';
|
import type { ApiTypeStory, ApiUser, ApiUserStories } from '../../api/types';
|
||||||
|
import type { StoryViewerOrigin } from '../../types';
|
||||||
|
|
||||||
import { selectTabState } from '../../global/selectors';
|
import { selectTabState } from '../../global/selectors';
|
||||||
import renderText from '../common/helpers/renderText';
|
import renderText from '../common/helpers/renderText';
|
||||||
@ -19,10 +20,11 @@ interface OwnProps {
|
|||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
lastViewedId?: number;
|
lastViewedId?: number;
|
||||||
|
origin?: StoryViewerOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StoryPreview({
|
function StoryPreview({
|
||||||
user, userStories, lastViewedId,
|
user, userStories, lastViewedId, origin,
|
||||||
}: OwnProps & StateProps) {
|
}: OwnProps & StateProps) {
|
||||||
const { openStoryViewer, loadUserSkippedStories } = getActions();
|
const { openStoryViewer, loadUserSkippedStories } = getActions();
|
||||||
|
|
||||||
@ -61,7 +63,7 @@ function StoryPreview({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.slideInner}
|
className={styles.slideInner}
|
||||||
onClick={() => { openStoryViewer({ userId: story.userId, storyId: story.id }); }}
|
onClick={() => { openStoryViewer({ userId: story.userId, storyId: story.id, origin }); }}
|
||||||
>
|
>
|
||||||
{thumbUrl && (
|
{thumbUrl && (
|
||||||
<img src={thumbUrl} alt="" className={styles.media} draggable={false} />
|
<img src={thumbUrl} alt="" className={styles.media} draggable={false} />
|
||||||
@ -83,10 +85,12 @@ export default memo(withGlobal<OwnProps>((global, { user }): StateProps => {
|
|||||||
const {
|
const {
|
||||||
storyViewer: {
|
storyViewer: {
|
||||||
lastViewedByUserIds,
|
lastViewedByUserIds,
|
||||||
|
origin,
|
||||||
},
|
},
|
||||||
} = selectTabState(global);
|
} = selectTabState(global);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lastViewedId: user?.id ? lastViewedByUserIds?.[user.id] : undefined,
|
lastViewedId: user?.id ? lastViewedByUserIds?.[user.id] : undefined,
|
||||||
|
origin,
|
||||||
};
|
};
|
||||||
})(StoryPreview));
|
})(StoryPreview));
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React, { memo, useRef } from '../../lib/teact/teact';
|
|||||||
import { getActions } from '../../global';
|
import { getActions } from '../../global';
|
||||||
|
|
||||||
import type { ApiUser } from '../../api/types';
|
import type { ApiUser } from '../../api/types';
|
||||||
|
import { StoryViewerOrigin } from '../../types';
|
||||||
|
|
||||||
import buildClassName from '../../util/buildClassName';
|
import buildClassName from '../../util/buildClassName';
|
||||||
import { getUserFirstOrLastName } from '../../global/helpers';
|
import { getUserFirstOrLastName } from '../../global/helpers';
|
||||||
@ -62,7 +63,7 @@ function StoryRibbonButton({ user, isArchived }: OwnProps) {
|
|||||||
const handleClick = useLastCallback(() => {
|
const handleClick = useLastCallback(() => {
|
||||||
if (isContextMenuOpen) return;
|
if (isContextMenuOpen) return;
|
||||||
|
|
||||||
openStoryViewer({ userId: user.id });
|
openStoryViewer({ userId: user.id, origin: StoryViewerOrigin.StoryRibbon });
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleMouseDown = useLastCallback((e: React.MouseEvent<HTMLElement>) => {
|
const handleMouseDown = useLastCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||||
@ -103,6 +104,7 @@ function StoryRibbonButton({ user, isArchived }: OwnProps) {
|
|||||||
<Avatar
|
<Avatar
|
||||||
peer={user}
|
peer={user}
|
||||||
withStory
|
withStory
|
||||||
|
storyViewerOrigin={StoryViewerOrigin.StoryRibbon}
|
||||||
storyViewerMode="full"
|
storyViewerMode="full"
|
||||||
/>
|
/>
|
||||||
<div className={buildClassName(styles.name, user.hasUnreadStories && styles.name_hasUnreadStory)}>
|
<div className={buildClassName(styles.name, user.hasUnreadStories && styles.name_hasUnreadStory)}>
|
||||||
|
|||||||
@ -8,15 +8,15 @@ import type { ApiUserStories } from '../../api/types';
|
|||||||
import { IS_FIREFOX, IS_SAFARI } from '../../util/windowEnvironment';
|
import { IS_FIREFOX, IS_SAFARI } from '../../util/windowEnvironment';
|
||||||
import { ANIMATION_END_DELAY } from '../../config';
|
import { ANIMATION_END_DELAY } from '../../config';
|
||||||
import { selectIsStoryViewerOpen, selectTabState, selectUser } from '../../global/selectors';
|
import { selectIsStoryViewerOpen, selectTabState, selectUser } from '../../global/selectors';
|
||||||
import { calculateOffsetX, calculateSlideSizes } from './helpers/dimensions';
|
import { calculateOffsetX } from './helpers/dimensions';
|
||||||
import buildClassName from '../../util/buildClassName';
|
import buildClassName from '../../util/buildClassName';
|
||||||
import buildStyle from '../../util/buildStyle';
|
import buildStyle from '../../util/buildStyle';
|
||||||
|
|
||||||
import useLastCallback from '../../hooks/useLastCallback';
|
import useLastCallback from '../../hooks/useLastCallback';
|
||||||
import usePrevious from '../../hooks/usePrevious';
|
import usePrevious from '../../hooks/usePrevious';
|
||||||
import useWindowSize from '../../hooks/useWindowSize';
|
|
||||||
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
|
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
|
||||||
import useSignal from '../../hooks/useSignal';
|
import useSignal from '../../hooks/useSignal';
|
||||||
|
import { useSlideSizes } from './hooks/useSlideSizes';
|
||||||
|
|
||||||
import Story from './Story';
|
import Story from './Story';
|
||||||
import StoryPreview from './StoryPreview';
|
import StoryPreview from './StoryPreview';
|
||||||
@ -59,10 +59,8 @@ function StorySlides({
|
|||||||
const renderingIsPrivate = useCurrentOrPrev(isPrivate, true);
|
const renderingIsPrivate = useCurrentOrPrev(isPrivate, true);
|
||||||
const renderingIsSingleUser = useCurrentOrPrev(isSingleUser, true);
|
const renderingIsSingleUser = useCurrentOrPrev(isSingleUser, true);
|
||||||
const renderingIsSingleStory = useCurrentOrPrev(isSingleStory, true);
|
const renderingIsSingleStory = useCurrentOrPrev(isSingleStory, true);
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
const slideSizes = useSlideSizes();
|
||||||
const slideSizes = useMemo(() => {
|
|
||||||
return calculateSlideSizes(windowWidth, windowHeight);
|
|
||||||
}, [windowWidth, windowHeight]);
|
|
||||||
const rendersRef = useRef<Record<string, { current: HTMLDivElement | null }>>({});
|
const rendersRef = useRef<Record<string, { current: HTMLDivElement | null }>>({});
|
||||||
const [getIsAnimating, setIsAnimating] = useSignal(false);
|
const [getIsAnimating, setIsAnimating] = useSignal(false);
|
||||||
|
|
||||||
|
|||||||
@ -19,11 +19,8 @@
|
|||||||
transform: scale(0);
|
transform: scale(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.opacity-transition) {
|
&:global(.opacity-transition) {
|
||||||
transition: opacity 350ms;
|
transition: opacity 200ms;
|
||||||
@media (max-width: 600px) {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.text-entity-link) {
|
:global(.text-entity-link) {
|
||||||
@ -35,18 +32,6 @@
|
|||||||
text-decoration: none !important;
|
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 {
|
.fullSize, .backdrop, .captionBackdrop {
|
||||||
@ -63,6 +48,7 @@
|
|||||||
|
|
||||||
.backdrop {
|
.backdrop {
|
||||||
background-color: rgba(0, 0, 0, 0.9);
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backdropNonInteractive {
|
.backdropNonInteractive {
|
||||||
@ -78,10 +64,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
top: 1rem;
|
top: 1rem;
|
||||||
z-index: 2;
|
z-index: 3;
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
top: 1.125rem;
|
top: 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,6 +80,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
max-width: calc(73.5rem * var(--story-viewer-scale));
|
max-width: calc(73.5rem * var(--story-viewer-scale));
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -101,7 +88,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.slideAnimation {
|
.slideAnimation {
|
||||||
transition: transform 350ms ease-in-out;
|
transition: transform 350ms ease-in-out !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slideAnimationToActive {
|
.slideAnimationToActive {
|
||||||
@ -145,8 +132,7 @@
|
|||||||
calc(var(--slide-x, -50%) - var(--slide-translate-x, 0px)),
|
calc(var(--slide-x, -50%) - var(--slide-translate-x, 0px)),
|
||||||
calc(-50% - var(--slide-translate-y, 0px)),
|
calc(-50% - var(--slide-translate-y, 0px)),
|
||||||
0
|
0
|
||||||
)
|
) scale(var(--slide-translate-scale, 1));
|
||||||
scale(var(--slide-translate-scale, 1));
|
|
||||||
transform-origin: 0 50%;
|
transform-origin: 0 50%;
|
||||||
|
|
||||||
border-radius: var(--border-radius-default-small);
|
border-radius: var(--border-radius-default-small);
|
||||||
@ -173,6 +159,7 @@
|
|||||||
|
|
||||||
.slidePreview {
|
.slidePreview {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: opacity 200ms ease-in-out;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@ -187,7 +174,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.slideAnimationToActive::before {
|
&.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;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -224,7 +216,7 @@
|
|||||||
|
|
||||||
@media (min-width: 600.001px) {
|
@media (min-width: 600.001px) {
|
||||||
&.slideAnimationFromActive::before {
|
&.slideAnimationFromActive::before {
|
||||||
transition: opacity 350ms ease-in-out;
|
transition: opacity 350ms ease-in-out !important;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -253,6 +245,10 @@
|
|||||||
height: calc(100% - 4rem) !important;
|
height: calc(100% - 4rem) !important;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(body.ghost-animating) & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media {
|
.media {
|
||||||
@ -272,6 +268,10 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(body.ghost-animating) .activeSlide & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@ -317,7 +317,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.storyHeader {
|
// Shared styles for the header that are also used in ghost animation
|
||||||
|
@mixin story-header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
content: "";
|
content: "";
|
||||||
@ -330,6 +331,14 @@
|
|||||||
border-radius: var(--border-radius-default-small) var(--border-radius-default-small) 0 0;
|
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 {
|
.storyIndicators {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -459,7 +468,7 @@
|
|||||||
|
|
||||||
.caption {
|
.caption {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 3.5rem;
|
bottom: calc(3.5rem - 1px);
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -690,3 +699,35 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin-block: auto;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,13 +3,28 @@ import React, {
|
|||||||
} from '../../lib/teact/teact';
|
} from '../../lib/teact/teact';
|
||||||
import { getActions, withGlobal } from '../../global';
|
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 captureEscKeyListener from '../../util/captureEscKeyListener';
|
||||||
import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager';
|
import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager';
|
||||||
|
|
||||||
|
import { ANIMATION_END_DELAY } from '../../config';
|
||||||
|
|
||||||
import useFlag from '../../hooks/useFlag';
|
import useFlag from '../../hooks/useFlag';
|
||||||
import useLang from '../../hooks/useLang';
|
import useLang from '../../hooks/useLang';
|
||||||
import useHistoryBack from '../../hooks/useHistoryBack';
|
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 { dispatchPriorityPlaybackEvent } from '../../hooks/usePriorityPlaybackCheck';
|
||||||
|
|
||||||
import ShowTransition from '../ui/ShowTransition';
|
import ShowTransition from '../ui/ShowTransition';
|
||||||
@ -22,11 +37,16 @@ import StorySettings from './StorySettings';
|
|||||||
|
|
||||||
import styles from './StoryViewer.module.scss';
|
import styles from './StoryViewer.module.scss';
|
||||||
|
|
||||||
|
const ANIMATION_DURATION = 350;
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
storyId?: number;
|
storyId?: number;
|
||||||
|
story?: ApiTypeStory;
|
||||||
|
origin?: StoryViewerOrigin;
|
||||||
shouldSkipHistoryAnimations?: boolean;
|
shouldSkipHistoryAnimations?: boolean;
|
||||||
|
withAnimation?: boolean;
|
||||||
isPrivacyModalOpen?: boolean;
|
isPrivacyModalOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,7 +54,10 @@ function StoryViewer({
|
|||||||
isOpen,
|
isOpen,
|
||||||
userId,
|
userId,
|
||||||
storyId,
|
storyId,
|
||||||
|
story,
|
||||||
|
origin,
|
||||||
shouldSkipHistoryAnimations,
|
shouldSkipHistoryAnimations,
|
||||||
|
withAnimation,
|
||||||
isPrivacyModalOpen,
|
isPrivacyModalOpen,
|
||||||
}: StateProps) {
|
}: StateProps) {
|
||||||
const { closeStoryViewer, closeStoryPrivacyEditor } = getActions();
|
const { closeStoryViewer, closeStoryPrivacyEditor } = getActions();
|
||||||
@ -44,6 +67,14 @@ function StoryViewer({
|
|||||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(false);
|
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(false);
|
||||||
const [isReportModalOpen, openReportModal, closeReportModal] = 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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setIdStoryForDelete(undefined);
|
setIdStoryForDelete(undefined);
|
||||||
@ -90,6 +121,29 @@ function StoryViewer({
|
|||||||
handleClose();
|
handleClose();
|
||||||
}) : undefined), [handleClose, isOpen]);
|
}) : 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 (
|
return (
|
||||||
<ShowTransition
|
<ShowTransition
|
||||||
id="StoryViewer"
|
id="StoryViewer"
|
||||||
@ -137,13 +191,22 @@ function StoryViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default memo(withGlobal((global): StateProps => {
|
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 {
|
return {
|
||||||
isOpen: selectIsStoryViewerOpen(global),
|
isOpen: selectIsStoryViewerOpen(global),
|
||||||
shouldSkipHistoryAnimations,
|
shouldSkipHistoryAnimations,
|
||||||
userId,
|
userId,
|
||||||
storyId,
|
storyId,
|
||||||
|
story,
|
||||||
|
origin,
|
||||||
|
withAnimation,
|
||||||
isPrivacyModalOpen,
|
isPrivacyModalOpen,
|
||||||
};
|
};
|
||||||
})(StoryViewer));
|
})(StoryViewer));
|
||||||
|
|||||||
237
src/components/story/helpers/ghostAnimation.ts
Normal file
237
src/components/story/helpers/ghostAnimation.ts
Normal 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);
|
||||||
|
}
|
||||||
8
src/components/story/hooks/useSlideSizes.ts
Normal file
8
src/components/story/hooks/useSlideSizes.ts
Normal 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]);
|
||||||
|
};
|
||||||
51
src/components/story/hooks/useStoryProps.ts
Normal file
51
src/components/story/hooks/useStoryProps.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -21,7 +21,7 @@ import * as langProvider from '../../../util/langProvider';
|
|||||||
|
|
||||||
addActionHandler('openStoryViewer', async (global, actions, payload): Promise<void> => {
|
addActionHandler('openStoryViewer', async (global, actions, payload): Promise<void> => {
|
||||||
const {
|
const {
|
||||||
userId, storyId, isSingleUser, isSingleStory, isPrivate, isArchive, tabId = getCurrentTabId(),
|
userId, storyId, isSingleUser, isSingleStory, isPrivate, isArchive, origin, tabId = getCurrentTabId(),
|
||||||
} = payload;
|
} = payload;
|
||||||
|
|
||||||
const user = selectUser(global, userId);
|
const user = selectUser(global, userId);
|
||||||
@ -52,6 +52,7 @@ addActionHandler('openStoryViewer', async (global, actions, payload): Promise<vo
|
|||||||
isPrivate,
|
isPrivate,
|
||||||
isArchive,
|
isArchive,
|
||||||
isSingleStory,
|
isSingleStory,
|
||||||
|
origin,
|
||||||
storyIdSeenBy: undefined,
|
storyIdSeenBy: undefined,
|
||||||
},
|
},
|
||||||
}, tabId);
|
}, tabId);
|
||||||
@ -60,7 +61,7 @@ addActionHandler('openStoryViewer', async (global, actions, payload): Promise<vo
|
|||||||
|
|
||||||
addActionHandler('openStoryViewerByUsername', async (global, actions, payload): Promise<void> => {
|
addActionHandler('openStoryViewerByUsername', async (global, actions, payload): Promise<void> => {
|
||||||
const {
|
const {
|
||||||
username, storyId, tabId = getCurrentTabId(),
|
username, storyId, origin, tabId = getCurrentTabId(),
|
||||||
} = payload;
|
} = payload;
|
||||||
|
|
||||||
const chat = await fetchChatByUsername(global, username);
|
const chat = await fetchChatByUsername(global, username);
|
||||||
@ -74,6 +75,7 @@ addActionHandler('openStoryViewerByUsername', async (global, actions, payload):
|
|||||||
storyId,
|
storyId,
|
||||||
isSingleUser: true,
|
isSingleUser: true,
|
||||||
isSingleStory: true,
|
isSingleStory: true,
|
||||||
|
origin,
|
||||||
tabId,
|
tabId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -280,3 +280,7 @@ export function getUserColorKey(peer: ApiUser | ApiChat | undefined) {
|
|||||||
export function getMainUsername(userOrChat: ApiUser | ApiChat) {
|
export function getMainUsername(userOrChat: ApiUser | ApiChat) {
|
||||||
return userOrChat.usernames?.find((u) => u.isActive)?.username;
|
return userOrChat.usernames?.find((u) => u.isActive)?.username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUserStoryHtmlId(userId: string) {
|
||||||
|
return `user-story${userId}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -94,6 +94,7 @@ import type {
|
|||||||
ShippingOption,
|
ShippingOption,
|
||||||
ThemeKey,
|
ThemeKey,
|
||||||
ProfileTabType,
|
ProfileTabType,
|
||||||
|
StoryViewerOrigin,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { P2pMessage } from '../lib/secret-sauce';
|
import type { P2pMessage } from '../lib/secret-sauce';
|
||||||
import type { ApiCredentials } from '../components/payment/PaymentModal';
|
import type { ApiCredentials } from '../components/payment/PaymentModal';
|
||||||
@ -361,6 +362,7 @@ export type TabState = {
|
|||||||
// Used for better switch animation between users.
|
// Used for better switch animation between users.
|
||||||
lastViewedByUserIds?: Record<string, number>;
|
lastViewedByUserIds?: Record<string, number>;
|
||||||
isPrivacyModalOpen?: boolean;
|
isPrivacyModalOpen?: boolean;
|
||||||
|
origin?: StoryViewerOrigin;
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaViewer: {
|
mediaViewer: {
|
||||||
@ -1970,10 +1972,12 @@ export interface ActionPayloads {
|
|||||||
isSingleStory?: boolean;
|
isSingleStory?: boolean;
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
isArchive?: boolean;
|
isArchive?: boolean;
|
||||||
|
origin?: StoryViewerOrigin;
|
||||||
} & WithTabId;
|
} & WithTabId;
|
||||||
openStoryViewerByUsername: {
|
openStoryViewerByUsername: {
|
||||||
username: string;
|
username: string;
|
||||||
storyId: number;
|
storyId: number;
|
||||||
|
origin?: StoryViewerOrigin;
|
||||||
} & WithTabId;
|
} & WithTabId;
|
||||||
openPreviousStory: WithTabId | undefined;
|
openPreviousStory: WithTabId | undefined;
|
||||||
openNextStory: WithTabId | undefined;
|
openNextStory: WithTabId | undefined;
|
||||||
|
|||||||
@ -292,6 +292,13 @@ export enum MediaViewerOrigin {
|
|||||||
SuggestedAvatar,
|
SuggestedAvatar,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum StoryViewerOrigin {
|
||||||
|
StoryRibbon,
|
||||||
|
MiddleHeaderAvatar,
|
||||||
|
ChatList,
|
||||||
|
SearchResult,
|
||||||
|
}
|
||||||
|
|
||||||
export enum AudioOrigin {
|
export enum AudioOrigin {
|
||||||
Inline,
|
Inline,
|
||||||
SharedMedia,
|
SharedMedia,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user