From f7ee532a8521b96afd2cb358980d9b25bd59c29a Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Thu, 23 May 2024 16:39:39 -0700
Subject: Improve moderation behaviors: show alert/inform sources and improve
 UX around threads (#3677)

* Dont show account or profile alerts and informs on posts

* Sort threads to put blurred items at bottom

* Group blurred replies under a single 'show hidden replies' control

* Distinguish between muted and hidden replies in the thread view

* Fix types

* Modify the label alerts with some minor aesthetic updates and to show the source of a label

* Tune when an account-level alert is shown on a post

* Revert: show account-level alerts on posts again

* Rm unused import

* Fix to showing hidden replies when viewing a blurred item

* Go ahead and uncover replies when 'show hidden posts' is clicked

---------

Co-authored-by: dan <dan.abramov@gmail.com>
---
 src/view/com/post-thread/PostThread.tsx | 145 +++++++++++++++++++++++++++++---
 1 file changed, 132 insertions(+), 13 deletions(-)

(limited to 'src/view/com/post-thread/PostThread.tsx')

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) {
-- 
cgit 1.4.1

