From a244d64670341842182ef9671c098df43bc4eaf6 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:18:26 +0400 Subject: [PATCH] Message List: Fix scroll animation --- src/components/middle/MessageList.scss | 6 +- src/components/middle/MessageList.tsx | 69 +++++++++++-------- .../middle/MessageListBottomMarker.tsx | 3 - src/util/animateScroll.ts | 16 ++++- 4 files changed, 59 insertions(+), 35 deletions(-) diff --git a/src/components/middle/MessageList.scss b/src/components/middle/MessageList.scss index e86ec9faa..5806b2324 100644 --- a/src/components/middle/MessageList.scss +++ b/src/components/middle/MessageList.scss @@ -1,8 +1,6 @@ .MessageList { --action-message-bg: var(--pattern-color); - scroll-snap-type: y proximity; - overflow-x: hidden; overflow-y: scroll; flex: 1; @@ -38,8 +36,8 @@ display: none; } - &.no-bottom-snap { - scroll-snap-type: none; + &.with-bottom-snap { + scroll-snap-type: y proximity; } .messages-container { diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 86fa09b40..74ee4db7a 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -1,4 +1,3 @@ -import type { FC } from '@teact'; import { beginHeavyAnimation, memo, useEffect, useMemo, useRef } from '@teact'; import { addExtraClass, removeExtraClass } from '@teact/teact-dom'; 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 BOTTOM_THRESHOLD = 50; -const BOTTOM_SNAP_THRESHOLD = 10; +const BOTTOM_SNAP_THRESHOLD = 7; const UNREAD_DIVIDER_TOP = 10; const SCROLL_DEBOUNCE = 200; @@ -180,11 +179,11 @@ const SELECT_MODE_ANIMATION_DURATION = 200; const UNREAD_DIVIDER_CLASS = 'unread-divider'; 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 MessageList: FC = ({ +const MessageList = ({ chatId, threadId, type, @@ -243,7 +242,7 @@ const MessageList: FC = ({ onIntersectPinnedMessage, onScrollDownToggle, onNotchToggle, -}) => { +}: OwnProps & StateProps) => { const { loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds, loadMessageViews, loadPeerStoriesByIds, loadFactChecks, requestChatTranslation, @@ -471,6 +470,33 @@ const MessageList: FC = ({ const { isScrolled, updateStickyDates } = useStickyDates(); + const updateBottomSnapClass = useLastCallback(() => { + const container = containerRef.current; + const bottomTrigger = container?.querySelector('.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(() => { if (isScrollTopJustUpdatedRef.current) { isScrollTopJustUpdatedRef.current = false; @@ -486,6 +512,8 @@ const MessageList: FC = ({ updateStickyDates(container); } + updateBottomSnapClass(); + runDebouncedForScroll(() => { const global = getGlobal(); @@ -509,28 +537,14 @@ const MessageList: FC = ({ const [getContainerHeight, prevContainerHeightRef] = useContainerHeight(containerRef, canPost && !isSelectModeActive); const handleWheel = useLastCallback((e: React.WheelEvent) => { - // 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 - 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(() => { - addExtraClass(container, NO_BOTTOM_SNAP_CLASS); - container.scrollBy(0, -BOTTOM_SNAP_THRESHOLD); // Manually scroll to prevent ignoring first event - }); - } else { - requestMutation(() => { - removeExtraClass(container, NO_BOTTOM_SNAP_CLASS); + removeExtraClass(container, BOTTOM_SNAP_CLASS); }); } }); @@ -645,10 +659,12 @@ const MessageList: FC = ({ if (wasMessageAdded) { clearTimeout(scrollSnapDisabledTimerRef.current); - addExtraClass(container, NO_BOTTOM_SNAP_CLASS); + removeExtraClass(container, BOTTOM_SNAP_CLASS); scrollSnapDisabledTimerRef.current = window.setTimeout(() => { - removeExtraClass(container, NO_BOTTOM_SNAP_CLASS); + requestMutation(() => { + addExtraClass(container, BOTTOM_SNAP_CLASS); + }); }, MESSAGE_ANIMATION_DURATION); } @@ -768,7 +784,6 @@ const MessageList: FC = ({ !isReady && 'is-animating', hasOpenChatButton && 'saved-dialog', isChatProtected && 'hide-on-print', - IS_FIREFOX && NO_BOTTOM_SNAP_CLASS, ); const hasMessages = Boolean((messageIds && messageGroups) || lastMessage); diff --git a/src/components/middle/MessageListBottomMarker.tsx b/src/components/middle/MessageListBottomMarker.tsx index 2489553ab..ba5d0ce69 100644 --- a/src/components/middle/MessageListBottomMarker.tsx +++ b/src/components/middle/MessageListBottomMarker.tsx @@ -1,7 +1,5 @@ import { memo, useRef } from '@teact'; -import { FocusDirection } from '../../types'; - import buildClassName from '../../util/buildClassName'; import useFocusMessageListElement from './message/hooks/useFocusMessageListElement'; @@ -19,7 +17,6 @@ const MessageListBottomMarker = ({ isFocused, className }: OwnProps) => { isJustAdded: true, isFocused, noFocusHighlight: true, - focusDirection: FocusDirection.Down, }); return ( diff --git a/src/util/animateScroll.ts b/src/util/animateScroll.ts index 73008038b..9fe83138d 100644 --- a/src/util/animateScroll.ts +++ b/src/util/animateScroll.ts @@ -1,3 +1,4 @@ +import { setExtraStyles } from '@teact/teact-dom'; import { beginHeavyAnimation } from '../lib/teact/teact'; import { getGlobal } from '../global'; @@ -140,6 +141,10 @@ function createMutateFunction(args: AnimateScrollArgs) { isAnimating = true; + setExtraStyles(container, { + scrollSnapType: 'none', + }); + const prevOnHeavyAnimationEnd = onHeavyAnimationEnd; onHeavyAnimationEnd = beginHeavyAnimation(undefined, true); prevOnHeavyAnimationEnd?.(); @@ -155,6 +160,9 @@ function createMutateFunction(args: AnimateScrollArgs) { if (!isAnimating) { currentArgs = undefined; + setExtraStyles(container, { + scrollSnapType: '', + }); onHeavyAnimationEnd?.(); onHeavyAnimationEnd = undefined; @@ -170,7 +178,13 @@ export function isAnimatingScroll() { } export function cancelScrollBlockingAnimation() { - onHeavyAnimationEnd!(); + if (currentArgs?.container) { + setExtraStyles(currentArgs.container, { + scrollSnapType: '', + }); + } + + onHeavyAnimationEnd?.(); onHeavyAnimationEnd = undefined; }