From ecab279de3e2e7c082980b2d412a3d60932d5cc4 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 25 Apr 2025 15:12:35 -0500 Subject: [PATCH] Verify step, integrate stateful control --- src/components/dialogs/Context.tsx | 4 +- .../components/ResendEmailText.tsx | 56 ++++++++ .../EmailDialog/data/useRefreshSession.ts | 11 ++ src/components/dialogs/EmailDialog/index.tsx | 62 +++++---- .../dialogs/EmailDialog/screens/Update.tsx | 85 ++++-------- .../dialogs/EmailDialog/screens/Verify.tsx | 128 ++++++++++++++++++ src/components/dialogs/EmailDialog/types.ts | 43 +++--- src/screens/Settings/AccountSettings.tsx | 38 +++--- 8 files changed, 301 insertions(+), 126 deletions(-) create mode 100644 src/components/dialogs/EmailDialog/components/ResendEmailText.tsx create mode 100644 src/components/dialogs/EmailDialog/data/useRefreshSession.ts create mode 100644 src/components/dialogs/EmailDialog/screens/Verify.tsx diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx index fda904b8b3..6b8d688ed1 100644 --- a/src/components/dialogs/Context.tsx +++ b/src/components/dialogs/Context.tsx @@ -48,7 +48,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) } -function useStatefulDialogControl(initialValue?: T): StatefulControl { +export function useStatefulDialogControl( + initialValue?: T, +): StatefulControl { const [value, setValue] = useState(initialValue) const control = Dialog.useDialogControl() return useMemo( diff --git a/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx b/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx new file mode 100644 index 0000000000..e5281392d6 --- /dev/null +++ b/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx @@ -0,0 +1,56 @@ +import {useState} from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {wait} from '#/lib/async/wait' +import {atoms as a, type TextStyleProp,useTheme} from '#/alf' +import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {createStaticClick,InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Span,Text} from '#/components/Typography' + +export function ResendEmailText({ + onPress, + style, +}: TextStyleProp & { + onPress: () => Promise +}) { + const t = useTheme() + const {_} = useLingui() + const [status, setStatus] = useState<'sending' | 'success' | null>(null) + + const handleOnPress = async () => { + setStatus('sending') + try { + await wait(1000, onPress()) + setStatus('success') + } finally { + setTimeout(() => { + setStatus(null) + }, 1000) + } + } + + return ( + + + Don't see an email?{' '} + { + handleOnPress() + })}> + Click here to resend. + + {' '} + + {status === 'sending' ? ( + + ) : status === 'success' ? ( + + ) : null} + + + ) +} diff --git a/src/components/dialogs/EmailDialog/data/useRefreshSession.ts b/src/components/dialogs/EmailDialog/data/useRefreshSession.ts new file mode 100644 index 0000000000..72fcdb577e --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useRefreshSession.ts @@ -0,0 +1,11 @@ +import {useCallback} from 'react' + +import {useAgent} from '#/state/session' + +export function useRefreshSession() { + const agent = useAgent() + + return useCallback(() => { + return agent.resumeSession(agent.session!).catch(() => {}) + }, [agent]) +} diff --git a/src/components/dialogs/EmailDialog/index.tsx b/src/components/dialogs/EmailDialog/index.tsx index e8dc68d0a7..b59b2be9a0 100644 --- a/src/components/dialogs/EmailDialog/index.tsx +++ b/src/components/dialogs/EmailDialog/index.tsx @@ -1,49 +1,65 @@ +import {useCallback} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import * as Dialog from '#/components/Dialog' -import {ScreenID, Screen} from '#/components/dialogs/EmailDialog/types' - +import { + type StatefulControl, + useStatefulDialogControl, +} from '#/components/dialogs/Context' +import {useRefreshSession} from '#/components/dialogs/EmailDialog/data/useRefreshSession' /* * Steps */ import {Update} from '#/components/dialogs/EmailDialog/screens/Update' +import {Verify} from '#/components/dialogs/EmailDialog/screens/Verify' +import {type Screen,ScreenID} from '#/components/dialogs/EmailDialog/types' export {useDialogControl} from '#/components/Dialog' -export {ScreenID, type Screen} from '#/components/dialogs/EmailDialog/types' +export { + ScreenID as EmailDialogScreenID, + type Screen, +} from '#/components/dialogs/EmailDialog/types' -export function EmailDialog({ - control, - initialScreen, -}: { - control: Dialog.DialogControlProps - initialScreen: Screen -}) { +export function useEmailDialogControl() { + return useStatefulDialogControl() +} + +export function EmailDialog({control}: {control: StatefulControl}) { const {_} = useLingui() + const refreshSession = useRefreshSession() + const onClose = useCallback(() => { + /** + * If link in any verification email is clicked, it will open a new tab. + * When the user returns to this tab, we'll refresh their account state + * when the dialog closes. + */ + refreshSession() + }, [refreshSession]) return ( - + - - + + ) } -function Inner({ - initialScreen, -}: { - control: Dialog.DialogControlProps - initialScreen: Screen -}) { - switch (initialScreen.id) { +function Inner({control}: {control: StatefulControl}) { + if (!control.value) return null + + switch (control.value.id) { case ScreenID.Update: { - return ( - - ) + return + } + case ScreenID.Verify: { + return } default: { return null diff --git a/src/components/dialogs/EmailDialog/screens/Update.tsx b/src/components/dialogs/EmailDialog/screens/Update.tsx index a3b386a6df..628be74d29 100644 --- a/src/components/dialogs/EmailDialog/screens/Update.tsx +++ b/src/components/dialogs/EmailDialog/screens/Update.tsx @@ -3,26 +3,28 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import * as TextField from '#/components/forms/TextField' -import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' -import {Text, Span} from '#/components/Typography' -import {atoms as a, useTheme} from '#/alf' -import {Divider} from '#/components/Divider' -import {Loader} from '#/components/Loader' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {logger} from '#/logger' -import {Admonition} from '#/components/Admonition' -import {useSession} from '#/state/session' -import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' -import {InlineLinkText, createStaticClick} from '#/components/Link' import {wait} from '#/lib/async/wait' - -import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail' +import {logger} from '#/logger' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' +import {TokenField} from '#/components/dialogs/EmailDialog/components/TokenField' import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' -import {TokenField} from '#/components/dialogs/EmailDialog/components/TokenField' +import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail' +import {type Screen} from '#/components/dialogs/EmailDialog/types' +import {Divider} from '#/components/Divider' +import * as TextField from '#/components/forms/TextField' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' -export function Update() { +export function Update(_props: { + config: Exclude +}) { const t = useTheme() const {_} = useLingui() const {currentAccount} = useSession() @@ -34,28 +36,10 @@ export function Update() { const [tokenRequired, setTokenRequired] = useState(false) const [updateStatus, setUpdateStatus] = useState<'sending' | null>(null) - const {mutateAsync: updateEmail, isPending: isUpdateEmailPending} = - useUpdateEmail() - - const [resendStatus, setResendStatus] = useState< - 'sending' | 'success' | null - >(null) + const {mutateAsync: updateEmail} = useUpdateEmail() const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() - const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() - const handleResendRequestEmailUpdate = async () => { - setResendStatus('sending') - try { - await wait(1000, requestEmailUpdate()) - setResendStatus('success') - } finally { - setTimeout(() => { - setResendStatus(null) - }, 1000) - } - } - const handleEmailChange = (email: string) => { setEmail(email) @@ -144,31 +128,10 @@ export function Update() { onSubmitEditing={handleUpdateEmail} /> {!success && ( - - - Don't see an email?{' '} - { - handleResendRequestEmailUpdate() - })}> - Click here to resend. - - {' '} - - {resendStatus === 'sending' ? ( - - ) : resendStatus === 'success' ? ( - - ) : null} - - + )} @@ -207,7 +170,7 @@ export function Update() { disabled={ !email || (tokenRequired && !token) || - isUpdateEmailPending || + updateStatus === 'sending' || success }> diff --git a/src/components/dialogs/EmailDialog/screens/Verify.tsx b/src/components/dialogs/EmailDialog/screens/Verify.tsx new file mode 100644 index 0000000000..296324be82 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Verify.tsx @@ -0,0 +1,128 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {wait} from '#/lib/async/wait' +import {logger} from '#/logger' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' +import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' +import {type Screen} from '#/components/dialogs/EmailDialog/types' +import {Divider} from '#/components/Divider' +import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {createStaticClick,InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Span,Text} from '#/components/Typography' + +export function Verify(_props: { + config: Exclude +}) { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const [error, setError] = useState('') + + const [sentEmail, setSentEmail] = useState(false) + const [sendingStatus, setSendingStatus] = useState<'sending' | null>(null) + const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() + + const handleRequestEmailVerification = async () => { + setError('') + setSendingStatus('sending') + + try { + await wait(1000, requestEmailVerification()) + setSentEmail(true) + } catch (e) { + logger.error('EmailDialog: sending verification email failed', { + safeMessage: e, + }) + setError(_(msg`Failed to send email, please try again.`)) + } finally { + setSendingStatus(null) + } + } + + return ( + + + + {sentEmail ? ( + <> + + + {' '} + Email sent! + + ) : ( + Verify email + )} + + + + {sentEmail ? ( + + We sent an email to{' '} + + {currentAccount!.email} + {' '} + containing a link. Click on it to complete the email verification + process. + + ) : ( + + We'll send you an email to{' '} + + {currentAccount!.email} + {' '} + containing a link. Click on it to complete the email verification + process. + + )} + + + {sentEmail && } + + + {!sentEmail && ( + <> + {error && {error} } + + + + + + + + Have a code?{' '} + {})}> + Click here. + + + + + )} + + ) +} diff --git a/src/components/dialogs/EmailDialog/types.ts b/src/components/dialogs/EmailDialog/types.ts index 09166fbf6b..d477240e87 100644 --- a/src/components/dialogs/EmailDialog/types.ts +++ b/src/components/dialogs/EmailDialog/types.ts @@ -8,28 +8,31 @@ export type EmailDialogProps = { export type EmailDialogInnerProps = EmailDialogProps & {} -export type Screen = { - id: ScreenID.Update -} | { - id: ScreenID.EnterCode - /** - * - email was sent earlier - * - email was _just_ sent - */ - instructions: ReactNode[] -} | { - id: ScreenID.VerifyEmail - /** - * - default flow - * - new user blockers (x5) - */ - instructions: ReactNode[] - // onCloseWithoutVerifying, - // onCloseAfterVerifying, -} +export type Screen = + | { + id: ScreenID.Update + } + | { + id: ScreenID.EnterCode + /** + * - email was sent earlier + * - email was _just_ sent + */ + instructions: ReactNode[] + } + | { + id: ScreenID.Verify + /** + * - default flow + * - new user blockers (x5) + */ + instructions?: ReactNode[] + // onCloseWithoutVerifying, + // onCloseAfterVerifying, + } export enum ScreenID { Update = 'Update', // normal, instructs to click link first EnterCode = 'EnterCode', // if user elects to enter a code - VerifyEmail = 'VerifyEmail', // as a separate step, instructs to click link first + Verify = 'Verify', // as a separate step, instructs to click link first } diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx index 1f70a20b4c..1cf214f248 100644 --- a/src/screens/Settings/AccountSettings.tsx +++ b/src/screens/Settings/AccountSettings.tsx @@ -9,8 +9,11 @@ import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' -import {ChangeEmailDialog} from '#/components/dialogs/ChangeEmailDialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' +import { + EmailDialog, + EmailDialogScreenID, + useEmailDialogControl, +} from '#/components/dialogs/EmailDialog' import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At' import {BirthdayCake_Stroke2_Corner2_Rounded as BirthdayCakeIcon} from '#/components/icons/BirthdayCake' import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car' @@ -24,7 +27,6 @@ import * as Layout from '#/components/Layout' import {ChangeHandleDialog} from './components/ChangeHandleDialog' import {DeactivateAccountDialog} from './components/DeactivateAccountDialog' import {ExportCarDialog} from './components/ExportCarDialog' -import {EmailDialog, ScreenID} from '#/components/dialogs/EmailDialog' type Props = NativeStackScreenProps export function AccountSettingsScreen({}: Props) { @@ -32,8 +34,7 @@ export function AccountSettingsScreen({}: Props) { const {_} = useLingui() const {currentAccount} = useSession() const {openModal} = useModalControls() - const verifyEmailControl = useDialogControl() - const changeEmailControl = useDialogControl() + const emailDialogControl = useEmailDialogControl() const birthdayControl = useDialogControl() const changeHandleControl = useDialogControl() const exportCarControl = useDialogControl() @@ -76,7 +77,11 @@ export function AccountSettingsScreen({}: Props) { {currentAccount && !currentAccount.emailConfirmed && ( verifyEmailControl.open()} + onPress={() => + emailDialogControl.open({ + id: EmailDialogScreenID.Verify, + }) + } style={[ a.my_xs, a.mx_lg, @@ -98,7 +103,11 @@ export function AccountSettingsScreen({}: Props) { )} changeEmailControl.open()}> + onPress={() => + emailDialogControl.open({ + id: EmailDialogScreenID.Update, + }) + }> Change email @@ -168,20 +177,7 @@ export function AccountSettingsScreen({}: Props) { - - {/* - - */} - +