Skip to content
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

Merged
merged 25 commits into from
Nov 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d1a7800
Fix stuff
wojtus7 Oct 2, 2023
f2a47aa
Merge remote-tracking branch 'origin/main' into magic-code-blur
wojtus7 Oct 3, 2023
d50aae2
Prettier
wojtus7 Oct 3, 2023
e96e1dd
Merge remote-tracking branch 'origin/main' into magic-code-blur
wojtus7 Oct 23, 2023
690d22a
Merge remote-tracking branch 'origin/main' into magic-code-blur
wojtus7 Oct 27, 2023
2618870
Refocus input in basevalidatecodeform and remove wasSubmitted flag
wojtus7 Oct 27, 2023
4d8bc1e
Use InteractionManager instead of delay
wojtus7 Oct 27, 2023
c2987ca
Prettier
wojtus7 Oct 27, 2023
3b6beeb
Merge remote-tracking branch 'origin/main' into magic-code-blur
wojtus7 Oct 30, 2023
b703947
Merge remote-tracking branch 'origin/main' into magic-code-blur
wojtus7 Oct 30, 2023
008400e
Use @ imports
wojtus7 Oct 30, 2023
3ad8872
Improve import
wojtus7 Oct 30, 2023
b757c0c
Prettier
wojtus7 Oct 30, 2023
a10e186
Merge remote-tracking branch 'origin/main' into magic-code-blur
wojtus7 Nov 7, 2023
6d5b229
Merge remote-tracking branch 'origin/main' into magic-code-blur
wojtus7 Nov 13, 2023
d1285a0
Use role instead of accessibilityRole
wojtus7 Nov 13, 2023
cc2f797
Resurrect wasSubmitted flag
wojtus7 Nov 13, 2023
527b9e0
Add additional checks for code input paste and fast typing
wojtus7 Nov 13, 2023
f1628fc
Fix lint
wojtus7 Nov 14, 2023
9bd3d59
add shouldDelayFocus
kosmydel Nov 14, 2023
92f5be1
fix android keyboard
kosmydel Nov 16, 2023
09358c8
Merge branch 'main' into @kosmydel/magic-code-blur
kosmydel Nov 16, 2023
98c04ab
Refactor focusTimeoutRef.current in
kosmydel Nov 16, 2023
5c2a666
add helper function
kosmydel Nov 28, 2023
de8fee6
add comments
kosmydel Nov 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 127 additions & 58 deletions src/components/MagicCodeInput.js
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';
Expand All @@ -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,
Expand Down Expand Up @@ -104,23 +108,53 @@ 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();
};

const setInputAndIndex = (index) => {
setInput(TEXT_INPUT_EMPTY_STATE);
setFocusedIndex(index);
setEditIndex(index);
};

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();
lastFocusedIndex.current = 0;
setInputAndIndex(0);
inputRefs.current.focus();
props.onChangeText('');
},
blur() {
Expand All @@ -140,6 +174,7 @@ function MagicCodeInput(props) {
// on complete, it will call the onFulfill callback.
blurMagicCodeInput();
props.onFulfill(props.value);
lastValue.current = '';
};

useNetwork({onReconnect: validateAndSubmit});
Expand All @@ -154,17 +189,34 @@ 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;
setInputAndIndex(lastFocusedIndex.current);
}
event.preventDefault();
setInput('');
setFocusedIndex(index);
setEditIndex(index);
};

/**
* 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();
}
setInputAndIndex(index);
lastFocusedIndex.current = index;
};

/**
Expand All @@ -181,9 +233,16 @@ function MagicCodeInput(props) {
return;
}

// Checks if one new character was added, or if the content was replaced
const hasToSlice = value.length - 1 === lastValue.current.length && value.slice(0, value.length - 1) === lastValue.current;

// Gets the new value added by the user
const addedValue = hasToSlice ? 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);
Expand All @@ -192,7 +251,7 @@ 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();
setInputAndIndex(updatedFocusedIndex);

const finalInput = composeToString(numbers);
props.onChangeText(finalInput);
Expand Down Expand Up @@ -225,7 +284,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));
Expand All @@ -244,24 +303,31 @@ 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.
setInputAndIndex(newFocusedIndex);
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();
setInputAndIndex(newFocusedIndex);
inputRefs.current.focus();
} else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) {
const newFocusedIndex = Math.min(focusedIndex + 1, props.maxLength - 1);
inputRefs.current[newFocusedIndex].focus();
setInputAndIndex(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);
}
};
Expand Down Expand Up @@ -290,6 +356,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}
Expand All @@ -305,46 +414,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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caused crash. Now deploy blocker - #32181
@wojtus7 are you able to raise quick PR?

crash

Copy link
Contributor

Choose a reason for hiding this comment

The 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!

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can help too )

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working on the PR

Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR

I'm recording videos, however, I have a rate limit for adding new contact methods.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is ready :)
cc @situchan @dangrous

});
}}
prompt={this.props.translate('contacts.removeAreYouSure')}
confirmText={this.props.translate('common.yesContinue')}
cancelText={this.props.translate('common.cancel')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Contributor

@alitoshmatov alitoshmatov Dec 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

focusLastSelected should have been used also here, like this:
focusTimeoutRef.current = setTimeout(inputValidateCodeRef.current.focusLastSelected, CONST.ANIMATED_TRANSITION);

This caused issue - #33170

return () => {
if (!focusTimeoutRef.current) {
Expand Down
Loading