Add birth date gating to moderation settings (#1435)

* Add birth date preference, modal to set, link in settings, and age gate in moderation

* Styling fixes for android

* Fix types
zio/stable
Paul Frazee 2023-09-11 18:04:09 -07:00 committed by GitHub
parent 0090371011
commit 9e8b14f710
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 297 additions and 62 deletions

View File

@ -118,6 +118,7 @@ export class CreateAccountModel {
password: this.password,
inviteCode: this.inviteCode,
})
/* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate)
track('Create Account')
} catch (e: any) {
this.rootStore.onboarding.skip() // undo starting the onboard

View File

@ -7,6 +7,7 @@ import {RootStoreModel} from '../root-store'
import {ModerationOpts} from '@atproto/api'
import {DEFAULT_FEEDS} from 'lib/constants'
import {deviceLocales} from 'platform/detection'
import {getAge} from 'lib/strings/time'
import {LANGUAGES} from '../../../locale/languages'
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
@ -47,6 +48,7 @@ export class PreferencesModel {
contentLabels = new LabelPreferencesModel()
savedFeeds: string[] = []
pinnedFeeds: string[] = []
birthDate: Date | undefined = undefined
homeFeedRepliesEnabled: boolean = true
homeFeedRepliesThreshold: number = 2
homeFeedRepostsEnabled: boolean = true
@ -60,6 +62,13 @@ export class PreferencesModel {
makeAutoObservable(this, {lock: false}, {autoBind: true})
}
get userAge(): number | undefined {
if (!this.birthDate) {
return undefined
}
return getAge(this.birthDate)
}
serialize() {
return {
contentLanguages: this.contentLanguages,
@ -199,6 +208,7 @@ export class PreferencesModel {
) {
this.pinnedFeeds = prefs.feeds.pinned
}
this.birthDate = prefs.birthDate
})
// set defaults on missing items
@ -430,6 +440,11 @@ export class PreferencesModel {
)
}
async setBirthDate(birthDate: Date) {
this.birthDate = birthDate
await this.rootStore.agent.setPersonalDetails({birthDate})
}
toggleHomeFeedRepliesEnabled() {
this.homeFeedRepliesEnabled = !this.homeFeedRepliesEnabled
}

View File

@ -136,6 +136,10 @@ export interface PostLanguagesSettingsModal {
name: 'post-languages-settings'
}
export interface BirthDateSettingsModal {
name: 'birth-date-settings'
}
export type Modal =
// Account
| AddAppPasswordModal
@ -143,6 +147,7 @@ export type Modal =
| DeleteAccountModal
| EditProfileModal
| ProfilePreviewModal
| BirthDateSettingsModal
// Curation
| ContentFilteringSettingsModal

View File

@ -0,0 +1,132 @@
import React, {useState} from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {observer} from 'mobx-react-lite'
import {Text} from '../util/text/Text'
import {DateInput} from '../util/forms/DateInput'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors'
export const snapPoints = ['50%']
export const Component = observer(function Component({}: {}) {
const pal = usePalette('default')
const store = useStores()
const [date, setDate] = useState<Date>(
store.preferences.birthDate || new Date(),
)
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const {isMobile} = useWebMediaQueries()
const onSave = async () => {
setError('')
setIsProcessing(true)
try {
await store.preferences.setBirthDate(date)
store.shell.closeModal()
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
return (
<View
testID="birthDateSettingsModal"
style={[pal.view, styles.container, isMobile && {paddingHorizontal: 18}]}>
<View style={styles.titleSection}>
<Text type="title-lg" style={[pal.text, styles.title]}>
My Birthday
</Text>
</View>
<Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
This information is not shared with other users.
</Text>
<View>
<DateInput
testID="birthdayInput"
value={date}
onChange={setDate}
buttonType="default-light"
buttonStyle={[pal.border, styles.dateInputButton]}
buttonLabelType="lg"
accessibilityLabel="Birthday"
accessibilityHint="Enter your birth date"
accessibilityLabelledBy="birthDate"
/>
</View>
{error ? (
<ErrorMessage message={error} style={styles.error} />
) : undefined}
<View style={[styles.btnContainer, pal.borderDark]}>
{isProcessing ? (
<View style={styles.btn}>
<ActivityIndicator color="#fff" />
</View>
) : (
<TouchableOpacity
testID="confirmBtn"
onPress={onSave}
style={styles.btn}
accessibilityRole="button"
accessibilityLabel="Save"
accessibilityHint="">
<Text style={[s.white, s.bold, s.f18]}>Save</Text>
</TouchableOpacity>
)}
</View>
</View>
)
})
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isWeb ? 0 : 40,
},
titleSection: {
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
marginBottom: 5,
},
error: {
borderRadius: 6,
marginTop: 10,
},
dateInputButton: {
borderWidth: 1,
borderRadius: 6,
paddingVertical: 14,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
paddingHorizontal: 20,
},
})

View File

@ -9,6 +9,7 @@ import {s, colors, gradients} from 'lib/styles'
import {Text} from '../util/text/Text'
import {TextLink} from '../util/Link'
import {ToggleButton} from '../util/forms/ToggleButton'
import {Button} from '../util/forms/Button'
import {usePalette} from 'lib/hooks/usePalette'
import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const'
import {isIOS} from 'platform/detection'
@ -27,22 +28,6 @@ export const Component = observer(
store.preferences.sync()
}, [store])
const onToggleAdultContent = React.useCallback(async () => {
if (isIOS) {
return
}
try {
await store.preferences.setAdultContentEnabled(
!store.preferences.adultContentEnabled,
)
} catch (e) {
Toast.show(
'There was an issue syncing your preferences with the server',
)
store.log.error('Failed to update preferences with server', {e})
}
}, [store])
const onPressDone = React.useCallback(() => {
store.shell.closeModal()
}, [store])
@ -51,29 +36,7 @@ export const Component = observer(
<View testID="contentFilteringModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Filtering</Text>
<ScrollView style={styles.scrollContainer}>
<View style={s.mb10}>
{isIOS ? (
store.preferences.adultContentEnabled ? null : (
<Text type="md" style={pal.textLight}>
Adult content can only be enabled via the Web at{' '}
<TextLink
style={pal.link}
href="https://bsky.app"
text="bsky.app"
/>
.
</Text>
)
) : (
<ToggleButton
type="default-light"
label="Enable Adult Content"
isSelected={store.preferences.adultContentEnabled}
onPress={onToggleAdultContent}
style={styles.toggleBtn}
/>
)}
</View>
<AdultContentEnabledPref />
<ContentLabelPref
group="nsfw"
disabled={!store.preferences.adultContentEnabled}
@ -121,6 +84,71 @@ export const Component = observer(
},
)
const AdultContentEnabledPref = observer(
function AdultContentEnabledPrefImpl() {
const store = useStores()
const pal = usePalette('default')
const onSetAge = () => store.shell.openModal({name: 'birth-date-settings'})
const onToggleAdultContent = async () => {
if (isIOS) {
return
}
try {
await store.preferences.setAdultContentEnabled(
!store.preferences.adultContentEnabled,
)
} catch (e) {
Toast.show(
'There was an issue syncing your preferences with the server',
)
store.log.error('Failed to update preferences with server', {e})
}
}
return (
<View style={s.mb10}>
{isIOS ? (
store.preferences.adultContentEnabled ? null : (
<Text type="md" style={pal.textLight}>
Adult content can only be enabled via the Web at{' '}
<TextLink
style={pal.link}
href="https://bsky.app"
text="bsky.app"
/>
.
</Text>
)
) : typeof store.preferences.birthDate === 'undefined' ? (
<View style={[pal.viewLight, styles.agePrompt]}>
<Text type="md" style={[pal.text, {flex: 1}]}>
Confirm your age to enable adult content.
</Text>
<Button type="primary" label="Set Age" onPress={onSetAge} />
</View>
) : (store.preferences.userAge || 0) >= 18 ? (
<ToggleButton
type="default-light"
label="Enable Adult Content"
isSelected={store.preferences.adultContentEnabled}
onPress={onToggleAdultContent}
style={styles.toggleBtn}
/>
) : (
<View style={[pal.viewLight, styles.agePrompt]}>
<Text type="md" style={[pal.text, {flex: 1}]}>
You must be 18 or older to enable adult content.
</Text>
<Button type="primary" label="Set Age" onPress={onSetAge} />
</View>
)}
</View>
)
},
)
// TODO: Refactor this component to pass labels down to each tab
const ContentLabelPref = observer(function ContentLabelPrefImpl({
group,
@ -277,6 +305,16 @@ const styles = StyleSheet.create({
borderTopWidth: 1,
},
agePrompt: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingLeft: 14,
paddingRight: 10,
paddingVertical: 8,
borderRadius: 8,
},
contentLabelPref: {
flexDirection: 'row',
justifyContent: 'space-between',

View File

@ -29,6 +29,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as ModerationDetailsModal from './ModerationDetails'
import * as BirthDateSettingsModal from './BirthDateSettings'
const DEFAULT_SNAPPOINTS = ['90%']
@ -132,6 +133,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'moderation-details') {
snapPoints = ModerationDetailsModal.snapPoints
element = <ModerationDetailsModal.Component {...activeModal} />
} else if (activeModal?.name === 'birth-date-settings') {
snapPoints = BirthDateSettingsModal.snapPoints
element = <BirthDateSettingsModal.Component />
} else {
return null
}

View File

@ -27,6 +27,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as ModerationDetailsModal from './ModerationDetails'
import * as BirthDateSettingsModal from './BirthDateSettings'
export const ModalsContainer = observer(function ModalsContainer() {
const store = useStores()
@ -107,6 +108,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <EditImageModal.Component {...modal} />
} else if (modal.name === 'moderation-details') {
element = <ModerationDetailsModal.Component {...modal} />
} else if (modal.name === 'birth-date-settings') {
element = <BirthDateSettingsModal.Component />
} else {
return null
}

View File

@ -40,6 +40,7 @@ import {AccountData} from 'state/models/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers'
import {HandIcon} from 'lib/icons'
import {formatCount} from 'view/com/util/numeric/format'
import Clipboard from '@react-native-clipboard/clipboard'
import {reset as resetNavigation} from '../../Navigation'
@ -175,7 +176,7 @@ export const SettingsScreen = withAuthRequired(
Toast.show('Copied build version to clipboard')
}, [])
const openPreferencesModal = React.useCallback(() => {
const openHomeFeedPreferences = React.useCallback(() => {
navigation.navigate('PreferencesHomeFeed')
}, [navigation])
@ -220,6 +221,19 @@ export const SettingsScreen = withAuthRequired(
</Text>
</Text>
</View>
<View style={[styles.infoLine]}>
<Text type="lg-medium" style={pal.text}>
Birthday:{' '}
</Text>
<Link
onPress={() =>
store.shell.openModal({name: 'birth-date-settings'})
}>
<Text type="lg" style={pal.link}>
Show
</Text>
</Link>
</View>
<View style={styles.spacer20} />
</>
) : null}
@ -387,15 +401,15 @@ export const SettingsScreen = withAuthRequired(
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Advanced
Basics
</Text>
<TouchableOpacity
testID="preferencesHomeFeedButton"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={openPreferencesModal}
onPress={openHomeFeedPreferences}
accessibilityRole="button"
accessibilityHint="Open home feed preferences modal"
accessibilityLabel="Opens the home feed preferences modal">
accessibilityHint=""
accessibilityLabel="Opens the home feed preferences">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="sliders"
@ -406,23 +420,6 @@ export const SettingsScreen = withAuthRequired(
Home Feed Preferences
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="appPasswordBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={onPressAppPasswords}
accessibilityRole="button"
accessibilityHint="Open app password settings"
accessibilityLabel="Opens the app password settings page">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="lock"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
App passwords
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="savedFeedsBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
@ -456,6 +453,44 @@ export const SettingsScreen = withAuthRequired(
Content languages
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="moderationBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={
isSwitching ? undefined : () => navigation.navigate('Moderation')
}
accessibilityRole="button"
accessibilityHint=""
accessibilityLabel="Opens moderation settings">
<View style={[styles.iconContainer, pal.btn]}>
<HandIcon style={pal.text} size={18} strokeWidth={6} />
</View>
<Text type="lg" style={pal.text}>
Moderation
</Text>
</TouchableOpacity>
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Advanced
</Text>
<TouchableOpacity
testID="appPasswordBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={onPressAppPasswords}
accessibilityRole="button"
accessibilityHint="Open app password settings"
accessibilityLabel="Opens the app password settings page">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="lock"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
App passwords
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="changeHandleBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
@ -620,6 +655,8 @@ const styles = StyleSheet.create({
paddingBottom: 6,
},
infoLine: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingBottom: 6,
},

View File

@ -311,7 +311,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
icon={
<HandIcon
strokeWidth={5.5}
style={pal.text as FontAwesomeIconStyle}
style={pal.text}
size={isDesktop ? 24 : 27}
/>
}