diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 893a02288e77..340fc9dfedbf 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -1,14 +1,15 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; +import React, {useEffect, useRef, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; import _ from 'underscore'; +import useLocalize from '@hooks/useLocalize'; import * as Browser from '@libs/Browser'; -import compose from '@libs/compose'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import getImageResolution from '@libs/fileDownload/getImageResolution'; -import SpinningIndicatorAnimation from '@styles/animation/SpinningIndicatorAnimation'; import stylePropTypes from '@styles/stylePropTypes'; +import styles from '@styles/styles'; +import themeColors from '@styles/themes/default'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import AttachmentModal from './AttachmentModal'; @@ -21,11 +22,8 @@ import * as Expensicons from './Icon/Expensicons'; import OfflineWithFeedback from './OfflineWithFeedback'; import PopoverMenu from './PopoverMenu'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; -import Tooltip from './Tooltip/PopoverAnchorTooltip'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; +import Tooltip from './Tooltip'; import withNavigationFocus from './withNavigationFocus'; -import withTheme, {withThemePropTypes} from './withTheme'; -import withThemeStyles, {withThemeStylesPropTypes} from './withThemeStyles'; const propTypes = { /** Avatar source to display */ @@ -54,9 +52,6 @@ const propTypes = { left: PropTypes.number, }).isRequired, - /** Flag to see if image is being uploaded */ - isUploading: PropTypes.bool, - /** Size of Indicator */ size: PropTypes.oneOf([CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]), @@ -94,9 +89,11 @@ const propTypes = { /** Whether navigation is focused */ isFocused: PropTypes.bool.isRequired, - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, - ...withThemePropTypes, + /** Where the popover should be positioned relative to the anchor points. */ + anchorAlignment: PropTypes.shape({ + horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), + vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), + }), }; const defaultProps = { @@ -106,7 +103,6 @@ const defaultProps = { style: [], DefaultAvatar: () => {}, isUsingDefaultAvatar: false, - isUploading: false, size: CONST.AVATAR_SIZE.DEFAULT, fallbackIcon: Expensicons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, @@ -118,58 +114,67 @@ const defaultProps = { headerTitle: '', previewSource: '', originalFileName: '', + anchorAlignment: { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }, }; -class AvatarWithImagePicker extends React.Component { - constructor(props) { - super(props); - this.animation = new SpinningIndicatorAnimation(); - this.setError = this.setError.bind(this); - this.isValidSize = this.isValidSize.bind(this); - this.showAvatarCropModal = this.showAvatarCropModal.bind(this); - this.hideAvatarCropModal = this.hideAvatarCropModal.bind(this); - this.state = { - isMenuVisible: false, - validationError: null, - phraseParam: {}, - isAvatarCropModalOpen: false, - imageName: '', - imageUri: '', - imageType: '', - }; - this.anchorRef = React.createRef(); - } - - componentDidMount() { - if (!this.props.isUploading) { - return; - } - - this.animation.start(); - } - - componentDidUpdate(prevProps) { - if (!prevProps.isFocused && this.props.isFocused) { - this.setError(null, {}); - } - if (!prevProps.isUploading && this.props.isUploading) { - this.animation.start(); - } else if (prevProps.isUploading && !this.props.isUploading) { - this.animation.stop(); - } - } - - componentWillUnmount() { - this.animation.stop(); - } +function AvatarWithImagePicker({ + isFocused, + DefaultAvatar, + style, + pendingAction, + errors, + errorRowStyles, + onErrorClose, + source, + fallbackIcon, + size, + type, + headerTitle, + previewSource, + originalFileName, + isUsingDefaultAvatar, + onImageRemoved, + anchorPosition, + anchorAlignment, + onImageSelected, + editorMaskImage, +}) { + const [isMenuVisible, setIsMenuVisible] = useState(false); + const [errorData, setErrorData] = useState({ + validationError: null, + phraseParam: {}, + }); + const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false); + const [imageData, setImageData] = useState({ + uri: '', + name: '', + type: '', + }); + const anchorRef = useRef(); + const {translate} = useLocalize(); /** * @param {String} error * @param {Object} phraseParam */ - setError(error, phraseParam) { - this.setState({validationError: error, phraseParam}); - } + const setError = (error, phraseParam) => { + setErrorData({ + validationError: error, + phraseParam, + }); + }; + + useEffect(() => { + if (isFocused) { + return; + } + + // Reset the error if the component is no longer focused. + setError(null, {}); + }, [isFocused]); /** * Check if the attachment extension is allowed. @@ -177,10 +182,10 @@ class AvatarWithImagePicker extends React.Component { * @param {Object} image * @returns {Boolean} */ - isValidExtension(image) { + const isValidExtension = (image) => { const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(image, 'name', '')); return _.contains(CONST.AVATAR_ALLOWED_EXTENSIONS, fileExtension.toLowerCase()); - } + }; /** * Check if the attachment size is less than allowed size. @@ -188,9 +193,7 @@ class AvatarWithImagePicker extends React.Component { * @param {Object} image * @returns {Boolean} */ - isValidSize(image) { - return image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE; - } + const isValidSize = (image) => image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE; /** * Check if the attachment resolution matches constraints. @@ -198,34 +201,29 @@ class AvatarWithImagePicker extends React.Component { * @param {Object} image * @returns {Promise} */ - isValidResolution(image) { - return getImageResolution(image).then( - (resolution) => - resolution.height >= CONST.AVATAR_MIN_HEIGHT_PX && - resolution.width >= CONST.AVATAR_MIN_WIDTH_PX && - resolution.height <= CONST.AVATAR_MAX_HEIGHT_PX && - resolution.width <= CONST.AVATAR_MAX_WIDTH_PX, + const isValidResolution = (image) => + getImageResolution(image).then( + ({height, width}) => height >= CONST.AVATAR_MIN_HEIGHT_PX && width >= CONST.AVATAR_MIN_WIDTH_PX && height <= CONST.AVATAR_MAX_HEIGHT_PX && width <= CONST.AVATAR_MAX_WIDTH_PX, ); - } /** * Validates if an image has a valid resolution and opens an avatar crop modal * * @param {Object} image */ - showAvatarCropModal(image) { - if (!this.isValidExtension(image)) { - this.setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS}); + const showAvatarCropModal = (image) => { + if (!isValidExtension(image)) { + setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS}); return; } - if (!this.isValidSize(image)) { - this.setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)}); + if (!isValidSize(image)) { + setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)}); return; } - this.isValidResolution(image).then((isValidResolution) => { - if (!isValidResolution) { - this.setError('avatarWithImagePicker.resolutionConstraints', { + isValidResolution(image).then((isValid) => { + if (!isValid) { + setError('avatarWithImagePicker.resolutionConstraints', { minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX, minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX, maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX, @@ -234,158 +232,168 @@ class AvatarWithImagePicker extends React.Component { return; } - this.setState({ - isAvatarCropModalOpen: true, - validationError: null, - phraseParam: {}, - isMenuVisible: false, - imageUri: image.uri, - imageName: image.name, - imageType: image.type, + setIsAvatarCropModalOpen(true); + setError(null, {}); + setIsMenuVisible(false); + setImageData({ + uri: image.uri, + name: image.name, + type: image.type, }); }); - } - - hideAvatarCropModal() { - this.setState({isAvatarCropModalOpen: false}); - } - - render() { - const DefaultAvatar = this.props.DefaultAvatar; - const additionalStyles = _.isArray(this.props.style) ? this.props.style : [this.props.style]; - - return ( - - - - - this.setState((prev) => ({isMenuVisible: !prev.isMenuVisible}))} - role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} - accessibilityLabel={this.props.translate('avatarWithImagePicker.editImage')} - disabled={this.state.isAvatarCropModalOpen} - ref={this.anchorRef} - > - - {this.props.source ? ( - - ) : ( - - )} - - - { + setIsAvatarCropModalOpen(false); + }; + + /** + * Create menu items list for avatar menu + * + * @param {Function} openPicker + * @returns {Array} + */ + const createMenuItems = (openPicker) => { + const menuItems = [ + { + icon: Expensicons.Upload, + text: translate('avatarWithImagePicker.uploadPhoto'), + onSelected: () => { + if (Browser.isSafari()) { + return; + } + openPicker({ + onPicked: showAvatarCropModal, + }); + }, + }, + ]; + + // If current avatar isn't a default avatar, allow Remove Photo option + if (!isUsingDefaultAvatar) { + menuItems.push({ + icon: Expensicons.Trashcan, + text: translate('avatarWithImagePicker.removePhoto'), + onSelected: () => { + setError(null, {}); + onImageRemoved(); + }, + }); + } + return menuItems; + }; + + return ( + + + + + setIsMenuVisible((prev) => !prev)} + role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + accessibilityLabel={translate('avatarWithImagePicker.editImage')} + disabled={isAvatarCropModalOpen} + ref={anchorRef} + > + + {source ? ( + - - - - - - {({show}) => ( - - {({openPicker}) => { - const menuItems = [ - { - icon: Expensicons.Upload, - text: this.props.translate('avatarWithImagePicker.uploadPhoto'), - onSelected: () => { - if (Browser.isSafari()) { - return; - } + ) : ( + + )} + + + + + + + + + {({show}) => ( + + {({openPicker}) => { + const menuItems = createMenuItems(openPicker); + + // If the current avatar isn't a default avatar, allow the "View Photo" option + if (!isUsingDefaultAvatar) { + menuItems.push({ + icon: Expensicons.Eye, + text: translate('avatarWithImagePicker.viewPhoto'), + onSelected: show, + }); + } + + return ( + setIsMenuVisible(false)} + onItemSelected={(item, index) => { + setIsMenuVisible(false); + // In order for the file picker to open dynamically, the click + // function must be called from within an event handler that was initiated + // by the user on Safari. + if (index === 0 && Browser.isSafari()) { openPicker({ - onPicked: this.showAvatarCropModal, + onPicked: showAvatarCropModal, }); - }, - }, - ]; - - // If current avatar isn't a default avatar, allow Remove Photo option - if (!this.props.isUsingDefaultAvatar) { - menuItems.push({ - icon: Expensicons.Trashcan, - text: this.props.translate('avatarWithImagePicker.removePhoto'), - onSelected: () => { - this.setError(null, {}); - this.props.onImageRemoved(); - }, - }); - - menuItems.push({ - icon: Expensicons.Eye, - text: this.props.translate('avatarWithImagePicker.viewPhoto'), - onSelected: () => show(), - }); - } - return ( - this.setState({isMenuVisible: false})} - onItemSelected={(item, index) => { - this.setState({isMenuVisible: false}); - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === 0 && Browser.isSafari()) { - openPicker({ - onPicked: this.showAvatarCropModal, - }); - } - }} - menuItems={menuItems} - anchorPosition={this.props.anchorPosition} - withoutOverlay - anchorRef={this.anchorRef} - anchorAlignment={this.props.anchorAlignment} - /> - ); - }} - - )} - - - {this.state.validationError && ( - - )} - + } + }} + menuItems={menuItems} + anchorPosition={anchorPosition} + withoutOverlay + anchorRef={anchorRef} + anchorAlignment={anchorAlignment} + /> + ); + }} + + )} + - ); - } + {errorData.validationError && ( + + )} + + + ); } AvatarWithImagePicker.propTypes = propTypes; AvatarWithImagePicker.defaultProps = defaultProps; +AvatarWithImagePicker.displayName = 'AvatarWithImagePicker'; -export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(AvatarWithImagePicker); +export default withNavigationFocus(AvatarWithImagePicker); diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js index cc9505a4378f..b51146cde7f3 100644 --- a/src/pages/workspace/WorkspaceSettingsPage.js +++ b/src/pages/workspace/WorkspaceSettingsPage.js @@ -111,7 +111,6 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) { enabledWhenOffline > ( diff --git a/src/styles/animation/SpinningIndicatorAnimation.js b/src/styles/animation/SpinningIndicatorAnimation.js deleted file mode 100644 index 1ae4b1518325..000000000000 --- a/src/styles/animation/SpinningIndicatorAnimation.js +++ /dev/null @@ -1,89 +0,0 @@ -import {Animated, Easing} from 'react-native'; -import useNativeDriver from '@libs/useNativeDriver'; - -class SpinningIndicatorAnimation { - constructor() { - this.rotate = new Animated.Value(0); - this.scale = new Animated.Value(1); - this.startRotation = this.startRotation.bind(this); - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.getSyncingStyles = this.getSyncingStyles.bind(this); - } - - /** - * Rotation animation for indicator in a loop - * - * @memberof AvatarWithImagePicker - */ - startRotation() { - this.rotate.setValue(0); - Animated.loop( - Animated.timing(this.rotate, { - toValue: 1, - duration: 2000, - easing: Easing.linear, - isInteraction: false, - - // Animated.loop does not work with `useNativeDriver: true` on Web - useNativeDriver, - }), - ).start(); - } - - /** - * Start Animation for Indicator - * - * @memberof AvatarWithImagePicker - */ - start() { - this.startRotation(); - Animated.spring(this.scale, { - toValue: 1.666, - tension: 1, - isInteraction: false, - useNativeDriver, - }).start(); - } - - /** - * Stop Animation for Indicator - * - * @memberof AvatarWithImagePicker - */ - stop() { - Animated.spring(this.scale, { - toValue: 1, - tension: 1, - isInteraction: false, - useNativeDriver, - }).start(() => { - this.rotate.resetAnimation(); - this.scale.resetAnimation(); - this.rotate.setValue(0); - }); - } - - /** - * Get Indicator Styles while animating - * - * @returns {Object} - */ - getSyncingStyles() { - return { - transform: [ - { - rotate: this.rotate.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '-360deg'], - }), - }, - { - scale: this.scale, - }, - ], - }; - } -} - -export default SpinningIndicatorAnimation;