Message List: Fix scroll animation

This commit is contained in:
zubiden 2025-11-17 12:18:26 +04:00 committed by Alexander Zinchuk
parent 880a6c6977
commit a244d64670
4 changed files with 59 additions and 35 deletions

View File

@ -1,8 +1,6 @@
.MessageList { .MessageList {
--action-message-bg: var(--pattern-color); --action-message-bg: var(--pattern-color);
scroll-snap-type: y proximity;
overflow-x: hidden; overflow-x: hidden;
overflow-y: scroll; overflow-y: scroll;
flex: 1; flex: 1;
@ -38,8 +36,8 @@
display: none; display: none;
} }
&.no-bottom-snap { &.with-bottom-snap {
scroll-snap-type: none; scroll-snap-type: y proximity;
} }
.messages-container { .messages-container {

View File

@ -1,4 +1,3 @@
import type { FC } from '@teact';
import { beginHeavyAnimation, memo, useEffect, useMemo, useRef } from '@teact'; import { beginHeavyAnimation, memo, useEffect, useMemo, useRef } from '@teact';
import { addExtraClass, removeExtraClass } from '@teact/teact-dom'; import { addExtraClass, removeExtraClass } from '@teact/teact-dom';
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
@ -170,7 +169,7 @@ const MESSAGE_FACT_CHECK_UPDATE_INTERVAL = 5 * 1000;
const MESSAGE_STORY_POLLING_INTERVAL = 5 * 60 * 1000; const MESSAGE_STORY_POLLING_INTERVAL = 5 * 60 * 1000;
const BOTTOM_THRESHOLD = 50; const BOTTOM_THRESHOLD = 50;
const BOTTOM_SNAP_THRESHOLD = 10; const BOTTOM_SNAP_THRESHOLD = 7;
const UNREAD_DIVIDER_TOP = 10; const UNREAD_DIVIDER_TOP = 10;
const SCROLL_DEBOUNCE = 200; const SCROLL_DEBOUNCE = 200;
@ -180,11 +179,11 @@ const SELECT_MODE_ANIMATION_DURATION = 200;
const UNREAD_DIVIDER_CLASS = 'unread-divider'; const UNREAD_DIVIDER_CLASS = 'unread-divider';
const FORCE_MESSAGES_SCROLL_CLASS = 'force-messages-scroll'; const FORCE_MESSAGES_SCROLL_CLASS = 'force-messages-scroll';
const NO_BOTTOM_SNAP_CLASS = 'no-bottom-snap'; const BOTTOM_SNAP_CLASS = 'with-bottom-snap';
const runDebouncedForScroll = debounce((cb) => cb(), SCROLL_DEBOUNCE, false); const runDebouncedForScroll = debounce((cb) => cb(), SCROLL_DEBOUNCE, false);
const MessageList: FC<OwnProps & StateProps> = ({ const MessageList = ({
chatId, chatId,
threadId, threadId,
type, type,
@ -243,7 +242,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
onIntersectPinnedMessage, onIntersectPinnedMessage,
onScrollDownToggle, onScrollDownToggle,
onNotchToggle, onNotchToggle,
}) => { }: OwnProps & StateProps) => {
const { const {
loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds, loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds,
loadMessageViews, loadPeerStoriesByIds, loadFactChecks, requestChatTranslation, loadMessageViews, loadPeerStoriesByIds, loadFactChecks, requestChatTranslation,
@ -471,6 +470,33 @@ const MessageList: FC<OwnProps & StateProps> = ({
const { isScrolled, updateStickyDates } = useStickyDates(); const { isScrolled, updateStickyDates } = useStickyDates();
const updateBottomSnapClass = useLastCallback(() => {
const container = containerRef.current;
const bottomTrigger = container?.querySelector<HTMLDivElement>('.fab-trigger');
if (!container || !bottomTrigger) return;
// Check if fab-trigger + threshold are entering the viewport
const viewportBottom = container.scrollTop + container.offsetHeight;
const triggerPosition = bottomTrigger.offsetTop;
// Scroll is near fab-trigger + threshold. Prevents snap on sponsored message
const shouldSnapBeActive = triggerPosition - BOTTOM_SNAP_THRESHOLD <= viewportBottom
&& viewportBottom <= triggerPosition + BOTTOM_SNAP_THRESHOLD * 2;
const hasSnap = container.classList.contains(BOTTOM_SNAP_CLASS);
if (hasSnap === shouldSnapBeActive) return;
if (shouldSnapBeActive) {
requestMutation(() => {
addExtraClass(container, BOTTOM_SNAP_CLASS);
});
} else {
clearTimeout(scrollSnapDisabledTimerRef.current);
requestMutation(() => {
removeExtraClass(container, BOTTOM_SNAP_CLASS);
});
}
});
const handleScroll = useLastCallback(() => { const handleScroll = useLastCallback(() => {
if (isScrollTopJustUpdatedRef.current) { if (isScrollTopJustUpdatedRef.current) {
isScrollTopJustUpdatedRef.current = false; isScrollTopJustUpdatedRef.current = false;
@ -486,6 +512,8 @@ const MessageList: FC<OwnProps & StateProps> = ({
updateStickyDates(container); updateStickyDates(container);
} }
updateBottomSnapClass();
runDebouncedForScroll(() => { runDebouncedForScroll(() => {
const global = getGlobal(); const global = getGlobal();
@ -509,28 +537,14 @@ const MessageList: FC<OwnProps & StateProps> = ({
const [getContainerHeight, prevContainerHeightRef] = useContainerHeight(containerRef, canPost && !isSelectModeActive); const [getContainerHeight, prevContainerHeightRef] = useContainerHeight(containerRef, canPost && !isSelectModeActive);
const handleWheel = useLastCallback((e: React.WheelEvent<HTMLDivElement>) => { const handleWheel = useLastCallback((e: React.WheelEvent<HTMLDivElement>) => {
// Firefox is finicky about bottom scroll snapping, so we enable it only when nearing the bottom // Remove snap when scrolling up to avoid scroll bug
// https://bugzilla.mozilla.org/show_bug.cgi?id=1753188 // https://bugzilla.mozilla.org/show_bug.cgi?id=1753188
if (!IS_FIREFOX) return; if (IS_FIREFOX && e.deltaY < 0) {
const container = containerRef.current;
if (!container) return;
const container = containerRef.current;
if (!container) return;
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const offsetHeight = container.offsetHeight;
const isNearBottomForSnap = scrollTop >= scrollHeight - offsetHeight - BOTTOM_SNAP_THRESHOLD;
if (!isNearBottomForSnap) return;
if (e.deltaY < 0) {
clearTimeout(scrollSnapDisabledTimerRef.current);
requestMutation(() => { requestMutation(() => {
addExtraClass(container, NO_BOTTOM_SNAP_CLASS); removeExtraClass(container, BOTTOM_SNAP_CLASS);
container.scrollBy(0, -BOTTOM_SNAP_THRESHOLD); // Manually scroll to prevent ignoring first event
});
} else {
requestMutation(() => {
removeExtraClass(container, NO_BOTTOM_SNAP_CLASS);
}); });
} }
}); });
@ -645,10 +659,12 @@ const MessageList: FC<OwnProps & StateProps> = ({
if (wasMessageAdded) { if (wasMessageAdded) {
clearTimeout(scrollSnapDisabledTimerRef.current); clearTimeout(scrollSnapDisabledTimerRef.current);
addExtraClass(container, NO_BOTTOM_SNAP_CLASS); removeExtraClass(container, BOTTOM_SNAP_CLASS);
scrollSnapDisabledTimerRef.current = window.setTimeout(() => { scrollSnapDisabledTimerRef.current = window.setTimeout(() => {
removeExtraClass(container, NO_BOTTOM_SNAP_CLASS); requestMutation(() => {
addExtraClass(container, BOTTOM_SNAP_CLASS);
});
}, MESSAGE_ANIMATION_DURATION); }, MESSAGE_ANIMATION_DURATION);
} }
@ -768,7 +784,6 @@ const MessageList: FC<OwnProps & StateProps> = ({
!isReady && 'is-animating', !isReady && 'is-animating',
hasOpenChatButton && 'saved-dialog', hasOpenChatButton && 'saved-dialog',
isChatProtected && 'hide-on-print', isChatProtected && 'hide-on-print',
IS_FIREFOX && NO_BOTTOM_SNAP_CLASS,
); );
const hasMessages = Boolean((messageIds && messageGroups) || lastMessage); const hasMessages = Boolean((messageIds && messageGroups) || lastMessage);

View File

@ -1,7 +1,5 @@
import { memo, useRef } from '@teact'; import { memo, useRef } from '@teact';
import { FocusDirection } from '../../types';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import useFocusMessageListElement from './message/hooks/useFocusMessageListElement'; import useFocusMessageListElement from './message/hooks/useFocusMessageListElement';
@ -19,7 +17,6 @@ const MessageListBottomMarker = ({ isFocused, className }: OwnProps) => {
isJustAdded: true, isJustAdded: true,
isFocused, isFocused,
noFocusHighlight: true, noFocusHighlight: true,
focusDirection: FocusDirection.Down,
}); });
return ( return (

View File

@ -1,3 +1,4 @@
import { setExtraStyles } from '@teact/teact-dom';
import { beginHeavyAnimation } from '../lib/teact/teact'; import { beginHeavyAnimation } from '../lib/teact/teact';
import { getGlobal } from '../global'; import { getGlobal } from '../global';
@ -140,6 +141,10 @@ function createMutateFunction(args: AnimateScrollArgs) {
isAnimating = true; isAnimating = true;
setExtraStyles(container, {
scrollSnapType: 'none',
});
const prevOnHeavyAnimationEnd = onHeavyAnimationEnd; const prevOnHeavyAnimationEnd = onHeavyAnimationEnd;
onHeavyAnimationEnd = beginHeavyAnimation(undefined, true); onHeavyAnimationEnd = beginHeavyAnimation(undefined, true);
prevOnHeavyAnimationEnd?.(); prevOnHeavyAnimationEnd?.();
@ -155,6 +160,9 @@ function createMutateFunction(args: AnimateScrollArgs) {
if (!isAnimating) { if (!isAnimating) {
currentArgs = undefined; currentArgs = undefined;
setExtraStyles(container, {
scrollSnapType: '',
});
onHeavyAnimationEnd?.(); onHeavyAnimationEnd?.();
onHeavyAnimationEnd = undefined; onHeavyAnimationEnd = undefined;
@ -170,7 +178,13 @@ export function isAnimatingScroll() {
} }
export function cancelScrollBlockingAnimation() { export function cancelScrollBlockingAnimation() {
onHeavyAnimationEnd!(); if (currentArgs?.container) {
setExtraStyles(currentArgs.container, {
scrollSnapType: '',
});
}
onHeavyAnimationEnd?.();
onHeavyAnimationEnd = undefined; onHeavyAnimationEnd = undefined;
} }