Refactor moderation to apply to accounts, profiles, and posts correctly (#548)

* Add ScreenHider component

* Add blur attribute to UserAvatar and UserBanner

* Remove dead suggested posts component and model

* Bump @atproto/api@0.2.10

* Rework moderation tooling to give a more precise DSL

* Add label mocks

* Apply finer grained moderation controls

* Refactor ProfileCard to just take the profile object

* Apply moderation to user listings and banner

* Apply moderation to notifications

* Fix lint

* Tune avatar & banner blur settings per platform

* 1.24
This commit is contained in:
Paul Frazee 2023-04-27 12:38:23 -05:00 committed by GitHub
parent 51be8474db
commit 1d50ddb378
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1195 additions and 763 deletions

View file

@ -97,7 +97,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
<UserAvatar
avatar={opts.authorAvatar}
size={16}
hasWarning={opts.authorHasWarning}
// TODO moderation
/>
</View>
)}

View file

@ -13,8 +13,11 @@ import {useStores} from 'state/index'
import {colors} from 'lib/styles'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {isWeb, isAndroid} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {AvatarModeration} from 'lib/labeling/types'
const BLUR_AMOUNT = isWeb ? 5 : 100
function DefaultAvatar({size}: {size: number}) {
return (
@ -40,12 +43,12 @@ function DefaultAvatar({size}: {size: number}) {
export function UserAvatar({
size,
avatar,
hasWarning,
moderation,
onSelectNewAvatar,
}: {
size: number
avatar?: string | null
hasWarning?: boolean
moderation?: AvatarModeration
onSelectNewAvatar?: (img: RNImage | null) => void
}) {
const store = useStores()
@ -114,7 +117,7 @@ export function UserAvatar({
)
const warning = useMemo(() => {
if (!hasWarning) {
if (!moderation?.warn) {
return null
}
return (
@ -126,7 +129,7 @@ export function UserAvatar({
/>
</View>
)
}, [hasWarning, size, pal])
}, [moderation?.warn, size, pal])
// onSelectNewAvatar is only passed as prop on the EditProfile component
return onSelectNewAvatar ? (
@ -159,13 +162,15 @@ export function UserAvatar({
/>
</View>
</DropdownButton>
) : avatar ? (
) : avatar &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<View style={{width: size, height: size}}>
<HighPriorityImage
testID="userAvatarImage"
style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
contentFit="cover"
source={{uri: avatar}}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
/>
{warning}
</View>

View file

@ -13,13 +13,16 @@ import {
} from 'lib/hooks/usePermissions'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {AvatarModeration} from 'lib/labeling/types'
import {isWeb, isAndroid} from 'platform/detection'
export function UserBanner({
banner,
moderation,
onSelectNewBanner,
}: {
banner?: string | null
moderation?: AvatarModeration
onSelectNewBanner?: (img: TImage | null) => void
}) {
const store = useStores()
@ -107,12 +110,14 @@ export function UserBanner({
/>
</View>
</DropdownButton>
) : banner ? (
) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<Image
testID="userBannerImage"
style={styles.bannerImage}
resizeMode="cover"
source={{uri: banner}}
blurRadius={moderation?.blur ? 100 : 0}
/>
) : (
<View

View file

@ -35,7 +35,7 @@ export function ErrorScreen({
]}>
<FontAwesomeIcon
icon="exclamation"
style={pal.textInverted}
style={pal.textInverted as FontAwesomeIconStyle}
size={24}
/>
</View>

View file

@ -6,32 +6,31 @@ import {
View,
ViewStyle,
} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {Text} from '../text/Text'
import {addStyle} from 'lib/styles'
import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
export function ContentHider({
testID,
isMuted,
labels,
moderation,
style,
containerStyle,
children,
}: React.PropsWithChildren<{
testID?: string
isMuted?: boolean
labels: ComAtprotoLabelDefs.Label[] | undefined
moderation: ModerationBehavior
style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const [override, setOverride] = React.useState(false)
const store = useStores()
const labelPref = store.preferences.getLabelPreference(labels)
if (!isMuted && labelPref.pref === 'show') {
if (
moderation.behavior === ModerationBehaviorCode.Show ||
moderation.behavior === ModerationBehaviorCode.Warn ||
moderation.behavior === ModerationBehaviorCode.WarnImages
) {
return (
<View testID={testID} style={style}>
{children}
@ -39,7 +38,7 @@ export function ContentHider({
)
}
if (labelPref.pref === 'hide') {
if (moderation.behavior === ModerationBehaviorCode.Hide) {
return null
}
@ -52,11 +51,7 @@ export function ContentHider({
override && styles.descriptionOpen,
]}>
<Text type="md" style={pal.textLight}>
{isMuted ? (
<>Post from an account you muted.</>
) : (
<>Warning: {labelPref.desc.warning || labelPref.desc.title}</>
)}
{moderation.reason || 'Content warning'}
</Text>
<TouchableOpacity
style={styles.showBtn}

View file

@ -6,77 +6,72 @@ import {
View,
ViewStyle,
} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {Link} from '../Link'
import {Text} from '../text/Text'
import {addStyle} from 'lib/styles'
import {useStores} from 'state/index'
import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
export function PostHider({
testID,
href,
isMuted,
labels,
moderation,
style,
children,
}: React.PropsWithChildren<{
testID?: string
href: string
isMuted: boolean | undefined
labels: ComAtprotoLabelDefs.Label[] | undefined
href?: string
moderation: ModerationBehavior
style: StyleProp<ViewStyle>
}>) {
const store = useStores()
const pal = usePalette('default')
const [override, setOverride] = React.useState(false)
const bg = override ? pal.viewLight : pal.view
const labelPref = store.preferences.getLabelPreference(labels)
if (labelPref.pref === 'hide') {
return <></>
if (moderation.behavior === ModerationBehaviorCode.Hide) {
return null
}
if (!isMuted) {
// NOTE: any further label enforcement should occur in ContentContainer
if (moderation.behavior === ModerationBehaviorCode.Warn) {
return (
<Link testID={testID} style={style} href={href} noFeedback>
{children}
</Link>
<>
<View style={[styles.description, bg, pal.border]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[styles.icon, pal.text]}
/>
<Text type="md" style={pal.textLight}>
{moderation.reason || 'Content warning'}
</Text>
<TouchableOpacity
style={styles.showBtn}
onPress={() => setOverride(v => !v)}>
<Text type="md" style={pal.link}>
{override ? 'Hide' : 'Show'} post
</Text>
</TouchableOpacity>
</View>
{override && (
<View style={[styles.childrenContainer, pal.border, bg]}>
<Link
testID={testID}
style={addStyle(style, styles.child)}
href={href}
noFeedback>
{children}
</Link>
</View>
)}
</>
)
}
// NOTE: any further label enforcement should occur in ContentContainer
return (
<>
<View style={[styles.description, bg, pal.border]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[styles.icon, pal.text]}
/>
<Text type="md" style={pal.textLight}>
Post from an account you muted.
</Text>
<TouchableOpacity
style={styles.showBtn}
onPress={() => setOverride(v => !v)}>
<Text type="md" style={pal.link}>
{override ? 'Hide' : 'Show'} post
</Text>
</TouchableOpacity>
</View>
{override && (
<View style={[styles.childrenContainer, pal.border, bg]}>
<Link
testID={testID}
style={addStyle(style, styles.child)}
href={href}
noFeedback>
{children}
</Link>
</View>
)}
</>
<Link testID={testID} style={style} href={href} noFeedback>
{children}
</Link>
)
}

View file

@ -1,55 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {getLabelValueGroup} from 'lib/labeling/helpers'
export function ProfileHeaderLabels({
labels,
}: {
labels: ComAtprotoLabelDefs.Label[] | undefined
}) {
const palErr = usePalette('error')
if (!labels?.length) {
return null
}
return (
<>
{labels.map((label, i) => {
const labelGroup = getLabelValueGroup(label?.val || '')
return (
<View
key={`${label.val}-${i}`}
style={[styles.container, palErr.border, palErr.view]}>
<FontAwesomeIcon
icon="circle-exclamation"
style={palErr.text as FontAwesomeIconStyle}
size={20}
/>
<Text style={palErr.text}>
This account has been flagged for{' '}
{(labelGroup.warning || labelGroup.title).toLocaleLowerCase()}.
</Text>
</View>
)
})}
</>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
},
})

View file

@ -0,0 +1,44 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
export function ProfileHeaderWarnings({
moderation,
}: {
moderation: ModerationBehavior
}) {
const palErr = usePalette('error')
if (moderation.behavior === ModerationBehaviorCode.Show) {
return null
}
return (
<View style={[styles.container, palErr.border, palErr.view]}>
<FontAwesomeIcon
icon="circle-exclamation"
style={palErr.text as FontAwesomeIconStyle}
size={20}
/>
<Text style={palErr.text}>
This account has been flagged: {moderation.reason}
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
},
})

View file

@ -0,0 +1,129 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
import {NavigationProp} from 'lib/routes/types'
import {Text} from '../text/Text'
import {Button} from '../forms/Button'
import {isDesktopWeb} from 'platform/detection'
import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
export function ScreenHider({
testID,
screenDescription,
moderation,
style,
containerStyle,
children,
}: React.PropsWithChildren<{
testID?: string
screenDescription: string
moderation: ModerationBehavior
style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const [override, setOverride] = React.useState(false)
const navigation = useNavigation<NavigationProp>()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
if (moderation.behavior !== ModerationBehaviorCode.Hide || override) {
return (
<View testID={testID} style={style}>
{children}
</View>
)
}
return (
<View style={[styles.container, pal.view, containerStyle]}>
<View style={styles.iconContainer}>
<View style={[styles.icon, palInverted.view]}>
<FontAwesomeIcon
icon="exclamation"
style={pal.textInverted as FontAwesomeIconStyle}
size={24}
/>
</View>
</View>
<Text type="title-2xl" style={[styles.title, pal.text]}>
Content Warning
</Text>
<Text type="2xl" style={[styles.description, pal.textLight]}>
This {screenDescription} has been flagged:{' '}
{moderation.reason || 'Content warning'}
</Text>
{!isDesktopWeb && <View style={styles.spacer} />}
<View style={styles.btnContainer}>
<Button type="inverted" onPress={onPressBack} style={styles.btn}>
<Text type="button-lg" style={pal.textInverted}>
Go back
</Text>
</Button>
{!moderation.noOverride && (
<Button
type="default"
onPress={() => setOverride(v => !v)}
style={styles.btn}>
<Text type="button-lg" style={pal.text}>
Show anyway
</Text>
</Button>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
spacer: {
flex: 1,
},
container: {
flex: 1,
paddingTop: 100,
paddingBottom: 150,
},
iconContainer: {
alignItems: 'center',
marginBottom: 10,
},
icon: {
borderRadius: 25,
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
},
title: {
textAlign: 'center',
marginBottom: 10,
},
description: {
marginBottom: 10,
paddingHorizontal: 20,
textAlign: 'center',
},
btnContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginVertical: 10,
gap: 10,
},
btn: {
paddingHorizontal: 20,
paddingVertical: 14,
},
})