diff --git a/package-lock.json b/package-lock.json index c6cdf54af695..d207e358dc56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7083,8 +7083,7 @@ "html-entities": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.3.1.tgz", - "integrity": "sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA==", - "dev": true + "integrity": "sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA==" }, "html-escaper": { "version": "2.0.2", @@ -11720,6 +11719,15 @@ "prop-types": "^15.6.2" } }, + "react-beforeunload": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-beforeunload/-/react-beforeunload-2.2.2.tgz", + "integrity": "sha512-U2ZMbVj58ziIYEwQAKJTsCbe9jXyltnKIrU47OTqk8amXzopL/S4fK7kqg0ph8O4aQoSy5ydvKL+KLg+jIUe0Q==", + "requires": { + "prop-types": "^15.7.2", + "use-latest": "^1.1.0" + } + }, "react-devtools-core": { "version": "4.8.2", "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-4.8.2.tgz", @@ -14101,6 +14109,19 @@ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" }, + "use-isomorphic-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.0.0.tgz", + "integrity": "sha512-JMwJ7Vd86NwAt1jH7q+OIozZSIxA4ND0fx6AsOe2q1H8ooBUp5aN6DvVCqZiIaYU6JaMRJGyR0FO7EBCIsb/Rg==" + }, + "use-latest": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.1.0.tgz", + "integrity": "sha512-gF04d0ZMV3AMB8Q7HtfkAWe+oq1tFXP6dZKwBHQF5nVXtGsh2oAYeeqma5ZzxtlpOcW8Ro/tLcfmEodjDeqtuw==", + "requires": { + "use-isomorphic-layout-effect": "^1.0.0" + } + }, "use-subscription": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/use-subscription/-/use-subscription-1.4.1.tgz", diff --git a/package.json b/package.json index 4c8aa9e1a702..c4b68aa1c1c0 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,13 @@ }, "dependencies": { "@react-native-community/async-storage": "^1.11.0", + "html-entities": "^1.3.1", "jquery": "^3.5.1", "lodash.get": "^4.4.2", "moment": "^2.27.0", + "prop-types": "^15.7.2", "react": "^16.13.1", + "react-beforeunload": "^2.2.2", "react-dom": "^16.13.1", "react-native": "0.63.2", "react-native-web": "^0.13.5", diff --git a/src/CONFIG.js b/src/CONFIG.js index 7dcfe5902d07..44ff19f20faa 100644 --- a/src/CONFIG.js +++ b/src/CONFIG.js @@ -1,8 +1,10 @@ import {Platform} from 'react-native'; +// eslint-disable-next-line no-undef const IS_IN_PRODUCTION = Platform.OS === 'web' ? process.env.NODE_ENV === 'production' : !__DEV__; export default { + IS_IN_PRODUCTION, PUSHER: { APP_KEY: IS_IN_PRODUCTION ? '268df511a204fbb60884' : 'ac6d22b891daae55283a', AUTH_URL: IS_IN_PRODUCTION ? 'https://www.expensify.com' : 'https://www.expensify.com.dev', diff --git a/src/CONST.js b/src/CONST.js new file mode 100644 index 000000000000..88147aa0a726 --- /dev/null +++ b/src/CONST.js @@ -0,0 +1,5 @@ +const CONST = { + CLOUDFRONT_URL: 'https://d2k5nsl2zxldvw.cloudfront.net', +}; + +export default CONST; diff --git a/src/Expensify.js b/src/Expensify.js index 306b7c468529..74b0cb7683de 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -1,10 +1,12 @@ import React, {Component} from 'react'; +import {Beforeunload} from 'react-beforeunload'; import SignInPage from './page/SignInPage'; import HomePage from './page/HomePage/HomePage'; import * as Store from './store/Store'; import * as ActiveClientManager from './lib/ActiveClientManager'; import {verifyAuthToken} from './store/actions/SessionActions'; import STOREKEYS from './store/STOREKEYS'; +import WithStore from './components/WithStore'; import { Route, Router, @@ -15,44 +17,34 @@ import { // Initialize the store when the app loads for the first time Store.init(); -export default class Expensify extends Component { - constructor(props) { - super(props); - - this.state = { - redirectTo: null, - }; - } - - async componentDidMount() { - // Listen for when the app wants to redirect to a specific URL - Store.subscribe(STOREKEYS.APP_REDIRECT_TO, (redirectTo) => { - this.setState({redirectTo}); - }); - - // Verify that our authToken is OK to use - verifyAuthToken(); - - // Initialize this client as being an active client - await ActiveClientManager.init(); - - // TODO: Refactor window events - // window.addEventListener('beforeunload', () => { - // ActiveClientManager.removeClient(); - // }); - } - +class Expensify extends Component { render() { return ( - - {/* If there is ever a property for redirecting, we do the redirect here */} - {this.state.redirectTo && } - - - - - - + // TODO: Mobile does not support Beforeunload + // + + {/* If there is ever a property for redirecting, we do the redirect here */} + {this.state && this.state.redirectTo && } + + + + + + + // ); } } + +export default WithStore({ + redirectTo: { + key: STOREKEYS.APP_REDIRECT_TO, + loader: () => { + // Verify that our authToken is OK to use + verifyAuthToken(); + + // Initialize this client as being an active client + ActiveClientManager.init(); + }, + }, +})(Expensify); diff --git a/src/components/WithStore.js b/src/components/WithStore.js new file mode 100644 index 000000000000..a4cf95b89603 --- /dev/null +++ b/src/components/WithStore.js @@ -0,0 +1,94 @@ +/** + * This is a higher order component that provides the ability to map a state property directly to + * something in the store. That way, as soon as the store changes, the state will be set and the view + * will automatically change to reflect the new data. + */ +import React from 'react'; +import _ from 'underscore'; +import * as Store from '../store/Store'; + +export default function (mapStoreToStates) { + return WrappedComponent => class WithStore extends React.Component { + constructor(props) { + super(props); + + this.subscriptionIDs = []; + this.bind = this.bind.bind(this); + this.unbind = this.unbind.bind(this); + + // Initialize the state with each of our property names + this.state = _.reduce(_.keys(mapStoreToStates), (finalResult, propertyName) => ({ + ...finalResult, + [propertyName]: null, + }), {}); + } + + componentDidMount() { + this.bindStoreToStates(mapStoreToStates, this.wrappedComponent); + } + + componentWillUnmount() { + this.unbind(); + } + + /** + * A method that is convenient to bind the state to the store. Typically used when you can't pass + * mapStoreToStates to this HOC. For example: if the key that you want to subscribe to has a piece of + * information that can only come from the component's props, then you want to use bind() directly from inside + * componentDidMount(). All subscriptions will automatically be unbound when unmounted. + * + * The options passed to bind are the exact same that you would pass to the HOC. + * + * @param {object} mapping + * @param {object} component + */ + bind(mapping, component) { + this.bindStoreToStates(mapping, component); + } + + bindStoreToStates(statesToStoreMap, component) { + // Subscribe each of the state properties to the proper store key + _.each(statesToStoreMap, (stateToStoreMap, propertyName) => { + const { + key, + path, + prefillWithKey, + loader, + loaderParams, + defaultValue, + } = stateToStoreMap; + + this.subscriptionIDs.push(Store.bind(key, path, defaultValue, propertyName, component)); + if (prefillWithKey) { + Store.get(prefillWithKey, path, defaultValue) + .then(data => component.setState({[propertyName]: data})); + } + if (loader) { + loader(...loaderParams || []); + } + }); + } + + /** + * Unsubscribe from any subscriptions + */ + unbind() { + _.each(this.subscriptionIDs, Store.unbind); + } + + render() { + // Spreading props and state is necessary in an HOC where the data cannot be predicted + return ( + this.wrappedComponent = el} + bind={this.bind} + unbind={this.unbind} + /> + ); + } + }; +} diff --git a/src/lib/ActiveClientManager.js b/src/lib/ActiveClientManager.js index dabd2958f4eb..e73b0d2c05bd 100644 --- a/src/lib/ActiveClientManager.js +++ b/src/lib/ActiveClientManager.js @@ -1,3 +1,4 @@ +import _ from 'underscore'; import Guid from './Guid'; import * as Store from '../store/Store'; import STOREKEYS from '../store/STOREKEYS'; @@ -6,33 +7,34 @@ const clientID = Guid(); /** * Add our client ID to the list of active IDs + * + * @returns {Promise} */ -const init = async () => { - const activeClientIDs = (await Store.get(STOREKEYS.ACTIVE_CLIENT_IDS)) || []; - activeClientIDs.push(clientID); - Store.set(STOREKEYS.ACTIVE_CLIENT_IDS, activeClientIDs); -}; +const init = () => Store.merge(STOREKEYS.ACTIVE_CLIENT_IDS, {clientID}); /** * Remove this client ID from the array of active client IDs when this client is exited + * + * @returns {Promise} */ function removeClient() { - const activeClientIDs = Store.get(STOREKEYS.ACTIVE_CLIENT_IDS) || []; - const newActiveClientIDs = activeClientIDs.filter(activeClientID => activeClientID !== clientID); - Store.set(STOREKEYS.ACTIVE_CLIENT_IDS, newActiveClientIDs); + return Store.get(STOREKEYS.ACTIVE_CLIENT_IDS) + .then(activeClientIDs => _.omit(activeClientIDs, clientID)) + .then(newActiveClientIDs => Store.set(STOREKEYS.ACTIVE_CLIENT_IDS, newActiveClientIDs)); } /** * Checks if the current client is the leader (the first one in the list of active clients) * - * @returns {boolean} + * @returns {Promise} */ function isClientTheLeader() { - const activeClientIDs = Store.get(STOREKEYS.ACTIVE_CLIENT_IDS) || []; - if (!activeClientIDs.length) { - return false; - } - return activeClientIDs[0] === clientID; + return Store.get(STOREKEYS.ACTIVE_CLIENT_IDS) + .then(activeClientIDs => _.first(activeClientIDs) === clientID); } -export {init, removeClient, isClientTheLeader}; +export { + init, + removeClient, + isClientTheLeader +}; diff --git a/src/lib/ExpensiMark.js b/src/lib/ExpensiMark.js index ea49c834a25f..4ddf63a1fdeb 100644 --- a/src/lib/ExpensiMark.js +++ b/src/lib/ExpensiMark.js @@ -17,16 +17,19 @@ export default class ExpensiMark { }, { /** - * Use \b in this case because it will match on words, letters, and _: https://www.rexegg.com/regex-boundaries.html#wordboundary + * Use \b in this case because it will match on words, letters, and _: + * https://www.rexegg.com/regex-boundaries.html#wordboundary * The !_blank is to prevent the `target="_blank">` section of the link replacement from being captured - * Additionally, something like `\b\_([^<>]*?)\_\b` doesn't work because it won't replace `_https://www.test.com_` + * Additionally, something like `\b\_([^<>]*?)\_\b` doesn't work because it won't replace + * `_https://www.test.com_` */ name: 'italic', regex: '(?!_blank">)\\b\\_(.*?)\\_\\b', replacement: '$1', }, { - // Use \B in this case because \b doesn't match * or ~. \B will match everything that \b doesn't, so it works for * and ~: https://www.rexegg.com/regex-boundaries.html#notb + // Use \B in this case because \b doesn't match * or ~. \B will match everything that \b doesn't, so it + // works for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: 'bold', regex: '\\B\\*(.*?)\\*\\B', replacement: '$1', @@ -52,12 +55,12 @@ export default class ExpensiMark { */ replace(text) { // This ensures that any html the user puts into the comment field shows as raw html - text = Str.safeEscape(text); + let safeText = Str.safeEscape(text); this.rules.forEach((rule) => { - text = text.replace(new RegExp(rule.regex, 'g'), rule.replacement); + safeText = safeText.replace(new RegExp(rule.regex, 'g'), rule.replacement); }); - return text; + return safeText; } } diff --git a/src/lib/Network.js b/src/lib/Network.js index f915b6318a4e..f6f21e97f897 100644 --- a/src/lib/Network.js +++ b/src/lib/Network.js @@ -26,6 +26,12 @@ function request(command, data, type = 'post') { body: formData, })) .then(response => response.json()) + .then((responseData) => { + if (responseData.jsonCode === 200) { + return responseData; + } + console.error('[API] Error', responseData); + }) // eslint-disable-next-line no-unused-vars .catch(() => isAppOffline = true); } diff --git a/src/lib/PersistentStorage.js b/src/lib/PersistentStorage.js deleted file mode 100644 index 0416366dcbf4..000000000000 --- a/src/lib/PersistentStorage.js +++ /dev/null @@ -1,95 +0,0 @@ -/** - * This module is an abstraction around a persistent storage system. This file can be modified to use whatever - * persistent storage method is desired. - */ -import AsyncStorage from '@react-native-community/async-storage'; -import _ from 'underscore'; - -/** - * Get a key from storage - * - * @param {string} key - * @returns {Promise} - */ -function get(key) { - return AsyncStorage.getItem(key) - .then(val => JSON.parse(val)) - .catch(err => console.error(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`)); -} - -/** - * Get the data for multiple keys - * - * @param {string[]} keys - * @returns {Promise} - */ -function multiGet(keys) { - // AsyncStorage returns the data in an array format like: - // [ ['@MyApp_user', 'myUserValue'], ['@MyApp_key', 'myKeyValue'] ] - // This method will transform the data into a better JSON format like: - // {'@MyApp_user': 'myUserValue', '@MyApp_key': 'myKeyValue'} - return AsyncStorage.multiGet(keys) - .then(arrayOfData => _.reduce(arrayOfData, (finalData, keyValuePair) => ({ - ...finalData, - [keyValuePair[0]]: JSON.parse(keyValuePair[1]), - }), {})) - .catch(err => console.error(`Unable to get item from persistent storage. Keys: ${JSON.stringify(keys)} Error: ${err}`)); -} - -/** - * Write a key to storage - * - * @param {string} key - * @param {mixed} val - * @returns {Promise} - */ -function set(key, val) { - return AsyncStorage.setItem(key, JSON.stringify(val)); -} - -/** - * Set multiple keys at once - * - * @param {object} data where the keys and values will be stored - * @returns {Promise|Promise|*} - */ -function multiSet(data) { - // AsyncStorage expenses the data in an array like: - // [["@MyApp_user", "value_1"], ["@MyApp_key", "value_2"]] - // This method will transform the params from a better JSON format like: - // {'@MyApp_user': 'myUserValue', '@MyApp_key': 'myKeyValue'} - const keyValuePairs = _.reduce(data, (finalArray, val, key) => ([ - ...finalArray, - [key, JSON.stringify(val)], - ]), []); - return AsyncStorage.multiSet(keyValuePairs); -} - -/** - * Empty out the storage (like when the user signs out) - * - * @returns {Promise} - */ -function clear() { - return AsyncStorage.clear(); -} - -/** - * Merges `val` into an existing key. Best used when updating an existing object - * - * @param {string} key - * @param {mixed} val - * @returns {Promise} - */ -function merge(key, val) { - return AsyncStorage.mergeItem(key, val); -} - -export { - get, - multiGet, - set, - multiSet, - merge, - clear, -}; diff --git a/src/lib/Router/index.js b/src/lib/Router/index.js index 02a430d7d165..15923a042e87 100644 --- a/src/lib/Router/index.js +++ b/src/lib/Router/index.js @@ -1,5 +1,5 @@ import { - BrowserRouter as Router, + HashRouter as Router, Link, Route, Redirect, diff --git a/src/lib/Str.js b/src/lib/Str.js index b768528bc0c5..d8f97dba2df9 100644 --- a/src/lib/Str.js +++ b/src/lib/Str.js @@ -1,7 +1,8 @@ -/* globals $, _ */ - +import _ from 'underscore'; +import {AllHtmlEntities} from 'html-entities'; import Guid from './Guid'; + const Str = { /** * Returns the proper phrase depending on the count that is passed. @@ -39,7 +40,7 @@ const Str = { * @return {String} The decoded string. */ htmlDecode(s) { - return $('