add age assurance outage screen
This commit is contained in:
@@ -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')
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type Events = {
|
|||||||
| 'SignupQueued'
|
| 'SignupQueued'
|
||||||
| 'Deactivated'
|
| 'Deactivated'
|
||||||
| 'Takendown'
|
| 'Takendown'
|
||||||
|
| 'AgeAssuranceDataUnavailableScreen'
|
||||||
| 'AgeAssuranceNoAccessScreen'
|
| 'AgeAssuranceNoAccessScreen'
|
||||||
scope: 'current' | 'every'
|
scope: 'current' | 'every'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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