about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-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
4 files changed, 202 insertions, 22 deletions
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