Age Assurance V2 (#9479)
* Age Assurance V2 * Tighten up test * Add todos for sdk migration * Align RQ versions * Use useEffect for side effect * Improve effects, memoize * Standarize on birthdate * Copy feedback * Copilot * Add support link * Reove double .. * Cleanup * Remove redirect dialog * Cleanup todos, add comments * Update splash in main template too * Mock some stuff * Exhaustive checks Co-authored-by: Samuel Newman <mozzius@protonmail.com> * Exhaustive checks Co-authored-by: Samuel Newman <mozzius@protonmail.com> * Small fix to bday handling * Add comment * onboarding style tweak sneaking this in sorry! * rm unreachable breaks * Put useIntentHandler back on web * Remove misleading success set * Align on birthdate --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export const prefetchAgeAssuranceData = () => {}
|
||||
export const setBirthdateForDid = () => {}
|
||||
export const setCreatedAtForDid = () => {}
|
||||
@@ -0,0 +1,355 @@
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
SupportCode,
|
||||
useCreateSupportLink,
|
||||
} from '#/lib/hooks/useCreateSupportLink'
|
||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
||||
import {Full as Logo} from '#/components/icons/Logo'
|
||||
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
||||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||
import {
|
||||
isLegacyBirthdateBug,
|
||||
useAgeAssuranceRegionConfig,
|
||||
} from '#/ageAssurance/util'
|
||||
import {useDeviceGeolocationApi} from '#/geolocation'
|
||||
|
||||
const textStyles = [a.text_md, a.leading_snug]
|
||||
|
||||
export function NoAccessScreen() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const insets = useSafeAreaInsets()
|
||||
const birthdateControl = useDialogControl()
|
||||
const {data} = useAgeAssuranceDataContext()
|
||||
const region = useAgeAssuranceRegionConfig()
|
||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const createSupportLink = useCreateSupportLink()
|
||||
|
||||
const aa = useAgeAssurance()
|
||||
const isBlocked = aa.state.status === aa.Status.Blocked
|
||||
const isAARegion = !!region
|
||||
const hasDeclaredAge = data?.declaredAge !== undefined
|
||||
const canUpdateBirthday =
|
||||
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
|
||||
|
||||
useEffect(() => {
|
||||
// just counting overall hits here
|
||||
logger.metric(`blockedGeoOverlay:shown`, {})
|
||||
}, [])
|
||||
|
||||
const onPressLogout = useCallback(() => {
|
||||
if (isWeb) {
|
||||
// We're switching accounts, which remounts the entire app.
|
||||
// On mobile, this gets us Home, but on the web we also need reset the URL.
|
||||
// We can't change the URL via a navigate() call because the navigator
|
||||
// itself is about to unmount, and it calls pushState() too late.
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
logoutCurrentAccount('AgeAssuranceNoAccessScreen')
|
||||
}, [logoutCurrentAccount])
|
||||
|
||||
const birthdateUpdateText = canUpdateBirthday ? (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
style={[textStyles]}
|
||||
{...createStaticClick(() => {
|
||||
birthdateControl.open()
|
||||
})}>
|
||||
clicking here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, please{' '}
|
||||
<SimpleInlineLinkText
|
||||
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
|
||||
label={_(msg`Click here to contact our support team`)}
|
||||
style={[textStyles]}>
|
||||
contact our support team
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.w_full,
|
||||
web({
|
||||
maxWidth: 380,
|
||||
paddingTop: gtPhone ? '8vh' : undefined,
|
||||
}),
|
||||
{
|
||||
gap: 32,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.align_start]}>
|
||||
<AgeAssuranceBadge />
|
||||
</View>
|
||||
|
||||
{hasDeclaredAge ? (
|
||||
<>
|
||||
{isAARegion ? (
|
||||
<>
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
You are accessing Bluesky from a region that legally
|
||||
requires us to verify your age before allowing you to
|
||||
access the app.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{!isBlocked && birthdateUpdateText}
|
||||
</View>
|
||||
|
||||
<AccessSection />
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, the birthdate you have saved to your
|
||||
profile makes you too young to access Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{birthdateUpdateText}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
It looks like you haven't added your birthdate. You must
|
||||
provide an accurate date of birth to use Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
onPress={() => birthdateControl.open()}>
|
||||
<ButtonText>
|
||||
<Trans>Add your birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.pt_lg, a.gap_xl]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
To log out,{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to log out`)}
|
||||
{...createStaticClick(() => {
|
||||
onPressLogout()
|
||||
})}>
|
||||
click here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<BirthDateSettingsDialog control={birthdateControl} />
|
||||
|
||||
{/*
|
||||
* While this blocking overlay is up, other dialogs in the shell
|
||||
* are not mounted, so it _should_ be safe to use these here
|
||||
* without fear of other modals showing up.
|
||||
*/}
|
||||
<BottomSheetOutlet />
|
||||
<PortalOutlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessSection() {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const control = useDialogControl()
|
||||
const appealControl = Dialog.useDialogControl()
|
||||
const locationControl = Dialog.useDialogControl()
|
||||
const getTimeAgo = useGetTimeAgo()
|
||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||
|
||||
const aa = useAgeAssurance()
|
||||
const {status, lastInitiatedAt} = aa.state
|
||||
const isBlocked = status === aa.Status.Blocked
|
||||
const hasInitiated = !!lastInitiatedAt
|
||||
const timeAgo = lastInitiatedAt
|
||||
? getTimeAgo(lastInitiatedAt, new Date())
|
||||
: null
|
||||
const diff = lastInitiatedAt
|
||||
? dateDiff(lastInitiatedAt, new Date(), 'down')
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<AgeAssuranceInitDialog control={control} />
|
||||
<AgeAssuranceAppealDialog control={appealControl} />
|
||||
|
||||
<View style={[a.gap_xl]}>
|
||||
{isBlocked ? (
|
||||
<Admonition type="warning">
|
||||
<Trans>
|
||||
You are currently unable to access Bluesky's Age Assurance flow.
|
||||
Please{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Contact our moderation team`)}
|
||||
{...createStaticClick(() => {
|
||||
appealControl.open()
|
||||
logger.metric('ageAssurance:appealDialogOpen', {})
|
||||
})}>
|
||||
contact our moderation team
|
||||
</SimpleInlineLinkText>{' '}
|
||||
if you believe this is an error.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
) : (
|
||||
<>
|
||||
<View style={[a.gap_md]}>
|
||||
<Button
|
||||
label={_(msg`Verify now`)}
|
||||
size="large"
|
||||
color={hasInitiated ? 'secondary' : 'primary'}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
logger.metric('ageAssurance:initDialogOpen', {
|
||||
hasInitiatedPreviously: hasInitiated,
|
||||
})
|
||||
}}>
|
||||
<ButtonIcon icon={ShieldIcon} />
|
||||
<ButtonText>
|
||||
{hasInitiated ? (
|
||||
<Trans>Verify again</Trans>
|
||||
) : (
|
||||
<Trans>Verify now</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{lastInitiatedAt && timeAgo && diff ? (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||||
title={i18n.date(lastInitiatedAt, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})}>
|
||||
{diff.value === 0 ? (
|
||||
<Trans>Last initiated just now</Trans>
|
||||
) : (
|
||||
<Trans>Last initiated {timeAgo} ago</Trans>
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Age assurance only takes a few minutes</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[a.gap_xs]}>
|
||||
{isNative && (
|
||||
<>
|
||||
<Admonition>
|
||||
<Trans>
|
||||
Is your location not accurate?{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Confirm your location`)}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to confirm your location.
|
||||
</SimpleInlineLinkText>{' '}
|
||||
</Trans>
|
||||
</Admonition>
|
||||
|
||||
<DeviceLocationRequestDialog
|
||||
control={locationControl}
|
||||
onLocationAcquired={props => {
|
||||
const access = computeAgeAssuranceRegionAccess(
|
||||
props.geolocation,
|
||||
)
|
||||
if (access !== aa.Access.Full) {
|
||||
props.disableDialogAction()
|
||||
props.setDialogError(
|
||||
_(
|
||||
msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
props.closeDialog(() => {
|
||||
// set this after close!
|
||||
setDeviceGeolocation(props.geolocation)
|
||||
Toast.show(_(msg`Thanks! You're all set.`), {
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {Dimensions, View} from 'react-native'
|
||||
import * as Linking from 'expo-linking'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {retry} from '#/lib/async/retry'
|
||||
import {wait} from '#/lib/async/wait'
|
||||
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
||||
import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {refetchAgeAssuranceServerState} from '#/ageAssurance'
|
||||
import {logger} from '#/ageAssurance'
|
||||
|
||||
export type RedirectOverlayState = {
|
||||
result: 'success' | 'unknown'
|
||||
actorDid: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse the query parameters returned from the age assurance
|
||||
* redirect. If not valid, returns `undefined` and the dialog will not open.
|
||||
*/
|
||||
export function parseRedirectOverlayState(
|
||||
state: {
|
||||
result?: string
|
||||
actorDid?: string
|
||||
} = {},
|
||||
): RedirectOverlayState | undefined {
|
||||
let result: RedirectOverlayState['result'] = 'unknown'
|
||||
const actorDid = state.actorDid
|
||||
|
||||
switch (state.result) {
|
||||
case 'success':
|
||||
result = 'success'
|
||||
break
|
||||
case 'unknown':
|
||||
default:
|
||||
result = 'unknown'
|
||||
break
|
||||
}
|
||||
|
||||
if (actorDid) {
|
||||
return {
|
||||
result,
|
||||
actorDid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
isOpen: boolean
|
||||
open: (state: RedirectOverlayState) => void
|
||||
close: () => void
|
||||
}>({
|
||||
isOpen: false,
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
})
|
||||
|
||||
export function useRedirectOverlayContext() {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export function Provider({children}: {children?: React.ReactNode}) {
|
||||
const {currentAccount} = useSession()
|
||||
const incomingUrl = Linking.useLinkingURL()
|
||||
const [state, setState] = useState<RedirectOverlayState | null>(() => {
|
||||
if (!incomingUrl) return null
|
||||
const url = parseLinkingUrl(incomingUrl)
|
||||
if (url.pathname !== '/intent/age-assurance') return null
|
||||
const params = url.searchParams
|
||||
const state = parseRedirectOverlayState({
|
||||
result: params.get('result') ?? undefined,
|
||||
actorDid: params.get('actorDid') ?? undefined,
|
||||
})
|
||||
|
||||
if (isWeb) {
|
||||
// Clear the URL parameters so they don't re-trigger
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
|
||||
/*
|
||||
* If we don't have an account or the account doesn't match, do
|
||||
* nothing. By the time the user switches to their other account, AA
|
||||
* state should be ready for them.
|
||||
*/
|
||||
if (state && currentAccount && state.actorDid === currentAccount.did) {
|
||||
return state
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
const open = useCallback((state: RedirectOverlayState) => {
|
||||
setState(state)
|
||||
}, [])
|
||||
const close = useCallback(() => {
|
||||
setState(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Context.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
isOpen: state !== null,
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[state, open, close],
|
||||
)}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function RedirectOverlay() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {isOpen} = useRedirectOverlayContext()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
return isOpen ? (
|
||||
<FullWindowOverlay>
|
||||
<View
|
||||
style={[
|
||||
a.fixed,
|
||||
a.inset_0,
|
||||
// setting a zIndex when using FullWindowOverlay on iOS
|
||||
// means the taps pass straight through to the underlying content (???)
|
||||
// so don't set it on iOS. FullWindowOverlay already does the job.
|
||||
!isIOS && {zIndex: 9999},
|
||||
t.atoms.bg,
|
||||
gtMobile ? a.p_2xl : a.p_xl,
|
||||
a.align_center,
|
||||
// @ts-ignore
|
||||
platform({
|
||||
web: {
|
||||
paddingTop: '35vh',
|
||||
},
|
||||
default: {
|
||||
paddingTop: Dimensions.get('window').height * 0.35,
|
||||
},
|
||||
}),
|
||||
]}>
|
||||
<View
|
||||
role="dialog"
|
||||
aria-role="dialog"
|
||||
aria-label={_(msg`Verifying your age assurance status`)}>
|
||||
<View style={[a.pb_3xl, {width: 300}]}>
|
||||
<Inner />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</FullWindowOverlay>
|
||||
) : null
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const polling = useRef(false)
|
||||
const unmounted = useRef(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const {close} = useRedirectOverlayContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (polling.current) return
|
||||
|
||||
polling.current = true
|
||||
|
||||
logger.metric('ageAssurance:redirectDialogOpen', {})
|
||||
|
||||
wait(
|
||||
3e3,
|
||||
retry(
|
||||
5,
|
||||
() => true,
|
||||
async () => {
|
||||
if (!agent.session) return
|
||||
if (unmounted.current) return
|
||||
|
||||
const data = await refetchAgeAssuranceServerState({agent})
|
||||
|
||||
if (data?.state.status !== 'assured') {
|
||||
throw new Error(
|
||||
`Polling for age assurance state did not receive assured status`,
|
||||
)
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
1e3,
|
||||
),
|
||||
)
|
||||
.then(async data => {
|
||||
if (!data) return
|
||||
if (!agent.session) return
|
||||
if (unmounted.current) return
|
||||
|
||||
setSuccess(true)
|
||||
|
||||
logger.metric('ageAssurance:redirectDialogSuccess', {})
|
||||
})
|
||||
.catch(() => {
|
||||
if (unmounted.current) return
|
||||
setError(true)
|
||||
logger.metric('ageAssurance:redirectDialogFail', {})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unmounted.current = true
|
||||
}
|
||||
}, [agent])
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<>
|
||||
<View style={[a.align_start, a.w_full]}>
|
||||
<AgeAssuranceBadge />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pt_lg,
|
||||
a.pb_md,
|
||||
]}>
|
||||
<SuccessIcon size="sm" fill={t.palette.positive_500} />
|
||||
<Text style={[a.text_3xl, a.font_bold]}>
|
||||
<Trans>Success</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
We've confirmed your age assurance status. You can now close this
|
||||
dialog.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.w_full, a.pt_lg]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[a.align_start, a.w_full]}>
|
||||
<AgeAssuranceBadge />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pt_lg,
|
||||
a.pb_md,
|
||||
]}>
|
||||
{error && <ErrorIcon size="lg" fill={t.palette.negative_500} />}
|
||||
|
||||
<Text style={[a.text_3xl, a.font_bold]}>
|
||||
{error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
|
||||
</Text>
|
||||
|
||||
{!error && <Loader size="lg" />}
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{error ? (
|
||||
<Trans>
|
||||
We were unable to receive the verification due to a connection
|
||||
issue. It may arrive later. If it does, your account will update
|
||||
automatically.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
We're confirming your age assurance status with our servers. This
|
||||
should only take a few seconds.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<View style={[a.w_full, a.pt_lg]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||
import {
|
||||
type AppBskyAgeassuranceDefs,
|
||||
type AppBskyAgeassuranceGetConfig,
|
||||
type AppBskyAgeassuranceGetState,
|
||||
AtpAgent,
|
||||
getAgeAssuranceRegionConfig,
|
||||
} from '@atproto/api'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as debug from '#/ageAssurance/debug'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {isLegacyBirthdateBug} from '#/ageAssurance/util'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {device} from '#/storage'
|
||||
|
||||
/**
|
||||
* Special query client for age assurance data so we can prefetch on app
|
||||
* load without interfering with other queries.
|
||||
*/
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
/**
|
||||
* We clear this manually, so disable automatic garbage collection.
|
||||
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
|
||||
*/
|
||||
gcTime: Infinity,
|
||||
},
|
||||
},
|
||||
})
|
||||
const persister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: 'age-assurance-query-client',
|
||||
})
|
||||
const [, cacheHydrationPromise] = persistQueryClient({
|
||||
queryClient: qc,
|
||||
persister,
|
||||
})
|
||||
|
||||
function getDidFromAgentSession(agent: AtpAgent) {
|
||||
const sessionManager = agent.sessionManager
|
||||
if (!sessionManager || !sessionManager.did) return
|
||||
return sessionManager.did
|
||||
}
|
||||
|
||||
/*
|
||||
* Optimistic data
|
||||
*/
|
||||
|
||||
const createdAtCache = new Map<string, string>()
|
||||
export function setCreatedAtForDid({
|
||||
did,
|
||||
createdAt,
|
||||
}: {
|
||||
did: string
|
||||
createdAt: string
|
||||
}) {
|
||||
createdAtCache.set(did, createdAt)
|
||||
}
|
||||
const birthdateCache = new Map<string, string>()
|
||||
export function setBirthdateForDid({
|
||||
did,
|
||||
birthdate,
|
||||
}: {
|
||||
did: string
|
||||
birthdate: string
|
||||
}) {
|
||||
birthdateCache.set(did, birthdate)
|
||||
}
|
||||
|
||||
/*
|
||||
* Config
|
||||
*/
|
||||
|
||||
export const configQueryKey = ['config']
|
||||
export async function getConfig() {
|
||||
if (debug.enabled) return debug.resolve(debug.config)
|
||||
const agent = new AtpAgent({
|
||||
service: PUBLIC_BSKY_SERVICE,
|
||||
})
|
||||
const res = await agent.app.bsky.ageassurance.getConfig()
|
||||
return res.data
|
||||
}
|
||||
export function getConfigFromCache():
|
||||
| AppBskyAgeassuranceGetConfig.OutputSchema
|
||||
| undefined {
|
||||
return qc.getQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
||||
configQueryKey,
|
||||
)
|
||||
}
|
||||
let configPrefetchPromise: Promise<void> | undefined
|
||||
export async function prefetchConfig() {
|
||||
if (configPrefetchPromise) {
|
||||
logger.debug(`prefetchAgeAssuranceConfig: already in progress`)
|
||||
return
|
||||
}
|
||||
|
||||
configPrefetchPromise = new Promise(async resolve => {
|
||||
await cacheHydrationPromise
|
||||
const cached = getConfigFromCache()
|
||||
|
||||
if (cached) {
|
||||
logger.debug(`prefetchAgeAssuranceConfig: using cache`)
|
||||
resolve()
|
||||
} else {
|
||||
try {
|
||||
logger.debug(`prefetchAgeAssuranceConfig: resolving...`)
|
||||
const res = await networkRetry(3, () => getConfig())
|
||||
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
||||
configQueryKey,
|
||||
res,
|
||||
)
|
||||
} catch (e: any) {
|
||||
logger.warn(`prefetchAgeAssuranceConfig: failed`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
} finally {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
export function useConfigQuery() {
|
||||
return useQuery(
|
||||
{
|
||||
/**
|
||||
* Will re-fetch when stale, at most every hour (or 5s in dev for easier
|
||||
* testing).
|
||||
*
|
||||
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
|
||||
*/
|
||||
staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
|
||||
initialData: getConfigFromCache(),
|
||||
initialDataUpdatedAt: () =>
|
||||
qc.getQueryState(configQueryKey)?.dataUpdatedAt,
|
||||
queryKey: configQueryKey,
|
||||
async queryFn() {
|
||||
logger.debug(`useConfigQuery: fetching config`)
|
||||
return getConfig()
|
||||
},
|
||||
},
|
||||
qc,
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* Server state
|
||||
*/
|
||||
|
||||
export function createServerStateQueryKey({did}: {did: string}) {
|
||||
return ['serverState', did]
|
||||
}
|
||||
export async function getServerState({agent}: {agent: AtpAgent}) {
|
||||
if (debug.enabled && debug.serverState)
|
||||
return debug.resolve(debug.serverState)
|
||||
const geolocation = device.get(['mergedGeolocation'])
|
||||
if (!geolocation || !geolocation.countryCode) {
|
||||
logger.error(`getServerState: missing geolocation countryCode`)
|
||||
return
|
||||
}
|
||||
const {data} = await agent.app.bsky.ageassurance.getState({
|
||||
countryCode: geolocation.countryCode,
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (data && did && createdAtCache.has(did)) {
|
||||
/*
|
||||
* If account was just created, just use the local cache if available. On
|
||||
* subsequent reloads, the server should have the correct value.
|
||||
*/
|
||||
data.metadata.accountCreatedAt = createdAtCache.get(did)
|
||||
}
|
||||
return data ?? null
|
||||
}
|
||||
export function getServerStateFromCache({
|
||||
did,
|
||||
}: {
|
||||
did: string
|
||||
}): AppBskyAgeassuranceGetState.OutputSchema | undefined {
|
||||
return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
createServerStateQueryKey({did}),
|
||||
)
|
||||
}
|
||||
export async function prefetchServerState({agent}: {agent: AtpAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
|
||||
if (!did) return
|
||||
|
||||
await cacheHydrationPromise
|
||||
const qk = createServerStateQueryKey({did})
|
||||
const cached = getServerStateFromCache({did})
|
||||
|
||||
if (cached) {
|
||||
logger.debug(`prefetchServerState: using cache`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`prefetchServerState: resolving...`)
|
||||
const res = await networkRetry(3, () => getServerState({agent}))
|
||||
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
|
||||
} catch (e: any) {
|
||||
logger.warn(`prefetchServerState: failed`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
export async function refetchServerState({agent}: {agent: AtpAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (!did) return
|
||||
logger.debug(`refetchServerState: fetching...`)
|
||||
const res = await networkRetry(3, () => getServerState({agent}))
|
||||
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
createServerStateQueryKey({did}),
|
||||
res,
|
||||
)
|
||||
return res
|
||||
}
|
||||
export function usePatchServerState() {
|
||||
const {currentAccount} = useSession()
|
||||
return useCallback(
|
||||
async (next: AppBskyAgeassuranceDefs.State) => {
|
||||
if (!currentAccount) return
|
||||
const did = currentAccount.did
|
||||
const prev = getServerStateFromCache({did})
|
||||
const merged: AppBskyAgeassuranceGetState.OutputSchema = {
|
||||
metadata: {},
|
||||
...(prev || {}),
|
||||
state: next,
|
||||
}
|
||||
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
createServerStateQueryKey({did}),
|
||||
merged,
|
||||
)
|
||||
},
|
||||
[currentAccount],
|
||||
)
|
||||
}
|
||||
export function useServerStateQuery() {
|
||||
const agent = useAgent()
|
||||
const did = getDidFromAgentSession(agent)
|
||||
const query = useQuery(
|
||||
{
|
||||
enabled: !!did,
|
||||
initialData: () => {
|
||||
if (!did) return
|
||||
return getServerStateFromCache({did})
|
||||
},
|
||||
queryKey: createServerStateQueryKey({did: did!}),
|
||||
async queryFn() {
|
||||
return getServerState({agent})
|
||||
},
|
||||
},
|
||||
qc,
|
||||
)
|
||||
const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch])
|
||||
|
||||
const isAssured = query.data?.state?.status === 'assured'
|
||||
|
||||
/**
|
||||
* `refetchOnWindowFocus` doesn't seem to want to work for this custom query
|
||||
* client, so we manually subscribe to focus changes.
|
||||
*/
|
||||
useEffect(() => {
|
||||
return focusManager.subscribe(() => {
|
||||
// logged out
|
||||
if (!did) return
|
||||
|
||||
const isFocused = focusManager.isFocused()
|
||||
|
||||
if (!isFocused) return
|
||||
|
||||
const config = getConfigFromCache()
|
||||
const geolocation = device.get(['mergedGeolocation'])
|
||||
const isAArequired = Boolean(
|
||||
config &&
|
||||
geolocation &&
|
||||
!!getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation?.countryCode ?? '',
|
||||
regionCode: geolocation?.regionCode,
|
||||
}),
|
||||
)
|
||||
|
||||
// only refetch when needed
|
||||
if (isAssured || !isAArequired) return
|
||||
|
||||
refetch()
|
||||
})
|
||||
}, [did, refetch, isAssured])
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
/*
|
||||
* Other required data
|
||||
*/
|
||||
|
||||
export type OtherRequiredData = {
|
||||
birthdate: string | undefined
|
||||
}
|
||||
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
||||
return ['otherRequiredData', did]
|
||||
}
|
||||
export async function getOtherRequiredData({
|
||||
agent,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
}): Promise<OtherRequiredData> {
|
||||
if (debug.enabled) return debug.resolve(debug.otherRequiredData)
|
||||
const [prefs] = await Promise.all([agent.getPreferences()])
|
||||
const data: OtherRequiredData = {
|
||||
birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined,
|
||||
}
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (data && did && birthdateCache.has(did)) {
|
||||
/*
|
||||
* If birthdate was just set, use the local cache value. On subsequent
|
||||
* reloads, the server should have the correct value.
|
||||
*/
|
||||
data.birthdate = birthdateCache.get(did)
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is under the minimum age, and the birthdate is not due to
|
||||
* the legacy bug, snooze further birthdate updates for this user.
|
||||
*/
|
||||
if (data.birthdate && !isLegacyBirthdateBug(data.birthdate)) {
|
||||
snoozeBirthdateUpdateAllowedForDid(did!)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
export function getOtherRequiredDataFromCache({
|
||||
did,
|
||||
}: {
|
||||
did: string
|
||||
}): OtherRequiredData | undefined {
|
||||
return qc.getQueryData<OtherRequiredData>(
|
||||
createOtherRequiredDataQueryKey({did}),
|
||||
)
|
||||
}
|
||||
export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
|
||||
if (!did) return
|
||||
|
||||
await cacheHydrationPromise
|
||||
const qk = createOtherRequiredDataQueryKey({did})
|
||||
const cached = getOtherRequiredDataFromCache({did})
|
||||
|
||||
if (cached) {
|
||||
logger.debug(`prefetchOtherRequiredData: using cache`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
||||
const res = await networkRetry(3, () => getOtherRequiredData({agent}))
|
||||
qc.setQueryData<OtherRequiredData>(qk, res)
|
||||
} catch (e: any) {
|
||||
logger.warn(`prefetchOtherRequiredData: failed`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
export function usePatchOtherRequiredData() {
|
||||
const {currentAccount} = useSession()
|
||||
return useCallback(
|
||||
async (next: OtherRequiredData) => {
|
||||
if (!currentAccount) return
|
||||
const did = currentAccount.did
|
||||
const prev = getOtherRequiredDataFromCache({did})
|
||||
const merged: OtherRequiredData = {
|
||||
...(prev || {}),
|
||||
...next,
|
||||
}
|
||||
qc.setQueryData<OtherRequiredData>(
|
||||
createOtherRequiredDataQueryKey({did}),
|
||||
merged,
|
||||
)
|
||||
},
|
||||
[currentAccount],
|
||||
)
|
||||
}
|
||||
export function useOtherRequiredDataQuery() {
|
||||
const agent = useAgent()
|
||||
const did = getDidFromAgentSession(agent)
|
||||
return useQuery(
|
||||
{
|
||||
enabled: !!did,
|
||||
initialData: () => {
|
||||
if (!did) return
|
||||
return getOtherRequiredDataFromCache({did})
|
||||
},
|
||||
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
||||
async queryFn() {
|
||||
return getOtherRequiredData({agent})
|
||||
},
|
||||
},
|
||||
qc,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to prefetch all age assurance data.
|
||||
*/
|
||||
export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
|
||||
return Promise.allSettled([
|
||||
// config fetch initiated at the top of the App.platform.tsx files, awaited here
|
||||
configPrefetchPromise,
|
||||
prefetchServerState({agent}),
|
||||
prefetchOtherRequiredData({agent}),
|
||||
])
|
||||
}
|
||||
|
||||
export function clearAgeAssuranceDataForDid({did}: {did: string}) {
|
||||
logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
|
||||
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
|
||||
qc.removeQueries({
|
||||
queryKey: createOtherRequiredDataQueryKey({did}),
|
||||
exact: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function clearAgeAssuranceData() {
|
||||
logger.debug(`clearAgeAssuranceData`)
|
||||
qc.clear()
|
||||
}
|
||||
|
||||
/*
|
||||
* Context
|
||||
*/
|
||||
|
||||
export type AgeAssuranceData = {
|
||||
config: AppBskyAgeassuranceDefs.Config | undefined
|
||||
state: AppBskyAgeassuranceDefs.State | undefined
|
||||
data:
|
||||
| {
|
||||
accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
|
||||
declaredAge: number | undefined
|
||||
birthdate: string | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
|
||||
config: undefined,
|
||||
state: undefined,
|
||||
data: {
|
||||
accountCreatedAt: undefined,
|
||||
declaredAge: undefined,
|
||||
birthdate: undefined,
|
||||
},
|
||||
})
|
||||
export function useAgeAssuranceDataContext() {
|
||||
return useContext(AgeAssuranceDataContext)
|
||||
}
|
||||
export function AgeAssuranceDataProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {data: config} = useConfigQuery()
|
||||
const serverState = useServerStateQuery()
|
||||
const {state, metadata} = serverState.data || {}
|
||||
const {data} = useOtherRequiredDataQuery()
|
||||
const ctx = useMemo(
|
||||
() => ({
|
||||
config,
|
||||
state,
|
||||
data: {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: data?.birthdate
|
||||
? getAge(new Date(data.birthdate))
|
||||
: undefined,
|
||||
birthdate: data?.birthdate,
|
||||
},
|
||||
}),
|
||||
[config, state, data, metadata],
|
||||
)
|
||||
return (
|
||||
<AgeAssuranceDataContext.Provider value={ctx}>
|
||||
{children}
|
||||
</AgeAssuranceDataContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
type AppBskyAgeassuranceGetState,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {type OtherRequiredData} from '#/ageAssurance/data'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {type Geolocation} from '#/geolocation'
|
||||
|
||||
export const enabled = IS_DEV && false
|
||||
|
||||
export const geolocation: Geolocation | undefined = enabled
|
||||
? {
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const deviceGeolocation: Geolocation | undefined = enabled
|
||||
? {
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
regions: [
|
||||
{
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfAccountNewerThan,
|
||||
date: '2025-12-01T00:00:00Z',
|
||||
access: 'none',
|
||||
},
|
||||
{
|
||||
$type: ids.IfAssuredOverAge,
|
||||
age: 18,
|
||||
access: 'full',
|
||||
},
|
||||
{
|
||||
$type: ids.IfAssuredOverAge,
|
||||
age: 16,
|
||||
access: 'safe',
|
||||
},
|
||||
{
|
||||
$type: ids.IfDeclaredUnderAge,
|
||||
age: 16,
|
||||
access: 'none',
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: 'safe',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const otherRequiredData: OtherRequiredData = {
|
||||
birthdate: new Date(2000, 1, 1).toISOString(),
|
||||
}
|
||||
|
||||
const serverStateEnabled = false
|
||||
export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
|
||||
serverStateEnabled
|
||||
? {
|
||||
state: {
|
||||
lastInitiatedAt: new Date(2023, 5, 1).toISOString(),
|
||||
status: 'assured',
|
||||
access: 'safe',
|
||||
},
|
||||
metadata: {
|
||||
accountCreatedAt: new Date(2023, 11, 1).toISOString(),
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
export async function resolve<T>(data: T) {
|
||||
await new Promise(y => setTimeout(y, 2000)) // simulate network
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||
|
||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {AgeAssuranceDataProvider} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {
|
||||
useAgeAssuranceState,
|
||||
useOnAgeAssuranceAccessUpdate,
|
||||
} from '#/ageAssurance/state'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceState,
|
||||
AgeAssuranceStatus,
|
||||
} from '#/ageAssurance/types'
|
||||
|
||||
export {
|
||||
prefetchConfig as prefetchAgeAssuranceConfig,
|
||||
prefetchAgeAssuranceData,
|
||||
refetchServerState as refetchAgeAssuranceServerState,
|
||||
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
|
||||
usePatchServerState as usePatchAgeAssuranceServerState,
|
||||
} from '#/ageAssurance/data'
|
||||
export {logger} from '#/ageAssurance/logger'
|
||||
|
||||
const AgeAssuranceStateContext = createContext<{
|
||||
Access: typeof AgeAssuranceAccess
|
||||
Status: typeof AgeAssuranceStatus
|
||||
state: AgeAssuranceState
|
||||
}>({
|
||||
Access: AgeAssuranceAccess,
|
||||
Status: AgeAssuranceStatus,
|
||||
state: {
|
||||
lastInitiatedAt: undefined,
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* THE MAIN AGE ASSURANCE CONTEXT HOOK
|
||||
*
|
||||
* Prefer this to using any of the lower-level data-provider hooks.
|
||||
*/
|
||||
export function useAgeAssurance() {
|
||||
return useContext(AgeAssuranceStateContext)
|
||||
}
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<AgeAssuranceDataProvider>
|
||||
<InnerProvider>
|
||||
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
|
||||
</InnerProvider>
|
||||
</AgeAssuranceDataProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
const state = useAgeAssuranceState()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
|
||||
const handleAccessUpdate = useCallback(
|
||||
(s: AgeAssuranceState) => {
|
||||
getAndRegisterPushToken({
|
||||
isAgeRestricted: s.access !== AgeAssuranceAccess.Full,
|
||||
})
|
||||
},
|
||||
[getAndRegisterPushToken],
|
||||
)
|
||||
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug(`useAgeAssuranceState`, {state})
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<AgeAssuranceStateContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
Access: AgeAssuranceAccess,
|
||||
Status: AgeAssuranceStatus,
|
||||
state,
|
||||
}),
|
||||
[state],
|
||||
)}>
|
||||
{children}
|
||||
</AgeAssuranceStateContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {Logger} from '#/logger'
|
||||
|
||||
export const logger = Logger.create(Logger.Context.AgeAssurance)
|
||||
@@ -0,0 +1,100 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceState,
|
||||
AgeAssuranceStatus,
|
||||
parseAccessFromString,
|
||||
parseStatusFromString,
|
||||
} from '#/ageAssurance/types'
|
||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
|
||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
const {hasSession} = useSession()
|
||||
const geolocation = useGeolocation()
|
||||
const {config, state, data} = useAgeAssuranceDataContext()
|
||||
|
||||
return useMemo(() => {
|
||||
/**
|
||||
* This is where we control logged-out moderation prefs. It's all
|
||||
* downstream of AA now.
|
||||
*/
|
||||
if (!hasSession)
|
||||
return {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Safe,
|
||||
}
|
||||
|
||||
// should never happen, but need to guard
|
||||
if (!config) {
|
||||
logger.warn('useAgeAssuranceState: missing config')
|
||||
return {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
|
||||
const isAARequired = region.countryCode !== '*'
|
||||
const isTerminalState =
|
||||
state?.status === 'assured' || state?.status === 'blocked'
|
||||
|
||||
/*
|
||||
* If we are in a terminal state and AA is required for this region,
|
||||
* we can trust the server state completely and avoid recomputing.
|
||||
*/
|
||||
if (isTerminalState && isAARequired) {
|
||||
return {
|
||||
lastInitiatedAt: state.lastInitiatedAt,
|
||||
status: parseStatusFromString(state.status),
|
||||
access: parseAccessFromString(state.access),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Otherwise, we need to compute the access based on the latest data. For
|
||||
* accounts with an accurate birthdate, our default fallback rules should
|
||||
* ensure correct access.
|
||||
*/
|
||||
const result = computeAgeAssuranceRegionAccess(region, data)
|
||||
const computed = {
|
||||
lastInitiatedAt: state?.lastInitiatedAt,
|
||||
// prefer server state
|
||||
status: state?.status
|
||||
? parseStatusFromString(state?.status)
|
||||
: AgeAssuranceStatus.Unknown,
|
||||
// prefer server state
|
||||
access: result
|
||||
? parseAccessFromString(result.access)
|
||||
: AgeAssuranceAccess.Full,
|
||||
}
|
||||
logger.debug('debug useAgeAssuranceState', {
|
||||
region,
|
||||
state,
|
||||
data,
|
||||
computed,
|
||||
})
|
||||
return computed
|
||||
}, [hasSession, geolocation, config, state, data])
|
||||
}
|
||||
|
||||
export function useOnAgeAssuranceAccessUpdate(
|
||||
cb: (state: AgeAssuranceState) => void,
|
||||
) {
|
||||
const state = useAgeAssuranceState()
|
||||
// start with null to ensure callback is called on first render
|
||||
const [prevAccess, setPrevAccess] = useState<AgeAssuranceAccess | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (prevAccess !== state.access) {
|
||||
setPrevAccess(state.access)
|
||||
cb(state)
|
||||
logger.debug(`useOnAgeAssuranceAccessUpdate`, {state})
|
||||
}
|
||||
}, [cb, state, prevAccess])
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
|
||||
export enum AgeAssuranceAccess {
|
||||
Unknown = 'unknown',
|
||||
None = 'none',
|
||||
Safe = 'safe',
|
||||
Full = 'full',
|
||||
}
|
||||
|
||||
export enum AgeAssuranceStatus {
|
||||
Unknown = 'unknown',
|
||||
Pending = 'pending',
|
||||
Assured = 'assured',
|
||||
Blocked = 'blocked',
|
||||
}
|
||||
|
||||
export type AgeAssuranceState = {
|
||||
lastInitiatedAt?: string
|
||||
status: AgeAssuranceStatus
|
||||
access: AgeAssuranceAccess
|
||||
}
|
||||
|
||||
export function parseStatusFromString(raw: string) {
|
||||
switch (raw) {
|
||||
case 'unknown':
|
||||
return AgeAssuranceStatus.Unknown
|
||||
case 'pending':
|
||||
return AgeAssuranceStatus.Pending
|
||||
case 'assured':
|
||||
return AgeAssuranceStatus.Assured
|
||||
case 'blocked':
|
||||
return AgeAssuranceStatus.Blocked
|
||||
default:
|
||||
logger.error(`parseStatusFromString: unknown status value: ${raw}`)
|
||||
return AgeAssuranceStatus.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAccessFromString(raw: string) {
|
||||
switch (raw) {
|
||||
case 'unknown':
|
||||
return AgeAssuranceAccess.Unknown
|
||||
case 'none':
|
||||
return AgeAssuranceAccess.None
|
||||
case 'safe':
|
||||
return AgeAssuranceAccess.Safe
|
||||
case 'full':
|
||||
return AgeAssuranceAccess.Full
|
||||
default:
|
||||
logger.error(`parseAccessFromString: unknown access value: ${raw}`)
|
||||
return AgeAssuranceAccess.Full
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {wait} from '#/lib/async/wait'
|
||||
import {
|
||||
DEV_ENV_APPVIEW,
|
||||
PUBLIC_APPVIEW,
|
||||
PUBLIC_APPVIEW_DID,
|
||||
} from '#/lib/constants'
|
||||
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
|
||||
import {BLUESKY_PROXY_DID} from '#/env'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
|
||||
const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
|
||||
const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
|
||||
|
||||
export function useBeginAgeAssurance() {
|
||||
const agent = useAgent()
|
||||
const geolocation = useGeolocation()
|
||||
const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState()
|
||||
|
||||
return useMutation({
|
||||
async mutationFn(
|
||||
props: Omit<
|
||||
AppBskyAgeassuranceBegin.InputSchema,
|
||||
'countryCode' | 'regionCode'
|
||||
>,
|
||||
) {
|
||||
const countryCode = geolocation?.countryCode
|
||||
const regionCode = geolocation?.regionCode
|
||||
if (!countryCode) {
|
||||
throw new Error(`Geolocation not available, cannot init age assurance.`)
|
||||
}
|
||||
|
||||
const {
|
||||
data: {token},
|
||||
} = await agent.com.atproto.server.getServiceAuth({
|
||||
aud: BLUESKY_PROXY_DID,
|
||||
lxm: `app.bsky.ageassurance.begin`,
|
||||
})
|
||||
|
||||
const appView = new AtpAgent({service: APPVIEW})
|
||||
appView.sessionManager.session = {...agent.session!}
|
||||
appView.sessionManager.session.accessJwt = token
|
||||
appView.sessionManager.session.refreshJwt = ''
|
||||
|
||||
/*
|
||||
* 2s wait is good actually. Email sending takes a hot sec and this helps
|
||||
* ensure the email is ready for the user once they open their inbox.
|
||||
*/
|
||||
const {data} = await wait(
|
||||
2e3,
|
||||
appView.app.bsky.ageassurance.begin({
|
||||
...props,
|
||||
countryCode: countryCode.toUpperCase(),
|
||||
regionCode: regionCode ? regionCode.toUpperCase() : undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
// Just keeps this in sync, not necessarily used right now
|
||||
patchAgeAssuranceStateResponse(data)
|
||||
},
|
||||
onError(e) {
|
||||
if (!isNetworkError(e)) {
|
||||
logger.error(`useBeginAgeAssurance failed`, {
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {useCallback} from 'react'
|
||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
|
||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||
import {type Geolocation} from '#/geolocation'
|
||||
|
||||
export function useComputeAgeAssuranceRegionAccess() {
|
||||
const {config, data} = useAgeAssuranceDataContext()
|
||||
return useCallback(
|
||||
(geolocation: Geolocation) => {
|
||||
if (!config) {
|
||||
logger.warn('useComputeAgeAssuranceRegionAccess: missing config')
|
||||
return AgeAssuranceAccess.Unknown
|
||||
}
|
||||
const region = getAgeAssuranceRegionConfigWithFallback(
|
||||
config,
|
||||
geolocation,
|
||||
)
|
||||
const result = computeAgeAssuranceRegionAccess(region, data)
|
||||
return result
|
||||
? parseAccessFromString(result.access)
|
||||
: AgeAssuranceAccess.Full
|
||||
},
|
||||
[config, data],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {useMemo} from 'react'
|
||||
import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
getAgeAssuranceRegionConfig,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
|
||||
const DEFAULT_MIN_AGE = 13
|
||||
|
||||
/**
|
||||
* Get age assurance region config based on geolocation, with fallback to
|
||||
* app defaults if no region config is found.
|
||||
*
|
||||
* See {@link getAgeAssuranceRegionConfig} for the generic option, which can
|
||||
* return undefined if the geolocation does not match any AA region.
|
||||
*/
|
||||
export function getAgeAssuranceRegionConfigWithFallback(
|
||||
config: AppBskyAgeassuranceDefs.Config,
|
||||
geolocation: Geolocation,
|
||||
): AppBskyAgeassuranceDefs.ConfigRegion {
|
||||
const region = getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
|
||||
return (
|
||||
region || {
|
||||
countryCode: '*',
|
||||
regionCode: undefined,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
age: DEFAULT_MIN_AGE,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: AgeAssuranceAccess.None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the age assurance region config based on current geolocation.
|
||||
* Does not fall-back to our app defaults. If no config is found, returns
|
||||
* undefined, which indicates no regional age assurance rules apply.
|
||||
*/
|
||||
export function useAgeAssuranceRegionConfig() {
|
||||
const geolocation = useGeolocation()
|
||||
const {config} = useAgeAssuranceDataContext()
|
||||
return useMemo(() => {
|
||||
if (!config) return
|
||||
// use generic helper, we want to potentially return undefined
|
||||
return getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
}, [config, geolocation])
|
||||
}
|
||||
|
||||
/**
|
||||
* Some users may have erroneously set their birth date to the current date
|
||||
* if one wasn't set on their account. We previously didn't do validation on
|
||||
* the bday dialog, and it defaulted to the current date. This bug _has_ been
|
||||
* seen in production, so we need to check for it where possible.
|
||||
*/
|
||||
export function isLegacyBirthdateBug(birthDate: string) {
|
||||
return ['2025', '2024', '2023'].includes((birthDate || '').slice(0, 4))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the user is under the minimum age required to use the app.
|
||||
* This applies to all regions.
|
||||
*/
|
||||
export function isUserUnderMinimumAge(birthDate: string) {
|
||||
return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE
|
||||
}
|
||||
Reference in New Issue
Block a user