diff --git a/src/ageAssurance/components/DataUnavailableScreen.tsx b/src/ageAssurance/components/DataUnavailableScreen.tsx new file mode 100644 index 0000000000..cbc0efd2c8 --- /dev/null +++ b/src/ageAssurance/components/DataUnavailableScreen.tsx @@ -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 ( + void refetch()} + isRetrying={isFetching} + secondaryAction={{ + label: l`Sign out`, + onPress: () => { + if (IS_WEB) history.pushState(null, '', '/') + logoutCurrentAccount('AgeAssuranceDataUnavailableScreen') + }, + }} + /> + ) +} diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index f7d4476b66..a2069ddc00 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -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(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( diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index f909767156..6b77b6069f 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -44,6 +44,7 @@ export type Events = { | 'SignupQueued' | 'Deactivated' | 'Takendown' + | 'AgeAssuranceDataUnavailableScreen' | 'AgeAssuranceNoAccessScreen' scope: 'current' | 'every' } diff --git a/src/components/Error.tsx b/src/components/Error.tsx index 190165a2df..a8739ce35a 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -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 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 ( - - {title} + + {Icon && } + + {title} + {onRetry && ( )} - {!hideBackButton && ( + {!hideBackButton && secondaryAction ? ( - )} + ) : !hideBackButton ? ( + + ) : null} ) } + +function GoBackButton({ + hasRetry, + isRetrying, + onGoBack, +}: { + hasRetry: boolean + isRetrying?: boolean + onGoBack?: () => unknown +}) { + const {t: l} = useLingui() + const goBack = useGoBack(onGoBack) + + return ( + + ) +} diff --git a/src/lib/async/retry.test.ts b/src/lib/async/retry.test.ts new file mode 100644 index 0000000000..97a321ec8c --- /dev/null +++ b/src/lib/async/retry.test.ts @@ -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) + }) +}) diff --git a/src/lib/async/retry.ts b/src/lib/async/retry.ts index 479a1cdc12..cb706c61c9 100644 --- a/src/lib/async/retry.ts +++ b/src/lib/async/retry.ts @@ -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

( retries: number, diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 9603236d4d..c74a055499 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -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() { ) : ( <> - {aa.state.access === aa.Access.None ? ( + {aa.state.error === 'account-data' ? ( + + ) : aa.state.access === aa.Access.None ? ( ) : ( diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 6a68530f05..325e665761 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -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() { ) : ( <> - {aa.state.access === aa.Access.None ? ( + {aa.state.error === 'account-data' ? ( + + ) : aa.state.access === aa.Access.None ? ( ) : (