diff options
author | Eric Bailey <git@esb.lol> | 2025-07-17 14:32:58 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-07-17 14:32:58 -0500 |
commit | 964eed54eaa53f0912b336391642654cb8a0f605 (patch) | |
tree | fa5beda05823ca6025e8bcec4ad711f52919baba /src | |
parent | 00b017804bcb811b5f9292a88619423df3a29ef8 (diff) | |
download | voidsky-964eed54eaa53f0912b336391642654cb8a0f605.tar.zst |
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>
Diffstat (limited to 'src')
21 files changed, 461 insertions, 261 deletions
diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx index 530e43d44..a00a8c71a 100644 --- a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx +++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx @@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' +import {logger} from '#/state/ageAssurance/util' import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' import {Admonition} from '#/components/Admonition' import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' @@ -83,6 +84,7 @@ function Inner({style}: ViewStyleProp & {}) { label={_(msg`Contact our moderation team`)} {...createStaticClick(() => { appealControl.open() + logger.metric('ageAssurance:appealDialogOpen', {}) })}> contact our moderation team </InlineLinkText>{' '} @@ -109,7 +111,12 @@ function Inner({style}: ViewStyleProp & {}) { size="small" variant="solid" color={hasInitiated ? 'secondary' : 'primary'} - onPress={() => control.open()}> + onPress={() => { + control.open() + logger.metric('ageAssurance:initDialogOpen', { + hasInitiatedPreviously: hasInitiated, + }) + }}> <ButtonText> {hasInitiated ? ( <Trans>Verify again</Trans> diff --git a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx index d140b7873..1c77adbbb 100644 --- a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx +++ b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx @@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' +import {logger} from '#/state/ageAssurance/util' import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf' import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog' import type * as Dialog from '#/components/Dialog' @@ -87,7 +88,10 @@ function Inner({ <InlineLinkText label={_(msg`Go to account settings`)} 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. </InlineLinkText> </Trans> diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx index 166f6c26d..cc0d568ca 100644 --- a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx @@ -5,7 +5,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useMutation} from '@tanstack/react-query' -import {logger} from '#/logger' +import {logger} from '#/state/ageAssurance/util' import {useAgent, useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useBreakpoints, web} from '#/alf' @@ -45,6 +45,8 @@ function Inner({control}: {control: Dialog.DialogControlProps}) { const {mutate, isPending} = useMutation({ mutationFn: async () => { + logger.metric('ageAssurance:appealDialogSubmit', {}) + await agent.createModerationReport( { reasonType: ComAtprotoModerationDefs.REASONAPPEAL, diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx new file mode 100644 index 000000000..cad7e2dc8 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx @@ -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> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx deleted file mode 100644 index b6505fb0e..000000000 --- a/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx index 30e2fbec4..c9f242ca8 100644 --- a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx +++ b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx @@ -3,6 +3,7 @@ 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, type ViewStyleProp} from '#/alf' import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' @@ -37,13 +38,14 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { variant="solid" color="secondary_inverted" shape="round" - onPress={() => + onPress={() => { save({ id: Nux.AgeAssuranceDismissibleNotice, completed: true, data: undefined, }) - } + logger.metric('ageAssurance:dismissSettingsNotice', {}) + }} style={[ a.absolute, { diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx index ad13cc1c2..a189d9af2 100644 --- a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx @@ -1,16 +1,22 @@ import {useState} from 'react' import {View} from 'react-native' +import {XRPCError} from '@atproto/xrpc' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {validate as validateEmail} from 'email-validator' import {useCleanError} from '#/lib/hooks/useCleanError' +import { + SupportCode, + useCreateSupportLink, +} from '#/lib/hooks/useCreateSupportLink' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useTLDs} from '#/lib/hooks/useTLDs' import {isEmailMaybeInvalid} from '#/lib/strings/email' import {type AppLanguage} from '#/locale/languages' import {useAgeAssuranceContext} from '#/state/ageAssurance' import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance' +import {logger} from '#/state/ageAssurance/util' import {useLanguagePrefs} from '#/state/preferences' import {useSession} from '#/state/session' import {atoms as a, useTheme, web} from '#/alf' @@ -66,6 +72,7 @@ function Inner() { const {lastInitiatedAt} = useAgeAssuranceContext() const getTimeAgo = useGetTimeAgo() const tlds = useTLDs() + const createSupportLink = useCreateSupportLink() const wasRecentlyInitiated = lastInitiatedAt && @@ -79,7 +86,7 @@ function Inner() { const [language, setLanguage] = useState<string | undefined>( convertToKWSSupportedLanguage(langPrefs.appLanguage), ) - const [error, setError] = useState<string>('') + const [error, setError] = useState<React.ReactNode>(null) const {mutateAsync: init, isPending} = useInitAgeAssurance() @@ -109,6 +116,8 @@ function Inner() { const onSubmit = async () => { setLanguageError(false) + logger.metric('ageAssurance:initDialogSubmit', {}) + try { const {status} = runEmailValidation() @@ -125,22 +134,35 @@ function Inner() { setSuccess(true) } catch (e) { - const {clean, raw} = cleanError(e) - - if (clean) { - 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 = _( + if (e instanceof XRPCError) { + if (e.error === 'InvalidEmail') { + setError( + _( 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'}) } - - setError(message) + } else { + const {clean, raw} = cleanError(e) + setError(clean || raw || _(msg`Something went wrong, please try again`)) + logger.metric('ageAssurance:initDialogError', {code: 'other'}) } } } diff --git a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx index 41e706fee..ff2e0bfd0 100644 --- a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx @@ -7,6 +7,7 @@ import {retry} from '#/lib/async/retry' import {wait} from '#/lib/async/wait' import {isNative} from '#/platform/detection' import {useAgeAssuranceAPIContext} from '#/state/ageAssurance' +import {logger} from '#/state/ageAssurance/util' import {useAgent} from '#/state/session' import {atoms as a, useTheme, web} from '#/alf' import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' @@ -92,6 +93,8 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { polling.current = true + logger.metric('ageAssurance:redirectDialogOpen', {}) + wait( 3e3, retry( @@ -124,12 +127,15 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { control.clear() control.control.close() + + logger.metric('ageAssurance:redirectDialogSuccess', {}) }) .catch(() => { if (unmounted.current) return setError(true) // try a refetch anyway refreshAgeAssuranceState() + logger.metric('ageAssurance:redirectDialogFail', {}) }) return () => { diff --git a/src/components/ageAssurance/AgeRestrictedScreen.tsx b/src/components/ageAssurance/AgeRestrictedScreen.tsx index 2a9882415..b47cc5b0c 100644 --- a/src/components/ageAssurance/AgeRestrictedScreen.tsx +++ b/src/components/ageAssurance/AgeRestrictedScreen.tsx @@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' +import {logger} from '#/state/ageAssurance/util' import {atoms as a} from '#/alf' import {Admonition} from '#/components/Admonition' import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' @@ -61,13 +62,11 @@ export function AgeRestrictedScreen({ <View style={[a.gap_sm, a.pb_lg]}> <Text style={[a.text_xl, a.leading_snug, a.font_heavy]}> <Trans> - You must verify your age in order to access this screen. + You must complete age assurance in order to access this screen. </Trans> </Text> - <Text style={[a.text_md, a.leading_snug]}> - <Trans>{copy.notice}</Trans> - </Text> + <Text style={[a.text_md, a.leading_snug]}>{copy.notice}</Text> </View> <View @@ -77,7 +76,10 @@ export function AgeRestrictedScreen({ to="/settings/account" size="small" variant="solid" - color="primary"> + color="primary" + onPress={() => { + logger.metric('ageAssurance:navigateToSettings', {}) + }}> <ButtonText> <Trans>Go to account settings</Trans> </ButtonText> diff --git a/src/components/ageAssurance/useAgeAssuranceCopy.ts b/src/components/ageAssurance/useAgeAssuranceCopy.ts index 045806994..f8a0edd79 100644 --- a/src/components/ageAssurance/useAgeAssuranceCopy.ts +++ b/src/components/ageAssurance/useAgeAssuranceCopy.ts @@ -8,10 +8,13 @@ export function useAgeAssuranceCopy() { return useMemo(() => { return { 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: _( - 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.`, ), } }, [_]) diff --git a/src/lib/hooks/useCreateSupportLink.ts b/src/lib/hooks/useCreateSupportLink.ts new file mode 100644 index 000000000..5ec7578c5 --- /dev/null +++ b/src/lib/hooks/useCreateSupportLink.ts @@ -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], + ) +} diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 0d2f9ed09..67a38a52c 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -192,6 +192,9 @@ export function useNotificationsRegistration() { * Register the push token with the Bluesky server, whenever it changes. * 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 * while the app is open in some rare cases. This will fire * `registerPushToken` whenever that happens. diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 3390c4b4b..dfca1f7d8 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -457,4 +457,20 @@ export type MetricEvents = { name: 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': {} } diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index a7b434e52..1517792a1 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -304,127 +304,144 @@ export function ModerationScreenInner({ </Link> </View> - <Text - style={[ - a.pt_2xl, - a.pb_md, - a.text_md, - a.font_bold, - t.atoms.text_contrast_high, - ]}> - <Trans>Content filters</Trans> - </Text> + {declaredAge === undefined && ( + <> + <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> + <Button + label={_(msg`Confirm your birthdate`)} + size="small" + variant="solid" + color="secondary" + onPress={() => { + birthdateDialogControl.open() + }} + style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}> + <ButtonText> + <Trans>Confirm your age:</Trans> + </ButtonText> + <ButtonText> + <Trans>Set birthdate</Trans> + </ButtonText> + </Button> - <View style={[a.gap_md]}> - {declaredAge === undefined && ( - <> - <Button - label={_(msg`Confirm your birthdate`)} - size="small" - variant="solid" - color="secondary" - onPress={() => { - birthdateDialogControl.open() - }} - style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}> - <ButtonText> - <Trans>Confirm your age:</Trans> - </ButtonText> - <ButtonText> - <Trans>Set birthdate</Trans> - </ButtonText> - </Button> + <BirthDateSettingsDialog control={birthdateDialogControl} /> + </> + )} - <BirthDateSettingsDialog control={birthdateDialogControl} /> - </> - )} - <View - style={[ - a.w_full, - a.rounded_md, - a.overflow_hidden, - t.atoms.bg_contrast_25, - ]}> - {!isDeclaredUnderage && !isAgeRestricted && ( - <> - <View - style={[ - a.py_lg, - a.px_lg, - a.flex_row, - a.align_center, - a.justify_between, - disabledOnIOS && {opacity: 0.5}, - ]}> - <Text style={[a.font_bold, t.atoms.text_contrast_high]}> - <Trans>Enable adult content</Trans> - </Text> - <Toggle.Item - label={_(msg`Toggle to enable or disable adult content`)} - disabled={disabledOnIOS} - name="adultContent" - value={adultContentEnabled} - onChange={onToggleAdultContentEnabled}> - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - <Text style={[t.atoms.text_contrast_medium]}> - {adultContentEnabled ? ( - <Trans>Enabled</Trans> - ) : ( - <Trans>Disabled</Trans> - )} + {!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 + style={[ + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + {!isDeclaredUnderage && ( + <> + <View + style={[ + a.py_lg, + a.px_lg, + a.flex_row, + a.align_center, + a.justify_between, + disabledOnIOS && {opacity: 0.5}, + ]}> + <Text style={[a.font_bold, t.atoms.text_contrast_high]}> + <Trans>Enable adult content</Trans> </Text> - <Toggle.Switch /> + <Toggle.Item + label={_(msg`Toggle to enable or disable adult content`)} + disabled={disabledOnIOS || isAgeRestricted} + name="adultContent" + value={adultContentEnabled} + onChange={onToggleAdultContentEnabled}> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <Text style={[t.atoms.text_contrast_medium]}> + {adultContentEnabled ? ( + <Trans>Enabled</Trans> + ) : ( + <Trans>Disabled</Trans> + )} + </Text> + <Toggle.Switch /> + </View> + </Toggle.Item> </View> - </Toggle.Item> - </View> - {disabledOnIOS && ( - <View style={[a.pb_lg, a.px_lg]}> - <Text> - <Trans> - Adult content can only be enabled via the Web at{' '} - <InlineLinkText - label={_(msg`The Bluesky web application`)} - to="" - onPress={evt => { - evt.preventDefault() - Linking.openURL('https://bsky.app/') - return false - }}> - bsky.app - </InlineLinkText> - . - </Trans> - </Text> - </View> - )} - <Divider /> + {disabledOnIOS && ( + <View style={[a.pb_lg, a.px_lg]}> + <Text> + <Trans> + Adult content can only be enabled via the Web at{' '} + <InlineLinkText + label={_(msg`The Bluesky web application`)} + to="" + onPress={evt => { + evt.preventDefault() + Linking.openURL('https://bsky.app/') + return false + }}> + bsky.app + </InlineLinkText> + . + </Trans> + </Text> + </View> + )} - {adultContentEnabled && ( - <> - <GlobalLabelPreference labelDefinition={LABELS.porn} /> - <Divider /> - <GlobalLabelPreference labelDefinition={LABELS.sexual} /> - <Divider /> - <GlobalLabelPreference - labelDefinition={LABELS['graphic-media']} - /> - <Divider /> + {adultContentEnabled && ( + <> + <Divider /> + <GlobalLabelPreference labelDefinition={LABELS.porn} /> + <Divider /> + <GlobalLabelPreference labelDefinition={LABELS.sexual} /> + <Divider /> + <GlobalLabelPreference + labelDefinition={LABELS['graphic-media']} + /> + <Divider /> + <GlobalLabelPreference + disabled={isDeclaredUnderage || isAgeRestricted} + labelDefinition={LABELS.nudity} + /> + </> + )} </> )} - </> - )} - <GlobalLabelPreference - disabled={isDeclaredUnderage || isAgeRestricted} - labelDefinition={LABELS.nudity} - /> - </View> - </View> + </View> + </View> + </> + )} <Text style={[ diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx index 6be881a88..0ce127ff3 100644 --- a/src/screens/Settings/AboutSettings.tsx +++ b/src/screens/Settings/AboutSettings.tsx @@ -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 {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 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 * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' -import {device} from '#/storage' import {useDemoMode} from '#/storage/hooks/demo-mode' import {useDevMode} from '#/storage/hooks/dev-mode' import {OTAInfo} from './components/OTAInfo' @@ -181,20 +179,6 @@ export function AboutSettingsScreen({}: Props) { </SettingsList.ItemText> </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> diff --git a/src/state/ageAssurance/index.tsx b/src/state/ageAssurance/index.tsx index aab954e6c..eded74773 100644 --- a/src/state/ageAssurance/index.tsx +++ b/src/state/ageAssurance/index.tsx @@ -6,17 +6,15 @@ import {networkRetry} from '#/lib/async/retry' import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' import {useGate} from '#/lib/statsig/statsig' import {isNetworkError} from '#/lib/strings/errors' -import {Logger} from '#/logger' import { type AgeAssuranceAPIContextType, type AgeAssuranceContextType, } from '#/state/ageAssurance/types' import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled' +import {logger} from '#/state/ageAssurance/util' import {useGeolocation} from '#/state/geolocation' import {useAgent} from '#/state/session' -const logger = Logger.create(Logger.Context.AgeAssurance) - export const createAgeAssuranceQueryKey = (did: string) => ['ageAssurance', did] as const diff --git a/src/state/ageAssurance/useAgeAssurance.ts b/src/state/ageAssurance/useAgeAssurance.ts index 455f38c92..0215cc88d 100644 --- a/src/state/ageAssurance/useAgeAssurance.ts +++ b/src/state/ageAssurance/useAgeAssurance.ts @@ -1,11 +1,9 @@ import {useMemo} from 'react' -import {Logger} from '#/logger' import {useAgeAssuranceContext} from '#/state/ageAssurance' +import {logger} from '#/state/ageAssurance/util' import {usePreferencesQuery} from '#/state/queries/preferences' -const logger = Logger.create(Logger.Context.AgeAssurance) - type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & { /** * The age the user has declared in their preferences, if any. diff --git a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts index 5c1a7b1c4..06fe46d23 100644 --- a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts +++ b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts @@ -8,6 +8,7 @@ export function useIsAgeAssuranceEnabled() { const {geolocation} = useGeolocation() return useMemo(() => { - return gate('age_assurance') && !!geolocation?.isAgeRestrictedGeo + const enabled = gate('age_assurance') + return enabled && !!geolocation?.isAgeRestrictedGeo }, [geolocation, gate]) } diff --git a/src/state/ageAssurance/util.ts b/src/state/ageAssurance/util.ts new file mode 100644 index 000000000..6b0e97b1c --- /dev/null +++ b/src/state/ageAssurance/util.ts @@ -0,0 +1,3 @@ +import {Logger} from '#/logger' + +export const logger = Logger.create(Logger.Context.AgeAssurance) diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index 61657992f..3d5c132f2 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -8,7 +8,7 @@ export enum Nux { InitialVerificationAnnouncement = 'InitialVerificationAnnouncement', ActivitySubscriptions = 'ActivitySubscriptions', AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', - AgeAssuranceDismissibleHeaderButton = 'AgeAssuranceDismissibleHeaderButton', + AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner', } export const nuxNames = new Set(Object.values(Nux)) @@ -35,7 +35,7 @@ export type AppNux = BaseNux< data: undefined } | { - id: Nux.AgeAssuranceDismissibleHeaderButton + id: Nux.AgeAssuranceDismissibleFeedBanner data: undefined } > @@ -46,5 +46,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { [Nux.InitialVerificationAnnouncement]: undefined, [Nux.ActivitySubscriptions]: undefined, [Nux.AgeAssuranceDismissibleNotice]: undefined, - [Nux.AgeAssuranceDismissibleHeaderButton]: undefined, + [Nux.AgeAssuranceDismissibleFeedBanner]: undefined, } diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 1d0649b2e..34ebc06fa 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -43,11 +43,16 @@ import { import {useLiveNowConfig} from '#/state/service-config' import {useSession} from '#/state/session' import {useProgressGuide} from '#/state/shell/progress-guide' +import {useSelectedFeed} from '#/state/shell/selected-feed' import {List, type ListRef} from '#/view/com/util/List' import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' import {useBreakpoints, useLayoutBreakpoints} from '#/alf' +import { + AgeAssuranceDismissibleFeedBanner, + useInternalState as useAgeAssuranceBannerState, +} from '#/components/ageAssurance/AgeAssuranceDismissibleFeedBanner' import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' import { PostFeedVideoGridRow, @@ -131,6 +136,10 @@ type FeedRow = type: 'showLessFollowup' key: string } + | { + type: 'ageAssuranceBanner' + key: string + } export function getItemsForFeedback(feedRow: FeedRow): { item: FeedPostSliceItem @@ -335,6 +344,14 @@ let PostFeed = ({ 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(() => { // wraps a slice item, and replaces it with a showLessFollowup item // if the user has pressed show less on it @@ -450,6 +467,21 @@ let PostFeed = ({ type: 'interstitialProgressGuide', 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) { arr.push({ @@ -478,6 +510,17 @@ let PostFeed = ({ 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, areVideoFeedsEnabled, hasPressedShowLessUris, + ageAssuranceBannerState, + isCurrentFeedAtStartupSelected, ]) // events @@ -666,6 +711,8 @@ let PostFeed = ({ return <SuggestedFollows feed={feed} /> } else if (row.type === 'interstitialProgressGuide') { return <ProgressGuide /> + } else if (row.type === 'ageAssuranceBanner') { + return <AgeAssuranceDismissibleFeedBanner /> } else if (row.type === 'interstitialTrending') { return <TrendingInterstitial /> } else if (row.type === 'interstitialTrendingVideos') { |