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 $('').html(s).text();
+ return AllHtmlEntities.decode(s);
},
/**
diff --git a/src/lib/md5.js b/src/lib/md5.js
new file mode 100644
index 000000000000..bb572097ca88
--- /dev/null
+++ b/src/lib/md5.js
@@ -0,0 +1,194 @@
+/**
+ * md5 hash implementation
+ * http://www.myersdaily.org/joseph/javascript/md5-text.html
+ *
+ * Expensify modification: Wrap in a function to avoid global
+ * namespace pollution
+ *
+ */
+/* eslint-disable */
+function md5cycle(x, k) {
+ var a = x[0],
+ b = x[1],
+ c = x[2],
+ d = x[3];
+
+ a = ff(a, b, c, d, k[0], 7, -680876936);
+ d = ff(d, a, b, c, k[1], 12, -389564586);
+ c = ff(c, d, a, b, k[2], 17, 606105819);
+ b = ff(b, c, d, a, k[3], 22, -1044525330);
+ a = ff(a, b, c, d, k[4], 7, -176418897);
+ d = ff(d, a, b, c, k[5], 12, 1200080426);
+ c = ff(c, d, a, b, k[6], 17, -1473231341);
+ b = ff(b, c, d, a, k[7], 22, -45705983);
+ a = ff(a, b, c, d, k[8], 7, 1770035416);
+ d = ff(d, a, b, c, k[9], 12, -1958414417);
+ c = ff(c, d, a, b, k[10], 17, -42063);
+ b = ff(b, c, d, a, k[11], 22, -1990404162);
+ a = ff(a, b, c, d, k[12], 7, 1804603682);
+ d = ff(d, a, b, c, k[13], 12, -40341101);
+ c = ff(c, d, a, b, k[14], 17, -1502002290);
+ b = ff(b, c, d, a, k[15], 22, 1236535329);
+
+ a = gg(a, b, c, d, k[1], 5, -165796510);
+ d = gg(d, a, b, c, k[6], 9, -1069501632);
+ c = gg(c, d, a, b, k[11], 14, 643717713);
+ b = gg(b, c, d, a, k[0], 20, -373897302);
+ a = gg(a, b, c, d, k[5], 5, -701558691);
+ d = gg(d, a, b, c, k[10], 9, 38016083);
+ c = gg(c, d, a, b, k[15], 14, -660478335);
+ b = gg(b, c, d, a, k[4], 20, -405537848);
+ a = gg(a, b, c, d, k[9], 5, 568446438);
+ d = gg(d, a, b, c, k[14], 9, -1019803690);
+ c = gg(c, d, a, b, k[3], 14, -187363961);
+ b = gg(b, c, d, a, k[8], 20, 1163531501);
+ a = gg(a, b, c, d, k[13], 5, -1444681467);
+ d = gg(d, a, b, c, k[2], 9, -51403784);
+ c = gg(c, d, a, b, k[7], 14, 1735328473);
+ b = gg(b, c, d, a, k[12], 20, -1926607734);
+
+ a = hh(a, b, c, d, k[5], 4, -378558);
+ d = hh(d, a, b, c, k[8], 11, -2022574463);
+ c = hh(c, d, a, b, k[11], 16, 1839030562);
+ b = hh(b, c, d, a, k[14], 23, -35309556);
+ a = hh(a, b, c, d, k[1], 4, -1530992060);
+ d = hh(d, a, b, c, k[4], 11, 1272893353);
+ c = hh(c, d, a, b, k[7], 16, -155497632);
+ b = hh(b, c, d, a, k[10], 23, -1094730640);
+ a = hh(a, b, c, d, k[13], 4, 681279174);
+ d = hh(d, a, b, c, k[0], 11, -358537222);
+ c = hh(c, d, a, b, k[3], 16, -722521979);
+ b = hh(b, c, d, a, k[6], 23, 76029189);
+ a = hh(a, b, c, d, k[9], 4, -640364487);
+ d = hh(d, a, b, c, k[12], 11, -421815835);
+ c = hh(c, d, a, b, k[15], 16, 530742520);
+ b = hh(b, c, d, a, k[2], 23, -995338651);
+
+ a = ii(a, b, c, d, k[0], 6, -198630844);
+ d = ii(d, a, b, c, k[7], 10, 1126891415);
+ c = ii(c, d, a, b, k[14], 15, -1416354905);
+ b = ii(b, c, d, a, k[5], 21, -57434055);
+ a = ii(a, b, c, d, k[12], 6, 1700485571);
+ d = ii(d, a, b, c, k[3], 10, -1894986606);
+ c = ii(c, d, a, b, k[10], 15, -1051523);
+ b = ii(b, c, d, a, k[1], 21, -2054922799);
+ a = ii(a, b, c, d, k[8], 6, 1873313359);
+ d = ii(d, a, b, c, k[15], 10, -30611744);
+ c = ii(c, d, a, b, k[6], 15, -1560198380);
+ b = ii(b, c, d, a, k[13], 21, 1309151649);
+ a = ii(a, b, c, d, k[4], 6, -145523070);
+ d = ii(d, a, b, c, k[11], 10, -1120210379);
+ c = ii(c, d, a, b, k[2], 15, 718787259);
+ b = ii(b, c, d, a, k[9], 21, -343485551);
+
+ x[0] = add32(a, x[0]);
+ x[1] = add32(b, x[1]);
+ x[2] = add32(c, x[2]);
+ x[3] = add32(d, x[3]);
+
+}
+
+function cmn(q, a, b, x, s, t) {
+ a = add32(add32(a, q), add32(x, t));
+ return add32((a << s) | (a >>> (32 - s)), b);
+}
+
+function ff(a, b, c, d, x, s, t) {
+ return cmn((b & c) | ((~b) & d), a, b, x, s, t);
+}
+
+function gg(a, b, c, d, x, s, t) {
+ return cmn((b & d) | (c & (~d)), a, b, x, s, t);
+}
+
+function hh(a, b, c, d, x, s, t) {
+ return cmn(b ^ c ^ d, a, b, x, s, t);
+}
+
+function ii(a, b, c, d, x, s, t) {
+ return cmn(c ^ (b | (~d)), a, b, x, s, t);
+}
+
+function md51(s) {
+ var n = s.length,
+ state = [1732584193, -271733879, -1732584194, 271733878],
+ i;
+ for (i = 64; i <= s.length; i += 64) {
+ md5cycle(state, md5blk(s.substring(i - 64, i)));
+ }
+ s = s.substring(i - 64);
+ var tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
+ for (i = 0; i < s.length; i++)
+ tail[i >> 2] |= s.charCodeAt(i) << ((i % 4) << 3);
+ tail[i >> 2] |= 0x80 << ((i % 4) << 3);
+ if (i > 55) {
+ md5cycle(state, tail);
+ for (i = 0; i < 16; i++) tail[i] = 0;
+ }
+ tail[14] = n * 8;
+ md5cycle(state, tail);
+ return state;
+}
+
+/* there needs to be support for Unicode here,
+ * unless we pretend that we can redefine the MD-5
+ * algorithm for multi-byte characters (perhaps
+ * by adding every four 16-bit characters and
+ * shortening the sum to 32 bits). Otherwise
+ * I suggest performing MD-5 as if every character
+ * was two bytes--e.g., 0040 0025 = @%--but then
+ * how will an ordinary MD-5 sum be matched?
+ * There is no way to standardize text to something
+ * like UTF-8 before transformation; speed cost is
+ * utterly prohibitive. The JavaScript standard
+ * itself needs to look at this: it should start
+ * providing access to strings as preformed UTF-8
+ * 8-bit unsigned value arrays.
+ */
+function md5blk(s) { /* I figured global was faster. */
+ var md5blks = [],
+ i; /* Andy King said do it this way. */
+ for (i = 0; i < 64; i += 4) {
+ md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i + 1) << 8) + (s.charCodeAt(i + 2) << 16) + (s.charCodeAt(i + 3) << 24);
+ }
+ return md5blks;
+}
+
+var hex_chr = '0123456789abcdef'.split('');
+
+function rhex(n) {
+ var s = '',
+ j = 0;
+ for (; j < 4; j++)
+ s += hex_chr[(n >> (j * 8 + 4)) & 0x0F] + hex_chr[(n >> (j * 8)) & 0x0F];
+ return s;
+}
+
+function hex(x) {
+ for (var i = 0; i < x.length; i++)
+ x[i] = rhex(x[i]);
+ return x.join('');
+}
+
+function md5(s) {
+ return hex(md51(s));
+}
+
+/* this function is much faster,
+ so if possible we use it. Some IEs
+ are the only ones I know of that
+ need the idiotic second function,
+ generated by an if clause. */
+
+function add32(a, b) {
+ return (a + b) & 0xFFFFFFFF;
+}
+
+if (md5('hello') != '5d41402abc4b2a76b9719d911017c592') {
+ function add32(x, y) {
+ var lsw = (x & 0xFFFF) + (y & 0xFFFF),
+ msw = (x >> 16) + (y >> 16) + (lsw >> 16);
+ return (msw << 16) | (lsw & 0xFFFF);
+ }
+}
+export default md5;
diff --git a/src/page/HomePage/HeaderView.js b/src/page/HomePage/HeaderView.js
new file mode 100644
index 000000000000..f671b4c5c151
--- /dev/null
+++ b/src/page/HomePage/HeaderView.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import {Button, View, Text} from 'react-native';
+import {signOut} from '../../store/actions/SessionActions';
+import {fetch as getPersonalDetails} from '../../store/actions/PersonalDetailsActions';
+import styles from '../../style/StyleSheet';
+import STOREKEYS from '../../store/STOREKEYS';
+import WithStore from '../../components/WithStore';
+
+class HeaderView extends React.Component {
+ render() {
+ return (
+
+ Expensify Chat
+ {this.state && this.state.currentReportName && (
+
+ {this.state.currentReportName}
+
+ )}
+
+ {this.state && this.state.userDisplayName && (
+
+ {`Welcome ${this.state.userDisplayName}!`}
+
+ )}
+
+
+ );
+ }
+}
+
+export default WithStore({
+ // Map this.state.name to the personal details key in the store and bind it to the displayName property
+ // and load it with data from getPersonalDetails()
+ userDisplayName: {
+ key: STOREKEYS.MY_PERSONAL_DETAILS,
+ path: 'displayName',
+ loader: getPersonalDetails,
+ prefillWithKey: STOREKEYS.MY_PERSONAL_DETAILS,
+ },
+ currentReportName: {
+ key: STOREKEYS.CURRENT_REPORT,
+ path: 'reportName',
+ prefillWithKey: STOREKEYS.CURRENT_REPORT,
+ },
+})(HeaderView);
diff --git a/src/page/HomePage/HomePage.js b/src/page/HomePage/HomePage.js
index f69b28678615..1e6ba115569e 100644
--- a/src/page/HomePage/HomePage.js
+++ b/src/page/HomePage/HomePage.js
@@ -1,48 +1,30 @@
-/**
- * @format
- * @flow strict-local
- */
-import React, {Component} from 'react';
+import React from 'react';
import {
SafeAreaView,
- Text,
StatusBar,
View,
- Button
} from 'react-native';
-import {signOut} from '../../store/actions/SessionActions';
+import {Route} from '../../lib/Router';
+import styles from '../../style/StyleSheet';
+import Header from './HeaderView';
+import Sidebar from './SidebarView';
+import Main from './MainView';
-export default class App extends Component {
- constructor(props) {
- super(props);
-
- this.signOut = this.signOut.bind(this);
- }
-
- async signOut() {
- await signOut();
- }
-
- render() {
- return (
- <>
-
-
-
-
- React Native Chat Homepage!
-
-
+const App = () => (
+ <>
+
+
+
+
+
+
+
+
-
- >
- );
- }
-}
+
+
+
+ >
+);
+App.displayName = 'App';
+export default App;
diff --git a/src/page/HomePage/MainView.js b/src/page/HomePage/MainView.js
new file mode 100644
index 000000000000..c4bbc1d238da
--- /dev/null
+++ b/src/page/HomePage/MainView.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import {View} from 'react-native';
+import styles from '../../style/StyleSheet';
+import ReportView from './Report/ReportView';
+
+const MainView = () => (
+
+
+
+);
+
+export default MainView;
diff --git a/src/page/HomePage/Report/ReportHistoryFragmentPropTypes.js b/src/page/HomePage/Report/ReportHistoryFragmentPropTypes.js
new file mode 100644
index 000000000000..9b495a399e70
--- /dev/null
+++ b/src/page/HomePage/Report/ReportHistoryFragmentPropTypes.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.shape({
+ // The type of the history fragment. Used to render a corresponding component
+ type: PropTypes.string.isRequired,
+
+ // The text content of the fragment.
+ text: PropTypes.string.isRequired,
+
+ // Used to apply additional styling. Style refers to a predetermined constant and not a class name. e.g. 'normal' or 'strong'
+ style: PropTypes.string,
+
+ // ID of a report
+ reportID: PropTypes.number,
+
+ // ID of a policy
+ policyID: PropTypes.string,
+
+ // The target of a link fragment e.g. '_blank'
+ target: PropTypes.string,
+
+ // The destination of a link fragment e.g. 'https://www.expensify.com'
+ href: PropTypes.string,
+
+ // An additional avatar url - not the main avatar url but used within a message.
+ iconUrl: PropTypes.string,
+});
diff --git a/src/page/HomePage/Report/ReportHistoryItem.js b/src/page/HomePage/Report/ReportHistoryItem.js
new file mode 100644
index 000000000000..b1d04628f420
--- /dev/null
+++ b/src/page/HomePage/Report/ReportHistoryItem.js
@@ -0,0 +1,23 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import ReportHistoryItemSingle from './ReportHistoryItemSingle';
+import ReportHistoryPropsTypes from './ReportHistoryPropsTypes';
+
+const propTypes = {
+ // All the data of the history item
+ historyItem: PropTypes.shape(ReportHistoryPropsTypes).isRequired,
+};
+
+class ReportHistoryItem extends React.Component {
+ render() {
+ return (
+
+
+
+ );
+ }
+}
+ReportHistoryItem.propTypes = propTypes;
+
+export default ReportHistoryItem;
diff --git a/src/page/HomePage/Report/ReportHistoryItemDate.js b/src/page/HomePage/Report/ReportHistoryItemDate.js
new file mode 100644
index 000000000000..801fcf692406
--- /dev/null
+++ b/src/page/HomePage/Report/ReportHistoryItemDate.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {Text} from 'react-native';
+import DateUtils from '../../../lib/DateUtils';
+
+const propTypes = {
+ // UTC timestamp for when the action was created
+ timestamp: PropTypes.number.isRequired,
+};
+
+class ReportHistoryItemDate extends React.Component {
+ render() {
+ return (
+
+ {DateUtils.timestampToRelative(this.props.timestamp)}
+
+ );
+ }
+}
+
+ReportHistoryItemDate.propTypes = propTypes;
+ReportHistoryItemDate.displayName = 'ReportHistoryItemDate';
+
+export default ReportHistoryItemDate;
diff --git a/src/page/HomePage/Report/ReportHistoryItemFragment.js b/src/page/HomePage/Report/ReportHistoryItemFragment.js
new file mode 100644
index 000000000000..ab1b78e01593
--- /dev/null
+++ b/src/page/HomePage/Report/ReportHistoryItemFragment.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import {Text} from 'react-native';
+import Str from '../../../lib/Str';
+import ReportHistoryFragmentPropTypes from './ReportHistoryFragmentPropTypes';
+
+const propTypes = {
+ // The message fragment needing to be displayed
+ fragment: ReportHistoryFragmentPropTypes.isRequired,
+};
+
+const ReportHistoryItemFragment = ({fragment}) => {
+ const styleClass = fragment.style === 'strong' ? 'quote' : '';
+ switch (fragment.type) {
+ case 'COMMENT':
+ return {Str.htmlDecode(fragment.text)};
+ // return fragment.html
+ // ? (
+ //
+ // // {
+ // // const src = get(img, ['dataset', 'expensifySource'], img.src);
+ // // PubSub.publish(EVENT.REPORT.SHOW_ATTACHMENT, {src});
+ // // }}
+ // // />
+ // )
+ // : {Str.htmlDecode(fragment.text)};
+ case 'TEXT':
+ return {Str.htmlDecode(fragment.text)};
+ case 'LINK':
+ return LINK;
+ // return (
+ //
+ // {Str.htmlDecode(fragment.text)}
+ //
+ // );
+ case 'INTEGRATION_COMMENT':
+ return REPORT_LINK;
+ // return (
+ //
+ //
+ //
+ //

+ //
+ //
+ //
+ //
+ // );
+ case 'REPORT_LINK':
+ return REPORT_LINK;
+ // return (
+ //
+ // );
+ case 'POLICY_LINK':
+ return POLICY_LINK;
+ // return (
+ //
+ // );
+
+ // If we have a message fragment type of OLD_MESSAGE this means we have not yet converted this over to the new data structure
+ // So we simply set this message as inner html and render it like we did before. This wil allow us to convert messages over to the new
+ // structure without needing to do it all at once.
+ case 'OLD_MESSAGE':
+ return OLD_MESSAGE;
+ default:
+ return fragment.text;
+ }
+};
+
+ReportHistoryItemFragment.propTypes = propTypes;
+ReportHistoryItemFragment.displayName = 'ReportHistoryItemFragment';
+
+export default ReportHistoryItemFragment;
diff --git a/src/page/HomePage/Report/ReportHistoryItemMessage.js b/src/page/HomePage/Report/ReportHistoryItemMessage.js
new file mode 100644
index 000000000000..304995d22297
--- /dev/null
+++ b/src/page/HomePage/Report/ReportHistoryItemMessage.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import ReportHistoryItemFragment from './ReportHistoryItemFragment';
+import ReportHistoryPropsTypes from './ReportHistoryPropsTypes';
+
+const propTypes = {
+ // The report history item
+ historyItem: PropTypes.shape(ReportHistoryPropsTypes).isRequired,
+};
+
+class ReportHistoryItemMessage extends React.Component {
+ render() {
+ return (
+ <>
+ {_.map(_.compact(this.props.historyItem.message), fragment => (
+
+ ))}
+ >
+ );
+ }
+}
+
+ReportHistoryItemMessage.propTypes = propTypes;
+ReportHistoryItemMessage.displayName = 'ReportHistoryItemMessage';
+
+export default ReportHistoryItemMessage;
diff --git a/src/page/HomePage/Report/ReportHistoryItemSingle.js b/src/page/HomePage/Report/ReportHistoryItemSingle.js
new file mode 100644
index 000000000000..b0214be6614d
--- /dev/null
+++ b/src/page/HomePage/Report/ReportHistoryItemSingle.js
@@ -0,0 +1,23 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import ReportHistoryPropsTypes from './ReportHistoryPropsTypes';
+import ReportHistoryItemMessage from './ReportHistoryItemMessage';
+
+const propTypes = {
+ // All the data of the history item
+ historyItem: PropTypes.shape(ReportHistoryPropsTypes).isRequired,
+};
+
+class ReportHistoryItemSingle extends React.Component {
+ render() {
+ return (
+
+
+
+ );
+ }
+}
+ReportHistoryItemSingle.propTypes = propTypes;
+
+export default ReportHistoryItemSingle;
diff --git a/src/page/HomePage/Report/ReportHistoryPropsTypes.js b/src/page/HomePage/Report/ReportHistoryPropsTypes.js
new file mode 100644
index 000000000000..7b1e7ffd2ebb
--- /dev/null
+++ b/src/page/HomePage/Report/ReportHistoryPropsTypes.js
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+
+import HistoryFragmentPropTypes from './ReportHistoryFragmentPropTypes';
+
+export default {
+ // Name of the action e.g. ADDCOMMENT
+ actionName: PropTypes.string.isRequired,
+
+ // Person who created the action
+ person: PropTypes.arrayOf(HistoryFragmentPropTypes).isRequired,
+
+ // ID of the report action
+ sequenceNumber: PropTypes.number.isRequired,
+
+ // Unix timestamp
+ timestamp: PropTypes.number.isRequired,
+
+ // Report history message
+ message: PropTypes.arrayOf(HistoryFragmentPropTypes).isRequired,
+};
diff --git a/src/page/HomePage/Report/ReportHistoryView.js b/src/page/HomePage/Report/ReportHistoryView.js
new file mode 100644
index 000000000000..82e77e4a53cd
--- /dev/null
+++ b/src/page/HomePage/Report/ReportHistoryView.js
@@ -0,0 +1,69 @@
+import React from 'react';
+import {View, Text} from 'react-native';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import lodashGet from 'lodash.get';
+import styles from '../../../style/StyleSheet';
+import {fetchHistory} from '../../../store/actions/ReportActions';
+import WithStore from '../../../components/WithStore';
+import STOREKEYS from '../../../store/STOREKEYS';
+import ReportHistoryItem from './ReportHistoryItem';
+
+const propTypes = {
+ // The ID of the report being looked at
+ reportID: PropTypes.string.isRequired,
+
+ // These are from WithStore
+ bind: PropTypes.func.isRequired,
+ unbind: PropTypes.func.isRequired,
+};
+
+class ReportHistoryView extends React.Component {
+ componentDidMount() {
+ this.bindToStore();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.reportID !== this.props.reportID) {
+ this.props.unbind();
+ this.bindToStore();
+ }
+ }
+
+ bindToStore() {
+ // Bind this.state.reportHistory to the history in the store
+ // and call fetchHistory to load it with data
+ this.props.bind({
+ reportHistory: {
+ key: `${STOREKEYS.REPORT}_${this.props.reportID}_history`,
+ loader: fetchHistory,
+ loaderParams: [this.props.reportID],
+ }
+ }, this);
+ }
+
+ render() {
+ const reportHistory = lodashGet(this.state, 'reportHistory');
+
+ // Only display the history items that are comments
+ const filteredHistory = _.filter(reportHistory, historyItem => historyItem.actionName === 'ADDCOMMENT');
+
+ return (
+
+ {filteredHistory.length === 0 && (
+ Be the first person to comment!
+ )}
+ {filteredHistory.length > 0
+ && _.map(filteredHistory, reportHistoryItem => (
+
+ ))}
+
+ );
+ }
+}
+ReportHistoryView.propTypes = propTypes;
+
+export default WithStore()(ReportHistoryView);
diff --git a/src/page/HomePage/Report/ReportView.js b/src/page/HomePage/Report/ReportView.js
new file mode 100644
index 000000000000..ea3de2881d9d
--- /dev/null
+++ b/src/page/HomePage/Report/ReportView.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import * as Store from '../../../store/Store';
+import {withRouter, Route} from '../../../lib/Router';
+import WithStore from '../../../components/WithStore';
+import STOREKEYS from '../../../store/STOREKEYS';
+import styles from '../../../style/StyleSheet';
+import ReportHistoryView from './ReportHistoryView';
+
+const propTypes = {
+ // These are from WithStore
+ bind: PropTypes.func.isRequired,
+ unbind: PropTypes.func.isRequired,
+
+ // These are from withRouter
+ // eslint-disable-next-line react/forbid-prop-types
+ match: PropTypes.object.isRequired,
+};
+
+class ReportView extends React.Component {
+ componentDidMount() {
+ this.bindToStore();
+ }
+
+ componentDidUpdate(prevProps) {
+ // If the report changed, then we need to re-bind to the store
+ if (prevProps.match.params.reportID !== this.props.match.params.reportID) {
+ this.props.unbind();
+ this.bindToStore();
+ }
+ }
+
+ /**
+ * Bind our state to our store. This can't be done with an HOC because props can't be accessed to make the key
+ */
+ bindToStore() {
+ const key = `${STOREKEYS.REPORT}_${this.props.match.params.reportID}`;
+ this.props.bind({
+ report: {
+ // Bind to only the data for the report (which is why there is a $ at the end)
+ key: `${key}$`,
+
+ // Prefill it with the key of the report exactly
+ // (because prefilling doesn't work with the regex patterns)
+ prefillWithKey: key,
+ }
+ }, this);
+ }
+
+ render() {
+ // Update the current report in the store so any other components can update
+ if (this.state && this.state.report) {
+ Store.set(STOREKEYS.CURRENT_REPORT, this.state.report);
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+ReportView.propTypes = propTypes;
+
+export default withRouter(WithStore()(ReportView));
diff --git a/src/page/HomePage/SidebarLink.js b/src/page/HomePage/SidebarLink.js
new file mode 100644
index 000000000000..4205e3554197
--- /dev/null
+++ b/src/page/HomePage/SidebarLink.js
@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {Text, View} from 'react-native';
+import {Link, withRouter} from '../../lib/Router';
+import STOREKEYS from '../../store/STOREKEYS';
+import styles from '../../style/StyleSheet';
+import WithStore from '../../components/WithStore';
+
+const propTypes = {
+ // The ID of the report for this link
+ reportID: PropTypes.number.isRequired,
+
+ // The name of the report to use as the text for this link
+ reportName: PropTypes.string.isRequired,
+
+ // These are from WithStore
+ bind: PropTypes.func.isRequired,
+
+ // These are from withRouter
+ // eslint-disable-next-line react/forbid-prop-types
+ match: PropTypes.object.isRequired,
+};
+
+class SidebarLink extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isUnread: false,
+ };
+ }
+
+ componentDidMount() {
+ this.props.bind({
+ isUnread: {
+ // Bind to ONLY the report object, not the comments (that's why a $ is added at the end of the key name)
+ key: `${STOREKEYS.REPORT}_${this.props.reportID}$`,
+ path: 'hasUnread',
+ defaultValue: false,
+ },
+ }, this);
+ }
+
+ render() {
+ const paramsReportID = parseInt(this.props.match.params.reportID, 10);
+ const isReportActive = paramsReportID === this.props.reportID;
+ const linkWrapperActiveStyle = isReportActive && styles.sidebarLinkActive;
+ const linkActiveStyle = isReportActive ? styles.sidebarLinkActiveAnchor : styles.sidebarLink;
+ const textActiveStyle = isReportActive ? styles.sidebarLinkActiveText : styles.sidebarLinkText;
+ return (
+
+
+
+ {this.props.reportName}
+ {this.state.isUnread && (
+ - Unread
+ )}
+
+
+
+ );
+ }
+}
+SidebarLink.propTypes = propTypes;
+
+export default withRouter(WithStore()(SidebarLink));
diff --git a/src/page/HomePage/SidebarView.js b/src/page/HomePage/SidebarView.js
new file mode 100644
index 000000000000..c5135e9acce0
--- /dev/null
+++ b/src/page/HomePage/SidebarView.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import {View} from 'react-native';
+import _ from 'underscore';
+import styles from '../../style/StyleSheet';
+import WithStore from '../../components/WithStore';
+import STOREKEYS from '../../store/STOREKEYS';
+import {fetchAll} from '../../store/actions/ReportActions';
+import SidebarLink from './SidebarLink';
+
+class SidebarView extends React.Component {
+ render() {
+ const reports = this.state && this.state.reports;
+ return (
+
+ {_.map(reports, report => (
+
+ ))}
+
+ );
+ }
+}
+
+export default WithStore({
+ reports: {
+ key: STOREKEYS.REPORTS,
+ loader: fetchAll,
+ prefillWithKey: STOREKEYS.REPORTS,
+ },
+})(SidebarView);
diff --git a/src/page/SignInPage.js b/src/page/SignInPage.js
index 86d1d6c1d07b..60024f2a5abc 100644
--- a/src/page/SignInPage.js
+++ b/src/page/SignInPage.js
@@ -1,8 +1,3 @@
-/**
- * @format
- * @flow strict-local
- */
-
import React, {Component} from 'react';
import {
SafeAreaView,
@@ -12,57 +7,25 @@ import {
TextInput,
View,
} from 'react-native';
-import * as Store from '../store/Store';
import {signIn} from '../store/actions/SessionActions';
import STOREKEYS from '../store/STOREKEYS';
+import WithStore from '../components/WithStore';
-export default class App extends Component {
+class App extends Component {
constructor(props) {
super(props);
- this.submit = this.submit.bind(this);
- this.sessionChanged = this.sessionChanged.bind(this);
-
this.state = {
login: '',
password: '',
- // eslint-disable-next-line react/no-unused-state
- error: null,
};
}
- componentDidMount() {
- // Listen for changes to our session
- Store.subscribe(STOREKEYS.SESSION, this.sessionChanged);
- Store.get(STOREKEYS.SESSION, 'error').then(error => this.setState({error}));
- }
-
- componentWillUnmount() {
- Store.unsubscribe(STOREKEYS.SESSION, this.sessionChanged);
- }
-
- /**
- * When the session changes, change which page the user sees
- *
- * @param {object} newSession
- */
- sessionChanged(newSession) {
- // eslint-disable-next-line react/no-unused-state
- this.setState({error: newSession && newSession.error});
- }
-
- /**
- * When the form is submitted, then we trigger our prop callback
- */
- submit() {
- signIn(this.state.login, this.state.password, true);
- }
-
render() {
return (
<>
-
+
Login:
-
- {this.state.error &&
- {this.state.error}
- }
+
>
);
}
}
+
+export default WithStore({
+ // Bind this.state.error to the error in the session object
+ error: {key: STOREKEYS.SESSION, path: 'error', defaultValue: null},
+})(App);
diff --git a/src/store/STOREKEYS.js b/src/store/STOREKEYS.js
index c78b0577ff59..2362788851e2 100644
--- a/src/store/STOREKEYS.js
+++ b/src/store/STOREKEYS.js
@@ -6,8 +6,10 @@ export default {
APP_REDIRECT_TO: 'app_redirectTo',
CREDENTIALS: 'credentials',
REPORT: 'report',
- ACTIVE_REPORT: 'active_report',
+ CURRENT_REPORT: 'current_report',
REPORTS: 'reports',
SESSION: 'session',
LAST_AUTHENTICATED: 'last_authenticated',
+ PERSONAL_DETAILS: 'personal_details',
+ MY_PERSONAL_DETAILS: 'my_personal_details',
};
diff --git a/src/store/Store.js b/src/store/Store.js
index d2c1172e342a..ecedf4bc2976 100644
--- a/src/store/Store.js
+++ b/src/store/Store.js
@@ -1,9 +1,9 @@
import lodashGet from 'lodash.get';
import _ from 'underscore';
-import * as PersistentStorage from '../lib/PersistentStorage';
+import AsyncStorage from '@react-native-community/async-storage';
-// Holds all of the callbacks that have registered for a specific key pattern
-const callbackMapping = {};
+// Keeps track of the last subscription ID that was used
+let lastSubscriptionID = 0;
/**
* Initialize the store with actions and listening for storage events
@@ -20,30 +20,41 @@ function init() {
// });
}
+// Holds a mapping of all the react components that want their state subscribed to a store key
+const callbackToStateMapping = {};
+
/**
- * Subscribe a regex pattern to trigger a callback when a storage event happens for a key matching that regex
+ * Subscribes a react component's state directly to a store key
*
* @param {string} keyPattern
- * @param {function} cb
+ * @param {string} path a specific path of the store object to map to the state
+ * @param {mixed} defaultValue to return if the there is nothing from the store
+ * @param {string} statePropertyName the name of the property in the state to bind the data to
+ * @param {object} reactComponent whose setState() method will be called with any changed data
+ * @returns {number} an ID to use when calling unbind
*/
-function subscribe(keyPattern, cb) {
- if (!callbackMapping[keyPattern]) {
- callbackMapping[keyPattern] = [];
- }
- callbackMapping[keyPattern].push(cb);
+function bind(keyPattern, path, defaultValue, statePropertyName, reactComponent) {
+ const subscriptionID = lastSubscriptionID++;
+ callbackToStateMapping[subscriptionID] = {
+ regex: RegExp(keyPattern),
+ statePropertyName,
+ path,
+ reactComponent,
+ defaultValue,
+ };
+ return subscriptionID;
}
/**
- * Remove a callback from a regex pattern
+ * Remove the listener for a react component
*
- * @param {string} keyPattern
- * @param {function} cb
+ * @param {string} subscriptionID
*/
-function unsubscribe(keyPattern, cb) {
- if (!callbackMapping[keyPattern] || !callbackMapping[keyPattern].length) {
+function unbind(subscriptionID) {
+ if (!callbackToStateMapping[subscriptionID]) {
return;
}
- callbackMapping[keyPattern] = callbackMapping[keyPattern].filter(existingCallback => existingCallback !== cb);
+ delete callbackToStateMapping[subscriptionID];
}
/**
@@ -53,16 +64,19 @@ function unsubscribe(keyPattern, cb) {
* @param {mixed} data
*/
function keyChanged(key, data) {
- _.each(callbackMapping, (callbacks, keyPattern) => {
- const regex = RegExp(keyPattern);
+ console.debug('[STORE] key changed', key, data);
- // If there is a callback whose regex matches the key that was changed, then the callback for that regex
- // is called and passed the data that changed
- if (regex.test(key)) {
- for (let i = 0; i < callbacks.length; i++) {
- const callback = callbacks[i];
- callback(data);
- }
+ // Find components that were added with bind() and trigger their setState() method with the new data
+ _.each(callbackToStateMapping, (mappedComponent) => {
+ if (mappedComponent && mappedComponent.regex.test(key)) {
+ const newValue = mappedComponent.path
+ ? lodashGet(data, mappedComponent.path, mappedComponent.defaultValue)
+ : data || mappedComponent.defaultValue || null;
+
+ // Set the state of the react component with either the pathed data, or the data
+ mappedComponent.reactComponent.setState({
+ [mappedComponent.statePropertyName]: newValue,
+ });
}
});
}
@@ -75,12 +89,11 @@ function keyChanged(key, data) {
* @returns {Promise}
*/
function set(key, val) {
- // The storage event doesn't trigger for the current window, so just call keyChanged() manually to mimic
- // the storage event
- keyChanged(key, val);
-
// Write the thing to persistent storage, which will trigger a storage event for any other tabs open on this domain
- return PersistentStorage.set(key, val);
+ return AsyncStorage.setItem(key, JSON.stringify(val))
+ .then(() => {
+ keyChanged(key, val);
+ });
}
/**
@@ -93,13 +106,15 @@ function set(key, val) {
* @returns {*}
*/
function get(key, extraPath, defaultValue) {
- return PersistentStorage.get(key)
+ return AsyncStorage.getItem(key)
+ .then(val => JSON.parse(val))
.then((val) => {
if (extraPath) {
return lodashGet(val, extraPath, defaultValue);
}
return val;
- });
+ })
+ .catch(err => console.error(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`));
}
/**
@@ -109,7 +124,16 @@ function get(key, extraPath, defaultValue) {
* @returns {Promise}
*/
function multiGet(keys) {
- return PersistentStorage.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. Error: ${err}`, keys));
}
/**
@@ -120,7 +144,15 @@ function multiGet(keys) {
* @returns {Promise}
*/
function multiSet(data) {
- return PersistentStorage.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)
.then(() => {
_.each(data, (val, key) => keyChanged(key, val));
});
@@ -132,16 +164,28 @@ function multiSet(data) {
* @returns {Promise}
*/
function clear() {
- return PersistentStorage.clear();
+ return AsyncStorage.clear();
+}
+
+/**
+ * Merge a new value into an existing value at a key
+ *
+ * @param {string} key
+ * @param {string} val
+ * @returns {Promise}
+ */
+function merge(key, val) {
+ return AsyncStorage.mergeItem(key, JSON.stringify(val));
}
export {
- subscribe,
- unsubscribe,
+ bind,
+ unbind,
set,
multiSet,
get,
multiGet,
+ merge,
clear,
init
};
diff --git a/src/store/actions/PersonalDetailsActions.js b/src/store/actions/PersonalDetailsActions.js
new file mode 100644
index 000000000000..e0d72a94b77f
--- /dev/null
+++ b/src/store/actions/PersonalDetailsActions.js
@@ -0,0 +1,97 @@
+import _ from 'underscore';
+import * as Store from '../Store';
+import {request} from '../../lib/Network';
+import STOREKEYS from '../STOREKEYS';
+import md5 from '../../lib/md5';
+import CONST from '../../CONST';
+
+/**
+ * Returns the URL for a user's avatar and handles someone not having any avatar at all
+ *
+ * @param {object} personalDetails
+ * @param {string} login
+ * @returns {string}
+ */
+function getAvatar(personalDetails, login) {
+ if (personalDetails.detailJSON && personalDetails.detailJSON.avatar) {
+ return personalDetails.detailJSON.avatar.replace(/&d=404$/, '');
+ }
+
+ // There are 8 possible default avatars, so we choose which one this user has based
+ // on a simple hash of their login (which is converted from HEX to INT)
+ const loginHashBucket = (parseInt(md5(login).substring(0, 4), 16) % 8) + 1;
+ return `${CONST.CLOUDFRONT_URL}/images/avatars/avatar_${loginHashBucket}.png`;
+}
+
+/**
+ * Get the personal details for our organization
+ *
+ * @returns {Promise}
+ */
+function fetch() {
+ let currentLogin;
+ const requestPromise = Store.get(STOREKEYS.SESSION, 'email')
+ .then((login) => {
+ currentLogin = login;
+ return request('Get', {
+ returnValueList: 'personalDetailsList',
+ });
+ })
+ .then((data) => {
+ const allPersonalDetails = _.reduce(data.personalDetailsList, (finalObject, personalDetails, login) => {
+ // Form the details into something that has all the data in an easy to use format.
+ const avatarURL = getAvatar(personalDetails, login);
+ const firstName = personalDetails.firstName || '';
+ const lastName = personalDetails.lastName || '';
+ const fullName = `${firstName} ${lastName}`.trim();
+ const displayName = fullName === '' ? login : fullName;
+ const displayNameWithEmail = fullName === '' ? login : `${fullName} (${login})`;
+ return {
+ ...finalObject,
+ [login]: {
+ login,
+ avatarURL,
+ firstName,
+ lastName,
+ fullName,
+ displayName,
+ displayNameWithEmail,
+ }
+ };
+ }, {});
+ const myPersonalDetails = allPersonalDetails[currentLogin];
+ return Store.multiSet({
+ [STOREKEYS.PERSONAL_DETAILS]: allPersonalDetails,
+ [STOREKEYS.MY_PERSONAL_DETAILS]: myPersonalDetails,
+ });
+ });
+
+ // Refresh the personal details every 30 minutes
+ setTimeout(fetch, 1000 * 60 * 30);
+ return requestPromise;
+}
+
+/**
+ * Get the timezone of the logged in user
+ *
+ * @returns {Promise}
+ */
+function fetchTimezone() {
+ const requestPromise = request('Get', {
+ returnValueList: 'nameValuePairs',
+ name: 'timeZone',
+ })
+ .then((data) => {
+ const timezone = data.nameValuePairs.timeZone.selected || 'America/Los_Angeles';
+ Store.merge(STOREKEYS.MY_PERSONAL_DETAILS, {timezone});
+ });
+
+ // Refresh the timezone every 30 minutes
+ setTimeout(fetchTimezone, 1000 * 60 * 30);
+ return requestPromise;
+}
+
+export {
+ fetch,
+ fetchTimezone,
+};
diff --git a/src/store/actions/ReportActions.js b/src/store/actions/ReportActions.js
index 8e6fb67c106c..dbf7dc8b79e5 100644
--- a/src/store/actions/ReportActions.js
+++ b/src/store/actions/ReportActions.js
@@ -5,6 +5,9 @@ import {request, delayedWrite} from '../../lib/Network';
import STOREKEYS from '../STOREKEYS';
import ExpensiMark from '../../lib/ExpensiMark';
import Guid from '../../lib/Guid';
+import CONFIG from '../../CONFIG';
+
+// @TODO implement pusher
// import * as pusher from '../../lib/pusher';
/**
@@ -48,9 +51,7 @@ function updateReportWithNewAction(reportID, reportAction) {
}
return reportHistory;
})
- .then((reportHistory) => {
- return Store.set(`${STOREKEYS.REPORT}_${reportID}_history`, reportHistory.sort(sortReportActions));
- });
+ .then(reportHistory => Store.set(`${STOREKEYS.REPORT}_${reportID}_history`, reportHistory.sort(sortReportActions)));
}
/**
@@ -64,7 +65,7 @@ function hasUnreadHistoryItems(accountID, report) {
const usersLastReadActionID = report.reportNameValuePairs[`lastReadActionID_${accountID}`];
if (!usersLastReadActionID || report.reportActionList.length === 0) {
return false;
- };
+ }
// Find the most recent sequence number from the report history
const highestSequenceNumber = _.chain(report.reportActionList)
@@ -94,6 +95,9 @@ function initPusher() {
/**
* Get a single report
+ *
+ * @param {string} reportID
+ * @returns {Promise}
*/
function fetch(reportID) {
let fetchedReport;
@@ -120,8 +124,7 @@ function fetch(reportID) {
* @returns {Promise}
*/
function fetchAll() {
- // @TODO Figure out how to tell if we are in production
- if (IS_IN_PRODUCTION) {
+ if (CONFIG.IS_IN_PRODUCTION) {
return request('Get', {
returnValueList: 'reportStuff',
reportIDList: '63212778,63212795,63212764,63212607',
@@ -146,12 +149,13 @@ function fetchAll() {
_.each(data.reportListBeta, report => fetch(report.reportID));
return data;
})
- .then(data => Store.set(STOREKEYS.REPORTS, _.values(data.reports)));
+ .then(data => Store.set(STOREKEYS.REPORTS, _.values(data.reportListBeta)));
}
/**
* Get the history of a report
*
+ * @param {string} reportID
* @returns {Promise}
*/
function fetchHistory(reportID) {
@@ -174,7 +178,7 @@ function addHistoryItem(reportID, commentText) {
const guid = Guid();
const historyKey = `${STOREKEYS.REPORT}_${reportID}_history`;
- Store.multiGet([historyKey, STOREKEYS.SESSION, STOREKEYS.PERSONAL_DETAILS])
+ return Store.multiGet([historyKey, STOREKEYS.SESSION, STOREKEYS.PERSONAL_DETAILS])
.then((values) => {
const reportHistory = values[historyKey];
const email = values[STOREKEYS.SESSION].email || '';
@@ -216,12 +220,10 @@ function addHistoryItem(reportID, commentText) {
}
]);
})
- .then(() => {
- return delayedWrite('Report_AddComment', {
- reportID,
- reportComment: commentText,
- });
- });
+ .then(() => delayedWrite('Report_AddComment', {
+ reportID,
+ reportComment: commentText,
+ }));
}
/**
@@ -241,14 +243,13 @@ function updateLastReadActionID(accountID, reportID, sequenceNumber) {
[`lastReadActionID_${accountID}`]: sequenceNumber,
}
})
- .then(() => {
- // Update the lastReadActionID on the report optimistically
- return delayedWrite('Report_SetLastReadActionID', {
- accountID,
- reportID,
- sequenceNumber,
- });
- });
+
+ // Update the lastReadActionID on the report optimistically
+ .then(() => delayedWrite('Report_SetLastReadActionID', {
+ accountID,
+ reportID,
+ sequenceNumber,
+ }));
}
export {
diff --git a/src/store/actions/SessionActions.js b/src/store/actions/SessionActions.js
index 99f2c38746c3..1a168175c2b2 100644
--- a/src/store/actions/SessionActions.js
+++ b/src/store/actions/SessionActions.js
@@ -31,7 +31,7 @@ function createLogin(authToken, login, password) {
partnerUserID: login,
partnerUserSecret: password,
}).then(() => Store.set(STOREKEYS.CREDENTIALS, {login, password}))
- .catch(err => Store.set(STOREKEYS.SESSION, {error: err}));
+ .catch(err => Store.merge(STOREKEYS.SESSION, {error: err}));
}
/**
@@ -56,6 +56,7 @@ function setSuccessfulSignInData(data) {
* @returns {Promise}
*/
function signIn(login, password, useExpensifyLogin = false) {
+ console.debug('[SIGNIN] Authenticating with expensify login?', useExpensifyLogin ? 'yes' : 'no');
let authToken;
return request('Authenticate', {
useExpensifyLogin,
@@ -65,12 +66,13 @@ function signIn(login, password, useExpensifyLogin = false) {
partnerUserSecret: password,
})
.then((data) => {
+ console.debug('[SIGNIN] Authentication result. Code:', data.jsonCode);
authToken = data && data.authToken;
// If we didn't get a 200 response from authenticate, the user needs to sign in again
- if (data.jsonCode !== 200) {
+ if (!useExpensifyLogin && data.jsonCode !== 200) {
// eslint-disable-next-line no-console
- console.debug('Non-200 from authenticate, going back to sign in page');
+ console.debug('[SIGNIN] Non-200 from authenticate, going back to sign in page');
return Store.multiSet({
[STOREKEYS.CREDENTIALS]: {},
[STOREKEYS.SESSION]: {error: data.message},
@@ -80,16 +82,22 @@ function signIn(login, password, useExpensifyLogin = false) {
// If Expensify login, it's the users first time logging in and we need to create a login for the user
if (useExpensifyLogin) {
+ console.debug('[SIGNIN] Creating a login');
return createLogin(data.authToken, Str.generateDeviceLoginID(), Guid())
- .then(() => setSuccessfulSignInData(data));
+ .then(() => {
+ console.debug('[SIGNIN] Successful sign in', 2);
+ return setSuccessfulSignInData(data);
+ });
}
+ console.debug('[SIGNIN] Successful sign in', 1);
return setSuccessfulSignInData();
})
.then(() => authToken)
.catch((err) => {
console.error(err);
- return Store.set(STOREKEYS.SESSION, {error: err.message});
+ console.debug('[SIGNIN] Request error');
+ return Store.merge(STOREKEYS.SESSION, {error: err.message});
});
}
@@ -105,7 +113,7 @@ function deleteLogin(authToken, login) {
partnerName: CONFIG.EXPENSIFY.PARTNER_NAME,
partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
partnerUserID: login,
- }).catch(err => Store.set(STOREKEYS.SESSION, {error: err.message}));
+ }).catch(err => Store.merge(STOREKEYS.SESSION, {error: err.message}));
}
/**
@@ -118,7 +126,7 @@ function signOut() {
.then(() => Store.multiGet([STOREKEYS.SESSION, STOREKEYS.CREDENTIALS]))
.then(data => deleteLogin(data.session.authToken, data.credentials.login))
.then(Store.clear)
- .catch(err => Store.set(STOREKEYS.SESSION, {error: err.message}));
+ .catch(err => Store.merge(STOREKEYS.SESSION, {error: err.message}));
}
/**
@@ -139,8 +147,7 @@ function verifyAuthToken() {
return request('Get', {returnValueList: 'account'}).then((data) => {
if (data.jsonCode === 200) {
- console.debug('We have valid auth token');
- return Store.set(STOREKEYS.SESSION, data);
+ return Store.merge(STOREKEYS.SESSION, data);
}
// If the auth token is bad and we didn't have credentials saved, we want them to go to the sign in page
diff --git a/src/style/StyleSheet.js b/src/style/StyleSheet.js
new file mode 100644
index 000000000000..7c41d07791ce
--- /dev/null
+++ b/src/style/StyleSheet.js
@@ -0,0 +1,67 @@
+const styles = {
+ // Utility classes
+ mr1: {
+ marginRight: 10,
+ },
+ ml1: {
+ marginLeft: 10,
+ },
+ p1: {
+ padding: 10,
+ },
+ h100p: {
+ height: '100%',
+ },
+ flex1: {
+ flex: 1,
+ },
+ flex4: {
+ flex: 4,
+ },
+ flexRow: {
+ flexDirection: 'row',
+ },
+ flexColumn: {
+ flexDirection: 'column',
+ },
+ flexGrow1: {
+ flexGrow: 1,
+ },
+ flexGrow4: {
+ flexGrow: 4,
+ },
+ nav: {
+ backgroundColor: '#efefef',
+ padding: 20,
+ },
+ brand: {
+ fontSize: 25,
+ fontWeight: 'bold',
+ },
+ navText: {
+ padding: 8,
+ },
+ sidebarLink: {
+ // TODO: Mobile does not support rem values
+ // padding: '.5rem 1rem',
+ textDecorationLine: 'none',
+ },
+ sidebarLinkActive: {
+ backgroundColor: '#007bff',
+ // TODO: Mobile does not support rem values
+ // borderRadius: '.25rem',
+ },
+ sidebarLinkActiveAnchor: {
+ // TODO: Mobile does not support rem values
+ // padding: '.5rem 1rem',
+ textDecorationLine: 'none',
+ },
+ sidebarLinkText: {
+ color: '#007bff',
+ },
+ sidebarLinkActiveText: {
+ color: '#ffffff',
+ },
+};
+
+export default styles;
diff --git a/web/index.html b/web/index.html
index 858c3771fe96..6db3633ebce1 100644
--- a/web/index.html
+++ b/web/index.html
@@ -2,6 +2,19 @@
Chat
+
diff --git a/webpack.config.js b/webpack.config.js
index 49ef0a83b29d..b8b938c9a876 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -28,8 +28,17 @@ module.exports = {
// Transpiles and lints all the JS
{
test: /\.js$/,
+ loader: 'babel-loader',
exclude: /node_modules|\.native.js$/,
- use: ['babel-loader', 'eslint-loader'],
+ },
+ {
+ test: /\.js$/,
+ loader: 'eslint-loader',
+ exclude: /node_modules|\.native.js$/,
+ options: {
+ cache: true,
+ emitWarning: true,
+ },
},
],
},