APP-2997: Handle age assurance data load failures (#11612)
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {Error} from '#/components/Error'
|
||||
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from '#/components/icons/Emoji'
|
||||
import {useOtherRequiredDataQuery} from '#/ageAssurance/data'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
export function DataUnavailableScreen() {
|
||||
const {t: l} = useLingui()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const {isFetching, refetch} = useOtherRequiredDataQuery()
|
||||
|
||||
return (
|
||||
<Error
|
||||
icon={EmojiSadIcon}
|
||||
title={l`Unable to load your account`}
|
||||
message={l`We couldn't load your account settings. Check your internet connection and try again.`}
|
||||
onRetry={() => void refetch()}
|
||||
isRetrying={isFetching}
|
||||
secondaryAction={{
|
||||
label: l`Sign out`,
|
||||
onPress: () => {
|
||||
if (IS_WEB) history.pushState(null, '', '/')
|
||||
logoutCurrentAccount('AgeAssuranceDataUnavailableScreen')
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ 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 {isRetryableRequestError, networkRetry} from '#/lib/async/retry'
|
||||
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {
|
||||
@@ -347,9 +347,15 @@ export type OtherRequiredData = {
|
||||
birthdate: string | undefined
|
||||
actorDeclaration?: chat.bsky.actor.declaration.Main
|
||||
}
|
||||
export type OtherRequiredDataStatus = 'pending' | 'error' | 'success'
|
||||
const otherRequiredDataRetryOptions = {
|
||||
retry: (failureCount: number, error: unknown) =>
|
||||
failureCount < 2 && isRetryableRequestError(error),
|
||||
}
|
||||
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
||||
return ['otherRequiredData', did]
|
||||
}
|
||||
|
||||
async function getOtherRequiredData({
|
||||
accountClient,
|
||||
}: {
|
||||
@@ -455,10 +461,11 @@ export async function prefetchOtherRequiredData({
|
||||
|
||||
try {
|
||||
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
||||
const res = await networkRetry(3, () =>
|
||||
getOtherRequiredData({accountClient}),
|
||||
)
|
||||
qc.setQueryData<OtherRequiredData>(qk, res)
|
||||
await qc.fetchQuery({
|
||||
...otherRequiredDataRetryOptions,
|
||||
queryKey: qk,
|
||||
queryFn: () => getOtherRequiredData({accountClient}),
|
||||
})
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
logger.warn(`prefetchOtherRequiredData: failed`, {
|
||||
@@ -490,12 +497,14 @@ export function useOtherRequiredDataQuery() {
|
||||
const did = accountClient.did
|
||||
return useQuery(
|
||||
{
|
||||
...otherRequiredDataRetryOptions,
|
||||
enabled: !!did,
|
||||
initialData: () => {
|
||||
if (!did) return
|
||||
return getOtherRequiredDataFromCache({did})
|
||||
},
|
||||
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
||||
retryOnMount: false,
|
||||
async queryFn() {
|
||||
return getOtherRequiredData({accountClient})
|
||||
},
|
||||
@@ -722,6 +731,11 @@ export type AgeAssuranceServerData = {
|
||||
*/
|
||||
state: app.bsky.ageassurance.defs.State | undefined
|
||||
metadata: AgeAssuranceMetadata | undefined
|
||||
/**
|
||||
* Whether the account data needed to compute age assurance is available.
|
||||
* A successful response without a birthdate is still `success`.
|
||||
*/
|
||||
otherRequiredDataStatus: OtherRequiredDataStatus
|
||||
/**
|
||||
* The native on-device age signals for the region the user is currently in,
|
||||
* if they've granted access there. Already resolved from the region-keyed
|
||||
@@ -739,6 +753,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||
declaredAge: undefined,
|
||||
birthdate: undefined,
|
||||
},
|
||||
otherRequiredDataStatus: 'pending',
|
||||
deviceSignals: undefined,
|
||||
})
|
||||
export function useAgeAssuranceServerDataContext() {
|
||||
@@ -752,7 +767,18 @@ export function AgeAssuranceServerDataProvider({
|
||||
const {data: config} = useConfigQuery()
|
||||
const serverState = useServerStateQuery()
|
||||
const {state, metadata} = serverState.data || {}
|
||||
const {data} = useOtherRequiredDataQuery()
|
||||
const {data, errorUpdatedAt, status} = useOtherRequiredDataQuery()
|
||||
/*
|
||||
* A data-less query returns to `pending` and clears `error` while refetching,
|
||||
* but retains `errorUpdatedAt`. Keep the error screen mounted until data
|
||||
* loads successfully.
|
||||
*/
|
||||
const otherRequiredDataStatus: OtherRequiredDataStatus =
|
||||
data !== undefined
|
||||
? 'success'
|
||||
: status === 'error' || errorUpdatedAt > 0
|
||||
? 'error'
|
||||
: 'pending'
|
||||
// `select` resolves the cached region-keyed map to the current region.
|
||||
const {data: deviceSignals} = useDeviceSignalsQuery()
|
||||
const ctx = useMemo(
|
||||
@@ -767,9 +793,10 @@ export function AgeAssuranceServerDataProvider({
|
||||
: undefined,
|
||||
birthdate: data?.birthdate,
|
||||
},
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
}),
|
||||
[config, state, data, metadata, deviceSignals],
|
||||
[config, state, data, metadata, otherRequiredDataStatus, deviceSignals],
|
||||
)
|
||||
return (
|
||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {computeAgeAssuranceState} from '#/ageAssurance/state'
|
||||
import {AgeAssuranceAccess, AgeAssuranceStatus} from '#/ageAssurance/types'
|
||||
|
||||
jest.mock('#/ageAssurance/data', () => ({}))
|
||||
jest.mock('#/ageAssurance/logger', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}))
|
||||
jest.mock('#/state/session', () => ({}))
|
||||
|
||||
const geolocation = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
}
|
||||
|
||||
describe('computeAgeAssuranceState', () => {
|
||||
it('computes access while required account data is pending', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation,
|
||||
config: {regions: []},
|
||||
otherRequiredDataStatus: 'pending',
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
})
|
||||
})
|
||||
|
||||
it('denies access when required account data fails', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation,
|
||||
config: {regions: []},
|
||||
otherRequiredDataStatus: 'error',
|
||||
}),
|
||||
).toEqual({
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
error: 'account-data',
|
||||
})
|
||||
})
|
||||
|
||||
it('computes access after a successful response without a birthdate', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation,
|
||||
config: {regions: []},
|
||||
metadata: {birthdate: undefined},
|
||||
otherRequiredDataStatus: 'success',
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves authoritative terminal server state without account data', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation: {countryCode: 'AA', regionCode: undefined},
|
||||
config: {
|
||||
regions: [
|
||||
{
|
||||
countryCode: 'AA',
|
||||
minAccessAge: 13,
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
state: {status: 'blocked', access: 'none'},
|
||||
otherRequiredDataStatus: 'error',
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: AgeAssuranceStatus.Blocked,
|
||||
access: AgeAssuranceAccess.None,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getDeviceSignalsFromCacheForRegion,
|
||||
getOtherRequiredDataFromCache,
|
||||
getServerStateFromCache,
|
||||
type OtherRequiredDataStatus,
|
||||
useAgeAssuranceServerDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
@@ -35,12 +36,13 @@ import {device} from '#/storage'
|
||||
* server state before computing access based on AA config from the server +
|
||||
* geolocation and other data.
|
||||
*/
|
||||
function computeAgeAssuranceState({
|
||||
export function computeAgeAssuranceState({
|
||||
hasSession,
|
||||
geolocation,
|
||||
config,
|
||||
state,
|
||||
metadata,
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
}: {
|
||||
hasSession: boolean
|
||||
@@ -48,6 +50,7 @@ function computeAgeAssuranceState({
|
||||
config?: app.bsky.ageassurance.defs.Config
|
||||
state?: app.bsky.ageassurance.defs.State
|
||||
metadata?: AgeAssuranceMetadata
|
||||
otherRequiredDataStatus: OtherRequiredDataStatus
|
||||
deviceSignals?: AgeRange.AgeRangeResponse
|
||||
}) {
|
||||
/**
|
||||
@@ -93,6 +96,14 @@ function computeAgeAssuranceState({
|
||||
}
|
||||
}
|
||||
|
||||
if (otherRequiredDataStatus === 'error') {
|
||||
return {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
error: 'account-data' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Otherwise, we need to compute the access based on the latest data. For
|
||||
* accounts with an accurate birthdate, our default fallback rules should
|
||||
@@ -177,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
geolocation,
|
||||
state: state.state,
|
||||
metadata,
|
||||
otherRequiredDataStatus: 'success',
|
||||
deviceSignals,
|
||||
})
|
||||
|
||||
@@ -194,7 +206,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
const {hasSession} = useSession()
|
||||
const geolocation = useGeolocation()
|
||||
const {config, state, metadata, deviceSignals} =
|
||||
const {config, state, metadata, otherRequiredDataStatus, deviceSignals} =
|
||||
useAgeAssuranceServerDataContext()
|
||||
|
||||
return useMemo(
|
||||
@@ -205,9 +217,18 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
geolocation,
|
||||
state,
|
||||
metadata,
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
}),
|
||||
[hasSession, geolocation, config, state, metadata, deviceSignals],
|
||||
[
|
||||
hasSession,
|
||||
geolocation,
|
||||
config,
|
||||
state,
|
||||
metadata,
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ export type AgeAssuranceState = {
|
||||
lastInitiatedAt?: string
|
||||
status: AgeAssuranceStatus
|
||||
access: AgeAssuranceAccess
|
||||
error?: 'config' // maybe other specific cases in the future
|
||||
isLoading?: boolean
|
||||
error?: 'config' | 'account-data'
|
||||
}
|
||||
|
||||
export type AgeAssuranceFlags = {
|
||||
|
||||
Reference in New Issue
Block a user