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 {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||||
import debounce from 'lodash.debounce'
|
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 {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
import {
|
import {
|
||||||
@@ -347,9 +347,15 @@ export type OtherRequiredData = {
|
|||||||
birthdate: string | undefined
|
birthdate: string | undefined
|
||||||
actorDeclaration?: chat.bsky.actor.declaration.Main
|
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}) {
|
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
||||||
return ['otherRequiredData', did]
|
return ['otherRequiredData', did]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOtherRequiredData({
|
async function getOtherRequiredData({
|
||||||
accountClient,
|
accountClient,
|
||||||
}: {
|
}: {
|
||||||
@@ -455,10 +461,11 @@ export async function prefetchOtherRequiredData({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
||||||
const res = await networkRetry(3, () =>
|
await qc.fetchQuery({
|
||||||
getOtherRequiredData({accountClient}),
|
...otherRequiredDataRetryOptions,
|
||||||
)
|
queryKey: qk,
|
||||||
qc.setQueryData<OtherRequiredData>(qk, res)
|
queryFn: () => getOtherRequiredData({accountClient}),
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
logger.warn(`prefetchOtherRequiredData: failed`, {
|
logger.warn(`prefetchOtherRequiredData: failed`, {
|
||||||
@@ -490,12 +497,14 @@ export function useOtherRequiredDataQuery() {
|
|||||||
const did = accountClient.did
|
const did = accountClient.did
|
||||||
return useQuery(
|
return useQuery(
|
||||||
{
|
{
|
||||||
|
...otherRequiredDataRetryOptions,
|
||||||
enabled: !!did,
|
enabled: !!did,
|
||||||
initialData: () => {
|
initialData: () => {
|
||||||
if (!did) return
|
if (!did) return
|
||||||
return getOtherRequiredDataFromCache({did})
|
return getOtherRequiredDataFromCache({did})
|
||||||
},
|
},
|
||||||
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
||||||
|
retryOnMount: false,
|
||||||
async queryFn() {
|
async queryFn() {
|
||||||
return getOtherRequiredData({accountClient})
|
return getOtherRequiredData({accountClient})
|
||||||
},
|
},
|
||||||
@@ -722,6 +731,11 @@ export type AgeAssuranceServerData = {
|
|||||||
*/
|
*/
|
||||||
state: app.bsky.ageassurance.defs.State | undefined
|
state: app.bsky.ageassurance.defs.State | undefined
|
||||||
metadata: AgeAssuranceMetadata | 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,
|
* 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
|
* if they've granted access there. Already resolved from the region-keyed
|
||||||
@@ -739,6 +753,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
|||||||
declaredAge: undefined,
|
declaredAge: undefined,
|
||||||
birthdate: undefined,
|
birthdate: undefined,
|
||||||
},
|
},
|
||||||
|
otherRequiredDataStatus: 'pending',
|
||||||
deviceSignals: undefined,
|
deviceSignals: undefined,
|
||||||
})
|
})
|
||||||
export function useAgeAssuranceServerDataContext() {
|
export function useAgeAssuranceServerDataContext() {
|
||||||
@@ -752,7 +767,18 @@ export function AgeAssuranceServerDataProvider({
|
|||||||
const {data: config} = useConfigQuery()
|
const {data: config} = useConfigQuery()
|
||||||
const serverState = useServerStateQuery()
|
const serverState = useServerStateQuery()
|
||||||
const {state, metadata} = serverState.data || {}
|
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.
|
// `select` resolves the cached region-keyed map to the current region.
|
||||||
const {data: deviceSignals} = useDeviceSignalsQuery()
|
const {data: deviceSignals} = useDeviceSignalsQuery()
|
||||||
const ctx = useMemo(
|
const ctx = useMemo(
|
||||||
@@ -767,9 +793,10 @@ export function AgeAssuranceServerDataProvider({
|
|||||||
: undefined,
|
: undefined,
|
||||||
birthdate: data?.birthdate,
|
birthdate: data?.birthdate,
|
||||||
},
|
},
|
||||||
|
otherRequiredDataStatus,
|
||||||
deviceSignals,
|
deviceSignals,
|
||||||
}),
|
}),
|
||||||
[config, state, data, metadata, deviceSignals],
|
[config, state, data, metadata, otherRequiredDataStatus, deviceSignals],
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
<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,
|
getDeviceSignalsFromCacheForRegion,
|
||||||
getOtherRequiredDataFromCache,
|
getOtherRequiredDataFromCache,
|
||||||
getServerStateFromCache,
|
getServerStateFromCache,
|
||||||
|
type OtherRequiredDataStatus,
|
||||||
useAgeAssuranceServerDataContext,
|
useAgeAssuranceServerDataContext,
|
||||||
} from '#/ageAssurance/data'
|
} from '#/ageAssurance/data'
|
||||||
import {logger} from '#/ageAssurance/logger'
|
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 +
|
* server state before computing access based on AA config from the server +
|
||||||
* geolocation and other data.
|
* geolocation and other data.
|
||||||
*/
|
*/
|
||||||
function computeAgeAssuranceState({
|
export function computeAgeAssuranceState({
|
||||||
hasSession,
|
hasSession,
|
||||||
geolocation,
|
geolocation,
|
||||||
config,
|
config,
|
||||||
state,
|
state,
|
||||||
metadata,
|
metadata,
|
||||||
|
otherRequiredDataStatus,
|
||||||
deviceSignals,
|
deviceSignals,
|
||||||
}: {
|
}: {
|
||||||
hasSession: boolean
|
hasSession: boolean
|
||||||
@@ -48,6 +50,7 @@ function computeAgeAssuranceState({
|
|||||||
config?: app.bsky.ageassurance.defs.Config
|
config?: app.bsky.ageassurance.defs.Config
|
||||||
state?: app.bsky.ageassurance.defs.State
|
state?: app.bsky.ageassurance.defs.State
|
||||||
metadata?: AgeAssuranceMetadata
|
metadata?: AgeAssuranceMetadata
|
||||||
|
otherRequiredDataStatus: OtherRequiredDataStatus
|
||||||
deviceSignals?: AgeRange.AgeRangeResponse
|
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
|
* Otherwise, we need to compute the access based on the latest data. For
|
||||||
* accounts with an accurate birthdate, our default fallback rules should
|
* accounts with an accurate birthdate, our default fallback rules should
|
||||||
@@ -177,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
|||||||
geolocation,
|
geolocation,
|
||||||
state: state.state,
|
state: state.state,
|
||||||
metadata,
|
metadata,
|
||||||
|
otherRequiredDataStatus: 'success',
|
||||||
deviceSignals,
|
deviceSignals,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -194,7 +206,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
|||||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
const geolocation = useGeolocation()
|
const geolocation = useGeolocation()
|
||||||
const {config, state, metadata, deviceSignals} =
|
const {config, state, metadata, otherRequiredDataStatus, deviceSignals} =
|
||||||
useAgeAssuranceServerDataContext()
|
useAgeAssuranceServerDataContext()
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
@@ -205,9 +217,18 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
|||||||
geolocation,
|
geolocation,
|
||||||
state,
|
state,
|
||||||
metadata,
|
metadata,
|
||||||
|
otherRequiredDataStatus,
|
||||||
deviceSignals,
|
deviceSignals,
|
||||||
}),
|
}),
|
||||||
[hasSession, geolocation, config, state, metadata, deviceSignals],
|
[
|
||||||
|
hasSession,
|
||||||
|
geolocation,
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
metadata,
|
||||||
|
otherRequiredDataStatus,
|
||||||
|
deviceSignals,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export type AgeAssuranceState = {
|
|||||||
lastInitiatedAt?: string
|
lastInitiatedAt?: string
|
||||||
status: AgeAssuranceStatus
|
status: AgeAssuranceStatus
|
||||||
access: AgeAssuranceAccess
|
access: AgeAssuranceAccess
|
||||||
error?: 'config' // maybe other specific cases in the future
|
isLoading?: boolean
|
||||||
|
error?: 'config' | 'account-data'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgeAssuranceFlags = {
|
export type AgeAssuranceFlags = {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type Events = {
|
|||||||
| 'SignupQueued'
|
| 'SignupQueued'
|
||||||
| 'Deactivated'
|
| 'Deactivated'
|
||||||
| 'Takendown'
|
| 'Takendown'
|
||||||
|
| 'AgeAssuranceDataUnavailableScreen'
|
||||||
| 'AgeAssuranceNoAccessScreen'
|
| 'AgeAssuranceNoAccessScreen'
|
||||||
scope: 'current' | 'every'
|
scope: 'current' | 'every'
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-10
@@ -3,27 +3,38 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {useGoBack} from '#/lib/hooks/useGoBack'
|
import {useGoBack} from '#/lib/hooks/useGoBack'
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function Error({
|
export function Error({
|
||||||
|
icon: Icon,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
onRetry,
|
onRetry,
|
||||||
onGoBack,
|
onGoBack,
|
||||||
hideBackButton,
|
hideBackButton,
|
||||||
|
secondaryAction,
|
||||||
|
isRetrying,
|
||||||
}: {
|
}: {
|
||||||
|
icon?: React.ComponentType<SVGIconProps>
|
||||||
title?: string
|
title?: string
|
||||||
message?: string
|
message?: string
|
||||||
onRetry?: () => unknown
|
onRetry?: () => unknown
|
||||||
onGoBack?: () => unknown
|
onGoBack?: () => unknown
|
||||||
hideBackButton?: boolean
|
hideBackButton?: boolean
|
||||||
|
isRetrying?: boolean
|
||||||
|
secondaryAction?: {
|
||||||
|
label: string
|
||||||
|
accessibilityLabel?: string
|
||||||
|
onPress: () => unknown
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const goBack = useGoBack(onGoBack)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Center
|
<Layout.Center
|
||||||
@@ -35,8 +46,11 @@ export function Error({
|
|||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{paddingTop: 175, paddingBottom: 110},
|
{paddingTop: 175, paddingBottom: 110},
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
<View style={[a.w_full, a.align_center, a.gap_lg, a.px_md]}>
|
||||||
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
|
{Icon && <Icon size="4xl" fill={t.atoms.text_contrast_medium.color} />}
|
||||||
|
<Text style={[a.font_semi_bold, a.text_3xl, a.text_center]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_md,
|
a.text_md,
|
||||||
@@ -51,29 +65,61 @@ export function Error({
|
|||||||
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
|
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
|
||||||
color="primary"
|
color="primary"
|
||||||
label={l`Press to retry`}
|
label={l`Press to retry`}
|
||||||
onPress={onRetry}
|
onPress={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
size="large">
|
size="large">
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Retry</Trans>
|
<Trans>Retry</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
|
{isRetrying && <ButtonIcon icon={Loader} />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!hideBackButton && (
|
{!hideBackButton && secondaryAction ? (
|
||||||
|
<Button
|
||||||
|
color={onRetry ? 'secondary' : 'primary'}
|
||||||
|
label={secondaryAction.accessibilityLabel ?? secondaryAction.label}
|
||||||
|
onPress={secondaryAction.onPress}
|
||||||
|
disabled={isRetrying}
|
||||||
|
size="large">
|
||||||
|
<ButtonText>{secondaryAction.label}</ButtonText>
|
||||||
|
</Button>
|
||||||
|
) : !hideBackButton ? (
|
||||||
|
<GoBackButton
|
||||||
|
hasRetry={Boolean(onRetry)}
|
||||||
|
isRetrying={isRetrying}
|
||||||
|
onGoBack={onGoBack}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Layout.Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoBackButton({
|
||||||
|
hasRetry,
|
||||||
|
isRetrying,
|
||||||
|
onGoBack,
|
||||||
|
}: {
|
||||||
|
hasRetry: boolean
|
||||||
|
isRetrying?: boolean
|
||||||
|
onGoBack?: () => unknown
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const goBack = useGoBack(onGoBack)
|
||||||
|
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color={onRetry ? 'secondary' : 'primary'}
|
color={hasRetry ? 'secondary' : 'primary'}
|
||||||
label={l`Return to previous page`}
|
label={l`Return to previous page`}
|
||||||
onPress={goBack}
|
onPress={goBack}
|
||||||
|
disabled={isRetrying}
|
||||||
size="large">
|
size="large">
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Go Back</Trans>
|
<Trans>Go Back</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Layout.Center>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import {isRetryableRequestError} from '#/lib/async/retry'
|
||||||
|
|
||||||
|
describe('retry', () => {
|
||||||
|
it('identifies retryable request errors', () => {
|
||||||
|
expect(isRetryableRequestError(new TypeError('Failed to fetch'))).toBe(true)
|
||||||
|
expect(isRetryableRequestError(new Error('Invalid request'))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import {timeout} from '#/lib/async/timeout'
|
import {timeout} from '#/lib/async/timeout'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError, shouldRetryError} from '#/lib/strings/errors'
|
||||||
|
|
||||||
|
export function isRetryableRequestError(error: unknown) {
|
||||||
|
return isNetworkError(error) || shouldRetryError(error)
|
||||||
|
}
|
||||||
|
|
||||||
export async function retry<P>(
|
export async function retry<P>(
|
||||||
retries: number,
|
retries: number,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from '#/components/PolicyUpdateOverlay'
|
} from '#/components/PolicyUpdateOverlay'
|
||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
import {useAgeAssurance} from '#/ageAssurance'
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
|
import {DataUnavailableScreen} from '#/ageAssurance/components/DataUnavailableScreen'
|
||||||
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
|
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
|
||||||
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
|
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
|
||||||
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
|
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
|
||||||
@@ -245,7 +246,9 @@ export function Shell() {
|
|||||||
<Deactivated />
|
<Deactivated />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{aa.state.access === aa.Access.None ? (
|
{aa.state.error === 'account-data' ? (
|
||||||
|
<DataUnavailableScreen />
|
||||||
|
) : aa.state.access === aa.Access.None ? (
|
||||||
<NoAccessScreen />
|
<NoAccessScreen />
|
||||||
) : (
|
) : (
|
||||||
<RoutesContainer>
|
<RoutesContainer>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
import {WelcomeModal} from '#/components/WelcomeModal'
|
import {WelcomeModal} from '#/components/WelcomeModal'
|
||||||
import {useAgeAssurance} from '#/ageAssurance'
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
|
import {DataUnavailableScreen} from '#/ageAssurance/components/DataUnavailableScreen'
|
||||||
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
|
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
|
||||||
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
|
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
|
||||||
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
|
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
|
||||||
@@ -167,7 +168,9 @@ export function Shell() {
|
|||||||
<Deactivated />
|
<Deactivated />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{aa.state.access === aa.Access.None ? (
|
{aa.state.error === 'account-data' ? (
|
||||||
|
<DataUnavailableScreen />
|
||||||
|
) : aa.state.access === aa.Access.None ? (
|
||||||
<NoAccessScreen />
|
<NoAccessScreen />
|
||||||
) : (
|
) : (
|
||||||
<RoutesContainer>
|
<RoutesContainer>
|
||||||
|
|||||||
Reference in New Issue
Block a user