[APP-639] Improve nsfw handling & force hidden on iOS (#605)

* Identify adult content labels and handle them more specifically

* Change adult content defaults to more conservative settings

* Add an adultcontentenabled override that prohibits access on iOS

* Improve usability of the content hider

* Fix lint
zio/stable
Paul Frazee 2023-05-09 00:43:20 -05:00 committed by GitHub
parent 7a176b3fdf
commit b756a27958
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 223 additions and 62 deletions

View File

@ -6,7 +6,6 @@ export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
title: 'Illegal Content', title: 'Illegal Content',
warning: 'Illegal Content', warning: 'Illegal Content',
values: ['csam', 'dmca-violation', 'nudity-nonconsentual'], values: ['csam', 'dmca-violation', 'nudity-nonconsentual'],
imagesOnly: false,
} }
export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = { export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = {
@ -14,7 +13,6 @@ export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = {
title: 'Content Warning', title: 'Content Warning',
warning: 'Content Warning', warning: 'Content Warning',
values: ['!filter'], values: ['!filter'],
imagesOnly: false,
} }
export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = { export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = {
@ -22,7 +20,6 @@ export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = {
title: 'Content Warning', title: 'Content Warning',
warning: 'Content Warning', warning: 'Content Warning',
values: ['!warn'], values: ['!warn'],
imagesOnly: false,
} }
export const UNKNOWN_LABEL_GROUP: LabelValGroup = { export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
@ -30,7 +27,6 @@ export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
title: 'Unknown Label', title: 'Unknown Label',
warning: 'Content Warning', warning: 'Content Warning',
values: [], values: [],
imagesOnly: false,
} }
export const CONFIGURABLE_LABEL_GROUPS: Record< export const CONFIGURABLE_LABEL_GROUPS: Record<
@ -43,7 +39,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'i.e. Pornography', subtitle: 'i.e. Pornography',
warning: 'Sexually Explicit', warning: 'Sexually Explicit',
values: ['porn'], values: ['porn'],
imagesOnly: false, // apply to whole thing isAdultImagery: true,
}, },
nudity: { nudity: {
id: 'nudity', id: 'nudity',
@ -51,7 +47,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Including non-sexual and artistic', subtitle: 'Including non-sexual and artistic',
warning: 'Nudity', warning: 'Nudity',
values: ['nudity'], values: ['nudity'],
imagesOnly: true, isAdultImagery: true,
}, },
suggestive: { suggestive: {
id: 'suggestive', id: 'suggestive',
@ -59,7 +55,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Does not include nudity', subtitle: 'Does not include nudity',
warning: 'Sexually Suggestive', warning: 'Sexually Suggestive',
values: ['sexual'], values: ['sexual'],
imagesOnly: true, isAdultImagery: true,
}, },
gore: { gore: {
id: 'gore', id: 'gore',
@ -67,14 +63,13 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Gore, self-harm, torture', subtitle: 'Gore, self-harm, torture',
warning: 'Violence', warning: 'Violence',
values: ['gore', 'self-harm', 'torture'], values: ['gore', 'self-harm', 'torture'],
imagesOnly: true, isAdultImagery: true,
}, },
hate: { hate: {
id: 'hate', id: 'hate',
title: 'Political Hate-Groups', title: 'Political Hate-Groups',
warning: 'Hate', warning: 'Hate',
values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'], values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
imagesOnly: false,
}, },
spam: { spam: {
id: 'spam', id: 'spam',
@ -82,7 +77,6 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Excessive low-quality posts', subtitle: 'Excessive low-quality posts',
warning: 'Spam', warning: 'Spam',
values: ['spam'], values: ['spam'],
imagesOnly: false,
}, },
impersonation: { impersonation: {
id: 'impersonation', id: 'impersonation',
@ -90,6 +84,5 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Accounts falsely claiming to be people or orgs', subtitle: 'Accounts falsely claiming to be people or orgs',
warning: 'Impersonation', warning: 'Impersonation',
values: ['impersonation'], values: ['impersonation'],
imagesOnly: false,
}, },
} }

View File

@ -137,12 +137,12 @@ export function getPostModeration(
// warning cases // warning cases
if (postPref.pref === 'warn') { if (postPref.pref === 'warn') {
if (postPref.desc.imagesOnly) { if (postPref.desc.isAdultImagery) {
return { return {
avatar, avatar,
list: warnContent(postPref.desc.warning), // TODO make warnImages when there's time list: warnImages(postPref.desc.warning),
thread: warnContent(postPref.desc.warning), // TODO make warnImages when there's time thread: warnImages(postPref.desc.warning),
view: warnContent(postPref.desc.warning), // TODO make warnImages when there's time view: warnImages(postPref.desc.warning),
} }
} }
return { return {
@ -401,10 +401,9 @@ function warnContent(reason: string) {
} }
} }
// TODO function warnImages(reason: string) {
// function warnImages(reason: string) { return {
// return { behavior: ModerationBehaviorCode.WarnImages,
// behavior: ModerationBehaviorCode.WarnImages, reason,
// reason, }
// } }
// }

View File

@ -11,7 +11,7 @@ export interface LabelValGroup {
| 'always-warn' | 'always-warn'
| 'unknown' | 'unknown'
title: string title: string
imagesOnly: boolean isAdultImagery?: boolean
subtitle?: string subtitle?: string
warning: string warning: string
values: string[] values: string[]

View File

@ -10,15 +10,16 @@ import {
ALWAYS_FILTER_LABEL_GROUP, ALWAYS_FILTER_LABEL_GROUP,
ALWAYS_WARN_LABEL_GROUP, ALWAYS_WARN_LABEL_GROUP,
} from 'lib/labeling/const' } from 'lib/labeling/const'
import {isIOS} from 'platform/detection'
const deviceLocales = getLocales() const deviceLocales = getLocales()
export type LabelPreference = 'show' | 'warn' | 'hide' export type LabelPreference = 'show' | 'warn' | 'hide'
export class LabelPreferencesModel { export class LabelPreferencesModel {
nsfw: LabelPreference = 'warn' nsfw: LabelPreference = 'hide'
nudity: LabelPreference = 'show' nudity: LabelPreference = 'warn'
suggestive: LabelPreference = 'show' suggestive: LabelPreference = 'warn'
gore: LabelPreference = 'warn' gore: LabelPreference = 'warn'
hate: LabelPreference = 'hide' hate: LabelPreference = 'hide'
spam: LabelPreference = 'hide' spam: LabelPreference = 'hide'
@ -30,6 +31,7 @@ export class LabelPreferencesModel {
} }
export class PreferencesModel { export class PreferencesModel {
adultContentEnabled = !isIOS
contentLanguages: string[] = contentLanguages: string[] =
deviceLocales?.map?.(locale => locale.languageCode) || [] deviceLocales?.map?.(locale => locale.languageCode) || []
contentLabels = new LabelPreferencesModel() contentLabels = new LabelPreferencesModel()
@ -102,7 +104,9 @@ export class PreferencesModel {
} else if (group.id === 'always-filter') { } else if (group.id === 'always-filter') {
return {pref: 'hide', desc: ALWAYS_FILTER_LABEL_GROUP} return {pref: 'hide', desc: ALWAYS_FILTER_LABEL_GROUP}
} else if (group.id === 'always-warn') { } else if (group.id === 'always-warn') {
return {pref: 'warn', desc: ALWAYS_WARN_LABEL_GROUP} res.pref = 'warn'
res.desc = ALWAYS_WARN_LABEL_GROUP
continue
} else if (group.id === 'unknown') { } else if (group.id === 'unknown') {
continue continue
} }
@ -115,6 +119,9 @@ export class PreferencesModel {
res.desc = group res.desc = group
} }
} }
if (res.desc.isAdultImagery && !this.adultContentEnabled) {
res.pref = 'hide'
}
return res return res
} }
} }

View File

@ -24,10 +24,22 @@ export function Component({}: {}) {
<View testID="contentModerationModal" style={[pal.view, styles.container]}> <View testID="contentModerationModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Moderation</Text> <Text style={[pal.text, styles.title]}>Content Moderation</Text>
<ScrollView style={styles.scrollContainer}> <ScrollView style={styles.scrollContainer}>
<ContentLabelPref group="nsfw" /> <ContentLabelPref
<ContentLabelPref group="nudity" /> group="nsfw"
<ContentLabelPref group="suggestive" /> disabled={!store.preferences.adultContentEnabled}
<ContentLabelPref group="gore" /> />
<ContentLabelPref
group="nudity"
disabled={!store.preferences.adultContentEnabled}
/>
<ContentLabelPref
group="suggestive"
disabled={!store.preferences.adultContentEnabled}
/>
<ContentLabelPref
group="gore"
disabled={!store.preferences.adultContentEnabled}
/>
<ContentLabelPref group="hate" /> <ContentLabelPref group="hate" />
<ContentLabelPref group="spam" /> <ContentLabelPref group="spam" />
<ContentLabelPref group="impersonation" /> <ContentLabelPref group="impersonation" />
@ -55,7 +67,13 @@ export function Component({}: {}) {
// TODO: Refactor this component to pass labels down to each tab // TODO: Refactor this component to pass labels down to each tab
const ContentLabelPref = observer( const ContentLabelPref = observer(
({group}: {group: keyof typeof CONFIGURABLE_LABEL_GROUPS}) => { ({
group,
disabled,
}: {
group: keyof typeof CONFIGURABLE_LABEL_GROUPS
disabled?: boolean
}) => {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
@ -70,11 +88,17 @@ const ContentLabelPref = observer(
</Text> </Text>
)} )}
</View> </View>
<SelectGroup {disabled ? (
current={store.preferences.contentLabels[group]} <Text type="sm-bold" style={pal.textLight}>
onChange={v => store.preferences.setContentLabelPref(group, v)} Hide
group={group} </Text>
/> ) : (
<SelectGroup
current={store.preferences.contentLabels[group]}
onChange={v => store.preferences.setContentLabelPref(group, v)}
group={group}
/>
)}
</View> </View>
) )
}, },

View File

@ -24,6 +24,7 @@ import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/PostCtrls' import {PostCtrls} from '../util/PostCtrls'
import {PostHider} from '../util/moderation/PostHider' import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider' import {ContentHider} from '../util/moderation/ContentHider'
import {ImageHider} from '../util/moderation/ImageHider'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {formatCount} from '../util/numeric/format' import {formatCount} from '../util/numeric/format'
@ -234,7 +235,9 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
</View> </View>
) : undefined} ) : undefined}
<PostEmbeds embed={item.post.embed} style={s.mb10} /> <ImageHider moderation={item.moderation.view} style={s.mb10}>
<PostEmbeds embed={item.post.embed} style={s.mb10} />
</ImageHider>
</ContentHider> </ContentHider>
<View style={[s.mt2, s.mb10]}> <View style={[s.mt2, s.mb10]}>
<Text style={pal.textLight}>{niceDate(item.post.indexedAt)}</Text> <Text style={pal.textLight}>{niceDate(item.post.indexedAt)}</Text>
@ -366,7 +369,9 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
</View> </View>
) : undefined} ) : undefined}
<PostEmbeds embed={item.post.embed} style={s.mb10} /> <ImageHider style={s.mb10} moderation={item.moderation.thread}>
<PostEmbeds embed={item.post.embed} style={s.mb10} />
</ImageHider>
</ContentHider> </ContentHider>
<PostCtrls <PostCtrls
itemUri={itemUri} itemUri={itemUri}

View File

@ -23,6 +23,7 @@ import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/PostCtrls' import {PostCtrls} from '../util/PostCtrls'
import {PostHider} from '../util/moderation/PostHider' import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider' import {ContentHider} from '../util/moderation/ContentHider'
import {ImageHider} from '../util/moderation/ImageHider'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
@ -258,7 +259,9 @@ const PostLoaded = observer(
/> />
</View> </View>
) : undefined} ) : undefined}
<PostEmbeds embed={item.post.embed} style={s.mb10} /> <ImageHider moderation={item.moderation.list} style={s.mb10}>
<PostEmbeds embed={item.post.embed} style={s.mb10} />
</ImageHider>
</ContentHider> </ContentHider>
<PostCtrls <PostCtrls
itemUri={itemUri} itemUri={itemUri}

View File

@ -16,6 +16,7 @@ import {PostCtrls} from '../util/PostCtrls'
import {PostEmbeds} from '../util/post-embeds' import {PostEmbeds} from '../util/post-embeds'
import {PostHider} from '../util/moderation/PostHider' import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider' import {ContentHider} from '../util/moderation/ContentHider'
import {ImageHider} from '../util/moderation/ImageHider'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
@ -243,7 +244,9 @@ export const FeedItem = observer(function ({
/> />
</View> </View>
) : undefined} ) : undefined}
<PostEmbeds embed={item.post.embed} style={styles.embed} /> <ImageHider moderation={item.moderation.list} style={styles.embed}>
<PostEmbeds embed={item.post.embed} style={styles.embed} />
</ImageHider>
</ContentHider> </ContentHider>
<PostCtrls <PostCtrls
style={styles.ctrls} style={styles.ctrls}

View File

@ -1,11 +1,5 @@
import React from 'react' import React from 'react'
import { import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {addStyle} from 'lib/styles' import {addStyle} from 'lib/styles'
@ -25,6 +19,12 @@ export function ContentHider({
}>) { }>) {
const pal = usePalette('default') const pal = usePalette('default')
const [override, setOverride] = React.useState(false) const [override, setOverride] = React.useState(false)
const onPressShow = React.useCallback(() => {
setOverride(true)
}, [setOverride])
const onPressHide = React.useCallback(() => {
setOverride(false)
}, [setOverride])
if ( if (
moderation.behavior === ModerationBehaviorCode.Show || moderation.behavior === ModerationBehaviorCode.Show ||
@ -44,7 +44,15 @@ export function ContentHider({
return ( return (
<View style={[styles.container, pal.view, pal.border, containerStyle]}> <View style={[styles.container, pal.view, pal.border, containerStyle]}>
<View <Pressable
onPress={override ? onPressHide : onPressShow}
accessibilityLabel={override ? 'Hide post' : 'Show post'}
// TODO: The text labelling should be split up so controls have unique roles
accessibilityHint={
override
? 'Re-hide post'
: 'Shows post hidden based on your moderation settings'
}
style={[ style={[
styles.description, styles.description,
pal.viewLight, pal.viewLight,
@ -53,21 +61,12 @@ export function ContentHider({
<Text type="md" style={pal.textLight}> <Text type="md" style={pal.textLight}>
{moderation.reason || 'Content warning'} {moderation.reason || 'Content warning'}
</Text> </Text>
<TouchableOpacity <View style={styles.showBtn}>
style={styles.showBtn} <Text type="md-medium" style={pal.link}>
onPress={() => setOverride(v => !v)}
accessibilityLabel={override ? 'Hide post' : 'Show post'}
// TODO: The text labelling should be split up so controls have unique roles
accessibilityHint={
override
? 'Re-hide post'
: 'Shows post hidden based on your moderation settings'
}>
<Text type="md" style={pal.link}>
{override ? 'Hide' : 'Show'} {override ? 'Hide' : 'Show'}
</Text> </Text>
</TouchableOpacity> </View>
</View> </Pressable>
{override && ( {override && (
<View style={[styles.childrenContainer, pal.border]}> <View style={[styles.childrenContainer, pal.border]}>
<View testID={testID} style={addStyle(style, styles.child)}> <View testID={testID} style={addStyle(style, styles.child)}>

View File

@ -0,0 +1,128 @@
import React from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from '../text/Text'
import {BlurView} from '../BlurView'
import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
import {isAndroid} from 'platform/detection'
export function ImageHider({
testID,
moderation,
style,
containerStyle,
children,
}: React.PropsWithChildren<{
testID?: string
moderation: ModerationBehavior
style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const [override, setOverride] = React.useState(false)
const onPressShow = React.useCallback(() => {
setOverride(true)
}, [setOverride])
const onPressHide = React.useCallback(() => {
setOverride(false)
}, [setOverride])
if (moderation.behavior !== ModerationBehaviorCode.WarnImages) {
return (
<View testID={testID} style={style}>
{children}
</View>
)
}
if (moderation.behavior === ModerationBehaviorCode.Hide) {
return null
}
return (
<View style={[styles.container, containerStyle]}>
<View testID={testID} style={style}>
{children}
</View>
{override ? (
<Pressable
onPress={onPressHide}
style={[styles.hideBtn, pal.view]}
accessibilityLabel="Hide image"
accessibilityHint="Rehides the image">
<Text type="xl-bold" style={pal.link}>
Hide
</Text>
</Pressable>
) : (
<>
{isAndroid ? (
/* android has an issue that breaks the blurview */
/* see https://github.com/Kureev/react-native-blur/issues/486 */
<View style={[pal.viewLight, styles.overlay, styles.coverView]} />
) : (
<BlurView
style={[styles.overlay, styles.blurView]}
blurType="light"
blurAmount={100}
reducedTransparencyFallbackColor="white"
/>
)}
<View style={[styles.overlay, styles.info]}>
<Pressable
onPress={onPressShow}
style={[styles.showBtn, pal.view]}
accessibilityLabel="Show image"
accessibilityHint="Shows image hidden based on your moderation settings">
<Text type="xl" style={pal.text}>
{moderation.reason || 'Content warning'}
</Text>
<Text type="xl-bold" style={pal.link}>
Show
</Text>
</Pressable>
</View>
</>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
position: 'relative',
marginBottom: 10,
},
overlay: {
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
},
blurView: {
borderRadius: 8,
},
coverView: {
borderRadius: 8,
},
info: {
justifyContent: 'center',
alignItems: 'center',
},
showBtn: {
flexDirection: 'row',
gap: 8,
paddingHorizontal: 18,
paddingVertical: 14,
borderRadius: 24,
},
hideBtn: {
position: 'absolute',
left: 8,
bottom: 20,
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 8,
},
})