Implement content filtering preferences

This commit is contained in:
Paul Frazee
2023-04-12 17:37:49 -07:00
parent 68fbeef926
commit 8e03e6a105
11 changed files with 328 additions and 107 deletions
+20 -85
View File
@@ -1,87 +1,27 @@
export const FILTER_SETTINGS = {
base: {
sourceDids: [
'did:plc:ar7c4by46qjdydhdevvrndac',
'did:plc:jonz5u6ulzdkwpnhqlwbsbrh',
],
filterLabelVals: ['csam', 'dmca-violation', 'nudity-nonconsentual'],
},
'bsky-unfiltered': {
sourceDids: [
'did:plc:ar7c4by46qjdydhdevvrndac',
'did:plc:jonz5u6ulzdkwpnhqlwbsbrh',
],
filterLabelVals: [],
},
'bsky-default': {
sourceDids: [
'did:plc:ar7c4by46qjdydhdevvrndac',
'did:plc:jonz5u6ulzdkwpnhqlwbsbrh',
],
filterLabelVals: ['porn', 'nudity', 'gore', 'self-harm', 'torture', 'spam'],
},
'bsky-calm': {
sourceDids: [
'did:plc:ar7c4by46qjdydhdevvrndac',
'did:plc:jonz5u6ulzdkwpnhqlwbsbrh',
],
filterLabelVals: [
'porn',
'nudity',
'sexual',
'gore',
'self-harm',
'torture',
'icon-kkk',
'icon-nazi',
'icon-confederate',
'spam',
'impersonation',
],
},
}
export const FILTERS = {
'bsky-unfiltered': {
title: 'Unfiltered',
author: 'Bluesky Team',
description: 'Show me everything.',
filterLabelVals: FILTER_SETTINGS.base.filterLabelVals.concat(
FILTER_SETTINGS['bsky-unfiltered'].filterLabelVals,
),
},
'bsky-default': {
title: 'Default',
author: 'Bluesky Team',
description:
'Filters out NSFW and nudity, gore, self-harm, torture, and spam.',
filterLabelVals: FILTER_SETTINGS.base.filterLabelVals.concat(
FILTER_SETTINGS['bsky-default'].filterLabelVals,
),
},
'bsky-calm': {
title: 'Calm',
author: 'Bluesky Team',
description:
'Like default, but also filters out hate-group iconography and impersonations.',
filterLabelVals: FILTER_SETTINGS.base.filterLabelVals.concat(
FILTER_SETTINGS['bsky-calm'].filterLabelVals,
),
},
}
import {LabelPreferencesModel} from 'state/models/ui/preferences'
export interface LabelValGroup {
id: string
id: keyof LabelPreferencesModel | 'illegal' | 'unknown'
title: string
values: string[]
}
export const LABEL_VAL_GROUPS: Record<string, LabelValGroup> = {
illegal: {
id: 'illegal',
title: 'Illegal Content',
values: ['csam', 'dmca-violation', 'nudity-nonconsentual'],
},
export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
id: 'illegal',
title: 'Illegal Content',
values: ['csam', 'dmca-violation', 'nudity-nonconsentual'],
}
export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
id: 'unknown',
title: 'Unknown Label',
values: [],
}
export const CONFIGURABLE_LABEL_GROUPS: Record<
keyof LabelPreferencesModel,
LabelValGroup
> = {
nsfw: {
id: 'nsfw',
title: 'Sexual Content',
@@ -89,12 +29,12 @@ export const LABEL_VAL_GROUPS: Record<string, LabelValGroup> = {
},
gore: {
id: 'gore',
title: 'Violent / Bloody Content',
title: 'Violent / Bloody',
values: ['gore', 'self-harm', 'torture'],
},
hate: {
id: 'hate',
title: 'Political Hate-Group Content',
title: 'Political Hate-Groups',
values: ['icon-kkk', 'icon-nazi', 'icon-confederate'],
},
spam: {
@@ -107,9 +47,4 @@ export const LABEL_VAL_GROUPS: Record<string, LabelValGroup> = {
title: 'Impersonation',
values: ['impersonation'],
},
unknown: {
id: 'unknown',
title: 'Unknown Label',
values: [],
},
}
+14 -5
View File
@@ -1,10 +1,19 @@
import {LabelValGroup, LABEL_VAL_GROUPS} from './const'
import {
LabelValGroup,
CONFIGURABLE_LABEL_GROUPS,
ILLEGAL_LABEL_GROUP,
UNKNOWN_LABEL_GROUP,
} from './const'
export function getLabelValueGroup(labelVal: string): LabelValGroup {
for (const id in LABEL_VAL_GROUPS) {
if (LABEL_VAL_GROUPS[id].values.includes(labelVal)) {
return LABEL_VAL_GROUPS[id]
let id: keyof typeof CONFIGURABLE_LABEL_GROUPS
for (id in CONFIGURABLE_LABEL_GROUPS) {
if (ILLEGAL_LABEL_GROUP.values.includes(labelVal)) {
return ILLEGAL_LABEL_GROUP
}
if (CONFIGURABLE_LABEL_GROUPS[id].values.includes(labelVal)) {
return CONFIGURABLE_LABEL_GROUPS[id]
}
}
return LABEL_VAL_GROUPS.unknown
return UNKNOWN_LABEL_GROUP
}
+63
View File
@@ -1,11 +1,33 @@
import {makeAutoObservable} from 'mobx'
import {getLocales} from 'expo-localization'
import {isObj, hasProp} from 'lib/type-guards'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {getLabelValueGroup} from 'lib/labeling/helpers'
import {
LabelValGroup,
UNKNOWN_LABEL_GROUP,
ILLEGAL_LABEL_GROUP,
} from 'lib/labeling/const'
const deviceLocales = getLocales()
export type LabelPreference = 'show' | 'warn' | 'hide'
export class LabelPreferencesModel {
nsfw: LabelPreference = 'warn'
gore: LabelPreference = 'hide'
hate: LabelPreference = 'hide'
spam: LabelPreference = 'hide'
impersonation: LabelPreference = 'warn'
constructor() {
makeAutoObservable(this, {}, {autoBind: true})
}
}
export class PreferencesModel {
_contentLanguages: string[] | undefined
contentLabels = new LabelPreferencesModel()
constructor() {
makeAutoObservable(this, {}, {autoBind: true})
@@ -22,6 +44,7 @@ export class PreferencesModel {
serialize() {
return {
contentLanguages: this._contentLanguages,
contentLabels: this.contentLabels,
}
}
@@ -34,6 +57,46 @@ export class PreferencesModel {
) {
this._contentLanguages = v.contentLanguages
}
if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') {
Object.assign(this.contentLabels, v.contentLabels)
}
}
}
setContentLabelPref(
key: keyof LabelPreferencesModel,
value: LabelPreference,
) {
this.contentLabels[key] = value
}
getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): {
pref: LabelPreference
desc: LabelValGroup
} {
let res: {pref: LabelPreference; desc: LabelValGroup} = {
pref: 'show',
desc: UNKNOWN_LABEL_GROUP,
}
if (!labels?.length) {
return res
}
for (const label of labels) {
const group = getLabelValueGroup(label.val)
if (group.id === 'illegal') {
return {pref: 'hide', desc: ILLEGAL_LABEL_GROUP}
} else if (group.id === 'unknown') {
continue
}
let pref = this.contentLabels[group.id]
if (pref === 'hide') {
res.pref = 'hide'
res.desc = group
} else if (pref === 'warn' && res.pref === 'show') {
res.pref = 'warn'
res.desc = group
}
}
return res
}
}
+5
View File
@@ -65,6 +65,10 @@ export interface InviteCodesModal {
name: 'invite-codes'
}
export interface ContentFilteringSettingsModal {
name: 'content-filtering-settings'
}
export type Modal =
| ConfirmModal
| EditProfileModal
@@ -77,6 +81,7 @@ export type Modal =
| ChangeHandleModal
| WaitlistModal
| InviteCodesModal
| ContentFilteringSettingsModal
interface LightboxModel {}
@@ -0,0 +1,185 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {LabelPreference} from 'state/models/ui/preferences'
import {s, colors, gradients} from 'lib/styles'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const'
export const snapPoints = [500]
export function Component({}: {}) {
const store = useStores()
const pal = usePalette('default')
const onPressDone = React.useCallback(() => {
store.shell.closeModal()
}, [store])
return (
<View testID="reportPostModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Filtering</Text>
<ContentLabelPref group="nsfw" />
<ContentLabelPref group="gore" />
<ContentLabelPref group="hate" />
<ContentLabelPref group="spam" />
<ContentLabelPref group="impersonation" />
<View style={s.flex1} />
<TouchableOpacity testID="sendReportBtn" onPress={onPressDone}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold, s.f18]}>Done</Text>
</LinearGradient>
</TouchableOpacity>
</View>
)
}
const ContentLabelPref = observer(
({group}: {group: keyof typeof CONFIGURABLE_LABEL_GROUPS}) => {
const store = useStores()
const pal = usePalette('default')
return (
<View style={[styles.contentLabelPref, pal.border]}>
<Text type="md-medium" style={[pal.text]}>
{CONFIGURABLE_LABEL_GROUPS[group].title}
</Text>
<SelectGroup
current={store.preferences.contentLabels[group]}
onChange={v => store.preferences.setContentLabelPref(group, v)}
/>
</View>
)
},
)
function SelectGroup({
current,
onChange,
}: {
current: LabelPreference
onChange: (v: LabelPreference) => void
}) {
return (
<View style={styles.selectableBtns}>
<SelectableBtn
current={current}
value="hide"
label="Hide"
left
onChange={onChange}
/>
<SelectableBtn
current={current}
value="warn"
label="Warn"
onChange={onChange}
/>
<SelectableBtn
current={current}
value="show"
label="Show"
right
onChange={onChange}
/>
</View>
)
}
function SelectableBtn({
current,
value,
label,
left,
right,
onChange,
}: {
current: string
value: LabelPreference
label: string
left?: boolean
right?: boolean
onChange: (v: LabelPreference) => void
}) {
const pal = usePalette('default')
const palPrimary = usePalette('inverted')
return (
<TouchableOpacity
style={[
styles.selectableBtn,
left && styles.selectableBtnLeft,
right && styles.selectableBtnRight,
pal.border,
current === value ? palPrimary.view : pal.view,
]}
onPress={() => onChange(value)}>
<Text style={current === value ? palPrimary.text : pal.text}>
{label}
</Text>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 10,
paddingBottom: 40,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
description: {
paddingHorizontal: 2,
marginBottom: 10,
},
contentLabelPref: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 10,
paddingLeft: 4,
marginBottom: 10,
borderTopWidth: 1,
},
selectableBtns: {
flexDirection: 'row',
},
selectableBtn: {
flexDirection: 'row',
justifyContent: 'center',
borderWidth: 1,
borderLeftWidth: 0,
paddingHorizontal: 10,
paddingVertical: 10,
},
selectableBtnLeft: {
borderTopLeftRadius: 8,
borderBottomLeftRadius: 8,
borderLeftWidth: 1,
},
selectableBtnRight: {
borderTopRightRadius: 8,
borderBottomRightRadius: 8,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 14,
backgroundColor: colors.gray1,
},
})
+6 -3
View File
@@ -1,9 +1,10 @@
import React, {useRef, useEffect} from 'react'
import {View} from 'react-native'
import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import BottomSheet from '@gorhom/bottom-sheet'
import {useStores} from 'state/index'
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import {usePalette} from 'lib/hooks/usePalette'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile'
@@ -15,8 +16,7 @@ import * as DeleteAccountModal from './DeleteAccount'
import * as ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes'
import {usePalette} from 'lib/hooks/usePalette'
import {StyleSheet} from 'react-native'
import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
const DEFAULT_SNAPPOINTS = ['90%']
@@ -77,6 +77,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'invite-codes') {
snapPoints = InviteCodesModal.snapPoints
element = <InviteCodesModal.Component />
} else if (activeModal?.name === 'content-filtering-settings') {
snapPoints = ContentFilteringSettingsModal.snapPoints
element = <ContentFilteringSettingsModal.Component />
} else {
return <View />
}
+3
View File
@@ -17,6 +17,7 @@ import * as CropImageModal from './crop-image/CropImage.web'
import * as ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes'
import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
export const ModalsContainer = observer(function ModalsContainer() {
const store = useStores()
@@ -75,6 +76,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <WaitlistModal.Component />
} else if (modal.name === 'invite-codes') {
element = <InviteCodesModal.Component />
} else if (modal.name === 'content-filtering-settings') {
element = <ContentFilteringSettingsModal.Component />
} else {
return null
}
@@ -7,10 +7,9 @@ import {
ViewStyle,
} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {Text} from '../text/Text'
import {getLabelValueGroup} from 'lib/labeling/helpers'
import {addStyle} from 'lib/styles'
export function ContentHider({
@@ -29,8 +28,10 @@ export function ContentHider({
}>) {
const pal = usePalette('default')
const [override, setOverride] = React.useState(false)
const store = useStores()
const labelPref = store.preferences.getLabelPreference(labels)
if (!isMuted && !labels?.length) {
if (!isMuted && labelPref.pref === 'show') {
return (
<View testID={testID} style={style}>
{children}
@@ -38,9 +39,7 @@ export function ContentHider({
)
}
const label = labels?.[0] // TODO use config to settle on most relevant item
const labelGroup = getLabelValueGroup(label?.val || '')
if (labelGroup.id === 'illegal') {
if (labelPref.pref === 'hide') {
return <></>
}
@@ -55,10 +54,8 @@ export function ContentHider({
<Text type="md" style={pal.textLight}>
{isMuted ? (
<>Post from an account you muted.</>
) : label ? (
<>Warning: {labelGroup.title}</>
) : (
''
<>Warning: {labelPref.desc.title}</>
)}
</Text>
<TouchableOpacity
+4 -4
View File
@@ -11,8 +11,8 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {Link} from '../Link'
import {Text} from '../text/Text'
import {getLabelValueGroup} from 'lib/labeling/helpers'
import {addStyle} from 'lib/styles'
import {useStores} from 'state/index'
export function PostHider({
testID,
@@ -28,13 +28,13 @@ export function PostHider({
labels: ComAtprotoLabelDefs.Label[] | undefined
style: StyleProp<ViewStyle>
}>) {
const store = useStores()
const pal = usePalette('default')
const [override, setOverride] = React.useState(false)
const bg = override ? pal.viewLight : pal.view
const label = labels?.[0] // TODO use config to settle on most relevant item
const labelGroup = getLabelValueGroup(label?.val || '')
if (labelGroup.id === 'illegal') {
const labelPref = store.preferences.getLabelPreference(labels)
if (labelPref.pref === 'hide') {
return <></>
}
+3 -1
View File
@@ -34,6 +34,7 @@ import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation'
import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
@@ -106,8 +107,8 @@ export function setup() {
faCompass,
faEllipsis,
faEnvelope,
faEye,
faExclamation,
faQuoteLeft,
farEyeSlash,
faGear,
faGlobe,
@@ -128,6 +129,7 @@ export function setup() {
faPenNib,
faPenToSquare,
faPlus,
faQuoteLeft,
faReply,
faRetweet,
faRss,
+19
View File
@@ -124,6 +124,11 @@ export const SettingsScreen = withAuthRequired(
store.shell.openModal({name: 'invite-codes'})
}, [track, store])
const onPressContentFiltering = React.useCallback(() => {
track('Settings:ContentfilteringButtonClicked')
store.shell.openModal({name: 'content-filtering-settings'})
}, [track, store])
const onPressSignout = React.useCallback(() => {
track('Settings:SignOutButtonClicked')
store.session.logout()
@@ -248,6 +253,20 @@ export const SettingsScreen = withAuthRequired(
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Advanced
</Text>
<TouchableOpacity
testID="contentFilteringBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={isSwitching ? undefined : onPressContentFiltering}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="eye"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Content moderation
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="changeHandleBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}