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>
zio/stable
Paul Frazee 2024-05-23 16:39:39 -07:00 committed by GitHub
parent d2c42cf169
commit f7ee532a85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 311 additions and 67 deletions

View File

@ -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 (
<>
<Button
label={desc.name}
variant="solid"
color="secondary"
size="small"
shape="default"
onPress={() => {
control.open()
}}
style={[a.px_sm, a.py_xs, a.gap_xs]}>
<ButtonIcon icon={desc.icon} position="left" />
<ButtonText style={[a.text_left, a.leading_snug]}>
}}>
{({hovered, pressed}) => (
<View
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}
</ButtonText>
{desc.source ? ` ${desc.source}` : ''}
</Text>
</View>
)}
</Button>
<ModerationDetailsDialog control={control} modcause={cause} />

View File

@ -18,6 +18,7 @@ import {
import {Text} from '#/components/Typography'
interface Props extends ComponentProps<typeof Link> {
disabled: boolean
iconSize: number
iconStyles: StyleProp<ViewStyle>
modui: ModerationUI
@ -27,6 +28,7 @@ interface Props extends ComponentProps<typeof Link> {
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 (
<Link
testID={testID}

View File

@ -2,15 +2,15 @@ import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {ModerationCause, ModerationDecision} from '@atproto/api'
import {getModerationCauseKey} from 'lib/moderation'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {atoms as a} from '#/alf'
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
import {getModerationCauseKey} from 'lib/moderation'
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 ProfileHeaderAlerts({
moderation,
@ -39,6 +39,7 @@ export function ProfileHeaderAlerts({
}
function ProfileLabel({cause}: {cause: ModerationCause}) {
const t = useTheme()
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
@ -46,18 +47,35 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
<>
<Button
label={desc.name}
variant="solid"
color="secondary"
size="small"
shape="default"
onPress={() => {
control.open()
}}
style={[a.px_sm, a.py_xs, a.gap_xs]}>
<ButtonIcon icon={desc.icon} position="left" />
<ButtonText style={[a.text_left, a.leading_snug]}>
}}>
{({hovered, pressed}) => (
<View
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}
</ButtonText>
{desc.source ? ` ${desc.source}` : ''}
</Text>
</View>
)}
</Button>
<ModerationDetailsDialog control={control} modcause={cause} />

View File

@ -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<ThreadNode, ModerationDecision>
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)
},

View File

@ -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) {

View File

@ -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}

View 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>
)
}

View File

@ -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

View File

@ -813,6 +813,7 @@ function MockPostFeedItem({
function MockPostThreadItem({
post,
moderation,
reply,
}: {
post: AppBskyFeedDefs.PostView
@ -824,12 +825,14 @@ function MockPostThreadItem({
// @ts-ignore
post={post}
record={post.record as AppBskyFeedPost.Record}
moderation={moderation}
depth={reply ? 1 : 0}
isHighlightedPost={!reply}
treeView={false}
prevPost={undefined}
nextPost={undefined}
hasPrecedingItem={false}
overrideBlur={false}
onPostReply={() => {}}
/>
)