Skip to content

Commit

Permalink
Merge pull request #3236 from tloncorp/po/land-1353-invert-list-only-…
Browse files Browse the repository at this point in the history
…when-appropriate

scroller: invert list based on reading direction
  • Loading branch information
patosullivan authored Feb 22, 2024
2 parents ccb391d + f796e74 commit 77cf0c2
Show file tree
Hide file tree
Showing 3 changed files with 212 additions and 47 deletions.
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 @@ -251,6 +251,7 @@ export default function ChatThread() {
hasLoadedNewest={false}
hasLoadedOldest={false}
onAtBottom={onAtBottom}
inThread
/>
)}
</div>
Expand Down

0 comments on commit 77cf0c2

Please sign in to comment.