Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[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>

authored by

Eric Bailey
Samuel Newman
surfdude29
and committed by
GitHub
0f96669f 0edd3bd3

+2257 -402
+8 -5
src/Navigation.tsx
··· 104 104 import TopicScreen from '#/screens/Topic' 105 105 import {VideoFeed} from '#/screens/VideoFeed' 106 106 import {useTheme} from '#/alf' 107 - import {useDialogControl} from '#/components/Dialog' 108 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 107 + import { 108 + EmailDialogScreenID, 109 + useEmailDialogControl, 110 + } from '#/components/dialogs/EmailDialog' 109 111 import {router} from '#/routes' 110 112 import {Referrer} from '../modules/expo-bluesky-swiss-army' 111 113 ··· 738 740 const theme = useColorSchemeStyle(DefaultTheme, DarkTheme) 739 741 const {currentAccount} = useSession() 740 742 const prevLoggedRouteName = React.useRef<string | undefined>(undefined) 741 - const verifyEmailDialogControl = useDialogControl() 743 + const emailDialogControl = useEmailDialogControl() 742 744 743 745 function onReady() { 744 746 prevLoggedRouteName.current = getCurrentRouteName() 745 747 if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { 746 - verifyEmailDialogControl.open() 748 + emailDialogControl.open({ 749 + id: EmailDialogScreenID.VerificationReminder, 750 + }) 747 751 snoozeEmailConfirmationPrompt() 748 752 } 749 753 } ··· 768 772 }}> 769 773 {children} 770 774 </NavigationContainer> 771 - <VerifyEmailDialog control={verifyEmailDialogControl} reminder /> 772 775 </> 773 776 ) 774 777 }
+7 -1
src/components/Dialog/index.web.tsx
··· 195 195 onDismiss={close} 196 196 style={{display: 'flex', flexDirection: 'column'}}> 197 197 {header} 198 - <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}> 198 + <View 199 + style={[ 200 + gtMobile ? a.p_2xl : a.p_xl, 201 + a.overflow_hidden, 202 + a.rounded_md, 203 + contentContainerStyle, 204 + ]}> 199 205 {children} 200 206 </View> 201 207 </DismissableLayer.DismissableLayer>
+25 -25
src/components/StarterPack/ProfileStarterPacks.tsx
··· 18 18 19 19 import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack' 20 20 import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' 21 - import {useEmail} from '#/lib/hooks/useEmail' 21 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 22 22 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 23 23 import {type NavigationProp} from '#/lib/routes/types' 24 24 import {parseStarterPackUri} from '#/lib/strings/starter-pack' ··· 30 30 import {atoms as a, ios, useTheme} from '#/alf' 31 31 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 32 32 import {useDialogControl} from '#/components/Dialog' 33 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 34 33 import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 35 34 import {LinearGradientBackground} from '#/components/LinearGradientBackground' 36 35 import {Loader} from '#/components/Loader' ··· 197 196 const confirmDialogControl = useDialogControl() 198 197 const followersDialogControl = useDialogControl() 199 198 const errorDialogControl = useDialogControl() 200 - 201 - const {needsEmailVerification} = useEmail() 202 - const verifyEmailControl = useDialogControl() 199 + const requireEmailVerification = useRequireEmailVerification() 203 200 204 201 const [isGenerating, setIsGenerating] = useState(false) 205 202 ··· 230 227 generateStarterPack() 231 228 } 232 229 230 + const openConfirmDialog = useCallback(() => { 231 + confirmDialogControl.open() 232 + }, [confirmDialogControl]) 233 + const wrappedOpenConfirmDialog = requireEmailVerification(openConfirmDialog, { 234 + instructions: [ 235 + <Trans key="confirm"> 236 + Before creating a starter pack, you must first verify your email. 237 + </Trans>, 238 + ], 239 + }) 240 + const navToWizard = useCallback(() => { 241 + navigation.navigate('StarterPackWizard') 242 + }, [navigation]) 243 + const wrappedNavToWizard = requireEmailVerification(navToWizard, { 244 + instructions: [ 245 + <Trans key="nav"> 246 + Before creating a starter pack, you must first verify your email. 247 + </Trans>, 248 + ], 249 + }) 250 + 233 251 return ( 234 252 <LinearGradientBackground 235 253 style={[ ··· 258 276 color="primary" 259 277 size="small" 260 278 disabled={isGenerating} 261 - onPress={() => { 262 - if (needsEmailVerification) { 263 - verifyEmailControl.open() 264 - } else { 265 - confirmDialogControl.open() 266 - } 267 - }} 279 + onPress={wrappedOpenConfirmDialog} 268 280 style={{backgroundColor: 'transparent'}}> 269 281 <ButtonText style={{color: 'white'}}> 270 282 <Trans>Make one for me</Trans> ··· 277 289 color="primary" 278 290 size="small" 279 291 disabled={isGenerating} 280 - onPress={() => { 281 - if (needsEmailVerification) { 282 - verifyEmailControl.open() 283 - } else { 284 - navigation.navigate('StarterPackWizard') 285 - } 286 - }} 292 + onPress={wrappedNavToWizard} 287 293 style={{ 288 294 backgroundColor: 'white', 289 295 borderColor: 'white', ··· 338 344 )} 339 345 onConfirm={generate} 340 346 confirmButtonCta={_(msg`Retry`)} 341 - /> 342 - <VerifyEmailDialog 343 - reasonText={_( 344 - msg`Before creating a starter pack, you must first verify your email.`, 345 - )} 346 - control={verifyEmailControl} 347 347 /> 348 348 </LinearGradientBackground> 349 349 )
+13 -2
src/components/dialogs/Context.tsx
··· 1 1 import {createContext, useContext, useMemo, useState} from 'react' 2 2 3 3 import * as Dialog from '#/components/Dialog' 4 + import {type Screen} from '#/components/dialogs/EmailDialog/types' 4 5 5 6 type Control = Dialog.DialogControlProps 6 7 ··· 15 16 mutedWordsDialogControl: Control 16 17 signinDialogControl: Control 17 18 inAppBrowserConsentControl: StatefulControl<string> 19 + emailDialogControl: StatefulControl<Screen> 18 20 } 19 21 20 22 const ControlsContext = createContext<ControlsContext | null>(null) ··· 33 35 const mutedWordsDialogControl = Dialog.useDialogControl() 34 36 const signinDialogControl = Dialog.useDialogControl() 35 37 const inAppBrowserConsentControl = useStatefulDialogControl<string>() 38 + const emailDialogControl = useStatefulDialogControl<Screen>() 36 39 37 40 const ctx = useMemo<ControlsContext>( 38 41 () => ({ 39 42 mutedWordsDialogControl, 40 43 signinDialogControl, 41 44 inAppBrowserConsentControl, 45 + emailDialogControl, 42 46 }), 43 - [mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl], 47 + [ 48 + mutedWordsDialogControl, 49 + signinDialogControl, 50 + inAppBrowserConsentControl, 51 + emailDialogControl, 52 + ], 44 53 ) 45 54 46 55 return ( ··· 48 57 ) 49 58 } 50 59 51 - function useStatefulDialogControl<T>(initialValue?: T): StatefulControl<T> { 60 + export function useStatefulDialogControl<T>( 61 + initialValue?: T, 62 + ): StatefulControl<T> { 52 63 const [value, setValue] = useState(initialValue) 53 64 const control = Dialog.useDialogControl() 54 65 return useMemo(
+56
src/components/dialogs/EmailDialog/components/ResendEmailText.tsx
··· 1 + import {useState} from 'react' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {wait} from '#/lib/async/wait' 6 + import {atoms as a, type TextStyleProp, useTheme} from '#/alf' 7 + import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 8 + import {createStaticClick, InlineLinkText} from '#/components/Link' 9 + import {Loader} from '#/components/Loader' 10 + import {Span, Text} from '#/components/Typography' 11 + 12 + export function ResendEmailText({ 13 + onPress, 14 + style, 15 + }: TextStyleProp & { 16 + onPress: () => Promise<any> 17 + }) { 18 + const t = useTheme() 19 + const {_} = useLingui() 20 + const [status, setStatus] = useState<'sending' | 'success' | null>(null) 21 + 22 + const handleOnPress = async () => { 23 + setStatus('sending') 24 + try { 25 + await wait(1000, onPress()) 26 + setStatus('success') 27 + } finally { 28 + setTimeout(() => { 29 + setStatus(null) 30 + }, 1000) 31 + } 32 + } 33 + 34 + return ( 35 + <Text 36 + style={[a.italic, a.leading_snug, t.atoms.text_contrast_medium, style]}> 37 + <Trans> 38 + Don't see an email?{' '} 39 + <InlineLinkText 40 + label={_(msg`Resend`)} 41 + {...createStaticClick(() => { 42 + handleOnPress() 43 + })}> 44 + Click here to resend. 45 + </InlineLinkText> 46 + </Trans>{' '} 47 + <Span style={{top: 1}}> 48 + {status === 'sending' ? ( 49 + <Loader size="xs" /> 50 + ) : status === 'success' ? ( 51 + <Check size="xs" fill={t.palette.positive_500} /> 52 + ) : null} 53 + </Span> 54 + </Text> 55 + ) 56 + }
+45
src/components/dialogs/EmailDialog/components/TokenField.tsx
··· 1 + import {type TextInputProps, View} from 'react-native' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import * as TextField from '#/components/forms/TextField' 6 + import {Shield_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 7 + 8 + export function normalizeCode(value: string) { 9 + const normalized = value.toUpperCase().replace(/[^A-Z2-7]/g, '') 10 + if (normalized.length <= 5) return normalized 11 + return `${normalized.slice(0, 5)}-${normalized.slice(5)}` 12 + } 13 + 14 + export function isValidCode(value?: string) { 15 + return Boolean(value && /^[A-Z2-7]{5}-[A-Z2-7]{5}$/.test(value)) 16 + } 17 + 18 + export function TokenField({ 19 + value, 20 + onChangeText, 21 + onSubmitEditing, 22 + }: Pick<TextInputProps, 'value' | 'onChangeText' | 'onSubmitEditing'>) { 23 + const {_} = useLingui() 24 + const isInvalid = Boolean(value && value.length > 10 && !isValidCode(value)) 25 + 26 + const handleOnChangeText = (v: string) => { 27 + onChangeText?.(normalizeCode(v)) 28 + } 29 + 30 + return ( 31 + <View> 32 + <TextField.Root> 33 + <TextField.Icon icon={Shield} /> 34 + <TextField.Input 35 + isInvalid={isInvalid} 36 + label={_(msg`Confirmation code`)} 37 + placeholder="XXXXX-XXXXX" 38 + value={value} 39 + onChangeText={handleOnChangeText} 40 + onSubmitEditing={onSubmitEditing} 41 + /> 42 + </TextField.Root> 43 + </View> 44 + ) 45 + }
+79
src/components/dialogs/EmailDialog/data/useAccountEmailState.ts
··· 1 + import {useCallback, useEffect, useState} from 'react' 2 + import {useQuery, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {useAgent} from '#/state/session' 5 + import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events' 6 + 7 + export type AccountEmailState = { 8 + isEmailVerified: boolean 9 + email2FAEnabled: boolean 10 + } 11 + 12 + export const accountEmailStateQueryKey = ['accountEmailState'] as const 13 + 14 + export function useInvalidateAccountEmailState() { 15 + const qc = useQueryClient() 16 + 17 + return useCallback(() => { 18 + return qc.invalidateQueries({ 19 + queryKey: accountEmailStateQueryKey, 20 + }) 21 + }, [qc]) 22 + } 23 + 24 + export function useUpdateAccountEmailStateQueryCache() { 25 + const qc = useQueryClient() 26 + 27 + return useCallback( 28 + (data: AccountEmailState) => { 29 + return qc.setQueriesData( 30 + { 31 + queryKey: accountEmailStateQueryKey, 32 + }, 33 + data, 34 + ) 35 + }, 36 + [qc], 37 + ) 38 + } 39 + 40 + export function useAccountEmailState() { 41 + const agent = useAgent() 42 + const [prevIsEmailVerified, setPrevEmailIsVerified] = useState( 43 + !!agent.session?.emailConfirmed, 44 + ) 45 + const fallbackData: AccountEmailState = { 46 + isEmailVerified: !!agent.session?.emailConfirmed, 47 + email2FAEnabled: !!agent.session?.emailAuthFactor, 48 + } 49 + const query = useQuery<AccountEmailState>({ 50 + enabled: !!agent.session, 51 + refetchOnWindowFocus: true, 52 + queryKey: accountEmailStateQueryKey, 53 + queryFn: async () => { 54 + // will also trigger updates to `#/state/session` data 55 + const {data} = await agent.resumeSession(agent.session!) 56 + return { 57 + isEmailVerified: !!data.emailConfirmed, 58 + email2FAEnabled: !!data.emailAuthFactor, 59 + } 60 + }, 61 + }) 62 + 63 + const state = query.data ?? fallbackData 64 + 65 + /* 66 + * This will emit `n` times for each instance of this hook. So the listeners 67 + * all use `once` to prevent multiple handlers firing. 68 + */ 69 + useEffect(() => { 70 + if (state.isEmailVerified && !prevIsEmailVerified) { 71 + setPrevEmailIsVerified(true) 72 + emitEmailVerified() 73 + } else if (!state.isEmailVerified && prevIsEmailVerified) { 74 + setPrevEmailIsVerified(false) 75 + } 76 + }, [state, prevIsEmailVerified]) 77 + 78 + return state 79 + }
+29
src/components/dialogs/EmailDialog/data/useConfirmEmail.ts
··· 1 + import {useMutation} from '@tanstack/react-query' 2 + 3 + import {useAgent, useSession} from '#/state/session' 4 + import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' 5 + 6 + export function useConfirmEmail() { 7 + const agent = useAgent() 8 + const {currentAccount} = useSession() 9 + const updateAccountEmailStateQueryCache = 10 + useUpdateAccountEmailStateQueryCache() 11 + 12 + return useMutation({ 13 + mutationFn: async ({token}: {token: string}) => { 14 + if (!currentAccount?.email) { 15 + throw new Error('No email found for the current account') 16 + } 17 + 18 + await agent.com.atproto.server.confirmEmail({ 19 + email: currentAccount.email, 20 + token: token.trim(), 21 + }) 22 + const {data} = await agent.resumeSession(agent.session!) 23 + updateAccountEmailStateQueryCache({ 24 + isEmailVerified: !!data.emailConfirmed, 25 + email2FAEnabled: !!data.emailAuthFactor, 26 + }) 27 + }, 28 + }) 29 + }
+35
src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts
··· 1 + import {useMutation} from '@tanstack/react-query' 2 + 3 + import {useAgent, useSession} from '#/state/session' 4 + import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' 5 + 6 + export function useManageEmail2FA() { 7 + const agent = useAgent() 8 + const {currentAccount} = useSession() 9 + const updateAccountEmailStateQueryCache = 10 + useUpdateAccountEmailStateQueryCache() 11 + 12 + return useMutation({ 13 + mutationFn: async ({ 14 + enabled, 15 + token, 16 + }: 17 + | {enabled: true; token?: undefined} 18 + | {enabled: false; token: string}) => { 19 + if (!currentAccount?.email) { 20 + throw new Error('No email found for the current account') 21 + } 22 + 23 + await agent.com.atproto.server.updateEmail({ 24 + email: currentAccount.email, 25 + emailAuthFactor: enabled, 26 + token, 27 + }) 28 + const {data} = await agent.resumeSession(agent.session!) 29 + updateAccountEmailStateQueryCache({ 30 + isEmailVerified: !!data.emailConfirmed, 31 + email2FAEnabled: !!data.emailAuthFactor, 32 + }) 33 + }, 34 + }) 35 + }
+13
src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts
··· 1 + import {useMutation} from '@tanstack/react-query' 2 + 3 + import {useAgent} from '#/state/session' 4 + 5 + export function useRequestEmailUpdate() { 6 + const agent = useAgent() 7 + 8 + return useMutation({ 9 + mutationFn: async () => { 10 + return (await agent.com.atproto.server.requestEmailUpdate()).data 11 + }, 12 + }) 13 + }
+13
src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts
··· 1 + import {useMutation} from '@tanstack/react-query' 2 + 3 + import {useAgent} from '#/state/session' 4 + 5 + export function useRequestEmailVerification() { 6 + const agent = useAgent() 7 + 8 + return useMutation({ 9 + mutationFn: async () => { 10 + await agent.com.atproto.server.requestEmailConfirmation() 11 + }, 12 + }) 13 + }
+45
src/components/dialogs/EmailDialog/data/useUpdateEmail.ts
··· 1 + import {useMutation} from '@tanstack/react-query' 2 + 3 + import {useAgent} from '#/state/session' 4 + import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' 5 + 6 + async function updateEmailAndRefreshSession( 7 + agent: ReturnType<typeof useAgent>, 8 + email: string, 9 + token?: string, 10 + ) { 11 + await agent.com.atproto.server.updateEmail({email: email.trim(), token}) 12 + await agent.resumeSession(agent.session!) 13 + } 14 + 15 + export function useUpdateEmail() { 16 + const agent = useAgent() 17 + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() 18 + 19 + return useMutation< 20 + {status: 'tokenRequired' | 'success'}, 21 + Error, 22 + {email: string; token?: string} 23 + >({ 24 + mutationFn: async ({email, token}: {email: string; token?: string}) => { 25 + if (token) { 26 + await updateEmailAndRefreshSession(agent, email, token) 27 + return { 28 + status: 'success', 29 + } 30 + } else { 31 + const {tokenRequired} = await requestEmailUpdate() 32 + if (tokenRequired) { 33 + return { 34 + status: 'tokenRequired', 35 + } 36 + } else { 37 + await updateEmailAndRefreshSession(agent, email, token) 38 + return { 39 + status: 'success', 40 + } 41 + } 42 + } 43 + }, 44 + }) 45 + }
+23
src/components/dialogs/EmailDialog/events.ts
··· 1 + import {useEffect} from 'react' 2 + import EventEmitter from 'eventemitter3' 3 + 4 + const events = new EventEmitter<{ 5 + emailVerified: void 6 + }>() 7 + 8 + export function emitEmailVerified() { 9 + events.emit('emailVerified') 10 + } 11 + 12 + export function useOnEmailVerified(cb: () => void) { 13 + useEffect(() => { 14 + /* 15 + * N.B. Use `once` here, since the event can fire multiple times for each 16 + * instance of `useAccountEmailState` 17 + */ 18 + events.once('emailVerified', cb) 19 + return () => { 20 + events.off('emailVerified', cb) 21 + } 22 + }, [cb]) 23 + }
+71
src/components/dialogs/EmailDialog/index.tsx
··· 1 + import {useCallback, useState} from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import * as Dialog from '#/components/Dialog' 6 + import {type StatefulControl} from '#/components/dialogs/Context' 7 + import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 8 + import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' 9 + import {Manage2FA} from '#/components/dialogs/EmailDialog/screens/Manage2FA' 10 + import {Update} from '#/components/dialogs/EmailDialog/screens/Update' 11 + import {VerificationReminder} from '#/components/dialogs/EmailDialog/screens/VerificationReminder' 12 + import {Verify} from '#/components/dialogs/EmailDialog/screens/Verify' 13 + import {type Screen, ScreenID} from '#/components/dialogs/EmailDialog/types' 14 + 15 + export type {Screen} from '#/components/dialogs/EmailDialog/types' 16 + export {ScreenID as EmailDialogScreenID} from '#/components/dialogs/EmailDialog/types' 17 + 18 + export function useEmailDialogControl() { 19 + return useGlobalDialogsControlContext().emailDialogControl 20 + } 21 + 22 + export function EmailDialog() { 23 + const {_} = useLingui() 24 + const emailDialogControl = useEmailDialogControl() 25 + const {isEmailVerified} = useAccountEmailState() 26 + const onClose = useCallback(() => { 27 + if (!isEmailVerified) { 28 + if (emailDialogControl.value?.id === ScreenID.Verify) { 29 + emailDialogControl.value.onCloseWithoutVerifying?.() 30 + } 31 + } 32 + emailDialogControl.clear() 33 + }, [isEmailVerified, emailDialogControl]) 34 + 35 + return ( 36 + <Dialog.Outer control={emailDialogControl.control} onClose={onClose}> 37 + <Dialog.Handle /> 38 + 39 + <Dialog.ScrollableInner 40 + label={_(msg`Make adjustments to email settings for your account`)} 41 + style={[{maxWidth: 400}]}> 42 + <Inner control={emailDialogControl} /> 43 + <Dialog.Close /> 44 + </Dialog.ScrollableInner> 45 + </Dialog.Outer> 46 + ) 47 + } 48 + 49 + function Inner({control}: {control: StatefulControl<Screen>}) { 50 + const [screen, showScreen] = useState(() => control.value) 51 + 52 + if (!screen) return null 53 + 54 + switch (screen.id) { 55 + case ScreenID.Update: { 56 + return <Update config={screen} showScreen={showScreen} /> 57 + } 58 + case ScreenID.Verify: { 59 + return <Verify config={screen} showScreen={showScreen} /> 60 + } 61 + case ScreenID.VerificationReminder: { 62 + return <VerificationReminder config={screen} showScreen={showScreen} /> 63 + } 64 + case ScreenID.Manage2FA: { 65 + return <Manage2FA config={screen} showScreen={showScreen} /> 66 + } 67 + default: { 68 + return null 69 + } 70 + } 71 + }
+254
src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx
··· 1 + import {useReducer, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {wait} from '#/lib/async/wait' 7 + import {useCleanError} from '#/lib/hooks/useCleanError' 8 + import {logger} from '#/logger' 9 + import {useSession} from '#/state/session' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {Admonition} from '#/components/Admonition' 12 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 13 + import {useDialogContext} from '#/components/Dialog' 14 + import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' 15 + import { 16 + isValidCode, 17 + TokenField, 18 + } from '#/components/dialogs/EmailDialog/components/TokenField' 19 + import {useManageEmail2FA} from '#/components/dialogs/EmailDialog/data/useManageEmail2FA' 20 + import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' 21 + import {Divider} from '#/components/Divider' 22 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 23 + import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' 24 + import {createStaticClick, InlineLinkText} from '#/components/Link' 25 + import {Loader} from '#/components/Loader' 26 + import {Span, Text} from '#/components/Typography' 27 + 28 + type State = { 29 + error: string 30 + step: 'email' | 'token' 31 + emailStatus: 'pending' | 'success' | 'error' | 'default' 32 + tokenStatus: 'pending' | 'success' | 'error' | 'default' 33 + } 34 + 35 + type Action = 36 + | { 37 + type: 'setError' 38 + error: string 39 + } 40 + | { 41 + type: 'setStep' 42 + step: 'email' | 'token' 43 + } 44 + | { 45 + type: 'setEmailStatus' 46 + status: State['emailStatus'] 47 + } 48 + | { 49 + type: 'setTokenStatus' 50 + status: State['tokenStatus'] 51 + } 52 + 53 + function reducer(state: State, action: Action): State { 54 + switch (action.type) { 55 + case 'setError': { 56 + return { 57 + ...state, 58 + error: action.error, 59 + emailStatus: 'error', 60 + tokenStatus: 'error', 61 + } 62 + } 63 + case 'setStep': { 64 + return { 65 + ...state, 66 + error: '', 67 + step: action.step, 68 + } 69 + } 70 + case 'setEmailStatus': { 71 + return { 72 + ...state, 73 + error: '', 74 + emailStatus: action.status, 75 + } 76 + } 77 + case 'setTokenStatus': { 78 + return { 79 + ...state, 80 + error: '', 81 + tokenStatus: action.status, 82 + } 83 + } 84 + default: { 85 + return state 86 + } 87 + } 88 + } 89 + 90 + export function Disable() { 91 + const t = useTheme() 92 + const {_} = useLingui() 93 + const cleanError = useCleanError() 94 + const {currentAccount} = useSession() 95 + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() 96 + const {mutateAsync: manageEmail2FA} = useManageEmail2FA() 97 + const control = useDialogContext() 98 + 99 + const [token, setToken] = useState('') 100 + const [state, dispatch] = useReducer(reducer, { 101 + error: '', 102 + step: 'email', 103 + emailStatus: 'default', 104 + tokenStatus: 'default', 105 + }) 106 + 107 + const handleSendEmail = async () => { 108 + dispatch({type: 'setEmailStatus', status: 'pending'}) 109 + try { 110 + await wait(1000, requestEmailUpdate()) 111 + dispatch({type: 'setEmailStatus', status: 'success'}) 112 + setTimeout(() => { 113 + dispatch({type: 'setStep', step: 'token'}) 114 + }, 1000) 115 + } catch (e) { 116 + logger.error('Manage2FA: email update code request failed', { 117 + safeMessage: e, 118 + }) 119 + const {clean} = cleanError(e) 120 + dispatch({ 121 + type: 'setError', 122 + error: clean || _(msg`Failed to send email, please try again.`), 123 + }) 124 + } 125 + } 126 + 127 + const handleManageEmail2FA = async () => { 128 + if (!isValidCode(token)) { 129 + dispatch({ 130 + type: 'setError', 131 + error: _(msg`Please enter a valid code.`), 132 + }) 133 + return 134 + } 135 + 136 + dispatch({type: 'setTokenStatus', status: 'pending'}) 137 + 138 + try { 139 + await wait(1000, manageEmail2FA({enabled: false, token})) 140 + dispatch({type: 'setTokenStatus', status: 'success'}) 141 + setTimeout(() => { 142 + control.close() 143 + }, 1000) 144 + } catch (e) { 145 + logger.error('Manage2FA: disable email 2FA failed', {safeMessage: e}) 146 + const {clean} = cleanError(e) 147 + dispatch({ 148 + type: 'setError', 149 + error: clean || _(msg`Failed to update email 2FA settings`), 150 + }) 151 + } 152 + } 153 + 154 + return ( 155 + <View style={[a.gap_sm]}> 156 + <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}> 157 + <Trans>Disable email 2FA</Trans> 158 + </Text> 159 + 160 + {state.step === 'email' ? ( 161 + <> 162 + <Text 163 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 164 + <Trans> 165 + To disable your email 2FA method, please verify your access to{' '} 166 + <Span style={[a.font_bold]}>{currentAccount?.email}</Span> 167 + </Trans> 168 + </Text> 169 + 170 + <View style={[a.gap_lg, a.pt_sm]}> 171 + {state.error && <Admonition type="error">{state.error}</Admonition>} 172 + 173 + <Button 174 + label={_(msg`Send email`)} 175 + size="large" 176 + variant="solid" 177 + color="primary" 178 + onPress={handleSendEmail} 179 + disabled={state.emailStatus === 'pending'}> 180 + <ButtonText> 181 + <Trans>Send email</Trans> 182 + </ButtonText> 183 + <ButtonIcon 184 + icon={ 185 + state.emailStatus === 'pending' 186 + ? Loader 187 + : state.emailStatus === 'success' 188 + ? Check 189 + : Envelope 190 + } 191 + /> 192 + </Button> 193 + 194 + <Divider /> 195 + 196 + <Text 197 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 198 + <Trans> 199 + Have a code?{' '} 200 + <InlineLinkText 201 + label={_(msg`Enter code`)} 202 + {...createStaticClick(() => { 203 + dispatch({type: 'setStep', step: 'token'}) 204 + })}> 205 + Click here. 206 + </InlineLinkText> 207 + </Trans> 208 + </Text> 209 + </View> 210 + </> 211 + ) : ( 212 + <> 213 + <Text 214 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 215 + <Trans> 216 + To disable your email 2FA method, please verify your access to{' '} 217 + <Span style={[a.font_bold]}>{currentAccount?.email}</Span> 218 + </Trans> 219 + </Text> 220 + 221 + <View style={[a.gap_sm, a.py_sm]}> 222 + <TokenField 223 + value={token} 224 + onChangeText={setToken} 225 + onSubmitEditing={handleManageEmail2FA} 226 + /> 227 + <ResendEmailText onPress={handleSendEmail} /> 228 + </View> 229 + 230 + {state.error && <Admonition type="error">{state.error}</Admonition>} 231 + 232 + <Button 233 + label={_(msg`Disable 2FA`)} 234 + size="large" 235 + variant="solid" 236 + color="primary" 237 + onPress={handleManageEmail2FA} 238 + disabled={ 239 + !token || token.length !== 11 || state.tokenStatus === 'pending' 240 + }> 241 + <ButtonText> 242 + <Trans>Disable 2FA</Trans> 243 + </ButtonText> 244 + {state.tokenStatus === 'pending' ? ( 245 + <ButtonIcon icon={Loader} /> 246 + ) : state.tokenStatus === 'success' ? ( 247 + <ButtonIcon icon={Check} /> 248 + ) : null} 249 + </Button> 250 + </> 251 + )} 252 + </View> 253 + ) 254 + }
+137
src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx
··· 1 + import {useReducer} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {wait} from '#/lib/async/wait' 7 + import {useCleanError} from '#/lib/hooks/useCleanError' 8 + import {logger} from '#/logger' 9 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 10 + import {Admonition} from '#/components/Admonition' 11 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 + import {useDialogContext} from '#/components/Dialog' 13 + import {useManageEmail2FA} from '#/components/dialogs/EmailDialog/data/useManageEmail2FA' 14 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 15 + import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' 16 + import {Loader} from '#/components/Loader' 17 + import {Text} from '#/components/Typography' 18 + 19 + type State = { 20 + error: string 21 + status: 'pending' | 'success' | 'error' | 'default' 22 + } 23 + 24 + type Action = 25 + | { 26 + type: 'setError' 27 + error: string 28 + } 29 + | { 30 + type: 'setStatus' 31 + status: State['status'] 32 + } 33 + 34 + function reducer(state: State, action: Action): State { 35 + switch (action.type) { 36 + case 'setError': { 37 + return { 38 + ...state, 39 + error: action.error, 40 + status: 'error', 41 + } 42 + } 43 + case 'setStatus': { 44 + return { 45 + ...state, 46 + error: '', 47 + status: action.status, 48 + } 49 + } 50 + default: { 51 + return state 52 + } 53 + } 54 + } 55 + 56 + export function Enable() { 57 + const t = useTheme() 58 + const {_} = useLingui() 59 + const cleanError = useCleanError() 60 + const {gtPhone} = useBreakpoints() 61 + const {mutateAsync: manageEmail2FA} = useManageEmail2FA() 62 + const control = useDialogContext() 63 + 64 + const [state, dispatch] = useReducer(reducer, { 65 + error: '', 66 + status: 'default', 67 + }) 68 + 69 + const handleManageEmail2FA = async () => { 70 + dispatch({type: 'setStatus', status: 'pending'}) 71 + 72 + try { 73 + await wait(1000, manageEmail2FA({enabled: true})) 74 + dispatch({type: 'setStatus', status: 'success'}) 75 + setTimeout(() => { 76 + control.close() 77 + }, 1000) 78 + } catch (e) { 79 + logger.error('Manage2FA: enable email 2FA failed', {safeMessage: e}) 80 + const {clean} = cleanError(e) 81 + dispatch({ 82 + type: 'setError', 83 + error: clean || _(msg`Failed to update email 2FA settings`), 84 + }) 85 + } 86 + } 87 + 88 + return ( 89 + <View style={[a.gap_lg]}> 90 + <View style={[a.gap_sm]}> 91 + <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}> 92 + <Trans>Enable email 2FA</Trans> 93 + </Text> 94 + 95 + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 96 + <Trans>Require an email code to sign in to your account.</Trans> 97 + </Text> 98 + </View> 99 + 100 + {state.error && <Admonition type="error">{state.error}</Admonition>} 101 + 102 + <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}> 103 + <Button 104 + label={_(msg`Enable`)} 105 + size="large" 106 + variant="solid" 107 + color="primary" 108 + onPress={handleManageEmail2FA} 109 + disabled={state.status === 'pending'}> 110 + <ButtonText> 111 + <Trans>Enable</Trans> 112 + </ButtonText> 113 + <ButtonIcon 114 + position="right" 115 + icon={ 116 + state.status === 'pending' 117 + ? Loader 118 + : state.status === 'success' 119 + ? Check 120 + : ShieldIcon 121 + } 122 + /> 123 + </Button> 124 + <Button 125 + label={_(msg`Cancel`)} 126 + size="large" 127 + variant="solid" 128 + color="secondary" 129 + onPress={() => control.close()}> 130 + <ButtonText> 131 + <Trans>Cancel</Trans> 132 + </ButtonText> 133 + </Button> 134 + </View> 135 + </View> 136 + ) 137 + }
+70
src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx
··· 1 + import {useEffect, useState} from 'react' 2 + import {Trans} from '@lingui/macro' 3 + 4 + import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' 5 + import {Disable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Disable' 6 + import {Enable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Enable' 7 + import { 8 + ScreenID, 9 + type ScreenProps, 10 + } from '#/components/dialogs/EmailDialog/types' 11 + 12 + export function Manage2FA({showScreen}: ScreenProps<ScreenID.Manage2FA>) { 13 + const {isEmailVerified, email2FAEnabled} = useAccountEmailState() 14 + const [requestedAction, setRequestedAction] = useState< 15 + 'enable' | 'disable' | null 16 + >(null) 17 + 18 + useEffect(() => { 19 + if (!isEmailVerified) { 20 + showScreen({ 21 + id: ScreenID.Verify, 22 + instructions: [ 23 + <Trans key="2fa"> 24 + You need to verify your email address before you can enable email 25 + 2FA. 26 + </Trans>, 27 + ], 28 + onVerify: () => { 29 + showScreen({ 30 + id: ScreenID.Manage2FA, 31 + }) 32 + }, 33 + }) 34 + } 35 + }, [isEmailVerified, showScreen]) 36 + 37 + /* 38 + * Wacky state handling so that once 2FA settings change, we don't show the 39 + * wrong step of this form - esb 40 + */ 41 + 42 + if (email2FAEnabled) { 43 + if (!requestedAction) { 44 + setRequestedAction('disable') 45 + return <Disable /> 46 + } 47 + 48 + if (requestedAction === 'disable') { 49 + return <Disable /> 50 + } 51 + if (requestedAction === 'enable') { 52 + return <Enable /> 53 + } 54 + } else { 55 + if (!requestedAction) { 56 + setRequestedAction('enable') 57 + return <Enable /> 58 + } 59 + 60 + if (requestedAction === 'disable') { 61 + return <Disable /> 62 + } 63 + if (requestedAction === 'enable') { 64 + return <Enable /> 65 + } 66 + } 67 + 68 + // should never happen 69 + return null 70 + }
+319
src/components/dialogs/EmailDialog/screens/Update.tsx
··· 1 + import {useReducer} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {validate as validateEmail} from 'email-validator' 6 + 7 + import {wait} from '#/lib/async/wait' 8 + import {useCleanError} from '#/lib/hooks/useCleanError' 9 + import {logger} from '#/logger' 10 + import {useSession} from '#/state/session' 11 + import {atoms as a, useTheme} from '#/alf' 12 + import {Admonition} from '#/components/Admonition' 13 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14 + import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' 15 + import { 16 + isValidCode, 17 + TokenField, 18 + } from '#/components/dialogs/EmailDialog/components/TokenField' 19 + import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' 20 + import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' 21 + import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail' 22 + import { 23 + type ScreenID, 24 + type ScreenProps, 25 + } from '#/components/dialogs/EmailDialog/types' 26 + import {Divider} from '#/components/Divider' 27 + import * as TextField from '#/components/forms/TextField' 28 + import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 29 + import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' 30 + import {Loader} from '#/components/Loader' 31 + import {Text} from '#/components/Typography' 32 + 33 + type State = { 34 + step: 'email' | 'token' 35 + mutationStatus: 'pending' | 'success' | 'error' | 'default' 36 + error: string 37 + emailValid: boolean 38 + email: string 39 + token: string 40 + } 41 + 42 + type Action = 43 + | { 44 + type: 'setStep' 45 + step: State['step'] 46 + } 47 + | { 48 + type: 'setError' 49 + error: string 50 + } 51 + | { 52 + type: 'setMutationStatus' 53 + status: State['mutationStatus'] 54 + } 55 + | { 56 + type: 'setEmail' 57 + value: string 58 + } 59 + | { 60 + type: 'setToken' 61 + value: string 62 + } 63 + 64 + function reducer(state: State, action: Action): State { 65 + switch (action.type) { 66 + case 'setStep': { 67 + return { 68 + ...state, 69 + step: action.step, 70 + } 71 + } 72 + case 'setError': { 73 + return { 74 + ...state, 75 + error: action.error, 76 + mutationStatus: 'error', 77 + } 78 + } 79 + case 'setMutationStatus': { 80 + return { 81 + ...state, 82 + error: '', 83 + mutationStatus: action.status, 84 + } 85 + } 86 + case 'setEmail': { 87 + const emailValid = validateEmail(action.value) 88 + return { 89 + ...state, 90 + step: 'email', 91 + token: '', 92 + email: action.value, 93 + emailValid, 94 + } 95 + } 96 + case 'setToken': { 97 + return { 98 + ...state, 99 + error: '', 100 + token: action.value, 101 + } 102 + } 103 + } 104 + } 105 + 106 + export function Update(_props: ScreenProps<ScreenID.Update>) { 107 + const t = useTheme() 108 + const {_} = useLingui() 109 + const cleanError = useCleanError() 110 + const {currentAccount} = useSession() 111 + const [state, dispatch] = useReducer(reducer, { 112 + step: 'email', 113 + mutationStatus: 'default', 114 + error: '', 115 + email: '', 116 + emailValid: true, 117 + token: '', 118 + }) 119 + 120 + const {mutateAsync: updateEmail} = useUpdateEmail() 121 + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() 122 + const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() 123 + 124 + const handleEmailChange = (email: string) => { 125 + dispatch({ 126 + type: 'setEmail', 127 + value: email, 128 + }) 129 + } 130 + 131 + const handleUpdateEmail = async () => { 132 + if (state.step === 'token' && !isValidCode(state.token)) { 133 + dispatch({ 134 + type: 'setError', 135 + error: _(msg`Please enter a valid code.`), 136 + }) 137 + return 138 + } 139 + 140 + dispatch({ 141 + type: 'setMutationStatus', 142 + status: 'pending', 143 + }) 144 + 145 + if (state.emailValid === false) { 146 + dispatch({ 147 + type: 'setError', 148 + error: _(msg`Please enter a valid email address.`), 149 + }) 150 + return 151 + } 152 + 153 + if (state.email === currentAccount!.email) { 154 + dispatch({ 155 + type: 'setError', 156 + error: _(msg`This email is already associated with your account.`), 157 + }) 158 + return 159 + } 160 + 161 + try { 162 + const {status} = await wait( 163 + 1000, 164 + updateEmail({ 165 + email: state.email, 166 + token: state.token, 167 + }), 168 + ) 169 + 170 + if (status === 'tokenRequired') { 171 + dispatch({ 172 + type: 'setStep', 173 + step: 'token', 174 + }) 175 + dispatch({ 176 + type: 'setMutationStatus', 177 + status: 'default', 178 + }) 179 + } else if (status === 'success') { 180 + dispatch({ 181 + type: 'setMutationStatus', 182 + status: 'success', 183 + }) 184 + 185 + try { 186 + // fire off a confirmation email immediately 187 + await requestEmailVerification() 188 + } catch {} 189 + } 190 + } catch (e) { 191 + logger.error('EmailDialog: update email failed', {safeMessage: e}) 192 + const {clean} = cleanError(e) 193 + dispatch({ 194 + type: 'setError', 195 + error: clean || _(msg`Failed to update email, please try again.`), 196 + }) 197 + } 198 + } 199 + 200 + return ( 201 + <View style={[a.gap_lg]}> 202 + <Text style={[a.text_xl, a.font_heavy]}> 203 + <Trans>Update your email</Trans> 204 + </Text> 205 + 206 + {currentAccount?.emailAuthFactor && ( 207 + <Admonition type="warning"> 208 + <Trans> 209 + If you update your email address, email 2FA will be disabled. 210 + </Trans> 211 + </Admonition> 212 + )} 213 + 214 + <View style={[a.gap_md]}> 215 + <View> 216 + <Text style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 217 + <Trans>Please enter your new email address.</Trans> 218 + </Text> 219 + <TextField.Root> 220 + <TextField.Icon icon={Envelope} /> 221 + <TextField.Input 222 + label={_(msg`New email address`)} 223 + placeholder={_(msg`alice@example.com`)} 224 + defaultValue={state.email} 225 + onChangeText={ 226 + state.mutationStatus === 'success' 227 + ? undefined 228 + : handleEmailChange 229 + } 230 + keyboardType="email-address" 231 + autoComplete="email" 232 + autoCapitalize="none" 233 + onSubmitEditing={handleUpdateEmail} 234 + /> 235 + </TextField.Root> 236 + </View> 237 + 238 + {state.step === 'token' && ( 239 + <> 240 + <Divider /> 241 + <View> 242 + <Text style={[a.text_md, a.pb_sm, a.font_bold]}> 243 + <Trans>Security step required</Trans> 244 + </Text> 245 + <Text 246 + style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 247 + <Trans> 248 + Please enter the security code we sent to your previous email 249 + address. 250 + </Trans> 251 + </Text> 252 + <TokenField 253 + value={state.token} 254 + onChangeText={ 255 + state.mutationStatus === 'success' 256 + ? undefined 257 + : token => { 258 + dispatch({ 259 + type: 'setToken', 260 + value: token, 261 + }) 262 + } 263 + } 264 + onSubmitEditing={handleUpdateEmail} 265 + /> 266 + {state.mutationStatus !== 'success' && ( 267 + <ResendEmailText 268 + onPress={requestEmailUpdate} 269 + style={[a.pt_sm]} 270 + /> 271 + )} 272 + </View> 273 + </> 274 + )} 275 + 276 + {state.error && <Admonition type="error">{state.error}</Admonition>} 277 + </View> 278 + 279 + {state.mutationStatus === 'success' ? ( 280 + <> 281 + <Divider /> 282 + <View style={[a.gap_sm]}> 283 + <View style={[a.flex_row, a.gap_sm, a.align_center]}> 284 + <Check fill={t.palette.positive_600} size="xs" /> 285 + <Text style={[a.text_md, a.font_heavy]}> 286 + <Trans>Success!</Trans> 287 + </Text> 288 + </View> 289 + <Text style={[a.leading_snug]}> 290 + <Trans> 291 + Please click on the link in the email we just sent you to verify 292 + your new email address. This is an important step to allow you 293 + to continue enjoying all the features of Bluesky. 294 + </Trans> 295 + </Text> 296 + </View> 297 + </> 298 + ) : ( 299 + <Button 300 + label={_(msg`Update email`)} 301 + size="large" 302 + variant="solid" 303 + color="primary" 304 + onPress={handleUpdateEmail} 305 + disabled={ 306 + !state.email || 307 + (state.step === 'token' && 308 + (!state.token || state.token.length !== 11)) || 309 + state.mutationStatus === 'pending' 310 + }> 311 + <ButtonText> 312 + <Trans>Update email</Trans> 313 + </ButtonText> 314 + {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />} 315 + </Button> 316 + )} 317 + </View> 318 + ) 319 + }
+99
src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' 6 + import {Button, ButtonText} from '#/components/Button' 7 + import {useDialogContext} from '#/components/Dialog' 8 + import { 9 + ScreenID, 10 + type ScreenProps, 11 + } from '#/components/dialogs/EmailDialog/types' 12 + import {Divider} from '#/components/Divider' 13 + import {GradientFill} from '#/components/GradientFill' 14 + import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' 15 + import {Text} from '#/components/Typography' 16 + 17 + export function VerificationReminder({ 18 + showScreen, 19 + }: ScreenProps<ScreenID.VerificationReminder>) { 20 + const t = useTheme() 21 + const {_} = useLingui() 22 + const {gtPhone, gtMobile} = useBreakpoints() 23 + const control = useDialogContext() 24 + 25 + const dialogPadding = gtMobile ? a.p_2xl.padding : a.p_xl.padding 26 + 27 + return ( 28 + <View style={[a.gap_lg]}> 29 + <View 30 + style={[ 31 + a.absolute, 32 + { 33 + top: platform({web: dialogPadding, default: a.p_2xl.padding}) * -1, 34 + left: dialogPadding * -1, 35 + right: dialogPadding * -1, 36 + height: 150, 37 + }, 38 + ]}> 39 + <View 40 + style={[ 41 + a.absolute, 42 + a.inset_0, 43 + a.align_center, 44 + a.justify_center, 45 + a.overflow_hidden, 46 + a.pt_md, 47 + t.atoms.bg_contrast_100, 48 + ]}> 49 + <GradientFill gradient={tokens.gradients.primary} /> 50 + <ShieldIcon width={64} fill="white" style={[a.z_10]} /> 51 + </View> 52 + </View> 53 + 54 + <View style={[a.mb_xs, {height: 150 - dialogPadding}]} /> 55 + 56 + <View style={[a.gap_sm]}> 57 + <Text style={[a.text_xl, a.font_heavy]}> 58 + <Trans>Please verify your email</Trans> 59 + </Text> 60 + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 61 + <Trans> 62 + Your email has not yet been verified. Please verify your email in 63 + order to enjoy all the features of Bluesky. 64 + </Trans> 65 + </Text> 66 + </View> 67 + 68 + <Divider /> 69 + 70 + <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}> 71 + <Button 72 + label={_(msg`Get started`)} 73 + variant="solid" 74 + color="primary" 75 + size="large" 76 + onPress={() => 77 + showScreen({ 78 + id: ScreenID.Verify, 79 + }) 80 + }> 81 + <ButtonText> 82 + <Trans>Get started</Trans> 83 + </ButtonText> 84 + </Button> 85 + <Button 86 + label={_(msg`Maybe later`)} 87 + accessibilityHint={_(msg`Snoozes the reminder`)} 88 + variant="ghost" 89 + color="secondary" 90 + size="large" 91 + onPress={() => control.close()}> 92 + <ButtonText> 93 + <Trans>Maybe later</Trans> 94 + </ButtonText> 95 + </Button> 96 + </View> 97 + </View> 98 + ) 99 + }
+386
src/components/dialogs/EmailDialog/screens/Verify.tsx
··· 1 + import {useReducer} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {wait} from '#/lib/async/wait' 7 + import {useCleanError} from '#/lib/hooks/useCleanError' 8 + import {logger} from '#/logger' 9 + import {useSession} from '#/state/session' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {Admonition} from '#/components/Admonition' 12 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 13 + import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' 14 + import { 15 + isValidCode, 16 + TokenField, 17 + } from '#/components/dialogs/EmailDialog/components/TokenField' 18 + import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail' 19 + import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' 20 + import {useOnEmailVerified} from '#/components/dialogs/EmailDialog/events' 21 + import { 22 + ScreenID, 23 + type ScreenProps, 24 + } from '#/components/dialogs/EmailDialog/types' 25 + import {Divider} from '#/components/Divider' 26 + import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 27 + import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' 28 + import {createStaticClick, InlineLinkText} from '#/components/Link' 29 + import {Loader} from '#/components/Loader' 30 + import {Span, Text} from '#/components/Typography' 31 + 32 + type State = { 33 + step: 'email' | 'token' | 'success' 34 + mutationStatus: 'pending' | 'success' | 'error' | 'default' 35 + error: string 36 + token: string 37 + } 38 + 39 + type Action = 40 + | { 41 + type: 'setStep' 42 + step: State['step'] 43 + } 44 + | { 45 + type: 'setError' 46 + error: string 47 + } 48 + | { 49 + type: 'setMutationStatus' 50 + status: State['mutationStatus'] 51 + } 52 + | { 53 + type: 'setToken' 54 + value: string 55 + } 56 + 57 + function reducer(state: State, action: Action): State { 58 + switch (action.type) { 59 + case 'setStep': { 60 + return { 61 + ...state, 62 + error: '', 63 + mutationStatus: 'default', 64 + step: action.step, 65 + } 66 + } 67 + case 'setError': { 68 + return { 69 + ...state, 70 + error: action.error, 71 + mutationStatus: 'error', 72 + } 73 + } 74 + case 'setMutationStatus': { 75 + return { 76 + ...state, 77 + error: '', 78 + mutationStatus: action.status, 79 + } 80 + } 81 + case 'setToken': { 82 + return { 83 + ...state, 84 + error: '', 85 + token: action.value, 86 + } 87 + } 88 + } 89 + } 90 + 91 + export function Verify({config, showScreen}: ScreenProps<ScreenID.Verify>) { 92 + const t = useTheme() 93 + const {_} = useLingui() 94 + const cleanError = useCleanError() 95 + const {currentAccount} = useSession() 96 + const [state, dispatch] = useReducer(reducer, { 97 + step: 'email', 98 + mutationStatus: 'default', 99 + error: '', 100 + token: '', 101 + }) 102 + 103 + const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() 104 + const {mutateAsync: confirmEmail} = useConfirmEmail() 105 + 106 + useOnEmailVerified(() => { 107 + if (config.onVerify) { 108 + config.onVerify() 109 + } else { 110 + dispatch({ 111 + type: 'setStep', 112 + step: 'success', 113 + }) 114 + } 115 + }) 116 + 117 + const handleRequestEmailVerification = async () => { 118 + dispatch({ 119 + type: 'setMutationStatus', 120 + status: 'pending', 121 + }) 122 + 123 + try { 124 + await wait(1000, requestEmailVerification()) 125 + dispatch({ 126 + type: 'setMutationStatus', 127 + status: 'success', 128 + }) 129 + } catch (e) { 130 + logger.error('EmailDialog: sending verification email failed', { 131 + safeMessage: e, 132 + }) 133 + const {clean} = cleanError(e) 134 + dispatch({ 135 + type: 'setError', 136 + error: clean || _(msg`Failed to send email, please try again.`), 137 + }) 138 + } 139 + } 140 + 141 + const handleConfirmEmail = async () => { 142 + if (!isValidCode(state.token)) { 143 + dispatch({ 144 + type: 'setError', 145 + error: _(msg`Please enter a valid code.`), 146 + }) 147 + return 148 + } 149 + 150 + dispatch({ 151 + type: 'setMutationStatus', 152 + status: 'pending', 153 + }) 154 + 155 + try { 156 + await wait(1000, confirmEmail({token: state.token})) 157 + dispatch({ 158 + type: 'setStep', 159 + step: 'success', 160 + }) 161 + } catch (e) { 162 + logger.error('EmailDialog: confirming email failed', { 163 + safeMessage: e, 164 + }) 165 + const {clean} = cleanError(e) 166 + dispatch({ 167 + type: 'setError', 168 + error: clean || _(msg`Failed to verify email, please try again.`), 169 + }) 170 + } 171 + } 172 + 173 + if (state.step === 'success') { 174 + return ( 175 + <View style={[a.gap_lg]}> 176 + <View style={[a.gap_sm]}> 177 + <Text style={[a.text_xl, a.font_heavy]}> 178 + <Span style={{top: 1}}> 179 + <Check size="sm" fill={t.palette.positive_600} /> 180 + </Span> 181 + {' '} 182 + <Trans>Email verification complete!</Trans> 183 + </Text> 184 + 185 + <Text 186 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 187 + <Trans> 188 + You have successfully verified your email address. You can close 189 + this dialog. 190 + </Trans> 191 + </Text> 192 + </View> 193 + </View> 194 + ) 195 + } 196 + 197 + return ( 198 + <View style={[a.gap_lg]}> 199 + <View style={[a.gap_sm]}> 200 + <Text style={[a.text_xl, a.font_heavy]}> 201 + {state.step === 'email' ? ( 202 + state.mutationStatus === 'success' ? ( 203 + <> 204 + <Span style={{top: 1}}> 205 + <Check size="sm" fill={t.palette.positive_600} /> 206 + </Span> 207 + {' '} 208 + <Trans>Email sent!</Trans> 209 + </> 210 + ) : ( 211 + <Trans>Verify your email</Trans> 212 + ) 213 + ) : ( 214 + <Trans>Verify email code</Trans> 215 + )} 216 + </Text> 217 + 218 + {state.step === 'email' && state.mutationStatus !== 'success' && ( 219 + <> 220 + {config.instructions?.map((int, i) => ( 221 + <Text 222 + key={i} 223 + style={[ 224 + a.italic, 225 + a.text_sm, 226 + a.leading_snug, 227 + t.atoms.text_contrast_medium, 228 + ]}> 229 + {int} 230 + </Text> 231 + ))} 232 + </> 233 + )} 234 + 235 + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 236 + {state.step === 'email' ? ( 237 + state.mutationStatus === 'success' ? ( 238 + <Trans> 239 + We sent an email to{' '} 240 + <Span style={[a.font_bold, t.atoms.text]}> 241 + {currentAccount!.email} 242 + </Span>{' '} 243 + containing a link. Please click on it to complete the email 244 + verification process. 245 + </Trans> 246 + ) : ( 247 + <Trans> 248 + We'll send an email to{' '} 249 + <Span style={[a.font_bold, t.atoms.text]}> 250 + {currentAccount!.email} 251 + </Span>{' '} 252 + containing a link. Please click on it to complete the email 253 + verification process. 254 + </Trans> 255 + ) 256 + ) : ( 257 + <Trans> 258 + Please enter the code we sent to{' '} 259 + <Span style={[a.font_bold, t.atoms.text]}> 260 + {currentAccount!.email} 261 + </Span>{' '} 262 + below. 263 + </Trans> 264 + )} 265 + </Text> 266 + 267 + {state.step === 'email' && state.mutationStatus !== 'success' && ( 268 + <Text 269 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 270 + <Trans> 271 + If you need to update your email,{' '} 272 + <InlineLinkText 273 + label={_(msg`Click here to update your email`)} 274 + {...createStaticClick(() => { 275 + showScreen({id: ScreenID.Update}) 276 + })}> 277 + click here 278 + </InlineLinkText> 279 + . 280 + </Trans> 281 + </Text> 282 + )} 283 + 284 + {state.step === 'email' && state.mutationStatus === 'success' && ( 285 + <ResendEmailText onPress={requestEmailVerification} /> 286 + )} 287 + </View> 288 + 289 + {state.step === 'email' && state.mutationStatus !== 'success' ? ( 290 + <> 291 + {state.error && <Admonition type="error">{state.error}</Admonition>} 292 + <Button 293 + label={_(msg`Send verification email`)} 294 + size="large" 295 + variant="solid" 296 + color="primary" 297 + onPress={handleRequestEmailVerification} 298 + disabled={state.mutationStatus === 'pending'}> 299 + <ButtonText> 300 + <Trans>Send email</Trans> 301 + </ButtonText> 302 + <ButtonIcon 303 + icon={state.mutationStatus === 'pending' ? Loader : Envelope} 304 + /> 305 + </Button> 306 + </> 307 + ) : null} 308 + 309 + {state.step === 'email' && ( 310 + <> 311 + <Divider /> 312 + 313 + <Text 314 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 315 + <Trans> 316 + Have a code?{' '} 317 + <InlineLinkText 318 + label={_(msg`Enter code`)} 319 + {...createStaticClick(() => { 320 + dispatch({ 321 + type: 'setStep', 322 + step: 'token', 323 + }) 324 + })}> 325 + Click here. 326 + </InlineLinkText> 327 + </Trans> 328 + </Text> 329 + </> 330 + )} 331 + 332 + {state.step === 'token' ? ( 333 + <> 334 + <TokenField 335 + value={state.token} 336 + onChangeText={token => { 337 + dispatch({ 338 + type: 'setToken', 339 + value: token, 340 + }) 341 + }} 342 + onSubmitEditing={handleConfirmEmail} 343 + /> 344 + 345 + {state.error && <Admonition type="error">{state.error}</Admonition>} 346 + 347 + <Button 348 + label={_(msg`Verify code`)} 349 + size="large" 350 + variant="solid" 351 + color="primary" 352 + onPress={handleConfirmEmail} 353 + disabled={ 354 + !state.token || 355 + state.token.length !== 11 || 356 + state.mutationStatus === 'pending' 357 + }> 358 + <ButtonText> 359 + <Trans>Verify code</Trans> 360 + </ButtonText> 361 + {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />} 362 + </Button> 363 + 364 + <Divider /> 365 + 366 + <Text 367 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 368 + <Trans> 369 + Don't have a code or need a new one?{' '} 370 + <InlineLinkText 371 + label={_(msg`Click here to restart the verification process.`)} 372 + {...createStaticClick(() => { 373 + dispatch({ 374 + type: 'setStep', 375 + step: 'email', 376 + }) 377 + })}> 378 + Click here. 379 + </InlineLinkText> 380 + </Trans> 381 + </Text> 382 + </> 383 + ) : null} 384 + </View> 385 + ) 386 + }
+38
src/components/dialogs/EmailDialog/types.ts
··· 1 + import {type ReactNode} from 'react' 2 + 3 + import {type DialogControlProps} from '#/components/Dialog' 4 + 5 + export type EmailDialogProps = { 6 + control: DialogControlProps 7 + } 8 + 9 + export type EmailDialogInnerProps = EmailDialogProps & {} 10 + 11 + export type Screen = 12 + | { 13 + id: ScreenID.Update 14 + } 15 + | { 16 + id: ScreenID.Verify 17 + instructions?: ReactNode[] 18 + onVerify?: () => void 19 + onCloseWithoutVerifying?: () => void 20 + } 21 + | { 22 + id: ScreenID.VerificationReminder 23 + } 24 + | { 25 + id: ScreenID.Manage2FA 26 + } 27 + 28 + export enum ScreenID { 29 + Update = 'Update', 30 + Verify = 'Verify', 31 + VerificationReminder = 'VerificationReminder', 32 + Manage2FA = 'Manage2FA', 33 + } 34 + 35 + export type ScreenProps<T extends ScreenID> = { 36 + config: Extract<Screen, {id: T}> 37 + showScreen: (screen: Screen) => void 38 + }
+15 -28
src/components/dms/MessageProfileButton.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {AppBskyActorDefs} from '@atproto/api' 4 - import {msg} from '@lingui/macro' 3 + import {type AppBskyActorDefs} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 import {useNavigation} from '@react-navigation/native' 7 7 8 - import {useEmail} from '#/lib/hooks/useEmail' 9 - import {NavigationProp} from '#/lib/routes/types' 8 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 9 + import {type NavigationProp} from '#/lib/routes/types' 10 10 import {logEvent} from '#/lib/statsig/statsig' 11 11 import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' 12 12 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 13 13 import * as Toast from '#/view/com/util/Toast' 14 14 import {atoms as a, useTheme} from '#/alf' 15 15 import {Button, ButtonIcon} from '#/components/Button' 16 - import {useDialogControl} from '#/components/Dialog' 17 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 18 16 import {canBeMessaged} from '#/components/dms/util' 19 17 import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' 20 18 ··· 26 24 const {_} = useLingui() 27 25 const t = useTheme() 28 26 const navigation = useNavigation<NavigationProp>() 29 - const {needsEmailVerification} = useEmail() 30 - const verifyEmailControl = useDialogControl() 27 + const requireEmailVerification = useRequireEmailVerification() 31 28 32 29 const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) 33 30 const {mutate: initiateConvo} = useGetConvoForMembers({ ··· 45 42 return 46 43 } 47 44 48 - if (needsEmailVerification) { 49 - verifyEmailControl.open() 50 - return 51 - } 52 - 53 45 if (convoAvailability.convo) { 54 46 logEvent('chat:open', {logContext: 'ProfileHeader'}) 55 47 navigation.navigate('MessagesConversation', { ··· 59 51 logEvent('chat:create', {logContext: 'ProfileHeader'}) 60 52 initiateConvo([profile.did]) 61 53 } 62 - }, [ 63 - needsEmailVerification, 64 - verifyEmailControl, 65 - navigation, 66 - profile.did, 67 - initiateConvo, 68 - convoAvailability, 69 - ]) 54 + }, [navigation, profile.did, initiateConvo, convoAvailability]) 55 + 56 + const wrappedOnPress = requireEmailVerification(onPress, { 57 + instructions: [ 58 + <Trans key="message"> 59 + Before you can message another user, you must first verify your email. 60 + </Trans>, 61 + ], 62 + }) 70 63 71 64 if (!convoAvailability) { 72 65 // show pending state based on declaration ··· 102 95 shape="round" 103 96 label={_(msg`Message ${profile.handle}`)} 104 97 style={[a.justify_center]} 105 - onPress={onPress}> 98 + onPress={wrappedOnPress}> 106 99 <ButtonIcon icon={Message} size="md" /> 107 100 </Button> 108 - <VerifyEmailDialog 109 - reasonText={_( 110 - msg`Before you may message another user, you must first verify your email.`, 111 - )} 112 - control={verifyEmailControl} 113 - /> 114 101 </> 115 102 ) 116 103 } else {
+15 -20
src/components/dms/dialogs/NewChatDialog.tsx
··· 1 1 import {useCallback} from 'react' 2 - import {msg} from '@lingui/macro' 2 + import {msg, Trans} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {useEmail} from '#/lib/hooks/useEmail' 5 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 6 6 import {logEvent} from '#/lib/statsig/statsig' 7 7 import {logger} from '#/logger' 8 8 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' ··· 10 10 import * as Toast from '#/view/com/util/Toast' 11 11 import {useTheme} from '#/alf' 12 12 import * as Dialog from '#/components/Dialog' 13 - import {useDialogControl} from '#/components/Dialog' 14 13 import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 15 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 16 14 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 17 15 18 16 export function NewChat({ ··· 24 22 }) { 25 23 const t = useTheme() 26 24 const {_} = useLingui() 27 - const {needsEmailVerification} = useEmail() 28 - const verifyEmailControl = useDialogControl() 25 + const requireEmailVerification = useRequireEmailVerification() 29 26 30 27 const {mutate: createChat} = useGetConvoForMembers({ 31 28 onSuccess: data => { ··· 49 46 [control, createChat], 50 47 ) 51 48 49 + const onPress = useCallback(() => { 50 + control.open() 51 + }, [control]) 52 + const wrappedOnPress = requireEmailVerification(onPress, { 53 + instructions: [ 54 + <Trans key="new-chat"> 55 + Before you can message another user, you must first verify your email. 56 + </Trans>, 57 + ], 58 + }) 59 + 52 60 return ( 53 61 <> 54 62 <FAB 55 63 testID="newChatFAB" 56 - onPress={() => { 57 - if (needsEmailVerification) { 58 - verifyEmailControl.open() 59 - } else { 60 - control.open() 61 - } 62 - }} 64 + onPress={wrappedOnPress} 63 65 icon={<Plus size="lg" fill={t.palette.white} />} 64 66 accessibilityRole="button" 65 67 accessibilityLabel={_(msg`New chat`)} ··· 74 76 sortByMessageDeclaration 75 77 /> 76 78 </Dialog.Outer> 77 - 78 - <VerifyEmailDialog 79 - reasonText={_( 80 - msg`Before you may message another user, you must first verify your email.`, 81 - )} 82 - control={verifyEmailControl} 83 - /> 84 79 </> 85 80 ) 86 81 }
+91
src/lib/hooks/useCleanError.ts
··· 1 + import {useCallback} from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + type CleanedError = { 6 + raw: string | undefined 7 + clean: string | undefined 8 + } 9 + 10 + export function useCleanError() { 11 + const {_} = useLingui() 12 + 13 + return useCallback<(error?: any) => CleanedError>( 14 + error => { 15 + if (!error) 16 + return { 17 + raw: undefined, 18 + clean: undefined, 19 + } 20 + 21 + let raw = error.toString() 22 + 23 + if (isNetworkError(raw)) { 24 + return { 25 + raw, 26 + clean: _( 27 + msg`Unable to connect. Please check your internet connection and try again.`, 28 + ), 29 + } 30 + } 31 + 32 + if ( 33 + raw.includes('Upstream Failure') || 34 + raw.includes('NotEnoughResources') || 35 + raw.includes('pipethrough network error') 36 + ) { 37 + return { 38 + raw, 39 + clean: _( 40 + msg`The server appears to be experiencing issues. Please try again in a few moments.`, 41 + ), 42 + } 43 + } 44 + 45 + if (raw.includes('Bad token scope')) { 46 + return { 47 + raw, 48 + clean: _( 49 + msg`This feature is not available while using an app password. Please sign in with your main password.`, 50 + ), 51 + } 52 + } 53 + 54 + if (raw.includes('Rate Limit Exceeded')) { 55 + return { 56 + raw, 57 + clean: _( 58 + msg`You've reached the maximum number of requests allowed. Please try again later.`, 59 + ), 60 + } 61 + } 62 + 63 + if (raw.startsWith('Error: ')) { 64 + raw = raw.slice('Error: '.length) 65 + } 66 + 67 + return { 68 + raw, 69 + clean: undefined, 70 + } 71 + }, 72 + [_], 73 + ) 74 + } 75 + 76 + const NETWORK_ERRORS = [ 77 + 'Abort', 78 + 'Network request failed', 79 + 'Failed to fetch', 80 + 'Load failed', 81 + ] 82 + 83 + export function isNetworkError(e: unknown) { 84 + const str = String(e) 85 + for (const err of NETWORK_ERRORS) { 86 + if (str.includes(err)) { 87 + return true 88 + } 89 + } 90 + return false 91 + }
+2 -2
src/lib/hooks/useIntentHandler.ts
··· 1 1 import React from 'react' 2 2 import * as Linking from 'expo-linking' 3 3 4 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 4 5 import {logEvent} from '#/lib/statsig/statsig' 5 6 import {isNative} from '#/platform/detection' 6 7 import {useSession} from '#/state/session' 7 - import {useComposerControls} from '#/state/shell' 8 8 import {useCloseAllActiveElements} from '#/state/util' 9 9 import {useIntentDialogs} from '#/components/intents/IntentDialogs' 10 10 import {Referrer} from '../../../modules/expo-bluesky-swiss-army' ··· 83 83 84 84 export function useComposeIntent() { 85 85 const closeAllActiveElements = useCloseAllActiveElements() 86 - const {openComposer} = useComposerControls() 86 + const {openComposer} = useOpenComposer() 87 87 const {hasSession} = useSession() 88 88 89 89 return React.useCallback(
+22
src/lib/hooks/useOpenComposer.tsx
··· 1 + import {useMemo} from 'react' 2 + import {Trans} from '@lingui/macro' 3 + 4 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 5 + import {useOpenComposer as rootUseOpenComposer} from '#/state/shell/composer' 6 + 7 + export function useOpenComposer() { 8 + const {openComposer} = rootUseOpenComposer() 9 + const requireEmailVerification = useRequireEmailVerification() 10 + return useMemo(() => { 11 + return { 12 + openComposer: requireEmailVerification(openComposer, { 13 + instructions: [ 14 + <Trans key="pre-compose"> 15 + Before creating a post or replying, you must first verify your 16 + email. 17 + </Trans>, 18 + ], 19 + }), 20 + } 21 + }, [openComposer, requireEmailVerification]) 22 + }
+53
src/lib/hooks/useRequireEmailVerification.tsx
··· 1 + import {useCallback} from 'react' 2 + import {Keyboard} from 'react-native' 3 + 4 + import {useEmail} from '#/lib/hooks/useEmail' 5 + import {useRequireAuth, useSession} from '#/state/session' 6 + import {useCloseAllActiveElements} from '#/state/util' 7 + import { 8 + EmailDialogScreenID, 9 + type Screen, 10 + useEmailDialogControl, 11 + } from '#/components/dialogs/EmailDialog' 12 + 13 + export function useRequireEmailVerification() { 14 + const {currentAccount} = useSession() 15 + const {needsEmailVerification} = useEmail() 16 + const requireAuth = useRequireAuth() 17 + const emailDialogControl = useEmailDialogControl() 18 + const closeAll = useCloseAllActiveElements() 19 + 20 + return useCallback( 21 + <T extends (...args: any[]) => any>( 22 + cb: T, 23 + config: Omit< 24 + Extract<Screen, {id: EmailDialogScreenID.Verify}>, 25 + 'id' 26 + > = {}, 27 + ): ((...args: Parameters<T>) => ReturnType<T>) => { 28 + return (...args: Parameters<T>): ReturnType<T> => { 29 + if (!currentAccount) { 30 + return requireAuth(() => cb(...args)) as ReturnType<T> 31 + } 32 + if (needsEmailVerification) { 33 + Keyboard.dismiss() 34 + closeAll() 35 + emailDialogControl.open({ 36 + id: EmailDialogScreenID.Verify, 37 + ...config, 38 + }) 39 + return undefined as ReturnType<T> 40 + } else { 41 + return cb(...args) 42 + } 43 + } 44 + }, 45 + [ 46 + needsEmailVerification, 47 + currentAccount, 48 + emailDialogControl, 49 + closeAll, 50 + requireAuth, 51 + ], 52 + ) 53 + }
+14 -1
src/screens/Messages/ChatList.tsx
··· 9 9 10 10 import {useAppState} from '#/lib/hooks/useAppState' 11 11 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 12 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 12 13 import {type MessagesTabNavigatorParams} from '#/lib/routes/types' 13 14 import {cleanError} from '#/lib/strings/errors' 14 15 import {logger} from '#/logger' ··· 321 322 function Header({newChatControl}: {newChatControl: DialogControlProps}) { 322 323 const {_} = useLingui() 323 324 const {gtMobile} = useBreakpoints() 325 + const requireEmailVerification = useRequireEmailVerification() 326 + 327 + const openChatControl = useCallback(() => { 328 + newChatControl.open() 329 + }, [newChatControl]) 330 + const wrappedOpenChatControl = requireEmailVerification(openChatControl, { 331 + instructions: [ 332 + <Trans key="new-chat"> 333 + Before you can message another user, you must first verify your email. 334 + </Trans>, 335 + ], 336 + }) 324 337 325 338 const settingsLink = ( 326 339 <Link ··· 352 365 color="primary" 353 366 size="small" 354 367 variant="solid" 355 - onPress={newChatControl.open}> 368 + onPress={wrappedOpenChatControl}> 356 369 <ButtonIcon icon={PlusIcon} position="left" /> 357 370 <ButtonText> 358 371 <Trans>New chat</Trans>
+43 -18
src/screens/Messages/Conversation.tsx
··· 1 - import React, {useCallback} from 'react' 1 + import React, {useCallback, useEffect} from 'react' 2 2 import {View} from 'react-native' 3 3 import { 4 4 type AppBskyActorDefs, 5 5 moderateProfile, 6 6 type ModerationDecision, 7 7 } from '@atproto/api' 8 - import {msg} from '@lingui/macro' 8 + import {msg, Trans} from '@lingui/macro' 9 9 import {useLingui} from '@lingui/react' 10 10 import { 11 11 type RouteProp, ··· 17 17 18 18 import {useEmail} from '#/lib/hooks/useEmail' 19 19 import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController' 20 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 20 21 import { 21 22 type CommonNavigatorParams, 22 23 type NavigationProp, ··· 31 32 import {useSetMinimalShellMode} from '#/state/shell' 32 33 import {MessagesList} from '#/screens/Messages/components/MessagesList' 33 34 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 34 - import {useDialogControl} from '#/components/Dialog' 35 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 35 + import { 36 + EmailDialogScreenID, 37 + useEmailDialogControl, 38 + } from '#/components/dialogs/EmailDialog' 36 39 import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter' 37 40 import {MessagesListHeader} from '#/components/dms/MessagesListHeader' 38 41 import {Error} from '#/components/Error' ··· 183 186 hasScrolled: boolean 184 187 setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> 185 188 }) { 186 - const {_} = useLingui() 187 189 const convoState = useConvo() 188 190 const navigation = useNavigation<NavigationProp>() 189 191 const {params} = 190 192 useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>() 191 - const verifyEmailControl = useDialogControl() 192 193 const {needsEmailVerification} = useEmail() 194 + const emailDialogControl = useEmailDialogControl() 193 195 194 - React.useEffect(() => { 196 + /** 197 + * Must be non-reactive, otherwise the update to open the global dialog will 198 + * cause a re-render loop. 199 + */ 200 + const maybeBlockForEmailVerification = useNonReactiveCallback(() => { 195 201 if (needsEmailVerification) { 196 - verifyEmailControl.open() 202 + /* 203 + * HACKFIX 204 + * 205 + * Load bearing timeout, to bump this state update until the after the 206 + * `navigator.addListener('state')` handler closes elements from 207 + * `shell/index.*.tsx` - sfn & esb 208 + */ 209 + setTimeout(() => 210 + emailDialogControl.open({ 211 + id: EmailDialogScreenID.Verify, 212 + instructions: [ 213 + <Trans key="pre-compose"> 214 + Before you can message another user, you must first verify your 215 + email. 216 + </Trans>, 217 + ], 218 + onCloseWithoutVerifying: () => { 219 + if (navigation.canGoBack()) { 220 + navigation.goBack() 221 + } else { 222 + navigation.navigate('Messages', {animation: 'pop'}) 223 + } 224 + }, 225 + }), 226 + ) 197 227 } 198 - }, [needsEmailVerification, verifyEmailControl]) 228 + }) 229 + 230 + useEffect(() => { 231 + maybeBlockForEmailVerification() 232 + }, [maybeBlockForEmailVerification]) 199 233 200 234 return ( 201 235 <> ··· 216 250 } 217 251 /> 218 252 )} 219 - <VerifyEmailDialog 220 - reasonText={_( 221 - msg`Before you may message another user, you must first verify your email.`, 222 - )} 223 - control={verifyEmailControl} 224 - onCloseWithoutVerifying={() => { 225 - navigation.navigate('Home') 226 - }} 227 - /> 228 253 </> 229 254 ) 230 255 }
+29 -5
src/screens/Messages/components/RequestButtons.tsx
··· 1 1 import {useCallback} from 'react' 2 - import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' 2 + import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 import {StackActions, useNavigation} from '@react-navigation/native' 6 6 import {useQueryClient} from '@tanstack/react-query' 7 7 8 - import {NavigationProp} from '#/lib/routes/types' 8 + import {useEmail} from '#/lib/hooks/useEmail' 9 + import {type NavigationProp} from '#/lib/routes/types' 9 10 import {useProfileShadow} from '#/state/cache/profile-shadow' 10 11 import {useAcceptConversation} from '#/state/queries/messages/accept-conversation' 11 12 import {precacheConvoQuery} from '#/state/queries/messages/conversation' ··· 13 14 import {useProfileBlockMutationQueue} from '#/state/queries/profile' 14 15 import * as Toast from '#/view/com/util/Toast' 15 16 import {atoms as a} from '#/alf' 16 - import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button' 17 + import { 18 + Button, 19 + ButtonIcon, 20 + type ButtonProps, 21 + ButtonText, 22 + } from '#/components/Button' 17 23 import {useDialogControl} from '#/components/Dialog' 24 + import { 25 + EmailDialogScreenID, 26 + useEmailDialogControl, 27 + } from '#/components/dialogs/EmailDialog' 18 28 import {ReportDialog} from '#/components/dms/ReportDialog' 19 29 import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX' 20 30 import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' ··· 186 196 const {_} = useLingui() 187 197 const queryClient = useQueryClient() 188 198 const navigation = useNavigation<NavigationProp>() 199 + const {needsEmailVerification} = useEmail() 200 + const emailDialogControl = useEmailDialogControl() 189 201 190 202 const {mutate: acceptConvo, isPending} = useAcceptConversation(convo.id, { 191 203 onMutate: () => { ··· 216 228 }) 217 229 218 230 const onPressAccept = useCallback(() => { 219 - acceptConvo() 220 - }, [acceptConvo]) 231 + if (needsEmailVerification) { 232 + emailDialogControl.open({ 233 + id: EmailDialogScreenID.Verify, 234 + instructions: [ 235 + <Trans key="request-btn"> 236 + Before you can accept this chat request, you must first verify your 237 + email. 238 + </Trans>, 239 + ], 240 + }) 241 + } else { 242 + acceptConvo() 243 + } 244 + }, [acceptConvo, needsEmailVerification, emailDialogControl]) 221 245 222 246 return ( 223 247 <Button
+1 -1
src/screens/Messages/components/RequestListItem.tsx
··· 1 1 import {View} from 'react-native' 2 - import {ChatBskyConvoDefs} from '@atproto/api' 2 + import {type ChatBskyConvoDefs} from '@atproto/api' 3 3 import {Trans} from '@lingui/macro' 4 4 5 5 import {useModerationOpts} from '#/state/preferences/moderation-opts'
+12 -9
src/screens/Profile/ProfileFeed/index.tsx
··· 5 5 import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 import {useIsFocused, useNavigation} from '@react-navigation/native' 8 - import {NativeStackScreenProps} from '@react-navigation/native-stack' 8 + import {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 9 import {useQueryClient} from '@tanstack/react-query' 10 10 11 11 import {VIDEO_FEED_URIS} from '#/lib/constants' 12 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 12 13 import {usePalette} from '#/lib/hooks/usePalette' 13 14 import {useSetTitle} from '#/lib/hooks/useSetTitle' 14 15 import {ComposeIcon2} from '#/lib/icons' 15 - import {CommonNavigatorParams} from '#/lib/routes/types' 16 - import {NavigationProp} from '#/lib/routes/types' 16 + import {type CommonNavigatorParams} from '#/lib/routes/types' 17 + import {type NavigationProp} from '#/lib/routes/types' 17 18 import {makeRecordUri} from '#/lib/strings/url-helpers' 18 19 import {s} from '#/lib/styles' 19 20 import {isNative} from '#/platform/detection' 20 21 import {listenSoftReset} from '#/state/events' 21 22 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 22 - import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' 23 - import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' 23 + import { 24 + type FeedSourceFeedInfo, 25 + useFeedSourceInfoQuery, 26 + } from '#/state/queries/feed' 27 + import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' 24 28 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 25 29 import { 26 30 usePreferencesQuery, 27 - UsePreferencesQueryResponse, 31 + type UsePreferencesQueryResponse, 28 32 } from '#/state/queries/preferences' 29 33 import {useResolveUriQuery} from '#/state/queries/resolve-uri' 30 34 import {truncateAndInvalidate} from '#/state/queries/util' 31 35 import {useSession} from '#/state/session' 32 - import {useComposerControls} from '#/state/shell/composer' 33 36 import {PostFeed} from '#/view/com/posts/PostFeed' 34 37 import {EmptyState} from '#/view/com/util/EmptyState' 35 38 import {FAB} from '#/view/com/util/fab/FAB' 36 39 import {Button} from '#/view/com/util/forms/Button' 37 - import {ListRef} from '#/view/com/util/List' 40 + import {type ListRef} from '#/view/com/util/List' 38 41 import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' 39 42 import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 40 43 import {Text} from '#/view/com/util/text/Text' ··· 156 159 }) { 157 160 const {_} = useLingui() 158 161 const {hasSession} = useSession() 159 - const {openComposer} = useComposerControls() 162 + const {openComposer} = useOpenComposer() 160 163 const isScreenFocused = useIsFocused() 161 164 162 165 useSetTitle(feedInfo?.displayName)
+17 -16
src/screens/Settings/AccountSettings.tsx
··· 9 9 import {atoms as a, useTheme} from '#/alf' 10 10 import {useDialogControl} from '#/components/Dialog' 11 11 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 12 - import {ChangeEmailDialog} from '#/components/dialogs/ChangeEmailDialog' 13 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 12 + import { 13 + EmailDialogScreenID, 14 + useEmailDialogControl, 15 + } from '#/components/dialogs/EmailDialog' 14 16 import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At' 15 17 import {BirthdayCake_Stroke2_Corner2_Rounded as BirthdayCakeIcon} from '#/components/icons/BirthdayCake' 16 18 import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car' ··· 31 33 const {_} = useLingui() 32 34 const {currentAccount} = useSession() 33 35 const {openModal} = useModalControls() 34 - const verifyEmailControl = useDialogControl() 35 - const changeEmailControl = useDialogControl() 36 + const emailDialogControl = useEmailDialogControl() 36 37 const birthdayControl = useDialogControl() 37 38 const changeHandleControl = useDialogControl() 38 39 const exportCarControl = useDialogControl() ··· 75 76 {currentAccount && !currentAccount.emailConfirmed && ( 76 77 <SettingsList.PressableItem 77 78 label={_(msg`Verify your email`)} 78 - onPress={() => verifyEmailControl.open()} 79 + onPress={() => 80 + emailDialogControl.open({ 81 + id: EmailDialogScreenID.Verify, 82 + }) 83 + } 79 84 style={[ 80 85 a.my_xs, 81 86 a.mx_lg, ··· 96 101 </SettingsList.PressableItem> 97 102 )} 98 103 <SettingsList.PressableItem 99 - label={_(msg`Change email`)} 100 - onPress={() => changeEmailControl.open()}> 104 + label={_(msg`Update email`)} 105 + onPress={() => 106 + emailDialogControl.open({ 107 + id: EmailDialogScreenID.Update, 108 + }) 109 + }> 101 110 <SettingsList.ItemIcon icon={PencilIcon} /> 102 111 <SettingsList.ItemText> 103 - <Trans>Change email</Trans> 112 + <Trans>Update email</Trans> 104 113 </SettingsList.ItemText> 105 114 <SettingsList.Chevron /> 106 115 </SettingsList.PressableItem> ··· 167 176 </SettingsList.Container> 168 177 </Layout.Content> 169 178 170 - <ChangeEmailDialog 171 - control={changeEmailControl} 172 - verifyEmailControl={verifyEmailControl} 173 - /> 174 - <VerifyEmailDialog 175 - control={verifyEmailControl} 176 - changeEmailControl={changeEmailControl} 177 - /> 178 179 <BirthDateSettingsDialog control={birthdayControl} /> 179 180 <ChangeHandleDialog control={changeHandleControl} /> 180 181 <ExportCarDialog control={exportCarControl} />
+10 -55
src/screens/Settings/components/Email2FAToggle.tsx
··· 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {useAgent, useSession} from '#/state/session' 5 + import {useSession} from '#/state/session' 6 6 import {useDialogControl} from '#/components/Dialog' 7 - import {ChangeEmailDialog} from '#/components/dialogs/ChangeEmailDialog' 8 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 9 - import * as Prompt from '#/components/Prompt' 7 + import { 8 + EmailDialogScreenID, 9 + useEmailDialogControl, 10 + } from '#/components/dialogs/EmailDialog' 10 11 import {DisableEmail2FADialog} from './DisableEmail2FADialog' 11 12 import * as SettingsList from './SettingsList' 12 13 ··· 14 15 const {_} = useLingui() 15 16 const {currentAccount} = useSession() 16 17 const disableDialogControl = useDialogControl() 17 - const enableDialogControl = useDialogControl() 18 - const verifyEmailDialogControl = useDialogControl() 19 - const changeEmailDialogControl = useDialogControl() 20 - const agent = useAgent() 21 - 22 - const enableEmailAuthFactor = React.useCallback(async () => { 23 - if (currentAccount?.email) { 24 - await agent.com.atproto.server.updateEmail({ 25 - email: currentAccount.email, 26 - emailAuthFactor: true, 27 - }) 28 - await agent.resumeSession(agent.session!) 29 - } 30 - }, [currentAccount, agent]) 18 + const emailDialogControl = useEmailDialogControl() 31 19 32 20 const onToggle = React.useCallback(() => { 33 - if (!currentAccount) { 34 - return 35 - } 36 - if (currentAccount.emailAuthFactor) { 37 - disableDialogControl.open() 38 - } else { 39 - if (!currentAccount.emailConfirmed) { 40 - verifyEmailDialogControl.open() 41 - return 42 - } 43 - enableDialogControl.open() 44 - } 45 - }, [ 46 - currentAccount, 47 - enableDialogControl, 48 - verifyEmailDialogControl, 49 - disableDialogControl, 50 - ]) 21 + emailDialogControl.open({ 22 + id: EmailDialogScreenID.Manage2FA, 23 + }) 24 + }, [emailDialogControl]) 51 25 52 26 return ( 53 27 <> 54 28 <DisableEmail2FADialog control={disableDialogControl} /> 55 - <Prompt.Basic 56 - control={enableDialogControl} 57 - title={_(msg`Enable Email 2FA`)} 58 - description={_(msg`Require an email code to sign in to your account.`)} 59 - onConfirm={enableEmailAuthFactor} 60 - confirmButtonCta={_(msg`Enable`)} 61 - /> 62 - <VerifyEmailDialog 63 - control={verifyEmailDialogControl} 64 - changeEmailControl={changeEmailDialogControl} 65 - onCloseAfterVerifying={enableDialogControl.open} 66 - reasonText={_( 67 - msg`You need to verify your email address before you can enable email 2FA.`, 68 - )} 69 - /> 70 - <ChangeEmailDialog 71 - control={changeEmailDialogControl} 72 - verifyEmailControl={verifyEmailDialogControl} 73 - /> 74 29 <SettingsList.BadgeButton 75 30 label={ 76 31 currentAccount?.emailAuthFactor ? _(msg`Change`) : _(msg`Enable`)
+24 -16
src/screens/VideoFeed/index.tsx
··· 1 1 import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 2 import { 3 3 LayoutAnimation, 4 - ListRenderItem, 4 + type ListRenderItem, 5 5 Pressable, 6 6 ScrollView, 7 7 View, 8 - ViewabilityConfig, 9 - ViewToken, 8 + type ViewabilityConfig, 9 + type ViewToken, 10 10 } from 'react-native' 11 11 import {SystemBars} from 'react-native-edge-to-edge' 12 12 import { 13 13 Gesture, 14 14 GestureDetector, 15 - NativeGesture, 15 + type NativeGesture, 16 16 } from 'react-native-gesture-handler' 17 17 import Animated, { 18 18 useAnimatedStyle, ··· 24 24 } from 'react-native-safe-area-context' 25 25 import {useEvent} from 'expo' 26 26 import {useEventListener} from 'expo' 27 - import {Image, ImageStyle} from 'expo-image' 27 + import {Image, type ImageStyle} from 'expo-image' 28 28 import {LinearGradient} from 'expo-linear-gradient' 29 - import {createVideoPlayer, VideoPlayer, VideoView} from 'expo-video' 29 + import {createVideoPlayer, type VideoPlayer, VideoView} from 'expo-video' 30 30 import { 31 31 AppBskyEmbedVideo, 32 - AppBskyFeedDefs, 32 + type AppBskyFeedDefs, 33 33 AppBskyFeedPost, 34 34 AtUri, 35 - ModerationDecision, 35 + type ModerationDecision, 36 36 RichText as RichTextAPI, 37 37 } from '@atproto/api' 38 38 import {msg, Trans} from '@lingui/macro' 39 39 import {useLingui} from '@lingui/react' 40 40 import { 41 - RouteProp, 41 + type RouteProp, 42 42 useFocusEffect, 43 43 useIsFocused, 44 44 useNavigation, 45 45 useRoute, 46 46 } from '@react-navigation/native' 47 - import {NativeStackScreenProps} from '@react-navigation/native-stack' 47 + import {type NativeStackScreenProps} from '@react-navigation/native-stack' 48 48 49 49 import {HITSLOP_20} from '#/lib/constants' 50 50 import {useHaptics} from '#/lib/haptics' 51 51 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 52 - import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 52 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 53 + import { 54 + type CommonNavigatorParams, 55 + type NavigationProp, 56 + } from '#/lib/routes/types' 53 57 import {sanitizeDisplayName} from '#/lib/strings/display-names' 54 58 import {cleanError} from '#/lib/strings/errors' 55 59 import {sanitizeHandle} from '#/lib/strings/handles' 56 60 import {isAndroid} from '#/platform/detection' 57 61 import {useA11y} from '#/state/a11y' 58 - import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' 62 + import { 63 + POST_TOMBSTONE, 64 + type Shadow, 65 + usePostShadow, 66 + } from '#/state/cache/post-shadow' 59 67 import {useProfileShadow} from '#/state/cache/profile-shadow' 60 68 import { 61 69 FeedFeedbackProvider, ··· 64 72 import {useFeedFeedback} from '#/state/feed-feedback' 65 73 import {usePostLikeMutationQueue} from '#/state/queries/post' 66 74 import { 67 - AuthorFilter, 68 - FeedPostSliceItem, 75 + type AuthorFilter, 76 + type FeedPostSliceItem, 69 77 usePostFeedQuery, 70 78 } from '#/state/queries/post-feed' 71 79 import {useProfileFollowMutationQueue} from '#/state/queries/profile' 72 80 import {useSession} from '#/state/session' 73 - import {useComposerControls, useSetMinimalShellMode} from '#/state/shell' 81 + import {useSetMinimalShellMode} from '#/state/shell' 74 82 import {useSetLightStatusBar} from '#/state/shell/light-status-bar' 75 83 import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' 76 84 import {List} from '#/view/com/util/List' ··· 685 693 }) { 686 694 const {_} = useLingui() 687 695 const t = useTheme() 688 - const {openComposer} = useComposerControls() 696 + const {openComposer} = useOpenComposer() 689 697 const {currentAccount} = useSession() 690 698 const navigation = useNavigation<NavigationProp>() 691 699 const seekingAnimationSV = useSharedValue(0)
+13 -1
src/state/shell/composer/index.tsx
··· 125 125 } 126 126 127 127 export function useComposerControls() { 128 - return React.useContext(controlsContext) 128 + const {closeComposer} = React.useContext(controlsContext) 129 + return React.useMemo(() => ({closeComposer}), [closeComposer]) 130 + } 131 + 132 + /** 133 + * DO NOT USE DIRECTLY. The deprecation notice as a warning only, it's not 134 + * actually deprecated. 135 + * 136 + * @deprecated use `#/lib/hooks/useOpenComposer` instead 137 + */ 138 + export function useOpenComposer() { 139 + const {openComposer} = React.useContext(controlsContext) 140 + return React.useMemo(() => ({openComposer}), [openComposer]) 129 141 }
+2 -2
src/state/shell/composer/useComposerKeyboardShortcut.tsx
··· 1 1 import React from 'react' 2 2 3 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 3 4 import {useDialogStateContext} from '#/state/dialogs' 4 5 import {useLightbox} from '#/state/lightbox' 5 6 import {useModals} from '#/state/modals' 6 7 import {useSession} from '#/state/session' 7 8 import {useIsDrawerOpen} from '#/state/shell/drawer-open' 8 - import {useComposerControls} from './' 9 9 10 10 /** 11 11 * Based on {@link https://github.com/jaywcjlove/hotkeys-js/blob/b0038773f3b902574f22af747f3bb003a850f1da/src/index.js#L51C1-L64C2} ··· 39 39 } 40 40 41 41 export function useComposerKeyboardShortcut() { 42 - const {openComposer} = useComposerControls() 42 + const {openComposer} = useOpenComposer() 43 43 const {openDialogs} = useDialogStateContext() 44 44 const {isModalActive} = useModals() 45 45 const {activeLightbox} = useLightbox()
+1 -2
src/state/shell/index.tsx
··· 1 - import React from 'react' 1 + import type React from 'react' 2 2 3 3 import {Provider as ColorModeProvider} from './color-mode' 4 4 import {Provider as DrawerOpenProvider} from './drawer-open' ··· 9 9 import {Provider as TickEveryMinuteProvider} from './tick-every-minute' 10 10 11 11 export {useSetThemePrefs, useThemePrefs} from './color-mode' 12 - export {useComposerControls, useComposerState} from './composer' 13 12 export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' 14 13 export { 15 14 useIsDrawerSwipeDisabled,
-21
src/view/com/composer/Composer.tsx
··· 62 62 type SupportedMimeTypes, 63 63 } from '#/lib/constants' 64 64 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 65 - import {useEmail} from '#/lib/hooks/useEmail' 66 65 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 67 66 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 68 67 import {usePalette} from '#/lib/hooks/usePalette' ··· 120 119 import {UserAvatar} from '#/view/com/util/UserAvatar' 121 120 import {atoms as a, native, useTheme, web} from '#/alf' 122 121 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 123 - import {useDialogControl} from '#/components/Dialog' 124 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 125 122 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 126 123 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' 127 124 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' ··· 331 328 } 332 329 }, [onPressCancel, closeAllDialogs, closeAllModals]) 333 330 334 - const {needsEmailVerification} = useEmail() 335 - const emailVerificationControl = useDialogControl() 336 - 337 - useEffect(() => { 338 - if (needsEmailVerification) { 339 - emailVerificationControl.open() 340 - } 341 - }, [needsEmailVerification, emailVerificationControl]) 342 - 343 331 const missingAltError = useMemo(() => { 344 332 if (!requireAltTextEnabled) { 345 333 return ··· 620 608 const isWebFooterSticky = !isNative && thread.posts.length > 1 621 609 return ( 622 610 <BottomSheetPortalProvider> 623 - <VerifyEmailDialog 624 - control={emailVerificationControl} 625 - onCloseWithoutVerifying={() => { 626 - onClose() 627 - }} 628 - reasonText={_( 629 - msg`Before creating a post, you must first verify your email.`, 630 - )} 631 - /> 632 611 <KeyboardAvoidingView 633 612 testID="composePostView" 634 613 behavior={isIOS ? 'padding' : 'height'}
+30 -82
src/view/com/composer/videos/SelectVideoBtn.tsx
··· 1 1 import {useCallback} from 'react' 2 - import {Keyboard} from 'react-native' 3 - import {ImagePickerAsset} from 'expo-image-picker' 2 + import {type ImagePickerAsset} from 'expo-image-picker' 4 3 import {msg} from '@lingui/macro' 5 4 import {useLingui} from '@lingui/react' 6 5 7 6 import { 8 7 SUPPORTED_MIME_TYPES, 9 - SupportedMimeTypes, 8 + type SupportedMimeTypes, 10 9 VIDEO_MAX_DURATION_MS, 11 10 } from '#/lib/constants' 12 - import {BSKY_SERVICE} from '#/lib/constants' 13 11 import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' 14 - import {getHostnameFromUrl} from '#/lib/strings/url-helpers' 15 12 import {isWeb} from '#/platform/detection' 16 13 import {isNative} from '#/platform/detection' 17 - import {useSession} from '#/state/session' 18 14 import {atoms as a, useTheme} from '#/alf' 19 15 import {Button} from '#/components/Button' 20 - import {useDialogControl} from '#/components/Dialog' 21 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 22 16 import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' 23 - import * as Prompt from '#/components/Prompt' 24 17 import {pickVideo} from './pickVideo' 25 18 26 19 type Props = { ··· 33 26 const {_} = useLingui() 34 27 const t = useTheme() 35 28 const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() 36 - const control = Prompt.usePromptControl() 37 - const {currentAccount} = useSession() 38 29 39 30 const onPressSelectVideo = useCallback(async () => { 40 31 if (isNative && !(await requestVideoAccessIfNeeded())) { 41 32 return 42 33 } 43 34 44 - if ( 45 - currentAccount && 46 - !currentAccount.emailConfirmed && 47 - getHostnameFromUrl(currentAccount.service) === 48 - getHostnameFromUrl(BSKY_SERVICE) 49 - ) { 50 - Keyboard.dismiss() 51 - control.open() 52 - } else { 53 - const response = await pickVideo() 54 - if (response.assets && response.assets.length > 0) { 55 - const asset = response.assets[0] 56 - try { 57 - if (isWeb) { 58 - // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) 59 - if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) { 60 - throw Error(_(msg`Videos must be less than 3 minutes long`)) 61 - } 62 - // compression step on native converts to mp4, so no need to check there 63 - if ( 64 - !SUPPORTED_MIME_TYPES.includes( 65 - asset.mimeType as SupportedMimeTypes, 66 - ) 67 - ) { 68 - throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) 69 - } 70 - } else { 71 - if (typeof asset.duration !== 'number') { 72 - throw Error('Asset is not a video') 73 - } 74 - if (asset.duration > VIDEO_MAX_DURATION_MS) { 75 - throw Error(_(msg`Videos must be less than 3 minutes long`)) 76 - } 35 + const response = await pickVideo() 36 + if (response.assets && response.assets.length > 0) { 37 + const asset = response.assets[0] 38 + try { 39 + if (isWeb) { 40 + // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) 41 + if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) { 42 + throw Error(_(msg`Videos must be less than 3 minutes long`)) 77 43 } 78 - onSelectVideo(asset) 79 - } catch (err) { 80 - if (err instanceof Error) { 81 - setError(err.message) 82 - } else { 83 - setError(_(msg`An error occurred while selecting the video`)) 44 + // compression step on native converts to mp4, so no need to check there 45 + if ( 46 + !SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes) 47 + ) { 48 + throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) 84 49 } 50 + } else { 51 + if (typeof asset.duration !== 'number') { 52 + throw Error('Asset is not a video') 53 + } 54 + if (asset.duration > VIDEO_MAX_DURATION_MS) { 55 + throw Error(_(msg`Videos must be less than 3 minutes long`)) 56 + } 57 + } 58 + onSelectVideo(asset) 59 + } catch (err) { 60 + if (err instanceof Error) { 61 + setError(err.message) 62 + } else { 63 + setError(_(msg`An error occurred while selecting the video`)) 85 64 } 86 65 } 87 66 } 88 - }, [ 89 - requestVideoAccessIfNeeded, 90 - currentAccount, 91 - control, 92 - setError, 93 - _, 94 - onSelectVideo, 95 - ]) 67 + }, [requestVideoAccessIfNeeded, setError, _, onSelectVideo]) 96 68 97 69 return ( 98 70 <> ··· 111 83 style={disabled && t.atoms.text_contrast_low} 112 84 /> 113 85 </Button> 114 - <VerifyEmailPrompt control={control} /> 115 - </> 116 - ) 117 - } 118 - 119 - function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) { 120 - const {_} = useLingui() 121 - const verifyEmailDialogControl = useDialogControl() 122 - 123 - return ( 124 - <> 125 - <Prompt.Basic 126 - control={control} 127 - title={_(msg`Verified email required`)} 128 - description={_( 129 - msg`To upload videos to Bluesky, you must first verify your email.`, 130 - )} 131 - confirmButtonCta={_(msg`Verify now`)} 132 - confirmButtonColor="primary" 133 - onConfirm={() => { 134 - verifyEmailDialogControl.open() 135 - }} 136 - /> 137 - <VerifyEmailDialog control={verifyEmailDialogControl} /> 138 86 </> 139 87 ) 140 88 }
+8 -8
src/view/com/feeds/FeedPage.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' 3 + import {type AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' 4 4 import {msg} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 - import {NavigationProp, useNavigation} from '@react-navigation/native' 6 + import {type NavigationProp, useNavigation} from '@react-navigation/native' 7 7 import {useQueryClient} from '@tanstack/react-query' 8 8 9 9 import {VIDEO_FEED_URIS} from '#/lib/constants' 10 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 10 11 import {ComposeIcon2} from '#/lib/icons' 11 12 import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers' 12 - import {AllNavigatorParams} from '#/lib/routes/types' 13 + import {type AllNavigatorParams} from '#/lib/routes/types' 13 14 import {logEvent} from '#/lib/statsig/statsig' 14 15 import {s} from '#/lib/styles' 15 16 import {isNative} from '#/platform/detection' 16 17 import {listenSoftReset} from '#/state/events' 17 18 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 18 19 import {useSetHomeBadge} from '#/state/home-badge' 19 - import {SavedFeedSourceInfo} from '#/state/queries/feed' 20 + import {type SavedFeedSourceInfo} from '#/state/queries/feed' 20 21 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 21 - import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' 22 + import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' 22 23 import {truncateAndInvalidate} from '#/state/queries/util' 23 24 import {useSession} from '#/state/session' 24 25 import {useSetMinimalShellMode} from '#/state/shell' 25 - import {useComposerControls} from '#/state/shell/composer' 26 26 import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' 27 27 import {PostFeed} from '../posts/PostFeed' 28 28 import {FAB} from '../util/fab/FAB' 29 - import {ListMethods} from '../util/List' 29 + import {type ListMethods} from '../util/List' 30 30 import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' 31 31 import {MainScrollProvider} from '../util/MainScrollProvider' 32 32 ··· 57 57 const {_} = useLingui() 58 58 const navigation = useNavigation<NavigationProp<AllNavigatorParams>>() 59 59 const queryClient = useQueryClient() 60 - const {openComposer} = useComposerControls() 60 + const {openComposer} = useOpenComposer() 61 61 const [isScrolledDown, setIsScrolledDown] = React.useState(false) 62 62 const setMinimalShellMode = useSetMinimalShellMode() 63 63 const headerOffset = useHeaderOffset()
+9 -9
src/view/com/post-thread/PostThread.tsx
··· 5 5 import {useSafeAreaInsets} from 'react-native-safe-area-context' 6 6 import { 7 7 AppBskyFeedDefs, 8 - AppBskyFeedThreadgate, 8 + type AppBskyFeedThreadgate, 9 9 moderatePost, 10 10 } from '@atproto/api' 11 11 import {msg, Trans} from '@lingui/macro' ··· 14 14 import {HITSLOP_10} from '#/lib/constants' 15 15 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 16 16 import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' 17 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 17 18 import {useSetTitle} from '#/lib/hooks/useSetTitle' 18 19 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 19 20 import {clamp} from '#/lib/numbers' ··· 25 26 import { 26 27 fillThreadModerationCache, 27 28 sortThread, 28 - ThreadBlocked, 29 - ThreadModerationCache, 30 - ThreadNode, 31 - ThreadNotFound, 32 - ThreadPost, 29 + type ThreadBlocked, 30 + type ThreadModerationCache, 31 + type ThreadNode, 32 + type ThreadNotFound, 33 + type ThreadPost, 33 34 usePostThreadQuery, 34 35 } from '#/state/queries/post-thread' 35 36 import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' 36 37 import {usePreferencesQuery} from '#/state/queries/preferences' 37 38 import {useSession} from '#/state/session' 38 - import {useComposerControls} from '#/state/shell' 39 39 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 40 - import {List, ListMethods} from '#/view/com/util/List' 40 + import {List, type ListMethods} from '#/view/com/util/List' 41 41 import {atoms as a, useTheme} from '#/alf' 42 42 import {Button, ButtonIcon} from '#/components/Button' 43 43 import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' ··· 394 394 [refetch], 395 395 ) 396 396 397 - const {openComposer} = useComposerControls() 397 + const {openComposer} = useOpenComposer() 398 398 const onPressReply = React.useCallback(() => { 399 399 if (thread?.type !== 'post') { 400 400 return
+2 -2
src/view/com/post-thread/PostThreadItem.tsx
··· 17 17 import {useLingui} from '@lingui/react' 18 18 19 19 import {MAX_POST_LINES} from '#/lib/constants' 20 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 20 21 import {useOpenLink} from '#/lib/hooks/useOpenLink' 21 22 import {usePalette} from '#/lib/hooks/usePalette' 22 23 import {makeProfileLink} from '#/lib/routes/links' ··· 36 37 import {useLanguagePrefs} from '#/state/preferences' 37 38 import {type ThreadPost} from '#/state/queries/post-thread' 38 39 import {useSession} from '#/state/session' 39 - import {useComposerControls} from '#/state/shell/composer' 40 40 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 41 41 import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' 42 42 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' ··· 204 204 const pal = usePalette('default') 205 205 const {_, i18n} = useLingui() 206 206 const langPrefs = useLanguagePrefs() 207 - const {openComposer} = useComposerControls() 207 + const {openComposer} = useOpenComposer() 208 208 const [limitLines, setLimitLines] = React.useState( 209 209 () => countLines(richText?.text) >= MAX_POST_LINES, 210 210 )
+10 -6
src/view/com/post/Post.tsx
··· 1 1 import React, {useMemo, useState} from 'react' 2 - import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 2 + import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' 3 3 import { 4 - AppBskyFeedDefs, 4 + type AppBskyFeedDefs, 5 5 AppBskyFeedPost, 6 6 AtUri, 7 7 moderatePost, 8 - ModerationDecision, 8 + type ModerationDecision, 9 9 RichText as RichTextAPI, 10 10 } from '@atproto/api' 11 11 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' ··· 14 14 import {useQueryClient} from '@tanstack/react-query' 15 15 16 16 import {MAX_POST_LINES} from '#/lib/constants' 17 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 17 18 import {usePalette} from '#/lib/hooks/usePalette' 18 19 import {makeProfileLink} from '#/lib/routes/links' 19 20 import {countLines} from '#/lib/strings/helpers' 20 21 import {colors, s} from '#/lib/styles' 21 - import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' 22 + import { 23 + POST_TOMBSTONE, 24 + type Shadow, 25 + usePostShadow, 26 + } from '#/state/cache/post-shadow' 22 27 import {useModerationOpts} from '#/state/preferences/moderation-opts' 23 28 import {precacheProfile} from '#/state/queries/profile' 24 29 import {useSession} from '#/state/session' 25 - import {useComposerControls} from '#/state/shell/composer' 26 30 import {AviFollowButton} from '#/view/com/posts/AviFollowButton' 27 31 import {atoms as a} from '#/alf' 28 32 import {ProfileHoverCard} from '#/components/ProfileHoverCard' ··· 113 117 const queryClient = useQueryClient() 114 118 const pal = usePalette('default') 115 119 const {_} = useLingui() 116 - const {openComposer} = useComposerControls() 120 + const {openComposer} = useOpenComposer() 117 121 const [limitLines, setLimitLines] = useState( 118 122 () => countLines(richText?.text) >= MAX_POST_LINES, 119 123 )
+2 -2
src/view/com/posts/PostFeedItem.tsx
··· 19 19 20 20 import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types' 21 21 import {MAX_POST_LINES} from '#/lib/constants' 22 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 22 23 import {usePalette} from '#/lib/hooks/usePalette' 23 24 import {makeProfileLink} from '#/lib/routes/links' 24 25 import {sanitizeDisplayName} from '#/lib/strings/display-names' ··· 33 34 import {useFeedFeedbackContext} from '#/state/feed-feedback' 34 35 import {precacheProfile} from '#/state/queries/profile' 35 36 import {useSession} from '#/state/session' 36 - import {useComposerControls} from '#/state/shell/composer' 37 37 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 38 38 import {FeedNameText} from '#/view/com/util/FeedInfoText' 39 39 import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' ··· 159 159 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 160 160 }): React.ReactNode => { 161 161 const queryClient = useQueryClient() 162 - const {openComposer} = useComposerControls() 162 + const {openComposer} = useOpenComposer() 163 163 const pal = usePalette('default') 164 164 const {_} = useLingui() 165 165
+2 -2
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 22 22 import {CountWheel} from '#/lib/custom-animations/CountWheel' 23 23 import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' 24 24 import {useHaptics} from '#/lib/haptics' 25 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 25 26 import {makeProfileLink} from '#/lib/routes/links' 26 27 import {shareUrl} from '#/lib/sharing' 27 28 import {useGate} from '#/lib/statsig/statsig' ··· 33 34 usePostRepostMutationQueue, 34 35 } from '#/state/queries/post' 35 36 import {useRequireAuth, useSession} from '#/state/session' 36 - import {useComposerControls} from '#/state/shell/composer' 37 37 import { 38 38 ProgressGuideAction, 39 39 useProgressGuideControls, ··· 76 76 }): React.ReactNode => { 77 77 const t = useTheme() 78 78 const {_, i18n} = useLingui() 79 - const {openComposer} = useComposerControls() 79 + const {openComposer} = useOpenComposer() 80 80 const {currentAccount} = useSession() 81 81 const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) 82 82 const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
+9 -6
src/view/screens/Feeds.tsx
··· 1 1 import React from 'react' 2 2 import {ActivityIndicator, StyleSheet, View} from 'react-native' 3 - import {AppBskyFeedDefs} from '@atproto/api' 3 + import {type AppBskyFeedDefs} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 import {useFocusEffect} from '@react-navigation/native' 7 7 import debounce from 'lodash.debounce' 8 8 9 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 9 10 import {usePalette} from '#/lib/hooks/usePalette' 10 11 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 11 12 import {ComposeIcon2} from '#/lib/icons' 12 - import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 13 + import { 14 + type CommonNavigatorParams, 15 + type NativeStackScreenProps, 16 + } from '#/lib/routes/types' 13 17 import {cleanError} from '#/lib/strings/errors' 14 18 import {s} from '#/lib/styles' 15 19 import {isNative, isWeb} from '#/platform/detection' 16 20 import { 17 - SavedFeedItem, 21 + type SavedFeedItem, 18 22 useGetPopularFeedsQuery, 19 23 useSavedFeeds, 20 24 useSearchPopularFeedsMutation, 21 25 } from '#/state/queries/feed' 22 26 import {useSession} from '#/state/session' 23 27 import {useSetMinimalShellMode} from '#/state/shell' 24 - import {useComposerControls} from '#/state/shell/composer' 25 28 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 26 29 import {FAB} from '#/view/com/util/fab/FAB' 27 - import {List, ListMethods} from '#/view/com/util/List' 30 + import {List, type ListMethods} from '#/view/com/util/List' 28 31 import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 29 32 import {Text} from '#/view/com/util/text/Text' 30 33 import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' ··· 102 105 103 106 export function FeedsScreen(_props: Props) { 104 107 const pal = usePalette('default') 105 - const {openComposer} = useComposerControls() 108 + const {openComposer} = useOpenComposer() 106 109 const {isMobile} = useWebMediaQueries() 107 110 const [query, setQuery] = React.useState('') 108 111 const [isPTR, setIsPTR] = React.useState(false)
+17 -20
src/view/screens/Lists.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 import {useFocusEffect, useNavigation} from '@react-navigation/native' 6 6 7 - import {useEmail} from '#/lib/hooks/useEmail' 8 - import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 9 - import {NavigationProp} from '#/lib/routes/types' 7 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 8 + import { 9 + type CommonNavigatorParams, 10 + type NativeStackScreenProps, 11 + } from '#/lib/routes/types' 12 + import {type NavigationProp} from '#/lib/routes/types' 10 13 import {useModalControls} from '#/state/modals' 11 14 import {useSetMinimalShellMode} from '#/state/shell' 12 15 import {MyLists} from '#/view/com/lists/MyLists' 13 16 import {atoms as a} from '#/alf' 14 17 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 - import {useDialogControl} from '#/components/Dialog' 16 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 17 18 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 18 19 import * as Layout from '#/components/Layout' 19 20 ··· 23 24 const setMinimalShellMode = useSetMinimalShellMode() 24 25 const navigation = useNavigation<NavigationProp>() 25 26 const {openModal} = useModalControls() 26 - const {needsEmailVerification} = useEmail() 27 - const control = useDialogControl() 27 + const requireEmailVerification = useRequireEmailVerification() 28 28 29 29 useFocusEffect( 30 30 React.useCallback(() => { ··· 33 33 ) 34 34 35 35 const onPressNewList = React.useCallback(() => { 36 - if (needsEmailVerification) { 37 - control.open() 38 - return 39 - } 40 - 41 36 openModal({ 42 37 name: 'create-or-edit-list', 43 38 purpose: 'app.bsky.graph.defs#curatelist', ··· 51 46 } catch {} 52 47 }, 53 48 }) 54 - }, [needsEmailVerification, control, openModal, navigation]) 49 + }, [openModal, navigation]) 50 + 51 + const wrappedOnPressNewList = requireEmailVerification(onPressNewList, { 52 + instructions: [ 53 + <Trans key="lists"> 54 + Before creating a list, you must first verify your email. 55 + </Trans>, 56 + ], 57 + }) 55 58 56 59 return ( 57 60 <Layout.Screen testID="listsScreen"> ··· 68 71 color="secondary" 69 72 variant="solid" 70 73 size="small" 71 - onPress={onPressNewList}> 74 + onPress={wrappedOnPressNewList}> 72 75 <ButtonIcon icon={PlusIcon} /> 73 76 <ButtonText> 74 77 <Trans context="action">New</Trans> ··· 76 79 </Button> 77 80 </Layout.Header.Outer> 78 81 <MyLists filter="curate" style={a.flex_grow} /> 79 - <VerifyEmailDialog 80 - reasonText={_( 81 - msg`Before creating a list, you must first verify your email.`, 82 - )} 83 - control={control} 84 - /> 85 82 </Layout.Screen> 86 83 ) 87 84 }
+17 -20
src/view/screens/ModerationModlists.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 import {useFocusEffect, useNavigation} from '@react-navigation/native' 6 6 7 - import {useEmail} from '#/lib/hooks/useEmail' 8 - import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 9 - import {NavigationProp} from '#/lib/routes/types' 7 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 8 + import { 9 + type CommonNavigatorParams, 10 + type NativeStackScreenProps, 11 + } from '#/lib/routes/types' 12 + import {type NavigationProp} from '#/lib/routes/types' 10 13 import {useModalControls} from '#/state/modals' 11 14 import {useSetMinimalShellMode} from '#/state/shell' 12 15 import {MyLists} from '#/view/com/lists/MyLists' 13 16 import {atoms as a} from '#/alf' 14 17 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 - import {useDialogControl} from '#/components/Dialog' 16 - import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 17 18 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 18 19 import * as Layout from '#/components/Layout' 19 20 ··· 23 24 const setMinimalShellMode = useSetMinimalShellMode() 24 25 const navigation = useNavigation<NavigationProp>() 25 26 const {openModal} = useModalControls() 26 - const {needsEmailVerification} = useEmail() 27 - const control = useDialogControl() 27 + const requireEmailVerification = useRequireEmailVerification() 28 28 29 29 useFocusEffect( 30 30 React.useCallback(() => { ··· 33 33 ) 34 34 35 35 const onPressNewList = React.useCallback(() => { 36 - if (needsEmailVerification) { 37 - control.open() 38 - return 39 - } 40 - 41 36 openModal({ 42 37 name: 'create-or-edit-list', 43 38 purpose: 'app.bsky.graph.defs#modlist', ··· 51 46 } catch {} 52 47 }, 53 48 }) 54 - }, [needsEmailVerification, control, openModal, navigation]) 49 + }, [openModal, navigation]) 50 + 51 + const wrappedOnPressNewList = requireEmailVerification(onPressNewList, { 52 + instructions: [ 53 + <Trans key="modlist"> 54 + Before creating a list, you must first verify your email. 55 + </Trans>, 56 + ], 57 + }) 55 58 56 59 return ( 57 60 <Layout.Screen testID="moderationModlistsScreen"> ··· 68 71 color="secondary" 69 72 variant="solid" 70 73 size="small" 71 - onPress={onPressNewList}> 74 + onPress={wrappedOnPressNewList}> 72 75 <ButtonIcon icon={PlusIcon} /> 73 76 <ButtonText> 74 77 <Trans context="action">New</Trans> ··· 76 79 </Button> 77 80 </Layout.Header.Outer> 78 81 <MyLists filter="mod" style={a.flex_grow} /> 79 - <VerifyEmailDialog 80 - reasonText={_( 81 - msg`Before creating a list, you must first verify your email.`, 82 - )} 83 - control={control} 84 - /> 85 82 </Layout.Screen> 86 83 ) 87 84 }
+5 -5
src/view/screens/Notifications.tsx
··· 6 6 import {useQueryClient} from '@tanstack/react-query' 7 7 8 8 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 9 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 9 10 import {ComposeIcon2} from '#/lib/icons' 10 11 import { 11 - NativeStackScreenProps, 12 - NotificationsTabNavigatorParams, 12 + type NativeStackScreenProps, 13 + type NotificationsTabNavigatorParams, 13 14 } from '#/lib/routes/types' 14 15 import {s} from '#/lib/styles' 15 16 import {logger} from '#/logger' ··· 22 23 } from '#/state/queries/notifications/unread' 23 24 import {truncateAndInvalidate} from '#/state/queries/util' 24 25 import {useSetMinimalShellMode} from '#/state/shell' 25 - import {useComposerControls} from '#/state/shell/composer' 26 26 import {NotificationFeed} from '#/view/com/notifications/NotificationFeed' 27 27 import {Pager} from '#/view/com/pager/Pager' 28 28 import {TabBar} from '#/view/com/pager/TabBar' 29 29 import {FAB} from '#/view/com/util/fab/FAB' 30 - import {ListMethods} from '#/view/com/util/List' 30 + import {type ListMethods} from '#/view/com/util/List' 31 31 import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' 32 32 import {MainScrollProvider} from '#/view/com/util/MainScrollProvider' 33 33 import {atoms as a} from '#/alf' ··· 49 49 > 50 50 export function NotificationsScreen({}: Props) { 51 51 const {_} = useLingui() 52 - const {openComposer} = useComposerControls() 52 + const {openComposer} = useOpenComposer() 53 53 const unreadNotifs = useUnreadNotifications() 54 54 const hasNew = !!unreadNotifs 55 55 const {checkUnread: checkUnreadAll} = useUnreadNotificationsApi()
+9 -6
src/view/screens/Profile.tsx
··· 2 2 import {StyleSheet} from 'react-native' 3 3 import {SafeAreaView} from 'react-native-safe-area-context' 4 4 import { 5 - AppBskyActorDefs, 5 + type AppBskyActorDefs, 6 6 moderateProfile, 7 - ModerationOpts, 7 + type ModerationOpts, 8 8 RichText as RichTextAPI, 9 9 } from '@atproto/api' 10 10 import {msg} from '@lingui/macro' ··· 12 12 import {useFocusEffect} from '@react-navigation/native' 13 13 import {useQueryClient} from '@tanstack/react-query' 14 14 15 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 15 16 import {useSetTitle} from '#/lib/hooks/useSetTitle' 16 17 import {ComposeIcon2} from '#/lib/icons' 17 - import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 18 + import { 19 + type CommonNavigatorParams, 20 + type NativeStackScreenProps, 21 + } from '#/lib/routes/types' 18 22 import {combinedDisplayName} from '#/lib/strings/display-names' 19 23 import {cleanError} from '#/lib/strings/errors' 20 24 import {isInvalidHandle} from '#/lib/strings/handles' ··· 28 32 import {useResolveDidQuery} from '#/state/queries/resolve-uri' 29 33 import {useAgent, useSession} from '#/state/session' 30 34 import {useSetMinimalShellMode} from '#/state/shell' 31 - import {useComposerControls} from '#/state/shell/composer' 32 35 import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens' 33 36 import {ProfileLists} from '#/view/com/lists/ProfileLists' 34 37 import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' 35 38 import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 36 39 import {FAB} from '#/view/com/util/fab/FAB' 37 - import {ListRef} from '#/view/com/util/List' 40 + import {type ListRef} from '#/view/com/util/List' 38 41 import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' 39 42 import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' 40 43 import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' ··· 165 168 const profile = useProfileShadow(profileUnshadowed) 166 169 const {hasSession, currentAccount} = useSession() 167 170 const setMinimalShellMode = useSetMinimalShellMode() 168 - const {openComposer} = useComposerControls() 171 + const {openComposer} = useOpenComposer() 169 172 const { 170 173 data: labelerInfo, 171 174 error: labelerError,
+2 -2
src/view/screens/ProfileList.tsx
··· 16 16 import {useQueryClient} from '@tanstack/react-query' 17 17 18 18 import {useHaptics} from '#/lib/haptics' 19 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 19 20 import {usePalette} from '#/lib/hooks/usePalette' 20 21 import {useSetTitle} from '#/lib/hooks/useSetTitle' 21 22 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' ··· 54 55 import {truncateAndInvalidate} from '#/state/queries/util' 55 56 import {useSession} from '#/state/session' 56 57 import {useSetMinimalShellMode} from '#/state/shell' 57 - import {useComposerControls} from '#/state/shell/composer' 58 58 import {ListMembers} from '#/view/com/lists/ListMembers' 59 59 import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' 60 60 import {PostFeed} from '#/view/com/posts/PostFeed' ··· 155 155 }) { 156 156 const {_} = useLingui() 157 157 const queryClient = useQueryClient() 158 - const {openComposer} = useComposerControls() 158 + const {openComposer} = useOpenComposer() 159 159 const setMinimalShellMode = useSetMinimalShellMode() 160 160 const {currentAccount} = useSession() 161 161 const {rkey} = route.params
+2 -2
src/view/shell/desktop/LeftNav.tsx
··· 10 10 } from '@react-navigation/native' 11 11 12 12 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 13 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 13 14 import {usePalette} from '#/lib/hooks/usePalette' 14 15 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 15 16 import {getCurrentRoute, isTab} from '#/lib/routes/helpers' ··· 25 26 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 26 27 import {useProfilesQuery} from '#/state/queries/profile' 27 28 import {type SessionAccount, useSession, useSessionApi} from '#/state/session' 28 - import {useComposerControls} from '#/state/shell/composer' 29 29 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 30 30 import {useCloseAllActiveElements} from '#/state/util' 31 31 import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' ··· 447 447 function ComposeBtn() { 448 448 const {currentAccount} = useSession() 449 449 const {getState} = useNavigation() 450 - const {openComposer} = useComposerControls() 450 + const {openComposer} = useOpenComposer() 451 451 const {_} = useLingui() 452 452 const {leftNavMinimal} = useLayoutBreakpoints() 453 453 const [isFetchingHandle, setIsFetchingHandle] = React.useState(false)
+2
src/view/shell/index.tsx
··· 25 25 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 26 26 import {atoms as a, select, useTheme} from '#/alf' 27 27 import {setSystemUITheme} from '#/alf/util/systemUI' 28 + import {EmailDialog} from '#/components/dialogs/EmailDialog' 28 29 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' 29 30 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 30 31 import {SigninDialog} from '#/components/dialogs/Signin' ··· 152 153 <ModalsContainer /> 153 154 <MutedWordsDialog /> 154 155 <SigninDialog /> 156 + <EmailDialog /> 155 157 <InAppBrowserConsentDialog /> 156 158 <Lightbox /> 157 159 <PortalOutlet />
+2
src/view/shell/index.web.tsx
··· 17 17 import {ModalsContainer} from '#/view/com/modals/Modal' 18 18 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 19 19 import {atoms as a, select, useTheme} from '#/alf' 20 + import {EmailDialog} from '#/components/dialogs/EmailDialog' 20 21 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 21 22 import {SigninDialog} from '#/components/dialogs/Signin' 22 23 import {Outlet as PortalOutlet} from '#/components/Portal' ··· 67 68 <ModalsContainer /> 68 69 <MutedWordsDialog /> 69 70 <SigninDialog /> 71 + <EmailDialog /> 70 72 <Lightbox /> 71 73 <PortalOutlet /> 72 74