forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {useCallback, useRef, useState} from 'react'
2import {type TextInput, View} from 'react-native'
3import {msg} from '@lingui/core/macro'
4import {useLingui} from '@lingui/react'
5import {Trans} from '@lingui/react/macro'
6
7import {DM_SERVICE_HEADERS} from '#/lib/constants'
8import {useCleanError} from '#/lib/hooks/useCleanError'
9import {sanitizeHandle} from '#/lib/strings/handles'
10import {logger} from '#/logger'
11import {useAgent, useSession, useSessionApi} from '#/state/session'
12import {atoms as a, useTheme, web} from '#/alf'
13import {Admonition} from '#/components/Admonition'
14import {type DialogOuterProps} from '#/components/Dialog'
15import {
16 isValidCode,
17 TokenField,
18} from '#/components/dialogs/EmailDialog/components/TokenField'
19import * as TextField from '#/components/forms/TextField'
20import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
21import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
22import {createStaticClick, InlineLinkText} from '#/components/Link'
23import {Loader} from '#/components/Loader'
24import * as Prompt from '#/components/Prompt'
25import * as toast from '#/components/Toast'
26import {Span, Text} from '#/components/Typography'
27import {resetToTab} from '#/Navigation'
28
29const WHITESPACE_RE = /\s/gu
30const PASSWORD_MIN_LENGTH = 8
31
32enum Step {
33 SEND_CODE,
34 VERIFY_CODE,
35 CONFIRM_DELETION,
36}
37
38enum EmailState {
39 DEFAULT,
40 PENDING,
41}
42
43function isPasswordValid(password: string) {
44 return password.length >= PASSWORD_MIN_LENGTH
45}
46
47export function DeleteAccountDialog({
48 control,
49 deactivateDialogControl,
50}: {
51 control: DialogOuterProps['control']
52 deactivateDialogControl: DialogOuterProps['control']
53}) {
54 return (
55 <Prompt.Outer control={control}>
56 <DeleteAccountDialogInner
57 control={control}
58 deactivateDialogControl={deactivateDialogControl}
59 />
60 </Prompt.Outer>
61 )
62}
63
64function DeleteAccountDialogInner({
65 control,
66 deactivateDialogControl,
67}: {
68 control: DialogOuterProps['control']
69 deactivateDialogControl: DialogOuterProps['control']
70}) {
71 const passwordRef = useRef<TextInput | null>(null)
72 const t = useTheme()
73 const {_} = useLingui()
74 const cleanError = useCleanError()
75 const agent = useAgent()
76 const {currentAccount} = useSession()
77 const {removeAccount} = useSessionApi()
78
79 const [emailState, setEmailState] = useState(EmailState.DEFAULT)
80 const [emailSentCount, setEmailSentCount] = useState(0)
81 const [step, setStep] = useState(Step.SEND_CODE)
82 const [confirmCode, setConfirmCode] = useState('')
83 const [password, setPassword] = useState('')
84 const [error, setError] = useState('')
85
86 const sendEmail = useCallback(async () => {
87 if (emailState === EmailState.PENDING) {
88 return
89 }
90 try {
91 setEmailState(EmailState.PENDING)
92 await agent.com.atproto.server.requestAccountDelete()
93 setError('')
94 setEmailSentCount(prevCount => prevCount + 1)
95 setStep(Step.VERIFY_CODE)
96 } catch (e: any) {
97 const {clean, raw} = cleanError(e)
98 const error = clean || raw || e
99 setError(error)
100 logger.error(raw || e, {
101 message: 'Failed to send account deletion verification email',
102 })
103 } finally {
104 setEmailState(EmailState.DEFAULT)
105 }
106 }, [agent, cleanError, emailState, setEmailState])
107
108 const confirmDeletion = useCallback(async () => {
109 try {
110 setError('')
111 if (!currentAccount?.did) {
112 throw new Error('Invalid did')
113 }
114 const token = confirmCode.replace(WHITESPACE_RE, '')
115 // Inform chat service of intent to delete account.
116 const {success} = await agent.api.chat.bsky.actor.deleteAccount(
117 undefined,
118 {
119 headers: DM_SERVICE_HEADERS,
120 },
121 )
122 if (!success) {
123 throw new Error('Failed to inform chat service of account deletion')
124 }
125 await agent.com.atproto.server.deleteAccount({
126 did: currentAccount.did,
127 password,
128 token,
129 })
130 control.close(() => {
131 toast.show(_(msg`Your account has been deleted, see ya! ✌️`))
132 resetToTab('HomeTab')
133 removeAccount(currentAccount)
134 })
135 } catch (e: any) {
136 const {clean, raw} = cleanError(e)
137 const error = clean || raw || e
138 setError(error)
139 logger.error(raw || e, {
140 message: 'Failed to delete account',
141 })
142 setConfirmCode('')
143 setPassword('')
144 setStep(Step.VERIFY_CODE)
145 }
146 }, [
147 _,
148 agent,
149 cleanError,
150 confirmCode,
151 control,
152 currentAccount,
153 password,
154 removeAccount,
155 ])
156
157 const handleDeactivate = useCallback(() => {
158 control.close(() => deactivateDialogControl.open())
159 }, [control, deactivateDialogControl])
160
161 const handleSendEmail = useCallback(() => {
162 void sendEmail()
163 }, [sendEmail])
164
165 const handleSubmitConfirmCode = useCallback(() => {
166 passwordRef.current?.focus()
167 }, [])
168
169 const handleDeleteAccount = useCallback(() => {
170 setStep(Step.CONFIRM_DELETION)
171 }, [setStep])
172
173 const handleConfirmDeletion = useCallback(() => {
174 void confirmDeletion()
175 }, [confirmDeletion])
176
177 const currentHandle = sanitizeHandle(currentAccount?.handle ?? '', '@')
178 const currentEmail = currentAccount?.email ?? '(no email)'
179
180 switch (step) {
181 case Step.SEND_CODE:
182 return (
183 <>
184 <Prompt.Content>
185 <Prompt.TitleText>
186 {_(msg`Delete account “${currentHandle}”`)}
187 </Prompt.TitleText>
188 <Prompt.DescriptionText>
189 <Trans>
190 For security reasons, we’ll need to send a confirmation code to
191 your email address{' '}
192 <Span style={[a.font_semi_bold, t.atoms.text]}>
193 {currentEmail}
194 </Span>
195 .
196 </Trans>
197 </Prompt.DescriptionText>
198 </Prompt.Content>
199 <Prompt.Actions>
200 <Prompt.Action
201 icon={emailState === EmailState.PENDING ? Loader : Envelope}
202 cta={_(msg`Send email`)}
203 shouldCloseOnPress={false}
204 onPress={handleSendEmail}
205 />
206 <Prompt.Cancel />
207 </Prompt.Actions>
208 {error && (
209 <Admonition style={[a.mt_lg]} type="error">
210 <Text style={[a.flex_1, a.leading_snug]}>{error}</Text>
211 </Admonition>
212 )}
213 <Admonition style={[a.mt_lg]} type="tip">
214 <Trans>
215 You can also{' '}
216 <Span
217 style={[{color: t.palette.primary_500}, web(a.underline)]}
218 onPress={handleDeactivate}>
219 temporarily deactivate
220 </Span>{' '}
221 your account instead. Your profile, posts, feeds, and lists will
222 no longer be visible to other Bluesky users. You can reactivate
223 your account at any time by logging in.
224 </Trans>
225 </Admonition>
226 </>
227 )
228 case Step.VERIFY_CODE:
229 return (
230 <>
231 <Prompt.Content>
232 <Prompt.TitleText>
233 {_(msg`Delete account “${currentHandle}”`)}
234 </Prompt.TitleText>
235 <Prompt.DescriptionText>
236 <Trans>
237 Check{' '}
238 <Span style={[a.font_semi_bold, t.atoms.text]}>
239 {currentEmail}
240 </Span>{' '}
241 for an email with the confirmation code to enter below:
242 </Trans>
243 </Prompt.DescriptionText>
244 </Prompt.Content>
245 <View style={[a.mb_xs]}>
246 <TextField.LabelText>
247 <Trans>Confirmation code</Trans>
248 </TextField.LabelText>
249 <TokenField
250 value={confirmCode}
251 onChangeText={setConfirmCode}
252 onSubmitEditing={handleSubmitConfirmCode}
253 />
254 </View>
255 <Text
256 style={[
257 a.text_sm,
258 a.leading_snug,
259 a.mb_lg,
260 t.atoms.text_contrast_medium,
261 ]}>
262 {emailSentCount > 1 ? (
263 <Trans>
264 Email sent!{' '}
265 <InlineLinkText
266 label={_(msg`Resend`)}
267 {...createStaticClick(() => {
268 void handleSendEmail()
269 })}>
270 Click here to resend.
271 </InlineLinkText>
272 </Trans>
273 ) : (
274 <Trans>
275 Don’t see a code?{' '}
276 <InlineLinkText
277 label={_(msg`Resend`)}
278 {...createStaticClick(() => {
279 void handleSendEmail()
280 })}>
281 Click here to resend.
282 </InlineLinkText>
283 </Trans>
284 )}{' '}
285 <Span style={{top: 1}}>
286 {emailState === EmailState.PENDING ? <Loader size="xs" /> : null}
287 </Span>
288 </Text>
289 <View style={[a.mb_xl]}>
290 <TextField.LabelText>
291 <Trans>Password</Trans>
292 </TextField.LabelText>
293 <TextField.Root>
294 <TextField.Icon icon={Lock} />
295 <TextField.Input
296 inputRef={passwordRef}
297 testID="newPasswordInput"
298 label={_(msg`Enter your password`)}
299 autoCapitalize="none"
300 autoCorrect={false}
301 returnKeyType="done"
302 secureTextEntry={true}
303 autoComplete="off"
304 clearButtonMode="while-editing"
305 passwordRules={`minlength: ${PASSWORD_MIN_LENGTH}};`}
306 value={password}
307 onChangeText={setPassword}
308 onSubmitEditing={handleDeleteAccount}
309 />
310 </TextField.Root>
311 </View>
312 <Prompt.Actions>
313 <Prompt.Action
314 color="negative"
315 disabled={!isValidCode(confirmCode) || !isPasswordValid(password)}
316 cta={_(msg`Delete my account`)}
317 shouldCloseOnPress={false}
318 onPress={handleDeleteAccount}
319 />
320 <Prompt.Cancel />
321 </Prompt.Actions>
322 {error && (
323 <Admonition style={[a.mt_lg]} type="error">
324 <Text style={[a.flex_1, a.leading_snug]}>{error}</Text>
325 </Admonition>
326 )}
327 </>
328 )
329 case Step.CONFIRM_DELETION:
330 return (
331 <>
332 <Prompt.Content>
333 <Prompt.TitleText>
334 {_(msg`Are you really, really sure?`)}
335 </Prompt.TitleText>
336 <Prompt.DescriptionText>
337 <Trans>
338 This will irreversibly delete your Bluesky account{' '}
339 <Span style={[a.font_semi_bold, t.atoms.text]}>
340 {currentHandle}
341 </Span>{' '}
342 and all associated data. Note that this will affect any other{' '}
343 <InlineLinkText
344 label={_(msg`Learn more about the AT Protocol.`)}
345 style={[a.text_md]}
346 to="https://bsky.social/about/faq">
347 AT Protocol
348 </InlineLinkText>{' '}
349 services you use with this account.
350 </Trans>
351 </Prompt.DescriptionText>
352 </Prompt.Content>
353 <Prompt.Actions>
354 <Prompt.Action
355 color="negative"
356 cta={_(msg`Yes, delete my account`)}
357 shouldCloseOnPress={false}
358 onPress={handleConfirmDeletion}
359 />
360 <Prompt.Cancel />
361 </Prompt.Actions>
362 </>
363 )
364 }
365}