From 591c526be3379c3bc0ae052a013748160e5f50af Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:52:27 +0200 Subject: [PATCH] UX: Rewrite overscroll detection (#6271) --- src/components/left/main/ChatList.tsx | 3 +- src/components/right/Profile.tsx | 10 +- src/hooks/scroll/useTopOverscroll.tsx | 281 +++++++++++++++++--------- src/styles/index.scss | 8 + 4 files changed, 193 insertions(+), 109 deletions(-) diff --git a/src/components/left/main/ChatList.tsx b/src/components/left/main/ChatList.tsx index 9cee48277..dff9ca886 100644 --- a/src/components/left/main/ChatList.tsx +++ b/src/components/left/main/ChatList.tsx @@ -215,7 +215,7 @@ const ChatList: FC = ({ toggleStoryRibbon({ isShown: false, isArchived }); }); - const renderedOverflowTrigger = useTopOverscroll(containerRef, handleShowStoryRibbon, handleHideStoryRibbon, isSaved); + useTopOverscroll(containerRef, handleShowStoryRibbon, handleHideStoryRibbon, isSaved); function renderChats() { const viewportOffset = orderedIds!.indexOf(viewportIds![0]); @@ -254,7 +254,6 @@ const ChatList: FC = ({ itemSelector=".ListItem:not(.chat-item-archive)" preloadBackwards={CHAT_LIST_SLICE} withAbsolutePositioning - beforeChildren={renderedOverflowTrigger} maxHeight={chatsHeight + archiveHeight + frozenNotificationHeight + unconfirmedSessionHeight} onLoadMore={getMore} onDragLeave={handleDragLeave} diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index 015b21c1c..0f2d87e52 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -25,7 +25,6 @@ import { MEMBERS_SLICE, PROFILE_SENSITIVE_AREA, SHARED_MEDIA_SLICE, SLIDE_TRANSI import { selectActiveGiftsCollectionId } from '../../global/selectors/payments'; const CONTENT_PANEL_SHOW_DELAY = 300; -import { forceMutation } from '../../lib/fasterdom/fasterdom.ts'; import { getHasAdminRight, getIsDownloading, @@ -71,7 +70,6 @@ import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment'; import buildClassName from '../../util/buildClassName'; import { captureEvents, SwipeDirection } from '../../util/captureEvents'; import { isUserId } from '../../util/entities/ids'; -import { stopScrollInertia } from '../../util/resetScroll.ts'; import { resolveTransitionName } from '../../util/resolveTransitionName.ts'; import { LOCAL_TGS_URLS } from '../common/helpers/animatedAssets'; import renderText from '../common/helpers/renderText'; @@ -531,12 +529,7 @@ const Profile: FC = ({ const handleCollapseProfile = useLastCallback(() => { if (!isProfileExpanded) return; - const scrollContainer = containerRef.current; startViewTransition(VTT_RIGHT_PROFILE_COLLAPSE, () => { - if (!scrollContainer) return; - forceMutation(() => { - stopScrollInertia(scrollContainer); - }, scrollContainer); collapseProfile(); }); }); @@ -622,7 +615,7 @@ const Profile: FC = ({ resetGiftProfileFilter({ peerId: chatId }); }); - const renderedOverflowTrigger = useTopOverscroll( + useTopOverscroll( containerRef, handleExpandProfile, handleCollapseProfile, !hasAvatar, ); @@ -1114,7 +1107,6 @@ const Profile: FC = ({ itemSelector={itemSelector} items={canRenderContent ? viewportIds : undefined} cacheBuster={cacheBuster} - beforeChildren={renderedOverflowTrigger} sensitiveArea={PROFILE_SENSITIVE_AREA} preloadBackwards={canRenderContent ? (resultType === 'members' ? MEMBERS_SLICE : SHARED_MEDIA_SLICE) : 0} // To prevent scroll jumps caused by reordering member list diff --git a/src/hooks/scroll/useTopOverscroll.tsx b/src/hooks/scroll/useTopOverscroll.tsx index cf80c05cd..48f7afe3a 100644 --- a/src/hooks/scroll/useTopOverscroll.tsx +++ b/src/hooks/scroll/useTopOverscroll.tsx @@ -1,16 +1,34 @@ -import type { ElementRef } from '../../lib/teact/teact'; -import { useEffect, useRef } from '../../lib/teact/teact'; +import { type ElementRef, useEffect, useRef, useSignal } from '@teact'; -import { forceMutation, requestMutation } from '../../lib/fasterdom/fasterdom'; -import { IS_TAURI } from '../../util/browser/globalEnvironment'; -import { IS_IOS, IS_SAFARI } from '../../util/browser/windowEnvironment'; -import { stopScrollInertia } from '../../util/resetScroll'; -import useDebouncedCallback from '../useDebouncedCallback'; +import { requestMutation } from '../../lib/fasterdom/fasterdom'; +import stopEvent from '../../util/stopEvent'; import useLastCallback from '../useLastCallback'; -const MOUSE_WHEEL_DEBOUNCE = 250; -const TRIGGER_HEIGHT = 1; -const INERTIA_THRESHOLD = 100; +type State = 'overscroll' | 'animating' | 'normal'; + +type ActiveScrollContext = { + lastDeltas: number[]; + lastAverageDelta: number; + isStartedAtTop: boolean; + resetStartTopAt?: number; + timeout: number | undefined; +}; + +const LAST_DELTA_COUNT = 7; +const ACTIVE_SCROLL_RESET_TIMEOUT = 100; +const NEW_INPUT_DELTA_THRESHOLD = 7; +const OVERSCROLL_CONTAINER_CLASS = 'no-overscroll'; +const NO_TOUCH_CONTAINER_CLASS = 'no-touch'; +const TRANSITION_DURATION = 350; +const DRAG_TRIGGER_DISTANCE = 75; + +const initialActiveScrollContext: ActiveScrollContext = { + lastDeltas: new Array(LAST_DELTA_COUNT).fill(0), + lastAverageDelta: 0, + isStartedAtTop: false, + resetStartTopAt: undefined, + timeout: undefined, +}; export default function useTopOverscroll( containerRef: ElementRef, @@ -18,121 +36,188 @@ export default function useTopOverscroll( onReset?: AnyToVoidFunction, isDisabled?: boolean, ) { - const overscrollTriggerRef = useRef(); + const [getState, setState] = useSignal('normal'); + const activeScrollRef = useRef({ ...initialActiveScrollContext }); + const transitionTimeoutRef = useRef(); + const touchStartYRef = useRef(); - const isTriggerJustEnabled = useRef(false); - const lastScrollTopRef = useRef(0); - const isTriggerEnabledRef = useRef(false); - const lastIsOnTopRef = useRef(true); - const lastScrollAtRef = useRef(0); - const isReturningOverscrollRef = useRef(false); - const lastCalledStateRef = useRef<'overscroll' | 'reset' | undefined>(undefined); - - const enableOverscrollTrigger = useLastCallback((noScrollInertiaStop = false) => { - if (isTriggerEnabledRef.current) return; - if (!overscrollTriggerRef.current || !containerRef.current) return; - - overscrollTriggerRef.current.style.display = 'block'; - containerRef.current.scrollTop = TRIGGER_HEIGHT; - - if (!IS_SAFARI && !noScrollInertiaStop && !IS_TAURI) { - stopScrollInertia(containerRef.current); - } - - isTriggerJustEnabled.current = true; - lastScrollTopRef.current = TRIGGER_HEIGHT; - isTriggerEnabledRef.current = true; - lastIsOnTopRef.current = true; + const triggerOverscroll = useLastCallback(() => { + clearTimeout(transitionTimeoutRef.current); + setState('overscroll'); + onOverscroll?.(); }); - const disableOverscrollTrigger = useLastCallback(() => { - if (!isTriggerEnabledRef.current) return; - if (!overscrollTriggerRef.current) return; - - overscrollTriggerRef.current.style.display = 'none'; - - isTriggerEnabledRef.current = false; + const triggerReset = useLastCallback(() => { + setState('animating'); + transitionTimeoutRef.current = window.setTimeout(() => { + setState('normal'); + }, TRANSITION_DURATION); + onReset?.(); }); - const handleScroll = useLastCallback(() => { - if (!containerRef.current) return; - - if (isTriggerJustEnabled.current) { - isTriggerJustEnabled.current = false; + const scheduleResetActiveScroll = useLastCallback((timeout: number) => { + clearTimeout(activeScrollRef.current.timeout); + activeScrollRef.current.timeout = window.setTimeout(() => { + activeScrollRef.current = { ...initialActiveScrollContext }; + }, timeout); + }); + const handleWheel = useLastCallback((e: WheelEvent) => { + const container = containerRef.current; + if (!container || e.defaultPrevented) { return; } - const newScrollTop = containerRef.current.scrollTop; - const isMovingDown = newScrollTop > lastScrollTopRef.current; - const isMovingUp = newScrollTop < lastScrollTopRef.current; - const isOnTop = newScrollTop === 0; - const lastEventDelay = Date.now() - lastScrollAtRef.current; + const { deltaY } = e; + const { scrollTop } = container; + const state = getState(); - if (overscrollTriggerRef.current) { - if (isOnTop && !isTriggerEnabledRef.current) { - forceMutation(enableOverscrollTrigger, [containerRef.current, overscrollTriggerRef.current]); - return; + const activeScroll = activeScrollRef.current; + const lastAverageDelta = activeScroll.lastAverageDelta; + + const isStarting = activeScroll.lastDeltas.at(-1) === 0 + || (activeScroll.resetStartTopAt && Date.now() >= activeScroll.resetStartTopAt); + if (scrollTop === 0 && isStarting) { + activeScroll.isStartedAtTop = true; + activeScroll.resetStartTopAt = undefined; + } + + const lastDeltas = activeScrollRef.current.lastDeltas.slice(); // Copy + lastDeltas.push(deltaY); + if (lastDeltas.length > LAST_DELTA_COUNT) { + lastDeltas.shift(); + } + activeScrollRef.current.lastDeltas = lastDeltas; + const currentAverageDelta = lastDeltas.reduce((a, b) => a + b, 0) / lastDeltas.length; + activeScrollRef.current.lastAverageDelta = currentAverageDelta; + + const isNewInput = Math.abs(currentAverageDelta) - Math.abs(lastAverageDelta) > NEW_INPUT_DELTA_THRESHOLD; + + scheduleResetActiveScroll(ACTIVE_SCROLL_RESET_TIMEOUT); + + // If we're at the top and scrolling up + if (scrollTop === 0 && deltaY < 0 && state !== 'overscroll') { + if (!activeScroll.resetStartTopAt) { + // Schedule delta reset, so we would respond to new input with `isStartedAtTop` flag set + activeScroll.resetStartTopAt = Date.now() + ACTIVE_SCROLL_RESET_TIMEOUT; } - forceMutation(disableOverscrollTrigger, overscrollTriggerRef.current); + // Only trigger overscroll on new input, ignore momentum events + if (isNewInput && activeScroll.isStartedAtTop) { + triggerOverscroll(); + } + return; } - if (lastCalledStateRef.current !== 'overscroll' && isMovingUp - && ( - (lastIsOnTopRef.current && lastEventDelay > INERTIA_THRESHOLD) - || (newScrollTop < 0 && isReturningOverscrollRef.current) // Overscroll repeated by the user - )) { - onOverscroll?.(); - lastCalledStateRef.current = 'overscroll'; - } else if (lastCalledStateRef.current !== 'reset' && isMovingDown && newScrollTop > 0) { - onReset?.(); - lastCalledStateRef.current = 'reset'; + // Ignore scroll events during collapse animation + if (state === 'animating' && deltaY > 0) { + stopEvent(e); + return; } - lastScrollTopRef.current = newScrollTop; - lastIsOnTopRef.current = isOnTop; - lastScrollAtRef.current = Date.now(); - isReturningOverscrollRef.current = isMovingDown && newScrollTop < 0; + // If we're overscrolled, any down wheel event should reset + if (state === 'overscroll' && deltaY > 0) { + triggerReset(); + stopEvent(e); + return; + } }); - // Handle non-scrollable container - const handleWheel = useDebouncedCallback((event: WheelEvent) => { - if (!containerRef.current) return; + const handleTouchStart = useLastCallback((e: TouchEvent) => { const container = containerRef.current; + if (!container || e.touches.length !== 1) return; - const isScrollable = container.scrollHeight > container.offsetHeight; - if (isScrollable || event.deltaY === 0) return; + const { scrollTop } = container; + const state = getState(); - if (lastCalledStateRef.current !== 'overscroll' && event.deltaY < 0) { - onOverscroll?.(); - lastCalledStateRef.current = 'overscroll'; - } else if (lastCalledStateRef.current !== 'reset') { - onReset?.(); - lastCalledStateRef.current = 'reset'; + // Register touch start position when at top or in overscroll state + if (scrollTop === 0 || state === 'overscroll') { + touchStartYRef.current = e.touches[0].clientY; } - }, [containerRef, onOverscroll, onReset], MOUSE_WHEEL_DEBOUNCE); + }); + + const handleTouchMove = useLastCallback((e: TouchEvent) => { + const container = containerRef.current; + const startY = touchStartYRef.current; + if (!container || startY === undefined || e.touches.length !== 1) return; + + const { scrollTop } = container; + const state = getState(); + const currentY = e.touches[0].clientY; + const deltaY = currentY - startY; + + if (state === 'animating') { + return; + } + + // If we're at the top and dragging down by more than trigger distance + if (scrollTop === 0 && deltaY > DRAG_TRIGGER_DISTANCE && state !== 'overscroll') { + triggerOverscroll(); + touchStartYRef.current = undefined; // Reset to prevent multiple triggers + return; + } + + // If we're overscrolled and dragging up by more than trigger distance, reset + if (state === 'overscroll' && deltaY < -DRAG_TRIGGER_DISTANCE) { + triggerReset(); + touchStartYRef.current = undefined; // Reset to prevent multiple triggers + return; + } + }); + + const handleTouchEnd = useLastCallback(() => { + touchStartYRef.current = undefined; + }); useEffect(() => { const container = containerRef.current; - if (!container) return undefined; - - if (container.scrollTop === 0) { - requestMutation(() => { - enableOverscrollTrigger(true); - }); - } - - container.addEventListener('scroll', handleScroll, { passive: true }); - container.addEventListener('wheel', handleWheel, { passive: true }); + if (isDisabled || !container) return; + requestMutation(() => { + container.classList.add(OVERSCROLL_CONTAINER_CLASS); + }); return () => { - container.removeEventListener('scroll', handleScroll); - container.removeEventListener('wheel', handleWheel); + requestMutation(() => { + container.classList.remove(OVERSCROLL_CONTAINER_CLASS); + }); }; - }, [containerRef, handleWheel]); + }, [containerRef, isDisabled]); - return !IS_IOS && !isDisabled ? ( -
- ) : undefined; + useEffect(() => { + const container = containerRef.current; + if (isDisabled || !container) return; + requestMutation(() => { + container.classList.toggle(NO_TOUCH_CONTAINER_CLASS, getState() !== 'normal'); + }); + + return () => { + requestMutation(() => { + container.classList.remove(NO_TOUCH_CONTAINER_CLASS); + }); + }; + }, [containerRef, isDisabled, getState]); + + useEffect(() => { + const container = containerRef.current; + if (isDisabled || !container) { + return undefined; + } + + container.addEventListener('wheel', handleWheel, { passive: getState() === 'normal' }); + container.addEventListener('touchstart', handleTouchStart, { passive: true }); + container.addEventListener('touchmove', handleTouchMove, { passive: true }); + container.addEventListener('touchend', handleTouchEnd, { passive: true }); + container.addEventListener('touchcancel', handleTouchEnd, { passive: true }); + + return () => { + container.removeEventListener('wheel', handleWheel); + container.removeEventListener('touchstart', handleTouchStart); + container.removeEventListener('touchmove', handleTouchMove); + container.removeEventListener('touchend', handleTouchEnd); + container.removeEventListener('touchcancel', handleTouchEnd); + + const activeScroll = activeScrollRef.current; + if (activeScroll?.timeout) clearTimeout(activeScroll.timeout); + }; + }, [containerRef, handleWheel, handleTouchStart, handleTouchMove, handleTouchEnd, getState, isDisabled]); } diff --git a/src/styles/index.scss b/src/styles/index.scss index a6cc412fc..1fdc2d3a1 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -226,6 +226,14 @@ body:not(.is-ios) { } } +.no-overscroll { + overscroll-behavior: none; +} + +.no-touch { + touch-action: none; +} + .emoji-small { overflow: hidden; display: inline-block;