diff --git a/src/components/dialogs/EmailDialog/components/TokenField.tsx b/src/components/dialogs/EmailDialog/components/TokenField.tsx index 26a51e4853..25a7dbe395 100644 --- a/src/components/dialogs/EmailDialog/components/TokenField.tsx +++ b/src/components/dialogs/EmailDialog/components/TokenField.tsx @@ -32,8 +32,11 @@ export function TokenField({ @@ -34,13 +34,13 @@ export function AccountSettingsScreen({}: Props) { const t = useTheme() const {_} = useLingui() const {currentAccount} = useSession() - const {openModal} = useModalControls() const emailDialogControl = useEmailDialogControl() const birthdayControl = useDialogControl() const changeHandleControl = useDialogControl() const changePasswordControl = useDialogControl() const exportCarControl = useDialogControl() const deactivateAccountControl = useDialogControl() + const deleteAccountControl = useDialogControl() return ( @@ -169,7 +169,7 @@ export function AccountSettingsScreen({}: Props) { openModal({name: 'delete-account'})} + onPress={() => deleteAccountControl.open()} destructive> @@ -185,6 +185,10 @@ export function AccountSettingsScreen({}: Props) { + ) } diff --git a/src/screens/Settings/components/DeleteAccountDialog.tsx b/src/screens/Settings/components/DeleteAccountDialog.tsx new file mode 100644 index 0000000000..fccf2f83a5 --- /dev/null +++ b/src/screens/Settings/components/DeleteAccountDialog.tsx @@ -0,0 +1,371 @@ +import {useCallback, useRef, useState} from 'react' +import {type TextInput, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {useCleanError} from '#/lib/hooks/useCleanError' +import {sanitizeHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import {useAgent, useSession, useSessionApi} from '#/state/session' +import {atoms as a, useTheme, web} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {type DialogOuterProps} from '#/components/Dialog' +import { + isValidCode, + TokenField, +} from '#/components/dialogs/EmailDialog/components/TokenField' +import * as TextField from '#/components/forms/TextField' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' +import {createStaticClick, InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import * as toast from '#/components/Toast' +import {Span, Text} from '#/components/Typography' +import {resetToTab} from '#/Navigation' + +const WHITESPACE_RE = /\s/gu +const PASSWORD_MIN_LENGTH = 8 + +enum Step { + SEND_CODE, + VERIFY_CODE, + CONFIRM_DELETION, +} + +enum EmailState { + DEFAULT, + PENDING, +} + +function isPasswordValid(password: string) { + return password.length >= PASSWORD_MIN_LENGTH +} + +export function DeleteAccountDialog({ + control, + deactivateDialogControl, +}: { + control: DialogOuterProps['control'] + deactivateDialogControl: DialogOuterProps['control'] +}) { + return ( + + + + ) +} + +function DeleteAccountDialogInner({ + control, + deactivateDialogControl, +}: { + control: DialogOuterProps['control'] + deactivateDialogControl: DialogOuterProps['control'] +}) { + const passwordRef = useRef(null) + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const agent = useAgent() + const {currentAccount} = useSession() + const {removeAccount} = useSessionApi() + + const [emailState, setEmailState] = useState(EmailState.DEFAULT) + const [emailSentCount, setEmailSentCount] = useState(0) + const [step, setStep] = useState(Step.SEND_CODE) + const [confirmCode, setConfirmCode] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + + const sendEmail = useCallback(async () => { + if (emailState === EmailState.PENDING) { + return + } + try { + setEmailState(EmailState.PENDING) + await agent.com.atproto.server.requestAccountDelete() + setError('') + setEmailSentCount(prevCount => prevCount + 1) + setStep(Step.VERIFY_CODE) + } catch (e: any) { + const {clean, raw} = cleanError(e) + const error = clean || raw || e + setError(error) + logger.error(raw || e, { + message: 'Failed to send account deletion verification email', + }) + } finally { + setEmailState(EmailState.DEFAULT) + } + }, [agent, cleanError, emailState, setEmailState]) + + const confirmDeletion = useCallback(async () => { + try { + setError('') + if (!currentAccount?.did) { + throw new Error('Invalid did') + } + const token = confirmCode.replace(WHITESPACE_RE, '') + // Inform chat service of intent to delete account. + const {success} = await agent.api.chat.bsky.actor.deleteAccount( + undefined, + { + headers: DM_SERVICE_HEADERS, + }, + ) + if (!success) { + throw new Error('Failed to inform chat service of account deletion') + } + await agent.com.atproto.server.deleteAccount({ + did: currentAccount.did, + password, + token, + }) + control.close(() => { + toast.show(_(msg`Your account has been deleted, see ya! ✌️`)) + resetToTab('HomeTab') + removeAccount(currentAccount) + }) + } catch (e: any) { + const {clean, raw} = cleanError(e) + const error = clean || raw || e + setError(error) + logger.error(raw || e, { + message: 'Failed to delete account', + }) + setConfirmCode('') + setPassword('') + setStep(Step.VERIFY_CODE) + } + }, [ + _, + agent, + cleanError, + confirmCode, + control, + currentAccount, + password, + removeAccount, + ]) + + const handleDeactivate = useCallback(() => { + control.close(() => deactivateDialogControl.open()) + }, [control, deactivateDialogControl]) + + const handleSendEmail = useCallback(() => { + void sendEmail() + }, [sendEmail]) + + const handleSubmitConfirmCode = useCallback(() => { + passwordRef.current?.focus() + }, []) + + const handleDeleteAccount = useCallback(() => { + setStep(Step.CONFIRM_DELETION) + }, [setStep]) + + const handleConfirmDeletion = useCallback(() => { + void confirmDeletion() + }, [confirmDeletion]) + + const currentHandle = sanitizeHandle(currentAccount?.handle ?? '', '@') + const currentEmail = currentAccount?.email ?? '(no email)' + + switch (step) { + case Step.SEND_CODE: + return ( + <> + + + {_(msg`Delete account “${currentHandle}”`)} + + + + For security reasons, we’ll need to send a confirmation code to + your email address{' '} + + {currentEmail} + + . + + + + + + + + {error && ( + + {error} + + )} + + + You can also{' '} + + temporarily deactivate + {' '} + your account instead. Your profile, posts, feeds, and lists will + no longer be visible to other Bluesky users. You can reactivate + your account at any time by logging in. + + + + ) + case Step.VERIFY_CODE: + return ( + <> + + + {_(msg`Delete account “${currentHandle}”`)} + + + + Check{' '} + + {currentEmail} + {' '} + for an email with the confirmation code to enter below: + + + + + + Confirmation code + + + + + {emailSentCount > 1 ? ( + + Email sent!{' '} + { + void handleSendEmail() + })}> + Click here to resend. + + + ) : ( + + Don’t see a code?{' '} + { + void handleSendEmail() + })}> + Click here to resend. + + + )}{' '} + + {emailState === EmailState.PENDING ? : null} + + + + + Password + + + + + + + + + + + {error && ( + + {error} + + )} + + ) + case Step.CONFIRM_DELETION: + return ( + <> + + + {_(msg`Are you really, really sure?`)} + + + + This will irreversably delete your Bluesky account{' '} + + {currentHandle} + {' '} + and all associated data. Note that this will affect any other{' '} + + AT Protocol + {' '} + services you use with this account. + + + + + + + + + ) + } +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index dab90c0af3..5f023e0680 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -11,10 +11,6 @@ export interface UserAddRemoveListsModal { onRemove?: (listUri: string) => void } -export interface DeleteAccountModal { - name: 'delete-account' -} - export interface ContentLanguagesSettingsModal { name: 'content-languages-settings' } @@ -23,9 +19,6 @@ export interface ContentLanguagesSettingsModal { * @deprecated DO NOT ADD NEW MODALS */ export type Modal = - // Account - | DeleteAccountModal - // Curation | ContentLanguagesSettingsModal diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx deleted file mode 100644 index b92a43d686..0000000000 --- a/src/view/com/modals/DeleteAccount.tsx +++ /dev/null @@ -1,342 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - SafeAreaView, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {LinearGradient} from 'expo-linear-gradient' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {DM_SERVICE_HEADERS} from '#/lib/constants' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {cleanError} from '#/lib/strings/errors' -import {colors, gradients, s} from '#/lib/styles' -import {useTheme} from '#/lib/ThemeContext' -import {useModalControls} from '#/state/modals' -import {useAgent, useSession, useSessionApi} from '#/state/session' -import {atoms as a, useTheme as useNewTheme} from '#/alf' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {Text as NewText} from '#/components/Typography' -import {IS_ANDROID, IS_WEB} from '#/env' -import {resetToTab} from '../../../Navigation' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {Text} from '../util/text/Text' -import * as Toast from '../util/Toast' -import {ScrollView, TextInput} from './util' - -export const snapPoints = IS_ANDROID ? ['90%'] : ['55%'] - -export function Component({}: {}) { - const pal = usePalette('default') - const theme = useTheme() - const t = useNewTheme() - const {currentAccount} = useSession() - const agent = useAgent() - const {removeAccount} = useSessionApi() - const {_} = useLingui() - const {closeModal} = useModalControls() - const {isMobile} = useWebMediaQueries() - const [isEmailSent, setIsEmailSent] = React.useState(false) - const [confirmCode, setConfirmCode] = React.useState('') - const [password, setPassword] = React.useState('') - const [isProcessing, setIsProcessing] = React.useState(false) - const [error, setError] = React.useState('') - const onPressSendEmail = async () => { - setError('') - setIsProcessing(true) - try { - await agent.com.atproto.server.requestAccountDelete() - setIsEmailSent(true) - } catch (e: any) { - setError(cleanError(e)) - } - setIsProcessing(false) - } - const onPressConfirmDelete = async () => { - if (!currentAccount?.did) { - throw new Error(`DeleteAccount modal: currentAccount.did is undefined`) - } - - setError('') - setIsProcessing(true) - const token = confirmCode.replace(/\s/g, '') - - try { - // inform chat service of intent to delete account - const {success} = await agent.api.chat.bsky.actor.deleteAccount( - undefined, - { - headers: DM_SERVICE_HEADERS, - }, - ) - if (!success) { - throw new Error('Failed to inform chat service of account deletion') - } - await agent.com.atproto.server.deleteAccount({ - did: currentAccount.did, - password, - token, - }) - Toast.show(_(msg`Your account has been deleted`)) - resetToTab('HomeTab') - removeAccount(currentAccount) - closeModal() - } catch (e: any) { - setError(cleanError(e)) - } - setIsProcessing(false) - } - const onCancel = () => { - closeModal() - } - return ( - - - - - - Delete Account{' '} - - " - - - {currentAccount?.handle} - - - " - - - - - {!isEmailSent ? ( - <> - - - For security reasons, we'll need to send a confirmation code to - your email address. - - - {error ? ( - - - - ) : undefined} - {isProcessing ? ( - - - - ) : ( - <> - - - - Send Email - - - - - - Cancel - - - - )} - - - - - - - - You can also temporarily deactivate your account instead, - and reactivate it at any time. - - - - - - ) : ( - <> - {/* TODO: Update this label to be more concise */} - - - Check your inbox for an email with the confirmation code to - enter below: - - - - - Please enter your password as well: - - - {error ? ( - - - - ) : undefined} - {isProcessing ? ( - - - - ) : ( - <> - - - Delete my account - - - - - Cancel - - - - )} - - )} - - - ) -} - -const styles = StyleSheet.create({ - titleContainer: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'center', - flexWrap: 'wrap', - marginTop: 12, - marginBottom: 12, - marginLeft: 20, - marginRight: 20, - }, - titleMobile: { - textAlign: 'center', - }, - titleDesktop: { - textAlign: 'center', - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - // @ts-ignore only rendered on web - maxWidth: '400px', - }, - description: { - textAlign: 'center', - paddingHorizontal: 22, - marginBottom: 10, - }, - mt20: { - marginTop: 20, - }, - mb20: { - marginBottom: 20, - }, - textInput: { - borderWidth: 1, - borderRadius: 6, - paddingHorizontal: 16, - paddingVertical: 12, - fontSize: 20, - marginHorizontal: 20, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - marginHorizontal: 20, - }, - evilBtn: { - backgroundColor: colors.red4, - }, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 4ebec459dd..a4ace3629f 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -7,7 +7,6 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useModalControls, useModals} from '#/state/modals' import {FullWindowOverlay} from '#/components/FullWindowOverlay' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' -import * as DeleteAccountModal from './DeleteAccount' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as UserAddRemoveListsModal from './UserAddRemoveLists' @@ -45,9 +44,6 @@ export function ModalsContainer() { if (activeModal?.name === 'user-add-remove-lists') { snapPoints = UserAddRemoveListsModal.snapPoints element = - } else if (activeModal?.name === 'delete-account') { - snapPoints = DeleteAccountModal.snapPoints - element = } else if (activeModal?.name === 'content-languages-settings') { snapPoints = ContentLanguagesSettingsModal.snapPoints element = diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index bdf8269925..cb932f4530 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -6,7 +6,6 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type Modal as ModalIface} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals' -import * as DeleteAccountModal from './DeleteAccount' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as UserAddRemoveLists from './UserAddRemoveLists' @@ -48,8 +47,6 @@ function Modal({modal}: {modal: ModalIface}) { let element if (modal.name === 'user-add-remove-lists') { element = - } else if (modal.name === 'delete-account') { - element = } else if (modal.name === 'content-languages-settings') { element = } else {