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

View File

@ -105,7 +105,7 @@ const ChatList: FC<OwnProps> = ({
? archiveSettings?.isMinimized ? ARCHIVE_MINIMIZED_HEIGHT : CHAT_HEIGHT_PX : 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);
@ -242,6 +242,7 @@ const ChatList: FC<OwnProps> = ({
isSavedDialog={isSaved}
animationType={getAnimationType(id)}
orderDiff={orderDiffById[id]}
onReorderAnimationEnd={onReorderAnimationEnd}
offsetTop={offsetTop}
observeIntersection={observe}
onDragEnter={handleDragEnter}

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import type {
} from '../../../../api/types';
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 {
getMessageIsSpoiler,
@ -17,6 +17,7 @@ import {
getMessageVideo,
} from '../../../../global/helpers';
import { getMessageSenderName } from '../../../../global/helpers/peers';
import { waitStartingTransitionsEnd } from '../../../../util/animations/waitTransitionEnd';
import buildClassName from '../../../../util/buildClassName';
import renderText from '../../../common/helpers/renderText';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
@ -33,8 +34,6 @@ import Icon from '../../../common/icons/Icon';
import MessageSummary from '../../../common/MessageSummary';
import TypingStatus from '../../../common/TypingStatus';
const ANIMATION_DURATION = 200;
export default function useChatListEntry({
chat,
topics,
@ -53,6 +52,7 @@ export default function useChatListEntry({
isSavedDialog,
isPreview,
noForumTitle,
onReorderAnimationEnd,
}: {
chat?: ApiChat;
topics?: Record<number, ApiTopic>;
@ -72,6 +72,7 @@ export default function useChatListEntry({
orderDiff: number;
withInterfaceAnimations?: boolean;
noForumTitle?: boolean;
onReorderAnimationEnd?: NoneToVoidFunction;
}) {
const lang = useLang();
const ref = useRef<HTMLDivElement>();
@ -171,6 +172,18 @@ export default function useChatListEntry({
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`
if (animationType === ChatAnimationTypes.Opacity) {
element.style.opacity = '0';
@ -178,6 +191,8 @@ export default function useChatListEntry({
requestMutation(() => {
element.classList.add('animate-opacity');
element.style.opacity = '1';
waitStartingTransitionsEnd(element).then(notifyAnimationEnd);
});
} else if (animationType === ChatAnimationTypes.Move) {
element.style.transform = `translate3d(0, ${-orderDiff * CHAT_HEIGHT_PX}px, 0)`;
@ -185,19 +200,17 @@ export default function useChatListEntry({
requestMutation(() => {
element.classList.add('animate-transform');
element.style.transform = '';
waitStartingTransitionsEnd(element).then(notifyAnimationEnd);
});
} else {
return;
}
setTimeout(() => {
requestMutation(() => {
element.classList.remove('animate-opacity', 'animate-transform');
element.style.opacity = '';
element.style.transform = '';
});
}, ANIMATION_DURATION + ANIMATION_END_DELAY);
}, [withInterfaceAnimations, orderDiff, animationType]);
return () => {
isCancelled = true;
};
}, [withInterfaceAnimations, orderDiff, animationType, onReorderAnimationEnd]);
return {
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 { useChatAnimationType } from './useChatAnimationType';
import useForceUpdate from '../../../../hooks/useForceUpdate';
import useLastCallback from '../../../../hooks/useLastCallback';
import usePreviousDeprecated from '../../../../hooks/usePreviousDeprecated';
import useSyncEffect from '../../../../hooks/useSyncEffect';
const EMPTY_ORDER_DIFF = {};
export default function useOrderDiff(orderedIds: (string | number)[] | undefined, key?: string) {
const orderById = useMemo(() => {
@ -20,20 +25,35 @@ export default function useOrderDiff(orderedIds: (string | number)[] | undefined
const prevOrderById = usePreviousDeprecated(orderById);
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) {
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;
});
const hasChanges = Object.values(diff).some((value) => value !== 0);
orderDiffByIdRef.current = hasChanges ? diff : EMPTY_ORDER_DIFF;
}, [key, orderById, prevChatId, prevOrderById]);
const getAnimationType = useChatAnimationType(orderDiffById);
const getAnimationType = useChatAnimationType(orderDiffByIdRef.current);
return {
orderDiffById,
orderDiffById: orderDiffByIdRef.current,
getAnimationType,
onReorderAnimationEnd,
};
}

View File

@ -23,7 +23,7 @@
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 {
overflow: hidden;
@ -70,8 +70,6 @@
opacity: 0;
background-color: var(--pattern-color);
transition: opacity 150ms, backdrop-filter 150ms, filter 150ms;
&::after {
content: attr(data-cnt);
@ -96,6 +94,10 @@
}
}
&.disabled {
background-color: var(--pattern-color);
}
.Message:hover &, &.loading {
opacity: 1;
}
@ -139,6 +141,10 @@
filter: none;
backdrop-filter: none;
}
&.disabled {
background-color: rgba(255, 255, 255, 0.08);
}
}
&:hover {
@ -249,6 +255,8 @@
&.disabled {
pointer-events: none;
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';
type OwnProps = {
threadInfo: ApiCommentsInfo;
threadInfo?: ApiCommentsInfo;
disabled?: boolean;
isLoading?: boolean;
isCustomShape?: boolean;
@ -45,10 +45,15 @@ const CommentButton: FC<OwnProps> = ({
const lang = useLang();
const {
originMessageId, chatId, messagesCount, lastMessageId, lastReadInboxMessageId, recentReplierIds, originChannelId,
} = threadInfo;
} = threadInfo || {};
const handleClick = useLastCallback(() => {
const global = getGlobal();
if (!originMessageId || !originChannelId) {
return;
}
if (selectIsCurrentUserFrozen(global)) {
openFrozenAccountModal();
return;
@ -71,10 +76,6 @@ const CommentButton: FC<OwnProps> = ({
}).filter(Boolean);
}, [recentReplierIds]);
if (messagesCount === undefined) {
return undefined;
}
function renderRecentRepliers() {
return (
Boolean(recentRepliers?.length) && (
@ -102,7 +103,7 @@ const CommentButton: FC<OwnProps> = ({
return (
<div
data-cnt={formatIntegerCompact(lang, messagesCount)}
data-cnt={formatIntegerCompact(lang, messagesCount || 0)}
className={buildClassName(
'CommentButton',
hasUnread && 'has-unread',

View File

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

View File

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

View File

@ -188,6 +188,14 @@ const Video = <T,>({
} = useShowTransition({
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 handleTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => {
@ -324,8 +332,8 @@ const Video = <T,>({
{!isLoadAllowed && !fullMediaData && (
<Icon name="download" />
)}
{isTransferring && (!isUnsupported || isDownloading) ? (
<span className="message-transfer-progress">
{shouldRenderTransferProgress ? (
<span ref={transferProgressRef} className="message-transfer-progress">
{(isUploading || isDownloading) ? `${Math.round(transferProgress * 100)}%` : '...'}
</span>
) : (

View File

@ -1,5 +1,6 @@
import { addCallback } from '../../../lib/teact/teactn';
import type { ApiThreadInfo } from '../../../api/types/messages';
import type { Thread, ThreadId } from '../../../types';
import type { RequiredGlobalActions } from '../../index';
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))
)).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)) {
global = getGlobal();
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
global = addMessages(global, lastMessages);
global = addMessages(global, savedLastMessages);

View File

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