diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 7c6270d8d..e46d33fcb 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -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 = ({ chatFoldersById, areTagsEnabled, withTags, + onReorderAnimationEnd, }) => { const { openChat, @@ -234,6 +236,7 @@ const Chat: FC = ({ orderDiff, isSavedDialog, isPreview, + onReorderAnimationEnd, topics, noForumTitle: shouldRenderTags, }); diff --git a/src/components/left/main/ChatList.tsx b/src/components/left/main/ChatList.tsx index 6fee30913..811c1893b 100644 --- a/src/components/left/main/ChatList.tsx +++ b/src/components/left/main/ChatList.tsx @@ -105,7 +105,7 @@ const ChatList: FC = ({ ? 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 = ({ isSavedDialog={isSaved} animationType={getAnimationType(id)} orderDiff={orderDiffById[id]} + onReorderAnimationEnd={onReorderAnimationEnd} offsetTop={offsetTop} observeIntersection={observe} onDragEnter={handleDragEnter} diff --git a/src/components/left/main/ForumPanel.tsx b/src/components/left/main/ForumPanel.tsx index 0e001cf34..9a876925d 100644 --- a/src/components/left/main/ForumPanel.tsx +++ b/src/components/left/main/ForumPanel.tsx @@ -130,7 +130,7 @@ const ForumPanel: FC = ({ : []; }, [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 = ({ observeIntersection={observe} animationType={getAnimationType(id)} orderDiff={orderDiffById[id]} + onReorderAnimationEnd={onReorderAnimationEnd} /> )); } diff --git a/src/components/left/main/Topic.tsx b/src/components/left/main/Topic.tsx index 3c841a9b4..00d4a8571 100644 --- a/src/components/left/main/Topic.tsx +++ b/src/components/left/main/Topic.tsx @@ -57,6 +57,7 @@ type OwnProps = { observeIntersection?: ObserveFn; orderDiff: number; animationType: ChatAnimationTypes; + onReorderAnimationEnd?: NoneToVoidFunction; }; type StateProps = { @@ -96,6 +97,7 @@ const Topic: FC = ({ draft, wasTopicOpened, topics, + onReorderAnimationEnd, }) => { const { openThread, @@ -152,6 +154,7 @@ const Topic: FC = ({ animationType, withInterfaceAnimations, orderDiff, + onReorderAnimationEnd, }); const handleOpenTopic = useLastCallback((e: React.MouseEvent) => { diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx index fc0a5d0a4..71ae8dd77 100644 --- a/src/components/left/main/hooks/useChatListEntry.tsx +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -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; @@ -72,6 +72,7 @@ export default function useChatListEntry({ orderDiff: number; withInterfaceAnimations?: boolean; noForumTitle?: boolean; + onReorderAnimationEnd?: NoneToVoidFunction; }) { const lang = useLang(); const ref = useRef(); @@ -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, diff --git a/src/components/left/main/hooks/useOrderDiff.ts b/src/components/left/main/hooks/useOrderDiff.ts index 9992b413a..c96b8c306 100644 --- a/src/components/left/main/hooks/useOrderDiff.ts +++ b/src/components/left/main/hooks/useOrderDiff.ts @@ -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>(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, }; } diff --git a/src/components/middle/message/CommentButton.scss b/src/components/middle/message/CommentButton.scss index b0f760cd2..6b67b8e14 100644 --- a/src/components/middle/message/CommentButton.scss +++ b/src/components/middle/message/CommentButton.scss @@ -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); } } diff --git a/src/components/middle/message/CommentButton.tsx b/src/components/middle/message/CommentButton.tsx index def79e62e..cd4578b2d 100644 --- a/src/components/middle/message/CommentButton.tsx +++ b/src/components/middle/message/CommentButton.tsx @@ -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 = ({ 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 = ({ }).filter(Boolean); }, [recentReplierIds]); - if (messagesCount === undefined) { - return undefined; - } - function renderRecentRepliers() { return ( Boolean(recentRepliers?.length) && ( @@ -102,7 +103,7 @@ const CommentButton: FC = ({ return (
{withCommentButton && isCustomShape && ( )} diff --git a/src/components/middle/message/Photo.tsx b/src/components/middle/message/Photo.tsx index 6c5a56ba5..96cc407b7 100644 --- a/src/components/middle/message/Photo.tsx +++ b/src/components/middle/message/Photo.tsx @@ -179,6 +179,14 @@ const Photo = ({ isOpen: !fullMediaData && !isLoadAllowed, withShouldRender: true, }); + const { + ref: transferProgressRef, + shouldRender: shouldRenderTransferProgress, + } = useShowTransition({ + isOpen: isTransferring, + noMountTransition: wasLoadDisabled, + withShouldRender: true, + }); const handleClick = useLastCallback((e: React.MouseEvent) => { if (isUploading) { @@ -291,10 +299,9 @@ const Photo = ({ className="media-spoiler" isNsfw={isMediaNsfw} /> - {isTransferring && ( - - {Math.round(transferProgress * 100)} - % + {shouldRenderTransferProgress && ( + + {`${Math.round(transferProgress * 100)}%`} )} ({ } = 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(0); const handleTimeUpdate = useLastCallback((e: React.SyntheticEvent) => { @@ -324,8 +332,8 @@ const Video = ({ {!isLoadAllowed && !fullMediaData && ( )} - {isTransferring && (!isUnsupported || isDownloading) ? ( - + {shouldRenderTransferProgress ? ( + {(isUploading || isDownloading) ? `${Math.round(transferProgress * 100)}%` : '...'} ) : ( diff --git a/src/global/actions/api/sync.ts b/src/global/actions/api/sync.ts index 38482cdca..f2c869f88 100644 --- a/src/global/actions/api/sync.ts +++ b/src/global/actions/api/sync.ts @@ -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(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(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); diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 560705f7b..48bf55203 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -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; diff --git a/src/util/animations/waitTransitionEnd.ts b/src/util/animations/waitTransitionEnd.ts new file mode 100644 index 000000000..9149f9574 --- /dev/null +++ b/src/util/animations/waitTransitionEnd.ts @@ -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((resolve) => { + scheduler(() => { + waitCurrentTransitionsEnd(element).then(() => resolve()); + }); + }); +}