diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx
index 0bfe6967..c59aa265 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 (
<>
diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx
index 05cb8464..177104f9 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 {
+ disabled: boolean
iconSize: number
iconStyles: StyleProp
modui: ModerationUI
@@ -27,6 +28,7 @@ interface Props extends ComponentProps {
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 (
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index 133304d2..4ee0eb3f 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
+
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 a52818fd..4f7d0d3c 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 && }
)
+ } else if (item === SHOW_HIDDEN_REPLIES) {
+ return (
+
+ setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
+ }
+ />
+ )
+ } else if (item === SHOW_MUTED_REPLIES) {
+ return (
+
+ setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
+ }
+ />
+ )
} else if (isThreadNotFound(item)) {
return (
@@ -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({
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 {
+ modCache: ThreadModerationCache,
+ showHiddenReplies: boolean,
+): Generator {
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 f644a536..c44875b3 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
}
@@ -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
@@ -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 = ({
void
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const label =
+ type === 'muted' ? _(msg`Show muted replies`) : _(msg`Show hidden replies`)
+
+ return (
+
+ )
+}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 0decb81d..6e7c1c7e 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}>
-
+
{richText.text ? (
{}}
/>
)