diff --git a/android/.ruby-version b/android/.ruby-version new file mode 100644 index 000000000..57cf282eb --- /dev/null +++ b/android/.ruby-version @@ -0,0 +1 @@ +2.6.5 diff --git a/android/app/build.gradle b/android/app/build.gradle index 6ef76d3f0..0b3a3f900 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,12 +90,12 @@ apply from: "../../node_modules/react-native/react.gradle" * Upload all the APKs to the Play Store and people will download * the correct one based on the CPU architecture of their device. */ -def enableSeparateBuildPerCPUArchitecture = false +def enableSeparateBuildPerCPUArchitecture = true /** * Run Proguard to shrink the Java bytecode in release builds. */ -def enableProguardInReleaseBuilds = false +def enableProguardInReleaseBuilds = true /** * The preferred build flavor of JavaScriptCore. @@ -131,8 +131,8 @@ android { applicationId "com.chatwoot.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 23 - versionName "0.0.23" + versionCode 25 + versionName "0.0.25" } splits { abi { diff --git a/ios/Chatwoot.xcodeproj/project.pbxproj b/ios/Chatwoot.xcodeproj/project.pbxproj index c57de3171..7777a474c 100644 --- a/ios/Chatwoot.xcodeproj/project.pbxproj +++ b/ios/Chatwoot.xcodeproj/project.pbxproj @@ -665,12 +665,12 @@ ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = 6C953F3RX2; INFOPLIST_FILE = Chatwoot/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 0.0.23; + MARKETING_VERSION = 0.0.24; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -691,11 +691,11 @@ ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 6C953F3RX2; INFOPLIST_FILE = Chatwoot/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 0.0.23; + MARKETING_VERSION = 0.0.24; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/package.json b/package.json index cc106e526..1403fe196 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/mobile-app", - "version": "0.0.23", + "version": "0.0.25", "private": true, "scripts": { "clean": "rm -rf $TMPDIR/react-* && watchman watch-del-all && npm cache clean --force", diff --git a/src/actions/auth.js b/src/actions/auth.js index bd5f0607c..b9764d0e6 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -1,5 +1,7 @@ import axios from '../helpers/APIHelper'; +import * as RootNavigation from '../helpers/NavigationHelper'; + import { LOGIN, LOGIN_ERROR, @@ -14,7 +16,7 @@ import { import { showToast } from '../helpers/ToastHelper'; import I18n from '../i18n'; -export const onLogin = ({ email, password }) => async dispatch => { +export const onLogin = ({ email, password }) => async (dispatch) => { try { dispatch({ type: LOGIN }); const response = await axios.post('auth/sign_in', { email, password }); @@ -31,7 +33,7 @@ export const onLogin = ({ email, password }) => async dispatch => { } }; -export const onResetPassword = ({ email }) => async dispatch => { +export const onResetPassword = ({ email }) => async (dispatch) => { try { dispatch({ type: RESET_PASSWORD }); const response = await axios.post('auth/password', { email }); @@ -43,10 +45,11 @@ export const onResetPassword = ({ email }) => async dispatch => { } }; -export const resetAuth = () => async dispatch => { +export const resetAuth = () => async (dispatch) => { + RootNavigation.navigate('ConfigureURL'); dispatch({ type: RESET_AUTH }); }; -export const onLogOut = () => async dispatch => { +export const onLogOut = () => async (dispatch) => { dispatch({ type: USER_LOGOUT }); }; diff --git a/src/actions/settings.js b/src/actions/settings.js new file mode 100644 index 000000000..09c68c464 --- /dev/null +++ b/src/actions/settings.js @@ -0,0 +1,21 @@ +import { + SET_URL, + SET_URL_ERROR, + SET_URL_SUCCESS, + RESET_SETTINGS, +} from '../constants/actions'; +import * as RootNavigation from '../helpers/NavigationHelper'; + +export const setInstallationUrl = ({ url }) => async (dispatch) => { + try { + dispatch({ type: SET_URL }); + dispatch({ type: SET_URL_SUCCESS, payload: `https://${url}/` }); + RootNavigation.navigate('Login'); + } catch (error) { + dispatch({ type: SET_URL_ERROR, payload: error }); + } +}; + +export const resetSettings = () => async (dispatch) => { + dispatch({ type: RESET_SETTINGS }); +}; diff --git a/src/constants/actions.js b/src/constants/actions.js index c796349d7..a4c1ad092 100644 --- a/src/constants/actions.js +++ b/src/constants/actions.js @@ -1,5 +1,9 @@ export const SET_LOCALE = 'SET_LOCALE'; +export const SET_URL = 'SET_URL'; +export const SET_URL_SUCCESS = 'SET_URL_SUCCESS'; +export const SET_URL_ERROR = 'SET_URL_ERROR'; + export const LOGIN = 'LOGIN'; export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; export const LOGIN_ERROR = 'LOGIN_ERROR'; @@ -50,5 +54,5 @@ export const RESET_PASSWORD_SUCCESS = 'RESET_PASSWORD_SUCCESS'; export const RESET_PASSWORD_ERROR = 'RESET_PASSWORD_ERROR'; export const RESET_AUTH = 'RESET_AUTH'; - +export const RESET_SETTINGS = 'RESET_SETTINGS'; export const USER_LOGOUT = 'USER_LOGOUT'; diff --git a/src/helpers/APIHelper.js b/src/helpers/APIHelper.js index 91239736d..f14c30545 100644 --- a/src/helpers/APIHelper.js +++ b/src/helpers/APIHelper.js @@ -8,8 +8,9 @@ import { getHeaders } from './AuthHelper'; import { store } from '../store'; import { onLogOut } from '../actions/auth'; +import { getBaseUrl } from './UrlHelper'; -const parseErrorCode = error => { +const parseErrorCode = (error) => { if (error.response) { if (error.response.status === 401) { store.dispatch(onLogOut()); @@ -28,9 +29,9 @@ const API = axios.create(); API.defaults.baseURL = BASE_URL; // Request parsing interceptor API.interceptors.request.use( - async config => { + async (config) => { const headers = await getHeaders(); - + config.baseURL = await getBaseUrl(); if (headers) { config.headers = headers; const { accountId } = headers; @@ -41,13 +42,13 @@ API.interceptors.request.use( return config; }, - error => Promise.reject(error), + (error) => Promise.reject(error), ); // Response parsing interceptor API.interceptors.response.use( - response => response, - error => parseErrorCode(error), + (response) => response, + (error) => parseErrorCode(error), ); export default API; diff --git a/src/helpers/NavigationHelper.js b/src/helpers/NavigationHelper.js new file mode 100644 index 000000000..568bbe1dd --- /dev/null +++ b/src/helpers/NavigationHelper.js @@ -0,0 +1,7 @@ +import * as React from 'react'; + +export const navigationRef = React.createRef(); + +export function navigate(name, params) { + navigationRef.current?.navigate(name, params); +} diff --git a/src/helpers/UrlHelper.js b/src/helpers/UrlHelper.js new file mode 100644 index 000000000..ab53a5f51 --- /dev/null +++ b/src/helpers/UrlHelper.js @@ -0,0 +1,9 @@ +import { store } from '../store'; + +export const getBaseUrl = async () => { + try { + const state = await store.getState(); + const { installationUrl } = state.settings; + return installationUrl; + } catch (error) {} +}; diff --git a/src/helpers/formHelper.js b/src/helpers/formHelper.js index adec8fe39..f52a320d9 100644 --- a/src/helpers/formHelper.js +++ b/src/helpers/formHelper.js @@ -4,33 +4,40 @@ import t from 'tcomb-form-native'; const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; const nameRegex = /^.{2}/; const passwordRegex = /^.{6}/; -const mobileNumberValidator = mobileNumber => { +const urlRegex = /^(?:(http|https):\/\/)?(?:[\w-]+\.)+[a-z]{2,6}$/; +const mobileNumberValidator = (mobileNumber) => { const regex = /^(?:(?:\+|0{0,2})91(\s*[-]\s*)?|[0]?)?[789]\d{9}$/g; return regex.test(mobileNumber); }; export const IndianMobileRegex = t.refinement(t.Number, mobileNumberValidator); -export const isStringEmail = email => { +export const isStringEmail = (email) => { const re = emailRegex; return re.test(email); }; -export const Email = t.refinement(t.String, email => isStringEmail(email)); +export const Email = t.refinement(t.String, (email) => isStringEmail(email)); -export const isStringName = name => { +export const isStringName = (name) => { const re = nameRegex; return re.test(name); }; -export const Name = t.refinement(t.String, name => isStringName(name)); +export const Name = t.refinement(t.String, (name) => isStringName(name)); -export const isStringPassword = password => { +export const isStringUrl = (url) => { + const re = urlRegex; + return re.test(url); +}; +export const URL = t.refinement(t.String, (url) => isStringUrl(url)); + +export const isStringPassword = (password) => { const re = passwordRegex; return re.test(password); }; -export const Password = t.refinement(t.String, password => +export const Password = t.refinement(t.String, (password) => isStringPassword(password), ); -export const isNumberValid = number => { +export const isNumberValid = (number) => { const regex = /^\+?\d{1,8}(?:\.\d{1,2})?$/; return regex.test(number); }; diff --git a/src/helpers/index.js b/src/helpers/index.js index dd360125a..d0b0d5dee 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -42,15 +42,26 @@ export const getRandomColor = function ({ userName }) { // eslint-disable-next-line no-bitwise hash = userName.charCodeAt(i) + ((hash << 5) - hash); } - let colour = '#'; + let color = '#'; for (let i = 0; i < 3; i++) { // eslint-disable-next-line no-bitwise let value = (hash >> (i * 8)) & 0xff; - colour += ('00' + value.toString(16)).substr(-2); + color += ('00' + value.toString(16)).substr(-2); } - return colour; + + return ( + '#' + + color + .replace(/^#/, '') + .replace(/../g, (value) => + ( + '0' + + Math.min(255, Math.max(0, parseInt(value, 16) + -20)).toString(16) + ).substr(-2), + ) + ); }; export const checkImageExist = ({ thumbnail }) => { diff --git a/src/i18n/en.json b/src/i18n/en.json index c8641c21d..114eec24a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4,6 +4,12 @@ "HOME": "Home", "SETTINGS": "Settings" }, + "CONFIGURE_URL": { + "ENTER_URL": "Chatwoot installation URL", + "URL_ERROR": "Enter a valid URL", + "NEXT": "Next", + "CHANGE_LANGUAGE": "Change language" + }, "LOGIN": { "EMAIL": "Email", "PASSWORD": "Password", diff --git a/src/reducer/index.js b/src/reducer/index.js index 1628a8b9d..47a2a1f0f 100644 --- a/src/reducer/index.js +++ b/src/reducer/index.js @@ -3,12 +3,14 @@ import locale from './locale'; import auth from './auth'; import inbox from './inbox'; import conversation from './conversation'; +import settings from './settings'; const rootReducer = combineReducers({ locale, auth, inbox, conversation, + settings, }); export default (state, action) => diff --git a/src/reducer/settings.js b/src/reducer/settings.js new file mode 100644 index 000000000..c65a56826 --- /dev/null +++ b/src/reducer/settings.js @@ -0,0 +1,41 @@ +import { + SET_URL, + SET_URL_SUCCESS, + SET_URL_ERROR, + RESET_SETTINGS, +} from '../constants/actions'; +const initialState = { + installationUrl: null, + isUrlSet: false, + isSettingUrl: false, + error: {}, +}; +export default (state = initialState, action) => { + switch (action.type) { + case SET_URL: + return { ...state, isSettingUrl: true }; + + case SET_URL_SUCCESS: + return { + ...state, + isSettingUrl: false, + isUrlSet: true, + installationUrl: action.payload, + error: {}, + }; + case SET_URL_ERROR: + return { + ...state, + isSettingUrl: true, + isUrlSet: false, + error: action.payload, + installationUrl: null, + }; + + case RESET_SETTINGS: + return initialState; + + default: + return state; + } +}; diff --git a/src/router.js b/src/router.js index f3a2e88c7..52758b6e7 100644 --- a/src/router.js +++ b/src/router.js @@ -3,8 +3,11 @@ import { connect } from 'react-redux'; import { NavigationContainer } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { navigationRef } from './helpers/NavigationHelper'; + import PropTypes from 'prop-types'; import { createStackNavigator } from '@react-navigation/stack'; +import ConfigureURLScreen from './screens/ConfigureURLScreen/ConfigureURLScreen'; import LoginScreen from './screens/LoginScreen/LoginScreen'; import TabBar from './components/TabBar'; import ConversationList from './screens/ConversationList/ConversationList'; @@ -30,7 +33,7 @@ const SettingsStack = () => ( ); const TabStack = () => ( - }> + }> @@ -39,17 +42,21 @@ const TabStack = () => ( class RootApp extends Component { static propTypes = { isLogged: PropTypes.bool, + isUrlSet: PropTypes.bool, }; static defaultProps = { isLogged: false, + isUrlSet: false, }; render() { - const { isLogged } = this.props; + const { isLogged, isUrlSet } = this.props; return ( - - + + {isLogged ? ( <> @@ -62,6 +69,10 @@ class RootApp extends Component { ) : ( <> + {}, + isSettingUrl: false, + }; + + state = { + values: { + url: '', + }, + options: { + fields: { + url: { + placeholder: 'Ex: app.chatwoot.com', + template: (props) => , + error: i18n.t('CONFIGURE_URL.URL_ERROR'), + autoCapitalize: 'none', + config: { + label: i18n.t('CONFIGURE_URL.ENTER_URL'), + }, + }, + }, + }, + }; + + componentDidMount = () => { + this.props.resetSettings(); + }; + + onChange(values) { + this.setState({ + values, + }); + } + + onSubmit() { + const value = this.formRef.getValue(); + + if (value) { + const { url } = value; + this.props.setInstallationUrl({ url }); + } + } + + render() { + const { options, values } = this.state; + const { isSettingUrl, themedStyle } = this.props; + + return ( + + + + + + + +
{ + this.formRef = ref; + }} + type={URLForm} + options={options} + value={values} + onChange={(value) => this.onChange(value)} + /> + + this.onSubmit()} + size="large" + textStyle={themedStyle.nextButtonText}> + {i18n.t('CONFIGURE_URL.NEXT')} + + + + + + ); + } +} + +function bindAction(dispatch) { + return { + setInstallationUrl: (data) => dispatch(setInstallationUrl(data)), + resetSettings: () => dispatch(resetSettings()), + }; +} +function mapStateToProps(state) { + return { + isSettingUrl: state.settings.isSettingUrl, + }; +} + +const ConfigureURLScreen = withStyles(ConfigureURLScreenComponent, styles); +export default connect(mapStateToProps, bindAction)(ConfigureURLScreen); diff --git a/src/screens/ConfigureURLScreen/ConfigureURLScreen.style.js b/src/screens/ConfigureURLScreen/ConfigureURLScreen.style.js new file mode 100644 index 000000000..021a8beb1 --- /dev/null +++ b/src/screens/ConfigureURLScreen/ConfigureURLScreen.style.js @@ -0,0 +1,45 @@ +import { Dimensions } from 'react-native'; + +const deviceWidth = Dimensions.get('window').width; + +export default (theme) => ({ + keyboardView: { + flex: 1, + flexDirection: 'column', + justifyContent: 'center', + backgroundColor: theme['background-basic-color-1'], + }, + logoView: { + alignItems: 'center', + justifyContent: 'center', + marginTop: Dimensions.get('window').height * 0.13, + }, + logo: { + width: deviceWidth * 0.819, + height: deviceWidth * 0.4, + aspectRatio: 2, + resizeMode: 'contain', + }, + + formView: { + paddingLeft: 40, + paddingRight: 40, + marginTop: Dimensions.get('window').height * 0.07, + }, + + nextButtonView: { + paddingTop: 64, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + + nextButton: { + flex: 1, + }, + nextButtonText: { + color: theme['text-control-color'], + fontWeight: theme['font-medium'], + fontSize: theme['font-size-large'], + }, +}); diff --git a/src/screens/LoginScreen/LoginScreen.js b/src/screens/LoginScreen/LoginScreen.js index a4622386d..99b752113 100644 --- a/src/screens/LoginScreen/LoginScreen.js +++ b/src/screens/LoginScreen/LoginScreen.js @@ -6,6 +6,7 @@ import { KeyboardAvoidingView, Dimensions, Platform, + Text, } from 'react-native'; import { Button, withStyles } from '@ui-kitten/components'; import t from 'tcomb-form-native'; @@ -58,7 +59,7 @@ class LoginScreenComponent extends Component { fields: { email: { placeholder: '', - template: props => , + template: (props) => , keyboardType: 'email-address', error: i18n.t('LOGIN.EMAIL_ERROR'), autoCapitalize: 'none', @@ -68,7 +69,7 @@ class LoginScreenComponent extends Component { }, password: { placeholder: '', - template: props => , + template: (props) => , keyboardType: 'default', autoCapitalize: 'none', error: i18n.t('LOGIN.PASSWORD_ERROR'), @@ -121,13 +122,13 @@ class LoginScreenComponent extends Component { { + ref={(ref) => { this.formRef = ref; }} type={LoginForm} options={options} value={values} - onChange={value => this.onChange(value)} + onChange={(value) => this.onChange(value)} /> + | + @@ -169,8 +179,8 @@ class LoginScreenComponent extends Component { function bindAction(dispatch) { return { resetAuth: () => dispatch(resetAuth()), - onLogin: data => dispatch(onLogin(data)), - setLocale: data => dispatch(setLocale(data)), + onLogin: (data) => dispatch(onLogin(data)), + setLocale: (data) => dispatch(setLocale(data)), }; } function mapStateToProps(state) { diff --git a/src/screens/LoginScreen/LoginScreen.style.js b/src/screens/LoginScreen/LoginScreen.style.js index b4071c4c9..21b28ead3 100644 --- a/src/screens/LoginScreen/LoginScreen.style.js +++ b/src/screens/LoginScreen/LoginScreen.style.js @@ -2,7 +2,7 @@ import { Dimensions } from 'react-native'; const deviceWidth = Dimensions.get('window').width; -export default theme => ({ +export default (theme) => ({ keyboardView: { flex: 1, flexDirection: 'column', @@ -56,4 +56,10 @@ export default theme => ({ alignItems: 'center', justifyContent: 'center', }, + button: { + padding: 0, + minWidth: 2, + paddingHorizontal: 0, + paddingVertical: 0, + }, });