From 7e6b666ee36c996e9c3aa35fb5161e5d04e8b395 Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Wed, 14 Feb 2024 03:17:22 +0000
Subject: Adjust post thread scroll for cached posts (#2865)

Co-authored-by: Hailey <me@haileyok.com>
---
 src/view/com/post-thread/PostThread.tsx | 143 ++++++++++++++++++--------------
 1 file changed, 82 insertions(+), 61 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 6c026d6cb..2b08bc402 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -47,7 +47,11 @@ import {isAndroid, isNative} from '#/platform/detection'
 import {logger} from '#/logger'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 
-const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 1}
+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.
+  minIndexForVisible: 0,
+}
 
 const TOP_COMPONENT = {_reactKey: '__top_component__'}
 const REPLY_PROMPT = {_reactKey: '__reply__'}
@@ -65,6 +69,12 @@ type RowItem =
   | typeof LOAD_MORE
   | typeof BOTTOM_COMPONENT
 
+type ThreadSkeletonParts = {
+  parents: YieldedItem[]
+  highlightedPost: ThreadNode
+  replies: YieldedItem[]
+}
+
 export function PostThread({
   uri,
   onCanReply,
@@ -155,10 +165,6 @@ function PostThreadLoaded({
   const {isMobile, isTabletOrMobile} = useWebMediaQueries()
   const ref = useRef<ListMethods>(null)
   const highlightedPostRef = useRef<View | null>(null)
-  const needsScrollAdjustment = useRef<boolean>(
-    !isNative || // web always uses scroll adjustment
-      (thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder
-  )
   const [maxVisible, setMaxVisible] = React.useState(100)
   const [isPTRing, setIsPTRing] = React.useState(false)
   const treeView = React.useMemo(
@@ -166,25 +172,55 @@ function PostThreadLoaded({
     [threadViewPrefs, thread],
   )
 
+  // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
+  // This ensures that the first render contains no parents--even if they are already available in the cache.
+  // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen.
+  // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
+  const [deferParents, setDeferParents] = React.useState(isNative)
+
+  const skeleton = React.useMemo(
+    () =>
+      createThreadSkeleton(
+        sortThread(thread, threadViewPrefs),
+        hasSession,
+        treeView,
+      ),
+    [thread, threadViewPrefs, hasSession, treeView],
+  )
+
   // construct content
   const posts = React.useMemo(() => {
-    const root = sortThread(thread, threadViewPrefs)
+    const {parents, highlightedPost, replies} = skeleton
     let arr: RowItem[] = []
-    if (root.type === 'post') {
-      if (!root.ctx.isParentLoading) {
+    if (highlightedPost.type === 'post') {
+      const isRoot =
+        !highlightedPost.parent && !highlightedPost.ctx.isParentLoading
+      if (isRoot) {
+        // No parents to load.
         arr.push(TOP_COMPONENT)
-        for (const parent of flattenThreadParents(root, hasSession)) {
-          arr.push(parent)
+      } else {
+        if (highlightedPost.ctx.isParentLoading || deferParents) {
+          // We're loading parents of the highlighted post.
+          // In this case, we don't render anything above the post.
+          // If you add something here, you'll need to update both
+          // 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)
+          }
         }
       }
-      arr.push(root)
-      if (!root.post.viewer?.replyDisabled) {
+      arr.push(highlightedPost)
+      if (!highlightedPost.post.viewer?.replyDisabled) {
         arr.push(REPLY_PROMPT)
       }
-      if (root.ctx.isChildLoading) {
+      if (highlightedPost.ctx.isChildLoading) {
         arr.push(CHILD_SPINNER)
       } else {
-        for (const reply of flattenThreadReplies(root, hasSession, treeView)) {
+        for (const reply of replies) {
           arr.push(reply)
         }
         arr.push(BOTTOM_COMPONENT)
@@ -194,34 +230,16 @@ function PostThreadLoaded({
       arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
     }
     return arr
-  }, [thread, treeView, maxVisible, threadViewPrefs, hasSession])
-
-  /**
-   * NOTE
-   * Scroll positioning
-   *
-   * This callback is run if needsScrollAdjustment.current == true, which is...
-   *  - On web: always
-   *  - On native: when the placeholder cache is not being used
-   *
-   * It then only runs when viewing a reply, and the goal is to scroll the
-   * reply into view.
-   *
-   * On native, if the placeholder cache is being used then maintainVisibleContentPosition
-   * is a more effective solution, so we use that. Otherwise, typically we're loading from
-   * the react-query cache, so we just need to immediately scroll down to the post.
-   *
-   * On desktop, maintainVisibleContentPosition isn't supported so we just always use
-   * this technique.
-   *
-   * -prf
-   */
-  const onContentSizeChange = React.useCallback(() => {
+  }, [skeleton, maxVisible, deferParents])
+
+  // This is only used on the web to keep the post in view when its parents load.
+  // On native, we rely on `maintainVisibleContentPosition` instead.
+  const didAdjustScrollWeb = useRef<boolean>(false)
+  const onContentSizeChangeWeb = React.useCallback(() => {
     // only run once
-    if (!needsScrollAdjustment.current) {
+    if (didAdjustScrollWeb.current) {
       return
     }
-
     // wait for loading to finish
     if (thread.type === 'post' && !!thread.parent) {
       function onMeasure(pageY: number) {
@@ -230,21 +248,13 @@ function PostThreadLoaded({
           offset: pageY,
         })
       }
-      if (isNative) {
-        highlightedPostRef.current?.measure(
-          (_x, _y, _width, _height, _pageX, pageY) => {
-            onMeasure(pageY)
-          },
-        )
-      } else {
-        // Measure synchronously to avoid a layout jump.
-        const domNode = highlightedPostRef.current
-        if (domNode) {
-          const pageY = (domNode as any as Element).getBoundingClientRect().top
-          onMeasure(pageY)
-        }
+      // Measure synchronously to avoid a layout jump.
+      const domNode = highlightedPostRef.current
+      if (domNode) {
+        const pageY = (domNode as any as Element).getBoundingClientRect().top
+        onMeasure(pageY)
       }
-      needsScrollAdjustment.current = false
+      didAdjustScrollWeb.current = true
     }
   }, [thread])
 
@@ -337,7 +347,8 @@ function PostThreadLoaded({
           : undefined
         return (
           <View
-            ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}>
+            ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
+            onLayout={deferParents ? () => setDeferParents(false) : undefined}>
             <PostThreadItem
               post={item.post}
               record={item.record}
@@ -370,6 +381,7 @@ function PostThreadLoaded({
       pal.colors.border,
       posts,
       onRefresh,
+      deferParents,
       treeView,
       _,
     ],
@@ -379,17 +391,14 @@ function PostThreadLoaded({
     <List
       ref={ref}
       data={posts}
-      initialNumToRender={!isNative ? posts.length : undefined}
-      maintainVisibleContentPosition={
-        !needsScrollAdjustment.current
-          ? MAINTAIN_VISIBLE_CONTENT_POSITION
-          : undefined
-      }
       keyExtractor={item => item._reactKey}
       renderItem={renderItem}
       refreshing={isPTRing}
       onRefresh={onPTR}
-      onContentSizeChange={onContentSizeChange}
+      onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
+      maintainVisibleContentPosition={
+        isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
+      }
       style={s.hContentRegion}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
@@ -509,6 +518,18 @@ function isThreadBlocked(v: unknown): v is ThreadBlocked {
   return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
 }
 
+function createThreadSkeleton(
+  node: ThreadNode,
+  hasSession: boolean,
+  treeView: boolean,
+): ThreadSkeletonParts {
+  return {
+    parents: Array.from(flattenThreadParents(node, hasSession)),
+    highlightedPost: node,
+    replies: Array.from(flattenThreadReplies(node, hasSession, treeView)),
+  }
+}
+
 function* flattenThreadParents(
   node: ThreadNode,
   hasSession: boolean,
-- 
cgit 1.4.1

