Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

scroller: invert list based on reading direction #3236

Merged
merged 10 commits into from
Feb 22, 2024
Merged
252 changes: 205 additions & 47 deletions apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,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.
Expand All @@ -195,6 +196,7 @@ export default function ChatScroller({
isLoadingOlder,
isLoadingNewer,
replying = false,
inThread = false,
topLoadEndMarker,
scrollTo: rawScrollTo = undefined,
scrollerRef,
Expand Down Expand Up @@ -270,41 +272,98 @@ export default function ChatScroller({
return count - 1;
}, [messageKeys, count, scrollTo]);

useObjectChangeLogging(
{
isAtTop,
isAtBottom,
hasLoadedNewest,
hasLoadedOldest,
loadDirection,
anchorIndex,
isLoadingOlder,
isLoadingNewer,
},
logger
);

const virtualizerRef = useRef<DivVirtualizer>();
// 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) => {
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 = 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,
isForceScrolling: isForceScrolling.current,
},
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(
Expand All @@ -316,9 +375,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];
Expand All @@ -331,7 +392,8 @@ export default function ChatScroller({

// Reset scroll when scrollTo changes
useEffect(() => {
logger.log('scrollto changed');
if (scrollTo === undefined) return;
logger.log('scrollto changed', scrollTo?.toString());
resetUserHasScrolled();
scrollToAnchor();
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -403,7 +465,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 {
Expand All @@ -426,36 +492,29 @@ 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 { 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);
}, [
isInverted,
anchorIndex,
userHasScrolled,
scrollToAnchor,
scrollElementRef,
isAtScrollBeginning,
isAtScrollEnd,
]),
});
virtualizerRef.current = virtualizer;
Expand Down Expand Up @@ -489,9 +548,19 @@ 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) {
logger.log('inverting chat scroller');
forceScroll(contentHeight - virtualizer.scrollOffset);
if (
isInverted !== lastIsInverted.current &&
!isLoadingOlder &&
!isLoadingNewer
) {
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);
// 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;
}

Expand All @@ -501,6 +570,93 @@ export default function ChatScroller({
// TODO: Distentangle virtualizer init to avoid this.
const finalHeight = contentHeight ?? virtualizer.getTotalSize();

const { scrollDirection } = virtualizerRef.current ?? {};

const lastOffset = useRef<number | null>(null);

useEffect(() => {
if (lastOffset.current === null) {
lastOffset.current = virtualizer.scrollOffset;
}

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;

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 (
<>
<div
Expand Down Expand Up @@ -561,6 +717,8 @@ export default function ChatScroller({
count,
scrollOffset: virtualizer.scrollOffset,
scrollHeight: finalHeight,
scrollDirection: virtualizer.scrollDirection,
readingDirection: readingDirectionRef.current,
hasLoadedNewest,
hasLoadedOldest,
anchorIndex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export default function ChatScrollerDebugOverlay({
count,
anchorIndex,
scrollHeight,
scrollDirection,
readingDirection,
scrollOffset,
isLoadingOlder,
isLoadingNewer,
Expand All @@ -27,6 +29,8 @@ export default function ChatScrollerDebugOverlay({
anchorIndex?: number | null;
scrollOffset: number;
scrollHeight: number;
scrollDirection: 'forward' | 'backward' | null;
readingDirection: string;
isLoadingOlder: boolean;
isLoadingNewer: boolean;
hasLoadedNewest: boolean;
Expand Down Expand Up @@ -59,6 +63,8 @@ export default function ChatScrollerDebugOverlay({
{Math.round(scrollOffset)}/{scrollHeight}
</label>
<label>Load direction: {loadDirection}</label>
<label>Scroll direction: {scrollDirection ?? 'none'}</label>
<label>Reading direction: {readingDirection}</label>
<label> {count} items</label>
<DebugBoolean label="User scrolled" value={userHasScrolled} />
<DebugBoolean label="At bottom" value={isAtBottom} />
Expand Down
1 change: 1 addition & 0 deletions apps/tlon-web/src/chat/ChatThread/ChatThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export default function ChatThread() {
hasLoadedNewest={false}
hasLoadedOldest={false}
onAtBottom={onAtBottom}
inThread
/>
)}
</div>
Expand Down
Loading