2696104e6d
Review fixes (PR #11182 round 2). Client identity is no longer stable across token refresh - on web, a cross-tab sync rebuilds the bundle and disposes the old clients (whose fetch now throws) - so anything that captured a client at construction time must re-point at the live one: - MessagesEventBus and Convo get updateClient(); providers sync it via effect. No remount, so poll cursors and optimistic pendingMessages survive. - FeedAPI implementations get setClient(); the post-feed queryFn and pollLatest re-point page-held apis before fetching (Merge/Home apis are stateful across pages, so they cannot be rebuilt per fetch). - usePreferencesQuery applies fetched labeler dids to the live appview client (applyLabelersToClient, factored from session/moderation), restoring the old BskyAgent.getPreferences header side effect. - ageAssurance redirect overlay/dialog polling is mount-only with render-synced refs; a client swap mid-poll no longer latches the unmounted flag and strands the overlay. - Convo message-failure classification defers to lex's shouldRetry() instead of treating all status-less errors as recoverable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
350 lines
9.2 KiB
TypeScript
350 lines
9.2 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import {Dimensions, View} from 'react-native'
|
|
import * as Linking from 'expo-linking'
|
|
import {msg} from '@lingui/core/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import {Trans} from '@lingui/react/macro'
|
|
|
|
import {retry} from '#/lib/async/retry'
|
|
import {wait} from '#/lib/async/wait'
|
|
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
|
import {useAppviewClient, useSession} from '#/state/session'
|
|
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
|
|
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
|
import {Button, ButtonText} from '#/components/Button'
|
|
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
|
import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
|
|
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
|
import {Loader} from '#/components/Loader'
|
|
import {Text} from '#/components/Typography'
|
|
import {refetchAgeAssuranceServerState} from '#/ageAssurance'
|
|
import {useAnalytics} from '#/analytics'
|
|
import {IS_IOS, IS_WEB} from '#/env'
|
|
|
|
export type RedirectOverlayState = {
|
|
result: 'success' | 'unknown'
|
|
actorDid: string
|
|
}
|
|
|
|
/**
|
|
* Validate and parse the query parameters returned from the age assurance
|
|
* redirect. If not valid, returns `undefined` and the dialog will not open.
|
|
*/
|
|
export function parseRedirectOverlayState(
|
|
state: {
|
|
result?: string
|
|
actorDid?: string
|
|
} = {},
|
|
): RedirectOverlayState | undefined {
|
|
let result: RedirectOverlayState['result'] = 'unknown'
|
|
const actorDid = state.actorDid
|
|
|
|
switch (state.result) {
|
|
case 'success':
|
|
result = 'success'
|
|
break
|
|
case 'unknown':
|
|
default:
|
|
result = 'unknown'
|
|
break
|
|
}
|
|
|
|
if (actorDid) {
|
|
return {
|
|
result,
|
|
actorDid,
|
|
}
|
|
}
|
|
}
|
|
|
|
const Context = createContext<{
|
|
isOpen: boolean
|
|
open: (state: RedirectOverlayState) => void
|
|
close: () => void
|
|
}>({
|
|
isOpen: false,
|
|
open: () => {},
|
|
close: () => {},
|
|
})
|
|
|
|
export function useRedirectOverlayContext() {
|
|
return useContext(Context)
|
|
}
|
|
|
|
export function Provider({children}: {children?: React.ReactNode}) {
|
|
const {currentAccount} = useSession()
|
|
const incomingUrl = Linking.useLinkingURL()
|
|
const [state, setState] = useState<RedirectOverlayState | null>(() => {
|
|
if (!incomingUrl) return null
|
|
const url = parseLinkingUrl(incomingUrl)
|
|
if (url.pathname !== '/intent/age-assurance') return null
|
|
const params = url.searchParams
|
|
const state = parseRedirectOverlayState({
|
|
result: params.get('result') ?? undefined,
|
|
actorDid: params.get('actorDid') ?? undefined,
|
|
})
|
|
|
|
if (IS_WEB) {
|
|
// Clear the URL parameters so they don't re-trigger
|
|
history.pushState(null, '', '/')
|
|
}
|
|
|
|
/*
|
|
* If we don't have an account or the account doesn't match, do
|
|
* nothing. By the time the user switches to their other account, AA
|
|
* state should be ready for them.
|
|
*/
|
|
if (state && currentAccount && state.actorDid === currentAccount.did) {
|
|
return state
|
|
}
|
|
|
|
return null
|
|
})
|
|
const open = useCallback((state: RedirectOverlayState) => {
|
|
setState(state)
|
|
}, [])
|
|
const close = useCallback(() => {
|
|
setState(null)
|
|
}, [])
|
|
|
|
return (
|
|
<Context.Provider
|
|
value={useMemo(
|
|
() => ({
|
|
isOpen: state !== null,
|
|
open,
|
|
close,
|
|
}),
|
|
[state, open, close],
|
|
)}>
|
|
{children}
|
|
</Context.Provider>
|
|
)
|
|
}
|
|
|
|
export function RedirectOverlay() {
|
|
const t = useTheme()
|
|
const {_} = useLingui()
|
|
const {isOpen} = useRedirectOverlayContext()
|
|
const {gtMobile} = useBreakpoints()
|
|
|
|
return isOpen ? (
|
|
<FullWindowOverlay>
|
|
<View
|
|
style={[
|
|
a.fixed,
|
|
a.inset_0,
|
|
// setting a zIndex when using FullWindowOverlay on iOS
|
|
// means the taps pass straight through to the underlying content (???)
|
|
// so don't set it on iOS. FullWindowOverlay already does the job.
|
|
!IS_IOS && {zIndex: 9999},
|
|
t.atoms.bg,
|
|
gtMobile ? a.p_2xl : a.p_xl,
|
|
a.align_center,
|
|
// @ts-ignore
|
|
platform({
|
|
web: {
|
|
paddingTop: '35vh',
|
|
},
|
|
default: {
|
|
paddingTop: Dimensions.get('window').height * 0.35,
|
|
},
|
|
}),
|
|
]}>
|
|
<View
|
|
role="dialog"
|
|
aria-role="dialog"
|
|
aria-label={_(msg`Verifying your age assurance status`)}>
|
|
<View style={[a.pb_3xl, {width: 300}]}>
|
|
<Inner />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</FullWindowOverlay>
|
|
) : null
|
|
}
|
|
|
|
function Inner() {
|
|
const t = useTheme()
|
|
const ax = useAnalytics()
|
|
const {_} = useLingui()
|
|
const {hasSession} = useSession()
|
|
const appviewClient = useAppviewClient()
|
|
/*
|
|
* The poll effect is mount-only so a session-bundle rebuild (web cross-tab
|
|
* token sync, which swaps the appview client identity) does not restart the
|
|
* flow or permanently latch it. Read the volatile values through refs kept
|
|
* fresh each render so the next retry attempt picks up the current client.
|
|
*/
|
|
const clientRef = useRef(appviewClient)
|
|
clientRef.current = appviewClient
|
|
const hasSessionRef = useRef(hasSession)
|
|
hasSessionRef.current = hasSession
|
|
const openMetricFired = useRef(false)
|
|
const [error, setError] = useState(false)
|
|
const [success, setSuccess] = useState(false)
|
|
const {close} = useRedirectOverlayContext()
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
if (!openMetricFired.current) {
|
|
openMetricFired.current = true
|
|
ax.metric('ageAssurance:redirectDialogOpen', {})
|
|
}
|
|
|
|
wait(
|
|
3e3,
|
|
retry(
|
|
5,
|
|
() => true,
|
|
async () => {
|
|
if (!hasSessionRef.current) return
|
|
if (cancelled) return
|
|
|
|
const data = await refetchAgeAssuranceServerState({
|
|
appviewClient: clientRef.current,
|
|
})
|
|
|
|
if (data?.state.status !== 'assured') {
|
|
throw new Error(
|
|
`Polling for age assurance state did not receive assured status`,
|
|
)
|
|
}
|
|
|
|
return data
|
|
},
|
|
1e3,
|
|
),
|
|
)
|
|
.then(async data => {
|
|
if (!data) return
|
|
if (!hasSessionRef.current) return
|
|
if (cancelled) return
|
|
|
|
setSuccess(true)
|
|
|
|
ax.metric('ageAssurance:redirectDialogSuccess', {})
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return
|
|
setError(true)
|
|
ax.metric('ageAssurance:redirectDialogFail', {})
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
if (success) {
|
|
return (
|
|
<>
|
|
<View style={[a.align_start, a.w_full]}>
|
|
<AgeAssuranceBadge />
|
|
|
|
<View
|
|
style={[
|
|
a.flex_row,
|
|
a.justify_between,
|
|
a.align_center,
|
|
a.gap_sm,
|
|
a.pt_lg,
|
|
a.pb_md,
|
|
]}>
|
|
<SuccessIcon size="sm" fill={t.palette.positive_500} />
|
|
<Text style={[a.text_3xl, a.font_bold]}>
|
|
<Trans>Success</Trans>
|
|
</Text>
|
|
</View>
|
|
|
|
<Text style={[a.text_md, a.leading_snug]}>
|
|
<Trans>
|
|
We've confirmed your age assurance status. You can now close this
|
|
dialog.
|
|
</Trans>
|
|
</Text>
|
|
|
|
<View style={[a.w_full, a.pt_lg]}>
|
|
<Button
|
|
label={_(msg`Close`)}
|
|
size="large"
|
|
variant="solid"
|
|
color="secondary"
|
|
onPress={() => close()}>
|
|
<ButtonText>
|
|
<Trans>Close</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<View style={[a.align_start, a.w_full]}>
|
|
<AgeAssuranceBadge />
|
|
|
|
<View
|
|
style={[
|
|
a.flex_row,
|
|
a.justify_between,
|
|
a.align_center,
|
|
a.gap_sm,
|
|
a.pt_lg,
|
|
a.pb_md,
|
|
]}>
|
|
{error && <ErrorIcon size="lg" fill={t.palette.negative_500} />}
|
|
|
|
<Text style={[a.text_3xl, a.font_bold]}>
|
|
{error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
|
|
</Text>
|
|
|
|
{!error && <Loader size="lg" />}
|
|
</View>
|
|
|
|
<Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
|
|
{error ? (
|
|
<Trans>
|
|
We were unable to receive the verification due to a connection
|
|
issue. It may arrive later. If it does, your account will update
|
|
automatically.
|
|
</Trans>
|
|
) : (
|
|
<Trans>
|
|
We're confirming your age assurance status with our servers. This
|
|
should only take a few seconds.
|
|
</Trans>
|
|
)}
|
|
</Text>
|
|
|
|
{error && (
|
|
<View style={[a.w_full, a.pt_lg]}>
|
|
<Button
|
|
label={_(msg`Close`)}
|
|
size="large"
|
|
variant="solid"
|
|
color="secondary"
|
|
onPress={() => close()}>
|
|
<ButtonText>
|
|
<Trans>Close</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</>
|
|
)
|
|
}
|