Age assurance fast-follows (#8656)

* Add feed banner

* Comment

* Update nux name

* Handle did error

* Hide mod settings if underage or age restricted

* Add metrics

* Remove DEV override

* Copy suggestion

* Small copy edits

* useState

* Fix bug

* Update src/components/ageAssurance/useAgeAssuranceCopy.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Get rid of debug button

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
Eric Bailey
2025-07-17 14:32:58 -05:00
committed by GitHub
parent 00b017804b
commit 964eed54ea
21 changed files with 462 additions and 262 deletions
@@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
@@ -83,6 +84,7 @@ function Inner({style}: ViewStyleProp & {}) {
label={_(msg`Contact our moderation team`)} label={_(msg`Contact our moderation team`)}
{...createStaticClick(() => { {...createStaticClick(() => {
appealControl.open() appealControl.open()
logger.metric('ageAssurance:appealDialogOpen', {})
})}> })}>
contact our moderation team contact our moderation team
</InlineLinkText>{' '} </InlineLinkText>{' '}
@@ -109,7 +111,12 @@ function Inner({style}: ViewStyleProp & {}) {
size="small" size="small"
variant="solid" variant="solid"
color={hasInitiated ? 'secondary' : 'primary'} color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => control.open()}> onPress={() => {
control.open()
logger.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
<ButtonText> <ButtonText>
{hasInitiated ? ( {hasInitiated ? (
<Trans>Verify again</Trans> <Trans>Verify again</Trans>
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf' import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog' import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import type * as Dialog from '#/components/Dialog' import type * as Dialog from '#/components/Dialog'
@@ -87,7 +88,10 @@ function Inner({
<InlineLinkText <InlineLinkText
label={_(msg`Go to account settings`)} label={_(msg`Go to account settings`)}
to={'/settings/account'} to={'/settings/account'}
style={[a.text_sm, a.leading_snug, a.font_bold]}> style={[a.text_sm, a.leading_snug, a.font_bold]}
onPress={() => {
logger.metric('ageAssurance:navigateToSettings', {})
}}>
account settings. account settings.
</InlineLinkText> </InlineLinkText>
</Trans> </Trans>
@@ -5,7 +5,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {logger} from '#/logger' import {logger} from '#/state/ageAssurance/util'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, web} from '#/alf' import {atoms as a, useBreakpoints, web} from '#/alf'
@@ -45,6 +45,8 @@ function Inner({control}: {control: Dialog.DialogControlProps}) {
const {mutate, isPending} = useMutation({ const {mutate, isPending} = useMutation({
mutationFn: async () => { mutationFn: async () => {
logger.metric('ageAssurance:appealDialogSubmit', {})
await agent.createModerationReport( await agent.createModerationReport(
{ {
reasonType: ComAtprotoModerationDefs.REASONAPPEAL, reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
@@ -0,0 +1,141 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, select, useTheme} from '#/alf'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button} from '#/components/Button'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function useInternalState() {
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
if (!isReady) return false
if (isDeclaredUnderage) return false
if (!isAgeRestricted) return false
if (lastInitiatedAt) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
}, [
isReady,
isDeclaredUnderage,
isAgeRestricted,
lastInitiatedAt,
hidden,
nux,
])
const close = () => {
save({
id: Nux.AgeAssuranceDismissibleFeedBanner,
completed: true,
data: undefined,
})
}
return {visible, close}
}
export function AgeAssuranceDismissibleFeedBanner() {
const t = useTheme()
const {_} = useLingui()
const {visible, close} = useInternalState()
const copy = useAgeAssuranceCopy()
if (!visible) return null
return (
<View
style={[
a.px_lg,
{
paddingVertical: 10,
backgroundColor: select(t.name, {
light: t.palette.primary_25,
dark: t.palette.primary_25,
dim: t.palette.primary_25,
}),
},
]}>
<Link
label={_(msg`Learn more about age assurance`)}
to="/settings/account"
onPress={() => {
close()
logger.metric('ageAssurance:navigateToSettings', {})
}}
style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 42,
height: 42,
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="lg" />
</View>
<View
style={[
a.flex_1,
{
paddingRight: 40,
},
]}>
<View style={{maxWidth: 400}}>
<Text style={[a.leading_snug]}>{copy.banner}</Text>
</View>
</View>
</Link>
<Button
label={_(msg`Don't show again`)}
size="small"
onPress={() => {
close()
logger.metric('ageAssurance:dismissFeedBanner', {})
}}
style={[
a.absolute,
a.justify_center,
a.align_center,
{
top: 0,
bottom: 0,
right: 0,
paddingRight: a.px_md.paddingLeft,
},
]}>
<X
width={20}
fill={select(t.name, {
light: t.palette.primary_600,
dark: t.palette.primary_600,
dim: t.palette.primary_600,
})}
/>
</Button>
</View>
)
}
@@ -1,95 +0,0 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, select, useTheme} from '#/alf'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function useInternalState() {
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleHeaderButton)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
if (!isReady) return false
if (isDeclaredUnderage) return false
if (!isAgeRestricted) return false
if (lastInitiatedAt) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
}, [
isReady,
isDeclaredUnderage,
isAgeRestricted,
lastInitiatedAt,
hidden,
nux,
])
const close = () => {
save({
id: Nux.AgeAssuranceDismissibleHeaderButton,
completed: true,
data: undefined,
})
}
return {visible, close}
}
export function AgeAssuranceDismissibleHeaderButton() {
const t = useTheme()
const {_} = useLingui()
const {visible, close} = useInternalState()
if (!visible) return null
return (
<Link
label={_(msg`Learn more about age assurance`)}
to="/settings/account"
onPress={close}>
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
a.px_sm,
a.pr_sm,
a.rounded_full,
{
paddingVertical: 6,
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="sm" />
<Text
style={[
a.font_bold,
a.leading_snug,
{
color: select(t.name, {
light: t.palette.primary_800,
dark: t.palette.primary_800,
dim: t.palette.primary_800,
}),
},
]}>
<Trans>Age Assurance</Trans>
</Text>
</View>
</Link>
)
}
@@ -3,6 +3,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, type ViewStyleProp} from '#/alf' import {atoms as a, type ViewStyleProp} from '#/alf'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
@@ -37,13 +38,14 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
variant="solid" variant="solid"
color="secondary_inverted" color="secondary_inverted"
shape="round" shape="round"
onPress={() => onPress={() => {
save({ save({
id: Nux.AgeAssuranceDismissibleNotice, id: Nux.AgeAssuranceDismissibleNotice,
completed: true, completed: true,
data: undefined, data: undefined,
}) })
} logger.metric('ageAssurance:dismissSettingsNotice', {})
}}
style={[ style={[
a.absolute, a.absolute,
{ {
@@ -1,16 +1,22 @@
import {useState} from 'react' import {useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {XRPCError} from '@atproto/xrpc'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {validate as validateEmail} from 'email-validator' import {validate as validateEmail} from 'email-validator'
import {useCleanError} from '#/lib/hooks/useCleanError' import {useCleanError} from '#/lib/hooks/useCleanError'
import {
SupportCode,
useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTLDs} from '#/lib/hooks/useTLDs' import {useTLDs} from '#/lib/hooks/useTLDs'
import {isEmailMaybeInvalid} from '#/lib/strings/email' import {isEmailMaybeInvalid} from '#/lib/strings/email'
import {type AppLanguage} from '#/locale/languages' import {type AppLanguage} from '#/locale/languages'
import {useAgeAssuranceContext} from '#/state/ageAssurance' import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance' import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
@@ -66,6 +72,7 @@ function Inner() {
const {lastInitiatedAt} = useAgeAssuranceContext() const {lastInitiatedAt} = useAgeAssuranceContext()
const getTimeAgo = useGetTimeAgo() const getTimeAgo = useGetTimeAgo()
const tlds = useTLDs() const tlds = useTLDs()
const createSupportLink = useCreateSupportLink()
const wasRecentlyInitiated = const wasRecentlyInitiated =
lastInitiatedAt && lastInitiatedAt &&
@@ -79,7 +86,7 @@ function Inner() {
const [language, setLanguage] = useState<string | undefined>( const [language, setLanguage] = useState<string | undefined>(
convertToKWSSupportedLanguage(langPrefs.appLanguage), convertToKWSSupportedLanguage(langPrefs.appLanguage),
) )
const [error, setError] = useState<string>('') const [error, setError] = useState<React.ReactNode>(null)
const {mutateAsync: init, isPending} = useInitAgeAssurance() const {mutateAsync: init, isPending} = useInitAgeAssurance()
@@ -109,6 +116,8 @@ function Inner() {
const onSubmit = async () => { const onSubmit = async () => {
setLanguageError(false) setLanguageError(false)
logger.metric('ageAssurance:initDialogSubmit', {})
try { try {
const {status} = runEmailValidation() const {status} = runEmailValidation()
@@ -125,22 +134,35 @@ function Inner() {
setSuccess(true) setSuccess(true)
} catch (e) { } catch (e) {
const {clean, raw} = cleanError(e) if (e instanceof XRPCError) {
if (e.error === 'InvalidEmail') {
if (clean) { setError(
setError(clean || _(msg`Something went wrong, please try again`)) _(
} else {
let message = _(msg`Something went wrong, please try again`)
if (raw) {
if (raw.startsWith('This email address is not supported')) {
message = _(
msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`,
),
) )
logger.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'})
} else if (e.error === 'DidTooLong') {
setError(
<>
<Trans>
We're having issues initializing the age assurance process for
your account. Please{' '}
<InlineLinkText
to={createSupportLink({code: SupportCode.AA_DID, email})}
label={_(msg`Contact support`)}>
contact support
</InlineLinkText>{' '}
for assistance.
</Trans>
</>,
)
logger.metric('ageAssurance:initDialogError', {code: 'DidTooLong'})
} }
} } else {
const {clean, raw} = cleanError(e)
setError(message) setError(clean || raw || _(msg`Something went wrong, please try again`))
logger.metric('ageAssurance:initDialogError', {code: 'other'})
} }
} }
} }
@@ -7,6 +7,7 @@ import {retry} from '#/lib/async/retry'
import {wait} from '#/lib/async/wait' import {wait} from '#/lib/async/wait'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useAgeAssuranceAPIContext} from '#/state/ageAssurance' import {useAgeAssuranceAPIContext} from '#/state/ageAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
@@ -92,6 +93,8 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
polling.current = true polling.current = true
logger.metric('ageAssurance:redirectDialogOpen', {})
wait( wait(
3e3, 3e3,
retry( retry(
@@ -124,12 +127,15 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
control.clear() control.clear()
control.control.close() control.control.close()
logger.metric('ageAssurance:redirectDialogSuccess', {})
}) })
.catch(() => { .catch(() => {
if (unmounted.current) return if (unmounted.current) return
setError(true) setError(true)
// try a refetch anyway // try a refetch anyway
refreshAgeAssuranceState() refreshAgeAssuranceState()
logger.metric('ageAssurance:redirectDialogFail', {})
}) })
return () => { return () => {
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
@@ -61,13 +62,11 @@ export function AgeRestrictedScreen({
<View style={[a.gap_sm, a.pb_lg]}> <View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_xl, a.leading_snug, a.font_heavy]}> <Text style={[a.text_xl, a.leading_snug, a.font_heavy]}>
<Trans> <Trans>
You must verify your age in order to access this screen. You must complete age assurance in order to access this screen.
</Trans> </Trans>
</Text> </Text>
<Text style={[a.text_md, a.leading_snug]}> <Text style={[a.text_md, a.leading_snug]}>{copy.notice}</Text>
<Trans>{copy.notice}</Trans>
</Text>
</View> </View>
<View <View
@@ -77,7 +76,10 @@ export function AgeRestrictedScreen({
to="/settings/account" to="/settings/account"
size="small" size="small"
variant="solid" variant="solid"
color="primary"> color="primary"
onPress={() => {
logger.metric('ageAssurance:navigateToSettings', {})
}}>
<ButtonText> <ButtonText>
<Trans>Go to account settings</Trans> <Trans>Go to account settings</Trans>
</ButtonText> </ButtonText>
@@ -8,10 +8,13 @@ export function useAgeAssuranceCopy() {
return useMemo(() => { return useMemo(() => {
return { return {
notice: _( notice: _(
msg`The laws in your location require that you verify your age before accessing certain features on Bluesky like adult content and direct messaging.`, msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`,
),
banner: _(
msg`The laws in your location require you to verify you're an adult. Tap to learn more.`,
), ),
chatsInfoText: _( chatsInfoText: _(
msg`Don't worry! All existing messages and settings are saved and will be available after you've been verified to be 18 or older.`, msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`,
), ),
} }
}, [_]) }, [_])
+39
View File
@@ -0,0 +1,39 @@
import {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
export const ZENDESK_SUPPORT_URL =
'https://blueskyweb.zendesk.com/hc/requests/new'
export enum SupportCode {
AA_DID = 'AA_DID',
}
/**
* {@link https://support.zendesk.com/hc/en-us/articles/4408839114522-Creating-pre-filled-ticket-forms}
*/
export function useCreateSupportLink() {
const {_} = useLingui()
const {currentAccount} = useSession()
return useCallback(
({code, email}: {code: SupportCode; email?: string}) => {
const url = new URL(ZENDESK_SUPPORT_URL)
if (currentAccount) {
url.search = new URLSearchParams({
tf_anonymous_requester_email: email || currentAccount.email || '', // email will be defined
tf_description:
`[Code: ${code}] — ` + _(msg`Please write your message below:`),
/**
* Custom field specific to {@link ZENDESK_SUPPORT_URL} form
*/
tf_17205412673421: currentAccount.handle + ` (${currentAccount.did})`,
}).toString()
}
return url.toString()
},
[_, currentAccount],
)
}
+3
View File
@@ -192,6 +192,9 @@ export function useNotificationsRegistration() {
* Register the push token with the Bluesky server, whenever it changes. * Register the push token with the Bluesky server, whenever it changes.
* This is also fired any time `getDevicePushTokenAsync` is called. * This is also fired any time `getDevicePushTokenAsync` is called.
* *
* Since this is registered immediately after `getAndRegisterPushToken`, it
* should also detect that getter and be fired almost immediately after this.
*
* According to the Expo docs, there is a chance that the token will change * According to the Expo docs, there is a chance that the token will change
* while the app is open in some rare cases. This will fire * while the app is open in some rare cases. This will fire
* `registerPushToken` whenever that happens. * `registerPushToken` whenever that happens.
+16
View File
@@ -457,4 +457,20 @@ export type MetricEvents = {
name: string name: string
value: string value: string
} }
'ageAssurance:navigateToSettings': {}
'ageAssurance:dismissFeedBanner': {}
'ageAssurance:dismissSettingsNotice': {}
'ageAssurance:initDialogOpen': {
hasInitiatedPreviously: boolean
}
'ageAssurance:initDialogSubmit': {}
'ageAssurance:initDialogError': {
code: string
}
'ageAssurance:redirectDialogOpen': {}
'ageAssurance:redirectDialogSuccess': {}
'ageAssurance:redirectDialogFail': {}
'ageAssurance:appealDialogOpen': {}
'ageAssurance:appealDialogSubmit': {}
} }
+33 -16
View File
@@ -304,6 +304,8 @@ export function ModerationScreenInner({
</Link> </Link>
</View> </View>
{declaredAge === undefined && (
<>
<Text <Text
style={[ style={[
a.pt_2xl, a.pt_2xl,
@@ -315,15 +317,6 @@ export function ModerationScreenInner({
<Trans>Content filters</Trans> <Trans>Content filters</Trans>
</Text> </Text>
<AgeAssuranceAdmonition style={[a.pb_md]}>
<Trans>
You must complete age assurance in order to access the settings below.
</Trans>
</AgeAssuranceAdmonition>
<View style={[a.gap_md]}>
{declaredAge === undefined && (
<>
<Button <Button
label={_(msg`Confirm your birthdate`)} label={_(msg`Confirm your birthdate`)}
size="small" size="small"
@@ -344,6 +337,28 @@ export function ModerationScreenInner({
<BirthDateSettingsDialog control={birthdateDialogControl} /> <BirthDateSettingsDialog control={birthdateDialogControl} />
</> </>
)} )}
{!isDeclaredUnderage && (
<>
<Text
style={[
a.pt_2xl,
a.pb_md,
a.text_md,
a.font_bold,
t.atoms.text_contrast_high,
]}>
<Trans>Content filters</Trans>
</Text>
<AgeAssuranceAdmonition style={[a.pb_md]}>
<Trans>
You must complete age assurance in order to access the settings
below.
</Trans>
</AgeAssuranceAdmonition>
<View style={[a.gap_md]}>
<View <View
style={[ style={[
a.w_full, a.w_full,
@@ -351,7 +366,7 @@ export function ModerationScreenInner({
a.overflow_hidden, a.overflow_hidden,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
]}> ]}>
{!isDeclaredUnderage && !isAgeRestricted && ( {!isDeclaredUnderage && (
<> <>
<View <View
style={[ style={[
@@ -367,7 +382,7 @@ export function ModerationScreenInner({
</Text> </Text>
<Toggle.Item <Toggle.Item
label={_(msg`Toggle to enable or disable adult content`)} label={_(msg`Toggle to enable or disable adult content`)}
disabled={disabledOnIOS} disabled={disabledOnIOS || isAgeRestricted}
name="adultContent" name="adultContent"
value={adultContentEnabled} value={adultContentEnabled}
onChange={onToggleAdultContentEnabled}> onChange={onToggleAdultContentEnabled}>
@@ -403,10 +418,10 @@ export function ModerationScreenInner({
</Text> </Text>
</View> </View>
)} )}
<Divider />
{adultContentEnabled && ( {adultContentEnabled && (
<> <>
<Divider />
<GlobalLabelPreference labelDefinition={LABELS.porn} /> <GlobalLabelPreference labelDefinition={LABELS.porn} />
<Divider /> <Divider />
<GlobalLabelPreference labelDefinition={LABELS.sexual} /> <GlobalLabelPreference labelDefinition={LABELS.sexual} />
@@ -415,16 +430,18 @@ export function ModerationScreenInner({
labelDefinition={LABELS['graphic-media']} labelDefinition={LABELS['graphic-media']}
/> />
<Divider /> <Divider />
</>
)}
</>
)}
<GlobalLabelPreference <GlobalLabelPreference
disabled={isDeclaredUnderage || isAgeRestricted} disabled={isDeclaredUnderage || isAgeRestricted}
labelDefinition={LABELS.nudity} labelDefinition={LABELS.nudity}
/> />
</>
)}
</>
)}
</View> </View>
</View> </View>
</>
)}
<Text <Text
style={[ style={[
-16
View File
@@ -20,11 +20,9 @@ import {BroomSparkle_Stroke2_Corner2_Rounded as BroomSparkleIcon} from '#/compon
import {CodeLines_Stroke2_Corner2_Rounded as CodeLinesIcon} from '#/components/icons/CodeLines' import {CodeLines_Stroke2_Corner2_Rounded as CodeLinesIcon} from '#/components/icons/CodeLines'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/icons/Newspaper' import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/icons/Newspaper'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench' import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {device} from '#/storage'
import {useDemoMode} from '#/storage/hooks/demo-mode' import {useDemoMode} from '#/storage/hooks/demo-mode'
import {useDevMode} from '#/storage/hooks/dev-mode' import {useDevMode} from '#/storage/hooks/dev-mode'
import {OTAInfo} from './components/OTAInfo' import {OTAInfo} from './components/OTAInfo'
@@ -181,20 +179,6 @@ export function AboutSettingsScreen({}: Props) {
</SettingsList.ItemText> </SettingsList.ItemText>
</SettingsList.PressableItem> </SettingsList.PressableItem>
)} )}
<SettingsList.PressableItem
onPress={() => {
device.set(['geolocation'], {
countryCode: 'GB',
isAgeRestrictedGeo: true,
})
}}
label="Simulate age restriction">
<SettingsList.ItemIcon icon={Shield} />
<SettingsList.ItemText>
Simulate age restriction
</SettingsList.ItemText>
</SettingsList.PressableItem>
</> </>
)} )}
</SettingsList.Container> </SettingsList.Container>
+1 -3
View File
@@ -6,17 +6,15 @@ import {networkRetry} from '#/lib/async/retry'
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {Logger} from '#/logger'
import { import {
type AgeAssuranceAPIContextType, type AgeAssuranceAPIContextType,
type AgeAssuranceContextType, type AgeAssuranceContextType,
} from '#/state/ageAssurance/types' } from '#/state/ageAssurance/types'
import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled' import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
import {logger} from '#/state/ageAssurance/util'
import {useGeolocation} from '#/state/geolocation' import {useGeolocation} from '#/state/geolocation'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
const logger = Logger.create(Logger.Context.AgeAssurance)
export const createAgeAssuranceQueryKey = (did: string) => export const createAgeAssuranceQueryKey = (did: string) =>
['ageAssurance', did] as const ['ageAssurance', did] as const
+1 -3
View File
@@ -1,11 +1,9 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {Logger} from '#/logger'
import {useAgeAssuranceContext} from '#/state/ageAssurance' import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {logger} from '#/state/ageAssurance/util'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
const logger = Logger.create(Logger.Context.AgeAssurance)
type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & { type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & {
/** /**
* The age the user has declared in their preferences, if any. * The age the user has declared in their preferences, if any.
@@ -8,6 +8,7 @@ export function useIsAgeAssuranceEnabled() {
const {geolocation} = useGeolocation() const {geolocation} = useGeolocation()
return useMemo(() => { return useMemo(() => {
return gate('age_assurance') && !!geolocation?.isAgeRestrictedGeo const enabled = gate('age_assurance')
return enabled && !!geolocation?.isAgeRestrictedGeo
}, [geolocation, gate]) }, [geolocation, gate])
} }
+3
View File
@@ -0,0 +1,3 @@
import {Logger} from '#/logger'
export const logger = Logger.create(Logger.Context.AgeAssurance)
+3 -3
View File
@@ -8,7 +8,7 @@ export enum Nux {
InitialVerificationAnnouncement = 'InitialVerificationAnnouncement', InitialVerificationAnnouncement = 'InitialVerificationAnnouncement',
ActivitySubscriptions = 'ActivitySubscriptions', ActivitySubscriptions = 'ActivitySubscriptions',
AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
AgeAssuranceDismissibleHeaderButton = 'AgeAssuranceDismissibleHeaderButton', AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner',
} }
export const nuxNames = new Set(Object.values(Nux)) export const nuxNames = new Set(Object.values(Nux))
@@ -35,7 +35,7 @@ export type AppNux = BaseNux<
data: undefined data: undefined
} }
| { | {
id: Nux.AgeAssuranceDismissibleHeaderButton id: Nux.AgeAssuranceDismissibleFeedBanner
data: undefined data: undefined
} }
> >
@@ -46,5 +46,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.InitialVerificationAnnouncement]: undefined, [Nux.InitialVerificationAnnouncement]: undefined,
[Nux.ActivitySubscriptions]: undefined, [Nux.ActivitySubscriptions]: undefined,
[Nux.AgeAssuranceDismissibleNotice]: undefined, [Nux.AgeAssuranceDismissibleNotice]: undefined,
[Nux.AgeAssuranceDismissibleHeaderButton]: undefined, [Nux.AgeAssuranceDismissibleFeedBanner]: undefined,
} }
+47
View File
@@ -43,11 +43,16 @@ import {
import {useLiveNowConfig} from '#/state/service-config' import {useLiveNowConfig} from '#/state/service-config'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide' import {useProgressGuide} from '#/state/shell/progress-guide'
import {useSelectedFeed} from '#/state/shell/selected-feed'
import {List, type ListRef} from '#/view/com/util/List' import {List, type ListRef} from '#/view/com/util/List'
import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {useBreakpoints, useLayoutBreakpoints} from '#/alf' import {useBreakpoints, useLayoutBreakpoints} from '#/alf'
import {
AgeAssuranceDismissibleFeedBanner,
useInternalState as useAgeAssuranceBannerState,
} from '#/components/ageAssurance/AgeAssuranceDismissibleFeedBanner'
import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
import { import {
PostFeedVideoGridRow, PostFeedVideoGridRow,
@@ -131,6 +136,10 @@ type FeedRow =
type: 'showLessFollowup' type: 'showLessFollowup'
key: string key: string
} }
| {
type: 'ageAssuranceBanner'
key: string
}
export function getItemsForFeedback(feedRow: FeedRow): { export function getItemsForFeedback(feedRow: FeedRow): {
item: FeedPostSliceItem item: FeedPostSliceItem
@@ -335,6 +344,14 @@ let PostFeed = ({
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
const ageAssuranceBannerState = useAgeAssuranceBannerState()
const selectedFeed = useSelectedFeed()
/**
* Cached value of whether the current feed was selected at startup. We don't
* want this to update when user swipes.
*/
const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed)
const feedItems: FeedRow[] = useMemo(() => { const feedItems: FeedRow[] = useMemo(() => {
// wraps a slice item, and replaces it with a showLessFollowup item // wraps a slice item, and replaces it with a showLessFollowup item
// if the user has pressed show less on it // if the user has pressed show less on it
@@ -450,6 +467,21 @@ let PostFeed = ({
type: 'interstitialProgressGuide', type: 'interstitialProgressGuide',
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
}) })
} else {
/*
* Only insert if Discover was the last selected feed at
* startup, the progress guide isn't shown, and the
* banner is eligible to be shown.
*/
if (
isCurrentFeedAtStartupSelected &&
ageAssuranceBannerState.visible
) {
arr.push({
type: 'ageAssuranceBanner',
key: 'ageAssuranceBanner-' + sliceIndex,
})
}
} }
if (!rightNavVisible && !trendingDisabled) { if (!rightNavVisible && !trendingDisabled) {
arr.push({ arr.push({
@@ -478,6 +510,17 @@ let PostFeed = ({
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
}) })
} }
} else {
/*
* Only insert if this feed was the last selected feed at
* startup and the banner is eligible to be shown.
*/
if (sliceIndex === 0 && isCurrentFeedAtStartupSelected) {
arr.push({
type: 'ageAssuranceBanner',
key: 'ageAssuranceBanner-' + sliceIndex,
})
}
} }
} }
@@ -580,6 +623,8 @@ let PostFeed = ({
isVideoFeed, isVideoFeed,
areVideoFeedsEnabled, areVideoFeedsEnabled,
hasPressedShowLessUris, hasPressedShowLessUris,
ageAssuranceBannerState,
isCurrentFeedAtStartupSelected,
]) ])
// events // events
@@ -666,6 +711,8 @@ let PostFeed = ({
return <SuggestedFollows feed={feed} /> return <SuggestedFollows feed={feed} />
} else if (row.type === 'interstitialProgressGuide') { } else if (row.type === 'interstitialProgressGuide') {
return <ProgressGuide /> return <ProgressGuide />
} else if (row.type === 'ageAssuranceBanner') {
return <AgeAssuranceDismissibleFeedBanner />
} else if (row.type === 'interstitialTrending') { } else if (row.type === 'interstitialTrending') {
return <TrendingInterstitial /> return <TrendingInterstitial />
} else if (row.type === 'interstitialTrendingVideos') { } else if (row.type === 'interstitialTrendingVideos') {