diff --git a/src/CONST.js b/src/CONST.js index 5875be480c05..2a23e488dd3d 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -389,6 +389,10 @@ const CONST = { ICON_TYPE_ICON: 'icon', ICON_TYPE_AVATAR: 'avatar', + AVATAR_SIZE: { + LARGE: 'large', + DEFAULT: 'default', + }, REGEX: { US_PHONE: /^\+1\d{10}$/, diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index d72244a5880a..ff8e65c5efbc 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -1,18 +1,22 @@ import _ from 'underscore'; import React from 'react'; -import {Pressable, View} from 'react-native'; +import { + Pressable, View, Animated, StyleSheet, +} from 'react-native'; import PropTypes from 'prop-types'; import Avatar from './Avatar'; import Icon from './Icon'; import PopoverMenu from './PopoverMenu'; import { - Upload, Trashcan, Pencil, + Upload, Trashcan, Pencil, Sync, } from './Icon/Expensicons'; import styles from '../styles/styles'; import themeColors from '../styles/themes/default'; import AttachmentPicker from './AttachmentPicker'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import variables from '../styles/variables'; +import CONST from '../CONST'; +import SpinningIndicatorAnimation from '../styles/animation/SpinningIndicatorAnimation'; const propTypes = { /** Avatar URL to display */ @@ -41,6 +45,12 @@ const propTypes = { left: PropTypes.number, }).isRequired, + /** Flag to see if image is being uploaded */ + isUploading: PropTypes.bool, + + /** Size of Indicator */ + size: PropTypes.string, + ...withLocalizePropTypes, }; @@ -51,17 +61,37 @@ const defaultProps = { style: [], DefaultAvatar: () => {}, isUsingDefaultAvatar: false, + isUploading: false, + size: CONST.AVATAR_SIZE.DEFAULT, }; class AvatarWithImagePicker extends React.Component { constructor(props) { super(props); - + this.animation = new SpinningIndicatorAnimation(); this.state = { isMenuVisible: false, }; } + componentDidMount() { + if (this.props.isUploading) { + this.animation.start(); + } + } + + componentDidUpdate(prevProps) { + if (!prevProps.isUploading && this.props.isUploading) { + this.animation.start(); + } else if (prevProps.isUploading && !this.props.isUploading) { + this.animation.stop(); + } + } + + componentWillUnmount() { + this.animation.stop(); + } + /** * Create menu items list for avatar menu * @@ -97,6 +127,17 @@ class AvatarWithImagePicker extends React.Component { render() { const {DefaultAvatar} = this.props; const additionalStyles = _.isArray(this.props.style) ? this.props.style : [this.props.style]; + + const indicatorStyles = [ + styles.alignItemsCenter, + styles.justifyContentCenter, + this.props.size === CONST.AVATAR_SIZE.LARGE ? styles.statusIndicatorLarge : styles.statusIndicator, + styles.statusIndicatorOnline, + this.animation.getSyncingStyles(), + ]; + + const indicatorIconSize = this.props.size === CONST.AVATAR_SIZE.LARGE ? variables.iconSizeXXSmall : variables.iconSizeXXXSmall; + return ( @@ -115,26 +156,45 @@ class AvatarWithImagePicker extends React.Component { {({openPicker}) => ( <> - this.setState({isMenuVisible: true})} - > - - - this.setState({isMenuVisible: false})} - onItemSelected={() => this.setState({isMenuVisible: false})} - menuItems={this.createMenuItems(openPicker)} - anchorPosition={this.props.anchorPosition} - animationIn="fadeInDown" - animationOut="fadeOutUp" - /> + { + this.props.isUploading + ? ( + + + + + ) + : ( + <> + this.setState({isMenuVisible: true})} + > + + + this.setState({isMenuVisible: false})} + onItemSelected={() => this.setState({isMenuVisible: false})} + menuItems={this.createMenuItems(openPicker)} + anchorPosition={this.props.anchorPosition} + animationIn="fadeInDown" + animationOut="fadeOutUp" + /> + + ) + } )} diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index 247ab5cdbec8..bec25774a386 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -1,6 +1,6 @@ import React, {PureComponent} from 'react'; import { - View, StyleSheet, Animated, Easing, + View, StyleSheet, Animated, } from 'react-native'; import PropTypes from 'prop-types'; import Avatar from './Avatar'; @@ -8,7 +8,7 @@ import themeColors from '../styles/themes/default'; import styles from '../styles/styles'; import Icon from './Icon'; import {Sync} from './Icon/Expensicons'; -import {getSyncingStyles} from '../styles/getAvatarWithIndicatorStyles'; +import SpinningIndicatorAnimation from '../styles/animation/SpinningIndicatorAnimation'; const propTypes = { /** Is user active? */ @@ -34,83 +34,27 @@ class AvatarWithIndicator extends PureComponent { constructor(props) { super(props); - this.rotate = new Animated.Value(0); - this.scale = new Animated.Value(1); - this.startRotation = this.startRotation.bind(this); - this.startSyncIndicator = this.startSyncIndicator.bind(this); - this.stopSyncIndicator = this.stopSyncIndicator.bind(this); + this.animation = new SpinningIndicatorAnimation(); } componentDidMount() { if (this.props.isSyncing) { - this.startSyncIndicator(); + this.animation.start(); } } componentDidUpdate(prevProps) { if (!prevProps.isSyncing && this.props.isSyncing) { - this.startSyncIndicator(); + this.animation.start(); } else if (prevProps.isSyncing && !this.props.isSyncing) { - this.stopSyncIndicator(); + this.animation.stop(); } } componentWillUnmount() { - this.stopSyncIndicator(); + this.animation.stop(); } - /** - * We need to manually loop the animations as `useNativeDriver` does not work well with Animated.loop. - * - * @memberof AvatarWithIndicator - */ - startRotation() { - this.rotate.setValue(0); - Animated.timing(this.rotate, { - toValue: 1, - duration: 2000, - easing: Easing.linear, - isInteraction: false, - useNativeDriver: true, - }).start(({finished}) => { - if (finished) { - this.startRotation(); - } - }); - } - - /** - * Start Animation for Indicator - * - * @memberof AvatarWithIndicator - */ - startSyncIndicator() { - this.startRotation(); - Animated.spring(this.scale, { - toValue: 1.666, - tension: 1, - isInteraction: false, - useNativeDriver: true, - }).start(); - } - - /** - * Stop Animation for Indicator - * - * @memberof AvatarWithIndicator - */ - stopSyncIndicator() { - Animated.spring(this.scale, { - toValue: 1, - tension: 1, - isInteraction: false, - useNativeDriver: true, - }).start(() => { - this.rotate.resetAnimation(); - this.scale.resetAnimation(); - this.rotate.setValue(0); - }); - } render() { const indicatorStyles = [ @@ -118,7 +62,7 @@ class AvatarWithIndicator extends PureComponent { styles.justifyContentCenter, this.props.size === 'large' ? styles.statusIndicatorLarge : styles.statusIndicator, this.props.isActive ? styles.statusIndicatorOnline : styles.statusIndicatorOffline, - getSyncingStyles(this.rotate, this.scale), + this.animation.getSyncingStyles(), ]; return ( diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index c7d5a5f4d6c7..39b6ba2f812b 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -271,10 +271,11 @@ function fetchLocalCurrency() { * @param {File|Object} file */ function setAvatar(file) { + setPersonalDetails({avatarUploading: true}); API.User_UploadAvatar({file}).then((response) => { // Once we get the s3url back, update the personal details for the user with the new avatar URL if (response.jsonCode === 200) { - setPersonalDetails({avatar: response.s3url}, true); + setPersonalDetails({avatar: response.s3url, avatarUploading: false}, true); } }); } diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 661a66b6ba27..7763663b43b9 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -250,11 +250,24 @@ function update(policyID, values) { return; } - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, values); + const updatedValues = {...values, ...{isPolicyUpdating: false}}; + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, updatedValues); Navigation.dismissModal(); + }).catch(() => { + const errorMessage = translateLocal('workspace.editor.genericFailureMessage'); + Growl.error(errorMessage, 5000); }); } +/** + * Sets local values for the policy + * @param {String} policyID + * @param {Object} values + */ +function updateLocalPolicyValues(policyID, values) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, values); +} + export { getPolicySummaries, getPolicyList, @@ -263,4 +276,5 @@ export { create, uploadAvatar, update, + updateLocalPolicyValues, }; diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 42a50801b7b5..1312416afbb8 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -203,12 +203,14 @@ class ProfilePage extends Component { /> deleteAvatar(this.props.myPersonalDetails.login)} // eslint-disable-next-line max-len isUsingDefaultAvatar={this.props.myPersonalDetails.avatar.includes('/images/avatars/avatar')} anchorPosition={styles.createMenuPositionProfile} + size={CONST.AVATAR_SIZE.LARGE} /> {this.props.translate('profilePage.tellUsAboutYourself')} diff --git a/src/pages/settings/Profile/currentUserPersonalDetailsPropsTypes.js b/src/pages/settings/Profile/currentUserPersonalDetailsPropsTypes.js index 45c1fc29a808..caa4d02d2e98 100644 --- a/src/pages/settings/Profile/currentUserPersonalDetailsPropsTypes.js +++ b/src/pages/settings/Profile/currentUserPersonalDetailsPropsTypes.js @@ -14,6 +14,9 @@ const currentUserPersonalDetailsPropsTypes = { /** Avatar URL of the current user from their personal details */ avatar: PropTypes.string, + /** Flag to set when Avatar uploading */ + avatarUploading: PropTypes.bool, + /** Pronouns of the current user from their personal details */ pronouns: PropTypes.string, diff --git a/src/pages/workspace/WorkspaceEditorPage.js b/src/pages/workspace/WorkspaceEditorPage.js index 7486a7ddf299..d6196070ba1e 100644 --- a/src/pages/workspace/WorkspaceEditorPage.js +++ b/src/pages/workspace/WorkspaceEditorPage.js @@ -16,12 +16,14 @@ import Button from '../../components/Button'; import Text from '../../components/Text'; import compose from '../../libs/compose'; import { - uploadAvatar, update, + uploadAvatar, update, updateLocalPolicyValues, } from '../../libs/actions/Policy'; import Icon from '../../components/Icon'; import {Workspace} from '../../components/Icon/Expensicons'; import AvatarWithImagePicker from '../../components/AvatarWithImagePicker'; import defaultTheme from '../../styles/themes/default'; +import Growl from '../../libs/Growl'; +import CONST from '../../CONST'; const propTypes = { /** List of betas */ @@ -50,12 +52,15 @@ class WorkspaceEditorPage extends React.Component { } onImageSelected(image) { + updateLocalPolicyValues(this.props.policy.id, {isAvatarUploading: true}); this.setState({previewAvatarURL: image.uri}); // Store the upload avatar promise so we can wait for it to finish before updating the policy this.uploadAvatarPromise = uploadAvatar(image).then(url => new Promise((resolve) => { this.setState({avatarURL: url}, resolve); - })); + })).catch(() => { + Growl.error(this.props.translate('workspace.editor.avatarUploadFailureMessage')); + }).finally(() => updateLocalPolicyValues(this.props.policy.id, {isAvatarUploading: false})); } onImageRemoved() { @@ -63,6 +68,8 @@ class WorkspaceEditorPage extends React.Component { } submit() { + updateLocalPolicyValues(this.props.policy.id, {isPolicyUpdating: true}); + // Wait for the upload avatar promise to finish before updating the policy this.uploadAvatarPromise.then(() => { const name = this.state.name.trim(); @@ -70,6 +77,8 @@ class WorkspaceEditorPage extends React.Component { const policyID = this.props.policy.id; update(policyID, {name, avatarURL}); + }).catch(() => { + updateLocalPolicyValues(this.props.policy.id, {isPolicyUpdating: false}); }); } @@ -85,6 +94,9 @@ class WorkspaceEditorPage extends React.Component { return null; } + const isButtonDisabled = policy.isAvatarUploading + || (this.state.avatarURL === this.props.policy.avatarURL + && this.state.name === this.props.policy.name); return ( ( { + 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; diff --git a/src/styles/getAvatarWithIndicatorStyles.js b/src/styles/getAvatarWithIndicatorStyles.js deleted file mode 100644 index 7b9a97568b15..000000000000 --- a/src/styles/getAvatarWithIndicatorStyles.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Get Indicator Styles while animating - * - * @param {Object} rotate - * @param {Object} scale - * @returns {Object} - */ -function getSyncingStyles(rotate, scale) { - return { - transform: [{ - rotate: rotate.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '-360deg'], - }), - }, - { - scale, - }], - }; -} - -// eslint-disable-next-line import/prefer-default-export -export {getSyncingStyles}; diff --git a/src/styles/variables.js b/src/styles/variables.js index b688aaaf4563..25b36949e936 100644 --- a/src/styles/variables.js +++ b/src/styles/variables.js @@ -21,9 +21,11 @@ export default { fontSizeh1: 19, fontSizeXLarge: 24, fontSizeXXXLarge: 32, - iconSizeExtraSmall: 12, fontSizeNormalHeight: 20, lineHeightHero: 40, + iconSizeXXXSmall: 4, + iconSizeXXSmall: 8, + iconSizeExtraSmall: 12, iconSizeSmall: 16, iconSizeNormal: 20, iconSizeLarge: 24,