APP-2997: Add age assurance outage screen (#11614)
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 {
|
||||
@@ -348,9 +348,14 @@ export type OtherRequiredData = {
|
||||
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,
|
||||
}: {
|
||||
@@ -456,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`, {
|
||||
@@ -491,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})
|
||||
},
|
||||
@@ -759,9 +767,18 @@ export function AgeAssuranceServerDataProvider({
|
||||
const {data: config} = useConfigQuery()
|
||||
const serverState = useServerStateQuery()
|
||||
const {state, metadata} = serverState.data || {}
|
||||
const {data, status} = 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 ? status : 'success'
|
||||
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(
|
||||
|
||||
@@ -44,6 +44,7 @@ export type Events = {
|
||||
| 'SignupQueued'
|
||||
| 'Deactivated'
|
||||
| 'Takendown'
|
||||
| 'AgeAssuranceDataUnavailableScreen'
|
||||
| 'AgeAssuranceNoAccessScreen'
|
||||
scope: 'current' | 'every'
|
||||
}
|
||||
|
||||
+59
-13
@@ -3,27 +3,38 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useGoBack} from '#/lib/hooks/useGoBack'
|
||||
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 {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Error({
|
||||
icon: Icon,
|
||||
title,
|
||||
message,
|
||||
onRetry,
|
||||
onGoBack,
|
||||
hideBackButton,
|
||||
secondaryAction,
|
||||
isRetrying,
|
||||
}: {
|
||||
icon?: React.ComponentType<SVGIconProps>
|
||||
title?: string
|
||||
message?: string
|
||||
onRetry?: () => unknown
|
||||
onGoBack?: () => unknown
|
||||
hideBackButton?: boolean
|
||||
isRetrying?: boolean
|
||||
secondaryAction?: {
|
||||
label: string
|
||||
accessibilityLabel?: string
|
||||
onPress: () => unknown
|
||||
}
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const goBack = useGoBack(onGoBack)
|
||||
|
||||
return (
|
||||
<Layout.Center
|
||||
@@ -35,8 +46,11 @@ export function Error({
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg, a.px_md]}>
|
||||
{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
|
||||
style={[
|
||||
a.text_md,
|
||||
@@ -51,29 +65,61 @@ export function Error({
|
||||
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label={l`Press to retry`}
|
||||
onPress={onRetry}
|
||||
disabled={isRetrying}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
{isRetrying && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
)}
|
||||
{!hideBackButton && (
|
||||
{!hideBackButton && secondaryAction ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label={l`Return to previous page`}
|
||||
onPress={goBack}
|
||||
label={secondaryAction.accessibilityLabel ?? secondaryAction.label}
|
||||
onPress={secondaryAction.onPress}
|
||||
disabled={isRetrying}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
<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
|
||||
variant="solid"
|
||||
color={hasRetry ? 'secondary' : 'primary'}
|
||||
label={l`Return to previous page`}
|
||||
onPress={goBack}
|
||||
disabled={isRetrying}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {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>(
|
||||
retries: number,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '#/components/PolicyUpdateOverlay'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {DataUnavailableScreen} from '#/ageAssurance/components/DataUnavailableScreen'
|
||||
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
|
||||
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
|
||||
@@ -245,7 +246,9 @@ export function Shell() {
|
||||
<Deactivated />
|
||||
) : (
|
||||
<>
|
||||
{aa.state.access === aa.Access.None ? (
|
||||
{aa.state.error === 'account-data' ? (
|
||||
<DataUnavailableScreen />
|
||||
) : aa.state.access === aa.Access.None ? (
|
||||
<NoAccessScreen />
|
||||
) : (
|
||||
<RoutesContainer>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import {WelcomeModal} from '#/components/WelcomeModal'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {DataUnavailableScreen} from '#/ageAssurance/components/DataUnavailableScreen'
|
||||
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
|
||||
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
|
||||
@@ -167,7 +168,9 @@ export function Shell() {
|
||||
<Deactivated />
|
||||
) : (
|
||||
<>
|
||||
{aa.state.access === aa.Access.None ? (
|
||||
{aa.state.error === 'account-data' ? (
|
||||
<DataUnavailableScreen />
|
||||
) : aa.state.access === aa.Access.None ? (
|
||||
<NoAccessScreen />
|
||||
) : (
|
||||
<RoutesContainer>
|
||||
|
||||
Reference in New Issue
Block a user