about summary refs log tree commit diff
path: root/src/view/com/post-thread
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/post-thread')
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx4
-rw-r--r--src/view/com/post-thread/PostThread.tsx675
-rw-r--r--src/view/com/post-thread/PostThreadFollowBtn.tsx5
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx160
4 files changed, 378 insertions, 466 deletions
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 55463dc13..0760ed7ff 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -8,7 +8,7 @@ import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
 import {logger} from '#/logger'
 import {LoadingScreen} from '../util/LoadingScreen'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
-import {usePostLikedByQuery} from '#/state/queries/post-liked-by'
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
 import {cleanError} from '#/lib/strings/errors'
 
 export function PostLikedBy({uri}: {uri: string}) {
@@ -28,7 +28,7 @@ export function PostLikedBy({uri}: {uri: string}) {
     isError,
     error,
     refetch,
-  } = usePostLikedByQuery(resolvedUri?.uri)
+  } = useLikedByQuery(resolvedUri?.uri)
   const likes = useMemo(() => {
     if (data?.pages) {
       return data.pages.flatMap(page => page.likes)
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index ef3d7e2b6..c1159379d 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,66 +1,68 @@
 import React, {useEffect, useRef} from 'react'
-import {
-  ActivityIndicator,
-  Pressable,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
+import {StyleSheet, useWindowDimensions, View} from 'react-native'
 import {AppBskyFeedDefs} from '@atproto/api'
-import {CenteredView} from '../util/Views'
-import {LoadingScreen} from '../util/LoadingScreen'
-import {List, ListMethods} from '../util/List'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {PostThreadItem} from './PostThreadItem'
-import {ComposePrompt} from '../composer/Prompt'
-import {ViewHeader} from '../util/ViewHeader'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {Text} from '../util/text/Text'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {isAndroid, isNative, isWeb} from '#/platform/detection'
 import {
+  sortThread,
+  ThreadBlocked,
   ThreadNode,
+  ThreadNotFound,
   ThreadPost,
   usePostThreadQuery,
-  sortThread,
 } from '#/state/queries/post-thread'
-import {useNavigation} from '@react-navigation/native'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {NavigationProp} from 'lib/routes/types'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {cleanError} from '#/lib/strings/errors'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
 import {
-  UsePreferencesQueryResponse,
   useModerationOpts,
   usePreferencesQuery,
 } from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
-import {isAndroid, isNative} from '#/platform/detection'
-import {logger} from '#/logger'
-import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {cleanError} from 'lib/strings/errors'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
+import {ComposePrompt} from '../composer/Prompt'
+import {List, ListMethods} from '../util/List'
+import {Text} from '../util/text/Text'
+import {ViewHeader} from '../util/ViewHeader'
+import {PostThreadItem} from './PostThreadItem'
+
+// 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 = {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__'}
-const DELETED = {_reactKey: '__deleted__'}
-const BLOCKED = {_reactKey: '__blocked__'}
-const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
 const LOAD_MORE = {_reactKey: '__load_more__'}
-const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
 
-type YieldedItem =
-  | ThreadPost
+type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound
+type RowItem =
+  | YieldedItem
+  // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
   | typeof TOP_COMPONENT
   | typeof REPLY_PROMPT
-  | typeof DELETED
-  | typeof BLOCKED
+  | typeof LOAD_MORE
+
+type ThreadSkeletonParts = {
+  parents: YieldedItem[]
+  highlightedPost: ThreadNode
+  replies: YieldedItem[]
+}
+
+const keyExtractor = (item: RowItem) => {
+  return item._reactKey
+}
 
 export function PostThread({
   uri,
@@ -69,17 +71,30 @@ export function PostThread({
 }: {
   uri: string | undefined
   onCanReply: (canReply: boolean) => void
-  onPressReply: () => void
+  onPressReply: () => unknown
 }) {
+  const {hasSession} = useSession()
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const {isMobile, isTabletOrMobile} = useWebMediaQueries()
+  const initialNumToRender = useInitialNumToRender()
+  const {height: windowHeight} = useWindowDimensions()
+
+  const {data: preferences} = usePreferencesQuery()
   const {
-    isLoading,
-    isError,
-    error,
+    isFetching,
+    isError: isThreadError,
+    error: threadError,
     refetch,
     data: thread,
   } = usePostThreadQuery(uri)
-  const {data: preferences} = usePreferencesQuery()
 
+  const treeView = React.useMemo(
+    () =>
+      !!preferences?.threadViewPrefs?.lab_treeViewEnabled &&
+      hasBranchingReplies(thread),
+    [preferences?.threadViewPrefs, thread],
+  )
   const rootPost = thread?.type === 'post' ? thread.post : undefined
   const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
 
@@ -89,14 +104,23 @@ export function PostThread({
       rootPost && moderationOpts
         ? moderatePost(rootPost, moderationOpts)
         : undefined
-
-    const cause = mod?.content.cause
-
-    return cause
-      ? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated'
-      : false
+    return !!mod
+      ?.ui('contentList')
+      .blurs.find(
+        cause =>
+          cause.type === 'label' &&
+          cause.labelDef.identifier === '!no-unauthenticated',
+      )
   }, [rootPost, moderationOpts])
 
+  // Values used for proper rendering of parents
+  const ref = useRef<ListMethods>(null)
+  const highlightedPostRef = useRef<View | null>(null)
+  const [maxParents, setMaxParents] = React.useState(
+    isWeb ? Infinity : PARENTS_CHUNK_SIZE,
+  )
+  const [maxReplies, setMaxReplies] = React.useState(50)
+
   useSetTitle(
     rootPost && !isNoPwi
       ? `${sanitizeDisplayName(
@@ -104,147 +128,164 @@ export function PostThread({
         )}: "${rootPostRecord!.text}"`
       : '',
   )
+
+  // 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(() => {
+    const threadViewPrefs = preferences?.threadViewPrefs
+    if (!threadViewPrefs || !thread) return null
+
+    return createThreadSkeleton(
+      sortThread(thread, threadViewPrefs),
+      hasSession,
+      treeView,
+    )
+  }, [thread, preferences?.threadViewPrefs, hasSession, treeView])
+
+  const error = React.useMemo(() => {
+    if (AppBskyFeedDefs.isNotFoundPost(thread)) {
+      return {
+        title: _(msg`Post not found`),
+        message: _(msg`The post may have been deleted.`),
+      }
+    } else if (skeleton?.highlightedPost.type === 'blocked') {
+      return {
+        title: _(msg`Post hidden`),
+        message: _(
+          msg`You have blocked the author or you have been blocked by the author.`,
+        ),
+      }
+    } else if (threadError?.message.startsWith('Post not found')) {
+      return {
+        title: _(msg`Post not found`),
+        message: _(msg`The post may have been deleted.`),
+      }
+    } else if (isThreadError) {
+      return {
+        message: threadError ? cleanError(threadError) : undefined,
+      }
+    }
+
+    return null
+  }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
+
   useEffect(() => {
-    if (rootPost) {
+    if (error) {
+      onCanReply(false)
+    } else if (rootPost) {
       onCanReply(!rootPost.viewer?.replyDisabled)
     }
-  }, [rootPost, onCanReply])
-
-  if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
-    return (
-      <PostThreadError
-        error={error}
-        notFound={AppBskyFeedDefs.isNotFoundPost(thread)}
-        onRefresh={refetch}
-      />
-    )
-  }
-  if (AppBskyFeedDefs.isBlockedPost(thread)) {
-    return <PostThreadBlocked />
-  }
-  if (!thread || isLoading || !preferences) {
-    return <LoadingScreen />
-  }
-  return (
-    <PostThreadLoaded
-      thread={thread}
-      threadViewPrefs={preferences.threadViewPrefs}
-      onRefresh={refetch}
-      onPressReply={onPressReply}
-    />
-  )
-}
-
-function PostThreadLoaded({
-  thread,
-  threadViewPrefs,
-  onRefresh,
-  onPressReply,
-}: {
-  thread: ThreadNode
-  threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
-  onRefresh: () => void
-  onPressReply: () => void
-}) {
-  const {hasSession} = useSession()
-  const {_} = useLingui()
-  const pal = usePalette('default')
-  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(
-    () => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread),
-    [threadViewPrefs, thread],
-  )
+  }, [rootPost, onCanReply, error])
 
   // construct content
   const posts = React.useMemo(() => {
-    let arr = Array.from(
-      flattenThreadSkeleton(
-        sortThread(thread, threadViewPrefs),
-        hasSession,
-        treeView,
-      ),
-    )
-    if (arr.length > maxVisible) {
-      arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
-    }
-    if (arr.indexOf(CHILD_SPINNER) === -1) {
-      arr.push(BOTTOM_COMPONENT)
+    if (!skeleton) return []
+
+    const {parents, highlightedPost, replies} = skeleton
+    let arr: RowItem[] = []
+    if (highlightedPost.type === 'post') {
+      const isRoot =
+        !highlightedPost.parent && !highlightedPost.ctx.isParentLoading
+      if (isRoot) {
+        // No parents to load.
+        arr.push(TOP_COMPONENT)
+      } 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
+          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])
+          }
+        }
+      }
+      arr.push(highlightedPost)
+      if (!highlightedPost.post.viewer?.replyDisabled) {
+        arr.push(REPLY_PROMPT)
+      }
+      for (let i = 0; i < replies.length; i++) {
+        arr.push(replies[i])
+        if (i === maxReplies) {
+          break
+        }
+      }
     }
     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, 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.
+  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) {
+    if (thread?.type === 'post' && !!thread.parent) {
       function onMeasure(pageY: number) {
         ref.current?.scrollToOffset({
           animated: false,
           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])
 
-  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 (skeleton?.parents && maxParents < skeleton.parents.length) {
+      needsBumpMaxParents.current = true
     }
-    setIsPTRing(false)
-  }, [setIsPTRing, onRefresh])
+  }, [maxParents, skeleton?.parents])
+  const bumpMaxParentsIfNeeded = React.useCallback(() => {
+    if (!isNative) {
+      return
+    }
+    if (needsBumpMaxParents.current) {
+      needsBumpMaxParents.current = false
+      setMaxParents(n => n + PARENTS_CHUNK_SIZE)
+    }
+  }, [])
+  const onMomentumScrollEnd = bumpMaxParentsIfNeeded
+  const onScrollToTop = bumpMaxParentsIfNeeded
+
+  const onEndReached = React.useCallback(() => {
+    if (isFetching || posts.length < maxReplies) return
+    setMaxReplies(prev => prev + 50)
+  }, [isFetching, maxReplies, posts.length])
 
   const renderItem = React.useCallback(
-    ({item, index}: {item: YieldedItem; index: number}) => {
+    ({item, index}: {item: RowItem; index: number}) => {
       if (item === TOP_COMPONENT) {
         return isTabletOrMobile ? (
           <ViewHeader
@@ -257,7 +298,7 @@ function PostThreadLoaded({
             {!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
           </View>
         )
-      } else if (item === DELETED) {
+      } else if (isThreadNotFound(item)) {
         return (
           <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
             <Text type="lg-bold" style={pal.textLight}>
@@ -265,7 +306,7 @@ function PostThreadLoaded({
             </Text>
           </View>
         )
-      } else if (item === BLOCKED) {
+      } else if (isThreadBlocked(item)) {
         return (
           <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
             <Text type="lg-bold" style={pal.textLight}>
@@ -273,46 +314,6 @@ function PostThreadLoaded({
             </Text>
           </View>
         )
-      } else if (item === LOAD_MORE) {
-        return (
-          <Pressable
-            onPress={() => setMaxVisible(n => n + 50)}
-            style={[pal.border, pal.view, styles.itemContainer]}
-            accessibilityLabel={_(msg`Load more posts`)}
-            accessibilityHint="">
-            <View
-              style={[
-                pal.viewLight,
-                {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6},
-              ]}>
-              <Text type="lg-medium" style={pal.text}>
-                <Trans>Load more posts</Trans>
-              </Text>
-            </View>
-          </Pressable>
-        )
-      } else if (item === BOTTOM_COMPONENT) {
-        // HACK
-        // due to some complexities with how flatlist works, this is the easiest way
-        // I could find to get a border positioned directly under the last item
-        // -prf
-        return (
-          <View
-            // @ts-ignore web-only
-            style={{
-              // Leave enough space below that the scroll doesn't jump
-              height: isNative ? 600 : '100vh',
-              borderTopWidth: 1,
-              borderColor: pal.colors.border,
-            }}
-          />
-        )
-      } else if (item === CHILD_SPINNER) {
-        return (
-          <View style={[pal.border, styles.childSpinner]}>
-            <ActivityIndicator />
-          </View>
-        )
       } else if (isThreadPost(item)) {
         const prev = isThreadPost(posts[index - 1])
           ? (posts[index - 1] as ThreadPost)
@@ -320,9 +321,14 @@ function PostThreadLoaded({
         const next = isThreadPost(posts[index - 1])
           ? (posts[index - 1] as ThreadPost)
           : undefined
+        const hasUnrevealedParents =
+          index === 0 &&
+          skeleton?.parents &&
+          maxParents < skeleton.parents.length
         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}
@@ -334,8 +340,10 @@ function PostThreadLoaded({
               hasMore={item.ctx.hasMore}
               showChildReplyLine={item.ctx.showChildReplyLine}
               showParentReplyLine={item.ctx.showParentReplyLine}
-              hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
-              onPostReply={onRefresh}
+              hasPrecedingItem={
+                !!prev?.ctx.showChildReplyLine || !!hasUnrevealedParents
+              }
+              onPostReply={refetch}
             />
           </View>
         )
@@ -345,182 +353,133 @@ function PostThreadLoaded({
     [
       hasSession,
       isTabletOrMobile,
+      _,
       isMobile,
       onPressReply,
       pal.border,
       pal.viewLight,
       pal.textLight,
-      pal.view,
-      pal.text,
-      pal.colors.border,
       posts,
-      onRefresh,
+      skeleton?.parents,
+      maxParents,
+      deferParents,
       treeView,
-      _,
+      refetch,
     ],
   )
 
   return (
-    <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}
-      style={s.hContentRegion}
-      // @ts-ignore our .web version only -prf
-      desktopFixedHeight
-      removeClippedSubviews={isAndroid ? false : undefined}
-    />
+    <>
+      <ListMaybePlaceholder
+        isLoading={(!preferences || !thread) && !error}
+        isError={!!error}
+        onRetry={refetch}
+        errorTitle={error?.title}
+        errorMessage={error?.message}
+      />
+      {!error && thread && (
+        <List
+          ref={ref}
+          data={posts}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
+          onStartReached={onStartReached}
+          onEndReached={onEndReached}
+          onEndReachedThreshold={2}
+          onMomentumScrollEnd={onMomentumScrollEnd}
+          onScrollToTop={onScrollToTop}
+          maintainVisibleContentPosition={
+            isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
+          }
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+          removeClippedSubviews={isAndroid ? false : undefined}
+          ListFooterComponent={
+            <ListFooter
+              isFetching={isFetching}
+              onRetry={refetch}
+              // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
+              // work without causing weird jumps on web or glitches on native
+              height={windowHeight - 200}
+            />
+          }
+          initialNumToRender={initialNumToRender}
+          windowSize={11}
+        />
+      )}
+    </>
   )
 }
 
-function PostThreadBlocked() {
-  const {_} = useLingui()
-  const pal = usePalette('default')
-  const navigation = useNavigation<NavigationProp>()
+function isThreadPost(v: unknown): v is ThreadPost {
+  return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
+}
 
-  const onPressBack = React.useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Home')
-    }
-  }, [navigation])
+function isThreadNotFound(v: unknown): v is ThreadNotFound {
+  return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
+}
 
-  return (
-    <CenteredView>
-      <View style={[pal.view, pal.border, styles.notFoundContainer]}>
-        <Text type="title-lg" style={[pal.text, s.mb5]}>
-          <Trans>Post hidden</Trans>
-        </Text>
-        <Text type="md" style={[pal.text, s.mb10]}>
-          <Trans>
-            You have blocked the author or you have been blocked by the author.
-          </Trans>
-        </Text>
-        <TouchableOpacity
-          onPress={onPressBack}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Back`)}
-          accessibilityHint="">
-          <Text type="2xl" style={pal.link}>
-            <FontAwesomeIcon
-              icon="angle-left"
-              style={[pal.link as FontAwesomeIconStyle, s.mr5]}
-              size={14}
-            />
-            <Trans context="action">Back</Trans>
-          </Text>
-        </TouchableOpacity>
-      </View>
-    </CenteredView>
-  )
+function isThreadBlocked(v: unknown): v is ThreadBlocked {
+  return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
 }
 
-function PostThreadError({
-  onRefresh,
-  notFound,
-  error,
-}: {
-  onRefresh: () => void
-  notFound: boolean
-  error: Error | null
-}) {
-  const {_} = useLingui()
-  const pal = usePalette('default')
-  const navigation = useNavigation<NavigationProp>()
+function createThreadSkeleton(
+  node: ThreadNode,
+  hasSession: boolean,
+  treeView: boolean,
+): ThreadSkeletonParts | null {
+  if (!node) return null
 
-  const onPressBack = React.useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Home')
-    }
-  }, [navigation])
-
-  if (notFound) {
-    return (
-      <CenteredView>
-        <View style={[pal.view, pal.border, styles.notFoundContainer]}>
-          <Text type="title-lg" style={[pal.text, s.mb5]}>
-            <Trans>Post not found</Trans>
-          </Text>
-          <Text type="md" style={[pal.text, s.mb10]}>
-            <Trans>The post may have been deleted.</Trans>
-          </Text>
-          <TouchableOpacity
-            onPress={onPressBack}
-            accessibilityRole="button"
-            accessibilityLabel={_(msg`Back`)}
-            accessibilityHint="">
-            <Text type="2xl" style={pal.link}>
-              <FontAwesomeIcon
-                icon="angle-left"
-                style={[pal.link as FontAwesomeIconStyle, s.mr5]}
-                size={14}
-              />
-              <Trans>Back</Trans>
-            </Text>
-          </TouchableOpacity>
-        </View>
-      </CenteredView>
-    )
+  return {
+    parents: Array.from(flattenThreadParents(node, hasSession)),
+    highlightedPost: node,
+    replies: Array.from(flattenThreadReplies(node, hasSession, treeView)),
   }
-  return (
-    <CenteredView>
-      <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
-    </CenteredView>
-  )
 }
 
-function isThreadPost(v: unknown): v is ThreadPost {
-  return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
+function* flattenThreadParents(
+  node: ThreadNode,
+  hasSession: boolean,
+): Generator<YieldedItem, void> {
+  if (node.type === 'post') {
+    if (node.parent) {
+      yield* flattenThreadParents(node.parent, hasSession)
+    }
+    if (!node.ctx.isHighlightedPost) {
+      yield node
+    }
+  } else if (node.type === 'not-found') {
+    yield node
+  } else if (node.type === 'blocked') {
+    yield node
+  }
 }
 
-function* flattenThreadSkeleton(
+function* flattenThreadReplies(
   node: ThreadNode,
   hasSession: boolean,
   treeView: boolean,
-  isTraversingReplies: boolean = false,
 ): Generator<YieldedItem, void> {
   if (node.type === 'post') {
-    if (!node.ctx.isParentLoading) {
-      if (node.parent) {
-        yield* flattenThreadSkeleton(node.parent, hasSession, treeView, false)
-      } else if (!isTraversingReplies) {
-        yield TOP_COMPONENT
-      }
-    }
-    if (!hasSession && node.ctx.depth > 0 && hasPwiOptOut(node)) {
+    if (!hasSession && hasPwiOptOut(node)) {
       return
     }
-    yield node
-    if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) {
-      yield REPLY_PROMPT
+    if (!node.ctx.isHighlightedPost) {
+      yield node
     }
     if (node.replies?.length) {
       for (const reply of node.replies) {
-        yield* flattenThreadSkeleton(reply, hasSession, treeView, true)
+        yield* flattenThreadReplies(reply, hasSession, treeView)
         if (!treeView && !node.ctx.isHighlightedPost) {
           break
         }
       }
-    } else if (node.ctx.isChildLoading) {
-      yield CHILD_SPINNER
     }
   } else if (node.type === 'not-found') {
-    yield DELETED
+    yield node
   } else if (node.type === 'blocked') {
-    yield BLOCKED
+    yield node
   }
 }
 
@@ -528,7 +487,10 @@ function hasPwiOptOut(node: ThreadPost) {
   return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
 }
 
-function hasBranchingReplies(node: ThreadNode) {
+function hasBranchingReplies(node?: ThreadNode) {
+  if (!node) {
+    return false
+  }
   if (node.type !== 'post') {
     return false
   }
@@ -542,20 +504,9 @@ function hasBranchingReplies(node: ThreadNode) {
 }
 
 const styles = StyleSheet.create({
-  notFoundContainer: {
-    margin: 10,
-    paddingHorizontal: 18,
-    paddingVertical: 14,
-    borderRadius: 6,
-  },
   itemContainer: {
     borderTopWidth: 1,
     paddingHorizontal: 18,
     paddingVertical: 18,
   },
-  childSpinner: {
-    borderTopWidth: 1,
-    paddingTop: 40,
-    paddingBottom: 200,
-  },
 })
diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx
index e5b747cc9..45c3771f5 100644
--- a/src/view/com/post-thread/PostThreadFollowBtn.tsx
+++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx
@@ -42,7 +42,10 @@ function PostThreadFollowBtnLoaded({
   const {isTabletOrDesktop} = useWebMediaQueries()
   const profile: Shadow<AppBskyActorDefs.ProfileViewBasic> =
     useProfileShadow(profileUnshadowed)
-  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
+    profile,
+    'PostThreadItem',
+  )
   const requireAuth = useRequireAuth()
 
   const isFollowing = !!profile.viewer?.following
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 826d0d161..6555bdf73 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -5,13 +5,13 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   RichText as RichTextAPI,
-  PostModeration,
+  ModerationDecision,
 } from '@atproto/api'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
 import {Link, TextLink} from '../util/Link'
-import {RichText} from '../util/text/RichText'
+import {RichText} from '#/components/RichText'
 import {Text} from '../util/text/Text'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
@@ -19,15 +19,14 @@ import {niceDate} from 'lib/strings/time'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {countLines, pluralize} from 'lib/strings/helpers'
-import {isEmbedByEmbedder} from 'lib/embeds'
 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostHider} from '../util/moderation/PostHider'
-import {ContentHider} from '../util/moderation/ContentHider'
-import {PostAlerts} from '../util/moderation/PostAlerts'
-import {PostSandboxWarning} from '../util/PostSandboxWarning'
+import {PostHider} from '../../../components/moderation/PostHider'
+import {ContentHider} from '../../../components/moderation/ContentHider'
+import {PostAlerts} from '../../../components/moderation/PostAlerts'
+import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {usePalette} from 'lib/hooks/usePalette'
 import {formatCount} from '../util/numeric/format'
@@ -44,6 +43,8 @@ import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 import {ThreadPost} from '#/state/queries/post-thread'
 import {useSession} from 'state/session'
 import {WhoCanReply} from '../threadgate/WhoCanReply'
+import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
+import {atoms as a} from '#/alf'
 
 export function PostThreadItem({
   post,
@@ -93,6 +94,8 @@ export function PostThreadItem({
   if (richText && moderation) {
     return (
       <PostThreadItemLoaded
+        // Safeguard from clobbering per-post state below:
+        key={postShadowed.uri}
         post={postShadowed}
         prevPost={prevPost}
         nextPost={nextPost}
@@ -144,7 +147,7 @@ let PostThreadItemLoaded = ({
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
   richText: RichTextAPI
-  moderation: PostModeration
+  moderation: ModerationDecision
   treeView: boolean
   depth: number
   prevPost: ThreadPost | undefined
@@ -164,8 +167,6 @@ let PostThreadItemLoaded = ({
     () => countLines(richText?.text) >= MAX_POST_LINES,
   )
   const {currentAccount} = useSession()
-  const hasEngagement = post.likeCount || post.repostCount
-
   const rootUri = record.reply?.root?.uri || post.uri
   const postHref = React.useMemo(() => {
     const urip = new AtUri(post.uri)
@@ -174,7 +175,6 @@ let PostThreadItemLoaded = ({
   const itemTitle = _(msg`Post by ${post.author.handle}`)
   const authorHref = makeProfileLink(post.author)
   const authorTitle = post.author.handle
-  const isAuthorMuted = post.author.viewer?.muted
   const likesHref = React.useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
@@ -205,11 +205,7 @@ let PostThreadItemLoaded = ({
         uri: post.uri,
         cid: post.cid,
         text: record.text,
-        author: {
-          handle: post.author.handle,
-          displayName: post.author.displayName,
-          avatar: post.author.avatar,
-        },
+        author: post.author,
         embed: post.embed,
         moderation,
       },
@@ -248,7 +244,6 @@ let PostThreadItemLoaded = ({
           testID={`postThreadItem-by-${post.author.handle}`}
           style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
           accessible={false}>
-          <PostSandboxWarning />
           <View style={[styles.layout]}>
             <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
               <PreviewableUserAvatar
@@ -256,7 +251,8 @@ let PostThreadItemLoaded = ({
                 did={post.author.did}
                 handle={post.author.handle}
                 avatar={post.author.avatar}
-                moderation={moderation.avatar}
+                moderation={moderation.ui('avatar')}
+                type={post.author.associated?.labeler ? 'labeler' : 'user'}
               />
             </View>
             <View style={styles.layoutContent}>
@@ -271,35 +267,12 @@ let PostThreadItemLoaded = ({
                     {sanitizeDisplayName(
                       post.author.displayName ||
                         sanitizeHandle(post.author.handle),
+                      moderation.ui('displayName'),
                     )}
                   </Text>
                 </Link>
               </View>
               <View style={styles.meta}>
-                {isAuthorMuted && (
-                  <View
-                    style={[
-                      pal.viewLight,
-                      {
-                        flexDirection: 'row',
-                        alignItems: 'center',
-                        gap: 4,
-                        borderRadius: 6,
-                        paddingHorizontal: 6,
-                        paddingVertical: 2,
-                        marginRight: 4,
-                      },
-                    ]}>
-                    <FontAwesomeIcon
-                      icon={['far', 'eye-slash']}
-                      size={12}
-                      color={pal.colors.textLight}
-                    />
-                    <Text type="sm-medium" style={pal.textLight}>
-                      Muted
-                    </Text>
-                  </View>
-                )}
                 <Link style={s.flex1} href={authorHref} title={authorTitle}>
                   <Text type="md" style={[pal.textLight]} numberOfLines={1}>
                     {sanitizeHandle(post.author.handle, '@')}
@@ -312,15 +285,16 @@ let PostThreadItemLoaded = ({
             )}
           </View>
           <View style={[s.pl10, s.pr10, s.pb10]}>
+            <LabelsOnMyPost post={post} />
             <ContentHider
-              moderation={moderation.content}
+              modui={moderation.ui('contentView')}
               ignoreMute
               style={styles.contentHider}
               childContainerStyle={styles.contentHiderChild}>
               <PostAlerts
-                moderation={moderation.content}
+                modui={moderation.ui('contentView')}
                 includeMute
-                style={styles.alert}
+                style={[a.pt_2xs, a.pb_sm]}
               />
               {richText?.text ? (
                 <View
@@ -329,27 +303,18 @@ let PostThreadItemLoaded = ({
                     styles.postTextLargeContainer,
                   ]}>
                   <RichText
-                    type="post-text-lg"
-                    richText={richText}
-                    lineHeight={1.3}
-                    style={s.flex1}
+                    enableTags
                     selectable
+                    value={richText}
+                    style={[a.flex_1, a.text_xl]}
+                    authorHandle={post.author.handle}
                   />
                 </View>
               ) : undefined}
               {post.embed && (
-                <ContentHider
-                  moderation={moderation.embed}
-                  moderationDecisions={moderation.decisions}
-                  ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
-                  ignoreQuoteDecisions
-                  style={s.mb10}>
-                  <PostEmbeds
-                    embed={post.embed}
-                    moderation={moderation.embed}
-                    moderationDecisions={moderation.decisions}
-                  />
-                </ContentHider>
+                <View style={[a.pb_sm]}>
+                  <PostEmbeds embed={post.embed} moderation={moderation} />
+                </View>
               )}
             </ContentHider>
             <ExpandedPostDetails
@@ -357,9 +322,16 @@ let PostThreadItemLoaded = ({
               translatorUrl={translatorUrl}
               needsTranslation={needsTranslation}
             />
-            {hasEngagement ? (
+            {post.repostCount !== 0 || post.likeCount !== 0 ? (
+              // Show this section unless we're *sure* it has no engagement.
               <View style={[styles.expandedInfo, pal.border]}>
-                {post.repostCount ? (
+                {post.repostCount == null && post.likeCount == null && (
+                  // If we're still loading and not sure, assume this post has engagement.
+                  // This lets us avoid a layout shift for the common case (embedded post with likes/reposts).
+                  // TODO: embeds should include metrics to avoid us having to guess.
+                  <LoadingPlaceholder width={50} height={20} />
+                )}
+                {post.repostCount != null && post.repostCount !== 0 ? (
                   <Link
                     style={styles.expandedInfoItem}
                     href={repostsHref}
@@ -374,10 +346,8 @@ let PostThreadItemLoaded = ({
                       {pluralize(post.repostCount, 'repost')}
                     </Text>
                   </Link>
-                ) : (
-                  <></>
-                )}
-                {post.likeCount ? (
+                ) : null}
+                {post.likeCount != null && post.likeCount !== 0 ? (
                   <Link
                     style={styles.expandedInfoItem}
                     href={likesHref}
@@ -392,13 +362,9 @@ let PostThreadItemLoaded = ({
                       {pluralize(post.likeCount, 'like')}
                     </Text>
                   </Link>
-                ) : (
-                  <></>
-                )}
+                ) : null}
               </View>
-            ) : (
-              <></>
-            )}
+            ) : null}
             <View style={[s.pl10, s.pr10, s.pb5]}>
               <PostCtrls
                 big
@@ -406,6 +372,7 @@ let PostThreadItemLoaded = ({
                 record={record}
                 richText={richText}
                 onPressReply={onPressReply}
+                logContext="PostThreadItem"
               />
             </View>
           </View>
@@ -431,15 +398,13 @@ let PostThreadItemLoaded = ({
             testID={`postThreadItem-by-${post.author.handle}`}
             href={postHref}
             style={[pal.view]}
-            moderation={moderation.content}
+            modui={moderation.ui('contentList')}
             iconSize={isThreadedChild ? 26 : 38}
             iconStyles={
               isThreadedChild
                 ? {marginRight: 4}
                 : {marginLeft: 2, marginRight: 2}
             }>
-            <PostSandboxWarning />
-
             <View
               style={{
                 flexDirection: 'row',
@@ -454,7 +419,7 @@ let PostThreadItemLoaded = ({
                       styles.replyLine,
                       {
                         flexGrow: 1,
-                        backgroundColor: pal.colors.border,
+                        backgroundColor: pal.colors.replyLine,
                         marginBottom: 4,
                       },
                     ]}
@@ -483,7 +448,8 @@ let PostThreadItemLoaded = ({
                     did={post.author.did}
                     handle={post.author.handle}
                     avatar={post.author.avatar}
-                    moderation={moderation.avatar}
+                    moderation={moderation.ui('avatar')}
+                    type={post.author.associated?.labeler ? 'labeler' : 'user'}
                   />
 
                   {showChildReplyLine && (
@@ -492,7 +458,7 @@ let PostThreadItemLoaded = ({
                         styles.replyLine,
                         {
                           flexGrow: 1,
-                          backgroundColor: pal.colors.border,
+                          backgroundColor: pal.colors.replyLine,
                           marginTop: 4,
                         },
                       ]}
@@ -509,28 +475,30 @@ let PostThreadItemLoaded = ({
                 }>
                 <PostMeta
                   author={post.author}
+                  moderation={moderation}
                   authorHasWarning={!!post.author.labels?.length}
                   timestamp={post.indexedAt}
                   postHref={postHref}
                   showAvatar={isThreadedChild}
-                  avatarModeration={moderation.avatar}
+                  avatarModeration={moderation.ui('avatar')}
                   avatarSize={28}
                   displayNameType="md-bold"
                   displayNameStyle={isThreadedChild && s.ml2}
                   style={isThreadedChild && s.mb2}
                 />
+                <LabelsOnMyPost post={post} />
                 <PostAlerts
-                  moderation={moderation.content}
-                  style={styles.alert}
+                  modui={moderation.ui('contentList')}
+                  style={[a.pt_xs, a.pb_sm]}
                 />
                 {richText?.text ? (
                   <View style={styles.postTextContainer}>
                     <RichText
-                      type="post-text"
-                      richText={richText}
-                      style={[pal.text, s.flex1]}
-                      lineHeight={1.3}
+                      enableTags
+                      value={richText}
+                      style={[a.flex_1, a.text_md]}
                       numberOfLines={limitLines ? MAX_POST_LINES : undefined}
+                      authorHandle={post.author.handle}
                     />
                   </View>
                 ) : undefined}
@@ -543,24 +511,16 @@ let PostThreadItemLoaded = ({
                   />
                 ) : undefined}
                 {post.embed && (
-                  <ContentHider
-                    style={styles.contentHider}
-                    moderation={moderation.embed}
-                    moderationDecisions={moderation.decisions}
-                    ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
-                    ignoreQuoteDecisions>
-                    <PostEmbeds
-                      embed={post.embed}
-                      moderation={moderation.embed}
-                      moderationDecisions={moderation.decisions}
-                    />
-                  </ContentHider>
+                  <View style={[a.pb_xs]}>
+                    <PostEmbeds embed={post.embed} moderation={moderation} />
+                  </View>
                 )}
                 <PostCtrls
                   post={post}
                   record={record}
                   richText={richText}
                   onPressReply={onPressReply}
+                  logContext="PostThreadItem"
                 />
               </View>
             </View>
@@ -578,7 +538,7 @@ let PostThreadItemLoaded = ({
                 title={itemTitle}
                 noFeedback>
                 <Text type="sm-medium" style={pal.textLight}>
-                  More
+                  <Trans>More</Trans>
                 </Text>
                 <FontAwesomeIcon
                   icon="angle-right"
@@ -621,7 +581,6 @@ function PostOuterWrapper({
     return (
       <View
         style={[
-          pal.view,
           pal.border,
           styles.cursor,
           {
@@ -649,7 +608,6 @@ function PostOuterWrapper({
     <View
       style={[
         styles.outer,
-        pal.view,
         pal.border,
         showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
         styles.cursor,