add age assurance outage screen

This commit is contained in:
Samuel Newman
2026-08-31 20:15:04 +03:00
committed by GitHub
parent 57c9edd2ed
commit e4e1987af9
9 changed files with 145 additions and 19 deletions
@@ -0,0 +1,41 @@
import {useState} from 'react'
import {useLingui} from '@lingui/react/macro'
import {usePdsClient, useSessionApi} from '#/state/session'
import {Error} from '#/components/Error'
import {refetchOtherRequiredData} from '#/ageAssurance/data'
import {IS_WEB} from '#/env'
export function DataUnavailableScreen() {
const {t: l} = useLingui()
const {logoutCurrentAccount} = useSessionApi()
const accountClient = usePdsClient()
const [isRetrying, setIsRetrying] = useState(false)
const onRetry = async () => {
setIsRetrying(true)
try {
await refetchOtherRequiredData({accountClient})
} catch {
// The error screen remains mounted so the user can retry again.
} finally {
setIsRetrying(false)
}
}
return (
<Error
title={l`Unable to load your account`}
message={l`We couldn't load your account settings. Check your internet connection and try again.`}
onRetry={onRetry}
isRetrying={isRetrying}
secondaryAction={{
label: l`Sign out`,
onPress: () => {
if (IS_WEB) history.pushState(null, '', '/')
logoutCurrentAccount('AgeAssuranceDataUnavailableScreen')
},
}}
/>
)
}
+22 -6
View File
@@ -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 {networkRetry, requestRetry} 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 {
@@ -359,7 +359,7 @@ async function getOtherRequiredData({
if (debug.enabled) return debug.resolve(debug.otherRequiredData) if (debug.enabled) return debug.resolve(debug.otherRequiredData)
const did = accountClient.did const did = accountClient.did
const [prefs, actorDeclaration] = await Promise.all([ const [prefs, actorDeclaration] = await Promise.all([
accountClient.call(getPreferences), requestRetry(3, () => accountClient.call(getPreferences)),
fetchActorDeclarationRecord({did, client: accountClient}), fetchActorDeclarationRecord({did, client: accountClient}),
]) ])
const data: OtherRequiredData = { const data: OtherRequiredData = {
@@ -456,10 +456,10 @@ export async function prefetchOtherRequiredData({
try { try {
logger.debug(`prefetchOtherRequiredData: resolving...`) logger.debug(`prefetchOtherRequiredData: resolving...`)
const res = await networkRetry(3, () => await qc.fetchQuery({
getOtherRequiredData({accountClient}), queryKey: qk,
) queryFn: () => getOtherRequiredData({accountClient}),
qc.setQueryData<OtherRequiredData>(qk, res) })
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
logger.warn(`prefetchOtherRequiredData: failed`, { logger.warn(`prefetchOtherRequiredData: failed`, {
@@ -486,6 +486,20 @@ export function usePatchOtherRequiredData() {
[currentAccount], [currentAccount],
) )
} }
export async function refetchOtherRequiredData({
accountClient,
}: {
accountClient: Client
}) {
const did = accountClient.did
if (!did) return
const data = await getOtherRequiredData({accountClient})
qc.setQueryData<OtherRequiredData>(
createOtherRequiredDataQueryKey({did}),
data,
)
return data
}
export function useOtherRequiredDataQuery() { export function useOtherRequiredDataQuery() {
const accountClient = usePdsClient() const accountClient = usePdsClient()
const did = accountClient.did const did = accountClient.did
@@ -497,6 +511,8 @@ export function useOtherRequiredDataQuery() {
return getOtherRequiredDataFromCache({did}) return getOtherRequiredDataFromCache({did})
}, },
queryKey: createOtherRequiredDataQueryKey({did: did!}), queryKey: createOtherRequiredDataQueryKey({did: did!}),
retry: false,
retryOnMount: false,
async queryFn() { async queryFn() {
return getOtherRequiredData({accountClient}) return getOtherRequiredData({accountClient})
}, },
+1
View File
@@ -44,6 +44,7 @@ export type Events = {
| 'SignupQueued' | 'SignupQueued'
| 'Deactivated' | 'Deactivated'
| 'Takendown' | 'Takendown'
| 'AgeAssuranceDataUnavailableScreen'
| 'AgeAssuranceNoAccessScreen' | 'AgeAssuranceNoAccessScreen'
scope: 'current' | 'every' scope: 'current' | 'every'
} }
+20 -4
View File
@@ -3,8 +3,9 @@ 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 * 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({
@@ -13,12 +14,20 @@ export function Error({
onRetry, onRetry,
onGoBack, onGoBack,
hideBackButton, hideBackButton,
secondaryAction,
isRetrying,
}: { }: {
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()
@@ -55,21 +64,28 @@ export function Error({
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 && (
<Button <Button
variant="solid" variant="solid"
color={onRetry ? 'secondary' : 'primary'} color={onRetry ? 'secondary' : 'primary'}
label={l`Return to previous page`} label={
onPress={goBack} secondaryAction?.accessibilityLabel ??
secondaryAction?.label ??
l`Return to previous page`
}
onPress={secondaryAction?.onPress ?? goBack}
disabled={isRetrying}
size="large"> size="large">
<ButtonText> <ButtonText>
<Trans>Go Back</Trans> {secondaryAction?.label ?? <Trans>Go Back</Trans>}
</ButtonText> </ButtonText>
</Button> </Button>
)} )}
+21
View File
@@ -0,0 +1,21 @@
import {exponentialBackoffRetryDelay, retry} from '#/lib/async/retry'
describe('retry', () => {
it('calculates capped exponential backoff delays', () => {
expect([0, 1, 2, 3, 10].map(exponentialBackoffRetryDelay)).toEqual([
1000, 2000, 4000, 8000, 30_000,
])
})
it('applies the delay between attempts, but not after the last one', async () => {
const action = jest
.fn<Promise<string>, []>()
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValue('ok')
const delay = jest.fn(() => 0)
await expect(retry(3, () => true, action, delay)).resolves.toBe('ok')
expect(delay.mock.calls).toEqual([[0], [1]])
})
})
+30 -6
View File
@@ -1,23 +1,35 @@
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'
type RetryDelay = number | ((attempt: number) => number)
export function exponentialBackoffRetryDelay(attempt: number) {
return Math.min(1000 * 2 ** attempt, 30_000)
}
export function isRetryableRequestError(error: unknown) {
return isNetworkError(error) || shouldRetryError(error)
}
export async function retry<P>( export async function retry<P>(
retries: number, retries: number,
shouldRetry: (err: any) => boolean, shouldRetry: (err: any) => boolean,
action: () => Promise<P>, action: () => Promise<P>,
delay?: number, delay?: RetryDelay,
): Promise<P> { ): Promise<P> {
let lastErr let lastErr
let attempt = 0
while (retries > 0) { while (retries > 0) {
try { try {
return await action() return await action()
} catch (e: any) { } catch (e: any) {
lastErr = e lastErr = e
if (shouldRetry(e)) { if (shouldRetry(e)) {
if (delay) {
await timeout(delay)
}
retries-- retries--
if (retries === 0) throw e
const delayMs = typeof delay === 'function' ? delay(attempt) : delay
if (delayMs) await timeout(delayMs)
attempt++
continue continue
} }
throw e throw e
@@ -29,7 +41,19 @@ export async function retry<P>(
export async function networkRetry<P>( export async function networkRetry<P>(
retries: number, retries: number,
fn: () => Promise<P>, fn: () => Promise<P>,
delay?: number, delay?: RetryDelay,
): Promise<P> { ): Promise<P> {
return retry(retries, isNetworkError, fn, delay) return retry(retries, isNetworkError, fn, delay)
} }
export async function requestRetry<P>(
retries: number,
fn: () => Promise<P>,
): Promise<P> {
return retry(
retries,
isRetryableRequestError,
fn,
exponentialBackoffRetryDelay,
)
}
+2 -1
View File
@@ -24,6 +24,7 @@ import {
import {type LabelPreference} from '@bsky/sdk/moderation' import {type LabelPreference} from '@bsky/sdk/moderation'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {requestRetry} from '#/lib/async/retry'
import {PROD_DEFAULT_FEED} from '#/lib/constants' import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {replaceEqualDeep} from '#/lib/functions' import {replaceEqualDeep} from '#/lib/functions'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
@@ -71,7 +72,7 @@ export function usePreferencesQuery() {
if (!client.did) { if (!client.did) {
return DEFAULT_LOGGED_OUT_PREFERENCES return DEFAULT_LOGGED_OUT_PREFERENCES
} else { } else {
const res = await client.call(getPreferences) const res = await requestRetry(3, () => client.call(getPreferences))
const labelerDids = res.moderationPrefs.labelers.map(l => l.did) const labelerDids = res.moderationPrefs.labelers.map(l => l.did)
+4 -1
View File
@@ -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>
+4 -1
View File
@@ -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>