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 ? ( {}} /> )