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>
This commit is contained in:
		
							parent
							
								
									d2c42cf169
								
							
						
					
					
						commit
						f7ee532a85
					
				
					 9 changed files with 311 additions and 67 deletions
				
			
		|  | @ -1,16 +1,16 @@ | ||||||
| import React from 'react' | import React from 'react' | ||||||
| import {StyleProp, View, ViewStyle} from 'react-native' | 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 {getModerationCauseKey} from '#/lib/moderation' | ||||||
| 
 | import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' | ||||||
| import {atoms as a} from '#/alf' | import {atoms as a, useTheme} from '#/alf' | ||||||
| import {Button, ButtonText, ButtonIcon} from '#/components/Button' | import {Button} from '#/components/Button' | ||||||
| import { | import { | ||||||
|   ModerationDetailsDialog, |   ModerationDetailsDialog, | ||||||
|   useModerationDetailsDialogControl, |   useModerationDetailsDialogControl, | ||||||
| } from '#/components/moderation/ModerationDetailsDialog' | } from '#/components/moderation/ModerationDetailsDialog' | ||||||
|  | import {Text} from '#/components/Typography' | ||||||
| 
 | 
 | ||||||
| export function PostAlerts({ | export function PostAlerts({ | ||||||
|   modui, |   modui, | ||||||
|  | @ -41,23 +41,41 @@ export function PostAlerts({ | ||||||
| function PostLabel({cause}: {cause: ModerationCause}) { | function PostLabel({cause}: {cause: ModerationCause}) { | ||||||
|   const control = useModerationDetailsDialogControl() |   const control = useModerationDetailsDialogControl() | ||||||
|   const desc = useModerationCauseDescription(cause) |   const desc = useModerationCauseDescription(cause) | ||||||
|  |   const t = useTheme() | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <Button |       <Button | ||||||
|         label={desc.name} |         label={desc.name} | ||||||
|         variant="solid" |  | ||||||
|         color="secondary" |  | ||||||
|         size="small" |  | ||||||
|         shape="default" |  | ||||||
|         onPress={() => { |         onPress={() => { | ||||||
|           control.open() |           control.open() | ||||||
|         }} |         }}> | ||||||
|         style={[a.px_sm, a.py_xs, a.gap_xs]}> |         {({hovered, pressed}) => ( | ||||||
|         <ButtonIcon icon={desc.icon} position="left" /> |           <View | ||||||
|         <ButtonText style={[a.text_left, a.leading_snug]}> |             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.name} | ||||||
|         </ButtonText> |               {desc.source ? ` – ${desc.source}` : ''} | ||||||
|  |             </Text> | ||||||
|  |           </View> | ||||||
|  |         )} | ||||||
|       </Button> |       </Button> | ||||||
| 
 | 
 | ||||||
|       <ModerationDetailsDialog control={control} modcause={cause} /> |       <ModerationDetailsDialog control={control} modcause={cause} /> | ||||||
|  |  | ||||||
|  | @ -18,6 +18,7 @@ import { | ||||||
| import {Text} from '#/components/Typography' | import {Text} from '#/components/Typography' | ||||||
| 
 | 
 | ||||||
| interface Props extends ComponentProps<typeof Link> { | interface Props extends ComponentProps<typeof Link> { | ||||||
|  |   disabled: boolean | ||||||
|   iconSize: number |   iconSize: number | ||||||
|   iconStyles: StyleProp<ViewStyle> |   iconStyles: StyleProp<ViewStyle> | ||||||
|   modui: ModerationUI |   modui: ModerationUI | ||||||
|  | @ -27,6 +28,7 @@ interface Props extends ComponentProps<typeof Link> { | ||||||
| export function PostHider({ | export function PostHider({ | ||||||
|   testID, |   testID, | ||||||
|   href, |   href, | ||||||
|  |   disabled, | ||||||
|   modui, |   modui, | ||||||
|   style, |   style, | ||||||
|   children, |   children, | ||||||
|  | @ -47,7 +49,7 @@ export function PostHider({ | ||||||
|     precacheProfile(queryClient, profile) |     precacheProfile(queryClient, profile) | ||||||
|   }, [queryClient, profile]) |   }, [queryClient, profile]) | ||||||
| 
 | 
 | ||||||
|   if (!blur) { |   if (!blur || (disabled && !modui.noOverride)) { | ||||||
|     return ( |     return ( | ||||||
|       <Link |       <Link | ||||||
|         testID={testID} |         testID={testID} | ||||||
|  |  | ||||||
|  | @ -2,15 +2,15 @@ import React from 'react' | ||||||
| import {StyleProp, View, ViewStyle} from 'react-native' | import {StyleProp, View, ViewStyle} from 'react-native' | ||||||
| import {ModerationCause, ModerationDecision} from '@atproto/api' | import {ModerationCause, ModerationDecision} from '@atproto/api' | ||||||
| 
 | 
 | ||||||
| import {getModerationCauseKey} from 'lib/moderation' |  | ||||||
| import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' | import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' | ||||||
| 
 | import {getModerationCauseKey} from 'lib/moderation' | ||||||
| import {atoms as a} from '#/alf' | import {atoms as a, useTheme} from '#/alf' | ||||||
| import {Button, ButtonText, ButtonIcon} from '#/components/Button' | import {Button} from '#/components/Button' | ||||||
| import { | import { | ||||||
|   ModerationDetailsDialog, |   ModerationDetailsDialog, | ||||||
|   useModerationDetailsDialogControl, |   useModerationDetailsDialogControl, | ||||||
| } from '#/components/moderation/ModerationDetailsDialog' | } from '#/components/moderation/ModerationDetailsDialog' | ||||||
|  | import {Text} from '#/components/Typography' | ||||||
| 
 | 
 | ||||||
| export function ProfileHeaderAlerts({ | export function ProfileHeaderAlerts({ | ||||||
|   moderation, |   moderation, | ||||||
|  | @ -39,6 +39,7 @@ export function ProfileHeaderAlerts({ | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function ProfileLabel({cause}: {cause: ModerationCause}) { | function ProfileLabel({cause}: {cause: ModerationCause}) { | ||||||
|  |   const t = useTheme() | ||||||
|   const control = useModerationDetailsDialogControl() |   const control = useModerationDetailsDialogControl() | ||||||
|   const desc = useModerationCauseDescription(cause) |   const desc = useModerationCauseDescription(cause) | ||||||
| 
 | 
 | ||||||
|  | @ -46,18 +47,35 @@ function ProfileLabel({cause}: {cause: ModerationCause}) { | ||||||
|     <> |     <> | ||||||
|       <Button |       <Button | ||||||
|         label={desc.name} |         label={desc.name} | ||||||
|         variant="solid" |  | ||||||
|         color="secondary" |  | ||||||
|         size="small" |  | ||||||
|         shape="default" |  | ||||||
|         onPress={() => { |         onPress={() => { | ||||||
|           control.open() |           control.open() | ||||||
|         }} |         }}> | ||||||
|         style={[a.px_sm, a.py_xs, a.gap_xs]}> |         {({hovered, pressed}) => ( | ||||||
|         <ButtonIcon icon={desc.icon} position="left" /> |           <View | ||||||
|         <ButtonText style={[a.text_left, a.leading_snug]}> |             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.name} | ||||||
|         </ButtonText> |               {desc.source ? ` – ${desc.source}` : ''} | ||||||
|  |             </Text> | ||||||
|  |           </View> | ||||||
|  |         )} | ||||||
|       </Button> |       </Button> | ||||||
| 
 | 
 | ||||||
|       <ModerationDetailsDialog control={control} modcause={cause} /> |       <ModerationDetailsDialog control={control} modcause={cause} /> | ||||||
|  |  | ||||||
|  | @ -3,9 +3,12 @@ import { | ||||||
|   AppBskyFeedDefs, |   AppBskyFeedDefs, | ||||||
|   AppBskyFeedGetPostThread, |   AppBskyFeedGetPostThread, | ||||||
|   AppBskyFeedPost, |   AppBskyFeedPost, | ||||||
|  |   ModerationDecision, | ||||||
|  |   ModerationOpts, | ||||||
| } from '@atproto/api' | } from '@atproto/api' | ||||||
| import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' | 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 {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' | ||||||
| import {useAgent} from '#/state/session' | import {useAgent} from '#/state/session' | ||||||
| import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from 'state/queries/search-posts' | import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from 'state/queries/search-posts' | ||||||
|  | @ -21,8 +24,6 @@ export interface ThreadCtx { | ||||||
|   depth: number |   depth: number | ||||||
|   isHighlightedPost?: boolean |   isHighlightedPost?: boolean | ||||||
|   hasMore?: boolean |   hasMore?: boolean | ||||||
|   showChildReplyLine?: boolean |  | ||||||
|   showParentReplyLine?: boolean |  | ||||||
|   isParentLoading?: boolean |   isParentLoading?: boolean | ||||||
|   isChildLoading?: boolean |   isChildLoading?: boolean | ||||||
| } | } | ||||||
|  | @ -63,6 +64,8 @@ export type ThreadNode = | ||||||
|   | ThreadBlocked |   | ThreadBlocked | ||||||
|   | ThreadUnknown |   | ThreadUnknown | ||||||
| 
 | 
 | ||||||
|  | export type ThreadModerationCache = WeakMap<ThreadNode, ModerationDecision> | ||||||
|  | 
 | ||||||
| export function usePostThreadQuery(uri: string | undefined) { | export function usePostThreadQuery(uri: string | undefined) { | ||||||
|   const queryClient = useQueryClient() |   const queryClient = useQueryClient() | ||||||
|   const {getAgent} = useAgent() |   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( | export function sortThread( | ||||||
|   node: ThreadNode, |   node: ThreadNode, | ||||||
|   opts: UsePreferencesQueryResponse['threadViewPrefs'], |   opts: UsePreferencesQueryResponse['threadViewPrefs'], | ||||||
|  |   modCache: ThreadModerationCache, | ||||||
| ): ThreadNode { | ): ThreadNode { | ||||||
|   if (node.type !== 'post') { |   if (node.type !== 'post') { | ||||||
|     return node |     return node | ||||||
|  | @ -117,6 +139,18 @@ export function sortThread( | ||||||
|       } else if (bIsByOp) { |       } else if (bIsByOp) { | ||||||
|         return 1 // op's own reply
 |         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) { |       if (opts.prioritizeFollowedUsers) { | ||||||
|         const af = a.post.author.viewer?.following |         const af = a.post.author.viewer?.following | ||||||
|         const bf = b.post.author.viewer?.following |         const bf = b.post.author.viewer?.following | ||||||
|  | @ -126,6 +160,7 @@ export function sortThread( | ||||||
|           return 1 |           return 1 | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|       if (opts.sort === 'oldest') { |       if (opts.sort === 'oldest') { | ||||||
|         return a.post.indexedAt.localeCompare(b.post.indexedAt) |         return a.post.indexedAt.localeCompare(b.post.indexedAt) | ||||||
|       } else if (opts.sort === 'newest') { |       } else if (opts.sort === 'newest') { | ||||||
|  | @ -141,7 +176,7 @@ export function sortThread( | ||||||
|       } |       } | ||||||
|       return b.post.indexedAt.localeCompare(a.post.indexedAt) |       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 |   return node | ||||||
| } | } | ||||||
|  | @ -188,12 +223,6 @@ function responseToThreadNodes( | ||||||
|         isHighlightedPost: depth === 0, |         isHighlightedPost: depth === 0, | ||||||
|         hasMore: |         hasMore: | ||||||
|           direction === 'down' && !node.replies?.length && !!node.replyCount, |           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)) { |   } else if (AppBskyFeedDefs.isBlockedPost(node)) { | ||||||
|  | @ -296,8 +325,6 @@ function threadNodeToPlaceholderThread( | ||||||
|       depth: 0, |       depth: 0, | ||||||
|       isHighlightedPost: true, |       isHighlightedPost: true, | ||||||
|       hasMore: false, |       hasMore: false, | ||||||
|       showChildReplyLine: false, |  | ||||||
|       showParentReplyLine: false, |  | ||||||
|       isParentLoading: !!node.record.reply, |       isParentLoading: !!node.record.reply, | ||||||
|       isChildLoading: !!node.post.replyCount, |       isChildLoading: !!node.post.replyCount, | ||||||
|     }, |     }, | ||||||
|  | @ -319,8 +346,6 @@ function postViewToPlaceholderThread( | ||||||
|       depth: 0, |       depth: 0, | ||||||
|       isHighlightedPost: true, |       isHighlightedPost: true, | ||||||
|       hasMore: false, |       hasMore: false, | ||||||
|       showChildReplyLine: false, |  | ||||||
|       showParentReplyLine: false, |  | ||||||
|       isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply, |       isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply, | ||||||
|       isChildLoading: true, // assume yes (show the spinner) just in case
 |       isChildLoading: true, // assume yes (show the spinner) just in case
 | ||||||
|     }, |     }, | ||||||
|  | @ -342,8 +367,6 @@ function embedViewRecordToPlaceholderThread( | ||||||
|       depth: 0, |       depth: 0, | ||||||
|       isHighlightedPost: true, |       isHighlightedPost: true, | ||||||
|       hasMore: false, |       hasMore: false, | ||||||
|       showChildReplyLine: false, |  | ||||||
|       showParentReplyLine: false, |  | ||||||
|       isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply, |       isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply, | ||||||
|       isChildLoading: true, // not available, so assume yes (to show the spinner)
 |       isChildLoading: true, // not available, so assume yes (to show the spinner)
 | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|  | @ -10,8 +10,10 @@ import {ScrollProvider} from '#/lib/ScrollContext' | ||||||
| import {isAndroid, isNative, isWeb} from '#/platform/detection' | import {isAndroid, isNative, isWeb} from '#/platform/detection' | ||||||
| import {useModerationOpts} from '#/state/preferences/moderation-opts' | import {useModerationOpts} from '#/state/preferences/moderation-opts' | ||||||
| import { | import { | ||||||
|  |   fillThreadModerationCache, | ||||||
|   sortThread, |   sortThread, | ||||||
|   ThreadBlocked, |   ThreadBlocked, | ||||||
|  |   ThreadModerationCache, | ||||||
|   ThreadNode, |   ThreadNode, | ||||||
|   ThreadNotFound, |   ThreadNotFound, | ||||||
|   ThreadPost, |   ThreadPost, | ||||||
|  | @ -31,6 +33,7 @@ import {List, ListMethods} from '../util/List' | ||||||
| import {Text} from '../util/text/Text' | import {Text} from '../util/text/Text' | ||||||
| import {ViewHeader} from '../util/ViewHeader' | import {ViewHeader} from '../util/ViewHeader' | ||||||
| import {PostThreadItem} from './PostThreadItem' | import {PostThreadItem} from './PostThreadItem' | ||||||
|  | import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies' | ||||||
| 
 | 
 | ||||||
| // FlatList maintainVisibleContentPosition breaks if too many items
 | // FlatList maintainVisibleContentPosition breaks if too many items
 | ||||||
| // are prepended. This seems to be an optimal number based on *shrug*.
 | // 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 TOP_COMPONENT = {_reactKey: '__top_component__'} | ||||||
| const REPLY_PROMPT = {_reactKey: '__reply__'} | const REPLY_PROMPT = {_reactKey: '__reply__'} | ||||||
| const LOAD_MORE = {_reactKey: '__load_more__'} | 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 = | type RowItem = | ||||||
|   | YieldedItem |   | YieldedItem | ||||||
|   // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
 |   // 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 {isMobile, isTabletOrMobile} = useWebMediaQueries() | ||||||
|   const initialNumToRender = useInitialNumToRender() |   const initialNumToRender = useInitialNumToRender() | ||||||
|   const {height: windowHeight} = useWindowDimensions() |   const {height: windowHeight} = useWindowDimensions() | ||||||
|  |   const [hiddenRepliesState, setHiddenRepliesState] = React.useState( | ||||||
|  |     HiddenRepliesState.Hide, | ||||||
|  |   ) | ||||||
| 
 | 
 | ||||||
|   const {data: preferences} = usePreferencesQuery() |   const {data: preferences} = usePreferencesQuery() | ||||||
|   const { |   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.
 |   // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
 | ||||||
|   const [deferParents, setDeferParents] = React.useState(isNative) |   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 skeleton = React.useMemo(() => { | ||||||
|     const threadViewPrefs = preferences?.threadViewPrefs |     const threadViewPrefs = preferences?.threadViewPrefs | ||||||
|     if (!threadViewPrefs || !thread) return null |     if (!threadViewPrefs || !thread) return null | ||||||
| 
 | 
 | ||||||
|     return createThreadSkeleton( |     return createThreadSkeleton( | ||||||
|       sortThread(thread, threadViewPrefs), |       sortThread(thread, threadViewPrefs, threadModerationCache), | ||||||
|       hasSession, |       hasSession, | ||||||
|       treeView, |       treeView, | ||||||
|  |       threadModerationCache, | ||||||
|  |       hiddenRepliesState !== HiddenRepliesState.Hide, | ||||||
|     ) |     ) | ||||||
|   }, [thread, preferences?.threadViewPrefs, hasSession, treeView]) |   }, [ | ||||||
|  |     thread, | ||||||
|  |     preferences?.threadViewPrefs, | ||||||
|  |     hasSession, | ||||||
|  |     treeView, | ||||||
|  |     threadModerationCache, | ||||||
|  |     hiddenRepliesState, | ||||||
|  |   ]) | ||||||
| 
 | 
 | ||||||
|   const error = React.useMemo(() => { |   const error = React.useMemo(() => { | ||||||
|     if (AppBskyFeedDefs.isNotFoundPost(thread)) { |     if (AppBskyFeedDefs.isNotFoundPost(thread)) { | ||||||
|  | @ -301,6 +337,24 @@ export function PostThread({ | ||||||
|             {!isMobile && <ComposePrompt onPressCompose={onPressReply} />} |             {!isMobile && <ComposePrompt onPressCompose={onPressReply} />} | ||||||
|           </View> |           </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)) { |       } else if (isThreadNotFound(item)) { | ||||||
|         return ( |         return ( | ||||||
|           <View style={[pal.border, pal.viewLight, styles.itemContainer]}> |           <View style={[pal.border, pal.viewLight, styles.itemContainer]}> | ||||||
|  | @ -321,9 +375,12 @@ export function PostThread({ | ||||||
|         const prev = isThreadPost(posts[index - 1]) |         const prev = isThreadPost(posts[index - 1]) | ||||||
|           ? (posts[index - 1] as ThreadPost) |           ? (posts[index - 1] as ThreadPost) | ||||||
|           : undefined |           : undefined | ||||||
|         const next = isThreadPost(posts[index - 1]) |         const next = isThreadPost(posts[index + 1]) | ||||||
|           ? (posts[index - 1] as ThreadPost) |           ? (posts[index + 1] as ThreadPost) | ||||||
|           : undefined |           : undefined | ||||||
|  |         const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth | ||||||
|  |         const showParentReplyLine = | ||||||
|  |           (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1 | ||||||
|         const hasUnrevealedParents = |         const hasUnrevealedParents = | ||||||
|           index === 0 && |           index === 0 && | ||||||
|           skeleton?.parents && |           skeleton?.parents && | ||||||
|  | @ -335,16 +392,20 @@ export function PostThread({ | ||||||
|             <PostThreadItem |             <PostThreadItem | ||||||
|               post={item.post} |               post={item.post} | ||||||
|               record={item.record} |               record={item.record} | ||||||
|  |               moderation={threadModerationCache.get(item)} | ||||||
|               treeView={treeView} |               treeView={treeView} | ||||||
|               depth={item.ctx.depth} |               depth={item.ctx.depth} | ||||||
|               prevPost={prev} |               prevPost={prev} | ||||||
|               nextPost={next} |               nextPost={next} | ||||||
|               isHighlightedPost={item.ctx.isHighlightedPost} |               isHighlightedPost={item.ctx.isHighlightedPost} | ||||||
|               hasMore={item.ctx.hasMore} |               hasMore={item.ctx.hasMore} | ||||||
|               showChildReplyLine={item.ctx.showChildReplyLine} |               showChildReplyLine={showChildReplyLine} | ||||||
|               showParentReplyLine={item.ctx.showParentReplyLine} |               showParentReplyLine={showParentReplyLine} | ||||||
|               hasPrecedingItem={ |               hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents} | ||||||
|                 !!prev?.ctx.showChildReplyLine || !!hasUnrevealedParents |               overrideBlur={ | ||||||
|  |                 hiddenRepliesState === | ||||||
|  |                   HiddenRepliesState.ShowAndOverridePostHider && | ||||||
|  |                 item.ctx.depth > 0 | ||||||
|               } |               } | ||||||
|               onPostReply={refetch} |               onPostReply={refetch} | ||||||
|             /> |             /> | ||||||
|  | @ -368,6 +429,9 @@ export function PostThread({ | ||||||
|       deferParents, |       deferParents, | ||||||
|       treeView, |       treeView, | ||||||
|       refetch, |       refetch, | ||||||
|  |       threadModerationCache, | ||||||
|  |       hiddenRepliesState, | ||||||
|  |       setHiddenRepliesState, | ||||||
|     ], |     ], | ||||||
|   ) |   ) | ||||||
| 
 | 
 | ||||||
|  | @ -437,13 +501,23 @@ function createThreadSkeleton( | ||||||
|   node: ThreadNode, |   node: ThreadNode, | ||||||
|   hasSession: boolean, |   hasSession: boolean, | ||||||
|   treeView: boolean, |   treeView: boolean, | ||||||
|  |   modCache: ThreadModerationCache, | ||||||
|  |   showHiddenReplies: boolean, | ||||||
| ): ThreadSkeletonParts | null { | ): ThreadSkeletonParts | null { | ||||||
|   if (!node) return null |   if (!node) return null | ||||||
| 
 | 
 | ||||||
|   return { |   return { | ||||||
|     parents: Array.from(flattenThreadParents(node, hasSession)), |     parents: Array.from(flattenThreadParents(node, hasSession)), | ||||||
|     highlightedPost: node, |     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( | function* flattenThreadReplies( | ||||||
|   node: ThreadNode, |   node: ThreadNode, | ||||||
|   hasSession: boolean, |   hasSession: boolean, | ||||||
|   treeView: boolean, |   treeView: boolean, | ||||||
| ): Generator<YieldedItem, void> { |   modCache: ThreadModerationCache, | ||||||
|  |   showHiddenReplies: boolean, | ||||||
|  | ): Generator<YieldedItem, HiddenReplyType> { | ||||||
|   if (node.type === 'post') { |   if (node.type === 'post') { | ||||||
|  |     // dont show pwi-opted-out posts to logged out users
 | ||||||
|     if (!hasSession && hasPwiOptOut(node)) { |     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) { |     if (!node.ctx.isHighlightedPost) { | ||||||
|       yield node |       yield node | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|     if (node.replies?.length) { |     if (node.replies?.length) { | ||||||
|  |       let hiddenReplies = HiddenReplyType.None | ||||||
|       for (const reply of node.replies) { |       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) { |         if (!treeView && !node.ctx.isHighlightedPost) { | ||||||
|           break |           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') { |   } else if (node.type === 'not-found') { | ||||||
|     yield node |     yield node | ||||||
|   } else if (node.type === 'blocked') { |   } else if (node.type === 'blocked') { | ||||||
|     yield node |     yield node | ||||||
|   } |   } | ||||||
|  |   return HiddenReplyType.None | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function hasPwiOptOut(node: ThreadPost) { | function hasPwiOptOut(node: ThreadPost) { | ||||||
|  |  | ||||||
|  | @ -11,11 +11,9 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' | ||||||
| import {msg, Plural, Trans} from '@lingui/macro' | import {msg, Plural, Trans} from '@lingui/macro' | ||||||
| import {useLingui} from '@lingui/react' | 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 {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' | ||||||
| import {useLanguagePrefs} from '#/state/preferences' | import {useLanguagePrefs} from '#/state/preferences' | ||||||
| import {useOpenLink} from '#/state/preferences/in-app-browser' | import {useOpenLink} from '#/state/preferences/in-app-browser' | ||||||
| import {useModerationOpts} from '#/state/preferences/moderation-opts' |  | ||||||
| import {ThreadPost} from '#/state/queries/post-thread' | import {ThreadPost} from '#/state/queries/post-thread' | ||||||
| import {useComposerControls} from '#/state/shell/composer' | import {useComposerControls} from '#/state/shell/composer' | ||||||
| import {MAX_POST_LINES} from 'lib/constants' | import {MAX_POST_LINES} from 'lib/constants' | ||||||
|  | @ -50,6 +48,7 @@ import {PreviewableUserAvatar} from '../util/UserAvatar' | ||||||
| export function PostThreadItem({ | export function PostThreadItem({ | ||||||
|   post, |   post, | ||||||
|   record, |   record, | ||||||
|  |   moderation, | ||||||
|   treeView, |   treeView, | ||||||
|   depth, |   depth, | ||||||
|   prevPost, |   prevPost, | ||||||
|  | @ -59,10 +58,12 @@ export function PostThreadItem({ | ||||||
|   showChildReplyLine, |   showChildReplyLine, | ||||||
|   showParentReplyLine, |   showParentReplyLine, | ||||||
|   hasPrecedingItem, |   hasPrecedingItem, | ||||||
|  |   overrideBlur, | ||||||
|   onPostReply, |   onPostReply, | ||||||
| }: { | }: { | ||||||
|   post: AppBskyFeedDefs.PostView |   post: AppBskyFeedDefs.PostView | ||||||
|   record: AppBskyFeedPost.Record |   record: AppBskyFeedPost.Record | ||||||
|  |   moderation: ModerationDecision | undefined | ||||||
|   treeView: boolean |   treeView: boolean | ||||||
|   depth: number |   depth: number | ||||||
|   prevPost: ThreadPost | undefined |   prevPost: ThreadPost | undefined | ||||||
|  | @ -72,9 +73,9 @@ export function PostThreadItem({ | ||||||
|   showChildReplyLine?: boolean |   showChildReplyLine?: boolean | ||||||
|   showParentReplyLine?: boolean |   showParentReplyLine?: boolean | ||||||
|   hasPrecedingItem: boolean |   hasPrecedingItem: boolean | ||||||
|  |   overrideBlur: boolean | ||||||
|   onPostReply: () => void |   onPostReply: () => void | ||||||
| }) { | }) { | ||||||
|   const moderationOpts = useModerationOpts() |  | ||||||
|   const postShadowed = usePostShadow(post) |   const postShadowed = usePostShadow(post) | ||||||
|   const richText = useMemo( |   const richText = useMemo( | ||||||
|     () => |     () => | ||||||
|  | @ -84,11 +85,6 @@ export function PostThreadItem({ | ||||||
|       }), |       }), | ||||||
|     [record], |     [record], | ||||||
|   ) |   ) | ||||||
|   const moderation = useMemo( |  | ||||||
|     () => |  | ||||||
|       post && moderationOpts ? moderatePost(post, moderationOpts) : undefined, |  | ||||||
|     [post, moderationOpts], |  | ||||||
|   ) |  | ||||||
|   if (postShadowed === POST_TOMBSTONE) { |   if (postShadowed === POST_TOMBSTONE) { | ||||||
|     return <PostThreadItemDeleted /> |     return <PostThreadItemDeleted /> | ||||||
|   } |   } | ||||||
|  | @ -110,6 +106,7 @@ export function PostThreadItem({ | ||||||
|         showChildReplyLine={showChildReplyLine} |         showChildReplyLine={showChildReplyLine} | ||||||
|         showParentReplyLine={showParentReplyLine} |         showParentReplyLine={showParentReplyLine} | ||||||
|         hasPrecedingItem={hasPrecedingItem} |         hasPrecedingItem={hasPrecedingItem} | ||||||
|  |         overrideBlur={overrideBlur} | ||||||
|         onPostReply={onPostReply} |         onPostReply={onPostReply} | ||||||
|       /> |       /> | ||||||
|     ) |     ) | ||||||
|  | @ -143,6 +140,7 @@ let PostThreadItemLoaded = ({ | ||||||
|   showChildReplyLine, |   showChildReplyLine, | ||||||
|   showParentReplyLine, |   showParentReplyLine, | ||||||
|   hasPrecedingItem, |   hasPrecedingItem, | ||||||
|  |   overrideBlur, | ||||||
|   onPostReply, |   onPostReply, | ||||||
| }: { | }: { | ||||||
|   post: Shadow<AppBskyFeedDefs.PostView> |   post: Shadow<AppBskyFeedDefs.PostView> | ||||||
|  | @ -158,6 +156,7 @@ let PostThreadItemLoaded = ({ | ||||||
|   showChildReplyLine?: boolean |   showChildReplyLine?: boolean | ||||||
|   showParentReplyLine?: boolean |   showParentReplyLine?: boolean | ||||||
|   hasPrecedingItem: boolean |   hasPrecedingItem: boolean | ||||||
|  |   overrideBlur: boolean | ||||||
|   onPostReply: () => void |   onPostReply: () => void | ||||||
| }): React.ReactNode => { | }): React.ReactNode => { | ||||||
|   const pal = usePalette('default') |   const pal = usePalette('default') | ||||||
|  | @ -394,6 +393,7 @@ let PostThreadItemLoaded = ({ | ||||||
|           <PostHider |           <PostHider | ||||||
|             testID={`postThreadItem-by-${post.author.handle}`} |             testID={`postThreadItem-by-${post.author.handle}`} | ||||||
|             href={postHref} |             href={postHref} | ||||||
|  |             disabled={overrideBlur} | ||||||
|             style={[pal.view]} |             style={[pal.view]} | ||||||
|             modui={moderation.ui('contentList')} |             modui={moderation.ui('contentList')} | ||||||
|             iconSize={isThreadedChild ? 26 : 38} |             iconSize={isThreadedChild ? 26 : 38} | ||||||
|  |  | ||||||
							
								
								
									
										61
									
								
								src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  | @ -367,7 +367,7 @@ let PostContent = ({ | ||||||
|       modui={moderation.ui('contentList')} |       modui={moderation.ui('contentList')} | ||||||
|       ignoreMute |       ignoreMute | ||||||
|       childContainerStyle={styles.contentHiderChild}> |       childContainerStyle={styles.contentHiderChild}> | ||||||
|       <PostAlerts modui={moderation.ui('contentList')} style={[a.py_xs]} /> |       <PostAlerts modui={moderation.ui('contentList')} style={[a.pb_xs]} /> | ||||||
|       {richText.text ? ( |       {richText.text ? ( | ||||||
|         <View style={styles.postTextContainer}> |         <View style={styles.postTextContainer}> | ||||||
|           <RichText |           <RichText | ||||||
|  |  | ||||||
|  | @ -813,6 +813,7 @@ function MockPostFeedItem({ | ||||||
| 
 | 
 | ||||||
| function MockPostThreadItem({ | function MockPostThreadItem({ | ||||||
|   post, |   post, | ||||||
|  |   moderation, | ||||||
|   reply, |   reply, | ||||||
| }: { | }: { | ||||||
|   post: AppBskyFeedDefs.PostView |   post: AppBskyFeedDefs.PostView | ||||||
|  | @ -824,12 +825,14 @@ function MockPostThreadItem({ | ||||||
|       // @ts-ignore
 |       // @ts-ignore
 | ||||||
|       post={post} |       post={post} | ||||||
|       record={post.record as AppBskyFeedPost.Record} |       record={post.record as AppBskyFeedPost.Record} | ||||||
|  |       moderation={moderation} | ||||||
|       depth={reply ? 1 : 0} |       depth={reply ? 1 : 0} | ||||||
|       isHighlightedPost={!reply} |       isHighlightedPost={!reply} | ||||||
|       treeView={false} |       treeView={false} | ||||||
|       prevPost={undefined} |       prevPost={undefined} | ||||||
|       nextPost={undefined} |       nextPost={undefined} | ||||||
|       hasPrecedingItem={false} |       hasPrecedingItem={false} | ||||||
|  |       overrideBlur={false} | ||||||
|       onPostReply={() => {}} |       onPostReply={() => {}} | ||||||
|     /> |     /> | ||||||
|   ) |   ) | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue