From 7e6b666ee36c996e9c3aa35fb5161e5d04e8b395 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 14 Feb 2024 03:17:22 +0000 Subject: [PATCH] Adjust post thread scroll for cached posts (#2865) Co-authored-by: Hailey --- src/view/com/post-thread/PostThread.tsx | 141 ++++++++++++++---------- 1 file changed, 81 insertions(+), 60 deletions(-) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 6c026d6c..2b08bc40 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(null) const highlightedPostRef = useRef(null) - const needsScrollAdjustment = useRef( - !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]) + }, [skeleton, maxVisible, deferParents]) - /** - * 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(() => { + // 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(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 ( + ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} + onLayout={deferParents ? () => setDeferParents(false) : undefined}> 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,