From b94999c204e22e0aa8a4815dca47b467d41d0351 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Thu, 18 Jan 2024 18:18:36 +0100 Subject: [PATCH] Stories: Fix flickering while switching stories (#4149) --- src/components/story/Story.tsx | 14 ++-- src/components/story/StoryPreview.tsx | 13 ++- src/components/story/StorySlides.tsx | 54 +++++-------- src/components/story/StoryViewer.module.scss | 9 +++ src/global/actions/ui/stories.ts | 32 ++++---- src/global/selectors/stories.ts | 84 ++++++++++++++++++++ src/global/types.ts | 5 ++ 7 files changed, 153 insertions(+), 58 deletions(-) diff --git a/src/components/story/Story.tsx b/src/components/story/Story.tsx index 0f57afe84..29003ea1b 100644 --- a/src/components/story/Story.tsx +++ b/src/components/story/Story.tsx @@ -15,11 +15,13 @@ import { MAIN_THREAD_ID } from '../../api/types'; import { EDITABLE_STORY_INPUT_CSS_SELECTOR, EDITABLE_STORY_INPUT_ID } from '../../config'; import { getSenderTitle, isUserId } from '../../global/helpers'; import { - selectChat, selectIsCurrentUserPremium, + selectChat, + selectIsCurrentUserPremium, selectPeer, - selectPeerStories, selectPeerStory, + selectPeerStory, selectPerformanceSettingsValue, - selectTabState, selectUser, + selectTabState, + selectUser, } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import captureKeyboardListeners from '../../util/captureKeyboardListeners'; @@ -864,8 +866,6 @@ function Story({ export default memo(withGlobal((global, { peerId, storyId, - isPrivateStories, - isArchivedStories, isReportModalOpen, isDeleteModalOpen, }): StateProps => { @@ -879,6 +879,7 @@ export default memo(withGlobal((global, { viewModal, isPrivacyModalOpen, isStealthModalOpen, + storyList, }, forwardMessages: { storyId: forwardedStoryId }, premiumModal, @@ -886,7 +887,6 @@ export default memo(withGlobal((global, { mapModal, } = tabState; const { isOpen: isPremiumModalOpen } = premiumModal || {}; - const { orderedIds, pinnedIds, archiveIds } = selectPeerStories(global, peerId) || {}; const story = selectPeerStory(global, peerId, storyId); const shouldForcePause = Boolean( viewModal || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen @@ -904,7 +904,7 @@ export default memo(withGlobal((global, { peer: (user || chat)!, forwardSender, story, - orderedIds: isArchivedStories ? archiveIds : (isPrivateStories ? pinnedIds : orderedIds), + orderedIds: storyList?.storyIdsByPeerId[peerId], isMuted, isCurrentUserPremium: selectIsCurrentUserPremium(global), shouldForcePause, diff --git a/src/components/story/StoryPreview.tsx b/src/components/story/StoryPreview.tsx index bca2a3135..2c76908ed 100644 --- a/src/components/story/StoryPreview.tsx +++ b/src/components/story/StoryPreview.tsx @@ -26,10 +26,11 @@ interface OwnProps { interface StateProps { lastViewedId?: number; origin?: StoryViewerOrigin; + storyIdsForViewer?: number[]; } function StoryPreview({ - peer, peerStories, lastViewedId, origin, + peer, peerStories, lastViewedId, storyIdsForViewer, origin, }: OwnProps & StateProps) { const { openStoryViewer, loadPeerSkippedStories } = getActions(); const lang = useLang(); @@ -43,11 +44,13 @@ function StoryPreview({ orderedIds, lastReadId, byId, } = peerStories; const hasUnreadStories = orderedIds[orderedIds.length - 1] !== lastReadId; - const previewIndexId = lastViewedId ?? (hasUnreadStories ? (lastReadId ?? -1) : -1); - const resultId = byId[previewIndexId]?.id || orderedIds[0]; + const previewIndexId = lastViewedId && storyIdsForViewer?.includes(lastViewedId) + ? lastViewedId ?? (hasUnreadStories ? (lastReadId ?? -1) : -1) + : -1; + const resultId = byId[previewIndexId]?.id || storyIdsForViewer?.[0] || orderedIds[0]; return byId[resultId]; - }, [lastViewedId, peerStories]); + }, [lastViewedId, peerStories, storyIdsForViewer]); const isLoaded = story && 'content' in story; @@ -95,11 +98,13 @@ export default memo(withGlobal((global, { peer }): StateProps => { storyViewer: { lastViewedByPeerIds, origin, + storyList, }, } = selectTabState(global); return { lastViewedId: peer?.id ? lastViewedByPeerIds?.[peer.id] : undefined, origin, + storyIdsForViewer: peer?.id ? storyList?.storyIdsByPeerId[peer.id] : undefined, }; })(StoryPreview)); diff --git a/src/components/story/StorySlides.tsx b/src/components/story/StorySlides.tsx index 5f44076b0..c4f96d4ec 100644 --- a/src/components/story/StorySlides.tsx +++ b/src/components/story/StorySlides.tsx @@ -158,45 +158,20 @@ function StorySlides({ return renderingPeerIds.indexOf(currentPeerId); }, [currentPeerId, renderingPeerIds]); + // Handling the flipping of stories from a current user useEffect(() => { - const timeoutId = window.setTimeout(() => { - setRenderingPeerId(currentPeerId); - }, animationDuration); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [animationDuration, currentPeerId]); - - useEffect(() => { - let timeOutId: number | undefined; - - if (renderingPeerId !== currentPeerId) { - timeOutId = window.setTimeout(() => { - setRenderingStoryId(currentStoryId); - }, animationDuration); - } else if (currentStoryId !== renderingStoryId) { + if (renderingPeerId === currentPeerId && currentStoryId !== renderingStoryId) { setRenderingStoryId(currentStoryId); } - - return () => { - window.clearTimeout(timeOutId); - }; - }, [renderingPeerId, currentStoryId, currentPeerId, renderingStoryId, animationDuration]); + }, [currentPeerId, currentStoryId, renderingPeerId, renderingStoryId]); useEffect(() => { - let timeOutId: number | undefined; - if (prevPeerId && prevPeerId !== currentPeerId) { setIsAnimating(true); - timeOutId = window.setTimeout(() => { - setIsAnimating(false); - }, animationDuration); } return () => { setIsAnimating(false); - window.clearTimeout(timeOutId); }; }, [prevPeerId, currentPeerId, setIsAnimating, animationDuration]); @@ -373,6 +348,21 @@ function StorySlides({ }); }, [currentPeerId, getIsAnimating, renderingPeerId, slideSizes, isMobile]); + const handleTransitionEnd = useLastCallback((event: React.TransitionEvent) => { + // It is `target` that is needed here, not `currentTarget` + const target = event.target as HTMLDivElement | null; + + if (!target || !target.classList.contains(styles.activeSlide)) return; + + if (renderingPeerId !== currentPeerId) { + setRenderingPeerId(currentPeerId); + setRenderingStoryId(currentStoryId); + } else if (currentStoryId !== renderingStoryId) { + setRenderingStoryId(currentStoryId); + } + setIsAnimating(false); + }); + if (isMobile) { return (
@@ -461,6 +451,7 @@ function StorySlides({ className={styles.wrapper} ref={containerRef} style={`--story-viewer-scale: ${slideSizes.scale}`} + onTransitionEnd={handleTransitionEnd} >
{renderingPeerIds.length > 1 && ( @@ -480,15 +471,14 @@ function StorySlides({ export default memo(withGlobal((global): StateProps => { const { storyViewer: { - peerId: currentPeerId, storyId: currentStoryId, isSinglePeer, isSingleStory, isPrivate, isArchive, + peerId: currentPeerId, storyId: currentStoryId, isSinglePeer, isSingleStory, isPrivate, isArchive, storyList, }, } = selectTabState(global); - const { byPeerId, orderedPeerIds: { archived, active } } = global.stories; - const peer = currentPeerId ? selectPeer(global, currentPeerId) : undefined; + const { byPeerId, orderedPeerIds: { active } } = global.stories; return { byPeerId, - peerIds: peer?.areStoriesHidden ? archived : active, + peerIds: storyList?.peerIds ?? active, currentPeerId, currentStoryId, isSinglePeer, diff --git a/src/components/story/StoryViewer.module.scss b/src/components/story/StoryViewer.module.scss index 1e3830b2b..e307026c8 100644 --- a/src/components/story/StoryViewer.module.scss +++ b/src/components/story/StoryViewer.module.scss @@ -89,6 +89,7 @@ } .slideAnimation { + /* Slide switching is made using the `onTransitionEnd` event, so don't remove the animation */ transition: transform 350ms ease-in-out !important; } @@ -132,6 +133,14 @@ opacity: 1 !important; visibility: visible; } + + .contentInner { + width: 100%; + } + + .name { + width: calc(100% * var(--slide-translate-scale)); + } } .slide { diff --git a/src/global/actions/ui/stories.ts b/src/global/actions/ui/stories.ts index acdf6b8d7..e2d1fbf62 100644 --- a/src/global/actions/ui/stories.ts +++ b/src/global/actions/ui/stories.ts @@ -14,6 +14,7 @@ import { selectPeerFirstStoryId, selectPeerFirstUnreadStoryId, selectPeerStories, + selectStoryListForViewer, selectTabState, } from '../../selectors'; import { fetchChatByUsername } from '../api/chats'; @@ -43,6 +44,9 @@ addActionHandler('openStoryViewer', async (global, actions, payload): Promise( @@ -56,3 +57,86 @@ export function selectPeerFirstStoryId( ) { return selectPeerStories(global, peerId)?.orderedIds?.[0]; } + +export function selectStoryListForViewer( + global: T, + peerId: string, + storyId?: number, + isSingleStory?: boolean, + isSinglePeer?: boolean, + isPrivate?: boolean, + isArchive?: boolean, +): { + peerIds: string[]; + storyIdsByPeerId: Record; + } | undefined { + const currentStoryId = storyId + || selectPeerFirstUnreadStoryId(global, peerId) + || selectPeerFirstStoryId(global, peerId); + if (!currentStoryId) { + return undefined; + } + + if (isSingleStory) { + return { + peerIds: [peerId], + storyIdsByPeerId: { [peerId]: [currentStoryId] }, + }; + } + + const peer = selectPeer(global, peerId); + const story = selectPeerStory(global, peerId, currentStoryId); + if (!peer || !story) { + return undefined; + } + + const isUnread = (global.stories.byPeerId[peerId].lastReadId || 0) < story.id; + + if (isSinglePeer) { + const storyIds = getPeerStoryIdsForViewer(global, peerId, isUnread, isArchive, isPrivate); + + return storyIds?.length + ? { peerIds: [peerId], storyIdsByPeerId: { [peerId]: storyIds } } + : undefined; + } + + const { orderedPeerIds: { active, archived } } = global.stories; + const orderedPeerIds = (peer.areStoriesHidden ? archived : active) ?? []; + const peerIds: string[] = []; + const storyIdsByPeerId: Record = {}; + + for (const currentPeerId of orderedPeerIds) { + const storyIds = getPeerStoryIdsForViewer(global, currentPeerId, isUnread, isArchive, isPrivate); + if (storyIds?.length) { + peerIds.push(currentPeerId); + storyIdsByPeerId[currentPeerId] = storyIds; + } + } + + return peerIds.length ? { peerIds, storyIdsByPeerId } : undefined; +} + +function getPeerStoryIdsForViewer( + global: T, + peerId: string, + isUnread?: boolean, + isArchive?: boolean, + isPrivate?: boolean, +): number[] | undefined { + const peerStories = selectPeerStories(global, peerId); + const storySourceProp = isArchive ? 'archiveIds' : isPrivate ? 'pinnedIds' : 'orderedIds'; + const storyIds = peerStories?.[storySourceProp]; + + if (!peerStories || !storyIds?.length) { + return undefined; + } + + if (!peerStories.lastReadId || !isUnread) { + return storyIds.slice(); + } + + const lastReadIndex = storyIds.indexOf(peerStories.lastReadId); + return (storyIds.length > lastReadIndex + 1) + ? storyIds.slice(lastReadIndex + 1) + : undefined; +} diff --git a/src/global/types.ts b/src/global/types.ts index 6090c6758..81f0677d6 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -383,6 +383,11 @@ export type TabState = { isLoading?: boolean; }; origin?: StoryViewerOrigin; + // Copy of story list for current view session + storyList?: { + peerIds: string[]; + storyIdsByPeerId: Record; + }; }; mediaViewer: {