diff options
author | Eric Bailey <git@esb.lol> | 2025-05-07 10:23:33 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-05-07 10:23:33 -0500 |
commit | 0f96669f8c0d578d888c06496d97929130d34a1f (patch) | |
tree | cd053b6062fc5045eb14411135dc6ea46d5018f3 /src/components | |
parent | 0edd3bd3b4445275ea3f9ddfc5f91ad4950acdd8 (diff) | |
download | voidsky-0f96669f8c0d578d888c06496d97929130d34a1f.tar.zst |
[APP-1158] Refactor email-related dialogs (#8296)
* WIP * Update email * Fire off confirmation email after change * Verify step, integrate stateful control * Remove tentative EnterCode step * Handle token step * Handle instructions and integrate into 2FA setting * Fix load state when reusing same email * Add new state * Add 2FA screens * Clean up state in Update step * Clean up verify state, handle normal callback * Normalize convetions * Add verification reminder screen * Improve session refresh * Handle verification requirements for composer and convo * Fix lint * Do better * Couple missing translations * Format * Use listeners for easier to grok logic * Clean errors * Move to global context * [APP-1158] Gate features by email verification state (#8305) * Use new hook in all locations * Format * Seems to work, not great duplication * Wrap all open composer calls * Remove unneeded spans * Missed one * Fix handler on Conversation * Gate new chat in header * Add comment * Remove whoopsie * Format * add hackfix for dialog not showing * add prompt to accept chat btn * navigation not necessary * send back one screen, rather than home * Update comment --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com> * Clear dialog state Co-authored-by: Samuel Newman <mozzius@protonmail.com> * Update icon * Check color * Add 2FA warning * Update instructions * Fix X button * Use an effect silly goose * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/dialaUpdate copyogs/EmailDialog/screens/Update.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy * Update copy * Update copy * Update copy * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy * Add link back to update email from verify email dialog * Handle token field validation --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Diffstat (limited to 'src/components')
22 files changed, 1787 insertions, 76 deletions
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 12bd8819b..c43e9c5c0 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -195,7 +195,13 @@ export function Inner({ onDismiss={close} style={{display: 'flex', flexDirection: 'column'}}> {header} - <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}> + <View + style={[ + gtMobile ? a.p_2xl : a.p_xl, + a.overflow_hidden, + a.rounded_md, + contentContainerStyle, + ]}> {children} </View> </DismissableLayer.DismissableLayer> diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index d7afa37d2..de19b0bce 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -18,7 +18,7 @@ import {useNavigation} from '@react-navigation/native' import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' -import {useEmail} from '#/lib/hooks/useEmail' +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type NavigationProp} from '#/lib/routes/types' import {parseStarterPackUri} from '#/lib/strings/starter-pack' @@ -30,7 +30,6 @@ import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {atoms as a, ios, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {Loader} from '#/components/Loader' @@ -197,9 +196,7 @@ function Empty() { const confirmDialogControl = useDialogControl() const followersDialogControl = useDialogControl() const errorDialogControl = useDialogControl() - - const {needsEmailVerification} = useEmail() - const verifyEmailControl = useDialogControl() + const requireEmailVerification = useRequireEmailVerification() const [isGenerating, setIsGenerating] = useState(false) @@ -230,6 +227,27 @@ function Empty() { generateStarterPack() } + const openConfirmDialog = useCallback(() => { + confirmDialogControl.open() + }, [confirmDialogControl]) + const wrappedOpenConfirmDialog = requireEmailVerification(openConfirmDialog, { + instructions: [ + <Trans key="confirm"> + Before creating a starter pack, you must first verify your email. + </Trans>, + ], + }) + const navToWizard = useCallback(() => { + navigation.navigate('StarterPackWizard') + }, [navigation]) + const wrappedNavToWizard = requireEmailVerification(navToWizard, { + instructions: [ + <Trans key="nav"> + Before creating a starter pack, you must first verify your email. + </Trans>, + ], + }) + return ( <LinearGradientBackground style={[ @@ -258,13 +276,7 @@ function Empty() { color="primary" size="small" disabled={isGenerating} - onPress={() => { - if (needsEmailVerification) { - verifyEmailControl.open() - } else { - confirmDialogControl.open() - } - }} + onPress={wrappedOpenConfirmDialog} style={{backgroundColor: 'transparent'}}> <ButtonText style={{color: 'white'}}> <Trans>Make one for me</Trans> @@ -277,13 +289,7 @@ function Empty() { color="primary" size="small" disabled={isGenerating} - onPress={() => { - if (needsEmailVerification) { - verifyEmailControl.open() - } else { - navigation.navigate('StarterPackWizard') - } - }} + onPress={wrappedNavToWizard} style={{ backgroundColor: 'white', borderColor: 'white', @@ -339,12 +345,6 @@ function Empty() { onConfirm={generate} confirmButtonCta={_(msg`Retry`)} /> - <VerifyEmailDialog - reasonText={_( - msg`Before creating a starter pack, you must first verify your email.`, - )} - control={verifyEmailControl} - /> </LinearGradientBackground> ) } diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx index fda904b8b..728044325 100644 --- a/src/components/dialogs/Context.tsx +++ b/src/components/dialogs/Context.tsx @@ -1,6 +1,7 @@ import {createContext, useContext, useMemo, useState} from 'react' import * as Dialog from '#/components/Dialog' +import {type Screen} from '#/components/dialogs/EmailDialog/types' type Control = Dialog.DialogControlProps @@ -15,6 +16,7 @@ type ControlsContext = { mutedWordsDialogControl: Control signinDialogControl: Control inAppBrowserConsentControl: StatefulControl<string> + emailDialogControl: StatefulControl<Screen> } const ControlsContext = createContext<ControlsContext | null>(null) @@ -33,14 +35,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const mutedWordsDialogControl = Dialog.useDialogControl() const signinDialogControl = Dialog.useDialogControl() const inAppBrowserConsentControl = useStatefulDialogControl<string>() + const emailDialogControl = useStatefulDialogControl<Screen>() const ctx = useMemo<ControlsContext>( () => ({ mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl, + emailDialogControl, }), - [mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl], + [ + mutedWordsDialogControl, + signinDialogControl, + inAppBrowserConsentControl, + emailDialogControl, + ], ) return ( @@ -48,7 +57,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) } -function useStatefulDialogControl<T>(initialValue?: T): StatefulControl<T> { +export function useStatefulDialogControl<T>( + initialValue?: T, +): StatefulControl<T> { 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 000000000..0fc9a5469 --- /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<any> +}) { + 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 ( + <Text + style={[a.italic, a.leading_snug, t.atoms.text_contrast_medium, style]}> + <Trans> + Don't see an email?{' '} + <InlineLinkText + label={_(msg`Resend`)} + {...createStaticClick(() => { + handleOnPress() + })}> + Click here to resend. + </InlineLinkText> + </Trans>{' '} + <Span style={{top: 1}}> + {status === 'sending' ? ( + <Loader size="xs" /> + ) : status === 'success' ? ( + <Check size="xs" fill={t.palette.positive_500} /> + ) : null} + </Span> + </Text> + ) +} diff --git a/src/components/dialogs/EmailDialog/components/TokenField.tsx b/src/components/dialogs/EmailDialog/components/TokenField.tsx new file mode 100644 index 000000000..26a51e485 --- /dev/null +++ b/src/components/dialogs/EmailDialog/components/TokenField.tsx @@ -0,0 +1,45 @@ +import {type TextInputProps, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as TextField from '#/components/forms/TextField' +import {Shield_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' + +export function normalizeCode(value: string) { + const normalized = value.toUpperCase().replace(/[^A-Z2-7]/g, '') + if (normalized.length <= 5) return normalized + return `${normalized.slice(0, 5)}-${normalized.slice(5)}` +} + +export function isValidCode(value?: string) { + return Boolean(value && /^[A-Z2-7]{5}-[A-Z2-7]{5}$/.test(value)) +} + +export function TokenField({ + value, + onChangeText, + onSubmitEditing, +}: Pick<TextInputProps, 'value' | 'onChangeText' | 'onSubmitEditing'>) { + const {_} = useLingui() + const isInvalid = Boolean(value && value.length > 10 && !isValidCode(value)) + + const handleOnChangeText = (v: string) => { + onChangeText?.(normalizeCode(v)) + } + + return ( + <View> + <TextField.Root> + <TextField.Icon icon={Shield} /> + <TextField.Input + isInvalid={isInvalid} + label={_(msg`Confirmation code`)} + placeholder="XXXXX-XXXXX" + value={value} + onChangeText={handleOnChangeText} + onSubmitEditing={onSubmitEditing} + /> + </TextField.Root> + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts b/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts new file mode 100644 index 000000000..377411107 --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts @@ -0,0 +1,79 @@ +import {useCallback, useEffect, useState} from 'react' +import {useQuery, useQueryClient} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' +import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events' + +export type AccountEmailState = { + isEmailVerified: boolean + email2FAEnabled: boolean +} + +export const accountEmailStateQueryKey = ['accountEmailState'] as const + +export function useInvalidateAccountEmailState() { + const qc = useQueryClient() + + return useCallback(() => { + return qc.invalidateQueries({ + queryKey: accountEmailStateQueryKey, + }) + }, [qc]) +} + +export function useUpdateAccountEmailStateQueryCache() { + const qc = useQueryClient() + + return useCallback( + (data: AccountEmailState) => { + return qc.setQueriesData( + { + queryKey: accountEmailStateQueryKey, + }, + data, + ) + }, + [qc], + ) +} + +export function useAccountEmailState() { + const agent = useAgent() + const [prevIsEmailVerified, setPrevEmailIsVerified] = useState( + !!agent.session?.emailConfirmed, + ) + const fallbackData: AccountEmailState = { + isEmailVerified: !!agent.session?.emailConfirmed, + email2FAEnabled: !!agent.session?.emailAuthFactor, + } + const query = useQuery<AccountEmailState>({ + enabled: !!agent.session, + refetchOnWindowFocus: true, + queryKey: accountEmailStateQueryKey, + queryFn: async () => { + // will also trigger updates to `#/state/session` data + const {data} = await agent.resumeSession(agent.session!) + return { + isEmailVerified: !!data.emailConfirmed, + email2FAEnabled: !!data.emailAuthFactor, + } + }, + }) + + const state = query.data ?? fallbackData + + /* + * This will emit `n` times for each instance of this hook. So the listeners + * all use `once` to prevent multiple handlers firing. + */ + useEffect(() => { + if (state.isEmailVerified && !prevIsEmailVerified) { + setPrevEmailIsVerified(true) + emitEmailVerified() + } else if (!state.isEmailVerified && prevIsEmailVerified) { + setPrevEmailIsVerified(false) + } + }, [state, prevIsEmailVerified]) + + return state +} diff --git a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts new file mode 100644 index 000000000..73f824fcc --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts @@ -0,0 +1,29 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent, useSession} from '#/state/session' +import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' + +export function useConfirmEmail() { + const agent = useAgent() + const {currentAccount} = useSession() + const updateAccountEmailStateQueryCache = + useUpdateAccountEmailStateQueryCache() + + return useMutation({ + mutationFn: async ({token}: {token: string}) => { + if (!currentAccount?.email) { + throw new Error('No email found for the current account') + } + + await agent.com.atproto.server.confirmEmail({ + email: currentAccount.email, + token: token.trim(), + }) + const {data} = await agent.resumeSession(agent.session!) + updateAccountEmailStateQueryCache({ + isEmailVerified: !!data.emailConfirmed, + email2FAEnabled: !!data.emailAuthFactor, + }) + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts new file mode 100644 index 000000000..39f5fd2d9 --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts @@ -0,0 +1,35 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent, useSession} from '#/state/session' +import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' + +export function useManageEmail2FA() { + const agent = useAgent() + const {currentAccount} = useSession() + const updateAccountEmailStateQueryCache = + useUpdateAccountEmailStateQueryCache() + + return useMutation({ + mutationFn: async ({ + enabled, + token, + }: + | {enabled: true; token?: undefined} + | {enabled: false; token: string}) => { + if (!currentAccount?.email) { + throw new Error('No email found for the current account') + } + + await agent.com.atproto.server.updateEmail({ + email: currentAccount.email, + emailAuthFactor: enabled, + token, + }) + const {data} = await agent.resumeSession(agent.session!) + updateAccountEmailStateQueryCache({ + isEmailVerified: !!data.emailConfirmed, + email2FAEnabled: !!data.emailAuthFactor, + }) + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts new file mode 100644 index 000000000..a442662fc --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts @@ -0,0 +1,13 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' + +export function useRequestEmailUpdate() { + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + return (await agent.com.atproto.server.requestEmailUpdate()).data + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts new file mode 100644 index 000000000..ae308c7af --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts @@ -0,0 +1,13 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' + +export function useRequestEmailVerification() { + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + await agent.com.atproto.server.requestEmailConfirmation() + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts new file mode 100644 index 000000000..2ec1eb6dc --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts @@ -0,0 +1,45 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' +import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' + +async function updateEmailAndRefreshSession( + agent: ReturnType<typeof useAgent>, + email: string, + token?: string, +) { + await agent.com.atproto.server.updateEmail({email: email.trim(), token}) + await agent.resumeSession(agent.session!) +} + +export function useUpdateEmail() { + const agent = useAgent() + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + + return useMutation< + {status: 'tokenRequired' | 'success'}, + Error, + {email: string; token?: string} + >({ + mutationFn: async ({email, token}: {email: string; token?: string}) => { + if (token) { + await updateEmailAndRefreshSession(agent, email, token) + return { + status: 'success', + } + } else { + const {tokenRequired} = await requestEmailUpdate() + if (tokenRequired) { + return { + status: 'tokenRequired', + } + } else { + await updateEmailAndRefreshSession(agent, email, token) + return { + status: 'success', + } + } + } + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/events.ts b/src/components/dialogs/EmailDialog/events.ts new file mode 100644 index 000000000..4fa171cad --- /dev/null +++ b/src/components/dialogs/EmailDialog/events.ts @@ -0,0 +1,23 @@ +import {useEffect} from 'react' +import EventEmitter from 'eventemitter3' + +const events = new EventEmitter<{ + emailVerified: void +}>() + +export function emitEmailVerified() { + events.emit('emailVerified') +} + +export function useOnEmailVerified(cb: () => void) { + useEffect(() => { + /* + * N.B. Use `once` here, since the event can fire multiple times for each + * instance of `useAccountEmailState` + */ + events.once('emailVerified', cb) + return () => { + events.off('emailVerified', cb) + } + }, [cb]) +} diff --git a/src/components/dialogs/EmailDialog/index.tsx b/src/components/dialogs/EmailDialog/index.tsx new file mode 100644 index 000000000..4bac7295a --- /dev/null +++ b/src/components/dialogs/EmailDialog/index.tsx @@ -0,0 +1,71 @@ +import {useCallback, useState} from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as Dialog from '#/components/Dialog' +import {type StatefulControl} from '#/components/dialogs/Context' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' +import {Manage2FA} from '#/components/dialogs/EmailDialog/screens/Manage2FA' +import {Update} from '#/components/dialogs/EmailDialog/screens/Update' +import {VerificationReminder} from '#/components/dialogs/EmailDialog/screens/VerificationReminder' +import {Verify} from '#/components/dialogs/EmailDialog/screens/Verify' +import {type Screen, ScreenID} from '#/components/dialogs/EmailDialog/types' + +export type {Screen} from '#/components/dialogs/EmailDialog/types' +export {ScreenID as EmailDialogScreenID} from '#/components/dialogs/EmailDialog/types' + +export function useEmailDialogControl() { + return useGlobalDialogsControlContext().emailDialogControl +} + +export function EmailDialog() { + const {_} = useLingui() + const emailDialogControl = useEmailDialogControl() + const {isEmailVerified} = useAccountEmailState() + const onClose = useCallback(() => { + if (!isEmailVerified) { + if (emailDialogControl.value?.id === ScreenID.Verify) { + emailDialogControl.value.onCloseWithoutVerifying?.() + } + } + emailDialogControl.clear() + }, [isEmailVerified, emailDialogControl]) + + return ( + <Dialog.Outer control={emailDialogControl.control} onClose={onClose}> + <Dialog.Handle /> + + <Dialog.ScrollableInner + label={_(msg`Make adjustments to email settings for your account`)} + style={[{maxWidth: 400}]}> + <Inner control={emailDialogControl} /> + <Dialog.Close /> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} + +function Inner({control}: {control: StatefulControl<Screen>}) { + const [screen, showScreen] = useState(() => control.value) + + if (!screen) return null + + switch (screen.id) { + case ScreenID.Update: { + return <Update config={screen} showScreen={showScreen} /> + } + case ScreenID.Verify: { + return <Verify config={screen} showScreen={showScreen} /> + } + case ScreenID.VerificationReminder: { + return <VerificationReminder config={screen} showScreen={showScreen} /> + } + case ScreenID.Manage2FA: { + return <Manage2FA config={screen} showScreen={showScreen} /> + } + default: { + return null + } + } +} diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx new file mode 100644 index 000000000..1896ff27d --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx @@ -0,0 +1,254 @@ +import {useReducer, 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 {useCleanError} from '#/lib/hooks/useCleanError' +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 {useDialogContext} from '#/components/Dialog' +import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' +import { + isValidCode, + TokenField, +} from '#/components/dialogs/EmailDialog/components/TokenField' +import {useManageEmail2FA} from '#/components/dialogs/EmailDialog/data/useManageEmail2FA' +import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' +import {Divider} from '#/components/Divider' +import {Check_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' + +type State = { + error: string + step: 'email' | 'token' + emailStatus: 'pending' | 'success' | 'error' | 'default' + tokenStatus: 'pending' | 'success' | 'error' | 'default' +} + +type Action = + | { + type: 'setError' + error: string + } + | { + type: 'setStep' + step: 'email' | 'token' + } + | { + type: 'setEmailStatus' + status: State['emailStatus'] + } + | { + type: 'setTokenStatus' + status: State['tokenStatus'] + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setError': { + return { + ...state, + error: action.error, + emailStatus: 'error', + tokenStatus: 'error', + } + } + case 'setStep': { + return { + ...state, + error: '', + step: action.step, + } + } + case 'setEmailStatus': { + return { + ...state, + error: '', + emailStatus: action.status, + } + } + case 'setTokenStatus': { + return { + ...state, + error: '', + tokenStatus: action.status, + } + } + default: { + return state + } + } +} + +export function Disable() { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const {currentAccount} = useSession() + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + const {mutateAsync: manageEmail2FA} = useManageEmail2FA() + const control = useDialogContext() + + const [token, setToken] = useState('') + const [state, dispatch] = useReducer(reducer, { + error: '', + step: 'email', + emailStatus: 'default', + tokenStatus: 'default', + }) + + const handleSendEmail = async () => { + dispatch({type: 'setEmailStatus', status: 'pending'}) + try { + await wait(1000, requestEmailUpdate()) + dispatch({type: 'setEmailStatus', status: 'success'}) + setTimeout(() => { + dispatch({type: 'setStep', step: 'token'}) + }, 1000) + } catch (e) { + logger.error('Manage2FA: email update code request failed', { + safeMessage: e, + }) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to send email, please try again.`), + }) + } + } + + const handleManageEmail2FA = async () => { + if (!isValidCode(token)) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid code.`), + }) + return + } + + dispatch({type: 'setTokenStatus', status: 'pending'}) + + try { + await wait(1000, manageEmail2FA({enabled: false, token})) + dispatch({type: 'setTokenStatus', status: 'success'}) + setTimeout(() => { + control.close() + }, 1000) + } catch (e) { + logger.error('Manage2FA: disable email 2FA failed', {safeMessage: e}) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to update email 2FA settings`), + }) + } + } + + return ( + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}> + <Trans>Disable email 2FA</Trans> + </Text> + + {state.step === 'email' ? ( + <> + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + To disable your email 2FA method, please verify your access to{' '} + <Span style={[a.font_bold]}>{currentAccount?.email}</Span> + </Trans> + </Text> + + <View style={[a.gap_lg, a.pt_sm]}> + {state.error && <Admonition type="error">{state.error}</Admonition>} + + <Button + label={_(msg`Send email`)} + size="large" + variant="solid" + color="primary" + onPress={handleSendEmail} + disabled={state.emailStatus === 'pending'}> + <ButtonText> + <Trans>Send email</Trans> + </ButtonText> + <ButtonIcon + icon={ + state.emailStatus === 'pending' + ? Loader + : state.emailStatus === 'success' + ? Check + : Envelope + } + /> + </Button> + + <Divider /> + + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Have a code?{' '} + <InlineLinkText + label={_(msg`Enter code`)} + {...createStaticClick(() => { + dispatch({type: 'setStep', step: 'token'}) + })}> + Click here. + </InlineLinkText> + </Trans> + </Text> + </View> + </> + ) : ( + <> + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + To disable your email 2FA method, please verify your access to{' '} + <Span style={[a.font_bold]}>{currentAccount?.email}</Span> + </Trans> + </Text> + + <View style={[a.gap_sm, a.py_sm]}> + <TokenField + value={token} + onChangeText={setToken} + onSubmitEditing={handleManageEmail2FA} + /> + <ResendEmailText onPress={handleSendEmail} /> + </View> + + {state.error && <Admonition type="error">{state.error}</Admonition>} + + <Button + label={_(msg`Disable 2FA`)} + size="large" + variant="solid" + color="primary" + onPress={handleManageEmail2FA} + disabled={ + !token || token.length !== 11 || state.tokenStatus === 'pending' + }> + <ButtonText> + <Trans>Disable 2FA</Trans> + </ButtonText> + {state.tokenStatus === 'pending' ? ( + <ButtonIcon icon={Loader} /> + ) : state.tokenStatus === 'success' ? ( + <ButtonIcon icon={Check} /> + ) : null} + </Button> + </> + )} + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx new file mode 100644 index 000000000..7a126792a --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx @@ -0,0 +1,137 @@ +import {useReducer} 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 {useCleanError} from '#/lib/hooks/useCleanError' +import {logger} from '#/logger' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogContext} from '#/components/Dialog' +import {useManageEmail2FA} from '#/components/dialogs/EmailDialog/data/useManageEmail2FA' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +type State = { + error: string + status: 'pending' | 'success' | 'error' | 'default' +} + +type Action = + | { + type: 'setError' + error: string + } + | { + type: 'setStatus' + status: State['status'] + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setError': { + return { + ...state, + error: action.error, + status: 'error', + } + } + case 'setStatus': { + return { + ...state, + error: '', + status: action.status, + } + } + default: { + return state + } + } +} + +export function Enable() { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const {gtPhone} = useBreakpoints() + const {mutateAsync: manageEmail2FA} = useManageEmail2FA() + const control = useDialogContext() + + const [state, dispatch] = useReducer(reducer, { + error: '', + status: 'default', + }) + + const handleManageEmail2FA = async () => { + dispatch({type: 'setStatus', status: 'pending'}) + + try { + await wait(1000, manageEmail2FA({enabled: true})) + dispatch({type: 'setStatus', status: 'success'}) + setTimeout(() => { + control.close() + }, 1000) + } catch (e) { + logger.error('Manage2FA: enable email 2FA failed', {safeMessage: e}) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to update email 2FA settings`), + }) + } + } + + return ( + <View style={[a.gap_lg]}> + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}> + <Trans>Enable email 2FA</Trans> + </Text> + + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans>Require an email code to sign in to your account.</Trans> + </Text> + </View> + + {state.error && <Admonition type="error">{state.error}</Admonition>} + + <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}> + <Button + label={_(msg`Enable`)} + size="large" + variant="solid" + color="primary" + onPress={handleManageEmail2FA} + disabled={state.status === 'pending'}> + <ButtonText> + <Trans>Enable</Trans> + </ButtonText> + <ButtonIcon + position="right" + icon={ + state.status === 'pending' + ? Loader + : state.status === 'success' + ? Check + : ShieldIcon + } + /> + </Button> + <Button + label={_(msg`Cancel`)} + size="large" + variant="solid" + color="secondary" + onPress={() => control.close()}> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx new file mode 100644 index 000000000..427a42b1f --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx @@ -0,0 +1,70 @@ +import {useEffect, useState} from 'react' +import {Trans} from '@lingui/macro' + +import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' +import {Disable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Disable' +import {Enable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Enable' +import { + ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' + +export function Manage2FA({showScreen}: ScreenProps<ScreenID.Manage2FA>) { + const {isEmailVerified, email2FAEnabled} = useAccountEmailState() + const [requestedAction, setRequestedAction] = useState< + 'enable' | 'disable' | null + >(null) + + useEffect(() => { + if (!isEmailVerified) { + showScreen({ + id: ScreenID.Verify, + instructions: [ + <Trans key="2fa"> + You need to verify your email address before you can enable email + 2FA. + </Trans>, + ], + onVerify: () => { + showScreen({ + id: ScreenID.Manage2FA, + }) + }, + }) + } + }, [isEmailVerified, showScreen]) + + /* + * Wacky state handling so that once 2FA settings change, we don't show the + * wrong step of this form - esb + */ + + if (email2FAEnabled) { + if (!requestedAction) { + setRequestedAction('disable') + return <Disable /> + } + + if (requestedAction === 'disable') { + return <Disable /> + } + if (requestedAction === 'enable') { + return <Enable /> + } + } else { + if (!requestedAction) { + setRequestedAction('enable') + return <Enable /> + } + + if (requestedAction === 'disable') { + return <Disable /> + } + if (requestedAction === 'enable') { + return <Enable /> + } + } + + // should never happen + return null +} diff --git a/src/components/dialogs/EmailDialog/screens/Update.tsx b/src/components/dialogs/EmailDialog/screens/Update.tsx new file mode 100644 index 000000000..be0af8807 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Update.tsx @@ -0,0 +1,319 @@ +import {useReducer} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {validate as validateEmail} from 'email-validator' + +import {wait} from '#/lib/async/wait' +import {useCleanError} from '#/lib/hooks/useCleanError' +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 { + isValidCode, + TokenField, +} from '#/components/dialogs/EmailDialog/components/TokenField' +import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' +import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' +import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail' +import { + type ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' +import {Divider} from '#/components/Divider' +import * as TextField from '#/components/forms/TextField' +import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +type State = { + step: 'email' | 'token' + mutationStatus: 'pending' | 'success' | 'error' | 'default' + error: string + emailValid: boolean + email: string + token: string +} + +type Action = + | { + type: 'setStep' + step: State['step'] + } + | { + type: 'setError' + error: string + } + | { + type: 'setMutationStatus' + status: State['mutationStatus'] + } + | { + type: 'setEmail' + value: string + } + | { + type: 'setToken' + value: string + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setStep': { + return { + ...state, + step: action.step, + } + } + case 'setError': { + return { + ...state, + error: action.error, + mutationStatus: 'error', + } + } + case 'setMutationStatus': { + return { + ...state, + error: '', + mutationStatus: action.status, + } + } + case 'setEmail': { + const emailValid = validateEmail(action.value) + return { + ...state, + step: 'email', + token: '', + email: action.value, + emailValid, + } + } + case 'setToken': { + return { + ...state, + error: '', + token: action.value, + } + } + } +} + +export function Update(_props: ScreenProps<ScreenID.Update>) { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const {currentAccount} = useSession() + const [state, dispatch] = useReducer(reducer, { + step: 'email', + mutationStatus: 'default', + error: '', + email: '', + emailValid: true, + token: '', + }) + + const {mutateAsync: updateEmail} = useUpdateEmail() + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() + + const handleEmailChange = (email: string) => { + dispatch({ + type: 'setEmail', + value: email, + }) + } + + const handleUpdateEmail = async () => { + if (state.step === 'token' && !isValidCode(state.token)) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid code.`), + }) + return + } + + dispatch({ + type: 'setMutationStatus', + status: 'pending', + }) + + if (state.emailValid === false) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid email address.`), + }) + return + } + + if (state.email === currentAccount!.email) { + dispatch({ + type: 'setError', + error: _(msg`This email is already associated with your account.`), + }) + return + } + + try { + const {status} = await wait( + 1000, + updateEmail({ + email: state.email, + token: state.token, + }), + ) + + if (status === 'tokenRequired') { + dispatch({ + type: 'setStep', + step: 'token', + }) + dispatch({ + type: 'setMutationStatus', + status: 'default', + }) + } else if (status === 'success') { + dispatch({ + type: 'setMutationStatus', + status: 'success', + }) + + try { + // fire off a confirmation email immediately + await requestEmailVerification() + } catch {} + } + } catch (e) { + logger.error('EmailDialog: update email failed', {safeMessage: e}) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to update email, please try again.`), + }) + } + } + + return ( + <View style={[a.gap_lg]}> + <Text style={[a.text_xl, a.font_heavy]}> + <Trans>Update your email</Trans> + </Text> + + {currentAccount?.emailAuthFactor && ( + <Admonition type="warning"> + <Trans> + If you update your email address, email 2FA will be disabled. + </Trans> + </Admonition> + )} + + <View style={[a.gap_md]}> + <View> + <Text style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans>Please enter your new email address.</Trans> + </Text> + <TextField.Root> + <TextField.Icon icon={Envelope} /> + <TextField.Input + label={_(msg`New email address`)} + placeholder={_(msg`alice@example.com`)} + defaultValue={state.email} + onChangeText={ + state.mutationStatus === 'success' + ? undefined + : handleEmailChange + } + keyboardType="email-address" + autoComplete="email" + autoCapitalize="none" + onSubmitEditing={handleUpdateEmail} + /> + </TextField.Root> + </View> + + {state.step === 'token' && ( + <> + <Divider /> + <View> + <Text style={[a.text_md, a.pb_sm, a.font_bold]}> + <Trans>Security step required</Trans> + </Text> + <Text + style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Please enter the security code we sent to your previous email + address. + </Trans> + </Text> + <TokenField + value={state.token} + onChangeText={ + state.mutationStatus === 'success' + ? undefined + : token => { + dispatch({ + type: 'setToken', + value: token, + }) + } + } + onSubmitEditing={handleUpdateEmail} + /> + {state.mutationStatus !== 'success' && ( + <ResendEmailText + onPress={requestEmailUpdate} + style={[a.pt_sm]} + /> + )} + </View> + </> + )} + + {state.error && <Admonition type="error">{state.error}</Admonition>} + </View> + + {state.mutationStatus === 'success' ? ( + <> + <Divider /> + <View style={[a.gap_sm]}> + <View style={[a.flex_row, a.gap_sm, a.align_center]}> + <Check fill={t.palette.positive_600} size="xs" /> + <Text style={[a.text_md, a.font_heavy]}> + <Trans>Success!</Trans> + </Text> + </View> + <Text style={[a.leading_snug]}> + <Trans> + Please click on the link in the email we just sent you to verify + your new email address. This is an important step to allow you + to continue enjoying all the features of Bluesky. + </Trans> + </Text> + </View> + </> + ) : ( + <Button + label={_(msg`Update email`)} + size="large" + variant="solid" + color="primary" + onPress={handleUpdateEmail} + disabled={ + !state.email || + (state.step === 'token' && + (!state.token || state.token.length !== 11)) || + state.mutationStatus === 'pending' + }> + <ButtonText> + <Trans>Update email</Trans> + </ButtonText> + {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />} + </Button> + )} + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx new file mode 100644 index 000000000..267b784b0 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx @@ -0,0 +1,99 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {useDialogContext} from '#/components/Dialog' +import { + ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' +import {Divider} from '#/components/Divider' +import {GradientFill} from '#/components/GradientFill' +import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' +import {Text} from '#/components/Typography' + +export function VerificationReminder({ + showScreen, +}: ScreenProps<ScreenID.VerificationReminder>) { + const t = useTheme() + const {_} = useLingui() + const {gtPhone, gtMobile} = useBreakpoints() + const control = useDialogContext() + + const dialogPadding = gtMobile ? a.p_2xl.padding : a.p_xl.padding + + return ( + <View style={[a.gap_lg]}> + <View + style={[ + a.absolute, + { + top: platform({web: dialogPadding, default: a.p_2xl.padding}) * -1, + left: dialogPadding * -1, + right: dialogPadding * -1, + height: 150, + }, + ]}> + <View + style={[ + a.absolute, + a.inset_0, + a.align_center, + a.justify_center, + a.overflow_hidden, + a.pt_md, + t.atoms.bg_contrast_100, + ]}> + <GradientFill gradient={tokens.gradients.primary} /> + <ShieldIcon width={64} fill="white" style={[a.z_10]} /> + </View> + </View> + + <View style={[a.mb_xs, {height: 150 - dialogPadding}]} /> + + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy]}> + <Trans>Please verify your email</Trans> + </Text> + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Your email has not yet been verified. Please verify your email in + order to enjoy all the features of Bluesky. + </Trans> + </Text> + </View> + + <Divider /> + + <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}> + <Button + label={_(msg`Get started`)} + variant="solid" + color="primary" + size="large" + onPress={() => + showScreen({ + id: ScreenID.Verify, + }) + }> + <ButtonText> + <Trans>Get started</Trans> + </ButtonText> + </Button> + <Button + label={_(msg`Maybe later`)} + accessibilityHint={_(msg`Snoozes the reminder`)} + variant="ghost" + color="secondary" + size="large" + onPress={() => control.close()}> + <ButtonText> + <Trans>Maybe later</Trans> + </ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/Verify.tsx b/src/components/dialogs/EmailDialog/screens/Verify.tsx new file mode 100644 index 000000000..dabd0d2f2 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Verify.tsx @@ -0,0 +1,386 @@ +import {useReducer} 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 {useCleanError} from '#/lib/hooks/useCleanError' +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 { + isValidCode, + TokenField, +} from '#/components/dialogs/EmailDialog/components/TokenField' +import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail' +import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' +import {useOnEmailVerified} from '#/components/dialogs/EmailDialog/events' +import { + ScreenID, + type ScreenProps, +} 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' + +type State = { + step: 'email' | 'token' | 'success' + mutationStatus: 'pending' | 'success' | 'error' | 'default' + error: string + token: string +} + +type Action = + | { + type: 'setStep' + step: State['step'] + } + | { + type: 'setError' + error: string + } + | { + type: 'setMutationStatus' + status: State['mutationStatus'] + } + | { + type: 'setToken' + value: string + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setStep': { + return { + ...state, + error: '', + mutationStatus: 'default', + step: action.step, + } + } + case 'setError': { + return { + ...state, + error: action.error, + mutationStatus: 'error', + } + } + case 'setMutationStatus': { + return { + ...state, + error: '', + mutationStatus: action.status, + } + } + case 'setToken': { + return { + ...state, + error: '', + token: action.value, + } + } + } +} + +export function Verify({config, showScreen}: ScreenProps<ScreenID.Verify>) { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const {currentAccount} = useSession() + const [state, dispatch] = useReducer(reducer, { + step: 'email', + mutationStatus: 'default', + error: '', + token: '', + }) + + const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() + const {mutateAsync: confirmEmail} = useConfirmEmail() + + useOnEmailVerified(() => { + if (config.onVerify) { + config.onVerify() + } else { + dispatch({ + type: 'setStep', + step: 'success', + }) + } + }) + + const handleRequestEmailVerification = async () => { + dispatch({ + type: 'setMutationStatus', + status: 'pending', + }) + + try { + await wait(1000, requestEmailVerification()) + dispatch({ + type: 'setMutationStatus', + status: 'success', + }) + } catch (e) { + logger.error('EmailDialog: sending verification email failed', { + safeMessage: e, + }) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to send email, please try again.`), + }) + } + } + + const handleConfirmEmail = async () => { + if (!isValidCode(state.token)) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid code.`), + }) + return + } + + dispatch({ + type: 'setMutationStatus', + status: 'pending', + }) + + try { + await wait(1000, confirmEmail({token: state.token})) + dispatch({ + type: 'setStep', + step: 'success', + }) + } catch (e) { + logger.error('EmailDialog: confirming email failed', { + safeMessage: e, + }) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to verify email, please try again.`), + }) + } + } + + if (state.step === 'success') { + return ( + <View style={[a.gap_lg]}> + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy]}> + <Span style={{top: 1}}> + <Check size="sm" fill={t.palette.positive_600} /> + </Span> + {' '} + <Trans>Email verification complete!</Trans> + </Text> + + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + You have successfully verified your email address. You can close + this dialog. + </Trans> + </Text> + </View> + </View> + ) + } + + return ( + <View style={[a.gap_lg]}> + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy]}> + {state.step === 'email' ? ( + state.mutationStatus === 'success' ? ( + <> + <Span style={{top: 1}}> + <Check size="sm" fill={t.palette.positive_600} /> + </Span> + {' '} + <Trans>Email sent!</Trans> + </> + ) : ( + <Trans>Verify your email</Trans> + ) + ) : ( + <Trans>Verify email code</Trans> + )} + </Text> + + {state.step === 'email' && state.mutationStatus !== 'success' && ( + <> + {config.instructions?.map((int, i) => ( + <Text + key={i} + style={[ + a.italic, + a.text_sm, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {int} + </Text> + ))} + </> + )} + + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + {state.step === 'email' ? ( + state.mutationStatus === 'success' ? ( + <Trans> + We sent an email to{' '} + <Span style={[a.font_bold, t.atoms.text]}> + {currentAccount!.email} + </Span>{' '} + containing a link. Please click on it to complete the email + verification process. + </Trans> + ) : ( + <Trans> + We'll send an email to{' '} + <Span style={[a.font_bold, t.atoms.text]}> + {currentAccount!.email} + </Span>{' '} + containing a link. Please click on it to complete the email + verification process. + </Trans> + ) + ) : ( + <Trans> + Please enter the code we sent to{' '} + <Span style={[a.font_bold, t.atoms.text]}> + {currentAccount!.email} + </Span>{' '} + below. + </Trans> + )} + </Text> + + {state.step === 'email' && state.mutationStatus !== 'success' && ( + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + If you need to update your email,{' '} + <InlineLinkText + label={_(msg`Click here to update your email`)} + {...createStaticClick(() => { + showScreen({id: ScreenID.Update}) + })}> + click here + </InlineLinkText> + . + </Trans> + </Text> + )} + + {state.step === 'email' && state.mutationStatus === 'success' && ( + <ResendEmailText onPress={requestEmailVerification} /> + )} + </View> + + {state.step === 'email' && state.mutationStatus !== 'success' ? ( + <> + {state.error && <Admonition type="error">{state.error}</Admonition>} + <Button + label={_(msg`Send verification email`)} + size="large" + variant="solid" + color="primary" + onPress={handleRequestEmailVerification} + disabled={state.mutationStatus === 'pending'}> + <ButtonText> + <Trans>Send email</Trans> + </ButtonText> + <ButtonIcon + icon={state.mutationStatus === 'pending' ? Loader : Envelope} + /> + </Button> + </> + ) : null} + + {state.step === 'email' && ( + <> + <Divider /> + + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Have a code?{' '} + <InlineLinkText + label={_(msg`Enter code`)} + {...createStaticClick(() => { + dispatch({ + type: 'setStep', + step: 'token', + }) + })}> + Click here. + </InlineLinkText> + </Trans> + </Text> + </> + )} + + {state.step === 'token' ? ( + <> + <TokenField + value={state.token} + onChangeText={token => { + dispatch({ + type: 'setToken', + value: token, + }) + }} + onSubmitEditing={handleConfirmEmail} + /> + + {state.error && <Admonition type="error">{state.error}</Admonition>} + + <Button + label={_(msg`Verify code`)} + size="large" + variant="solid" + color="primary" + onPress={handleConfirmEmail} + disabled={ + !state.token || + state.token.length !== 11 || + state.mutationStatus === 'pending' + }> + <ButtonText> + <Trans>Verify code</Trans> + </ButtonText> + {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />} + </Button> + + <Divider /> + + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Don't have a code or need a new one?{' '} + <InlineLinkText + label={_(msg`Click here to restart the verification process.`)} + {...createStaticClick(() => { + dispatch({ + type: 'setStep', + step: 'email', + }) + })}> + Click here. + </InlineLinkText> + </Trans> + </Text> + </> + ) : null} + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/types.ts b/src/components/dialogs/EmailDialog/types.ts new file mode 100644 index 000000000..7edc3facc --- /dev/null +++ b/src/components/dialogs/EmailDialog/types.ts @@ -0,0 +1,38 @@ +import {type ReactNode} from 'react' + +import {type DialogControlProps} from '#/components/Dialog' + +export type EmailDialogProps = { + control: DialogControlProps +} + +export type EmailDialogInnerProps = EmailDialogProps & {} + +export type Screen = + | { + id: ScreenID.Update + } + | { + id: ScreenID.Verify + instructions?: ReactNode[] + onVerify?: () => void + onCloseWithoutVerifying?: () => void + } + | { + id: ScreenID.VerificationReminder + } + | { + id: ScreenID.Manage2FA + } + +export enum ScreenID { + Update = 'Update', + Verify = 'Verify', + VerificationReminder = 'VerificationReminder', + Manage2FA = 'Manage2FA', +} + +export type ScreenProps<T extends ScreenID> = { + config: Extract<Screen, {id: T}> + showScreen: (screen: Screen) => void +} diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx index 7f31f550c..07b3a0c04 100644 --- a/src/components/dms/MessageProfileButton.tsx +++ b/src/components/dms/MessageProfileButton.tsx @@ -1,20 +1,18 @@ import React from 'react' import {View} from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' -import {msg} from '@lingui/macro' +import {type AppBskyActorDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {useEmail} from '#/lib/hooks/useEmail' -import {NavigationProp} from '#/lib/routes/types' +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' +import {type NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {canBeMessaged} from '#/components/dms/util' import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' @@ -26,8 +24,7 @@ export function MessageProfileButton({ const {_} = useLingui() const t = useTheme() const navigation = useNavigation<NavigationProp>() - const {needsEmailVerification} = useEmail() - const verifyEmailControl = useDialogControl() + const requireEmailVerification = useRequireEmailVerification() const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) const {mutate: initiateConvo} = useGetConvoForMembers({ @@ -45,11 +42,6 @@ export function MessageProfileButton({ return } - if (needsEmailVerification) { - verifyEmailControl.open() - return - } - if (convoAvailability.convo) { logEvent('chat:open', {logContext: 'ProfileHeader'}) navigation.navigate('MessagesConversation', { @@ -59,14 +51,15 @@ export function MessageProfileButton({ logEvent('chat:create', {logContext: 'ProfileHeader'}) initiateConvo([profile.did]) } - }, [ - needsEmailVerification, - verifyEmailControl, - navigation, - profile.did, - initiateConvo, - convoAvailability, - ]) + }, [navigation, profile.did, initiateConvo, convoAvailability]) + + const wrappedOnPress = requireEmailVerification(onPress, { + instructions: [ + <Trans key="message"> + Before you can message another user, you must first verify your email. + </Trans>, + ], + }) if (!convoAvailability) { // show pending state based on declaration @@ -102,15 +95,9 @@ export function MessageProfileButton({ shape="round" label={_(msg`Message ${profile.handle}`)} style={[a.justify_center]} - onPress={onPress}> + onPress={wrappedOnPress}> <ButtonIcon icon={Message} size="md" /> </Button> - <VerifyEmailDialog - reasonText={_( - msg`Before you may message another user, you must first verify your email.`, - )} - control={verifyEmailControl} - /> </> ) } else { diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index a5ba793fb..192e36b5d 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -1,8 +1,8 @@ import {useCallback} from 'react' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useEmail} from '#/lib/hooks/useEmail' +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' @@ -10,9 +10,7 @@ import {FAB} from '#/view/com/util/fab/FAB' import * as Toast from '#/view/com/util/Toast' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' -import {useDialogControl} from '#/components/Dialog' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' export function NewChat({ @@ -24,8 +22,7 @@ export function NewChat({ }) { const t = useTheme() const {_} = useLingui() - const {needsEmailVerification} = useEmail() - const verifyEmailControl = useDialogControl() + const requireEmailVerification = useRequireEmailVerification() const {mutate: createChat} = useGetConvoForMembers({ onSuccess: data => { @@ -49,17 +46,22 @@ export function NewChat({ [control, createChat], ) + const onPress = useCallback(() => { + control.open() + }, [control]) + const wrappedOnPress = requireEmailVerification(onPress, { + instructions: [ + <Trans key="new-chat"> + Before you can message another user, you must first verify your email. + </Trans>, + ], + }) + return ( <> <FAB testID="newChatFAB" - onPress={() => { - if (needsEmailVerification) { - verifyEmailControl.open() - } else { - control.open() - } - }} + onPress={wrappedOnPress} icon={<Plus size="lg" fill={t.palette.white} />} accessibilityRole="button" accessibilityLabel={_(msg`New chat`)} @@ -74,13 +76,6 @@ export function NewChat({ sortByMessageDeclaration /> </Dialog.Outer> - - <VerifyEmailDialog - reasonText={_( - msg`Before you may message another user, you must first verify your email.`, - )} - control={verifyEmailControl} - /> </> ) } |