diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index 12d1728730..b4b183d630 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -1,14 +1,13 @@ -import {useCallback, useEffect, useState} from 'react' +import {useCallback, useEffect} from 'react' import {ScrollView, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import type * as AgeRange from 'expo-age-range' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' -import {useSession, useSessionApi} from '#/state/session' +import {useSessionApi} from '#/state/session' import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog' import {DeleteAccountDialog} from '#/screens/Settings/components/DeleteAccountDialog' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' @@ -30,18 +29,12 @@ import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {BottomSheetOutlet} from '#/../modules/bottom-sheet' import {useAgeAssurance} from '#/ageAssurance' -import { - getDeviceSignals, - setDeviceSignalsForRegion, - useAgeAssuranceServerDataContext, -} from '#/ageAssurance/data' -import {logger} from '#/ageAssurance/logger' -import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' +import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' +import {useAgeAssuranceVerificationFlow} from '#/ageAssurance/useVerificationFlow' import { canBirthdateUpdateIncreaseAccess, createGeolocationString, - getAgeAssuranceDataFromDeviceSignals, isLegacyBirthdateBug, useAgeAssuranceRegionConfig, } from '#/ageAssurance/util' @@ -380,8 +373,6 @@ function AccessSection() { const control = useDialogControl() const appealControl = Dialog.useDialogControl() const getTimeAgo = useGetTimeAgo() - const {currentAccount} = useSession() - const region = useAgeAssuranceRegionConfig() const aa = useAgeAssurance() const {status, lastInitiatedAt} = aa.state @@ -393,108 +384,13 @@ function AccessSection() { const diff = lastInitiatedAt ? dateDiff(lastInitiatedAt, new Date(), 'down') : null - const allowsDeviceVerification = region && aa.flags.allowsDeviceVerification - const verifyCta = allowsDeviceVerification - ? _(msg`Share age data`) - : hasInitiated - ? _(msg`Verify again`) - : _(msg`Verify now`) - - const [isVerifyingDevice, setIsVerifyingDevice] = useState(false) - - const openKwsDialog = useCallback(() => { - control.open() - ax.metric('ageAssurance:initDialogOpen', { - hasInitiatedPreviously: hasInitiated, - }) - }, [control, ax, hasInitiated]) - - const onPressVerify = useCallback(async () => { - const did = currentAccount?.did - - // Just for typescript, this screen won't be shown without a logged in user - if (!did) return - - /* - * In regions that permit on-device verification, try the native age API - * first. We tag the result with the current region (device assurance is - * region-bound — a TX grant only counts in TX) and, if it's sufficient, - * persist it client-side so the AA state recompute lifts the gate. - * - * Once the OS returns a response we stay on the device path and report the - * outcome via a toast (sufficient, under-age, or no usable data) rather than - * silently falling back — users can still opt into KWS via the inline link. - * We only fall through to the KWS dialog below when the device can't give us - * a response at all: `getDeviceSignals` handles its own errors and returns - * undefined (e.g. on web or failure), and there's nothing to act on without - * a session DID. - */ - if (allowsDeviceVerification) { - // Show a loading state while the OS age prompt is up. - setIsVerifyingDevice(true) - let signals: AgeRange.AgeRangeResponse | undefined - try { - signals = await getDeviceSignals() - } finally { - setIsVerifyingDevice(false) - } - if (signals) { - const {assuredAge} = getAgeAssuranceDataFromDeviceSignals( - region, - signals, - ) - if (assuredAge !== undefined) { - // Persist (keyed by this region) so the AA state recomputes from the - // cache write. Recompute here too so we can react to the outcome: a - // sufficient age lifts the gate (nothing more to do), but the device - // may report an age below the region's threshold, in which case - // access stays `none` and we tell the user. - setDeviceSignalsForRegion({did, region, signals}) - const {state} = unsafeGetAndComputeAgeAssurance({did}) - if (state.access === aa.Access.None) { - Toast.show( - _( - msg`We're sorry, but based on the data shared by your device, you are not old enough to access Bluesky.`, - ), - {type: 'info'}, - ) - } else if (state.access === aa.Access.Unknown) { - Toast.show( - _( - msg`Hmm, it seems we weren't able to compute your level of access. Please try again.`, - ), - {type: 'warning'}, - ) - } else { - Toast.show(_(msg`Thanks! You're all set.`), { - type: 'success', - }) - } - return - } - // We got a device response but it carried no usable age information. - Toast.show( - _( - msg`Hmm, it seems your device was unable to share age information with us.`, - ), - {type: 'warning'}, - ) - return - } - logger.debug( - `onPressVerify: no device signals available (web/error/no DID), falling back to KWS`, - ) - } - - openKwsDialog() - }, [ - region, - currentAccount?.did, + const { + onPressVerify, openKwsDialog, + isVerifyingDevice, allowsDeviceVerification, - aa, - _, - ]) + verifyCta, + } = useAgeAssuranceVerificationFlow({initDialogControl: control}) return ( <> diff --git a/src/ageAssurance/useVerificationFlow.ts b/src/ageAssurance/useVerificationFlow.ts new file mode 100644 index 0000000000..04473db710 --- /dev/null +++ b/src/ageAssurance/useVerificationFlow.ts @@ -0,0 +1,160 @@ +import {useCallback, useState} from 'react' +import type * as AgeRange from 'expo-age-range' +import {useLingui} from '@lingui/react/macro' + +import {useSession} from '#/state/session' +import {type DialogControlProps} from '#/components/Dialog' +import * as Toast from '#/components/Toast' +import {useAgeAssurance} from '#/ageAssurance' +import {getDeviceSignals, setDeviceSignalsForRegion} from '#/ageAssurance/data' +import {logger} from '#/ageAssurance/logger' +import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' +import { + getAgeAssuranceDataFromDeviceSignals, + useAgeAssuranceRegionConfig, +} from '#/ageAssurance/util' +import {useAnalytics} from '#/analytics' + +/** + * Shared "Verify now" flow for the age assurance surfaces (the no-access screen + * and the account settings card). Handles the device-first verification path + * with a KWS fallback, keeping the two entry points in sync. + * + * The caller owns the KWS dialog (`AgeAssuranceInitDialog`) and passes its + * `initDialogControl` here so we can open it for the fallback / opt-in cases. + * + * Returns: + * - `onPressVerify`: the button handler. In device-verification regions it + * prompts the OS age API, persists a sufficient result (region-bound), and + * toasts the outcome; otherwise (or on any missing/failed device response) it + * opens the KWS dialog. + * - `openKwsDialog`: opens the KWS dialog directly (for the inline "use KWS" + * link), emitting the same metric as the fallback path. + * - `isVerifyingDevice`: true while the OS age prompt is up, for a button + * loading state. + * - `allowsDeviceVerification`: whether this region + device supports the + * on-device flow, for CTA/copy branching. + * - `verifyCta`: the localized button label for the current mode. + */ +export function useAgeAssuranceVerificationFlow({ + initDialogControl, +}: { + initDialogControl: DialogControlProps +}) { + const {t: l} = useLingui() + const ax = useAnalytics() + const {currentAccount} = useSession() + const region = useAgeAssuranceRegionConfig() + const aa = useAgeAssurance() + const hasInitiated = !!aa.state.lastInitiatedAt + const allowsDeviceVerification = region && aa.flags.allowsDeviceVerification + const verifyCta = allowsDeviceVerification + ? l`Share age data` + : hasInitiated + ? l`Verify again` + : l`Verify now` + + const [isVerifyingDevice, setIsVerifyingDevice] = useState(false) + + const openKwsDialog = useCallback(() => { + initDialogControl.open() + ax.metric('ageAssurance:initDialogOpen', { + hasInitiatedPreviously: hasInitiated, + }) + }, [initDialogControl, ax, hasInitiated]) + + const onPressVerify = useCallback(async () => { + const did = currentAccount?.did + + // Just for typescript, these surfaces aren't shown without a logged in user + if (!did) return + + /* + * In regions that permit on-device verification, try the native age API + * first. We tag the result with the current region (device assurance is + * region-bound — a TX grant only counts in TX) and, if it's sufficient, + * persist it client-side so the AA state recompute lifts the gate. + * + * Once the OS returns a response we stay on the device path and report the + * outcome via a toast (sufficient, under-age, or no usable data) rather than + * silently falling back — users can still opt into KWS via the inline link. + * We only fall through to the KWS dialog below when the device can't give us + * a response at all: `getDeviceSignals` handles its own errors and returns + * undefined (e.g. on web or failure). + */ + if (allowsDeviceVerification) { + // Show a loading state while the OS age prompt is up. + setIsVerifyingDevice(true) + let signals: AgeRange.AgeRangeResponse | undefined + try { + signals = await getDeviceSignals() + } finally { + setIsVerifyingDevice(false) + } + if (signals) { + const {assuredAge} = getAgeAssuranceDataFromDeviceSignals( + region, + signals, + ) + if (assuredAge !== undefined) { + // Persist (keyed by this region) so the AA state recomputes from the + // cache write. Recompute here too so we can react to the outcome: a + // sufficient age lifts the gate (nothing more to do), but the device + // may report an age below the region's threshold, in which case + // access stays `none` and we tell the user. + setDeviceSignalsForRegion({did, region, signals}) + const {state} = unsafeGetAndComputeAgeAssurance({did}) + if (state.access === aa.Access.None) { + Toast.show( + l`We're sorry, but based on the data shared by your device, you are not old enough to access Bluesky.`, + {type: 'info'}, + ) + } else if (state.access === aa.Access.Unknown) { + Toast.show( + l`Hmm, it seems we weren't able to compute your level of access. Please try again.`, + {type: 'warning'}, + ) + } else if (state.access === aa.Access.Safe) { + Toast.show( + l`You're all set! However, certain features may still be restricted until you're able to verify you're an adult.`, + { + type: 'success', + }, + ) + } else { + Toast.show(l`You're all set!`, { + type: 'success', + }) + } + return + } + // We got a device response but it carried no usable age information. + Toast.show( + l`Hmm, it seems your device was unable to share age information with us.`, + {type: 'warning'}, + ) + return + } + logger.debug( + `onPressVerify: no device signals available (web/error), falling back to KWS`, + ) + } + + openKwsDialog() + }, [ + region, + currentAccount?.did, + openKwsDialog, + allowsDeviceVerification, + aa, + l, + ]) + + return { + onPressVerify, + openKwsDialog, + isVerifyingDevice, + allowsDeviceVerification, + verifyCta, + } +} diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx index f54fdc371f..52bdf0c4d3 100644 --- a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx +++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx @@ -1,10 +1,7 @@ -import {useCallback, useState} from 'react' import {View} from 'react-native' -import type * as AgeRange from 'expo-age-range' import {Trans, useLingui} from '@lingui/react/macro' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' -import {useSession} from '#/state/session' import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' import {Admonition} from '#/components/Admonition' import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' @@ -25,15 +22,9 @@ import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' -import {getDeviceSignals, setDeviceSignalsForRegion} from '#/ageAssurance/data' -import {logger} from '#/ageAssurance/logger' -import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' -import { - createGeolocationString, - getAgeAssuranceDataFromDeviceSignals, - useAgeAssuranceRegionConfig, -} from '#/ageAssurance/util' +import {useAgeAssuranceVerificationFlow} from '#/ageAssurance/useVerificationFlow' +import {createGeolocationString} from '#/ageAssurance/util' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import {useDeviceGeolocationApi, useGeolocation} from '#/geolocation' @@ -59,8 +50,6 @@ function Inner({style}: ViewStyleProp & {}) { const appealControl = Dialog.useDialogControl() const getTimeAgo = useGetTimeAgo() const {gtPhone} = useBreakpoints() - const {currentAccount} = useSession() - const region = useAgeAssuranceRegionConfig() const copy = useAgeAssuranceCopy() const aa = useAgeAssurance() @@ -74,101 +63,13 @@ function Inner({style}: ViewStyleProp & {}) { const diff = lastInitiatedAt ? dateDiff(lastInitiatedAt, new Date(), 'down') : null - const allowsDeviceVerification = region && aa.flags.allowsDeviceVerification - const verifyCta = allowsDeviceVerification - ? l`Share age data` - : hasInitiated - ? l`Verify again` - : l`Verify now` - - const [isVerifyingDevice, setIsVerifyingDevice] = useState(false) - - const openKwsDialog = useCallback(() => { - control.open() - ax.metric('ageAssurance:initDialogOpen', { - hasInitiatedPreviously: hasInitiated, - }) - }, [control, ax, hasInitiated]) - - const onPressVerify = useCallback(async () => { - const did = currentAccount?.did - - // Just for typescript, this card won't be shown without a logged in user - if (!did) return - - /* - * In regions that permit on-device verification, try the native age API - * first. We tag the result with the current region (device assurance is - * region-bound — a TX grant only counts in TX) and, if it's sufficient, - * persist it client-side so the AA state recompute lifts the gate. - * - * Once the OS returns a response we stay on the device path and report the - * outcome via a toast (sufficient, under-age, or no usable data) rather than - * silently falling back — users can still opt into KWS via the inline link. - * We only fall through to the KWS dialog below when the device can't give us - * a response at all: `getDeviceSignals` handles its own errors and returns - * undefined (e.g. on web or failure). - */ - if (allowsDeviceVerification) { - // Show a loading state while the OS age prompt is up. - setIsVerifyingDevice(true) - let signals: AgeRange.AgeRangeResponse | undefined - try { - signals = await getDeviceSignals() - } finally { - setIsVerifyingDevice(false) - } - if (signals) { - const {assuredAge} = getAgeAssuranceDataFromDeviceSignals( - region, - signals, - ) - if (assuredAge !== undefined) { - // Persist (keyed by this region) so the AA state recomputes from the - // cache write. Recompute here too so we can react to the outcome: a - // sufficient age lifts the gate (nothing more to do), but the device - // may report an age below the region's threshold, in which case - // access stays `none` and we tell the user. - setDeviceSignalsForRegion({did, region, signals}) - const {state} = unsafeGetAndComputeAgeAssurance({did}) - if (state.access === aa.Access.None) { - Toast.show( - l`We're sorry, but based on the data shared by your device, you are not old enough to access Bluesky.`, - {type: 'info'}, - ) - } else if (state.access === aa.Access.Unknown) { - Toast.show( - l`Hmm, it seems we weren't able to compute your level of access. Please try again.`, - {type: 'warning'}, - ) - } else { - Toast.show(l`Thanks! You're all set.`, { - type: 'success', - }) - } - return - } - // We got a device response but it carried no usable age information. - Toast.show( - l`Hmm, it seems your device was unable to share age information with us.`, - {type: 'warning'}, - ) - return - } - logger.debug( - `onPressVerify: no device signals available (web/error), falling back to KWS`, - ) - } - - openKwsDialog() - }, [ - region, - currentAccount?.did, + const { + onPressVerify, openKwsDialog, + isVerifyingDevice, allowsDeviceVerification, - aa, - l, - ]) + verifyCta, + } = useAgeAssuranceVerificationFlow({initDialogControl: control}) return ( <>