diff --git a/app/actions/views/edit_profile.js b/app/actions/views/edit_profile.js index fc5868cb1..3e91b7be5 100644 --- a/app/actions/views/edit_profile.js +++ b/app/actions/views/edit_profile.js @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {updateMe} from 'mattermost-redux/actions/users'; +import {updateMe, setDefaultProfileImage} from 'mattermost-redux/actions/users'; import {ViewTypes} from 'app/constants'; @@ -25,7 +25,15 @@ export function setProfileImageUri(imageUri = '') { }; } +export function removeProfileImage(user) { + return async (dispatch) => { + const result = await dispatch(setDefaultProfileImage(user)); + return result; + }; +} + export default { updateUser, setProfileImageUri, + removeProfileImage, }; diff --git a/app/components/__snapshots__/profile_picture_button.test.js.snap b/app/components/__snapshots__/profile_picture_button.test.js.snap new file mode 100644 index 000000000..8f915b597 --- /dev/null +++ b/app/components/__snapshots__/profile_picture_button.test.js.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`profile_picture_button should match snapshot 1`] = ` + +`; diff --git a/app/components/attachment_button.js b/app/components/attachment_button.js index eddf52718..bc1a1d33b 100644 --- a/app/components/attachment_button.js +++ b/app/components/attachment_button.js @@ -42,6 +42,7 @@ export default class AttachmentButton extends PureComponent { theme: PropTypes.object.isRequired, uploadFiles: PropTypes.func.isRequired, wrapper: PropTypes.bool, + extraOptions: PropTypes.arrayOf(PropTypes.object), }; static defaultProps = { @@ -52,6 +53,7 @@ export default class AttachmentButton extends PureComponent { canTakePhoto: true, canTakeVideo: true, maxFileCount: 5, + extraOptions: null, }; static contextTypes = { @@ -351,6 +353,7 @@ export default class AttachmentButton extends PureComponent { fileCount, maxFileCount, onShowFileMaxWarning, + extraOptions, } = this.props; if (fileCount === maxFileCount) { @@ -416,6 +419,14 @@ export default class AttachmentButton extends PureComponent { }); } + if (extraOptions) { + extraOptions.forEach((option) => { + if (option !== null) { + items.push(option); + } + }); + } + this.props.navigator.showModal({ screen: 'OptionsModal', title: '', @@ -479,4 +490,3 @@ const style = StyleSheet.create({ justifyContent: 'center', }, }); - diff --git a/app/components/profile_picture/profile_picture.js b/app/components/profile_picture/profile_picture.js index 6324e33ab..46ba37496 100644 --- a/app/components/profile_picture/profile_picture.js +++ b/app/components/profile_picture/profile_picture.js @@ -31,6 +31,7 @@ export default class ProfilePicture extends PureComponent { edit: PropTypes.bool, imageUri: PropTypes.string, profileImageUri: PropTypes.string, + profileImageRemove: PropTypes.bool, theme: PropTypes.object.isRequired, actions: PropTypes.shape({ getStatusForId: PropTypes.func.isRequired, @@ -47,6 +48,7 @@ export default class ProfilePicture extends PureComponent { state = { pictureUrl: null, + otherImageProps: {}, }; componentDidMount() { @@ -97,15 +99,34 @@ export default class ProfilePicture extends PureComponent { } }; + showDefaultImage = () => { + if (this.mounted) { + this.setState({otherImageProps: {defaultSource: placeholder}}); + } + }; + clearProfileImageUri = () => { if (this.props.isCurrentUser && this.props.profileImageUri !== '') { this.props.actions.setProfileImageUri(''); } } + componentDidUpdate(prevProps, prevState) { + if (!this.props.edit) { + if (this.state.otherImageProps !== prevState.otherImageProps) { + this.showDefaultImage(); + } + } + + if (this.props.profileImageRemove !== prevProps.profileImageRemove) { + this.setImageURL(null); + this.showDefaultImage(); + } + } + render() { const {edit, showStatus, theme} = this.props; - const {pictureUrl} = this.state; + const {pictureUrl, otherImageProps} = this.state; const style = getStyleSheet(theme); let statusIcon; @@ -146,11 +167,6 @@ export default class ProfilePicture extends PureComponent { }; } - const otherImageProps = {}; - if (!this.props.edit) { - otherImageProps.defaultSource = placeholder; - } - return ( { + let action = null; + const {removeProfileImage} = this.props; + const {id, last_picture_update: lastPictureUpdate} = this.props.currentUser; + + const profileImageUrl = Client4.getProfilePictureUrl(id, lastPictureUpdate); + + if (removeProfileImage !== null) { + action = removeProfileImage; + } + + // Check if image url includes query string for timestamp. + // If so, it means the image has been updated from the default + // i.e. '.../image?_=1544159746868' + if (profileImageUrl.includes('?')) { + return { + action, + text: { + id: t('mobile.edit_profile.remove_profile_photo'), + defaultMessage: 'Remove Photo', + }, + textStyle: { + color: '#CC3239', + }, + icon: 'trash', + iconStyle: { + color: '#CC3239', + }, + }; + } + return null; + } + + render() { + const {children, ...props} = this.props; + const {extraOptions} = this.state; + + // Avoid passing unneeded props + Reflect.deleteProperty(props, 'currentUser'); + + return ( + + {children} + + ); + } +} diff --git a/app/components/profile_picture_button.test.js b/app/components/profile_picture_button.test.js new file mode 100644 index 000000000..a10138e75 --- /dev/null +++ b/app/components/profile_picture_button.test.js @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {shallow} from 'enzyme'; + +import Preferences from 'mattermost-redux/constants/preferences'; + +import ProfilePictureButton from './profile_picture_button.js'; + +import {Client4} from 'mattermost-redux/client'; + +describe('profile_picture_button', () => { + const navigator = { + setOnNavigatorEvent: jest.fn(), + setButtons: jest.fn(), + dismissModal: jest.fn(), + push: jest.fn(), + }; + + const baseProps = { + theme: Preferences.THEMES.default, + navigator, + currentUser: { + first_name: 'Dwight', + last_name: 'Schrute', + username: 'ieatbeets', + email: 'dwight@schrutefarms.com', + nickname: 'Dragon', + position: 'position', + }, + blurTextBox: jest.fn(), + maxFileSize: 20 * 1024 * 1024, + uploadFiles: jest.fn(), + }; + + test('should match snapshot', async () => { + const wrapper = shallow( + , + ); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should NOT return option to remove when profile picture is default', () => { + Client4.getProfilePictureUrl = jest.fn(() => 'image.png'); + const wrapper = shallow( + , + ); + const instance = wrapper.instance(); + + // test default image (WITHOUT query param) + instance.getRemoveProfileImageOption(); + expect(wrapper.state('extraOptions')).toEqual([null]); + }); + + test('should return option to remove profile picture if customized', () => { + Client4.getProfilePictureUrl = jest.fn(() => 'image.png?query'); + const wrapper = shallow( + , + ); + const instance = wrapper.instance(); + + // test custom image (WITH query param) + instance.getRemoveProfileImageOption(); + expect(wrapper.state('extraOptions')).not.toEqual([null]); + }); +}); diff --git a/app/screens/edit_profile/__snapshots__/edit_profile.test.js.snap b/app/screens/edit_profile/__snapshots__/edit_profile.test.js.snap new file mode 100644 index 000000000..bd83dd9f0 --- /dev/null +++ b/app/screens/edit_profile/__snapshots__/edit_profile.test.js.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`edit_profile should match snapshot 1`] = ` + + + + + +`; diff --git a/app/screens/edit_profile/edit_profile.js b/app/screens/edit_profile/edit_profile.js index 5ca9a7f34..fa9e40bb3 100644 --- a/app/screens/edit_profile/edit_profile.js +++ b/app/screens/edit_profile/edit_profile.js @@ -21,8 +21,8 @@ import TextSetting from 'app/components/widgets/settings/text_setting'; import Loading from 'app/components/loading'; import ErrorText from 'app/components/error_text'; import StatusBar from 'app/components/status_bar/index'; +import ProfilePictureButton from 'app/components/profile_picture_button'; import ProfilePicture from 'app/components/profile_picture'; -import AttachmentButton from 'app/components/attachment_button'; import mattermostBucket from 'app/mattermost_bucket'; import LocalConfig from 'assets/config'; import {getFormattedFileSize} from 'mattermost-redux/utils/file_utils'; @@ -59,6 +59,7 @@ export default class EditProfile extends PureComponent { static propTypes = { actions: PropTypes.shape({ setProfileImageUri: PropTypes.func.isRequired, + removeProfileImage: PropTypes.func.isRequired, updateUser: PropTypes.func.isRequired, }).isRequired, config: PropTypes.object.isRequired, @@ -157,6 +158,7 @@ export default class EditProfile extends PureComponent { const { profileImage, + profileImageRemove, firstName, lastName, username, @@ -172,13 +174,17 @@ export default class EditProfile extends PureComponent { position, email, }; - const {actions} = this.props; + const {actions, currentUser} = this.props; if (profileImage) { actions.setProfileImageUri(profileImage.uri); this.uploadProfileImage().catch(this.handleUploadError); } + if (profileImageRemove) { + actions.removeProfileImage(currentUser.id); + } + if (this.canUpdate()) { const {error} = await actions.updateUser(user); if (error) { @@ -196,6 +202,14 @@ export default class EditProfile extends PureComponent { this.emitCanUpdateAccount(true); }; + handleRemoveProfileImage = () => { + this.setState({profileImageRemove: true}); + this.emitCanUpdateAccount(true); + this.props.navigator.dismissModal({ + animationType: 'none', + }); + } + uploadProfileImage = async () => { const {profileImage} = this.state; const {currentUser} = this.props; @@ -452,7 +466,7 @@ export default class EditProfile extends PureComponent { this.scrollView = ref; }; - render() { + renderProfilePicture = () => { const { currentUser, theme, @@ -461,12 +475,51 @@ export default class EditProfile extends PureComponent { const { profileImage, + profileImageRemove, + } = this.state; + + const style = getStyleSheet(theme); + const uri = profileImage ? profileImage.uri : null; + + return ( + + + + + + ); + } + + render() { + const {theme} = this.props; + + const { error, updating, } = this.state; const style = getStyleSheet(theme); - const uri = profileImage ? profileImage.uri : null; if (updating) { return ( @@ -501,29 +554,7 @@ export default class EditProfile extends PureComponent { > {displayError} - - - - - + {this.renderProfilePicture()} {this.renderFirstNameSettings()} {this.renderLastNameSettings()} diff --git a/app/screens/edit_profile/edit_profile.test.js b/app/screens/edit_profile/edit_profile.test.js new file mode 100644 index 000000000..249fedf10 --- /dev/null +++ b/app/screens/edit_profile/edit_profile.test.js @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {shallow} from 'enzyme'; + +import Preferences from 'mattermost-redux/constants/preferences'; + +import EditProfile from './edit_profile.js'; + +jest.mock('react-intl'); +jest.mock('app/utils/theme', () => { + const original = require.requireActual('app/utils/theme'); + return { + ...original, + changeOpacity: jest.fn(), + }; +}); + +describe('edit_profile', () => { + const navigator = { + setOnNavigatorEvent: jest.fn(), + setButtons: jest.fn(), + dismissModal: jest.fn(), + push: jest.fn(), + }; + + const actions = { + updateUser: jest.fn(), + setProfileImageUri: jest.fn(), + removeProfileImage: jest.fn(), + }; + + const baseProps = { + actions, + config: { + ShowEmailAddress: true, + }, + theme: Preferences.THEMES.default, + navigator, + currentUser: { + first_name: 'Dwight', + last_name: 'Schrute', + username: 'ieatbeets', + email: 'dwight@schrutefarms.com', + nickname: 'Dragon', + position: 'position', + }, + }; + + test('should match snapshot', async () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + expect(wrapper.instance().renderProfilePicture()).toMatchSnapshot(); + }); + + test('should match state on handleRemoveProfileImage', () => { + const newNavigator = { + dismissModal: jest.fn(), + setOnNavigatorEvent: jest.fn(), + setButtons: jest.fn(), + }; + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + wrapper.setProps({profileImageRemove: false}); + + const instance = wrapper.instance(); + instance.emitCanUpdateAccount = jest.fn(); + instance.handleRemoveProfileImage(); + + expect(wrapper.state('profileImageRemove')).toEqual(true); + expect(instance.emitCanUpdateAccount).toHaveBeenCalledTimes(1); + expect(instance.emitCanUpdateAccount).toBeCalledWith(true); + + expect(newNavigator.dismissModal).toHaveBeenCalledTimes(1); + expect(newNavigator.dismissModal).toBeCalledWith({animationType: 'none'}); + }); +}); diff --git a/app/screens/edit_profile/index.js b/app/screens/edit_profile/index.js index 6986c7d4c..50f3eff2c 100644 --- a/app/screens/edit_profile/index.js +++ b/app/screens/edit_profile/index.js @@ -7,7 +7,7 @@ import {bindActionCreators} from 'redux'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import {setProfileImageUri, updateUser} from 'app/actions/views/edit_profile'; +import {setProfileImageUri, removeProfileImage, updateUser} from 'app/actions/views/edit_profile'; import EditProfile from './edit_profile'; @@ -22,6 +22,7 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ setProfileImageUri, + removeProfileImage, updateUser, }, dispatch), }; diff --git a/app/screens/options_modal/options_modal_list.android.js b/app/screens/options_modal/options_modal_list.android.js index 5602d00ac..5c02d1c20 100644 --- a/app/screens/options_modal/options_modal_list.android.js +++ b/app/screens/options_modal/options_modal_list.android.js @@ -41,6 +41,11 @@ export default class OptionsModalList extends PureComponent { const options = items.map((item, index) => { let textComponent; + let optionIconStyle = style.optionIcon; + if (typeof item.iconStyle !== 'undefined') { + optionIconStyle = item.iconStyle; + } + if (item.text.hasOwnProperty('id')) { textComponent = ( } diff --git a/app/screens/options_modal/options_modal_list.ios.js b/app/screens/options_modal/options_modal_list.ios.js index 50ce66556..30a0b3447 100644 --- a/app/screens/options_modal/options_modal_list.ios.js +++ b/app/screens/options_modal/options_modal_list.ios.js @@ -45,6 +45,11 @@ export default class OptionsModalList extends PureComponent { const options = items.map((item, index) => { let textComponent; + let optionIconStyle = style.optionIcon; + if (typeof item.iconStyle !== 'undefined') { + optionIconStyle = item.iconStyle; + } + if (item.text.hasOwnProperty('id')) { textComponent = ( }