UI Rendering: Fix flicks (#6352)

This commit is contained in:
Alexander Zinchuk 2025-10-15 19:57:15 +02:00
parent ff0d9edc73
commit 89e37c280b
14 changed files with 153 additions and 41 deletions

View File

@ -97,6 +97,7 @@ type OwnProps = {
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
onDragEnter?: (chatId: string) => void; onDragEnter?: (chatId: string) => void;
withTags?: boolean; withTags?: boolean;
onReorderAnimationEnd?: NoneToVoidFunction;
}; };
type StateProps = { type StateProps = {
@ -172,6 +173,7 @@ const Chat: FC<OwnProps & StateProps> = ({
chatFoldersById, chatFoldersById,
areTagsEnabled, areTagsEnabled,
withTags, withTags,
onReorderAnimationEnd,
}) => { }) => {
const { const {
openChat, openChat,
@ -234,6 +236,7 @@ const Chat: FC<OwnProps & StateProps> = ({
orderDiff, orderDiff,
isSavedDialog, isSavedDialog,
isPreview, isPreview,
onReorderAnimationEnd,
topics, topics,
noForumTitle: shouldRenderTags, noForumTitle: shouldRenderTags,
}); });

View File

@ -105,7 +105,7 @@ const ChatList: FC<OwnProps> = ({
? archiveSettings?.isMinimized ? ARCHIVE_MINIMIZED_HEIGHT : CHAT_HEIGHT_PX : 0; ? archiveSettings?.isMinimized ? ARCHIVE_MINIMIZED_HEIGHT : CHAT_HEIGHT_PX : 0;
const frozenNotificationHeight = shouldShowFrozenAccountNotification ? 68 : 0; const frozenNotificationHeight = shouldShowFrozenAccountNotification ? 68 : 0;
const { orderDiffById, getAnimationType } = useOrderDiff(orderedIds); const { orderDiffById, getAnimationType, onReorderAnimationEnd: onReorderAnimationEnd } = useOrderDiff(orderedIds);
const [viewportIds, getMore] = useInfiniteScroll(undefined, orderedIds, undefined, CHAT_LIST_SLICE); const [viewportIds, getMore] = useInfiniteScroll(undefined, orderedIds, undefined, CHAT_LIST_SLICE);
@ -242,6 +242,7 @@ const ChatList: FC<OwnProps> = ({
isSavedDialog={isSaved} isSavedDialog={isSaved}
animationType={getAnimationType(id)} animationType={getAnimationType(id)}
orderDiff={orderDiffById[id]} orderDiff={orderDiffById[id]}
onReorderAnimationEnd={onReorderAnimationEnd}
offsetTop={offsetTop} offsetTop={offsetTop}
observeIntersection={observe} observeIntersection={observe}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}

View File

@ -130,7 +130,7 @@ const ForumPanel: FC<OwnProps & StateProps> = ({
: []; : [];
}, [topicsInfo]); }, [topicsInfo]);
const { orderDiffById, getAnimationType } = useOrderDiff(orderedIds, chat?.id); const { orderDiffById, getAnimationType, onReorderAnimationEnd } = useOrderDiff(orderedIds, chat?.id);
const [viewportIds, getMore] = useInfiniteScroll(() => { const [viewportIds, getMore] = useInfiniteScroll(() => {
if (!chat) return; if (!chat) return;
@ -207,6 +207,7 @@ const ForumPanel: FC<OwnProps & StateProps> = ({
observeIntersection={observe} observeIntersection={observe}
animationType={getAnimationType(id)} animationType={getAnimationType(id)}
orderDiff={orderDiffById[id]} orderDiff={orderDiffById[id]}
onReorderAnimationEnd={onReorderAnimationEnd}
/> />
)); ));
} }

View File

@ -57,6 +57,7 @@ type OwnProps = {
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
orderDiff: number; orderDiff: number;
animationType: ChatAnimationTypes; animationType: ChatAnimationTypes;
onReorderAnimationEnd?: NoneToVoidFunction;
}; };
type StateProps = { type StateProps = {
@ -96,6 +97,7 @@ const Topic: FC<OwnProps & StateProps> = ({
draft, draft,
wasTopicOpened, wasTopicOpened,
topics, topics,
onReorderAnimationEnd,
}) => { }) => {
const { const {
openThread, openThread,
@ -152,6 +154,7 @@ const Topic: FC<OwnProps & StateProps> = ({
animationType, animationType,
withInterfaceAnimations, withInterfaceAnimations,
orderDiff, orderDiff,
onReorderAnimationEnd,
}); });
const handleOpenTopic = useLastCallback((e: React.MouseEvent) => { const handleOpenTopic = useLastCallback((e: React.MouseEvent) => {

View File

@ -8,7 +8,7 @@ import type {
} from '../../../../api/types'; } from '../../../../api/types';
import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../../hooks/useIntersectionObserver';
import { ANIMATION_END_DELAY, CHAT_HEIGHT_PX } from '../../../../config'; import { CHAT_HEIGHT_PX } from '../../../../config';
import { requestMutation } from '../../../../lib/fasterdom/fasterdom'; import { requestMutation } from '../../../../lib/fasterdom/fasterdom';
import { import {
getMessageIsSpoiler, getMessageIsSpoiler,
@ -17,6 +17,7 @@ import {
getMessageVideo, getMessageVideo,
} from '../../../../global/helpers'; } from '../../../../global/helpers';
import { getMessageSenderName } from '../../../../global/helpers/peers'; import { getMessageSenderName } from '../../../../global/helpers/peers';
import { waitStartingTransitionsEnd } from '../../../../util/animations/waitTransitionEnd';
import buildClassName from '../../../../util/buildClassName'; import buildClassName from '../../../../util/buildClassName';
import renderText from '../../../common/helpers/renderText'; import renderText from '../../../common/helpers/renderText';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
@ -33,8 +34,6 @@ import Icon from '../../../common/icons/Icon';
import MessageSummary from '../../../common/MessageSummary'; import MessageSummary from '../../../common/MessageSummary';
import TypingStatus from '../../../common/TypingStatus'; import TypingStatus from '../../../common/TypingStatus';
const ANIMATION_DURATION = 200;
export default function useChatListEntry({ export default function useChatListEntry({
chat, chat,
topics, topics,
@ -53,6 +52,7 @@ export default function useChatListEntry({
isSavedDialog, isSavedDialog,
isPreview, isPreview,
noForumTitle, noForumTitle,
onReorderAnimationEnd,
}: { }: {
chat?: ApiChat; chat?: ApiChat;
topics?: Record<number, ApiTopic>; topics?: Record<number, ApiTopic>;
@ -72,6 +72,7 @@ export default function useChatListEntry({
orderDiff: number; orderDiff: number;
withInterfaceAnimations?: boolean; withInterfaceAnimations?: boolean;
noForumTitle?: boolean; noForumTitle?: boolean;
onReorderAnimationEnd?: NoneToVoidFunction;
}) { }) {
const lang = useLang(); const lang = useLang();
const ref = useRef<HTMLDivElement>(); const ref = useRef<HTMLDivElement>();
@ -171,6 +172,18 @@ export default function useChatListEntry({
return; return;
} }
let isCancelled = false;
const notifyAnimationEnd = () => {
if (isCancelled) return;
requestMutation(() => {
element.classList.remove('animate-opacity', 'animate-transform');
element.style.opacity = '';
element.style.transform = '';
});
onReorderAnimationEnd?.();
};
// TODO Refactor animation: create `useListAnimation` that owns `orderDiff` and `animationType` // TODO Refactor animation: create `useListAnimation` that owns `orderDiff` and `animationType`
if (animationType === ChatAnimationTypes.Opacity) { if (animationType === ChatAnimationTypes.Opacity) {
element.style.opacity = '0'; element.style.opacity = '0';
@ -178,6 +191,8 @@ export default function useChatListEntry({
requestMutation(() => { requestMutation(() => {
element.classList.add('animate-opacity'); element.classList.add('animate-opacity');
element.style.opacity = '1'; element.style.opacity = '1';
waitStartingTransitionsEnd(element).then(notifyAnimationEnd);
}); });
} else if (animationType === ChatAnimationTypes.Move) { } else if (animationType === ChatAnimationTypes.Move) {
element.style.transform = `translate3d(0, ${-orderDiff * CHAT_HEIGHT_PX}px, 0)`; element.style.transform = `translate3d(0, ${-orderDiff * CHAT_HEIGHT_PX}px, 0)`;
@ -185,19 +200,17 @@ export default function useChatListEntry({
requestMutation(() => { requestMutation(() => {
element.classList.add('animate-transform'); element.classList.add('animate-transform');
element.style.transform = ''; element.style.transform = '';
waitStartingTransitionsEnd(element).then(notifyAnimationEnd);
}); });
} else { } else {
return; return;
} }
setTimeout(() => { return () => {
requestMutation(() => { isCancelled = true;
element.classList.remove('animate-opacity', 'animate-transform'); };
element.style.opacity = ''; }, [withInterfaceAnimations, orderDiff, animationType, onReorderAnimationEnd]);
element.style.transform = '';
});
}, ANIMATION_DURATION + ANIMATION_END_DELAY);
}, [withInterfaceAnimations, orderDiff, animationType]);
return { return {
renderSubtitle, renderSubtitle,

View File

@ -1,9 +1,14 @@
import { useMemo } from '../../../../lib/teact/teact'; import { useMemo, useRef } from '../../../../lib/teact/teact';
import { mapValues } from '../../../../util/iteratees'; import { mapValues } from '../../../../util/iteratees';
import { useChatAnimationType } from './useChatAnimationType'; import { useChatAnimationType } from './useChatAnimationType';
import useForceUpdate from '../../../../hooks/useForceUpdate';
import useLastCallback from '../../../../hooks/useLastCallback';
import usePreviousDeprecated from '../../../../hooks/usePreviousDeprecated'; import usePreviousDeprecated from '../../../../hooks/usePreviousDeprecated';
import useSyncEffect from '../../../../hooks/useSyncEffect';
const EMPTY_ORDER_DIFF = {};
export default function useOrderDiff(orderedIds: (string | number)[] | undefined, key?: string) { export default function useOrderDiff(orderedIds: (string | number)[] | undefined, key?: string) {
const orderById = useMemo(() => { const orderById = useMemo(() => {
@ -20,20 +25,35 @@ export default function useOrderDiff(orderedIds: (string | number)[] | undefined
const prevOrderById = usePreviousDeprecated(orderById); const prevOrderById = usePreviousDeprecated(orderById);
const prevChatId = usePreviousDeprecated(key); const prevChatId = usePreviousDeprecated(key);
const orderDiffById = useMemo(() => { const orderDiffByIdRef = useRef<Record<string | number, number>>(EMPTY_ORDER_DIFF);
const forceUpdate = useForceUpdate();
const onReorderAnimationEnd = useLastCallback(() => {
if (orderDiffByIdRef.current === EMPTY_ORDER_DIFF) return;
orderDiffByIdRef.current = EMPTY_ORDER_DIFF;
forceUpdate();
});
useSyncEffect(() => {
if (!orderById || !prevOrderById || key !== prevChatId) { if (!orderById || !prevOrderById || key !== prevChatId) {
return {}; orderDiffByIdRef.current = EMPTY_ORDER_DIFF;
return;
} }
return mapValues(orderById, (order, id) => { const diff = mapValues(orderById, (order, id) => {
return prevOrderById[id] !== undefined ? order - prevOrderById[id] : -Infinity; return prevOrderById[id] !== undefined ? order - prevOrderById[id] : -Infinity;
}); });
const hasChanges = Object.values(diff).some((value) => value !== 0);
orderDiffByIdRef.current = hasChanges ? diff : EMPTY_ORDER_DIFF;
}, [key, orderById, prevChatId, prevOrderById]); }, [key, orderById, prevChatId, prevOrderById]);
const getAnimationType = useChatAnimationType(orderDiffById); const getAnimationType = useChatAnimationType(orderDiffByIdRef.current);
return { return {
orderDiffById, orderDiffById: orderDiffByIdRef.current,
getAnimationType, getAnimationType,
onReorderAnimationEnd,
}; };
} }

View File

@ -23,7 +23,7 @@
background: var(--background-color); background: var(--background-color);
transition: background-color 0.15s, color 0.15s; transition: background-color 0.15s, color 0.15s, opacity 150ms, backdrop-filter 150ms, filter 150ms;
.label { .label {
overflow: hidden; overflow: hidden;
@ -70,8 +70,6 @@
opacity: 0; opacity: 0;
background-color: var(--pattern-color); background-color: var(--pattern-color);
transition: opacity 150ms, backdrop-filter 150ms, filter 150ms;
&::after { &::after {
content: attr(data-cnt); content: attr(data-cnt);
@ -96,6 +94,10 @@
} }
} }
&.disabled {
background-color: var(--pattern-color);
}
.Message:hover &, &.loading { .Message:hover &, &.loading {
opacity: 1; opacity: 1;
} }
@ -139,6 +141,10 @@
filter: none; filter: none;
backdrop-filter: none; backdrop-filter: none;
} }
&.disabled {
background-color: rgba(255, 255, 255, 0.08);
}
} }
&:hover { &:hover {
@ -249,6 +255,8 @@
&.disabled { &.disabled {
pointer-events: none; pointer-events: none;
cursor: var(--custom-cursor, default); cursor: var(--custom-cursor, default);
opacity: 0.65;
background-color: var(--hover-color);
} }
} }

View File

@ -21,7 +21,7 @@ import Spinner from '../../ui/Spinner';
import './CommentButton.scss'; import './CommentButton.scss';
type OwnProps = { type OwnProps = {
threadInfo: ApiCommentsInfo; threadInfo?: ApiCommentsInfo;
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
isCustomShape?: boolean; isCustomShape?: boolean;
@ -45,10 +45,15 @@ const CommentButton: FC<OwnProps> = ({
const lang = useLang(); const lang = useLang();
const { const {
originMessageId, chatId, messagesCount, lastMessageId, lastReadInboxMessageId, recentReplierIds, originChannelId, originMessageId, chatId, messagesCount, lastMessageId, lastReadInboxMessageId, recentReplierIds, originChannelId,
} = threadInfo; } = threadInfo || {};
const handleClick = useLastCallback(() => { const handleClick = useLastCallback(() => {
const global = getGlobal(); const global = getGlobal();
if (!originMessageId || !originChannelId) {
return;
}
if (selectIsCurrentUserFrozen(global)) { if (selectIsCurrentUserFrozen(global)) {
openFrozenAccountModal(); openFrozenAccountModal();
return; return;
@ -71,10 +76,6 @@ const CommentButton: FC<OwnProps> = ({
}).filter(Boolean); }).filter(Boolean);
}, [recentReplierIds]); }, [recentReplierIds]);
if (messagesCount === undefined) {
return undefined;
}
function renderRecentRepliers() { function renderRecentRepliers() {
return ( return (
Boolean(recentRepliers?.length) && ( Boolean(recentRepliers?.length) && (
@ -102,7 +103,7 @@ const CommentButton: FC<OwnProps> = ({
return ( return (
<div <div
data-cnt={formatIntegerCompact(lang, messagesCount)} data-cnt={formatIntegerCompact(lang, messagesCount || 0)}
className={buildClassName( className={buildClassName(
'CommentButton', 'CommentButton',
hasUnread && 'has-unread', hasUnread && 'has-unread',

View File

@ -787,11 +787,14 @@ const Message = ({
const phoneCall = action?.type === 'phoneCall' ? action : undefined; const phoneCall = action?.type === 'phoneCall' ? action : undefined;
const isMediaWithCommentButton = (repliesThreadInfo || (hasLinkedChat && isChannel && isLocal)) const commentsThreadInfo = repliesThreadInfo?.isCommentsInfo ? repliesThreadInfo : undefined;
const isLocalWithCommentButton = hasLinkedChat && isChannel && isLocal;
const isMediaWithCommentButton = (commentsThreadInfo || isLocalWithCommentButton)
&& !isInDocumentGroupNotLast && !isInDocumentGroupNotLast
&& messageListType === 'thread' && messageListType === 'thread'
&& !noComments; && !noComments;
const withCommentButton = repliesThreadInfo?.isCommentsInfo const withCommentButton = (commentsThreadInfo || isLocalWithCommentButton)
&& !isInDocumentGroupNotLast && messageListType === 'thread' && !isInDocumentGroupNotLast && messageListType === 'thread'
&& !noComments; && !noComments;
const withQuickReactionButton = !isTouchScreen && !phoneCall && !isInSelectMode && defaultReaction const withQuickReactionButton = !isTouchScreen && !phoneCall && !isInSelectMode && defaultReaction
@ -1726,8 +1729,8 @@ const Message = ({
> >
{withCommentButton && isCustomShape && ( {withCommentButton && isCustomShape && (
<CommentButton <CommentButton
threadInfo={repliesThreadInfo} threadInfo={commentsThreadInfo}
disabled={noComments} disabled={noComments || !commentsThreadInfo}
isLoading={isLoadingComments} isLoading={isLoadingComments}
isCustomShape isCustomShape
asActionButton asActionButton
@ -1761,8 +1764,8 @@ const Message = ({
)} )}
{withCommentButton && !isCustomShape && ( {withCommentButton && !isCustomShape && (
<CommentButton <CommentButton
threadInfo={repliesThreadInfo} threadInfo={commentsThreadInfo}
disabled={noComments} disabled={noComments || !commentsThreadInfo}
isLoading={isLoadingComments} isLoading={isLoadingComments}
/> />
)} )}

View File

@ -179,6 +179,14 @@ const Photo = <T,>({
isOpen: !fullMediaData && !isLoadAllowed, isOpen: !fullMediaData && !isLoadAllowed,
withShouldRender: true, withShouldRender: true,
}); });
const {
ref: transferProgressRef,
shouldRender: shouldRenderTransferProgress,
} = useShowTransition({
isOpen: isTransferring,
noMountTransition: wasLoadDisabled,
withShouldRender: true,
});
const handleClick = useLastCallback((e: React.MouseEvent<HTMLElement>) => { const handleClick = useLastCallback((e: React.MouseEvent<HTMLElement>) => {
if (isUploading) { if (isUploading) {
@ -291,10 +299,9 @@ const Photo = <T,>({
className="media-spoiler" className="media-spoiler"
isNsfw={isMediaNsfw} isNsfw={isMediaNsfw}
/> />
{isTransferring && ( {shouldRenderTransferProgress && (
<span className="message-transfer-progress"> <span ref={transferProgressRef} className="message-transfer-progress">
{Math.round(transferProgress * 100)} {`${Math.round(transferProgress * 100)}%`}
%
</span> </span>
)} )}
<SensitiveContentConfirmModal <SensitiveContentConfirmModal

View File

@ -188,6 +188,14 @@ const Video = <T,>({
} = useShowTransition({ } = useShowTransition({
isOpen: Boolean((isLoadAllowed || fullMediaData) && !isPlayAllowed && !shouldRenderSpinner), isOpen: Boolean((isLoadAllowed || fullMediaData) && !isPlayAllowed && !shouldRenderSpinner),
}); });
const {
ref: transferProgressRef,
shouldRender: shouldRenderTransferProgress,
} = useShowTransition({
isOpen: isTransferring && (!isUnsupported || isDownloading),
noMountTransition: wasLoadDisabled,
withShouldRender: true,
});
const [playProgress, setPlayProgress] = useState<number>(0); const [playProgress, setPlayProgress] = useState<number>(0);
const handleTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => { const handleTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => {
@ -324,8 +332,8 @@ const Video = <T,>({
{!isLoadAllowed && !fullMediaData && ( {!isLoadAllowed && !fullMediaData && (
<Icon name="download" /> <Icon name="download" />
)} )}
{isTransferring && (!isUnsupported || isDownloading) ? ( {shouldRenderTransferProgress ? (
<span className="message-transfer-progress"> <span ref={transferProgressRef} className="message-transfer-progress">
{(isUploading || isDownloading) ? `${Math.round(transferProgress * 100)}%` : '...'} {(isUploading || isDownloading) ? `${Math.round(transferProgress * 100)}%` : '...'}
</span> </span>
) : ( ) : (

View File

@ -1,5 +1,6 @@
import { addCallback } from '../../../lib/teact/teactn'; import { addCallback } from '../../../lib/teact/teactn';
import type { ApiThreadInfo } from '../../../api/types/messages';
import type { Thread, ThreadId } from '../../../types'; import type { Thread, ThreadId } from '../../../types';
import type { RequiredGlobalActions } from '../../index'; import type { RequiredGlobalActions } from '../../index';
import type { ActionReturnType, GlobalState } from '../../types'; import type { ActionReturnType, GlobalState } from '../../types';
@ -128,6 +129,19 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
selectChatMessage(global, global.currentUserId!, Number(messageId)) selectChatMessage(global, global.currentUserId!, Number(messageId))
)).filter(Boolean); )).filter(Boolean);
// Memoize thread infos for last messages
const lastMessagesThreadInfos: { chatId: string; messageId: number; threadInfo: ApiThreadInfo }[] = [];
lastMessages.forEach((message) => {
const threadInfo = selectThreadInfo(global, message.chatId, message.id);
if (threadInfo) {
lastMessagesThreadInfos.push({
chatId: message.chatId,
messageId: message.id,
threadInfo,
});
}
});
for (const { id: tabId } of Object.values(global.byTabId)) { for (const { id: tabId } of Object.values(global.byTabId)) {
global = getGlobal(); global = getGlobal();
const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global, tabId) || {}; const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global, tabId) || {};
@ -256,6 +270,14 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
}); });
}); });
// Restore thread infos
lastMessagesThreadInfos.forEach(({ chatId, messageId, threadInfo }) => {
const memoThreadInfo = selectThreadInfo(global, chatId, messageId);
if (!memoThreadInfo) {
global = updateThreadInfo(global, chatId, String(messageId), threadInfo);
}
});
// Restore last messages // Restore last messages
global = addMessages(global, lastMessages); global = addMessages(global, lastMessages);
global = addMessages(global, savedLastMessages); global = addMessages(global, savedLastMessages);

View File

@ -587,6 +587,9 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
} = update; } = update;
global = updateThreadInfos(global, [threadInfo]); global = updateThreadInfos(global, [threadInfo]);
setGlobal(global);
global = getGlobal();
const { chatId, threadId } = threadInfo; const { chatId, threadId } = threadInfo;
if (!chatId || !threadId) return; if (!chatId || !threadId) return;

View File

@ -0,0 +1,19 @@
import { fastRaf } from '../schedulers';
export type Scheduler = typeof requestAnimationFrame | typeof fastRaf;
export function waitCurrentTransitionsEnd(element: HTMLElement) {
return Promise.allSettled(
element.getAnimations({ subtree: true })
.filter((a) => a instanceof CSSTransition)
.map((a) => a.finished),
);
}
export function waitStartingTransitionsEnd(element: HTMLElement, scheduler: Scheduler = fastRaf) {
return new Promise<void>((resolve) => {
scheduler(() => {
waitCurrentTransitionsEnd(element).then(() => resolve());
});
});
}