From cab16e395f9b8ebdf3da3ceca9419b5de964d733 Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Mon, 12 Feb 2024 11:24:29 -0600 Subject: [PATCH 1/7] scroller: invert list based on reading direction Fixes LAND-1353. This changes our logic about whether or not to invert the list. Rather than relying on load direction, we make the invert/uninvert decision base on reading direction (whether the user perceives to be scrolling up or down). If scrolling up, we invert. If scrolling down, we "uninvert". This avoids jumpiness associated with scrolling "up" in an uninverted state, or "down" in an inverted state. This also moves a few variables around for easier readability, and moves a few variables into useMemo where it seems appropriate. --- ui/src/chat/ChatScroller/ChatScroller.tsx | 194 ++++++++++++++---- .../ChatScroller/ChatScrollerDebugOverlay.tsx | 6 + ui/src/chat/ChatThread/ChatThread.tsx | 1 + 3 files changed, 160 insertions(+), 41 deletions(-) diff --git a/ui/src/chat/ChatScroller/ChatScroller.tsx b/ui/src/chat/ChatScroller/ChatScroller.tsx index 8ca23f68d8..2f4a27c748 100644 --- a/ui/src/chat/ChatScroller/ChatScroller.tsx +++ b/ui/src/chat/ChatScroller/ChatScroller.tsx @@ -172,6 +172,7 @@ export interface ChatScrollerProps { isLoadingOlder: boolean; isLoadingNewer: boolean; replying?: boolean; + inThread?: boolean; /** * Element to be inserted at the top of the list scroll after we've loaded the * entire history. @@ -193,6 +194,7 @@ export default function ChatScroller({ isLoadingOlder, isLoadingNewer, replying = false, + inThread = false, topLoadEndMarker, scrollTo: rawScrollTo = undefined, scrollerRef, @@ -268,41 +270,96 @@ export default function ChatScroller({ return count - 1; }, [messageKeys, count, scrollTo]); - useObjectChangeLogging( - { - isAtTop, - isAtBottom, - hasLoadedNewest, - hasLoadedOldest, - loadDirection, - anchorIndex, - isLoadingOlder, - isLoadingNewer, - }, - logger - ); - const virtualizerRef = useRef(); + // We need to track whether we're force scrolling so that we don't attempt + // to change reading direction or load new content while we're in the + // middle of a forced scroll. + const isForceScrolling = useRef(false); /** * Set scroll position, bypassing virtualizer change logic. */ const forceScroll = useCallback((offset: number) => { + if (isForceScrolling.current) return; + isForceScrolling.current = true; const virt = virtualizerRef.current; if (!virt) return; virt.scrollOffset = offset; virt.scrollElement?.scrollTo?.({ top: offset }); + setTimeout(() => { + isForceScrolling.current = false; + }, 300); }, []); - const isEmpty = count === 0 && hasLoadedNewest && hasLoadedOldest; + const isEmpty = useMemo( + () => count === 0 && hasLoadedNewest && hasLoadedOldest, + [count, hasLoadedNewest, hasLoadedOldest] + ); const contentHeight = virtualizerRef.current?.getTotalSize() ?? 0; const scrollElementHeight = scrollElementRef.current?.clientHeight ?? 0; - const isScrollable = contentHeight > scrollElementHeight; + const isScrollable = useMemo( + () => contentHeight > scrollElementHeight, + [contentHeight, scrollElementHeight] + ); + + const { clientHeight, scrollTop, scrollHeight } = + scrollElementRef.current ?? { + clientHeight: 0, + scrollTop: 0, + scrollHeight: 0, + }; + // Prevent list from being at the end of new messages and old messages + // at the same time -- can happen if there are few messages loaded. + const atEndThreshold = Math.min( + (scrollHeight - clientHeight) / 2, + thresholds.atEndThreshold + ); + const isAtExactScrollEnd = scrollHeight - scrollTop === clientHeight; + const isAtScrollBeginning = scrollTop === 0; + const isAtScrollEnd = + scrollTop + clientHeight >= scrollHeight - atEndThreshold; + const readingDirectionRef = useRef(''); + + // Determine whether the list should be inverted based on reading direction + // and whether the content is scrollable or if we're scrolling to a specific + // message. + // If the user is scrolling up, we want to keep the list inverted. + // If the user is scrolling down, we want to keep the list normal. + // If the user is at the bottom, we want it inverted (this is set in the readingDirection + // conditions further below). + // If the content is not scrollable, we want it inverted. + // If we're scrolling to a specific message, we want it normal because we + // assume the user is reading from that message down. + // However, if we're scrolling to a particular message in a thread, we want it inverted. + const isInverted = isEmpty ? false - : !isScrollable - ? true - : loadDirection === 'older'; + : userHasScrolled && readingDirectionRef.current === 'down' + ? false + : userHasScrolled && readingDirectionRef.current === 'up' + ? true + : scrollElementRef.current?.clientHeight && !isScrollable + ? true + : scrollTo && !inThread + ? false + : true; + + useObjectChangeLogging( + { + isAtTop, + isAtBottom, + hasLoadedNewest, + hasLoadedOldest, + loadDirection, + anchorIndex, + isLoadingOlder, + isLoadingNewer, + isInverted, + userHasScrolled, + }, + logger + ); + // We want to render newest messages first, but we receive them oldest-first. // This is a simple way to reverse the order without having to reverse a big array. const transformIndex = useCallback( @@ -427,25 +484,13 @@ export default function ChatScroller({ if (anchorIndex !== null && !userHasScrolled) { scrollToAnchor(); } - const { clientHeight, scrollTop, scrollHeight } = - scrollElementRef.current ?? { - clientHeight: 0, - scrollTop: 0, - scrollHeight: 0, - }; - // Prevent list from being at the end of new messages and old messages - // at the same time -- can happen if there are few messages loaded. - const atEndThreshold = Math.min( - (scrollHeight - clientHeight) / 2, - thresholds.atEndThreshold - ); - const isAtScrollBeginning = scrollTop === 0; - const isAtScrollEnd = - scrollTop + clientHeight >= scrollHeight - atEndThreshold; - const nextAtTop = - (isInverted && isAtScrollEnd) || (!isInverted && isAtScrollBeginning); - const nextAtBottom = - (isInverted && isAtScrollBeginning) || (!isInverted && isAtScrollEnd); + const nextAtTop = isForceScrolling.current + ? false + : (isInverted && isAtScrollEnd) || (!isInverted && isAtScrollBeginning); + const nextAtBottom = isForceScrolling.current + ? false + : (isInverted && isAtScrollBeginning) || (!isInverted && isAtScrollEnd); + setIsAtTop(nextAtTop); setIsAtBottom(nextAtBottom); }, [ @@ -453,7 +498,8 @@ export default function ChatScroller({ anchorIndex, userHasScrolled, scrollToAnchor, - scrollElementRef, + isAtScrollBeginning, + isAtScrollEnd, ]), }); virtualizerRef.current = virtualizer; @@ -487,9 +533,18 @@ export default function ChatScroller({ // When the list inverts, we need to flip the scroll position in order to appear to stay in the same place. // We do this here as opposed to in an effect so that virtualItems is correct in time for this render. const lastIsInverted = useRef(isInverted); - if (userHasScrolled && isInverted !== lastIsInverted.current) { + if ( + userHasScrolled && + isInverted !== lastIsInverted.current && + !isLoadingOlder && + !isLoadingNewer + ) { logger.log('inverting chat scroller'); - forceScroll(contentHeight - virtualizer.scrollOffset); + const offset = contentHeight - virtualizerRef.current.scrollOffset; + // We need to subtract the height of the scroll element to get the correct + // offset when inverting. + const newOffset = offset - scrollElementHeight; + forceScroll(newOffset); lastIsInverted.current = isInverted; } @@ -499,6 +554,61 @@ export default function ChatScroller({ // TODO: Distentangle virtualizer init to avoid this. const finalHeight = contentHeight ?? virtualizer.getTotalSize(); + const { scrollDirection } = virtualizerRef.current ?? {}; + + if (userHasScrolled && !isForceScrolling.current) { + logger.log('setting reading direction'); + + if (isInverted) { + if ( + scrollDirection === 'backward' && + readingDirectionRef.current !== 'down' + ) { + logger.log( + 'isInverted and scrollDirection is backward setting reading direction to down' + ); + readingDirectionRef.current = 'down'; + } + + if ( + scrollDirection === 'forward' && + readingDirectionRef.current !== 'up' + ) { + logger.log( + 'isInverted and scrollDirection is forward setting reading direction to up' + ); + readingDirectionRef.current = 'up'; + } + } else { + if ( + scrollDirection === 'backward' && + readingDirectionRef.current !== 'up' + ) { + logger.log( + 'not isInverted and scrollDirection is backward setting reading direction to up' + ); + readingDirectionRef.current = 'up'; + } + + if ( + scrollDirection === 'forward' && + readingDirectionRef.current !== 'down' + ) { + logger.log( + 'not isInverted and scrollDirection is forward setting reading direction to down' + ); + readingDirectionRef.current = 'down'; + } + + if (scrollDirection === null && isAtExactScrollEnd) { + logger.log( + 'not isInverted and scrollDirection is null setting reading direction to up' + ); + readingDirectionRef.current = 'up'; + } + } + } + return ( <>
+ + diff --git a/ui/src/chat/ChatThread/ChatThread.tsx b/ui/src/chat/ChatThread/ChatThread.tsx index f3c3444c15..b610693709 100644 --- a/ui/src/chat/ChatThread/ChatThread.tsx +++ b/ui/src/chat/ChatThread/ChatThread.tsx @@ -234,6 +234,7 @@ export default function ChatThread() { hasLoadedNewest={false} hasLoadedOldest={false} onAtBottom={onAtBottom} + inThread /> )}
From 1de4be56b3fe4d514b6ab06d9a3107e7b3a265bd Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Mon, 12 Feb 2024 11:32:44 -0600 Subject: [PATCH 2/7] scroller: clarify logging on setting direction to up at exact scrollend --- ui/src/chat/ChatScroller/ChatScroller.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/chat/ChatScroller/ChatScroller.tsx b/ui/src/chat/ChatScroller/ChatScroller.tsx index 2f4a27c748..935cf5b47f 100644 --- a/ui/src/chat/ChatScroller/ChatScroller.tsx +++ b/ui/src/chat/ChatScroller/ChatScroller.tsx @@ -602,7 +602,7 @@ export default function ChatScroller({ if (scrollDirection === null && isAtExactScrollEnd) { logger.log( - 'not isInverted and scrollDirection is null setting reading direction to up' + "not isInverted, scrollDirection is null, and we're at the bottom setting reading direction to up" ); readingDirectionRef.current = 'up'; } From dd6035d683da765b9dd32a2f297a8f7450dd96e7 Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Tue, 13 Feb 2024 09:28:45 -0600 Subject: [PATCH 3/7] scroller: adjust some conditions to prevent loading loop when exiting/reopening threads or clicking 'go to latest' --- ui/src/chat/ChatScroller/ChatScroller.tsx | 25 +++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/ui/src/chat/ChatScroller/ChatScroller.tsx b/ui/src/chat/ChatScroller/ChatScroller.tsx index 935cf5b47f..d545bb924f 100644 --- a/ui/src/chat/ChatScroller/ChatScroller.tsx +++ b/ui/src/chat/ChatScroller/ChatScroller.tsx @@ -36,7 +36,7 @@ import { PageTuple, ReplyTuple } from '@/types/channel'; import { useShowDevTools } from '@/state/local'; import ChatScrollerDebugOverlay from './ChatScrollerDebugOverlay'; -const logger = createDevLogger('ChatScroller', false); +const logger = createDevLogger('ChatScroller', true); interface CustomScrollItemData { type: 'custom'; @@ -281,6 +281,7 @@ export default function ChatScroller({ */ const forceScroll = useCallback((offset: number) => { if (isForceScrolling.current) return; + logger.log('force scrolling to', offset); isForceScrolling.current = true; const virt = virtualizerRef.current; if (!virt) return; @@ -371,9 +372,11 @@ export default function ChatScroller({ * Scroll to current anchor index */ const scrollToAnchor = useCallback(() => { - logger.log('scrolling to anchor'); const virt = virtualizerRef.current; if (!virt || anchorIndex === null) return; + logger.log('scrolling to anchor', { + anchorIndex, + }); const index = transformIndex(anchorIndex); const [nextOffset] = virt.getOffsetForIndex(index, 'center'); const measurement = virt.measurementsCache[index]; @@ -386,6 +389,7 @@ export default function ChatScroller({ // Reset scroll when scrollTo changes useEffect(() => { + if (scrollTo === undefined) return; logger.log('scrollto changed'); resetUserHasScrolled(); scrollToAnchor(); @@ -458,7 +462,11 @@ export default function ChatScroller({ // By default, the virtualizer tries to keep the position of the topmost // item on screen pinned, but we need to override that behavior to keep a // message centered or to stay at the bottom of the chat. - if (anchorIndex !== null && !userHasScrolled) { + if ( + anchorIndex !== null && + !userHasScrolled && + !isForceScrolling.current + ) { // Fix for no-param-reassign scrollToAnchor(); } else { @@ -481,7 +489,11 @@ export default function ChatScroller({ // Called by the virtualizer whenever any layout property changes. // We're using it to keep track of top and bottom thresholds. onChange: useCallback(() => { - if (anchorIndex !== null && !userHasScrolled) { + if ( + anchorIndex !== null && + !userHasScrolled && + !isForceScrolling.current + ) { scrollToAnchor(); } const nextAtTop = isForceScrolling.current @@ -534,16 +546,15 @@ export default function ChatScroller({ // We do this here as opposed to in an effect so that virtualItems is correct in time for this render. const lastIsInverted = useRef(isInverted); if ( - userHasScrolled && isInverted !== lastIsInverted.current && !isLoadingOlder && !isLoadingNewer ) { - logger.log('inverting chat scroller'); const offset = contentHeight - virtualizerRef.current.scrollOffset; // We need to subtract the height of the scroll element to get the correct // offset when inverting. const newOffset = offset - scrollElementHeight; + logger.log('inverting chat scroller, setting offset to', newOffset); forceScroll(newOffset); lastIsInverted.current = isInverted; } @@ -557,8 +568,6 @@ export default function ChatScroller({ const { scrollDirection } = virtualizerRef.current ?? {}; if (userHasScrolled && !isForceScrolling.current) { - logger.log('setting reading direction'); - if (isInverted) { if ( scrollDirection === 'backward' && From 6e0f398feba2369534169eca8a133d6ca3b87044 Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Tue, 13 Feb 2024 12:12:35 -0600 Subject: [PATCH 4/7] scroller: fix an issue (scrollTo not triggering) caused by delaying forceScrolls on inversion flips --- ui/src/chat/ChatScroller/ChatScroller.tsx | 38 ++++++++++++++--------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/ui/src/chat/ChatScroller/ChatScroller.tsx b/ui/src/chat/ChatScroller/ChatScroller.tsx index d545bb924f..ab29cb13c9 100644 --- a/ui/src/chat/ChatScroller/ChatScroller.tsx +++ b/ui/src/chat/ChatScroller/ChatScroller.tsx @@ -36,7 +36,7 @@ import { PageTuple, ReplyTuple } from '@/types/channel'; import { useShowDevTools } from '@/state/local'; import ChatScrollerDebugOverlay from './ChatScrollerDebugOverlay'; -const logger = createDevLogger('ChatScroller', true); +const logger = createDevLogger('ChatScroller', false); interface CustomScrollItemData { type: 'custom'; @@ -279,18 +279,23 @@ export default function ChatScroller({ /** * Set scroll position, bypassing virtualizer change logic. */ - const forceScroll = useCallback((offset: number) => { - if (isForceScrolling.current) return; - logger.log('force scrolling to', offset); - isForceScrolling.current = true; - const virt = virtualizerRef.current; - if (!virt) return; - virt.scrollOffset = offset; - virt.scrollElement?.scrollTo?.({ top: offset }); - setTimeout(() => { - isForceScrolling.current = false; - }, 300); - }, []); + const forceScroll = useCallback( + (offset: number, bypassDelay = false) => { + if (isForceScrolling.current && !bypassDelay) return; + if (!inThread) { + logger.log('force scrolling to', offset); + } + isForceScrolling.current = true; + const virt = virtualizerRef.current; + if (!virt) return; + virt.scrollOffset = offset; + virt.scrollElement?.scrollTo?.({ top: offset }); + setTimeout(() => { + isForceScrolling.current = false; + }, 300); + }, + [inThread] + ); const isEmpty = useMemo( () => count === 0 && hasLoadedNewest && hasLoadedOldest, @@ -357,6 +362,7 @@ export default function ChatScroller({ isLoadingNewer, isInverted, userHasScrolled, + isForceScrolling: isForceScrolling.current, }, logger ); @@ -390,7 +396,7 @@ export default function ChatScroller({ // Reset scroll when scrollTo changes useEffect(() => { if (scrollTo === undefined) return; - logger.log('scrollto changed'); + logger.log('scrollto changed', scrollTo?.toString()); resetUserHasScrolled(); scrollToAnchor(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -555,7 +561,9 @@ export default function ChatScroller({ // offset when inverting. const newOffset = offset - scrollElementHeight; logger.log('inverting chat scroller, setting offset to', newOffset); - forceScroll(newOffset); + // We need to bypass the delay here because we're inverting the scroll + // immediately after the user has scrolled in this case. + forceScroll(newOffset, true); lastIsInverted.current = isInverted; } From e50ae1307b17a07a777f894d7c5724426b4bcbe7 Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Fri, 16 Feb 2024 06:52:29 -0600 Subject: [PATCH 5/7] Fixed issues with package.json files, fixed import order issues in files for this branch --- package-lock.json | 2 +- package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c775186f9..d49436eb65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "landscape-apps-mono", + "name": "homestead", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index ebc8c5a5cb..ebe4ebaccd 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "build:shared": "npm run build --prefix ./packages/shared", "build:web": "npm run build:shared && npm run build --prefix ./apps/tlon-web", "dev:shared": "npm run dev --prefix ./packages/shared", - "dev:android": "concurrently \"npm run dev:shared\" \"npm run dev --prefix ./apps/tlon-mobile\" \"npm run android --prefix ./apps/tlon-mobile\"", + "dev:android": "concurrently \"npm run dev --prefix ./apps/tlon-mobile\" \"npm run android --prefix ./apps/tlon-mobile\"", "dev:ios": "concurrently \"npm run dev:shared\" \"npm run dev --prefix ./apps/tlon-mobile\" \"npm run ios --prefix ./apps/tlon-mobile\"", - "dev:web": "concurrently \"npm run dev:shared\" \"npm run dev-no-ssl --prefix ./apps/tlon-web\"", + "dev:web": "npm run dev --prefix ./apps/tlon-web", "test": "npm run test --workspaces --if-present -- run -u", "prepare": "husky", "postinstall": "patch-package" From a7693825e5f3ad96b3a62ad62311812d60eca813 Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Fri, 16 Feb 2024 13:28:57 -0600 Subject: [PATCH 6/7] revert change to package.json --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ebe4ebaccd..ebc8c5a5cb 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "build:shared": "npm run build --prefix ./packages/shared", "build:web": "npm run build:shared && npm run build --prefix ./apps/tlon-web", "dev:shared": "npm run dev --prefix ./packages/shared", - "dev:android": "concurrently \"npm run dev --prefix ./apps/tlon-mobile\" \"npm run android --prefix ./apps/tlon-mobile\"", + "dev:android": "concurrently \"npm run dev:shared\" \"npm run dev --prefix ./apps/tlon-mobile\" \"npm run android --prefix ./apps/tlon-mobile\"", "dev:ios": "concurrently \"npm run dev:shared\" \"npm run dev --prefix ./apps/tlon-mobile\" \"npm run ios --prefix ./apps/tlon-mobile\"", - "dev:web": "npm run dev --prefix ./apps/tlon-web", + "dev:web": "concurrently \"npm run dev:shared\" \"npm run dev-no-ssl --prefix ./apps/tlon-web\"", "test": "npm run test --workspaces --if-present -- run -u", "prepare": "husky", "postinstall": "patch-package" From f796e74325374cf712c72c5e6f759b1519e98a26 Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Tue, 20 Feb 2024 06:07:56 -0600 Subject: [PATCH 7/7] scroller: throttle reading direction change to reduce jumpiness, move reading direction change logic into useEffect --- .../src/chat/ChatScroller/ChatScroller.tsx | 153 +++++++++++------- 1 file changed, 91 insertions(+), 62 deletions(-) diff --git a/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx b/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx index fa3d13f341..7ef0a38738 100644 --- a/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx +++ b/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx @@ -281,23 +281,18 @@ export default function ChatScroller({ /** * Set scroll position, bypassing virtualizer change logic. */ - const forceScroll = useCallback( - (offset: number, bypassDelay = false) => { - if (isForceScrolling.current && !bypassDelay) return; - if (!inThread) { - logger.log('force scrolling to', offset); - } - isForceScrolling.current = true; - const virt = virtualizerRef.current; - if (!virt) return; - virt.scrollOffset = offset; - virt.scrollElement?.scrollTo?.({ top: offset }); - setTimeout(() => { - isForceScrolling.current = false; - }, 300); - }, - [inThread] - ); + const forceScroll = useCallback((offset: number, bypassDelay = false) => { + if (isForceScrolling.current && !bypassDelay) return; + logger.log('force scrolling to', offset); + isForceScrolling.current = true; + const virt = virtualizerRef.current; + if (!virt) return; + virt.scrollOffset = offset; + virt.scrollElement?.scrollTo?.({ top: offset }); + setTimeout(() => { + isForceScrolling.current = false; + }, 300); + }, []); const isEmpty = useMemo( () => count === 0 && hasLoadedNewest && hasLoadedOldest, @@ -577,56 +572,90 @@ export default function ChatScroller({ const { scrollDirection } = virtualizerRef.current ?? {}; - if (userHasScrolled && !isForceScrolling.current) { - if (isInverted) { - if ( - scrollDirection === 'backward' && - readingDirectionRef.current !== 'down' - ) { - logger.log( - 'isInverted and scrollDirection is backward setting reading direction to down' - ); - readingDirectionRef.current = 'down'; - } + const lastOffset = useRef(null); - if ( - scrollDirection === 'forward' && - readingDirectionRef.current !== 'up' - ) { - logger.log( - 'isInverted and scrollDirection is forward setting reading direction to up' - ); - readingDirectionRef.current = 'up'; - } - } else { - if ( - scrollDirection === 'backward' && - readingDirectionRef.current !== 'up' - ) { - logger.log( - 'not isInverted and scrollDirection is backward setting reading direction to up' - ); - readingDirectionRef.current = 'up'; - } + useEffect(() => { + if (lastOffset.current === null) { + lastOffset.current = virtualizer.scrollOffset; + } - if ( - scrollDirection === 'forward' && - readingDirectionRef.current !== 'down' - ) { - logger.log( - 'not isInverted and scrollDirection is forward setting reading direction to down' - ); - readingDirectionRef.current = 'down'; - } + if (isScrolling) { + lastOffset.current = virtualizer.scrollOffset; + } + }, [isScrolling, virtualizer.scrollOffset]); + + // We use the absolute change in scroll offset to throttle the change in + // reading direction. This is because the scroll direction can change + // rapidly when the user is scrolling, and we don't want to change the + // reading direction too quickly, it can be jumpy. + // There is still a small jump when the user changes direction, but it's + // less noticeable than if we didn't throttle it. + const absoluteOffsetChange = lastOffset.current + ? Math.abs(virtualizer.scrollOffset - lastOffset.current) + : 0; - if (scrollDirection === null && isAtExactScrollEnd) { - logger.log( - "not isInverted, scrollDirection is null, and we're at the bottom setting reading direction to up" - ); - readingDirectionRef.current = 'up'; + useEffect(() => { + if ( + userHasScrolled && + !isForceScrolling.current && + absoluteOffsetChange > 30 + ) { + if (isInverted) { + if ( + scrollDirection === 'backward' && + readingDirectionRef.current !== 'down' + ) { + logger.log( + 'isInverted and scrollDirection is backward setting reading direction to down' + ); + readingDirectionRef.current = 'down'; + } + + if ( + scrollDirection === 'forward' && + readingDirectionRef.current !== 'up' + ) { + logger.log( + 'isInverted and scrollDirection is forward setting reading direction to up' + ); + readingDirectionRef.current = 'up'; + } + } else { + if ( + scrollDirection === 'backward' && + readingDirectionRef.current !== 'up' + ) { + logger.log( + 'not isInverted and scrollDirection is backward setting reading direction to up' + ); + readingDirectionRef.current = 'up'; + } + + if ( + scrollDirection === 'forward' && + readingDirectionRef.current !== 'down' + ) { + logger.log( + 'not isInverted and scrollDirection is forward setting reading direction to down' + ); + readingDirectionRef.current = 'down'; + } + + if (scrollDirection === null && isAtExactScrollEnd) { + logger.log( + "not isInverted, scrollDirection is null, and we're at the bottom setting reading direction to up" + ); + readingDirectionRef.current = 'up'; + } } } - } + }, [ + scrollDirection, + userHasScrolled, + isAtExactScrollEnd, + isInverted, + absoluteOffsetChange, + ]); return ( <>