about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-11-27 17:41:30 -0800
committerGitHub <noreply@github.com>2023-11-27 17:41:30 -0800
commitf580d4daf0d2172fa285a5a87a1bec5100a70f63 (patch)
tree425c866f9d39a2c193d0b8097bcf01d6bbfe2064 /src
parentd4714baf13561236a85d44fec144f7f27a149bfd (diff)
downloadvoidsky-f580d4daf0d2172fa285a5a87a1bec5100a70f63.tar.zst
Restore post-thread caching behaviors (react-query refactor) (#2010)
* Rework resolve-did and resolve-uri queries to be smarter about cache reuse

* Precache handle resolutions

* Remove old unused code

* Load placeholder threads from the post-feed and notifications-feed queries

* Remove logs

* Fix bad ref

* Add loading spinners to the cache-loading thread view

* Scroll replies into view when loading threads

* Add caching within a thread

* Fix: dont show bottom border when the child spinner is active
Diffstat (limited to 'src')
-rw-r--r--src/state/queries/notifications/feed.ts39
-rw-r--r--src/state/queries/post-feed.ts40
-rw-r--r--src/state/queries/post-thread.ts146
-rw-r--r--src/state/queries/resolve-uri.ts77
-rw-r--r--src/view/com/post-thread/PostThread.tsx164
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx2
-rw-r--r--src/view/com/profile/ProfileFollows.tsx2
-rw-r--r--src/view/screens/Profile.tsx2
8 files changed, 365 insertions, 107 deletions
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index d78370e07..54bd87540 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -7,11 +7,18 @@ import {
   BskyAgent,
 } from '@atproto/api'
 import chunk from 'lodash.chunk'
-import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {
+  useInfiniteQuery,
+  InfiniteData,
+  QueryKey,
+  useQueryClient,
+  QueryClient,
+} from '@tanstack/react-query'
 import {getAgent} from '../../session'
 import {useModerationOpts} from '../preferences'
 import {shouldFilterNotif} from './util'
 import {useMutedThreads} from '#/state/muted-threads'
+import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
 
 const GROUPABLE_REASONS = ['like', 'repost', 'follow']
 const PAGE_SIZE = 30
@@ -48,6 +55,7 @@ export interface FeedPage {
 }
 
 export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
+  const queryClient = useQueryClient()
   const moderationOpts = useModerationOpts()
   const threadMutes = useMutedThreads()
   const enabled = opts?.enabled !== false
@@ -80,6 +88,9 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
       for (const notif of notifsGrouped) {
         if (notif.subjectUri) {
           notif.subject = subjects.get(notif.subjectUri)
+          if (notif.subject) {
+            precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
+          }
         }
       }
 
@@ -99,6 +110,32 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
   })
 }
 
+/**
+ * This helper is used by the post-thread placeholder function to
+ * find a post in the query-data cache
+ */
+export function findPostInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): AppBskyFeedDefs.PostView | undefined {
+  const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
+    queryKey: ['notification-feed'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const item of page.items) {
+        if (item.subject?.uri === uri) {
+          return item.subject
+        }
+      }
+    }
+  }
+  return undefined
+}
+
 function groupNotifications(
   notifs: AppBskyNotificationListNotifications.Notification[],
 ): FeedNotification[] {
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 5f81cb44d..1334461cf 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -1,6 +1,12 @@
 import {useCallback, useMemo} from 'react'
 import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
-import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {
+  useInfiniteQuery,
+  InfiniteData,
+  QueryKey,
+  QueryClient,
+  useQueryClient,
+} from '@tanstack/react-query'
 import {getAgent} from '../session'
 import {useFeedTuners} from '../preferences/feed-tuners'
 import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
@@ -14,6 +20,7 @@ import {MergeFeedAPI} from 'lib/api/feed/merge'
 import {useModerationOpts} from '#/state/queries/preferences'
 import {logger} from '#/logger'
 import {STALE} from '#/state/queries'
+import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri'
 
 type ActorDid = string
 type AuthorFilter =
@@ -66,6 +73,7 @@ export function usePostFeedQuery(
   params?: FeedParams,
   opts?: {enabled?: boolean},
 ) {
+  const queryClient = useQueryClient()
   const feedTuners = useFeedTuners(feedDesc)
   const enabled = opts?.enabled !== false
   const moderationOpts = useModerationOpts()
@@ -141,6 +149,7 @@ export function usePostFeedQuery(
         tuner.reset()
       }
       const res = await api.fetch({cursor: pageParam, limit: 30})
+      precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution
       const slices = tuner.tune(res.feed, feedTuners)
       return {
         cursor: res.cursor,
@@ -152,7 +161,6 @@ export function usePostFeedQuery(
             slice.items.every(
               item => item.post.author.did === slice.items[0].post.author.did,
             ),
-          source: undefined, // TODO
           items: slice.items
             .map((item, i) => {
               if (
@@ -180,3 +188,31 @@ export function usePostFeedQuery(
 
   return {...out, pollLatest}
 }
+
+/**
+ * This helper is used by the post-thread placeholder function to
+ * find a post in the query-data cache
+ */
+export function findPostInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): FeedPostSliceItem | undefined {
+  const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
+    queryKey: ['post-feed'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const slice of page.slices) {
+        for (const item of slice.items) {
+          if (item.uri === uri) {
+            return item
+          }
+        }
+      }
+    }
+  }
+  return undefined
+}
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index b4a474eab..c616b05cc 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -3,11 +3,17 @@ import {
   AppBskyFeedPost,
   AppBskyFeedGetPostThread,
 } from '@atproto/api'
-import {useQuery} from '@tanstack/react-query'
+import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
 import {STALE} from '#/state/queries'
+import {
+  findPostInQueryData as findPostInFeedQueryData,
+  FeedPostSliceItem,
+} from './post-feed'
+import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
+import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri'
 
 export const RQKEY = (uri: string) => ['post-thread', uri]
 type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
@@ -18,6 +24,8 @@ export interface ThreadCtx {
   hasMore?: boolean
   showChildReplyLine?: boolean
   showParentReplyLine?: boolean
+  isParentLoading?: boolean
+  isChildLoading?: boolean
 }
 
 export type ThreadPost = {
@@ -58,17 +66,44 @@ export type ThreadNode =
   | ThreadUnknown
 
 export function usePostThreadQuery(uri: string | undefined) {
+  const queryClient = useQueryClient()
   return useQuery<ThreadNode, Error>({
     staleTime: STALE.MINUTES.ONE,
     queryKey: RQKEY(uri || ''),
     async queryFn() {
       const res = await getAgent().getPostThread({uri: uri!})
       if (res.success) {
-        return responseToThreadNodes(res.data.thread)
+        const nodes = responseToThreadNodes(res.data.thread)
+        precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution
+        return nodes
       }
       return {type: 'unknown', uri: uri!}
     },
     enabled: !!uri,
+    placeholderData: () => {
+      if (!uri) {
+        return undefined
+      }
+      {
+        const item = findPostInQueryData(queryClient, uri)
+        if (item) {
+          return threadNodeToPlaceholderThread(item)
+        }
+      }
+      {
+        const item = findPostInFeedQueryData(queryClient, uri)
+        if (item) {
+          return feedItemToPlaceholderThread(item)
+        }
+      }
+      {
+        const item = findPostInNotifsQueryData(queryClient, uri)
+        if (item) {
+          return postViewToPlaceholderThread(item)
+        }
+      }
+      return undefined
+    },
   })
 }
 
@@ -178,3 +213,110 @@ function responseToThreadNodes(
     return {type: 'unknown', uri: ''}
   }
 }
+
+function findPostInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): ThreadNode | undefined {
+  const queryDatas = queryClient.getQueriesData<ThreadNode>({
+    queryKey: ['post-thread'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData) {
+      continue
+    }
+    for (const item of traverseThread(queryData)) {
+      if (item.uri === uri) {
+        return item
+      }
+    }
+  }
+  return undefined
+}
+
+function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
+  if (node.type === 'post') {
+    if (node.parent) {
+      yield* traverseThread(node.parent)
+    }
+    yield node
+    if (node.replies?.length) {
+      for (const reply of node.replies) {
+        yield* traverseThread(reply)
+      }
+    }
+  }
+}
+
+function threadNodeToPlaceholderThread(
+  node: ThreadNode,
+): ThreadNode | undefined {
+  if (node.type !== 'post') {
+    return undefined
+  }
+  return {
+    type: node.type,
+    _reactKey: node._reactKey,
+    uri: node.uri,
+    post: node.post,
+    record: node.record,
+    parent: undefined,
+    replies: undefined,
+    viewer: node.viewer,
+    ctx: {
+      depth: 0,
+      isHighlightedPost: true,
+      hasMore: false,
+      showChildReplyLine: false,
+      showParentReplyLine: false,
+      isParentLoading: !!node.record.reply,
+      isChildLoading: !!node.post.replyCount,
+    },
+  }
+}
+
+function feedItemToPlaceholderThread(item: FeedPostSliceItem): ThreadNode {
+  return {
+    type: 'post',
+    _reactKey: item.post.uri,
+    uri: item.post.uri,
+    post: item.post,
+    record: item.record,
+    parent: undefined,
+    replies: undefined,
+    viewer: item.post.viewer,
+    ctx: {
+      depth: 0,
+      isHighlightedPost: true,
+      hasMore: false,
+      showChildReplyLine: false,
+      showParentReplyLine: false,
+      isParentLoading: !!item.record.reply,
+      isChildLoading: !!item.post.replyCount,
+    },
+  }
+}
+
+function postViewToPlaceholderThread(
+  post: AppBskyFeedDefs.PostView,
+): ThreadNode {
+  return {
+    type: 'post',
+    _reactKey: post.uri,
+    uri: post.uri,
+    post: post,
+    record: post.record as AppBskyFeedPost.Record, // validate in notifs
+    parent: undefined,
+    replies: undefined,
+    viewer: post.viewer,
+    ctx: {
+      depth: 0,
+      isHighlightedPost: true,
+      hasMore: false,
+      showChildReplyLine: false,
+      showParentReplyLine: false,
+      isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply,
+      isChildLoading: !!post.replyCount,
+    },
+  }
+}
diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts
index dc8e7fbe1..05a9f4b1c 100644
--- a/src/state/queries/resolve-uri.ts
+++ b/src/state/queries/resolve-uri.ts
@@ -1,27 +1,76 @@
-import {useQuery} from '@tanstack/react-query'
-import {AtUri} from '@atproto/api'
+import {QueryClient, useQuery, UseQueryResult} from '@tanstack/react-query'
+import {AtUri, AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
 
 import {getAgent} from '#/state/session'
 import {STALE} from '#/state/queries'
+import {ThreadNode} from './post-thread'
 
-export const RQKEY = (uri: string) => ['resolved-uri', uri]
+export const RQKEY = (didOrHandle: string) => ['resolved-did', didOrHandle]
 
-export function useResolveUriQuery(uri: string | undefined) {
-  return useQuery<{uri: string; did: string}, Error>({
+type UriUseQueryResult = UseQueryResult<{did: string; uri: string}, Error>
+export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult {
+  const urip = new AtUri(uri || '')
+  const res = useResolveDidQuery(urip.host)
+  if (res.data) {
+    urip.host = res.data
+    return {
+      ...res,
+      data: {did: urip.host, uri: urip.toString()},
+    } as UriUseQueryResult
+  }
+  return res as UriUseQueryResult
+}
+
+export function useResolveDidQuery(didOrHandle: string | undefined) {
+  return useQuery<string, Error>({
     staleTime: STALE.INFINITY,
-    queryKey: RQKEY(uri || ''),
+    queryKey: RQKEY(didOrHandle || ''),
     async queryFn() {
-      const urip = new AtUri(uri || '')
-      if (!urip.host.startsWith('did:')) {
-        const res = await getAgent().resolveHandle({handle: urip.host})
-        urip.host = res.data.did
+      if (!didOrHandle) {
+        return ''
       }
-      return {did: urip.host, uri: urip.toString()}
+      if (!didOrHandle.startsWith('did:')) {
+        const res = await getAgent().resolveHandle({handle: didOrHandle})
+        didOrHandle = res.data.did
+      }
+      return didOrHandle
     },
-    enabled: !!uri,
+    enabled: !!didOrHandle,
   })
 }
 
-export function useResolveDidQuery(didOrHandle: string | undefined) {
-  return useResolveUriQuery(didOrHandle ? `at://${didOrHandle}/` : undefined)
+export function precacheProfile(
+  queryClient: QueryClient,
+  profile:
+    | AppBskyActorDefs.ProfileView
+    | AppBskyActorDefs.ProfileViewBasic
+    | AppBskyActorDefs.ProfileViewDetailed,
+) {
+  queryClient.setQueryData(RQKEY(profile.handle), profile.did)
+}
+
+export function precacheFeedPosts(
+  queryClient: QueryClient,
+  posts: AppBskyFeedDefs.FeedViewPost[],
+) {
+  for (const post of posts) {
+    precacheProfile(queryClient, post.post.author)
+  }
+}
+
+export function precacheThreadPosts(
+  queryClient: QueryClient,
+  node: ThreadNode,
+) {
+  if (node.type === 'post') {
+    precacheProfile(queryClient, node.post.author)
+    if (node.parent) {
+      precacheThreadPosts(queryClient, node.parent)
+    }
+    if (node.replies?.length) {
+      for (const reply of node.replies) {
+        precacheThreadPosts(queryClient, reply)
+      }
+    }
+  }
 }
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index c19833948..edf02e9c5 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -39,8 +39,10 @@ import {
   usePreferencesQuery,
 } from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
+import {isNative} from '#/platform/detection'
+import {logger} from '#/logger'
 
-// const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
+const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
 
 const TOP_COMPONENT = {_reactKey: '__top_component__'}
 const PARENT_SPINNER = {_reactKey: '__parent_spinner__'}
@@ -72,7 +74,6 @@ export function PostThread({
     isError,
     error,
     refetch,
-    isRefetching,
     data: thread,
   } = usePostThreadQuery(uri)
   const {data: preferences} = usePreferencesQuery()
@@ -110,7 +111,6 @@ export function PostThread({
   return (
     <PostThreadLoaded
       thread={thread}
-      isRefetching={isRefetching}
       threadViewPrefs={preferences.threadViewPrefs}
       onRefresh={refetch}
       onPressReply={onPressReply}
@@ -120,13 +120,11 @@ export function PostThread({
 
 function PostThreadLoaded({
   thread,
-  isRefetching,
   threadViewPrefs,
   onRefresh,
   onPressReply,
 }: {
   thread: ThreadNode
-  isRefetching: boolean
   threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
   onRefresh: () => void
   onPressReply: () => void
@@ -136,29 +134,15 @@ function PostThreadLoaded({
   const pal = usePalette('default')
   const {isTablet, isDesktop} = useWebMediaQueries()
   const ref = useRef<FlatList>(null)
-  // const hasScrolledIntoView = useRef<boolean>(false) TODO
+  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)
 
-  // TODO
-  // const posts = React.useMemo(() => {
-  //   if (view.thread) {
-  //     let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread)))
-  //     if (arr.length > maxVisible) {
-  //       arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
-  //     }
-  //     if (view.isLoadingFromCache) {
-  //       if (view.thread?.postRecord?.reply) {
-  //         arr.unshift(PARENT_SPINNER)
-  //       }
-  //       arr.push(CHILD_SPINNER)
-  //     } else {
-  //       arr.push(BOTTOM_COMPONENT)
-  //     }
-  //     return arr
-  //   }
-  //   return []
-  // }, [view.isLoadingFromCache, view.thread, maxVisible])
-  // const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
+  // construct content
   const posts = React.useMemo(() => {
     let arr = [TOP_COMPONENT].concat(
       Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))),
@@ -166,54 +150,61 @@ function PostThreadLoaded({
     if (arr.length > maxVisible) {
       arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
     }
-    arr.push(BOTTOM_COMPONENT)
+    if (arr.indexOf(CHILD_SPINNER) === -1) {
+      arr.push(BOTTOM_COMPONENT)
+    }
     return arr
   }, [thread, maxVisible, threadViewPrefs])
 
-  // TODO
-  /*const onContentSizeChange = React.useCallback(() => {
+  /**
+   * 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(() => {
     // only run once
-    if (hasScrolledIntoView.current) {
+    if (!needsScrollAdjustment.current) {
       return
     }
 
     // wait for loading to finish
-    if (
-      !view.hasContent ||
-      (view.isFromCache && view.isLoadingFromCache) ||
-      view.isLoading
-    ) {
-      return
+    if (thread.type === 'post' && !!thread.parent) {
+      highlightedPostRef.current?.measure(
+        (_x, _y, _width, _height, _pageX, pageY) => {
+          ref.current?.scrollToOffset({
+            animated: false,
+            offset: pageY - (isDesktop ? 0 : 50),
+          })
+        },
+      )
+      needsScrollAdjustment.current = false
     }
+  }, [thread, isDesktop])
 
-    if (highlightedPostIndex !== -1) {
-      ref.current?.scrollToIndex({
-        index: highlightedPostIndex,
-        animated: false,
-        viewPosition: 0,
-      })
-      hasScrolledIntoView.current = true
+  const onPTR = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await onRefresh()
+    } catch (err) {
+      logger.error('Failed to refresh posts thread', {error: err})
     }
-  }, [
-    highlightedPostIndex,
-    view.hasContent,
-    view.isFromCache,
-    view.isLoadingFromCache,
-    view.isLoading,
-  ])*/
-  const onScrollToIndexFailed = React.useCallback(
-    (info: {
-      index: number
-      highestMeasuredFrameIndex: number
-      averageItemLength: number
-    }) => {
-      ref.current?.scrollToOffset({
-        animated: false,
-        offset: info.averageItemLength * info.index,
-      })
-    },
-    [ref],
-  )
+    setIsPTRing(false)
+  }, [setIsPTRing, onRefresh])
 
   const renderItem = React.useCallback(
     ({item, index}: {item: YieldedItem; index: number}) => {
@@ -290,18 +281,21 @@ function PostThreadLoaded({
           ? (posts[index - 1] as ThreadPost)
           : undefined
         return (
-          <PostThreadItem
-            post={item.post}
-            record={item.record}
-            treeView={threadViewPrefs.lab_treeViewEnabled || false}
-            depth={item.ctx.depth}
-            isHighlightedPost={item.ctx.isHighlightedPost}
-            hasMore={item.ctx.hasMore}
-            showChildReplyLine={item.ctx.showChildReplyLine}
-            showParentReplyLine={item.ctx.showParentReplyLine}
-            hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
-            onPostReply={onRefresh}
-          />
+          <View
+            ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}>
+            <PostThreadItem
+              post={item.post}
+              record={item.record}
+              treeView={threadViewPrefs.lab_treeViewEnabled || false}
+              depth={item.ctx.depth}
+              isHighlightedPost={item.ctx.isHighlightedPost}
+              hasMore={item.ctx.hasMore}
+              showChildReplyLine={item.ctx.showChildReplyLine}
+              showParentReplyLine={item.ctx.showParentReplyLine}
+              hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
+              onPostReply={onRefresh}
+            />
+          </View>
         )
       }
       return null
@@ -330,25 +324,21 @@ function PostThreadLoaded({
       data={posts}
       initialNumToRender={posts.length}
       maintainVisibleContentPosition={
-        undefined // TODO
-        // isNative && view.isFromCache && view.isCachedPostAReply
-        //   ? MAINTAIN_VISIBLE_CONTENT_POSITION
-        //   : undefined
+        !needsScrollAdjustment.current
+          ? MAINTAIN_VISIBLE_CONTENT_POSITION
+          : undefined
       }
       keyExtractor={item => item._reactKey}
       renderItem={renderItem}
       refreshControl={
         <RefreshControl
-          refreshing={isRefetching}
-          onRefresh={onRefresh}
+          refreshing={isPTRing}
+          onRefresh={onPTR}
           tintColor={pal.colors.text}
           titleColor={pal.colors.text}
         />
       }
-      onContentSizeChange={
-        undefined //TODOisNative && view.isFromCache ? undefined : onContentSizeChange
-      }
-      onScrollToIndexFailed={onScrollToIndexFailed}
+      onContentSizeChange={onContentSizeChange}
       style={s.hContentRegion}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
@@ -465,6 +455,8 @@ function* flattenThreadSkeleton(
   if (node.type === 'post') {
     if (node.parent) {
       yield* flattenThreadSkeleton(node.parent)
+    } else if (node.ctx.isParentLoading) {
+      yield PARENT_SPINNER
     }
     yield node
     if (node.ctx.isHighlightedPost) {
@@ -474,6 +466,8 @@ function* flattenThreadSkeleton(
       for (const reply of node.replies) {
         yield* flattenThreadSkeleton(reply)
       }
+    } else if (node.ctx.isChildLoading) {
+      yield CHILD_SPINNER
     }
   } else if (node.type === 'not-found') {
     yield DELETED
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index 45c1b3ad6..d94f5103e 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -28,7 +28,7 @@ export function ProfileFollowers({name}: {name: string}) {
     isError,
     error,
     refetch,
-  } = useProfileFollowersQuery(resolvedDid?.did)
+  } = useProfileFollowersQuery(resolvedDid)
 
   const followers = React.useMemo(() => {
     if (data?.pages) {
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
index e1dce78a7..890c13eb2 100644
--- a/src/view/com/profile/ProfileFollows.tsx
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -28,7 +28,7 @@ export function ProfileFollows({name}: {name: string}) {
     isError,
     error,
     refetch,
-  } = useProfileFollowsQuery(resolvedDid?.did)
+  } = useProfileFollowsQuery(resolvedDid)
 
   const follows = React.useMemo(() => {
     if (data?.pages) {
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index c3bc598a0..7ddcf17af 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -58,7 +58,7 @@ export function ProfileScreen({route}: Props) {
     refetch: refetchProfile,
     isFetching: isFetchingProfile,
   } = useProfileQuery({
-    did: resolvedDid?.did,
+    did: resolvedDid,
   })
 
   const onPressTryAgain = React.useCallback(() => {