From 9a0e043ee9e83b083612f51269e6f277847b2d81 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 9 Jul 2025 13:43:39 -0500 Subject: [PATCH] Add intent dialog --- .../AgeAssuranceRedirectDialog.tsx | 172 ++++++++++++++++++ src/components/dialogs/Context.tsx | 6 + src/lib/hooks/useIntentHandler.ts | 29 ++- src/view/shell/index.tsx | 2 + src/view/shell/index.web.tsx | 2 + 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx diff --git a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx new file mode 100644 index 0000000000..9ef1ef965c --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx @@ -0,0 +1,172 @@ +import {useEffect, useRef, useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' + +import {retry} from '#/lib/async/retry' +import {createAgeAssuranceQueryKey} from '#/state/age-assurance' +import {useAgent} from '#/state/session' +import {atoms as a, web} from '#/alf' +import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' +import * as Dialog from '#/components/Dialog' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {Admonition} from '../Admonition' + +export type AgeAssuranceRedirectDialogState = { + status: 'success' | 'unknown' + did: 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 parseAgeAssuranceRedirectDialogState( + state: { + status?: string + did?: string + } = {}, +): AgeAssuranceRedirectDialogState | undefined { + let status: AgeAssuranceRedirectDialogState['status'] = 'unknown' + const did = state.did + + switch (state.status) { + case 'success': + status = 'success' + break + case 'unknown': + default: + status = 'unknown' + break + } + + if (status && did) { + return { + status, + did, + } + } +} + +export function useAgeAssuranceRedirectDialogControl() { + return useGlobalDialogsControlContext().ageAssuranceRedirectDialogControl +} + +export function AgeAssuranceRedirectDialog() { + const {_} = useLingui() + const control = useAgeAssuranceRedirectDialogControl() + + // TODO maybe handle invalid DID here + + return ( + + + + + + + + ) +} + +export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { + const {_} = useLingui() + const qc = useQueryClient() + const agent = useAgent() + const polling = useRef(false) + const unmounted = useRef(false) + const control = useAgeAssuranceRedirectDialogControl() + const [error, setError] = useState('') + + useEffect(() => { + if (polling.current) return + + polling.current = true + + retry( + 5, + () => true, + async () => { + if (!agent.session) return + if (unmounted.current) return + + const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState() + + if (data.status !== 'assured') { + throw new Error( + `Polling for age assurance state did not receive assured status`, + ) + } + + return data + }, + 1e3, + ) + .then(data => { + if (!data) return + if (!agent.session) return + if (unmounted.current) return + + qc.setQueryData(createAgeAssuranceQueryKey(agent.session.did), data) + + control.clear() + control.control.close() + }) + .catch(() => { + if (unmounted.current) return + setError( + _( + msg`We were unable to verify your age assurance status. But don't worry! Your account should be updated soon.`, + ), + ) + }) + + return () => { + unmounted.current = true + } + }, [_, agent, qc, control]) + + return ( + <> + + + + + + Verifying + + + {!error && } + + + + + We're confirming your status with our servers. This dialog should + close in a few seconds. + + + + {error && ( + + {error} + + )} + + + {error && } + + ) +} diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx index 1ee4d27398..8c700cafea 100644 --- a/src/components/dialogs/Context.tsx +++ b/src/components/dialogs/Context.tsx @@ -1,5 +1,6 @@ import {createContext, useContext, useMemo, useState} from 'react' +import {type AgeAssuranceRedirectDialogState} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import * as Dialog from '#/components/Dialog' import {type Screen} from '#/components/dialogs/EmailDialog/types' @@ -22,6 +23,7 @@ type ControlsContext = { displayText: string share?: boolean }> + ageAssuranceRedirectDialogControl: StatefulControl } const ControlsContext = createContext(null) @@ -46,6 +48,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { displayText: string share?: boolean }>() + const ageAssuranceRedirectDialogControl = + useStatefulDialogControl() const ctx = useMemo( () => ({ @@ -54,6 +58,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { inAppBrowserConsentControl, emailDialogControl, linkWarningDialogControl, + ageAssuranceRedirectDialogControl, }), [ mutedWordsDialogControl, @@ -61,6 +66,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { inAppBrowserConsentControl, emailDialogControl, linkWarningDialogControl, + ageAssuranceRedirectDialogControl, ], ) diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index 4a56537500..69254e7f64 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -6,20 +6,27 @@ import {logEvent} from '#/lib/statsig/statsig' import {isNative} from '#/platform/detection' import {useSession} from '#/state/session' import {useCloseAllActiveElements} from '#/state/util' +import { + parseAgeAssuranceRedirectDialogState, + useAgeAssuranceRedirectDialogControl, +} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import {useIntentDialogs} from '#/components/intents/IntentDialogs' import {Referrer} from '../../../modules/expo-bluesky-swiss-army' -type IntentType = 'compose' | 'verify-email' +type IntentType = 'compose' | 'verify-email' | 'age-assurance' const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/ // This needs to stay outside of react to persist between account switches let previousIntentUrl = '' +// TODO we handle intents for logged out users? export function useIntentHandler() { const incomingUrl = Linking.useURL() const composeIntent = useComposeIntent() const verifyEmailIntent = useVerifyEmailIntent() + const ageAssuranceRedirectDialogControl = + useAgeAssuranceRedirectDialogControl() React.useEffect(() => { const handleIncomingURL = (url: string) => { @@ -65,6 +72,19 @@ export function useIntentHandler() { verifyEmailIntent(code) return } + case 'age-assurance': { + const state = parseAgeAssuranceRedirectDialogState({ + status: params.get('status') ?? undefined, + did: params.get('did') ?? undefined, + }) + + // TODO maybe handle invalid DID here + + if (state) { + ageAssuranceRedirectDialogControl.open(state) + } + return + } default: { return } @@ -78,7 +98,12 @@ export function useIntentHandler() { handleIncomingURL(incomingUrl) previousIntentUrl = incomingUrl } - }, [incomingUrl, composeIntent, verifyEmailIntent]) + }, [ + incomingUrl, + composeIntent, + verifyEmailIntent, + ageAssuranceRedirectDialogControl, + ]) } export function useComposeIntent() { diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 8c08ec0c03..4d1a8c51b6 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -25,6 +25,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' import {setSystemUITheme} from '#/alf/util/systemUI' +import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import {EmailDialog} from '#/components/dialogs/EmailDialog' import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' @@ -155,6 +156,7 @@ function ShellInner() { + diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 8969d68f89..77c3f45f62 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -17,6 +17,7 @@ import {Lightbox} from '#/view/com/lightbox/Lightbox' import {ModalsContainer} from '#/view/com/modals/Modal' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' +import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import {EmailDialog} from '#/components/dialogs/EmailDialog' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' @@ -70,6 +71,7 @@ function ShellInner() { +