Add intent dialog
This commit is contained in:
@@ -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 (
|
||||||
|
<Dialog.Outer control={control.control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`Verifying your age verification status`)}
|
||||||
|
style={[web({maxWidth: 400})]}>
|
||||||
|
<Inner optimisticState={control.value} />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>('')
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<View style={[a.align_start]}>
|
||||||
|
<AgeAssuranceBadge />
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_between,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_md,
|
||||||
|
a.pt_lg,
|
||||||
|
a.pb_md,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_xl, a.font_heavy]}>
|
||||||
|
<Trans>Verifying</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!error && <Loader size="md" />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[a.text_md, a.leading_snug]}>
|
||||||
|
<Trans>
|
||||||
|
We're confirming your status with our servers. This dialog should
|
||||||
|
close in a few seconds.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={[a.pt_md]}>
|
||||||
|
<Admonition type="error">{error}</Admonition>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{error && <Dialog.Close />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {createContext, useContext, useMemo, useState} from 'react'
|
import {createContext, useContext, useMemo, useState} from 'react'
|
||||||
|
|
||||||
|
import {type AgeAssuranceRedirectDialogState} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {type Screen} from '#/components/dialogs/EmailDialog/types'
|
import {type Screen} from '#/components/dialogs/EmailDialog/types'
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ type ControlsContext = {
|
|||||||
displayText: string
|
displayText: string
|
||||||
share?: boolean
|
share?: boolean
|
||||||
}>
|
}>
|
||||||
|
ageAssuranceRedirectDialogControl: StatefulControl<AgeAssuranceRedirectDialogState>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ControlsContext = createContext<ControlsContext | null>(null)
|
const ControlsContext = createContext<ControlsContext | null>(null)
|
||||||
@@ -46,6 +48,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
displayText: string
|
displayText: string
|
||||||
share?: boolean
|
share?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
const ageAssuranceRedirectDialogControl =
|
||||||
|
useStatefulDialogControl<AgeAssuranceRedirectDialogState>()
|
||||||
|
|
||||||
const ctx = useMemo<ControlsContext>(
|
const ctx = useMemo<ControlsContext>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -54,6 +58,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
inAppBrowserConsentControl,
|
inAppBrowserConsentControl,
|
||||||
emailDialogControl,
|
emailDialogControl,
|
||||||
linkWarningDialogControl,
|
linkWarningDialogControl,
|
||||||
|
ageAssuranceRedirectDialogControl,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
mutedWordsDialogControl,
|
mutedWordsDialogControl,
|
||||||
@@ -61,6 +66,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
inAppBrowserConsentControl,
|
inAppBrowserConsentControl,
|
||||||
emailDialogControl,
|
emailDialogControl,
|
||||||
linkWarningDialogControl,
|
linkWarningDialogControl,
|
||||||
|
ageAssuranceRedirectDialogControl,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,27 @@ import {logEvent} from '#/lib/statsig/statsig'
|
|||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
|
import {
|
||||||
|
parseAgeAssuranceRedirectDialogState,
|
||||||
|
useAgeAssuranceRedirectDialogControl,
|
||||||
|
} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
|
||||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||||
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
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+)?$/
|
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
||||||
|
|
||||||
// This needs to stay outside of react to persist between account switches
|
// This needs to stay outside of react to persist between account switches
|
||||||
let previousIntentUrl = ''
|
let previousIntentUrl = ''
|
||||||
|
|
||||||
|
// TODO we handle intents for logged out users?
|
||||||
export function useIntentHandler() {
|
export function useIntentHandler() {
|
||||||
const incomingUrl = Linking.useURL()
|
const incomingUrl = Linking.useURL()
|
||||||
const composeIntent = useComposeIntent()
|
const composeIntent = useComposeIntent()
|
||||||
const verifyEmailIntent = useVerifyEmailIntent()
|
const verifyEmailIntent = useVerifyEmailIntent()
|
||||||
|
const ageAssuranceRedirectDialogControl =
|
||||||
|
useAgeAssuranceRedirectDialogControl()
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleIncomingURL = (url: string) => {
|
const handleIncomingURL = (url: string) => {
|
||||||
@@ -65,6 +72,19 @@ export function useIntentHandler() {
|
|||||||
verifyEmailIntent(code)
|
verifyEmailIntent(code)
|
||||||
return
|
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: {
|
default: {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -78,7 +98,12 @@ export function useIntentHandler() {
|
|||||||
handleIncomingURL(incomingUrl)
|
handleIncomingURL(incomingUrl)
|
||||||
previousIntentUrl = incomingUrl
|
previousIntentUrl = incomingUrl
|
||||||
}
|
}
|
||||||
}, [incomingUrl, composeIntent, verifyEmailIntent])
|
}, [
|
||||||
|
incomingUrl,
|
||||||
|
composeIntent,
|
||||||
|
verifyEmailIntent,
|
||||||
|
ageAssuranceRedirectDialogControl,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useComposeIntent() {
|
export function useComposeIntent() {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
|
|||||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||||
import {atoms as a, select, useTheme} from '#/alf'
|
import {atoms as a, select, useTheme} from '#/alf'
|
||||||
import {setSystemUITheme} from '#/alf/util/systemUI'
|
import {setSystemUITheme} from '#/alf/util/systemUI'
|
||||||
|
import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
|
||||||
import {EmailDialog} from '#/components/dialogs/EmailDialog'
|
import {EmailDialog} from '#/components/dialogs/EmailDialog'
|
||||||
import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
|
import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
|
||||||
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
|
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
|
||||||
@@ -155,6 +156,7 @@ function ShellInner() {
|
|||||||
<MutedWordsDialog />
|
<MutedWordsDialog />
|
||||||
<SigninDialog />
|
<SigninDialog />
|
||||||
<EmailDialog />
|
<EmailDialog />
|
||||||
|
<AgeAssuranceRedirectDialog />
|
||||||
<InAppBrowserConsentDialog />
|
<InAppBrowserConsentDialog />
|
||||||
<LinkWarningDialog />
|
<LinkWarningDialog />
|
||||||
<Lightbox />
|
<Lightbox />
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {Lightbox} from '#/view/com/lightbox/Lightbox'
|
|||||||
import {ModalsContainer} from '#/view/com/modals/Modal'
|
import {ModalsContainer} from '#/view/com/modals/Modal'
|
||||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||||
import {atoms as a, select, useTheme} from '#/alf'
|
import {atoms as a, select, useTheme} from '#/alf'
|
||||||
|
import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
|
||||||
import {EmailDialog} from '#/components/dialogs/EmailDialog'
|
import {EmailDialog} from '#/components/dialogs/EmailDialog'
|
||||||
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
|
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
|
||||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||||
@@ -70,6 +71,7 @@ function ShellInner() {
|
|||||||
<MutedWordsDialog />
|
<MutedWordsDialog />
|
||||||
<SigninDialog />
|
<SigninDialog />
|
||||||
<EmailDialog />
|
<EmailDialog />
|
||||||
|
<AgeAssuranceRedirectDialog />
|
||||||
<LinkWarningDialog />
|
<LinkWarningDialog />
|
||||||
<Lightbox />
|
<Lightbox />
|
||||||
<PortalOutlet />
|
<PortalOutlet />
|
||||||
|
|||||||
Reference in New Issue
Block a user