Bluesky app fork with some witchin' additions 💫

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>

authored by

Eric Bailey
surfdude29
and committed by
GitHub
964eed54 00b01780

+461 -261
+8 -1
src/components/ageAssurance/AgeAssuranceAccountCard.tsx
··· 4 4 5 5 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 6 6 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 7 + import {logger} from '#/state/ageAssurance/util' 7 8 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 8 9 import {Admonition} from '#/components/Admonition' 9 10 import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' ··· 83 84 label={_(msg`Contact our moderation team`)} 84 85 {...createStaticClick(() => { 85 86 appealControl.open() 87 + logger.metric('ageAssurance:appealDialogOpen', {}) 86 88 })}> 87 89 contact our moderation team 88 90 </InlineLinkText>{' '} ··· 109 111 size="small" 110 112 variant="solid" 111 113 color={hasInitiated ? 'secondary' : 'primary'} 112 - onPress={() => control.open()}> 114 + onPress={() => { 115 + control.open() 116 + logger.metric('ageAssurance:initDialogOpen', { 117 + hasInitiatedPreviously: hasInitiated, 118 + }) 119 + }}> 113 120 <ButtonText> 114 121 {hasInitiated ? ( 115 122 <Trans>Verify again</Trans>
+5 -1
src/components/ageAssurance/AgeAssuranceAdmonition.tsx
··· 3 3 import {useLingui} from '@lingui/react' 4 4 5 5 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 + import {logger} from '#/state/ageAssurance/util' 6 7 import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf' 7 8 import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog' 8 9 import type * as Dialog from '#/components/Dialog' ··· 87 88 <InlineLinkText 88 89 label={_(msg`Go to account settings`)} 89 90 to={'/settings/account'} 90 - style={[a.text_sm, a.leading_snug, a.font_bold]}> 91 + style={[a.text_sm, a.leading_snug, a.font_bold]} 92 + onPress={() => { 93 + logger.metric('ageAssurance:navigateToSettings', {}) 94 + }}> 91 95 account settings. 92 96 </InlineLinkText> 93 97 </Trans>
+3 -1
src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {useMutation} from '@tanstack/react-query' 7 7 8 - import {logger} from '#/logger' 8 + import {logger} from '#/state/ageAssurance/util' 9 9 import {useAgent, useSession} from '#/state/session' 10 10 import * as Toast from '#/view/com/util/Toast' 11 11 import {atoms as a, useBreakpoints, web} from '#/alf' ··· 45 45 46 46 const {mutate, isPending} = useMutation({ 47 47 mutationFn: async () => { 48 + logger.metric('ageAssurance:appealDialogSubmit', {}) 49 + 48 50 await agent.createModerationReport( 49 51 { 50 52 reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
+141
src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
··· 1 + import {useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 7 + import {logger} from '#/state/ageAssurance/util' 8 + import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 9 + import {atoms as a, select, useTheme} from '#/alf' 10 + import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 11 + import {Button} from '#/components/Button' 12 + import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 13 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 14 + import {Link} from '#/components/Link' 15 + import {Text} from '#/components/Typography' 16 + 17 + export function useInternalState() { 18 + const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = 19 + useAgeAssurance() 20 + const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner) 21 + const {mutate: save, variables} = useSaveNux() 22 + const hidden = !!variables 23 + 24 + const visible = useMemo(() => { 25 + if (!isReady) return false 26 + if (isDeclaredUnderage) return false 27 + if (!isAgeRestricted) return false 28 + if (lastInitiatedAt) return false 29 + if (hidden) return false 30 + if (nux && nux.completed) return false 31 + return true 32 + }, [ 33 + isReady, 34 + isDeclaredUnderage, 35 + isAgeRestricted, 36 + lastInitiatedAt, 37 + hidden, 38 + nux, 39 + ]) 40 + 41 + const close = () => { 42 + save({ 43 + id: Nux.AgeAssuranceDismissibleFeedBanner, 44 + completed: true, 45 + data: undefined, 46 + }) 47 + } 48 + 49 + return {visible, close} 50 + } 51 + 52 + export function AgeAssuranceDismissibleFeedBanner() { 53 + const t = useTheme() 54 + const {_} = useLingui() 55 + const {visible, close} = useInternalState() 56 + const copy = useAgeAssuranceCopy() 57 + 58 + if (!visible) return null 59 + 60 + return ( 61 + <View 62 + style={[ 63 + a.px_lg, 64 + { 65 + paddingVertical: 10, 66 + backgroundColor: select(t.name, { 67 + light: t.palette.primary_25, 68 + dark: t.palette.primary_25, 69 + dim: t.palette.primary_25, 70 + }), 71 + }, 72 + ]}> 73 + <Link 74 + label={_(msg`Learn more about age assurance`)} 75 + to="/settings/account" 76 + onPress={() => { 77 + close() 78 + logger.metric('ageAssurance:navigateToSettings', {}) 79 + }} 80 + style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}> 81 + <View 82 + style={[ 83 + a.align_center, 84 + a.justify_center, 85 + a.rounded_full, 86 + { 87 + width: 42, 88 + height: 42, 89 + backgroundColor: select(t.name, { 90 + light: t.palette.primary_100, 91 + dark: t.palette.primary_100, 92 + dim: t.palette.primary_100, 93 + }), 94 + }, 95 + ]}> 96 + <Shield size="lg" /> 97 + </View> 98 + 99 + <View 100 + style={[ 101 + a.flex_1, 102 + { 103 + paddingRight: 40, 104 + }, 105 + ]}> 106 + <View style={{maxWidth: 400}}> 107 + <Text style={[a.leading_snug]}>{copy.banner}</Text> 108 + </View> 109 + </View> 110 + </Link> 111 + 112 + <Button 113 + label={_(msg`Don't show again`)} 114 + size="small" 115 + onPress={() => { 116 + close() 117 + logger.metric('ageAssurance:dismissFeedBanner', {}) 118 + }} 119 + style={[ 120 + a.absolute, 121 + a.justify_center, 122 + a.align_center, 123 + { 124 + top: 0, 125 + bottom: 0, 126 + right: 0, 127 + paddingRight: a.px_md.paddingLeft, 128 + }, 129 + ]}> 130 + <X 131 + width={20} 132 + fill={select(t.name, { 133 + light: t.palette.primary_600, 134 + dark: t.palette.primary_600, 135 + dim: t.palette.primary_600, 136 + })} 137 + /> 138 + </Button> 139 + </View> 140 + ) 141 + }
-95
src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx
··· 1 - import {useMemo} from 'react' 2 - import {View} from 'react-native' 3 - import {msg, Trans} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - 6 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 7 - import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 8 - import {atoms as a, select, useTheme} from '#/alf' 9 - import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 10 - import {Link} from '#/components/Link' 11 - import {Text} from '#/components/Typography' 12 - 13 - export function useInternalState() { 14 - const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = 15 - useAgeAssurance() 16 - const {nux} = useNux(Nux.AgeAssuranceDismissibleHeaderButton) 17 - const {mutate: save, variables} = useSaveNux() 18 - const hidden = !!variables 19 - 20 - const visible = useMemo(() => { 21 - if (!isReady) return false 22 - if (isDeclaredUnderage) return false 23 - if (!isAgeRestricted) return false 24 - if (lastInitiatedAt) return false 25 - if (hidden) return false 26 - if (nux && nux.completed) return false 27 - return true 28 - }, [ 29 - isReady, 30 - isDeclaredUnderage, 31 - isAgeRestricted, 32 - lastInitiatedAt, 33 - hidden, 34 - nux, 35 - ]) 36 - 37 - const close = () => { 38 - save({ 39 - id: Nux.AgeAssuranceDismissibleHeaderButton, 40 - completed: true, 41 - data: undefined, 42 - }) 43 - } 44 - 45 - return {visible, close} 46 - } 47 - 48 - export function AgeAssuranceDismissibleHeaderButton() { 49 - const t = useTheme() 50 - const {_} = useLingui() 51 - const {visible, close} = useInternalState() 52 - 53 - if (!visible) return null 54 - 55 - return ( 56 - <Link 57 - label={_(msg`Learn more about age assurance`)} 58 - to="/settings/account" 59 - onPress={close}> 60 - <View 61 - style={[ 62 - a.flex_row, 63 - a.align_center, 64 - a.gap_xs, 65 - a.px_sm, 66 - a.pr_sm, 67 - a.rounded_full, 68 - { 69 - paddingVertical: 6, 70 - backgroundColor: select(t.name, { 71 - light: t.palette.primary_100, 72 - dark: t.palette.primary_100, 73 - dim: t.palette.primary_100, 74 - }), 75 - }, 76 - ]}> 77 - <Shield size="sm" /> 78 - <Text 79 - style={[ 80 - a.font_bold, 81 - a.leading_snug, 82 - { 83 - color: select(t.name, { 84 - light: t.palette.primary_800, 85 - dark: t.palette.primary_800, 86 - dim: t.palette.primary_800, 87 - }), 88 - }, 89 - ]}> 90 - <Trans>Age Assurance</Trans> 91 - </Text> 92 - </View> 93 - </Link> 94 - ) 95 - }
+4 -2
src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
··· 3 3 import {useLingui} from '@lingui/react' 4 4 5 5 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 + import {logger} from '#/state/ageAssurance/util' 6 7 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 7 8 import {atoms as a, type ViewStyleProp} from '#/alf' 8 9 import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' ··· 37 38 variant="solid" 38 39 color="secondary_inverted" 39 40 shape="round" 40 - onPress={() => 41 + onPress={() => { 41 42 save({ 42 43 id: Nux.AgeAssuranceDismissibleNotice, 43 44 completed: true, 44 45 data: undefined, 45 46 }) 46 - } 47 + logger.metric('ageAssurance:dismissSettingsNotice', {}) 48 + }} 47 49 style={[ 48 50 a.absolute, 49 51 {
+37 -15
src/components/ageAssurance/AgeAssuranceInitDialog.tsx
··· 1 1 import {useState} from 'react' 2 2 import {View} from 'react-native' 3 + import {XRPCError} from '@atproto/xrpc' 3 4 import {msg, Trans} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' 5 6 import {validate as validateEmail} from 'email-validator' 6 7 7 8 import {useCleanError} from '#/lib/hooks/useCleanError' 9 + import { 10 + SupportCode, 11 + useCreateSupportLink, 12 + } from '#/lib/hooks/useCreateSupportLink' 8 13 import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 9 14 import {useTLDs} from '#/lib/hooks/useTLDs' 10 15 import {isEmailMaybeInvalid} from '#/lib/strings/email' 11 16 import {type AppLanguage} from '#/locale/languages' 12 17 import {useAgeAssuranceContext} from '#/state/ageAssurance' 13 18 import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance' 19 + import {logger} from '#/state/ageAssurance/util' 14 20 import {useLanguagePrefs} from '#/state/preferences' 15 21 import {useSession} from '#/state/session' 16 22 import {atoms as a, useTheme, web} from '#/alf' ··· 66 72 const {lastInitiatedAt} = useAgeAssuranceContext() 67 73 const getTimeAgo = useGetTimeAgo() 68 74 const tlds = useTLDs() 75 + const createSupportLink = useCreateSupportLink() 69 76 70 77 const wasRecentlyInitiated = 71 78 lastInitiatedAt && ··· 79 86 const [language, setLanguage] = useState<string | undefined>( 80 87 convertToKWSSupportedLanguage(langPrefs.appLanguage), 81 88 ) 82 - const [error, setError] = useState<string>('') 89 + const [error, setError] = useState<React.ReactNode>(null) 83 90 84 91 const {mutateAsync: init, isPending} = useInitAgeAssurance() 85 92 ··· 109 116 const onSubmit = async () => { 110 117 setLanguageError(false) 111 118 119 + logger.metric('ageAssurance:initDialogSubmit', {}) 120 + 112 121 try { 113 122 const {status} = runEmailValidation() 114 123 ··· 125 134 126 135 setSuccess(true) 127 136 } catch (e) { 128 - const {clean, raw} = cleanError(e) 129 - 130 - if (clean) { 131 - setError(clean || _(msg`Something went wrong, please try again`)) 132 - } else { 133 - let message = _(msg`Something went wrong, please try again`) 134 - 135 - if (raw) { 136 - if (raw.startsWith('This email address is not supported')) { 137 - message = _( 137 + if (e instanceof XRPCError) { 138 + if (e.error === 'InvalidEmail') { 139 + setError( 140 + _( 138 141 msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, 139 - ) 140 - } 142 + ), 143 + ) 144 + logger.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) 145 + } else if (e.error === 'DidTooLong') { 146 + setError( 147 + <> 148 + <Trans> 149 + We're having issues initializing the age assurance process for 150 + your account. Please{' '} 151 + <InlineLinkText 152 + to={createSupportLink({code: SupportCode.AA_DID, email})} 153 + label={_(msg`Contact support`)}> 154 + contact support 155 + </InlineLinkText>{' '} 156 + for assistance. 157 + </Trans> 158 + </>, 159 + ) 160 + logger.metric('ageAssurance:initDialogError', {code: 'DidTooLong'}) 141 161 } 142 - 143 - setError(message) 162 + } else { 163 + const {clean, raw} = cleanError(e) 164 + setError(clean || raw || _(msg`Something went wrong, please try again`)) 165 + logger.metric('ageAssurance:initDialogError', {code: 'other'}) 144 166 } 145 167 } 146 168 }
+6
src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
··· 7 7 import {wait} from '#/lib/async/wait' 8 8 import {isNative} from '#/platform/detection' 9 9 import {useAgeAssuranceAPIContext} from '#/state/ageAssurance' 10 + import {logger} from '#/state/ageAssurance/util' 10 11 import {useAgent} from '#/state/session' 11 12 import {atoms as a, useTheme, web} from '#/alf' 12 13 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' ··· 92 93 93 94 polling.current = true 94 95 96 + logger.metric('ageAssurance:redirectDialogOpen', {}) 97 + 95 98 wait( 96 99 3e3, 97 100 retry( ··· 124 127 125 128 control.clear() 126 129 control.control.close() 130 + 131 + logger.metric('ageAssurance:redirectDialogSuccess', {}) 127 132 }) 128 133 .catch(() => { 129 134 if (unmounted.current) return 130 135 setError(true) 131 136 // try a refetch anyway 132 137 refreshAgeAssuranceState() 138 + logger.metric('ageAssurance:redirectDialogFail', {}) 133 139 }) 134 140 135 141 return () => {
+7 -5
src/components/ageAssurance/AgeRestrictedScreen.tsx
··· 3 3 import {useLingui} from '@lingui/react' 4 4 5 5 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 + import {logger} from '#/state/ageAssurance/util' 6 7 import {atoms as a} from '#/alf' 7 8 import {Admonition} from '#/components/Admonition' 8 9 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' ··· 61 62 <View style={[a.gap_sm, a.pb_lg]}> 62 63 <Text style={[a.text_xl, a.leading_snug, a.font_heavy]}> 63 64 <Trans> 64 - You must verify your age in order to access this screen. 65 + You must complete age assurance in order to access this screen. 65 66 </Trans> 66 67 </Text> 67 68 68 - <Text style={[a.text_md, a.leading_snug]}> 69 - <Trans>{copy.notice}</Trans> 70 - </Text> 69 + <Text style={[a.text_md, a.leading_snug]}>{copy.notice}</Text> 71 70 </View> 72 71 73 72 <View ··· 77 76 to="/settings/account" 78 77 size="small" 79 78 variant="solid" 80 - color="primary"> 79 + color="primary" 80 + onPress={() => { 81 + logger.metric('ageAssurance:navigateToSettings', {}) 82 + }}> 81 83 <ButtonText> 82 84 <Trans>Go to account settings</Trans> 83 85 </ButtonText>
+5 -2
src/components/ageAssurance/useAgeAssuranceCopy.ts
··· 8 8 return useMemo(() => { 9 9 return { 10 10 notice: _( 11 - msg`The laws in your location require that you verify your age before accessing certain features on Bluesky like adult content and direct messaging.`, 11 + 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.`, 12 + ), 13 + banner: _( 14 + msg`The laws in your location require you to verify you're an adult. Tap to learn more.`, 12 15 ), 13 16 chatsInfoText: _( 14 - 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.`, 17 + msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`, 15 18 ), 16 19 } 17 20 }, [_])
+39
src/lib/hooks/useCreateSupportLink.ts
··· 1 + import {useCallback} from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {useSession} from '#/state/session' 6 + 7 + export const ZENDESK_SUPPORT_URL = 8 + 'https://blueskyweb.zendesk.com/hc/requests/new' 9 + 10 + export enum SupportCode { 11 + AA_DID = 'AA_DID', 12 + } 13 + 14 + /** 15 + * {@link https://support.zendesk.com/hc/en-us/articles/4408839114522-Creating-pre-filled-ticket-forms} 16 + */ 17 + export function useCreateSupportLink() { 18 + const {_} = useLingui() 19 + const {currentAccount} = useSession() 20 + 21 + return useCallback( 22 + ({code, email}: {code: SupportCode; email?: string}) => { 23 + const url = new URL(ZENDESK_SUPPORT_URL) 24 + if (currentAccount) { 25 + url.search = new URLSearchParams({ 26 + tf_anonymous_requester_email: email || currentAccount.email || '', // email will be defined 27 + tf_description: 28 + `[Code: ${code}] — ` + _(msg`Please write your message below:`), 29 + /** 30 + * Custom field specific to {@link ZENDESK_SUPPORT_URL} form 31 + */ 32 + tf_17205412673421: currentAccount.handle + ` (${currentAccount.did})`, 33 + }).toString() 34 + } 35 + return url.toString() 36 + }, 37 + [_, currentAccount], 38 + ) 39 + }
+3
src/lib/notifications/notifications.ts
··· 192 192 * Register the push token with the Bluesky server, whenever it changes. 193 193 * This is also fired any time `getDevicePushTokenAsync` is called. 194 194 * 195 + * Since this is registered immediately after `getAndRegisterPushToken`, it 196 + * should also detect that getter and be fired almost immediately after this. 197 + * 195 198 * According to the Expo docs, there is a chance that the token will change 196 199 * while the app is open in some rare cases. This will fire 197 200 * `registerPushToken` whenever that happens.
+16
src/logger/metrics.ts
··· 457 457 name: string 458 458 value: string 459 459 } 460 + 461 + 'ageAssurance:navigateToSettings': {} 462 + 'ageAssurance:dismissFeedBanner': {} 463 + 'ageAssurance:dismissSettingsNotice': {} 464 + 'ageAssurance:initDialogOpen': { 465 + hasInitiatedPreviously: boolean 466 + } 467 + 'ageAssurance:initDialogSubmit': {} 468 + 'ageAssurance:initDialogError': { 469 + code: string 470 + } 471 + 'ageAssurance:redirectDialogOpen': {} 472 + 'ageAssurance:redirectDialogSuccess': {} 473 + 'ageAssurance:redirectDialogFail': {} 474 + 'ageAssurance:appealDialogOpen': {} 475 + 'ageAssurance:appealDialogSubmit': {} 460 476 }
+130 -113
src/screens/Moderation/index.tsx
··· 304 304 </Link> 305 305 </View> 306 306 307 - <Text 308 - style={[ 309 - a.pt_2xl, 310 - a.pb_md, 311 - a.text_md, 312 - a.font_bold, 313 - t.atoms.text_contrast_high, 314 - ]}> 315 - <Trans>Content filters</Trans> 316 - </Text> 307 + {declaredAge === undefined && ( 308 + <> 309 + <Text 310 + style={[ 311 + a.pt_2xl, 312 + a.pb_md, 313 + a.text_md, 314 + a.font_bold, 315 + t.atoms.text_contrast_high, 316 + ]}> 317 + <Trans>Content filters</Trans> 318 + </Text> 317 319 318 - <AgeAssuranceAdmonition style={[a.pb_md]}> 319 - <Trans> 320 - You must complete age assurance in order to access the settings below. 321 - </Trans> 322 - </AgeAssuranceAdmonition> 320 + <Button 321 + label={_(msg`Confirm your birthdate`)} 322 + size="small" 323 + variant="solid" 324 + color="secondary" 325 + onPress={() => { 326 + birthdateDialogControl.open() 327 + }} 328 + style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}> 329 + <ButtonText> 330 + <Trans>Confirm your age:</Trans> 331 + </ButtonText> 332 + <ButtonText> 333 + <Trans>Set birthdate</Trans> 334 + </ButtonText> 335 + </Button> 323 336 324 - <View style={[a.gap_md]}> 325 - {declaredAge === undefined && ( 326 - <> 327 - <Button 328 - label={_(msg`Confirm your birthdate`)} 329 - size="small" 330 - variant="solid" 331 - color="secondary" 332 - onPress={() => { 333 - birthdateDialogControl.open() 334 - }} 335 - style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}> 336 - <ButtonText> 337 - <Trans>Confirm your age:</Trans> 338 - </ButtonText> 339 - <ButtonText> 340 - <Trans>Set birthdate</Trans> 341 - </ButtonText> 342 - </Button> 337 + <BirthDateSettingsDialog control={birthdateDialogControl} /> 338 + </> 339 + )} 340 + 341 + {!isDeclaredUnderage && ( 342 + <> 343 + <Text 344 + style={[ 345 + a.pt_2xl, 346 + a.pb_md, 347 + a.text_md, 348 + a.font_bold, 349 + t.atoms.text_contrast_high, 350 + ]}> 351 + <Trans>Content filters</Trans> 352 + </Text> 353 + 354 + <AgeAssuranceAdmonition style={[a.pb_md]}> 355 + <Trans> 356 + You must complete age assurance in order to access the settings 357 + below. 358 + </Trans> 359 + </AgeAssuranceAdmonition> 343 360 344 - <BirthDateSettingsDialog control={birthdateDialogControl} /> 345 - </> 346 - )} 347 - <View 348 - style={[ 349 - a.w_full, 350 - a.rounded_md, 351 - a.overflow_hidden, 352 - t.atoms.bg_contrast_25, 353 - ]}> 354 - {!isDeclaredUnderage && !isAgeRestricted && ( 355 - <> 356 - <View 357 - style={[ 358 - a.py_lg, 359 - a.px_lg, 360 - a.flex_row, 361 - a.align_center, 362 - a.justify_between, 363 - disabledOnIOS && {opacity: 0.5}, 364 - ]}> 365 - <Text style={[a.font_bold, t.atoms.text_contrast_high]}> 366 - <Trans>Enable adult content</Trans> 367 - </Text> 368 - <Toggle.Item 369 - label={_(msg`Toggle to enable or disable adult content`)} 370 - disabled={disabledOnIOS} 371 - name="adultContent" 372 - value={adultContentEnabled} 373 - onChange={onToggleAdultContentEnabled}> 374 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 375 - <Text style={[t.atoms.text_contrast_medium]}> 376 - {adultContentEnabled ? ( 377 - <Trans>Enabled</Trans> 378 - ) : ( 379 - <Trans>Disabled</Trans> 380 - )} 361 + <View style={[a.gap_md]}> 362 + <View 363 + style={[ 364 + a.w_full, 365 + a.rounded_md, 366 + a.overflow_hidden, 367 + t.atoms.bg_contrast_25, 368 + ]}> 369 + {!isDeclaredUnderage && ( 370 + <> 371 + <View 372 + style={[ 373 + a.py_lg, 374 + a.px_lg, 375 + a.flex_row, 376 + a.align_center, 377 + a.justify_between, 378 + disabledOnIOS && {opacity: 0.5}, 379 + ]}> 380 + <Text style={[a.font_bold, t.atoms.text_contrast_high]}> 381 + <Trans>Enable adult content</Trans> 381 382 </Text> 382 - <Toggle.Switch /> 383 + <Toggle.Item 384 + label={_(msg`Toggle to enable or disable adult content`)} 385 + disabled={disabledOnIOS || isAgeRestricted} 386 + name="adultContent" 387 + value={adultContentEnabled} 388 + onChange={onToggleAdultContentEnabled}> 389 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 390 + <Text style={[t.atoms.text_contrast_medium]}> 391 + {adultContentEnabled ? ( 392 + <Trans>Enabled</Trans> 393 + ) : ( 394 + <Trans>Disabled</Trans> 395 + )} 396 + </Text> 397 + <Toggle.Switch /> 398 + </View> 399 + </Toggle.Item> 383 400 </View> 384 - </Toggle.Item> 385 - </View> 386 - {disabledOnIOS && ( 387 - <View style={[a.pb_lg, a.px_lg]}> 388 - <Text> 389 - <Trans> 390 - Adult content can only be enabled via the Web at{' '} 391 - <InlineLinkText 392 - label={_(msg`The Bluesky web application`)} 393 - to="" 394 - onPress={evt => { 395 - evt.preventDefault() 396 - Linking.openURL('https://bsky.app/') 397 - return false 398 - }}> 399 - bsky.app 400 - </InlineLinkText> 401 - . 402 - </Trans> 403 - </Text> 404 - </View> 405 - )} 406 - <Divider /> 401 + {disabledOnIOS && ( 402 + <View style={[a.pb_lg, a.px_lg]}> 403 + <Text> 404 + <Trans> 405 + Adult content can only be enabled via the Web at{' '} 406 + <InlineLinkText 407 + label={_(msg`The Bluesky web application`)} 408 + to="" 409 + onPress={evt => { 410 + evt.preventDefault() 411 + Linking.openURL('https://bsky.app/') 412 + return false 413 + }}> 414 + bsky.app 415 + </InlineLinkText> 416 + . 417 + </Trans> 418 + </Text> 419 + </View> 420 + )} 407 421 408 - {adultContentEnabled && ( 409 - <> 410 - <GlobalLabelPreference labelDefinition={LABELS.porn} /> 411 - <Divider /> 412 - <GlobalLabelPreference labelDefinition={LABELS.sexual} /> 413 - <Divider /> 414 - <GlobalLabelPreference 415 - labelDefinition={LABELS['graphic-media']} 416 - /> 417 - <Divider /> 422 + {adultContentEnabled && ( 423 + <> 424 + <Divider /> 425 + <GlobalLabelPreference labelDefinition={LABELS.porn} /> 426 + <Divider /> 427 + <GlobalLabelPreference labelDefinition={LABELS.sexual} /> 428 + <Divider /> 429 + <GlobalLabelPreference 430 + labelDefinition={LABELS['graphic-media']} 431 + /> 432 + <Divider /> 433 + <GlobalLabelPreference 434 + disabled={isDeclaredUnderage || isAgeRestricted} 435 + labelDefinition={LABELS.nudity} 436 + /> 437 + </> 438 + )} 418 439 </> 419 440 )} 420 - </> 421 - )} 422 - <GlobalLabelPreference 423 - disabled={isDeclaredUnderage || isAgeRestricted} 424 - labelDefinition={LABELS.nudity} 425 - /> 426 - </View> 427 - </View> 441 + </View> 442 + </View> 443 + </> 444 + )} 428 445 429 446 <Text 430 447 style={[
-16
src/screens/Settings/AboutSettings.tsx
··· 20 20 import {CodeLines_Stroke2_Corner2_Rounded as CodeLinesIcon} from '#/components/icons/CodeLines' 21 21 import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 22 22 import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/icons/Newspaper' 23 - import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 24 23 import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench' 25 24 import * as Layout from '#/components/Layout' 26 25 import {Loader} from '#/components/Loader' 27 - import {device} from '#/storage' 28 26 import {useDemoMode} from '#/storage/hooks/demo-mode' 29 27 import {useDevMode} from '#/storage/hooks/dev-mode' 30 28 import {OTAInfo} from './components/OTAInfo' ··· 181 179 </SettingsList.ItemText> 182 180 </SettingsList.PressableItem> 183 181 )} 184 - 185 - <SettingsList.PressableItem 186 - onPress={() => { 187 - device.set(['geolocation'], { 188 - countryCode: 'GB', 189 - isAgeRestrictedGeo: true, 190 - }) 191 - }} 192 - label="Simulate age restriction"> 193 - <SettingsList.ItemIcon icon={Shield} /> 194 - <SettingsList.ItemText> 195 - Simulate age restriction 196 - </SettingsList.ItemText> 197 - </SettingsList.PressableItem> 198 182 </> 199 183 )} 200 184 </SettingsList.Container>
+1 -3
src/state/ageAssurance/index.tsx
··· 6 6 import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' 7 7 import {useGate} from '#/lib/statsig/statsig' 8 8 import {isNetworkError} from '#/lib/strings/errors' 9 - import {Logger} from '#/logger' 10 9 import { 11 10 type AgeAssuranceAPIContextType, 12 11 type AgeAssuranceContextType, 13 12 } from '#/state/ageAssurance/types' 14 13 import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled' 14 + import {logger} from '#/state/ageAssurance/util' 15 15 import {useGeolocation} from '#/state/geolocation' 16 16 import {useAgent} from '#/state/session' 17 - 18 - const logger = Logger.create(Logger.Context.AgeAssurance) 19 17 20 18 export const createAgeAssuranceQueryKey = (did: string) => 21 19 ['ageAssurance', did] as const
+1 -3
src/state/ageAssurance/useAgeAssurance.ts
··· 1 1 import {useMemo} from 'react' 2 2 3 - import {Logger} from '#/logger' 4 3 import {useAgeAssuranceContext} from '#/state/ageAssurance' 4 + import {logger} from '#/state/ageAssurance/util' 5 5 import {usePreferencesQuery} from '#/state/queries/preferences' 6 - 7 - const logger = Logger.create(Logger.Context.AgeAssurance) 8 6 9 7 type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & { 10 8 /**
+2 -1
src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
··· 8 8 const {geolocation} = useGeolocation() 9 9 10 10 return useMemo(() => { 11 - return gate('age_assurance') && !!geolocation?.isAgeRestrictedGeo 11 + const enabled = gate('age_assurance') 12 + return enabled && !!geolocation?.isAgeRestrictedGeo 12 13 }, [geolocation, gate]) 13 14 }
+3
src/state/ageAssurance/util.ts
··· 1 + import {Logger} from '#/logger' 2 + 3 + export const logger = Logger.create(Logger.Context.AgeAssurance)
+3 -3
src/state/queries/nuxs/definitions.ts
··· 8 8 InitialVerificationAnnouncement = 'InitialVerificationAnnouncement', 9 9 ActivitySubscriptions = 'ActivitySubscriptions', 10 10 AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', 11 - AgeAssuranceDismissibleHeaderButton = 'AgeAssuranceDismissibleHeaderButton', 11 + AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner', 12 12 } 13 13 14 14 export const nuxNames = new Set(Object.values(Nux)) ··· 35 35 data: undefined 36 36 } 37 37 | { 38 - id: Nux.AgeAssuranceDismissibleHeaderButton 38 + id: Nux.AgeAssuranceDismissibleFeedBanner 39 39 data: undefined 40 40 } 41 41 > ··· 46 46 [Nux.InitialVerificationAnnouncement]: undefined, 47 47 [Nux.ActivitySubscriptions]: undefined, 48 48 [Nux.AgeAssuranceDismissibleNotice]: undefined, 49 - [Nux.AgeAssuranceDismissibleHeaderButton]: undefined, 49 + [Nux.AgeAssuranceDismissibleFeedBanner]: undefined, 50 50 }
+47
src/view/com/posts/PostFeed.tsx
··· 43 43 import {useLiveNowConfig} from '#/state/service-config' 44 44 import {useSession} from '#/state/session' 45 45 import {useProgressGuide} from '#/state/shell/progress-guide' 46 + import {useSelectedFeed} from '#/state/shell/selected-feed' 46 47 import {List, type ListRef} from '#/view/com/util/List' 47 48 import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 48 49 import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 49 50 import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' 50 51 import {useBreakpoints, useLayoutBreakpoints} from '#/alf' 52 + import { 53 + AgeAssuranceDismissibleFeedBanner, 54 + useInternalState as useAgeAssuranceBannerState, 55 + } from '#/components/ageAssurance/AgeAssuranceDismissibleFeedBanner' 51 56 import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' 52 57 import { 53 58 PostFeedVideoGridRow, ··· 129 134 } 130 135 | { 131 136 type: 'showLessFollowup' 137 + key: string 138 + } 139 + | { 140 + type: 'ageAssuranceBanner' 132 141 key: string 133 142 } 134 143 ··· 335 344 336 345 const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() 337 346 347 + const ageAssuranceBannerState = useAgeAssuranceBannerState() 348 + const selectedFeed = useSelectedFeed() 349 + /** 350 + * Cached value of whether the current feed was selected at startup. We don't 351 + * want this to update when user swipes. 352 + */ 353 + const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed) 354 + 338 355 const feedItems: FeedRow[] = useMemo(() => { 339 356 // wraps a slice item, and replaces it with a showLessFollowup item 340 357 // if the user has pressed show less on it ··· 450 467 type: 'interstitialProgressGuide', 451 468 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 452 469 }) 470 + } else { 471 + /* 472 + * Only insert if Discover was the last selected feed at 473 + * startup, the progress guide isn't shown, and the 474 + * banner is eligible to be shown. 475 + */ 476 + if ( 477 + isCurrentFeedAtStartupSelected && 478 + ageAssuranceBannerState.visible 479 + ) { 480 + arr.push({ 481 + type: 'ageAssuranceBanner', 482 + key: 'ageAssuranceBanner-' + sliceIndex, 483 + }) 484 + } 453 485 } 454 486 if (!rightNavVisible && !trendingDisabled) { 455 487 arr.push({ ··· 478 510 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 479 511 }) 480 512 } 513 + } else { 514 + /* 515 + * Only insert if this feed was the last selected feed at 516 + * startup and the banner is eligible to be shown. 517 + */ 518 + if (sliceIndex === 0 && isCurrentFeedAtStartupSelected) { 519 + arr.push({ 520 + type: 'ageAssuranceBanner', 521 + key: 'ageAssuranceBanner-' + sliceIndex, 522 + }) 523 + } 481 524 } 482 525 } 483 526 ··· 580 623 isVideoFeed, 581 624 areVideoFeedsEnabled, 582 625 hasPressedShowLessUris, 626 + ageAssuranceBannerState, 627 + isCurrentFeedAtStartupSelected, 583 628 ]) 584 629 585 630 // events ··· 666 711 return <SuggestedFollows feed={feed} /> 667 712 } else if (row.type === 'interstitialProgressGuide') { 668 713 return <ProgressGuide /> 714 + } else if (row.type === 'ageAssuranceBanner') { 715 + return <AgeAssuranceDismissibleFeedBanner /> 669 716 } else if (row.type === 'interstitialTrending') { 670 717 return <TrendingInterstitial /> 671 718 } else if (row.type === 'interstitialTrendingVideos') {