From c5641ac2b7bdcfdc4627175c7125131faf7c9744 Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Fri, 16 Feb 2024 18:07:47 +0000
Subject: Fix jumps when navigating into long threads (#2878)

* Reveal parents in chunks to fix scroll jumps

Co-authored-by: Hailey <me@haileyok.com>

* Prevent layout jump when navigating to QT due to missing metrics

---------

Co-authored-by: Hailey <me@haileyok.com>
---
 src/view/com/post-thread/PostThread.tsx | 86 +++++++++++++++++++++++----------
 1 file changed, 60 insertions(+), 26 deletions(-)

(limited to 'src/view/com/post-thread/PostThread.tsx')

diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 2b08bc402..434f018fc 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -43,10 +43,13 @@ import {
   usePreferencesQuery,
 } from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
-import {isAndroid, isNative} from '#/platform/detection'
-import {logger} from '#/logger'
+import {isAndroid, isNative, isWeb} from '#/platform/detection'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 
+// FlatList maintainVisibleContentPosition breaks if too many items
+// are prepended. This seems to be an optimal number based on *shrug*.
+const PARENTS_CHUNK_SIZE = 15
+
 const MAINTAIN_VISIBLE_CONTENT_POSITION = {
   // We don't insert any elements before the root row while loading.
   // So the row we want to use as the scroll anchor is the first row.
@@ -165,8 +168,10 @@ function PostThreadLoaded({
   const {isMobile, isTabletOrMobile} = useWebMediaQueries()
   const ref = useRef<ListMethods>(null)
   const highlightedPostRef = useRef<View | null>(null)
-  const [maxVisible, setMaxVisible] = React.useState(100)
-  const [isPTRing, setIsPTRing] = React.useState(false)
+  const [maxParents, setMaxParents] = React.useState(
+    isWeb ? Infinity : PARENTS_CHUNK_SIZE,
+  )
+  const [maxReplies, setMaxReplies] = React.useState(100)
   const treeView = React.useMemo(
     () => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread),
     [threadViewPrefs, thread],
@@ -206,10 +211,18 @@ function PostThreadLoaded({
           // maintainVisibleContentPosition and onContentSizeChange
           // to "hold onto" the correct row instead of the first one.
         } else {
-          // Everything is loaded.
-          arr.push(TOP_COMPONENT)
-          for (const parent of parents) {
-            arr.push(parent)
+          // Everything is loaded
+          let startIndex = Math.max(0, parents.length - maxParents)
+          if (startIndex === 0) {
+            arr.push(TOP_COMPONENT)
+          } else {
+            // When progressively revealing parents, rendering a placeholder
+            // here will cause scrolling jumps. Don't add it unless you test it.
+            // QT'ing this thread is a great way to test all the scrolling hacks:
+            // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
+          }
+          for (let i = startIndex; i < parents.length; i++) {
+            arr.push(parents[i])
           }
         }
       }
@@ -220,17 +233,18 @@ function PostThreadLoaded({
       if (highlightedPost.ctx.isChildLoading) {
         arr.push(CHILD_SPINNER)
       } else {
-        for (const reply of replies) {
-          arr.push(reply)
+        for (let i = 0; i < replies.length; i++) {
+          arr.push(replies[i])
+          if (i === maxReplies) {
+            arr.push(LOAD_MORE)
+            break
+          }
         }
         arr.push(BOTTOM_COMPONENT)
       }
     }
-    if (arr.length > maxVisible) {
-      arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
-    }
     return arr
-  }, [skeleton, maxVisible, deferParents])
+  }, [skeleton, deferParents, maxParents, maxReplies])
 
   // This is only used on the web to keep the post in view when its parents load.
   // On native, we rely on `maintainVisibleContentPosition` instead.
@@ -258,15 +272,28 @@ function PostThreadLoaded({
     }
   }, [thread])
 
-  const onPTR = React.useCallback(async () => {
-    setIsPTRing(true)
-    try {
-      await onRefresh()
-    } catch (err) {
-      logger.error('Failed to refresh posts thread', {message: err})
+  // On native, we reveal parents in chunks. Although they're all already
+  // loaded and FlatList already has its own virtualization, unfortunately FlatList
+  // has a bug that causes the content to jump around if too many items are getting
+  // prepended at once. It also jumps around if items get prepended during scroll.
+  // To work around this, we prepend rows after scroll bumps against the top and rests.
+  const needsBumpMaxParents = React.useRef(false)
+  const onStartReached = React.useCallback(() => {
+    if (maxParents < skeleton.parents.length) {
+      needsBumpMaxParents.current = true
+    }
+  }, [maxParents, skeleton.parents.length])
+  const bumpMaxParentsIfNeeded = React.useCallback(() => {
+    if (!isNative) {
+      return
+    }
+    if (needsBumpMaxParents.current) {
+      needsBumpMaxParents.current = false
+      setMaxParents(n => n + PARENTS_CHUNK_SIZE)
     }
-    setIsPTRing(false)
-  }, [setIsPTRing, onRefresh])
+  }, [])
+  const onMomentumScrollEnd = bumpMaxParentsIfNeeded
+  const onScrollToTop = bumpMaxParentsIfNeeded
 
   const renderItem = React.useCallback(
     ({item, index}: {item: RowItem; index: number}) => {
@@ -301,7 +328,7 @@ function PostThreadLoaded({
       } else if (item === LOAD_MORE) {
         return (
           <Pressable
-            onPress={() => setMaxVisible(n => n + 50)}
+            onPress={() => setMaxReplies(n => n + 50)}
             style={[pal.border, pal.view, styles.itemContainer]}
             accessibilityLabel={_(msg`Load more posts`)}
             accessibilityHint="">
@@ -345,6 +372,8 @@ function PostThreadLoaded({
         const next = isThreadPost(posts[index - 1])
           ? (posts[index - 1] as ThreadPost)
           : undefined
+        const hasUnrevealedParents =
+          index === 0 && maxParents < skeleton.parents.length
         return (
           <View
             ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
@@ -360,7 +389,9 @@ function PostThreadLoaded({
               hasMore={item.ctx.hasMore}
               showChildReplyLine={item.ctx.showChildReplyLine}
               showParentReplyLine={item.ctx.showParentReplyLine}
-              hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
+              hasPrecedingItem={
+                !!prev?.ctx.showChildReplyLine || hasUnrevealedParents
+              }
               onPostReply={onRefresh}
             />
           </View>
@@ -383,6 +414,8 @@ function PostThreadLoaded({
       onRefresh,
       deferParents,
       treeView,
+      skeleton.parents.length,
+      maxParents,
       _,
     ],
   )
@@ -393,9 +426,10 @@ function PostThreadLoaded({
       data={posts}
       keyExtractor={item => item._reactKey}
       renderItem={renderItem}
-      refreshing={isPTRing}
-      onRefresh={onPTR}
       onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
+      onStartReached={onStartReached}
+      onMomentumScrollEnd={onMomentumScrollEnd}
+      onScrollToTop={onScrollToTop}
       maintainVisibleContentPosition={
         isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
       }
-- 
cgit 1.4.1

