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() {
+