diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts index df0154523b..8403fdf1ae 100644 --- a/src/lib/labeling/const.ts +++ b/src/lib/labeling/const.ts @@ -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 = { - 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 = { }, 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 = { title: 'Impersonation', values: ['impersonation'], }, - unknown: { - id: 'unknown', - title: 'Unknown Label', - values: [], - }, } diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts index b4b5208659..b2057ff18c 100644 --- a/src/lib/labeling/helpers.ts +++ b/src/lib/labeling/helpers.ts @@ -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 } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index bffb2d89b9..5ab5d13fca 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -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 + } } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 917e7a09f5..dd5c899b3a 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -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 {} diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx new file mode 100644 index 0000000000..2e015e404e --- /dev/null +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -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 ( + + Content Filtering + + + + + + + + + Done + + + + ) +} + +const ContentLabelPref = observer( + ({group}: {group: keyof typeof CONFIGURABLE_LABEL_GROUPS}) => { + const store = useStores() + const pal = usePalette('default') + return ( + + + {CONFIGURABLE_LABEL_GROUPS[group].title} + + store.preferences.setContentLabelPref(group, v)} + /> + + ) + }, +) + +function SelectGroup({ + current, + onChange, +}: { + current: LabelPreference + onChange: (v: LabelPreference) => void +}) { + return ( + + + + + + ) +} + +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 ( + onChange(value)}> + + {label} + + + ) +} + +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, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index b1c7d47386..3f10ec8365 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -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 = + } else if (activeModal?.name === 'content-filtering-settings') { + snapPoints = ContentFilteringSettingsModal.snapPoints + element = } else { return } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index e6d54926b0..6f026e174b 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -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 = } else if (modal.name === 'invite-codes') { element = + } else if (modal.name === 'content-filtering-settings') { + element = } else { return null } diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index e9749efe75..f65635d353 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -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 ( {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({ {isMuted ? ( <>Post from an account you muted. - ) : label ? ( - <>Warning: {labelGroup.title} ) : ( - '' + <>Warning: {labelPref.desc.title} )} }>) { + 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 <> } diff --git a/src/view/index.ts b/src/view/index.ts index 47a5f8acfd..e6e3426974 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -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, diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index d429db1b63..081be8dca3 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -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( Advanced + + + + + + Content moderation + +