Message List: Fixes and refactoring of scroll animation

This commit is contained in:
Alexander Zinchuk 2023-04-23 18:33:12 +04:00
parent 0ab0c13d87
commit 83557863b8
22 changed files with 219 additions and 188 deletions

View File

@ -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<OwnProps & StateProps> = ({
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) => {

View File

@ -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<OwnProps> = ({
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<OwnProps> = ({
setCurrentSlideIndex(index);
startScrolling();
await fastSmoothScrollHorizontal(scrollContainer, scrollContainer.clientWidth * index, 800);
await animateHorizontalScroll(scrollContainer, scrollContainer.clientWidth * index, 800);
stopScrolling();
}, [startScrolling, stopScrolling]);

View File

@ -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<OwnProps & StateProps> = ({
message,
isEmbedded,
appearanceOrder = 0,
isJustAdded,
isLastInList,
usersById,
senderUser,
@ -83,7 +83,6 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
isFocused,
focusDirection,
noFocusHighlight,
viewportIds,
premiumGiftSticker,
isInsideTopic,
topic,
@ -102,7 +101,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
};
export default memo(withGlobal<OwnProps>(
(global, { message, threadId, messageListType }): StateProps => {
(global, { message, threadId }): StateProps => {
const {
chatId, senderId, replyToMessageId, content,
} = message;
@ -291,9 +290,6 @@ export default memo(withGlobal<OwnProps>(
...(isFocused && {
focusDirection,
noFocusHighlight,
viewportIds: threadId && messageListType
? selectCurrentMessageIds(global, chatId, threadId, messageListType)
: undefined,
}),
};
},

View File

@ -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<OwnProps & StateProps> = ({
return;
}
fastSmoothScroll(messagesContainer, lastMessageElement, 'end', FOCUS_MARGIN);
animateScroll(messagesContainer, lastMessageElement, 'end', FOCUS_MARGIN);
}
}, [isShown, messageListType, focusNextReply]);

View File

@ -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 {

View File

@ -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<OwnProps & StateProps> = ({
// 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<HTMLDivElement>('.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<HTMLDivElement>('.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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
&& container.querySelector<HTMLDivElement>(`.${UNREAD_DIVIDER_CLASS}`)
);
let newScrollTop!: number;
if (isAtBottom && isResized) {
newScrollTop = scrollHeight - offsetHeight;
} else if (anchor) {
@ -498,17 +495,10 @@ const MessageList: FC<OwnProps & StateProps> = ({
}
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<OwnProps & StateProps> = ({
});
}
scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight);
if (process.env.APP_ENV === 'perf') {
// eslint-disable-next-line no-console
console.timeEnd('scrollTop');

View File

@ -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<OwnProps> = ({
<span>{lang('UnreadMessages')}</span>
</div>
);
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<OwnProps> = ({
observeIntersectionForPlaying={observeIntersectionForPlaying}
memoFirstUnreadIdRef={memoFirstUnreadIdRef}
appearanceOrder={messageCountToAnimate - ++appearanceIndex}
isJustAdded={isLastInList && isNewMessage}
isLastInList={isLastInList}
onPinnedIntersectionChange={onPinnedIntersectionChange}
/>,
@ -219,6 +225,7 @@ const MessageListContent: FC<OwnProps> = ({
noComments={noComments}
noReplies={!noComments || threadId !== MAIN_THREAD_ID}
appearanceOrder={messageCountToAnimate - ++appearanceIndex}
isJustAdded={position.isLastInList && isNewMessage}
isFirstInGroup={position.isFirstInGroup}
isLastInGroup={position.isLastInGroup}
isFirstInDocumentGroup={position.isFirstInDocumentGroup}

View File

@ -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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
setActiveCategoryIndex(index);
const categoryEl = containerRef.current!.closest<HTMLElement>('.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) => {

View File

@ -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<string, any>) {
const position = index > visibleIndexes[visibleIndexes.length - 1] ? 'start' : 'end';
const newLeft = position === 'start' ? index * EMOJI_BUTTON_WIDTH : 0;
fastSmoothScrollHorizontal(container, newLeft);
animateHorizontalScroll(container, newLeft);
}
}

View File

@ -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<OwnProps & StateProps> = ({
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) => {

View File

@ -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,

View File

@ -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<OwnProps & StateProps> = ({
noComments,
noReplies,
appearanceOrder,
isJustAdded,
isFirstInGroup,
isPremium,
isLastInGroup,
@ -320,7 +321,6 @@ const Message: FC<OwnProps & StateProps> = ({
focusDirection,
noFocusHighlight,
isResizingContainer,
viewportIds,
isForwarding,
isChatWithSelf,
isRepliesChat,
@ -412,7 +412,12 @@ const Message: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
);
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<OwnProps>(
focusDirection,
noFocusHighlight,
isResizingContainer,
viewportIds: selectCurrentMessageIds(global, chatId, threadId, messageListType),
}),
};
},

View File

@ -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<HTMLDivElement>('.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,
]);
}

View File

@ -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',

View File

@ -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<OwnProps & StateProps> = ({
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({

View File

@ -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<OwnProps> = ({
return;
}
fastSmoothScrollHorizontal(container, newLeft, SCROLL_DURATION);
animateHorizontalScroll(container, newLeft, SCROLL_DURATION);
}, [activeTab]);
const lang = useLang();

View File

@ -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

View File

@ -89,7 +89,24 @@ export function useIntersectionObserver({
function initController() {
const callbacks = new Map();
const entriesAccumulator = new Map<Element, IntersectionObserverEntry>();
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();
}

View File

@ -8,7 +8,7 @@ const DEFAULT_DURATION = 300;
const stopById: Map<string, VoidFunction> = 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;
}

View File

@ -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<typeof createMutateFunction>;
let isAnimating = false;
let currentArgs: Parameters<typeof createMutateFunction> | 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) {

View File

@ -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()) {

View File

@ -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);
}
}