about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/moderation/PostAlerts.tsx48
-rw-r--r--src/components/moderation/PostHider.tsx4
-rw-r--r--src/components/moderation/ProfileHeaderAlerts.tsx46
-rw-r--r--src/state/queries/post-thread.ts53
-rw-r--r--src/view/com/post-thread/PostThread.tsx145
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx16
-rw-r--r--src/view/com/post-thread/PostThreadShowHiddenReplies.tsx61
-rw-r--r--src/view/com/posts/FeedItem.tsx2
-rw-r--r--src/view/screens/DebugMod.tsx3
9 files changed, 311 insertions, 67 deletions
diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx
index 0bfe69678..c59aa2655 100644
--- a/src/components/moderation/PostAlerts.tsx
+++ b/src/components/moderation/PostAlerts.tsx
@@ -1,16 +1,16 @@
 import React from 'react'
 import {StyleProp, View, ViewStyle} from 'react-native'
-import {ModerationUI, ModerationCause} from '@atproto/api'
+import {ModerationCause, ModerationUI} from '@atproto/api'
 
-import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
 import {getModerationCauseKey} from '#/lib/moderation'
-
-import {atoms as a} from '#/alf'
-import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
 import {
   ModerationDetailsDialog,
   useModerationDetailsDialogControl,
 } from '#/components/moderation/ModerationDetailsDialog'
+import {Text} from '#/components/Typography'
 
 export function PostAlerts({
   modui,
@@ -41,23 +41,41 @@ export function PostAlerts({
 function PostLabel({cause}: {cause: ModerationCause}) {
   const control = useModerationDetailsDialogControl()
   const desc = useModerationCauseDescription(cause)
+  const t = useTheme()
 
   return (
     <>
       <Button
         label={desc.name}
-        variant="solid"
-        color="secondary"
-        size="small"
-        shape="default"
         onPress={() => {
           control.open()
-        }}
-        style={[a.px_sm, a.py_xs, a.gap_xs]}>
-        <ButtonIcon icon={desc.icon} position="left" />
-        <ButtonText style={[a.text_left, a.leading_snug]}>
-          {desc.name}
-        </ButtonText>
+        }}>
+        {({hovered, pressed}) => (
+          <View
+            style={[
+              a.flex_row,
+              a.align_center,
+              {paddingLeft: 4, paddingRight: 6, paddingVertical: 1},
+              a.gap_xs,
+              a.rounded_sm,
+              hovered || pressed
+                ? t.atoms.bg_contrast_50
+                : t.atoms.bg_contrast_25,
+            ]}>
+            <desc.icon size="xs" fill={t.atoms.text_contrast_medium.color} />
+            <Text
+              style={[
+                a.text_left,
+                a.leading_snug,
+                a.text_xs,
+                t.atoms.text_contrast_medium,
+                a.font_semibold,
+              ]}>
+              {desc.name}
+              {desc.source ? ` – ${desc.source}` : ''}
+            </Text>
+          </View>
+        )}
       </Button>
 
       <ModerationDetailsDialog control={control} modcause={cause} />
diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx
index 05cb8464e..177104f93 100644
--- a/src/components/moderation/PostHider.tsx
+++ b/src/components/moderation/PostHider.tsx
@@ -18,6 +18,7 @@ import {
 import {Text} from '#/components/Typography'
 
 interface Props extends ComponentProps<typeof Link> {
+  disabled: boolean
   iconSize: number
   iconStyles: StyleProp<ViewStyle>
   modui: ModerationUI
@@ -27,6 +28,7 @@ interface Props extends ComponentProps<typeof Link> {
 export function PostHider({
   testID,
   href,
+  disabled,
   modui,
   style,
   children,
@@ -47,7 +49,7 @@ export function PostHider({
     precacheProfile(queryClient, profile)
   }, [queryClient, profile])
 
-  if (!blur) {
+  if (!blur || (disabled && !modui.noOverride)) {
     return (
       <Link
         testID={testID}
diff --git a/src/components/moderation/ProfileHeaderAlerts.tsx b/src/components/moderation/ProfileHeaderAlerts.tsx
index dfc2aa557..3fa24b938 100644
--- a/src/components/moderation/ProfileHeaderAlerts.tsx
+++ b/src/components/moderation/ProfileHeaderAlerts.tsx
@@ -2,15 +2,15 @@ import React from 'react'
 import {StyleProp, View, ViewStyle} from 'react-native'
 import {ModerationCause, ModerationDecision} from '@atproto/api'
 
-import {getModerationCauseKey} from 'lib/moderation'
 import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
-
-import {atoms as a} from '#/alf'
-import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import {getModerationCauseKey} from 'lib/moderation'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
 import {
   ModerationDetailsDialog,
   useModerationDetailsDialogControl,
 } from '#/components/moderation/ModerationDetailsDialog'
+import {Text} from '#/components/Typography'
 
 export function ProfileHeaderAlerts({
   moderation,
@@ -39,6 +39,7 @@ export function ProfileHeaderAlerts({
 }
 
 function ProfileLabel({cause}: {cause: ModerationCause}) {
+  const t = useTheme()
   const control = useModerationDetailsDialogControl()
   const desc = useModerationCauseDescription(cause)
 
@@ -46,18 +47,35 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
     <>
       <Button
         label={desc.name}
-        variant="solid"
-        color="secondary"
-        size="small"
-        shape="default"
         onPress={() => {
           control.open()
-        }}
-        style={[a.px_sm, a.py_xs, a.gap_xs]}>
-        <ButtonIcon icon={desc.icon} position="left" />
-        <ButtonText style={[a.text_left, a.leading_snug]}>
-          {desc.name}
-        </ButtonText>
+        }}>
+        {({hovered, pressed}) => (
+          <View
+            style={[
+              a.flex_row,
+              a.align_center,
+              {paddingLeft: 6, paddingRight: 8, paddingVertical: 4},
+              a.gap_xs,
+              a.rounded_md,
+              hovered || pressed
+                ? t.atoms.bg_contrast_50
+                : t.atoms.bg_contrast_25,
+            ]}>
+            <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
+            <Text
+              style={[
+                a.text_left,
+                a.leading_snug,
+                a.text_sm,
+                t.atoms.text_contrast_medium,
+                a.font_semibold,
+              ]}>
+              {desc.name}
+              {desc.source ? ` – ${desc.source}` : ''}
+            </Text>
+          </View>
+        )}
       </Button>
 
       <ModerationDetailsDialog control={control} modcause={cause} />
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index 133304d2e..4ee0eb3f9 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -3,9 +3,12 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedGetPostThread,
   AppBskyFeedPost,
+  ModerationDecision,
+  ModerationOpts,
 } from '@atproto/api'
 import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
 
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
 import {useAgent} from '#/state/session'
 import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from 'state/queries/search-posts'
@@ -21,8 +24,6 @@ export interface ThreadCtx {
   depth: number
   isHighlightedPost?: boolean
   hasMore?: boolean
-  showChildReplyLine?: boolean
-  showParentReplyLine?: boolean
   isParentLoading?: boolean
   isChildLoading?: boolean
 }
@@ -63,6 +64,8 @@ export type ThreadNode =
   | ThreadBlocked
   | ThreadUnknown
 
+export type ThreadModerationCache = WeakMap<ThreadNode, ModerationDecision>
+
 export function usePostThreadQuery(uri: string | undefined) {
   const queryClient = useQueryClient()
   const {getAgent} = useAgent()
@@ -92,9 +95,28 @@ export function usePostThreadQuery(uri: string | undefined) {
   })
 }
 
+export function fillThreadModerationCache(
+  cache: ThreadModerationCache,
+  node: ThreadNode,
+  moderationOpts: ModerationOpts,
+) {
+  if (node.type === 'post') {
+    cache.set(node, moderatePost(node.post, moderationOpts))
+    if (node.parent) {
+      fillThreadModerationCache(cache, node.parent, moderationOpts)
+    }
+    if (node.replies) {
+      for (const reply of node.replies) {
+        fillThreadModerationCache(cache, reply, moderationOpts)
+      }
+    }
+  }
+}
+
 export function sortThread(
   node: ThreadNode,
   opts: UsePreferencesQueryResponse['threadViewPrefs'],
+  modCache: ThreadModerationCache,
 ): ThreadNode {
   if (node.type !== 'post') {
     return node
@@ -117,6 +139,18 @@ export function sortThread(
       } else if (bIsByOp) {
         return 1 // op's own reply
       }
+
+      const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
+      const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
+      if (aBlur !== bBlur) {
+        if (aBlur) {
+          return 1
+        }
+        if (bBlur) {
+          return -1
+        }
+      }
+
       if (opts.prioritizeFollowedUsers) {
         const af = a.post.author.viewer?.following
         const bf = b.post.author.viewer?.following
@@ -126,6 +160,7 @@ export function sortThread(
           return 1
         }
       }
+
       if (opts.sort === 'oldest') {
         return a.post.indexedAt.localeCompare(b.post.indexedAt)
       } else if (opts.sort === 'newest') {
@@ -141,7 +176,7 @@ export function sortThread(
       }
       return b.post.indexedAt.localeCompare(a.post.indexedAt)
     })
-    node.replies.forEach(reply => sortThread(reply, opts))
+    node.replies.forEach(reply => sortThread(reply, opts, modCache))
   }
   return node
 }
@@ -188,12 +223,6 @@ function responseToThreadNodes(
         isHighlightedPost: depth === 0,
         hasMore:
           direction === 'down' && !node.replies?.length && !!node.replyCount,
-        showChildReplyLine:
-          direction === 'up' ||
-          (direction === 'down' && !!node.replies?.length),
-        showParentReplyLine:
-          (direction === 'up' && !!node.parent) ||
-          (direction === 'down' && depth !== 1),
       },
     }
   } else if (AppBskyFeedDefs.isBlockedPost(node)) {
@@ -296,8 +325,6 @@ function threadNodeToPlaceholderThread(
       depth: 0,
       isHighlightedPost: true,
       hasMore: false,
-      showChildReplyLine: false,
-      showParentReplyLine: false,
       isParentLoading: !!node.record.reply,
       isChildLoading: !!node.post.replyCount,
     },
@@ -319,8 +346,6 @@ function postViewToPlaceholderThread(
       depth: 0,
       isHighlightedPost: true,
       hasMore: false,
-      showChildReplyLine: false,
-      showParentReplyLine: false,
       isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply,
       isChildLoading: true, // assume yes (show the spinner) just in case
     },
@@ -342,8 +367,6 @@ function embedViewRecordToPlaceholderThread(
       depth: 0,
       isHighlightedPost: true,
       hasMore: false,
-      showChildReplyLine: false,
-      showParentReplyLine: false,
       isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply,
       isChildLoading: true, // not available, so assume yes (to show the spinner)
     },
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index a52818fd1..4f7d0d3c6 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -10,8 +10,10 @@ import {ScrollProvider} from '#/lib/ScrollContext'
 import {isAndroid, isNative, isWeb} from '#/platform/detection'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {
+  fillThreadModerationCache,
   sortThread,
   ThreadBlocked,
+  ThreadModerationCache,
   ThreadNode,
   ThreadNotFound,
   ThreadPost,
@@ -31,6 +33,7 @@ import {List, ListMethods} from '../util/List'
 import {Text} from '../util/text/Text'
 import {ViewHeader} from '../util/ViewHeader'
 import {PostThreadItem} from './PostThreadItem'
+import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
 
 // FlatList maintainVisibleContentPosition breaks if too many items
 // are prepended. This seems to be an optimal number based on *shrug*.
@@ -45,8 +48,21 @@ const MAINTAIN_VISIBLE_CONTENT_POSITION = {
 const TOP_COMPONENT = {_reactKey: '__top_component__'}
 const REPLY_PROMPT = {_reactKey: '__reply__'}
 const LOAD_MORE = {_reactKey: '__load_more__'}
+const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'}
+const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'}
 
-type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound
+enum HiddenRepliesState {
+  Hide,
+  Show,
+  ShowAndOverridePostHider,
+}
+
+type YieldedItem =
+  | ThreadPost
+  | ThreadBlocked
+  | ThreadNotFound
+  | typeof SHOW_HIDDEN_REPLIES
+  | typeof SHOW_MUTED_REPLIES
 type RowItem =
   | YieldedItem
   // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
@@ -79,6 +95,9 @@ export function PostThread({
   const {isMobile, isTabletOrMobile} = useWebMediaQueries()
   const initialNumToRender = useInitialNumToRender()
   const {height: windowHeight} = useWindowDimensions()
+  const [hiddenRepliesState, setHiddenRepliesState] = React.useState(
+    HiddenRepliesState.Hide,
+  )
 
   const {data: preferences} = usePreferencesQuery()
   const {
@@ -135,16 +154,33 @@ export function PostThread({
   // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
   const [deferParents, setDeferParents] = React.useState(isNative)
 
+  const threadModerationCache = React.useMemo(() => {
+    const cache: ThreadModerationCache = new WeakMap()
+    if (thread && moderationOpts) {
+      fillThreadModerationCache(cache, thread, moderationOpts)
+    }
+    return cache
+  }, [thread, moderationOpts])
+
   const skeleton = React.useMemo(() => {
     const threadViewPrefs = preferences?.threadViewPrefs
     if (!threadViewPrefs || !thread) return null
 
     return createThreadSkeleton(
-      sortThread(thread, threadViewPrefs),
+      sortThread(thread, threadViewPrefs, threadModerationCache),
       hasSession,
       treeView,
+      threadModerationCache,
+      hiddenRepliesState !== HiddenRepliesState.Hide,
     )
-  }, [thread, preferences?.threadViewPrefs, hasSession, treeView])
+  }, [
+    thread,
+    preferences?.threadViewPrefs,
+    hasSession,
+    treeView,
+    threadModerationCache,
+    hiddenRepliesState,
+  ])
 
   const error = React.useMemo(() => {
     if (AppBskyFeedDefs.isNotFoundPost(thread)) {
@@ -301,6 +337,24 @@ export function PostThread({
             {!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
           </View>
         )
+      } else if (item === SHOW_HIDDEN_REPLIES) {
+        return (
+          <PostThreadShowHiddenReplies
+            type="hidden"
+            onPress={() =>
+              setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
+            }
+          />
+        )
+      } else if (item === SHOW_MUTED_REPLIES) {
+        return (
+          <PostThreadShowHiddenReplies
+            type="muted"
+            onPress={() =>
+              setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
+            }
+          />
+        )
       } else if (isThreadNotFound(item)) {
         return (
           <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
@@ -321,9 +375,12 @@ export function PostThread({
         const prev = isThreadPost(posts[index - 1])
           ? (posts[index - 1] as ThreadPost)
           : undefined
-        const next = isThreadPost(posts[index - 1])
-          ? (posts[index - 1] as ThreadPost)
+        const next = isThreadPost(posts[index + 1])
+          ? (posts[index + 1] as ThreadPost)
           : undefined
+        const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth
+        const showParentReplyLine =
+          (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
         const hasUnrevealedParents =
           index === 0 &&
           skeleton?.parents &&
@@ -335,16 +392,20 @@ export function PostThread({
             <PostThreadItem
               post={item.post}
               record={item.record}
+              moderation={threadModerationCache.get(item)}
               treeView={treeView}
               depth={item.ctx.depth}
               prevPost={prev}
               nextPost={next}
               isHighlightedPost={item.ctx.isHighlightedPost}
               hasMore={item.ctx.hasMore}
-              showChildReplyLine={item.ctx.showChildReplyLine}
-              showParentReplyLine={item.ctx.showParentReplyLine}
-              hasPrecedingItem={
-                !!prev?.ctx.showChildReplyLine || !!hasUnrevealedParents
+              showChildReplyLine={showChildReplyLine}
+              showParentReplyLine={showParentReplyLine}
+              hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents}
+              overrideBlur={
+                hiddenRepliesState ===
+                  HiddenRepliesState.ShowAndOverridePostHider &&
+                item.ctx.depth > 0
               }
               onPostReply={refetch}
             />
@@ -368,6 +429,9 @@ export function PostThread({
       deferParents,
       treeView,
       refetch,
+      threadModerationCache,
+      hiddenRepliesState,
+      setHiddenRepliesState,
     ],
   )
 
@@ -437,13 +501,23 @@ function createThreadSkeleton(
   node: ThreadNode,
   hasSession: boolean,
   treeView: boolean,
+  modCache: ThreadModerationCache,
+  showHiddenReplies: boolean,
 ): ThreadSkeletonParts | null {
   if (!node) return null
 
   return {
     parents: Array.from(flattenThreadParents(node, hasSession)),
     highlightedPost: node,
-    replies: Array.from(flattenThreadReplies(node, hasSession, treeView)),
+    replies: Array.from(
+      flattenThreadReplies(
+        node,
+        hasSession,
+        treeView,
+        modCache,
+        showHiddenReplies,
+      ),
+    ),
   }
 }
 
@@ -465,31 +539,76 @@ function* flattenThreadParents(
   }
 }
 
+// The enum is ordered to make them easy to merge
+enum HiddenReplyType {
+  None = 0,
+  Muted = 1,
+  Hidden = 2,
+}
+
 function* flattenThreadReplies(
   node: ThreadNode,
   hasSession: boolean,
   treeView: boolean,
-): Generator<YieldedItem, void> {
+  modCache: ThreadModerationCache,
+  showHiddenReplies: boolean,
+): Generator<YieldedItem, HiddenReplyType> {
   if (node.type === 'post') {
+    // dont show pwi-opted-out posts to logged out users
     if (!hasSession && hasPwiOptOut(node)) {
-      return
+      return HiddenReplyType.None
+    }
+
+    // handle blurred items
+    if (node.ctx.depth > 0) {
+      const modui = modCache.get(node)?.ui('contentList')
+      if (modui?.blur) {
+        if (!showHiddenReplies || node.ctx.depth > 1) {
+          if (modui.blurs[0].type === 'muted') {
+            return HiddenReplyType.Muted
+          }
+          return HiddenReplyType.Hidden
+        }
+      }
     }
+
     if (!node.ctx.isHighlightedPost) {
       yield node
     }
+
     if (node.replies?.length) {
+      let hiddenReplies = HiddenReplyType.None
       for (const reply of node.replies) {
-        yield* flattenThreadReplies(reply, hasSession, treeView)
+        let hiddenReply = yield* flattenThreadReplies(
+          reply,
+          hasSession,
+          treeView,
+          modCache,
+          showHiddenReplies,
+        )
+        if (hiddenReply > hiddenReplies) {
+          hiddenReplies = hiddenReply
+        }
         if (!treeView && !node.ctx.isHighlightedPost) {
           break
         }
       }
+
+      // show control to enable hidden replies
+      if (node.ctx.depth === 0) {
+        if (hiddenReplies === HiddenReplyType.Muted) {
+          yield SHOW_MUTED_REPLIES
+        } else if (hiddenReplies === HiddenReplyType.Hidden) {
+          yield SHOW_HIDDEN_REPLIES
+        }
+      }
     }
   } else if (node.type === 'not-found') {
     yield node
   } else if (node.type === 'blocked') {
     yield node
   }
+  return HiddenReplyType.None
 }
 
 function hasPwiOptOut(node: ThreadPost) {
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index f644a5366..c44875b37 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -11,11 +11,9 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useOpenLink} from '#/state/preferences/in-app-browser'
-import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {ThreadPost} from '#/state/queries/post-thread'
 import {useComposerControls} from '#/state/shell/composer'
 import {MAX_POST_LINES} from 'lib/constants'
@@ -50,6 +48,7 @@ import {PreviewableUserAvatar} from '../util/UserAvatar'
 export function PostThreadItem({
   post,
   record,
+  moderation,
   treeView,
   depth,
   prevPost,
@@ -59,10 +58,12 @@ export function PostThreadItem({
   showChildReplyLine,
   showParentReplyLine,
   hasPrecedingItem,
+  overrideBlur,
   onPostReply,
 }: {
   post: AppBskyFeedDefs.PostView
   record: AppBskyFeedPost.Record
+  moderation: ModerationDecision | undefined
   treeView: boolean
   depth: number
   prevPost: ThreadPost | undefined
@@ -72,9 +73,9 @@ export function PostThreadItem({
   showChildReplyLine?: boolean
   showParentReplyLine?: boolean
   hasPrecedingItem: boolean
+  overrideBlur: boolean
   onPostReply: () => void
 }) {
-  const moderationOpts = useModerationOpts()
   const postShadowed = usePostShadow(post)
   const richText = useMemo(
     () =>
@@ -84,11 +85,6 @@ export function PostThreadItem({
       }),
     [record],
   )
-  const moderation = useMemo(
-    () =>
-      post && moderationOpts ? moderatePost(post, moderationOpts) : undefined,
-    [post, moderationOpts],
-  )
   if (postShadowed === POST_TOMBSTONE) {
     return <PostThreadItemDeleted />
   }
@@ -110,6 +106,7 @@ export function PostThreadItem({
         showChildReplyLine={showChildReplyLine}
         showParentReplyLine={showParentReplyLine}
         hasPrecedingItem={hasPrecedingItem}
+        overrideBlur={overrideBlur}
         onPostReply={onPostReply}
       />
     )
@@ -143,6 +140,7 @@ let PostThreadItemLoaded = ({
   showChildReplyLine,
   showParentReplyLine,
   hasPrecedingItem,
+  overrideBlur,
   onPostReply,
 }: {
   post: Shadow<AppBskyFeedDefs.PostView>
@@ -158,6 +156,7 @@ let PostThreadItemLoaded = ({
   showChildReplyLine?: boolean
   showParentReplyLine?: boolean
   hasPrecedingItem: boolean
+  overrideBlur: boolean
   onPostReply: () => void
 }): React.ReactNode => {
   const pal = usePalette('default')
@@ -394,6 +393,7 @@ let PostThreadItemLoaded = ({
           <PostHider
             testID={`postThreadItem-by-${post.author.handle}`}
             href={postHref}
+            disabled={overrideBlur}
             style={[pal.view]}
             modui={moderation.ui('contentList')}
             iconSize={isThreadedChild ? 26 : 38}
diff --git a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx b/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
new file mode 100644
index 000000000..998906524
--- /dev/null
+++ b/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
@@ -0,0 +1,61 @@
+import * as React from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {Text} from '#/components/Typography'
+
+export function PostThreadShowHiddenReplies({
+  type,
+  onPress,
+}: {
+  type: 'hidden' | 'muted'
+  onPress: () => void
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const label =
+    type === 'muted' ? _(msg`Show muted replies`) : _(msg`Show hidden replies`)
+
+  return (
+    <Button onPress={onPress} label={label}>
+      {({hovered, pressed}) => (
+        <View
+          style={[
+            a.flex_1,
+            a.flex_row,
+            a.align_center,
+            a.gap_sm,
+            a.py_lg,
+            a.px_xl,
+            a.border_t,
+            t.atoms.border_contrast_low,
+            hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg,
+          ]}>
+          <View
+            style={[
+              t.atoms.bg_contrast_25,
+              a.align_center,
+              a.justify_center,
+              {
+                width: 26,
+                height: 26,
+                borderRadius: 13,
+                marginRight: 4,
+              },
+            ]}>
+            <EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} />
+          </View>
+          <Text
+            style={[t.atoms.text_contrast_medium, a.flex_1]}
+            numberOfLines={1}>
+            {label}
+          </Text>
+        </View>
+      )}
+    </Button>
+  )
+}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 0decb81df..6e7c1c7eb 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -367,7 +367,7 @@ let PostContent = ({
       modui={moderation.ui('contentList')}
       ignoreMute
       childContainerStyle={styles.contentHiderChild}>
-      <PostAlerts modui={moderation.ui('contentList')} style={[a.py_xs]} />
+      <PostAlerts modui={moderation.ui('contentList')} style={[a.pb_xs]} />
       {richText.text ? (
         <View style={styles.postTextContainer}>
           <RichText
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
index 77b07b8c9..7d0d2fb03 100644
--- a/src/view/screens/DebugMod.tsx
+++ b/src/view/screens/DebugMod.tsx
@@ -813,6 +813,7 @@ function MockPostFeedItem({
 
 function MockPostThreadItem({
   post,
+  moderation,
   reply,
 }: {
   post: AppBskyFeedDefs.PostView
@@ -824,12 +825,14 @@ function MockPostThreadItem({
       // @ts-ignore
       post={post}
       record={post.record as AppBskyFeedPost.Record}
+      moderation={moderation}
       depth={reply ? 1 : 0}
       isHighlightedPost={!reply}
       treeView={false}
       prevPost={undefined}
       nextPost={undefined}
       hasPrecedingItem={false}
+      overrideBlur={false}
       onPostReply={() => {}}
     />
   )