-
Notifications
You must be signed in to change notification settings - Fork 3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Magic code blur #28711
Magic code blur #28711
Changes from 23 commits
d1a7800
f2a47aa
d50aae2
e96e1dd
690d22a
2618870
4d8bc1e
c2987ca
3b6beeb
b703947
008400e
3ad8872
b757c0c
a10e186
6d5b229
d1285a0
cc2f797
527b9e0
f1628fc
9bd3d59
92f5be1
09358c8
98c04ab
5c2a666
de8fee6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,10 @@ | ||
import PropTypes from 'prop-types'; | ||
import React, {forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react'; | ||
import {StyleSheet, View} from 'react-native'; | ||
import {TapGestureHandler} from 'react-native-gesture-handler'; | ||
import _ from 'underscore'; | ||
import useNetwork from '@hooks/useNetwork'; | ||
import * as Browser from '@libs/Browser'; | ||
import * as ValidationUtils from '@libs/ValidationUtils'; | ||
import * as StyleUtils from '@styles/StyleUtils'; | ||
import useThemeStyles from '@styles/useThemeStyles'; | ||
|
@@ -13,6 +15,8 @@ import {withNetwork} from './OnyxProvider'; | |
import Text from './Text'; | ||
import TextInput from './TextInput'; | ||
|
||
const TEXT_INPUT_EMPTY_STATE = ''; | ||
|
||
const propTypes = { | ||
/** Information about the network */ | ||
network: networkPropTypes.isRequired, | ||
|
@@ -104,23 +108,49 @@ const getInputPlaceholderSlots = (length) => Array.from(Array(length).keys()); | |
|
||
function MagicCodeInput(props) { | ||
const styles = useThemeStyles(); | ||
const inputRefs = useRef([]); | ||
const [input, setInput] = useState(''); | ||
const inputRefs = useRef(); | ||
const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE); | ||
const [focusedIndex, setFocusedIndex] = useState(0); | ||
const [editIndex, setEditIndex] = useState(0); | ||
const [wasSubmitted, setWasSubmitted] = useState(false); | ||
const shouldFocusLast = useRef(false); | ||
const inputWidth = useRef(0); | ||
const lastFocusedIndex = useRef(0); | ||
const lastValue = useRef(TEXT_INPUT_EMPTY_STATE); | ||
|
||
useEffect(() => { | ||
lastValue.current = input.length; | ||
}, [input]); | ||
|
||
const blurMagicCodeInput = () => { | ||
inputRefs.current[editIndex].blur(); | ||
inputRefs.current.blur(); | ||
setFocusedIndex(undefined); | ||
}; | ||
|
||
const focusMagicCodeInput = () => { | ||
setFocusedIndex(0); | ||
lastFocusedIndex.current = 0; | ||
setEditIndex(0); | ||
inputRefs.current.focus(); | ||
}; | ||
|
||
useImperativeHandle(props.innerRef, () => ({ | ||
focus() { | ||
inputRefs.current[0].focus(); | ||
focusMagicCodeInput(); | ||
}, | ||
focusLastSelected() { | ||
inputRefs.current.focus(); | ||
}, | ||
resetFocus() { | ||
setInput(TEXT_INPUT_EMPTY_STATE); | ||
focusMagicCodeInput(); | ||
}, | ||
clear() { | ||
inputRefs.current[0].focus(); | ||
setInput(TEXT_INPUT_EMPTY_STATE); | ||
setFocusedIndex(0); | ||
lastFocusedIndex.current = 0; | ||
setEditIndex(0); | ||
inputRefs.current.focus(); | ||
props.onChangeText(''); | ||
}, | ||
blur() { | ||
|
@@ -140,6 +170,7 @@ function MagicCodeInput(props) { | |
// on complete, it will call the onFulfill callback. | ||
blurMagicCodeInput(); | ||
props.onFulfill(props.value); | ||
lastValue.current = ''; | ||
}; | ||
|
||
useNetwork({onReconnect: validateAndSubmit}); | ||
|
@@ -154,17 +185,38 @@ function MagicCodeInput(props) { | |
}, [props.value, props.shouldSubmitOnComplete]); | ||
|
||
/** | ||
* Callback for the onFocus event, updates the indexes | ||
* of the currently focused input. | ||
* Focuses on the input when it is pressed. | ||
* | ||
* @param {Object} event | ||
* @param {Number} index | ||
*/ | ||
const onFocus = (event, index) => { | ||
const onFocus = (event) => { | ||
if (shouldFocusLast.current) { | ||
lastValue.current = TEXT_INPUT_EMPTY_STATE; | ||
setInput(TEXT_INPUT_EMPTY_STATE); | ||
setFocusedIndex(lastFocusedIndex.current); | ||
setEditIndex(lastFocusedIndex.current); | ||
} | ||
event.preventDefault(); | ||
setInput(''); | ||
}; | ||
|
||
/** | ||
* Callback for the onPress event, updates the indexes | ||
* of the currently focused input. | ||
* | ||
* @param {Number} index | ||
*/ | ||
const onPress = (index) => { | ||
shouldFocusLast.current = false; | ||
// TapGestureHandler works differently on mobile web and native app | ||
// On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually | ||
if (!Browser.isMobileChrome() && !Browser.isMobileSafari()) { | ||
inputRefs.current.focus(); | ||
} | ||
setInput(TEXT_INPUT_EMPTY_STATE); | ||
setFocusedIndex(index); | ||
setEditIndex(index); | ||
lastFocusedIndex.current = index; | ||
}; | ||
|
||
/** | ||
|
@@ -181,9 +233,12 @@ function MagicCodeInput(props) { | |
return; | ||
} | ||
|
||
const addedValue = | ||
value.length - 1 === lastValue.current.length && value.slice(0, value.length - 1) === lastValue.current ? value.slice(lastValue.current.length, value.length) : value; | ||
lastValue.current = value; | ||
// Updates the focused input taking into consideration the last input | ||
// edited and the number of digits added by the user. | ||
const numbersArr = value | ||
const numbersArr = addedValue | ||
.trim() | ||
.split('') | ||
.slice(0, props.maxLength - editIndex); | ||
|
@@ -192,7 +247,9 @@ function MagicCodeInput(props) { | |
let numbers = decomposeString(props.value, props.maxLength); | ||
numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, props.maxLength)]; | ||
|
||
inputRefs.current[updatedFocusedIndex].focus(); | ||
setFocusedIndex(updatedFocusedIndex); | ||
setEditIndex(updatedFocusedIndex); | ||
setInput(TEXT_INPUT_EMPTY_STATE); | ||
|
||
const finalInput = composeToString(numbers); | ||
props.onChangeText(finalInput); | ||
|
@@ -225,7 +282,7 @@ function MagicCodeInput(props) { | |
// If the currently focused index already has a value, it will delete | ||
// that value but maintain the focus on the same input. | ||
if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { | ||
setInput(''); | ||
setInput(TEXT_INPUT_EMPTY_STATE); | ||
numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, props.maxLength)]; | ||
setEditIndex(focusedIndex); | ||
props.onChangeText(composeToString(numbers)); | ||
|
@@ -244,24 +301,37 @@ function MagicCodeInput(props) { | |
} | ||
|
||
const newFocusedIndex = Math.max(0, focusedIndex - 1); | ||
|
||
// Saves the input string so that it can compare to the change text | ||
// event that will be triggered, this is a workaround for mobile that | ||
// triggers the change text on the event after the key press. | ||
setInput(TEXT_INPUT_EMPTY_STATE); | ||
setFocusedIndex(newFocusedIndex); | ||
setEditIndex(newFocusedIndex); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We use these three lines in a number of places - might it be good to separate them out into a helper function? |
||
props.onChangeText(composeToString(numbers)); | ||
|
||
if (!_.isUndefined(newFocusedIndex)) { | ||
inputRefs.current[newFocusedIndex].focus(); | ||
inputRefs.current.focus(); | ||
} | ||
} | ||
if (keyValue === 'ArrowLeft' && !_.isUndefined(focusedIndex)) { | ||
const newFocusedIndex = Math.max(0, focusedIndex - 1); | ||
inputRefs.current[newFocusedIndex].focus(); | ||
setInput(TEXT_INPUT_EMPTY_STATE); | ||
setFocusedIndex(newFocusedIndex); | ||
setEditIndex(newFocusedIndex); | ||
inputRefs.current.focus(); | ||
} else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) { | ||
const newFocusedIndex = Math.min(focusedIndex + 1, props.maxLength - 1); | ||
inputRefs.current[newFocusedIndex].focus(); | ||
setInput(TEXT_INPUT_EMPTY_STATE); | ||
setFocusedIndex(newFocusedIndex); | ||
setEditIndex(newFocusedIndex); | ||
inputRefs.current.focus(); | ||
} else if (keyValue === 'Enter') { | ||
// We should prevent users from submitting when it's offline. | ||
if (props.network.isOffline) { | ||
return; | ||
} | ||
setInput(''); | ||
setInput(TEXT_INPUT_EMPTY_STATE); | ||
props.onFulfill(props.value); | ||
} | ||
}; | ||
|
@@ -290,6 +360,49 @@ function MagicCodeInput(props) { | |
return ( | ||
<> | ||
<View style={[styles.magicCodeInputContainer]}> | ||
<TapGestureHandler | ||
onBegan={(e) => { | ||
onPress(Math.floor(e.nativeEvent.x / (inputWidth.current / props.maxLength))); | ||
}} | ||
> | ||
{/* Android does not handle touch on invisible Views so I created a wrapper around invisible TextInput just to handle taps */} | ||
<View | ||
style={[StyleSheet.absoluteFillObject, styles.w100, styles.h100, styles.invisibleOverlay]} | ||
collapsable={false} | ||
> | ||
<TextInput | ||
onLayout={(e) => { | ||
inputWidth.current = e.nativeEvent.layout.width; | ||
}} | ||
ref={(ref) => (inputRefs.current = ref)} | ||
autoFocus={props.autoFocus} | ||
inputMode="numeric" | ||
textContentType="oneTimeCode" | ||
name={props.name} | ||
maxLength={props.maxLength} | ||
value={input} | ||
hideFocusedState | ||
autoComplete={input.length === 0 && props.autoComplete} | ||
shouldDelayFocus={input.length === 0 && props.shouldDelayFocus} | ||
keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} | ||
onChangeText={(value) => { | ||
onChangeText(value); | ||
}} | ||
onKeyPress={onKeyPress} | ||
onFocus={onFocus} | ||
onBlur={() => { | ||
shouldFocusLast.current = true; | ||
lastFocusedIndex.current = focusedIndex; | ||
setFocusedIndex(undefined); | ||
}} | ||
selectionColor="transparent" | ||
inputStyle={[styles.inputTransparent]} | ||
role={CONST.ACCESSIBILITY_ROLE.TEXT} | ||
style={[styles.inputTransparent]} | ||
textInputContainerStyles={[styles.borderNone]} | ||
/> | ||
</View> | ||
</TapGestureHandler> | ||
{_.map(getInputPlaceholderSlots(props.maxLength), (index) => ( | ||
<View | ||
key={index} | ||
|
@@ -305,46 +418,6 @@ function MagicCodeInput(props) { | |
> | ||
<Text style={[styles.magicCodeInput, styles.textAlignCenter]}>{decomposeString(props.value, props.maxLength)[index] || ''}</Text> | ||
</View> | ||
{/* Hide the input above the text. Cannot set opacity to 0 as it would break pasting on iOS Safari. */} | ||
<View style={[StyleSheet.absoluteFillObject, styles.w100, styles.bgTransparent]}> | ||
<TextInput | ||
ref={(ref) => { | ||
inputRefs.current[index] = ref; | ||
// Setting attribute type to "search" to prevent Password Manager from appearing in Mobile Chrome | ||
if (ref && ref.setAttribute) { | ||
ref.setAttribute('type', 'search'); | ||
} | ||
}} | ||
disableKeyboard={props.isDisableKeyboard} | ||
autoFocus={index === 0 && props.autoFocus} | ||
shouldDelayFocus={index === 0 && props.shouldDelayFocus} | ||
inputMode={props.isDisableKeyboard ? 'none' : 'numeric'} | ||
textContentType="oneTimeCode" | ||
name={props.name} | ||
maxLength={props.maxLength} | ||
value={input} | ||
hideFocusedState | ||
autoComplete={index === 0 ? props.autoComplete : 'off'} | ||
onChangeText={(value) => { | ||
// Do not run when the event comes from an input that is | ||
// not currently being responsible for the input, this is | ||
// necessary to avoid calls when the input changes due to | ||
// deleted characters. Only happens in mobile. | ||
if (index !== editIndex || _.isUndefined(focusedIndex)) { | ||
return; | ||
} | ||
onChangeText(value); | ||
}} | ||
onKeyPress={onKeyPress} | ||
onFocus={(event) => onFocus(event, index)} | ||
// Manually set selectionColor to make caret transparent. | ||
// We cannot use caretHidden as it breaks the pasting function on Android. | ||
selectionColor="transparent" | ||
textInputContainerStyles={[styles.borderNone]} | ||
inputStyle={[styles.inputTransparent]} | ||
role={CONST.ACCESSIBILITY_ROLE.TEXT} | ||
/> | ||
</View> | ||
</View> | ||
))} | ||
</View> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,7 @@ import Str from 'expensify-common/lib/str'; | |
import lodashGet from 'lodash/get'; | ||
import PropTypes from 'prop-types'; | ||
import React, {Component} from 'react'; | ||
import {Keyboard, ScrollView, View} from 'react-native'; | ||
import {InteractionManager, Keyboard, ScrollView, View} from 'react-native'; | ||
import {withOnyx} from 'react-native-onyx'; | ||
import _ from 'underscore'; | ||
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; | ||
|
@@ -264,6 +264,11 @@ class ContactMethodDetailsPage extends Component { | |
title={this.props.translate('contacts.removeContactMethod')} | ||
onConfirm={this.confirmDeleteAndHideModal} | ||
onCancel={() => this.toggleDeleteModal(false)} | ||
onModalHide={() => { | ||
InteractionManager.runAfterInteractions(() => { | ||
this.validateCodeFormRef.current.focusLastSelected(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can handle the fix if need be but if you're around @wojtus7 that would be great! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hey, @wojtus7 is not here. I can help with this one in an hour or two. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can help too ) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Working on the PR There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for jumping in @kosmydel! We'll be ready to review whenever There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm recording videos, however, I have a rate limit for adding new contact methods. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
}); | ||
}} | ||
prompt={this.props.translate('contacts.removeAreYouSure')} | ||
confirmText={this.props.translate('common.yesContinue')} | ||
cancelText={this.props.translate('common.cancel')} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -89,13 +89,25 @@ function BaseValidateCodeForm(props) { | |
} | ||
inputValidateCodeRef.current.focus(); | ||
}, | ||
focusLastSelected() { | ||
if (!inputValidateCodeRef.current) { | ||
return; | ||
} | ||
if (focusTimeoutRef.current) { | ||
clearTimeout(focusTimeoutRef.current); | ||
} | ||
focusTimeoutRef.current = setTimeout(inputValidateCodeRef.current.focusLastSelected, CONST.ANIMATED_TRANSITION); | ||
}, | ||
})); | ||
|
||
useFocusEffect( | ||
useCallback(() => { | ||
if (!inputValidateCodeRef.current) { | ||
return; | ||
} | ||
if (focusTimeoutRef.current) { | ||
clearTimeout(focusTimeoutRef.current); | ||
} | ||
focusTimeoutRef.current = setTimeout(inputValidateCodeRef.current.focus, CONST.ANIMATED_TRANSITION); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This caused issue - #33170 |
||
return () => { | ||
if (!focusTimeoutRef.current) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add a comment to explain this? It's very confusing to look at, haha. Also some parentheses around the boolean section might help.