diff --git a/src/components/common/CustomEmojiPicker.tsx b/src/components/common/CustomEmojiPicker.tsx index 65833406d..7075dce97 100644 --- a/src/components/common/CustomEmojiPicker.tsx +++ b/src/components/common/CustomEmojiPicker.tsx @@ -23,9 +23,9 @@ import { } from '../../config'; import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; -import fastSmoothScroll from '../../util/fastSmoothScroll'; +import animateScroll from '../../util/animateScroll'; import buildClassName from '../../util/buildClassName'; -import fastSmoothScrollHorizontal from '../../util/fastSmoothScrollHorizontal'; +import animateHorizontalScroll from '../../util/animateHorizontalScroll'; import { pickTruthy, unique } from '../../util/iteratees'; import { isSameReaction } from '../../global/helpers'; import { @@ -275,13 +275,13 @@ const CustomEmojiPicker: FC = ({ const newLeft = activeSetIndex * HEADER_BUTTON_WIDTH - (header.offsetWidth / 2 - HEADER_BUTTON_WIDTH / 2); - fastSmoothScrollHorizontal(header, newLeft); + animateHorizontalScroll(header, newLeft); }, [areAddedLoaded, activeSetIndex]); const selectStickerSet = useCallback((index: number) => { setActiveSetIndex(index); const stickerSetEl = document.getElementById(`${idPrefix}-${index}`)!; - fastSmoothScroll(containerRef.current!, stickerSetEl, 'start', undefined, SMOOTH_SCROLL_DISTANCE); + animateScroll(containerRef.current!, stickerSetEl, 'start', undefined, SMOOTH_SCROLL_DISTANCE); }, [idPrefix]); const handleEmojiSelect = useCallback((emoji: ApiSticker) => { diff --git a/src/components/main/premium/PremiumFeatureModal.tsx b/src/components/main/premium/PremiumFeatureModal.tsx index caaca1c86..35ea977f2 100644 --- a/src/components/main/premium/PremiumFeatureModal.tsx +++ b/src/components/main/premium/PremiumFeatureModal.tsx @@ -8,7 +8,7 @@ import type { ApiLimitType, GlobalState } from '../../../global/types'; import buildClassName from '../../../util/buildClassName'; import useLang from '../../../hooks/useLang'; -import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal'; +import animateHorizontalScroll from '../../../util/animateHorizontalScroll'; import useFlag from '../../../hooks/useFlag'; import renderText from '../../common/helpers/renderText'; import usePrevious from '../../../hooks/usePrevious'; @@ -185,7 +185,7 @@ const PremiumFeatureModal: FC = ({ const index = PREMIUM_FEATURE_SECTIONS.indexOf(initialSection); setCurrentSlideIndex(index); startScrolling(); - fastSmoothScrollHorizontal(scrollContainer, scrollContainer.clientWidth * index, 0) + animateHorizontalScroll(scrollContainer, scrollContainer.clientWidth * index, 0) .then(stopScrolling); }, [currentSlideIndex, initialSection, prevInitialSection, startScrolling, stopScrolling]); @@ -196,7 +196,7 @@ const PremiumFeatureModal: FC = ({ setCurrentSlideIndex(index); startScrolling(); - await fastSmoothScrollHorizontal(scrollContainer, scrollContainer.clientWidth * index, 800); + await animateHorizontalScroll(scrollContainer, scrollContainer.clientWidth * index, 800); stopScrolling(); }, [startScrolling, stopScrolling]); diff --git a/src/components/middle/ActionMessage.tsx b/src/components/middle/ActionMessage.tsx index 1ff49695e..df9f91ee4 100644 --- a/src/components/middle/ActionMessage.tsx +++ b/src/components/middle/ActionMessage.tsx @@ -18,7 +18,6 @@ import { selectChat, selectTopicFromMessage, selectTabState, - selectCurrentMessageIds, } from '../../global/selectors'; import { getMessageHtmlId, isChatChannel } from '../../global/helpers'; import buildClassName from '../../util/buildClassName'; @@ -46,6 +45,7 @@ type OwnProps = { observeIntersectionForPlaying?: ObserveFn; isEmbedded?: boolean; appearanceOrder?: number; + isJustAdded?: boolean; isLastInList?: boolean; isInsideTopic?: boolean; memoFirstUnreadIdRef?: { current: number | undefined }; @@ -63,7 +63,6 @@ type StateProps = { topic?: ApiTopic; focusDirection?: FocusDirection; noFocusHighlight?: boolean; - viewportIds?: number[]; premiumGiftSticker?: ApiSticker; }; @@ -73,6 +72,7 @@ const ActionMessage: FC = ({ message, isEmbedded, appearanceOrder = 0, + isJustAdded, isLastInList, usersById, senderUser, @@ -83,7 +83,6 @@ const ActionMessage: FC = ({ isFocused, focusDirection, noFocusHighlight, - viewportIds, premiumGiftSticker, isInsideTopic, topic, @@ -102,7 +101,7 @@ const ActionMessage: FC = ({ useOnIntersect(ref, observeIntersectionForReading); useEnsureMessage(message.chatId, message.replyToMessageId, targetMessage); - useFocusMessage(ref, message.id, message.chatId, isFocused, focusDirection, noFocusHighlight, viewportIds); + useFocusMessage(ref, message.id, message.chatId, isFocused, focusDirection, noFocusHighlight, isJustAdded); useEffect(() => { if (!message.isPinned) return undefined; @@ -252,7 +251,7 @@ const ActionMessage: FC = ({ }; export default memo(withGlobal( - (global, { message, threadId, messageListType }): StateProps => { + (global, { message, threadId }): StateProps => { const { chatId, senderId, replyToMessageId, content, } = message; @@ -291,9 +290,6 @@ export default memo(withGlobal( ...(isFocused && { focusDirection, noFocusHighlight, - viewportIds: threadId && messageListType - ? selectCurrentMessageIds(global, chatId, threadId, messageListType) - : undefined, }), }; }, diff --git a/src/components/middle/FloatingActionButtons.tsx b/src/components/middle/FloatingActionButtons.tsx index 6009b2569..22c2f341b 100644 --- a/src/components/middle/FloatingActionButtons.tsx +++ b/src/components/middle/FloatingActionButtons.tsx @@ -9,7 +9,7 @@ import { MAIN_THREAD_ID } from '../../api/types'; import { selectChat, selectCurrentMessageList } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; -import fastSmoothScroll from '../../util/fastSmoothScroll'; +import animateScroll from '../../util/animateScroll'; import ScrollDownButton from './ScrollDownButton'; @@ -79,7 +79,7 @@ const FloatingActionButtons: FC = ({ return; } - fastSmoothScroll(messagesContainer, lastMessageElement, 'end', FOCUS_MARGIN); + animateScroll(messagesContainer, lastMessageElement, 'end', FOCUS_MARGIN); } }, [isShown, messageListType, focusNextReply]); diff --git a/src/components/middle/MessageList.scss b/src/components/middle/MessageList.scss index 2589cfc14..be7b6cea3 100644 --- a/src/components/middle/MessageList.scss +++ b/src/components/middle/MessageList.scss @@ -125,6 +125,17 @@ opacity: 0; } + body.animation-level-0 & { + opacity: 1; + transform: none; + display: flex !important; + transition: none !important; + } + + &.is-just-added:not(.own) { + transform: none; + } + // Restore stacking context // https://developer.mozilla.org/ru/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context &.open.shown { diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 2bfb5607d..6f59cda9c 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -5,6 +5,7 @@ import React, { useMemo, useRef, } from '../../lib/teact/teact'; +import { addExtraClass, removeExtraClass } from '../../lib/teact/teact-dom'; import { requestForcedReflow, forceMeasure, requestMeasure } from '../../lib/fasterdom/fasterdom'; import type { FC } from '../../lib/teact/teact'; @@ -58,7 +59,7 @@ import buildClassName from '../../util/buildClassName'; import { groupMessages } from './helpers/groupMessages'; import { preventMessageInputBlur } from './helpers/preventMessageInputBlur'; import resetScroll from '../../util/resetScroll'; -import fastSmoothScroll, { isAnimatingScroll } from '../../util/fastSmoothScroll'; +import animateScroll, { isAnimatingScroll, restartCurrentScrollAnimation } from '../../util/animateScroll'; import renderText from '../common/helpers/renderText'; import { useStateRef } from '../../hooks/useStateRef'; @@ -397,40 +398,50 @@ const MessageList: FC = ({ // Handles updated message list, takes care of scroll repositioning useLayoutEffectWithPrevDeps(([prevMessageIds, prevIsViewportNewest]) => { + if (process.env.APP_ENV === 'perf') { + // eslint-disable-next-line no-console + console.time('scrollTop'); + } + const containerHeight = getContainerHeight(); const prevContainerHeight = prevContainerHeightRef.current; prevContainerHeightRef.current = containerHeight; + const container = containerRef.current!; + listItemElementsRef.current = Array.from(container.querySelectorAll('.message-list-item')); + const lastItemElement = listItemElementsRef.current[listItemElementsRef.current.length - 1]; + + const hasLastMessageChanged = ( + messageIds && prevMessageIds && messageIds[messageIds.length - 1] !== prevMessageIds[prevMessageIds.length - 1] + ); + const hasViewportShifted = ( + messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1) + ); + const wasMessageAdded = hasLastMessageChanged && !hasViewportShifted; + + // Add extra height when few messages to allow scroll animation + if ( + isViewportNewest + && wasMessageAdded + && (messageIds && messageIds.length < MESSAGE_LIST_SLICE / 2) + && !container.parentElement!.classList.contains('force-messages-scroll') + && forceMeasure(() => ( + (container.firstElementChild as HTMLDivElement)!.clientHeight <= container.offsetHeight * 2 + )) + ) { + addExtraClass(container.parentElement!, 'force-messages-scroll'); + container.parentElement!.classList.add('force-messages-scroll'); + + setTimeout(() => { + if (container.parentElement) { + removeExtraClass(container.parentElement!, 'force-messages-scroll'); + } + }, MESSAGE_ANIMATION_DURATION); + } + requestForcedReflow(() => { - const container = containerRef.current!; - listItemElementsRef.current = Array.from(container.querySelectorAll('.message-list-item')); - - const hasLastMessageChanged = ( - messageIds && prevMessageIds && messageIds[messageIds.length - 1] !== prevMessageIds[prevMessageIds.length - 1] - ); - const hasViewportShifted = ( - messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1) - ); - const wasMessageAdded = hasLastMessageChanged && !hasViewportShifted; - const isAlreadyFocusing = messageIds && memoFocusingIdRef.current === messageIds[messageIds.length - 1]; - - // Add extra height when few messages to allow smooth scroll animation. Uses assumption that `parentElement` - // is a Transition slide and its CSS class can not be reset in a declarative way. - const shouldForceScroll = ( - isViewportNewest - && wasMessageAdded - && (messageIds && messageIds.length < MESSAGE_LIST_SLICE / 2) - && !container.parentElement!.classList.contains('force-messages-scroll') - && (container.firstElementChild as HTMLDivElement)!.clientHeight <= container.offsetHeight * 2 - ); - - const { - scrollTop, - scrollHeight, - offsetHeight, - } = container; + const { scrollTop, scrollHeight, offsetHeight } = container; const scrollOffset = scrollOffsetRef.current; - const lastItemElement = listItemElementsRef.current[listItemElementsRef.current.length - 1]; let bottomOffset = scrollOffset - (prevContainerHeight || offsetHeight); if (wasMessageAdded) { @@ -441,34 +452,19 @@ const MessageList: FC = ({ bottomOffset -= lastItemHeight; } const isAtBottom = isViewportNewest && prevIsViewportNewest && bottomOffset <= BOTTOM_THRESHOLD; + const isAlreadyFocusing = messageIds && memoFocusingIdRef.current === messageIds[messageIds.length - 1]; - let newScrollTop!: number; - + // Animate incoming message if (wasMessageAdded && isAtBottom && !isAlreadyFocusing) { - if (lastItemElement) { - // Break out of `forceLayout` - requestMeasure(() => { - fastSmoothScroll( - container, - lastItemElement, - 'end', - BOTTOM_FOCUS_MARGIN, - ); - }); - } - - newScrollTop = scrollHeight - offsetHeight; - scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight); - - // Scroll still needs to be restored after container resize - if (!shouldForceScroll) { - return undefined; - } - } - - if (process.env.APP_ENV === 'perf') { - // eslint-disable-next-line no-console - console.time('scrollTop'); + // Break out of `forceLayout` + requestMeasure(() => { + animateScroll( + container, + lastItemElement!, + 'end', + BOTTOM_FOCUS_MARGIN, + ); + }); } const isResized = prevContainerHeight !== undefined && prevContainerHeight !== containerHeight; @@ -483,6 +479,7 @@ const MessageList: FC = ({ && container.querySelector(`.${UNREAD_DIVIDER_CLASS}`) ); + let newScrollTop!: number; if (isAtBottom && isResized) { newScrollTop = scrollHeight - offsetHeight; } else if (anchor) { @@ -498,17 +495,10 @@ const MessageList: FC = ({ } return () => { - if (shouldForceScroll) { - container.parentElement!.classList.add('force-messages-scroll'); - - setTimeout(() => { - if (container.parentElement) { - container.parentElement.classList.remove('force-messages-scroll'); - } - }, MESSAGE_ANIMATION_DURATION); - } - resetScroll(container, Math.ceil(newScrollTop)); + restartCurrentScrollAnimation(); + + scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight); if (!memoFocusingIdRef.current) { isScrollTopJustUpdatedRef.current = true; @@ -518,8 +508,6 @@ const MessageList: FC = ({ }); } - scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight); - if (process.env.APP_ENV === 'perf') { // eslint-disable-next-line no-console console.timeEnd('scrollTop'); diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 6561c338e..9b3f9d6dd 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -21,6 +21,7 @@ import { isAlbum } from './helpers/groupMessages'; import { preventMessageInputBlur } from './helpers/preventMessageInputBlur'; import useScrollHooks from './hooks/useScrollHooks'; import useMessageObservers from './hooks/useMessageObservers'; +import usePrevious from '../../hooks/usePrevious'; import Message from './message/Message'; import SponsoredMessage from './message/SponsoredMessage'; @@ -114,12 +115,16 @@ const MessageListContent: FC = ({ {lang('UnreadMessages')} ); - const messageCountToAnimate = noAppearanceAnimation ? 0 : messageGroups.reduce((acc, messageGroup) => { return acc + messageGroup.senderGroups.flat().length; }, 0); let appearanceIndex = 0; + const prevMessageIds = usePrevious(messageIds); + const isNewMessage = Boolean( + messageIds && prevMessageIds && messageIds[messageIds.length - 2] === prevMessageIds[prevMessageIds.length - 1], + ); + const dateGroups = messageGroups.map(( dateGroup: MessageDateGroup, dateGroupIndex: number, @@ -155,6 +160,7 @@ const MessageListContent: FC = ({ observeIntersectionForPlaying={observeIntersectionForPlaying} memoFirstUnreadIdRef={memoFirstUnreadIdRef} appearanceOrder={messageCountToAnimate - ++appearanceIndex} + isJustAdded={isLastInList && isNewMessage} isLastInList={isLastInList} onPinnedIntersectionChange={onPinnedIntersectionChange} />, @@ -219,6 +225,7 @@ const MessageListContent: FC = ({ noComments={noComments} noReplies={!noComments || threadId !== MAIN_THREAD_ID} appearanceOrder={messageCountToAnimate - ++appearanceIndex} + isJustAdded={position.isLastInList && isNewMessage} isFirstInGroup={position.isFirstInGroup} isLastInGroup={position.isLastInGroup} isFirstInDocumentGroup={position.isFirstInDocumentGroup} diff --git a/src/components/middle/composer/EmojiPicker.tsx b/src/components/middle/composer/EmojiPicker.tsx index 7ec96067f..ef7bb3252 100644 --- a/src/components/middle/composer/EmojiPicker.tsx +++ b/src/components/middle/composer/EmojiPicker.tsx @@ -15,10 +15,10 @@ import { MENU_TRANSITION_DURATION, RECENT_SYMBOL_SET_ID } from '../../../config' import { IS_TOUCH_ENV } from '../../../util/windowEnvironment'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { uncompressEmoji } from '../../../util/emoji'; -import fastSmoothScroll from '../../../util/fastSmoothScroll'; +import animateScroll from '../../../util/animateScroll'; import { pick } from '../../../util/iteratees'; import buildClassName from '../../../util/buildClassName'; -import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal'; +import animateHorizontalScroll from '../../../util/animateHorizontalScroll'; import useAsyncRendering from '../../right/hooks/useAsyncRendering'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; @@ -122,7 +122,7 @@ const EmojiPicker: FC = ({ const newLeft = activeCategoryIndex * HEADER_BUTTON_WIDTH - header.offsetWidth / 2 + HEADER_BUTTON_WIDTH / 2; - fastSmoothScrollHorizontal(header, newLeft); + animateHorizontalScroll(header, newLeft); }, [categories, activeCategoryIndex]); const lang = useLang(); @@ -165,7 +165,7 @@ const EmojiPicker: FC = ({ setActiveCategoryIndex(index); const categoryEl = containerRef.current!.closest('.SymbolMenu-main')! .querySelector(`#emoji-category-${index}`)! as HTMLElement; - fastSmoothScroll(containerRef.current!, categoryEl, 'start', FOCUS_MARGIN, SMOOTH_SCROLL_DISTANCE); + animateScroll(containerRef.current!, categoryEl, 'start', FOCUS_MARGIN, SMOOTH_SCROLL_DISTANCE); }, []); const handleEmojiSelect = useCallback((emoji: string, name: string) => { diff --git a/src/components/middle/composer/EmojiTooltip.tsx b/src/components/middle/composer/EmojiTooltip.tsx index e71b0a6cc..3fc6dc32b 100644 --- a/src/components/middle/composer/EmojiTooltip.tsx +++ b/src/components/middle/composer/EmojiTooltip.tsx @@ -8,7 +8,7 @@ import type { FC } from '../../../lib/teact/teact'; import buildClassName from '../../../util/buildClassName'; import findInViewport from '../../../util/findInViewport'; import isFullyVisible from '../../../util/isFullyVisible'; -import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal'; +import animateHorizontalScroll from '../../../util/animateHorizontalScroll'; import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; import useShowTransition from '../../../hooks/useShowTransition'; @@ -51,7 +51,7 @@ function setItemVisible(index: number, containerRef: Record) { const position = index > visibleIndexes[visibleIndexes.length - 1] ? 'start' : 'end'; const newLeft = position === 'start' ? index * EMOJI_BUTTON_WIDTH : 0; - fastSmoothScrollHorizontal(container, newLeft); + animateHorizontalScroll(container, newLeft); } } diff --git a/src/components/middle/composer/StickerPicker.tsx b/src/components/middle/composer/StickerPicker.tsx index d07483402..8a35cb5e9 100644 --- a/src/components/middle/composer/StickerPicker.tsx +++ b/src/components/middle/composer/StickerPicker.tsx @@ -17,9 +17,9 @@ import { } from '../../../config'; import { IS_TOUCH_ENV } from '../../../util/windowEnvironment'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; -import fastSmoothScroll from '../../../util/fastSmoothScroll'; +import animateScroll from '../../../util/animateScroll'; import buildClassName from '../../../util/buildClassName'; -import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal'; +import animateHorizontalScroll from '../../../util/animateHorizontalScroll'; import { pickTruthy, uniqueByField } from '../../../util/iteratees'; import { selectChat, selectIsChatWithSelf, selectIsCurrentUserPremium } from '../../../global/selectors'; @@ -212,13 +212,13 @@ const StickerPicker: FC = ({ const newLeft = activeSetIndex * HEADER_BUTTON_WIDTH - (header.offsetWidth / 2 - HEADER_BUTTON_WIDTH / 2); - fastSmoothScrollHorizontal(header, newLeft); + animateHorizontalScroll(header, newLeft); }, [areAddedLoaded, activeSetIndex]); const selectStickerSet = useCallback((index: number) => { setActiveSetIndex(index); const stickerSetEl = document.getElementById(`sticker-set-${index}`)!; - fastSmoothScroll(containerRef.current!, stickerSetEl, 'start', undefined, SMOOTH_SCROLL_DISTANCE); + animateScroll(containerRef.current!, stickerSetEl, 'start', undefined, SMOOTH_SCROLL_DISTANCE); }, []); const handleStickerSelect = useCallback((sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => { diff --git a/src/components/middle/hooks/useScrollHooks.ts b/src/components/middle/hooks/useScrollHooks.ts index c9783528d..b6482f9d7 100644 --- a/src/components/middle/hooks/useScrollHooks.ts +++ b/src/components/middle/hooks/useScrollHooks.ts @@ -21,7 +21,7 @@ import { useDebouncedSignal } from '../../../hooks/useAsyncResolvers'; const FAB_THRESHOLD = 50; const NOTCH_THRESHOLD = 1; // Notch has zero height so we at least need a 1px margin to intersect const CONTAINER_HEIGHT_DEBOUNCE = 100; -const TOOLS_FREEZE_TIMEOUT = 250; // Approximate message sending animation duration +const TOOLS_FREEZE_TIMEOUT = 350; // Approximate message sending animation duration export default function useScrollHooks( type: MessageListType, diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index d62a7c1ea..38bbb80f5 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -67,7 +67,7 @@ import { selectTopicFromMessage, selectTabState, selectChatTranslations, - selectRequestedTranslationLanguage, selectCurrentMessageIds, + selectRequestedTranslationLanguage, } from '../../../global/selectors'; import { getMessageContent, @@ -109,7 +109,7 @@ import renderText from '../../common/helpers/renderText'; import { getServerTime } from '../../../util/serverTime'; import { isElementInViewport } from '../../../util/isElementInViewport'; import { getCustomEmojiSize } from '../composer/helpers/customEmoji'; -import { isAnimatingScroll } from '../../../util/fastSmoothScroll'; +import { isAnimatingScroll } from '../../../util/animateScroll'; import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; @@ -186,6 +186,7 @@ type OwnProps = noComments: boolean; noReplies: boolean; appearanceOrder: number; + isJustAdded: boolean; memoFirstUnreadIdRef: { current: number | undefined }; onPinnedIntersectionChange: PinnedIntersectionChangedCallback; } @@ -212,7 +213,6 @@ type StateProps = { focusDirection?: FocusDirection; noFocusHighlight?: boolean; isResizingContainer?: boolean; - viewportIds?: number[]; isForwarding?: boolean; isChatWithSelf?: boolean; isRepliesChat?: boolean; @@ -293,6 +293,7 @@ const Message: FC = ({ noComments, noReplies, appearanceOrder, + isJustAdded, isFirstInGroup, isPremium, isLastInGroup, @@ -320,7 +321,6 @@ const Message: FC = ({ focusDirection, noFocusHighlight, isResizingContainer, - viewportIds, isForwarding, isChatWithSelf, isRepliesChat, @@ -412,7 +412,12 @@ const Message: FC = ({ setTimeout(markShown, appearanceOrder * APPEARANCE_DELAY); }, [appearanceOrder, markShown, noAppearanceAnimation]); - const { transitionClassNames } = useShowTransition(isShown, undefined, noAppearanceAnimation, false); + const { transitionClassNames } = useShowTransition( + isShown || isJustAdded, + undefined, + noAppearanceAnimation && !isJustAdded, + false, + ); const { id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, @@ -576,6 +581,7 @@ const Message: FC = ({ Boolean(message.inlineButtons) && 'has-inline-buttons', isSwiped && 'is-swiped', transitionClassNames, + isJustAdded && 'is-just-added', (Boolean(activeReactions) || hasActiveStickerEffect) && 'has-active-reaction', ); @@ -658,7 +664,7 @@ const Message: FC = ({ ); useFocusMessage( - ref, messageId, chatId, isFocused, focusDirection, noFocusHighlight, viewportIds, isResizingContainer, + ref, messageId, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isJustAdded, ); const signature = (isChannel && message.postAuthorTitle) @@ -1480,7 +1486,6 @@ export default memo(withGlobal( focusDirection, noFocusHighlight, isResizingContainer, - viewportIds: selectCurrentMessageIds(global, chatId, threadId, messageListType), }), }; }, diff --git a/src/components/middle/message/hooks/useFocusMessage.ts b/src/components/middle/message/hooks/useFocusMessage.ts index 2acede6e6..e5cfea66b 100644 --- a/src/components/middle/message/hooks/useFocusMessage.ts +++ b/src/components/middle/message/hooks/useFocusMessage.ts @@ -1,8 +1,9 @@ -import { useLayoutEffect, useMemo } from '../../../../lib/teact/teact'; +import { useLayoutEffect, useRef } from '../../../../lib/teact/teact'; +import { requestForcedReflow, requestMeasure, requestMutation } from '../../../../lib/fasterdom/fasterdom'; import type { FocusDirection } from '../../../../types'; -import fastSmoothScroll from '../../../../util/fastSmoothScroll'; +import animateScroll from '../../../../util/animateScroll'; // This is used when the viewport was replaced. const BOTTOM_FOCUS_OFFSET = 500; @@ -16,38 +17,42 @@ export default function useFocusMessage( isFocused?: boolean, focusDirection?: FocusDirection, noFocusHighlight?: boolean, - viewportIds?: number[], isResizingContainer?: boolean, + isJustAdded?: boolean, ) { - const viewportIndex = useMemo(() => { - if (!viewportIds) { - return 0; - } - - const index = viewportIds.indexOf(messageId); - return Math.min(index, viewportIds.length - index - 1); - }, [messageId, viewportIds]); + const isRelocatedRef = useRef(!isJustAdded); useLayoutEffect(() => { + const isRelocated = isRelocatedRef.current; + isRelocatedRef.current = false; + if (isFocused && elementRef.current) { const messagesContainer = elementRef.current.closest('.MessageList')!; // `noFocusHighlight` is always called with “scroll-to-bottom” buttons const isToBottom = noFocusHighlight; - fastSmoothScroll( + const exec = () => animateScroll( messagesContainer, - elementRef.current, + elementRef.current!, isToBottom ? 'end' : 'centerOrTop', FOCUS_MARGIN, focusDirection !== undefined ? (isToBottom ? BOTTOM_FOCUS_OFFSET : RELOCATED_FOCUS_OFFSET) : undefined, focusDirection, undefined, isResizingContainer, - // We need this to override scroll setting from Message List layout effect true, ); + + if (isRelocated) { + // We need this to override scroll setting from Message List layout effect + requestForcedReflow(exec); + } else { + requestMeasure(() => { + requestMutation(exec()!); + }); + } } }, [ - elementRef, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, viewportIndex, + elementRef, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, ]); } diff --git a/src/components/right/hooks/useProfileState.ts b/src/components/right/hooks/useProfileState.ts index ab4914bee..b7bae0c5c 100644 --- a/src/components/right/hooks/useProfileState.ts +++ b/src/components/right/hooks/useProfileState.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect } from '../../../lib/teact/teact'; import { ProfileState } from '../../../types'; -import fastSmoothScroll from '../../../util/fastSmoothScroll'; +import animateScroll from '../../../util/animateScroll'; import { throttle } from '../../../util/schedulers'; import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; @@ -27,7 +27,7 @@ export default function useProfileState( if (container.scrollTop < tabsEl.offsetTop) { onProfileStateChange(tabType === 'members' ? ProfileState.MemberList : ProfileState.SharedMedia); isScrollingProgrammatically = true; - fastSmoothScroll(container, tabsEl, 'start', undefined, undefined, undefined, TRANSITION_DURATION); + animateScroll(container, tabsEl, 'start', undefined, undefined, undefined, TRANSITION_DURATION); setTimeout(() => { isScrollingProgrammatically = false; }, PROGRAMMATIC_SCROLL_TIMEOUT_MS); @@ -52,7 +52,7 @@ export default function useProfileState( } isScrollingProgrammatically = true; - fastSmoothScroll( + animateScroll( container, container.firstElementChild as HTMLElement, 'start', diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 4b080d189..bd647fb7e 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -1,6 +1,6 @@ import type { RefObject } from 'react'; import type { FC, TeactNode } from '../../lib/teact/teact'; -import React, { useEffect, useRef } from '../../lib/teact/teact'; +import React, { useCallback, useEffect, useRef } from '../../lib/teact/teact'; import type { TextPart } from '../../types'; @@ -78,9 +78,19 @@ const Modal: FC = ({ return enableDirectTextInput; }, [isOpen]); - useEffect(() => (isOpen - ? captureKeyboardListeners({ onEsc: onClose, onEnter }) - : undefined), [isOpen, onClose, onEnter]); + const handleEnter = useCallback((e: KeyboardEvent) => { + if (!onEnter) { + return false; + } + + e.preventDefault(); + onEnter(); + return true; + }, [onEnter]); + + useEffect(() => ( + isOpen ? captureKeyboardListeners({ onEsc: onClose, onEnter: handleEnter }) : undefined + ), [isOpen, onClose, handleEnter]); useEffect(() => (isOpen && modalRef.current ? trapFocus(modalRef.current) : undefined), [isOpen]); useHistoryBack({ diff --git a/src/components/ui/TabList.tsx b/src/components/ui/TabList.tsx index 94a852808..a6cdf92ce 100644 --- a/src/components/ui/TabList.tsx +++ b/src/components/ui/TabList.tsx @@ -3,7 +3,7 @@ import React, { memo, useRef, useEffect } from '../../lib/teact/teact'; import { ALL_FOLDER_ID } from '../../config'; import { IS_ANDROID, IS_IOS } from '../../util/windowEnvironment'; -import fastSmoothScrollHorizontal from '../../util/fastSmoothScrollHorizontal'; +import animateHorizontalScroll from '../../util/animateHorizontalScroll'; import usePrevious from '../../hooks/usePrevious'; import useHorizontalScroll from '../../hooks/useHorizontalScroll'; @@ -63,7 +63,7 @@ const TabList: FC = ({ return; } - fastSmoothScrollHorizontal(container, newLeft, SCROLL_DURATION); + animateHorizontalScroll(container, newLeft, SCROLL_DURATION); }, [activeTab]); const lang = useLang(); diff --git a/src/config.ts b/src/config.ts index a47cda175..740c5ab37 100644 --- a/src/config.ts +++ b/src/config.ts @@ -144,7 +144,7 @@ export const TMP_CHAT_ID = '0'; export const ANIMATION_END_DELAY = 100; -export const FAST_SMOOTH_MIN_DURATION = 250; +export const FAST_SMOOTH_MIN_DURATION = 300; export const FAST_SMOOTH_MAX_DURATION = 600; export const FAST_SMOOTH_MAX_DISTANCE = 750; export const FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE = 300; // px diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts index ea10a87be..75f87eb62 100644 --- a/src/hooks/useIntersectionObserver.ts +++ b/src/hooks/useIntersectionObserver.ts @@ -89,7 +89,24 @@ export function useIntersectionObserver({ function initController() { const callbacks = new Map(); const entriesAccumulator = new Map(); - const observerCallbackSync = () => { + + let observerCallback: typeof observerCallbackSync; + if (typeof throttleScheduler === 'function') { + observerCallback = throttleWith(throttleScheduler, observerCallbackSync); + } else if (throttleMs) { + observerCallback = throttle(observerCallbackSync, throttleMs, !shouldSkipFirst); + } else if (debounceMs) { + observerCallback = debounce(observerCallbackSync, debounceMs, !shouldSkipFirst); + } else { + observerCallback = observerCallbackSync; + } + + function observerCallbackSync() { + if (freezeFlagsRef.current) { + onUnfreezeRef.current = observerCallback; + return; + } + const entries = Array.from(entriesAccumulator.values()); entries.forEach((entry: IntersectionObserverEntry) => { @@ -104,17 +121,6 @@ export function useIntersectionObserver({ } entriesAccumulator.clear(); - }; - - let observerCallback: typeof observerCallbackSync; - if (typeof throttleScheduler === 'function') { - observerCallback = throttleWith(throttleScheduler, observerCallbackSync); - } else if (throttleMs) { - observerCallback = throttle(observerCallbackSync, throttleMs, !shouldSkipFirst); - } else if (debounceMs) { - observerCallback = debounce(observerCallbackSync, debounceMs, !shouldSkipFirst); - } else { - observerCallback = observerCallbackSync; } const observer = new IntersectionObserver( @@ -124,9 +130,7 @@ export function useIntersectionObserver({ }); if (freezeFlagsRef.current) { - onUnfreezeRef.current = () => { - observerCallback(); - }; + onUnfreezeRef.current = observerCallback; } else { observerCallback(); } diff --git a/src/util/fastSmoothScrollHorizontal.ts b/src/util/animateHorizontalScroll.ts similarity index 94% rename from src/util/fastSmoothScrollHorizontal.ts rename to src/util/animateHorizontalScroll.ts index 2783bc97d..1f8b0d5c7 100644 --- a/src/util/fastSmoothScrollHorizontal.ts +++ b/src/util/animateHorizontalScroll.ts @@ -8,7 +8,7 @@ const DEFAULT_DURATION = 300; const stopById: Map = new Map(); -export default function fastSmoothScrollHorizontal(container: HTMLElement, left: number, duration = DEFAULT_DURATION) { +export default function animateHorizontalScroll(container: HTMLElement, left: number, duration = DEFAULT_DURATION) { if (getGlobal().settings.byKey.animationLevel === ANIMATION_LEVEL_MIN) { duration = 0; } diff --git a/src/util/fastSmoothScroll.ts b/src/util/animateScroll.ts similarity index 75% rename from src/util/fastSmoothScroll.ts rename to src/util/animateScroll.ts index 3372bfe04..61dd2fe59 100644 --- a/src/util/fastSmoothScroll.ts +++ b/src/util/animateScroll.ts @@ -1,3 +1,4 @@ +import { requestMeasure, requestMutation } from '../lib/fasterdom/fasterdom'; import { getGlobal } from '../global'; import { FocusDirection } from '../types'; @@ -11,41 +12,40 @@ import { } from '../config'; import { IS_ANDROID } from './windowEnvironment'; import { dispatchHeavyAnimationEvent } from '../hooks/useHeavyAnimationCheck'; -import { animateSingle } from './animation'; -import { requestForcedReflow, requestMutation } from '../lib/fasterdom/fasterdom'; +import { animateSingle, cancelSingleAnimation } from './animation'; + +type Params = Parameters; let isAnimating = false; +let currentArgs: Parameters | undefined; -export default function fastSmoothScroll( - container: HTMLElement, - element: HTMLElement, - position: ScrollLogicalPosition | 'centerOrTop', - margin = 0, - maxDistance = FAST_SMOOTH_MAX_DISTANCE, - forceDirection?: FocusDirection, - forceDuration?: number, - forceNormalContainerHeight?: boolean, - withForcedReflow = false, -) { - const args = [ - container, - element, - position, - margin, - maxDistance, - forceDirection, - forceDuration, - forceNormalContainerHeight, - ] as const; +export default function animateScroll(...args: Params | [...Params, boolean]) { + currentArgs = args.slice(0, 8) as Params; - if (withForcedReflow) { - requestForcedReflow(() => measure(...args)); - } else { - requestMutation(measure(...args)); + const mutate = createMutateFunction(...currentArgs); + + const shouldReturnMutationFn = args[8]; + if (shouldReturnMutationFn) { + return mutate; } + + requestMutation(mutate); + return undefined; } -function measure( +export function restartCurrentScrollAnimation() { + if (!isAnimating) { + return; + } + + cancelSingleAnimation(); + + requestMeasure(() => { + requestMutation(createMutateFunction(...currentArgs!)); + }); +} + +function createMutateFunction( container: HTMLElement, element: HTMLElement, position: ScrollLogicalPosition | 'centerOrTop', @@ -88,12 +88,7 @@ function measure( const scrollFrom = calculateScrollFrom(container, scrollTo, maxDistance, forceDirection); - if (currentScrollTop !== scrollFrom) { - container.scrollTop = scrollFrom; - } - let path = scrollTo - scrollFrom; - if (path < 0) { const remainingPath = -scrollFrom; path = Math.max(path, remainingPath); @@ -102,12 +97,14 @@ function measure( path = Math.min(path, remainingPath); } - return () => { - if (currentScrollTop !== scrollFrom) { - container.scrollTop = scrollFrom; - } + const absPath = Math.abs(path); + + return () => { + if (absPath < 1) { + if (currentScrollTop !== scrollFrom) { + container.scrollTop = scrollFrom; + } - if (path === 0) { return; } @@ -120,7 +117,6 @@ function measure( isAnimating = true; - const absPath = Math.abs(path); const transition = absPath <= FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE ? shortTransition : longTransition; const duration = forceDuration || ( FAST_SMOOTH_MIN_DURATION @@ -132,12 +128,14 @@ function measure( animateSingle(() => { const t = Math.min((Date.now() - startAt) / duration, 1); const currentPath = path * (1 - transition(t)); + const newScrollTop = Math.round(target - currentPath); - container.scrollTop = Math.round(target - currentPath); + container.scrollTop = newScrollTop; - isAnimating = t < 1; + isAnimating = t < 1 && newScrollTop !== target; if (!isAnimating) { + currentArgs = undefined; onHeavyAnimationStop(); } @@ -176,7 +174,7 @@ function calculateScrollFrom( } function shortTransition(t: number) { - return 1 - ((1 - t) ** 3); + return 1 - ((1 - t) ** 3.5); } function longTransition(t: number) { diff --git a/src/util/animation.ts b/src/util/animation.ts index fb3efb9e0..b47b6fc83 100644 --- a/src/util/animation.ts +++ b/src/util/animation.ts @@ -24,6 +24,13 @@ export function animateSingle(tick: Function, schedulerFn: Scheduler, instance?: } } +export function cancelSingleAnimation() { + const dumbScheduler = (cb: AnyFunction) => cb; + const dumbCb = () => undefined; + + animateSingle(dumbCb, dumbScheduler); +} + export function animate(tick: Function, schedulerFn: Scheduler) { schedulerFn(() => { if (tick()) { diff --git a/src/util/setTooltipItemVisible.ts b/src/util/setTooltipItemVisible.ts index 55b4d3522..4d05d0556 100644 --- a/src/util/setTooltipItemVisible.ts +++ b/src/util/setTooltipItemVisible.ts @@ -1,6 +1,6 @@ import findInViewport from './findInViewport'; import isFullyVisible from './isFullyVisible'; -import fastSmoothScroll from './fastSmoothScroll'; +import animateScroll from './animateScroll'; const VIEWPORT_MARGIN = 8; const SCROLL_MARGIN = 10; @@ -25,6 +25,6 @@ export default function setTooltipItemVisible(selector: string, index: number, c if (!visibleIndexes.includes(index) || (index === first && !isFullyVisible(container, allElements[first]))) { const position = index > visibleIndexes[visibleIndexes.length - 1] ? 'start' : 'end'; - fastSmoothScroll(container, allElements[index], position, SCROLL_MARGIN); + animateScroll(container, allElements[index], position, SCROLL_MARGIN); } }