Stories: Fix flickering while switching stories (#4149)

This commit is contained in:
Alexander Zinchuk 2024-01-18 18:18:36 +01:00
parent 5b821bd350
commit b94999c204
7 changed files with 153 additions and 58 deletions

View File

@ -15,11 +15,13 @@ import { MAIN_THREAD_ID } from '../../api/types';
import { EDITABLE_STORY_INPUT_CSS_SELECTOR, EDITABLE_STORY_INPUT_ID } from '../../config'; import { EDITABLE_STORY_INPUT_CSS_SELECTOR, EDITABLE_STORY_INPUT_ID } from '../../config';
import { getSenderTitle, isUserId } from '../../global/helpers'; import { getSenderTitle, isUserId } from '../../global/helpers';
import { import {
selectChat, selectIsCurrentUserPremium, selectChat,
selectIsCurrentUserPremium,
selectPeer, selectPeer,
selectPeerStories, selectPeerStory, selectPeerStory,
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectTabState, selectUser, selectTabState,
selectUser,
} from '../../global/selectors'; } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import captureKeyboardListeners from '../../util/captureKeyboardListeners'; import captureKeyboardListeners from '../../util/captureKeyboardListeners';
@ -864,8 +866,6 @@ function Story({
export default memo(withGlobal<OwnProps>((global, { export default memo(withGlobal<OwnProps>((global, {
peerId, peerId,
storyId, storyId,
isPrivateStories,
isArchivedStories,
isReportModalOpen, isReportModalOpen,
isDeleteModalOpen, isDeleteModalOpen,
}): StateProps => { }): StateProps => {
@ -879,6 +879,7 @@ export default memo(withGlobal<OwnProps>((global, {
viewModal, viewModal,
isPrivacyModalOpen, isPrivacyModalOpen,
isStealthModalOpen, isStealthModalOpen,
storyList,
}, },
forwardMessages: { storyId: forwardedStoryId }, forwardMessages: { storyId: forwardedStoryId },
premiumModal, premiumModal,
@ -886,7 +887,6 @@ export default memo(withGlobal<OwnProps>((global, {
mapModal, mapModal,
} = tabState; } = tabState;
const { isOpen: isPremiumModalOpen } = premiumModal || {}; const { isOpen: isPremiumModalOpen } = premiumModal || {};
const { orderedIds, pinnedIds, archiveIds } = selectPeerStories(global, peerId) || {};
const story = selectPeerStory(global, peerId, storyId); const story = selectPeerStory(global, peerId, storyId);
const shouldForcePause = Boolean( const shouldForcePause = Boolean(
viewModal || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen viewModal || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen
@ -904,7 +904,7 @@ export default memo(withGlobal<OwnProps>((global, {
peer: (user || chat)!, peer: (user || chat)!,
forwardSender, forwardSender,
story, story,
orderedIds: isArchivedStories ? archiveIds : (isPrivateStories ? pinnedIds : orderedIds), orderedIds: storyList?.storyIdsByPeerId[peerId],
isMuted, isMuted,
isCurrentUserPremium: selectIsCurrentUserPremium(global), isCurrentUserPremium: selectIsCurrentUserPremium(global),
shouldForcePause, shouldForcePause,

View File

@ -26,10 +26,11 @@ interface OwnProps {
interface StateProps { interface StateProps {
lastViewedId?: number; lastViewedId?: number;
origin?: StoryViewerOrigin; origin?: StoryViewerOrigin;
storyIdsForViewer?: number[];
} }
function StoryPreview({ function StoryPreview({
peer, peerStories, lastViewedId, origin, peer, peerStories, lastViewedId, storyIdsForViewer, origin,
}: OwnProps & StateProps) { }: OwnProps & StateProps) {
const { openStoryViewer, loadPeerSkippedStories } = getActions(); const { openStoryViewer, loadPeerSkippedStories } = getActions();
const lang = useLang(); const lang = useLang();
@ -43,11 +44,13 @@ function StoryPreview({
orderedIds, lastReadId, byId, orderedIds, lastReadId, byId,
} = peerStories; } = peerStories;
const hasUnreadStories = orderedIds[orderedIds.length - 1] !== lastReadId; const hasUnreadStories = orderedIds[orderedIds.length - 1] !== lastReadId;
const previewIndexId = lastViewedId ?? (hasUnreadStories ? (lastReadId ?? -1) : -1); const previewIndexId = lastViewedId && storyIdsForViewer?.includes(lastViewedId)
const resultId = byId[previewIndexId]?.id || orderedIds[0]; ? lastViewedId ?? (hasUnreadStories ? (lastReadId ?? -1) : -1)
: -1;
const resultId = byId[previewIndexId]?.id || storyIdsForViewer?.[0] || orderedIds[0];
return byId[resultId]; return byId[resultId];
}, [lastViewedId, peerStories]); }, [lastViewedId, peerStories, storyIdsForViewer]);
const isLoaded = story && 'content' in story; const isLoaded = story && 'content' in story;
@ -95,11 +98,13 @@ export default memo(withGlobal<OwnProps>((global, { peer }): StateProps => {
storyViewer: { storyViewer: {
lastViewedByPeerIds, lastViewedByPeerIds,
origin, origin,
storyList,
}, },
} = selectTabState(global); } = selectTabState(global);
return { return {
lastViewedId: peer?.id ? lastViewedByPeerIds?.[peer.id] : undefined, lastViewedId: peer?.id ? lastViewedByPeerIds?.[peer.id] : undefined,
origin, origin,
storyIdsForViewer: peer?.id ? storyList?.storyIdsByPeerId[peer.id] : undefined,
}; };
})(StoryPreview)); })(StoryPreview));

View File

@ -158,45 +158,20 @@ function StorySlides({
return renderingPeerIds.indexOf(currentPeerId); return renderingPeerIds.indexOf(currentPeerId);
}, [currentPeerId, renderingPeerIds]); }, [currentPeerId, renderingPeerIds]);
// Handling the flipping of stories from a current user
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => { if (renderingPeerId === currentPeerId && currentStoryId !== renderingStoryId) {
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) {
setRenderingStoryId(currentStoryId); setRenderingStoryId(currentStoryId);
} }
}, [currentPeerId, currentStoryId, renderingPeerId, renderingStoryId]);
return () => {
window.clearTimeout(timeOutId);
};
}, [renderingPeerId, currentStoryId, currentPeerId, renderingStoryId, animationDuration]);
useEffect(() => { useEffect(() => {
let timeOutId: number | undefined;
if (prevPeerId && prevPeerId !== currentPeerId) { if (prevPeerId && prevPeerId !== currentPeerId) {
setIsAnimating(true); setIsAnimating(true);
timeOutId = window.setTimeout(() => {
setIsAnimating(false);
}, animationDuration);
} }
return () => { return () => {
setIsAnimating(false); setIsAnimating(false);
window.clearTimeout(timeOutId);
}; };
}, [prevPeerId, currentPeerId, setIsAnimating, animationDuration]); }, [prevPeerId, currentPeerId, setIsAnimating, animationDuration]);
@ -373,6 +348,21 @@ function StorySlides({
}); });
}, [currentPeerId, getIsAnimating, renderingPeerId, slideSizes, isMobile]); }, [currentPeerId, getIsAnimating, renderingPeerId, slideSizes, isMobile]);
const handleTransitionEnd = useLastCallback((event: React.TransitionEvent<HTMLDivElement>) => {
// 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) { if (isMobile) {
return ( return (
<div className={styles.wrapper} ref={containerRef}> <div className={styles.wrapper} ref={containerRef}>
@ -461,6 +451,7 @@ function StorySlides({
className={styles.wrapper} className={styles.wrapper}
ref={containerRef} ref={containerRef}
style={`--story-viewer-scale: ${slideSizes.scale}`} style={`--story-viewer-scale: ${slideSizes.scale}`}
onTransitionEnd={handleTransitionEnd}
> >
<div className={styles.fullSize} onClick={onClose} /> <div className={styles.fullSize} onClick={onClose} />
{renderingPeerIds.length > 1 && ( {renderingPeerIds.length > 1 && (
@ -480,15 +471,14 @@ function StorySlides({
export default memo(withGlobal<OwnProps>((global): StateProps => { export default memo(withGlobal<OwnProps>((global): StateProps => {
const { const {
storyViewer: { storyViewer: {
peerId: currentPeerId, storyId: currentStoryId, isSinglePeer, isSingleStory, isPrivate, isArchive, peerId: currentPeerId, storyId: currentStoryId, isSinglePeer, isSingleStory, isPrivate, isArchive, storyList,
}, },
} = selectTabState(global); } = selectTabState(global);
const { byPeerId, orderedPeerIds: { archived, active } } = global.stories; const { byPeerId, orderedPeerIds: { active } } = global.stories;
const peer = currentPeerId ? selectPeer(global, currentPeerId) : undefined;
return { return {
byPeerId, byPeerId,
peerIds: peer?.areStoriesHidden ? archived : active, peerIds: storyList?.peerIds ?? active,
currentPeerId, currentPeerId,
currentStoryId, currentStoryId,
isSinglePeer, isSinglePeer,

View File

@ -89,6 +89,7 @@
} }
.slideAnimation { .slideAnimation {
/* Slide switching is made using the `onTransitionEnd` event, so don't remove the animation */
transition: transform 350ms ease-in-out !important; transition: transform 350ms ease-in-out !important;
} }
@ -132,6 +133,14 @@
opacity: 1 !important; opacity: 1 !important;
visibility: visible; visibility: visible;
} }
.contentInner {
width: 100%;
}
.name {
width: calc(100% * var(--slide-translate-scale));
}
} }
.slide { .slide {

View File

@ -14,6 +14,7 @@ import {
selectPeerFirstStoryId, selectPeerFirstStoryId,
selectPeerFirstUnreadStoryId, selectPeerFirstUnreadStoryId,
selectPeerStories, selectPeerStories,
selectStoryListForViewer,
selectTabState, selectTabState,
} from '../../selectors'; } from '../../selectors';
import { fetchChatByUsername } from '../api/chats'; import { fetchChatByUsername } from '../api/chats';
@ -43,6 +44,9 @@ addActionHandler('openStoryViewer', async (global, actions, payload): Promise<vo
global = addStoriesForPeer(global, peerId, result.stories); global = addStoriesForPeer(global, peerId, result.stories);
} }
const storyList = tabState.storyViewer.storyList
|| selectStoryListForViewer(global, peerId, storyId, isSingleStory, isSinglePeer, isPrivate, isArchive);
global = updateTabState(global, { global = updateTabState(global, {
storyViewer: { storyViewer: {
...tabState.storyViewer, ...tabState.storyViewer,
@ -54,6 +58,7 @@ addActionHandler('openStoryViewer', async (global, actions, payload): Promise<vo
isSingleStory, isSingleStory,
viewModal: undefined, viewModal: undefined,
origin, origin,
storyList,
}, },
}, tabId); }, tabId);
setGlobal(global); setGlobal(global);
@ -94,6 +99,7 @@ addActionHandler('closeStoryViewer', (global, actions, payload): ActionReturnTyp
isRibbonShown, isRibbonShown,
isArchivedRibbonShown, isArchivedRibbonShown,
lastViewedByPeerIds: undefined, lastViewedByPeerIds: undefined,
storyList: undefined,
}, },
}, tabId); }, tabId);
@ -134,15 +140,14 @@ addActionHandler('openPreviousStory', (global, actions, payload): ActionReturnTy
const { tabId = getCurrentTabId() } = payload || {}; const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId); const tabState = selectTabState(global, tabId);
const { const {
peerId, storyId, isSinglePeer, isSingleStory, isPrivate, isArchive, peerId, storyId, isSinglePeer, isSingleStory, storyList,
} = tabState.storyViewer; } = tabState.storyViewer;
if (isSingleStory) { if (isSingleStory || !storyList) {
actions.closeStoryViewer({ tabId }); actions.closeStoryViewer({ tabId });
return undefined; return undefined;
} }
const { orderedPeerIds: { active, archived } } = global.stories;
if (!peerId || !storyId) { if (!peerId || !storyId) {
return undefined; return undefined;
} }
@ -153,9 +158,8 @@ addActionHandler('openPreviousStory', (global, actions, payload): ActionReturnTy
return undefined; return undefined;
} }
const orderedPeerIds = (peer.areStoriesHidden ? archived : active) ?? []; const { peerIds: orderedPeerIds, storyIdsByPeerId } = storyList;
const storySourceProp = isArchive ? 'archiveIds' : isPrivate ? 'pinnedIds' : 'orderedIds'; const peerStoryIds = storyIdsByPeerId[peerId] ?? [];
const peerStoryIds = peerStories[storySourceProp] ?? [];
const currentStoryIndex = peerStoryIds.indexOf(storyId); const currentStoryIndex = peerStoryIds.indexOf(storyId);
let previousStoryIndex: number; let previousStoryIndex: number;
let previousPeerId: string; let previousPeerId: string;
@ -170,10 +174,10 @@ addActionHandler('openPreviousStory', (global, actions, payload): ActionReturnTy
} }
previousPeerId = orderedPeerIds[previousPeerIdIndex]; previousPeerId = orderedPeerIds[previousPeerIdIndex];
previousStoryIndex = (selectPeerStories(global, previousPeerId)?.orderedIds.length || 1) - 1; previousStoryIndex = (storyIdsByPeerId?.[previousPeerId]?.length || 1) - 1;
} }
const previousStoryId = selectPeerStories(global, previousPeerId)?.[storySourceProp]?.[previousStoryIndex]; const previousStoryId = storyIdsByPeerId?.[previousPeerId]?.[previousStoryIndex];
if (!previousStoryId) { if (!previousStoryId) {
return undefined; return undefined;
} }
@ -191,14 +195,13 @@ addActionHandler('openNextStory', (global, actions, payload): ActionReturnType =
const { tabId = getCurrentTabId() } = payload || {}; const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId); const tabState = selectTabState(global, tabId);
const { const {
peerId, storyId, isSinglePeer, isSingleStory, isPrivate, isArchive, peerId, storyId, isSinglePeer, isSingleStory, storyList,
} = tabState.storyViewer; } = tabState.storyViewer;
if (isSingleStory) { if (isSingleStory || !storyList) {
actions.closeStoryViewer({ tabId }); actions.closeStoryViewer({ tabId });
return undefined; return undefined;
} }
const { orderedPeerIds: { active, archived } } = global.stories;
if (!peerId || !storyId) { if (!peerId || !storyId) {
return undefined; return undefined;
} }
@ -209,9 +212,8 @@ addActionHandler('openNextStory', (global, actions, payload): ActionReturnType =
return undefined; return undefined;
} }
const orderedPeerIds = (peer.areStoriesHidden ? archived : active) ?? []; const { peerIds: orderedPeerIds, storyIdsByPeerId } = storyList;
const storySourceProp = isArchive ? 'archiveIds' : isPrivate ? 'pinnedIds' : 'orderedIds'; const peerStoryIds = storyIdsByPeerId[peerId] ?? [];
const peerStoryIds = peerStories[storySourceProp] ?? [];
const currentStoryIndex = peerStoryIds.indexOf(storyId); const currentStoryIndex = peerStoryIds.indexOf(storyId);
let nextStoryIndex: number; let nextStoryIndex: number;
let nextPeerId: string; let nextPeerId: string;
@ -230,7 +232,7 @@ addActionHandler('openNextStory', (global, actions, payload): ActionReturnType =
nextStoryIndex = 0; nextStoryIndex = 0;
} }
const nextStoryId = selectPeerStories(global, nextPeerId)?.[storySourceProp]?.[nextStoryIndex]; const nextStoryId = storyIdsByPeerId?.[nextPeerId]?.[nextStoryIndex];
if (!nextStoryId) { if (!nextStoryId) {
return undefined; return undefined;
} }

View File

@ -2,6 +2,7 @@ import type { ApiPeerStories, ApiTypeStory } from '../../api/types';
import type { GlobalState, TabArgs } from '../types'; import type { GlobalState, TabArgs } from '../types';
import { getCurrentTabId } from '../../util/establishMultitabRole'; import { getCurrentTabId } from '../../util/establishMultitabRole';
import { selectPeer } from './chats';
import { selectTabState } from './tabs'; import { selectTabState } from './tabs';
export function selectCurrentViewedStory<T extends GlobalState>( export function selectCurrentViewedStory<T extends GlobalState>(
@ -56,3 +57,86 @@ export function selectPeerFirstStoryId<T extends GlobalState>(
) { ) {
return selectPeerStories(global, peerId)?.orderedIds?.[0]; return selectPeerStories(global, peerId)?.orderedIds?.[0];
} }
export function selectStoryListForViewer<T extends GlobalState>(
global: T,
peerId: string,
storyId?: number,
isSingleStory?: boolean,
isSinglePeer?: boolean,
isPrivate?: boolean,
isArchive?: boolean,
): {
peerIds: string[];
storyIdsByPeerId: Record<string, number[]>;
} | 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<string, number[]> = {};
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<T extends GlobalState>(
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;
}

View File

@ -383,6 +383,11 @@ export type TabState = {
isLoading?: boolean; isLoading?: boolean;
}; };
origin?: StoryViewerOrigin; origin?: StoryViewerOrigin;
// Copy of story list for current view session
storyList?: {
peerIds: string[];
storyIdsByPeerId: Record<string, number[]>;
};
}; };
mediaViewer: { mediaViewer: {