diff --git a/README.md b/README.md index 6e182db89b10d..6462d5ae68d66 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,45 @@ # Philosophy This application is built with the following principles. -1. **Offline first** - All data that is brought into the app should be stored immediately in Ion which puts the data into persistent storage (eg. localStorage on browser platforms). -1. **UI Binds to Ion** - UI components bind to Ion so that any change to the Ion data is automatically reflected in the component by calling setState() with the changed data. -1. **Actions manage Ion Data** - When the UI needs to request or write data from the server, this is done through Actions exclusively. - 1. Actions should never return data, see the first point. Example: if the action is `fetchReports()`, it does not return the reports, `fetchReports()` returns nothing. The action makes an XHR, then puts the data into Ion (using `Ion.set()` or `Ion.merge()`). Any UI that is subscribed to that piece of data in Ion is automatically updated. +1. **Data Flow** - Ideally, this is how data flows through the app: + 1. Server pushes data to the disk of any client (Server -> Pusher event -> Action listening to pusher event -> Ion). Currently the code only does this with report comments. Until we make more server changes, this steps is actually done by the client requesting data from the server via XHR and then storing the response in Ion. + 1. Disk pushes data to the UI (Ion -> withIon()/connect() -> React component). + 1. UI pushes data to people's brains (React component -> device screen). + 1. Brain pushes data into UI inputs (Device input -> React component). + 1. UI inputs push data to the server (React component -> Action -> XHR to server). + 1. Go to 1 +1. **Offline first** + - All data that is brought into the app and is necessary to display the app when offline should be stored on disk in persistent storage (eg. localStorage on browser platforms). [AsyncStorage](https://react-native-community.github.io/async-storage/) is a cross-platform abstraction layer that is used to access persistent storage. + - All data that is displayed, comes from persistent storage. +1. **UI Binds to data on disk** + - Ion is a Pub/Sub library to connect the application to the data stored on disk. + - UI components subscribe to Ion (using `withIon()`) and any change to the Ion data is published to the component by calling `setState()` with the changed data. + - Libraries subscribe to Ion (with `Ion.connect()`) and any change to the Ion data is published to the callback callback with the changed data. + - The UI should never call any Ion methods except for `Ion.connect()`. That is the job of Actions (see next section). + - The UI always triggers an Action when something needs to happen (eg. a person inputs data, the UI triggers an Action with this data). + - The UI should be as flexible as possible when it comes to: + - Incomplete or missing data. Always assume data is incomplete or not there. For example, when a comment is pushed to the client from a pusher event, it's possible that Ion does not have data for that report yet. That's OK. A partial report object is added to Ion for the report key `report_1234 = {reportID: 1234, isUnread: true}`. Then there is code that monitors Ion for reports with incomplete data, and calls `fetchChatReportsByIDs(1234)` to get the full data for that report. The UI should be able to gracefully handle the report object not being complete. In this example, the sidebar wouldn't display any report that doesn't have a report name. + - The order that actions are done in. All actions should be done in parallel instead of sequence. + - Parallel actions are asynchronous methods that don't return promises. Any number of these actions can be called at one time and it doesn't matter what order they happen in or when they complete. + - In-Sequence actions are asynchronous methods that return promises. This is necessary when one asynchronous method depends on the results from a previous asynchronous method. Example: Making an XHR to `command=CreateChatReport` which returns a reportID which is used to call `command=Get&rvl=reportStuff`. +1. **Actions manage Ion Data** + - When data needs to be written to or read from the server, this is done through Actions only. + - Public action methods should never return anything (not data or a promise). This is done to ensure that action methods can be called in parallel with no dependency on other methods (see discussion above). + - Actions should favor using `Ion.merge()` over `Ion.set()` so that other values in an object aren't completely overwritten. + - In general, the operations that happen inside an action should be done in parallel and not in sequence ((eg. don't use the promise of one Ion method to trigger a second Ion method). Ion is built so that every operation is done in parallel and it doesn't matter what order they finish in. XHRs on the other hand need to be handled in sequence with promise chains in order to access and act upon the response. + - If an Action needs to access data stored on disk, use a local variable and `Ion.connect()` + - Data should be optimistically stored on disk whenever possible without waiting for a server response. Example of creating a new optimistic comment: + 1. user adds a comment + 2. comment is shown in the UI (by mocking the expected response from the server) + 3. comment is created in the server + 4. server responds + 5. UI updates with data from the server + 1. **Cross Platform 99.9999%** 1. A feature isn't done until it works on all platforms. Accordingly, don't even bother writing a platform-specific code block because you're just going to need to undo it. 1. If the reason you can't write cross platform code is because there is a bug in ReactNative that is preventing it from working, the correct action is to fix RN and submit a PR upstream -- not to hack around RN bugs with platform-specific code paths. 1. If there is a feature that simply doesn't exist on all platforms and thus doesn't exist in RN, rather than doing if (platform=iOS) { }, instead write a "shim" library that is implemented with NOOPs on the other platforms. For example, rather than injecting platform-specific multi-tab code (which can only work on browsers, because it's the only platform with multiple tabs), write a TabManager class that just is NOOP for non-browser platforms. This encapsulates the platform-specific code into a platform library, rather than sprinkling through the business logic. - 1. Put all platform specific code in a dedicated branch, like /platform, and reject any PR that attempts to put platform-specific code anywhere else. This maintains a strict separation between business logic and platform code. + 1. Put all platform specific code in dedicated files and folders, like /platform, and reject any PR that attempts to put platform-specific code anywhere else. This maintains a strict separation between business logic and platform code. # Local development ## Getting started @@ -18,6 +48,9 @@ This application is built with the following principles. 2. Install `watchman`: `brew install watchman` 3. Install dependencies: `npm install` 4. Run `cp .env.example .env` and edit `.env` to have your local config options(for example, we are curretly hardcoding the pinned chat reports IDs with the `REPORT_IDS` config option). + +You can use any IDE or code editing tool for developing on any platform. Use your favorite! + ## Running the web app 🕸 * To run a **Development Server**: `npm run web` * To build a **production build**: `npm run build` @@ -54,6 +87,40 @@ This application is built with the following principles. 2. This will allow you to attach a debugger in your IDE, React Developer Tools, or your browser. 3. For more information on how to attach a debugger, see [React Native Debugging Documentation](https://reactnative.dev/docs/debugging#chrome-developer-tools) +## Things to know or brush up on before jumping into the code +1. The major difference between React-Native and React are the [components](https://reactnative.dev/docs/components-and-apis) that are used in the `render()` method. Everything else is exactly the same. If you learn React, you've already learned 98% of React-Native. +1. The application uses [React-Router](https://reactrouter.com/native/guides/quick-start) for navigating between parts of the app. +1. [Higher Order Components](https://reactjs.org/docs/higher-order-components.html) are used to connect React components to persistent storage via Ion. + +## Structure of the app +These are the main pieces of the application. + +### Ion +This is a persistent storage solution wrapped in a Pub/Sub library. In general that means: + +- Ion stores and retrieves data from persistent storage +- Data is stored as key/value pairs, where the value can be anything from a single piece of data to a complex object +- Collections of data are usually not stored as a single key (eg. an array with multiple objects), but as individual keys+ID (eg. `report_1234`, `report_4567`, etc.). Store collections as individual keys when a component will bind directly to one of those keys. For example: reports are stored as individual keys because `SidebarLink.js` binds to the individual report keys for each link. However, report actions are stored as an array of objects because nothing binds directly to a single report action. +- Ion allows other code to subscribe to changes in data, and then publishes change events whenever data is changed +- Anything needing to read Ion data needs to: + 1. Know what key the data is stored in (for web, you can find this by looking in the JS console > Application > local storage) + 2. Subscribe to changes of the data for a particular key or set of keys. React components use `withIon()` and non-React libs use `Ion.connect()`. + 3. Get initialized with the current value of that key from persistent storage (Ion does this by calling `setState()` or triggering the `callback` with the values currently on disk as part of the connection process) +- Subscribing to Ion keys is done using a regex pattern. For example, since all reports are stored as individual keys like `report_1234`, then if code needs to know about all the reports (eg. display a list of them in the nav menu), then it would subscribe to the key pattern `report_[0-9]+$`. + +### Actions +Actions are responsible for managing what is on disk. This is usually: + +- Subscribing to Pusher events to receive data from the server that will get put immediately into Ion +- Making XHRs to request necessary data from the server and then immediately putting that data into Ion +- Handling any business logic with input coming from the UI layer + +### The UI layer +This layer is solely responsible for: + +- Reflecting exactly the data that is in persistent storage by using `withIon()` to bind to Ion data. +- Taking user input and passing it to an action + # Deploying ## Continuous deployment / GitHub workflows Every PR merged into `master` will kick off the **Create a new version** GitHub workflow defined in `.github/workflows/version.yml`. diff --git a/src/Expensify.js b/src/Expensify.js index 6d13870948f54..e83e06622a758 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -50,17 +50,21 @@ class Expensify extends Component { } componentDidMount() { - Ion.connect({key: IONKEYS.SESSION, path: 'authToken', callback: this.removeLoadingState}); + Ion.connect({ + key: IONKEYS.SESSION, + callback: this.removeLoadingState, + }); } /** * When the authToken is updated, the app should remove the loading state and handle the authToken * - * @param {string} authToken + * @param {object} session + * @param {string} session.authToken */ - removeLoadingState(authToken) { + removeLoadingState(session) { this.setState({ - authToken, + authToken: session ? session.authToken : null, isLoading: false, }); } @@ -84,8 +88,17 @@ class Expensify extends Component { + ( + this.state.authToken + ? + : + )} + /> - + diff --git a/src/components/withIon.js b/src/components/withIon.js index 2761536f626fc..c8528dae7c78e 100644 --- a/src/components/withIon.js +++ b/src/components/withIon.js @@ -67,12 +67,8 @@ export default function (mapIonToState) { * Takes a single mapping and binds the state of the component to the store * * @param {object} mapping - * @param {string} [mapping.path] a specific path of the store object to map to the state - * @param {mixed} [mapping.defaultValue] Used in conjunction with mapping.path to return if the there is - * nothing at mapping.path - * @param {boolean} [mapping.addAsCollection] rather than setting a single state value, this will add things - * to an array - * @param {string} [mapping.collectionID] the name of the ID property to use for the collection + * @param {string} statePropertyName the name of the state property that Ion will add the data to + * @param {string} [mapping.indexBy] the name of the ID property to use for the collection * @param {string} [mapping.pathForProps] the statePropertyName can contain the string %DATAFROMPROPS% wich * will be replaced with data from the props matching this path. That way, the component can connect to an * Ion key that uses data from this.props. @@ -80,12 +76,8 @@ export default function (mapIonToState) { * For example, if a component wants to connect to the Ion key "report_22" and * "22" comes from this.props.match.params.reportID. The statePropertyName would be set to * "report_%DATAFROMPROPS%" and pathForProps would be set to "match.params.reportID" - * @param {function} [mapping.loader] a method that will be called after connection to Ion in order to load - * it with data. Typically this will be a method that makes an XHR to load data from the API. - * @param {mixed[]} [mapping.loaderParams] An array of params to be passed to the loader method * @param {boolean} [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the * component - * @param {string} statePropertyName the name of the state property that Ion will add the data to */ connectMappingToIon(mapping, statePropertyName) { const ionConnectionConfig = { @@ -112,27 +104,6 @@ export default function (mapIonToState) { connectionID = Ion.connect(ionConnectionConfig); this.actionConnectionIDs[connectionID] = connectionID; } - - // Pre-fill the state with any data already in the store - if (mapping.initWithStoredValues !== false) { - Ion.getInitialStateFromConnectionID(connectionID) - .then(data => this.setState({[statePropertyName]: data})); - } - - // Load the data from an API request if necessary - if (mapping.loader) { - const paramsForLoaderFunction = _.map(mapping.loaderParams, (loaderParam) => { - // Some params might com from the props data - if (loaderParam === '%DATAFROMPROPS%') { - return lodashGet(this.props, mapping.pathForProps); - } - return loaderParam; - }); - - // Call the loader function and pass it any params. The loader function will take care of putting - // data into Ion - mapping.loader(...paramsForLoaderFunction || []); - } } render() { diff --git a/src/lib/API.js b/src/lib/API.js index a3033a82db58e..e9bb87454281b 100644 --- a/src/lib/API.js +++ b/src/lib/API.js @@ -8,6 +8,7 @@ import ROUTES from '../ROUTES'; import Str from './Str'; import Guid from './Guid'; import redirectToSignIn from './actions/SignInRedirect'; +import {redirect} from './actions/App'; // Holds all of the callbacks that need to be triggered when the network reconnects const reconnectionCallbacks = []; @@ -21,20 +22,22 @@ let networkRequestQueue = []; let reauthenticating = false; let authToken; -Ion.connect({key: IONKEYS.SESSION, path: 'authToken', callback: val => authToken = val}); +Ion.connect({ + key: IONKEYS.SESSION, + callback: val => authToken = val ? val.authToken : null, +}); -// We susbcribe to changes to the online/offline status of the network to determine when we should fire off API calls -// vs queueing them for later. When going reconnecting, ie, going from offline to online, we fire off all the API calls -// that we have in the queue +// We subscribe to changes to the online/offline status of the network to determine when we should fire off API calls +// vs queueing them for later. When reconnecting, ie, going from offline to online, all the reconnection callbacks +// are triggered (this is usually Actions that need to re-download data from the server) let isOffline; Ion.connect({ key: IONKEYS.NETWORK, - path: 'isOffline', - callback: (isCurrentlyOffline) => { - if (isOffline && !isCurrentlyOffline) { + callback: (val) => { + if (isOffline && !val.isOffline) { _.each(reconnectionCallbacks, callback => callback()); } - isOffline = isCurrentlyOffline; + isOffline = val && val.isOffline; } }); @@ -42,7 +45,10 @@ Ion.connect({ // When the user's authToken expires we use this login to re-authenticate and get a new authToken // and use that new authToken in subsequent API calls let credentials; -Ion.connect({key: IONKEYS.CREDENTIALS, callback: ionCredentials => credentials = ionCredentials}); +Ion.connect({ + key: IONKEYS.CREDENTIALS, + callback: ionCredentials => credentials = ionCredentials, +}); /** * @param {string} login @@ -50,6 +56,10 @@ Ion.connect({key: IONKEYS.CREDENTIALS, callback: ionCredentials => credentials = * @returns {Promise} */ function createLogin(login, password) { + if (!authToken) { + throw new Error('createLogin() can\'t be called when there is no authToken'); + } + // Using xhr instead of request becasue request has logic to re-try API commands when we get a 407 authToken expired // in the response, and we call CreateLogin after getting a successful resposne to Authenticate so it's unlikely // that we'll get a 407. @@ -60,7 +70,7 @@ function createLogin(login, password) { partnerUserID: login, partnerUserSecret: password, }) - .then(() => Ion.set(IONKEYS.CREDENTIALS, {login, password})) + .then(() => Ion.merge(IONKEYS.CREDENTIALS, {login, password})) .catch(err => Ion.merge(IONKEYS.SESSION, {error: err})); } @@ -91,7 +101,6 @@ function queueRequest(command, data) { * * @param {object} data * @param {string} exitTo - * @returns {Promise} */ function setSuccessfulSignInData(data, exitTo) { let redirectTo; @@ -103,12 +112,8 @@ function setSuccessfulSignInData(data, exitTo) { } else { redirectTo = ROUTES.HOME; } - return Ion.multiSet({ - // The response from Authenticate includes requestID, jsonCode, etc - // but we only care about setting these three values in Ion - [IONKEYS.SESSION]: _.pick(data, 'authToken', 'accountID', 'email'), - [IONKEYS.APP_REDIRECT_TO]: redirectTo, - }); + redirect(redirectTo); + Ion.merge(IONKEYS.SESSION, _.pick(data, 'authToken', 'accountID', 'email')); } /** @@ -148,7 +153,8 @@ function request(command, parameters, type = 'post') { // We need to return the promise from setSuccessfulSignInData to ensure the authToken is updated before // we try to create a login below - return setSuccessfulSignInData(response, parameters.exitTo); + setSuccessfulSignInData(response, parameters.exitTo); + authToken = response.authToken; }) .then(() => { // If Expensify login, it's the users first time signing in and we need to @@ -284,9 +290,6 @@ Pusher.registerCustomAuthorizer((channel, {authEndpoint}) => ({ }, })); -// Initialize the pusher connection -Pusher.init(); - /** * Events that happen on the pusher socket are used to determine if the app is online or offline. The offline setting * is stored in Ion so the rest of the app has access to it. @@ -355,7 +358,7 @@ function authenticate(parameters) { .catch((err) => { console.error(err); console.debug('[SIGNIN] Request error'); - return Ion.merge(IONKEYS.SESSION, {error: err.message}); + Ion.merge(IONKEYS.SESSION, {error: err.message}); }); } diff --git a/src/lib/ActiveClientManager.js b/src/lib/ActiveClientManager.js index 1c0f279263e99..c6155c91b03df 100644 --- a/src/lib/ActiveClientManager.js +++ b/src/lib/ActiveClientManager.js @@ -1,38 +1,44 @@ -import _ from 'underscore'; import Guid from './Guid'; import Ion from './Ion'; import IONKEYS from '../IONKEYS'; const clientID = Guid(); +// @TODO make all this work by uncommenting code. This will work once +// there is a cross-platform method for onBeforeUnload +// See https://github.com/Expensify/ReactNativeChat/issues/413 +let activeClients; +Ion.connect({ + key: IONKEYS.ACTIVE_CLIENTS, + + callback: val => activeClients = val, +}); + /** * Add our client ID to the list of active IDs * * @returns {Promise} */ -const init = () => Ion.merge(IONKEYS.ACTIVE_CLIENTS, {clientID}); +// @TODO need to change this to Ion.merge() once we support multiple tabs since there is now way to remove +// clientIDs from this yet +const init = () => Ion.set(IONKEYS.ACTIVE_CLIENTS, {[clientID]: clientID}); /** * Remove this client ID from the array of active client IDs when this client is exited - * - * @returns {Promise} */ function removeClient() { - return Ion.get(IONKEYS.ACTIVE_CLIENTS) - .then(activeClientIDs => _.omit(activeClientIDs, clientID)) - .then(newActiveClientIDs => Ion.set(IONKEYS.ACTIVE_CLIENTS, newActiveClientIDs)); + // Ion.set(IONKEYS.ACTIVE_CLIENTS, _.omit(activeClients, clientID)); } /** * Checks if the current client is the leader (the first one in the list of active clients) * - * @returns {Promise} + * @returns {boolean} */ function isClientTheLeader() { // At the moment activeClients only has 1 value i.e., the latest clientID so let's compare if // the latest matches the current browsers clientID. - return Ion.get(IONKEYS.ACTIVE_CLIENTS) - .then(activeClients => activeClients.clientID === clientID); + return activeClients[clientID] === clientID; } export { diff --git a/src/lib/DateUtils.js b/src/lib/DateUtils.js index cac52395e2e93..34ca3a3f1fe6b 100644 --- a/src/lib/DateUtils.js +++ b/src/lib/DateUtils.js @@ -4,19 +4,14 @@ import moment from 'moment'; // eslint-disable-next-line no-unused-vars import momentTimzone from 'moment-timezone'; import Str from './Str'; +import Ion from './Ion'; +import IONKEYS from '../IONKEYS'; -// Non-Deprecated Methods - -/** - * Gets the user's stored time-zone NVP - * - * @returns {string} - * - * @private - */ -function getTimezone() { - return 'America/Los_Angeles'; -} +let timezone; +Ion.connect({ + key: IONKEYS.MY_PERSONAL_DETAILS, + callback: val => timezone = val ? val.timezone : 'America/Los_Angeles', +}); /** * Gets the user's stored time-zone NVP and returns a localized @@ -29,9 +24,6 @@ function getTimezone() { * @private */ function getLocalMomentFromTimestamp(timestamp) { - // We need a default here for flows where we may not have initialized the TIME_ZONE NVP like generatng PDFs in - // printablereport.php - const timezone = getTimezone(); return moment.unix(timestamp).tz(timezone); } diff --git a/src/lib/Ion.js b/src/lib/Ion.js index b079b20677ed3..898c6047e42c1 100644 --- a/src/lib/Ion.js +++ b/src/lib/Ion.js @@ -1,4 +1,3 @@ -import lodashGet from 'lodash.get'; import _ from 'underscore'; import AsyncStorage from '@react-native-community/async-storage'; @@ -27,20 +26,11 @@ const callbackToStateMapping = {}; * Get some data from the store * * @param {string} key - * @param {string} [extraPath] passed to _.get() in order to return just a piece of the localStorage object - * @param {mixed} [defaultValue] passed to the second param of _.get() in order to specify a default value if the value - * we are looking for doesn't exist in the object yet * @returns {*} */ -function get(key, extraPath, defaultValue) { +function 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}`)); } @@ -54,71 +44,114 @@ function keyChanged(key, data) { // Find components that were added with connect() 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; - if (_.isFunction(mappedComponent.callback)) { - mappedComponent.callback(newValue, key); + mappedComponent.callback(data, key); } if (!mappedComponent.withIonInstance) { return; } - // Set the state of the react component with either the pathed data, or the data - if (mappedComponent.addAsCollection) { + // Set the state of the react component with the data + if (mappedComponent.indexBy) { // Add the data to an array of existing items mappedComponent.withIonInstance.setState((prevState) => { const collection = prevState[mappedComponent.statePropertyName] || {}; - collection[newValue[mappedComponent.collectionID]] = newValue; + collection[data[mappedComponent.indexBy]] = data; return { [mappedComponent.statePropertyName]: collection, }; }); } else { mappedComponent.withIonInstance.setState({ - [mappedComponent.statePropertyName]: newValue, + [mappedComponent.statePropertyName]: data, }); } } }); } +/** + * Sends the data obtained from the keys to the connection. It either: + * - sets state on the withIonInstances + * - triggers the callback function + * + * @param {object} config + * @param {object} [config.withIonInstance] + * @param {string} [config.statePropertyName] + * @param {function} [config.callback] + * @param {*} val + * @param {string} [key] + */ +function sendDataToConnection(config, val, key) { + if (config.withIonInstance) { + config.withIonInstance.setState({ + [config.statePropertyName]: val, + }); + } else if (_.isFunction(config.callback)) { + config.callback(val, key); + } +} + /** * Subscribes a react component's state directly to a store key * * @param {object} mapping the mapping information to connect Ion to the components state - * @param {string} mapping.keyPattern - * @param {string} [mapping.path] a specific path of the store object to map to the state - * @param {mixed} [mapping.defaultValue] to return if the there is nothing from the store + * @param {string} mapping.key * @param {string} mapping.statePropertyName the name of the property in the state to connect the data to - * @param {boolean} [mapping.addAsCollection] rather than setting a single state value, this will add things to an array - * @param {string} [mapping.collectionID] the name of the ID property to use for the collection + * @param {string} [mapping.indexBy] the name of a property to index the collection by * @param {object} [mapping.withIonInstance] whose setState() method will be called with any changed data * This is used by React components to connect to Ion * @param {object} [mapping.callback] a method that will be called with changed data * This is used by any non-React code to connect to Ion + * @param {boolean} [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the + * component * @returns {number} an ID to use when calling disconnect */ function connect(mapping) { const connectionID = lastConnectionID++; - const connectionMapping = { + const config = { ...mapping, regex: RegExp(mapping.key), }; - callbackToStateMapping[connectionID] = connectionMapping; - - // If the mapping has a callback, trigger it with the existing data - // in Ion so it initializes properly - // @TODO remove the if statement when this is supported by react components - // @TODO need to support full regex key connections for callbacks. - // This would look something like getInitialStateFromConnectionID - if (mapping.callback) { - get(mapping.key) - .then(val => keyChanged(mapping.key, val)); + callbackToStateMapping[connectionID] = config; + + if (mapping.initWithStoredValues === false) { + return connectionID; } + // Get all the data from Ion to initialize the connection with + AsyncStorage.getAllKeys() + .then((keys) => { + // Find all the keys matched by the config regex + const matchingKeys = _.filter(keys, config.regex.test.bind(config.regex)); + + // If the key being connected to does not exist, initialize the value with null + if (matchingKeys.length === 0) { + sendDataToConnection(config, null, config.key); + return; + } + + // Currently, if a callback or react component is subscribing to a regex key + // and multiple keys match that regex, + // a data change will be published to the callback or react component for EACH + // matching key. In the future, this should be refactored so that identical + // React components or callbacks should only have a single data change published + // to them. + if (config.indexBy) { + Promise.all(_.map(matchingKeys, key => get(key))) + .then(values => _.reduce(values, (finalObject, value) => ({ + ...finalObject, + [value[config.indexBy]]: value, + }), {})) + .then(val => sendDataToConnection(config, val)); + } else { + _.each(matchingKeys, (key) => { + get(key).then(val => sendDataToConnection(config, val, key)); + }); + } + }); + return connectionID; } @@ -149,49 +182,6 @@ function set(key, val) { }); } -/** - * Returns initial state for a connection config so that stored data - * is available shortly after the first render. - * - * @param {Number} connectionID - * @return {Promise} - */ -function getInitialStateFromConnectionID(connectionID) { - const config = callbackToStateMapping[connectionID]; - if (config.addAsCollection) { - return AsyncStorage.getAllKeys() - .then((keys) => { - const regex = RegExp(config.key); - const matchingKeys = _.filter(keys, key => regex.test(key)); - return Promise.all(_.map(matchingKeys, key => get(key))); - }) - .then(values => _.reduce(values, (finalObject, value) => ({ - ...finalObject, - [value[config.collectionID]]: value, - }), {})); - } - return get(config.key, config.path, config.defaultValue); -} - -/** - * Get multiple keys of data - * - * @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. Error: ${err}`, keys)); -} - /** * Sets multiple keys and values. Example * Ion.multiSet({'key1': 'a', 'key2': 'b'}); @@ -227,14 +217,23 @@ function clear() { * Merge a new value into an existing value at a key * * @param {string} key - * @param {string} val - * @returns {Promise} + * @param {*} val */ function merge(key, val) { - return AsyncStorage.mergeItem(key, JSON.stringify(val)) - .then(() => get(key)) - .then((newObject) => { - keyChanged(key, newObject); + // Values that are objects can be merged into storage + if (_.isObject(val)) { + AsyncStorage.mergeItem(key, JSON.stringify(val)) + .then(() => get(key)) + .then((newObject) => { + keyChanged(key, newObject); + }); + return; + } + + // Anything else (strings and numbers) need to be set into storage + AsyncStorage.setItem(key, JSON.stringify(val)) + .then(() => { + keyChanged(key, val); }); } @@ -243,12 +242,9 @@ const Ion = { disconnect, set, multiSet, - get, - multiGet, merge, clear, init, - getInitialStateFromConnectionID, }; export default Ion; diff --git a/src/lib/Notification/BrowserNotifications.js b/src/lib/Notification/BrowserNotifications.js index 3763ba56de150..6e98ddb6aee1c 100644 --- a/src/lib/Notification/BrowserNotifications.js +++ b/src/lib/Notification/BrowserNotifications.js @@ -105,25 +105,22 @@ export default { * @param {Function} params.onClick */ pushReportCommentNotification({reportAction, onClick}) { - ActiveClientManager.isClientTheLeader() - .then((isClientTheLeader) => { - if (!isClientTheLeader) { - return; - } - - const {person, message} = reportAction; + if (!ActiveClientManager.isClientTheLeader()) { + console.debug('[BrowserNotifications] Skipping notification because this client is not the leader'); + return; + } - const plainTextPerson = Str.htmlDecode(person.map(f => f.text).join()); + const {person, message} = reportAction; + const plainTextPerson = Str.htmlDecode(person.map(f => f.text).join()); - // Specifically target the comment part of the message - const plainTextMessage = Str.htmlDecode((message.find(f => f.type === 'COMMENT') || {}).text); + // Specifically target the comment part of the message + const plainTextMessage = Str.htmlDecode((message.find(f => f.type === 'COMMENT') || {}).text); - push({ - title: `New message from ${plainTextPerson}`, - body: plainTextMessage, - delay: 0, - onClick, - }); - }); + push({ + title: `New message from ${plainTextPerson}`, + body: plainTextMessage, + delay: 0, + onClick, + }); }, }; diff --git a/src/lib/Pusher/pusher.js b/src/lib/Pusher/pusher.js index a8a210ec0ae3f..97278d0f0c983 100644 --- a/src/lib/Pusher/pusher.js +++ b/src/lib/Pusher/pusher.js @@ -22,9 +22,14 @@ function callSocketEventCallbacks(eventName, data) { * @param {String} appKey * @param {Object} [params] * @public + * @returns {Promise} resolves when Pusher has connected */ function init(appKey, params) { - if (!socket) { + return new Promise((resolve) => { + if (socket) { + return resolve(); + } + // Use this for debugging // Pusher.log = (message) => { // if (window.console && window.console.log) { @@ -62,6 +67,7 @@ function init(appKey, params) { socket.connection.bind('connected', () => { console.debug('[Pusher] connected'); callSocketEventCallbacks('connected'); + resolve(); }); socket.connection.bind('disconnected', () => { @@ -73,7 +79,7 @@ function init(appKey, params) { console.debug('[Pusher] state changed', states); callSocketEventCallbacks('state_change', states); }); - } + }); } /** @@ -171,7 +177,7 @@ function bindEventToChannel(channel, eventName, eventCallback = () => {}, isChun */ function subscribe(channelName, eventName, eventCallback = () => {}, isChunked = false) { return new Promise((resolve, reject) => { - // We cannot call subscribe() before init(). Prevent any attempt to do this on dev. + // We cannot call subscribe() before init(). Prevent any attempt to do this on dev. if (!socket) { throw new Error(`[Pusher] instance not found. Pusher.subscribe() most likely has been called before Pusher.init()`); @@ -340,6 +346,14 @@ function registerCustomAuthorizer(authorizer) { customAuthorizer = authorizer; } +/** + * Disconnect from Pusher + */ +function disconnect() { + socket.disconnect(); + socket = null; +} + if (window) { /** * Pusher socket for debugging purposes @@ -361,4 +375,5 @@ export { sendChunkedEvent, registerSocketEventCallback, registerCustomAuthorizer, + disconnect, }; diff --git a/src/lib/actions/App.js b/src/lib/actions/App.js index f9d591d4dc747..b17f7575b13db 100644 --- a/src/lib/actions/App.js +++ b/src/lib/actions/App.js @@ -7,6 +7,17 @@ Ion.connect({ callback: val => currentRedirectTo = val, }); + +/** + * Redirect the app to a new page by updating the state in Ion + * + * @param {mixed} url + */ +function redirect(url) { + const formattedURL = (typeof url === 'string' && url.startsWith('/')) ? url : `/${url}`; + Ion.merge(IONKEYS.APP_REDIRECT_TO, formattedURL); +} + /** * Keep the current route match stored in Ion so other libs can access it * Also reset the app_redirect_to in Ion so that if we go back to the current url the state will update @@ -14,23 +25,12 @@ Ion.connect({ * @param {object} match */ function recordCurrentRoute({match}) { - Ion.set(IONKEYS.CURRENT_URL, match.url); + Ion.merge(IONKEYS.CURRENT_URL, match.url); if (match.url === currentRedirectTo) { - Ion.set(IONKEYS.APP_REDIRECT_TO, ''); + Ion.merge(IONKEYS.APP_REDIRECT_TO, null); } } - -/** - * Redirect the app to a new page by updating the state in Ion - * - * @param {mixed} url - */ -function redirect(url) { - const formattedURL = (typeof url === 'string' && url.startsWith('/')) ? url : `/${url}`; - Ion.set(IONKEYS.APP_REDIRECT_TO, formattedURL); -} - export { recordCurrentRoute, redirect, diff --git a/src/lib/actions/PersonalDetails.js b/src/lib/actions/PersonalDetails.js index 0f34cfcc72fb2..47dbdfeabc163 100644 --- a/src/lib/actions/PersonalDetails.js +++ b/src/lib/actions/PersonalDetails.js @@ -1,10 +1,17 @@ import _ from 'underscore'; +import lodashGet from 'lodash.get'; import Ion from '../Ion'; import {onReconnect, queueRequest} from '../API'; import IONKEYS from '../../IONKEYS'; import md5 from '../md5'; import CONST from '../../CONST'; +let currentUserEmail; +Ion.connect({ + key: IONKEYS.SESSION, + callback: val => currentUserEmail = val ? val.email : null, +}); + /** * Returns the URL for a user's avatar and handles someone not having any avatar at all * @@ -53,85 +60,58 @@ function formatPersonalDetails(personalDetailsList) { }, {}); } -/** - * Get the personal details for our organization - * - * @returns {Promise} - */ -function fetch() { - let currentLogin; - let myPersonalDetails; - const requestPromise = Ion.get(IONKEYS.SESSION, 'email') - .then((login) => { - if (!login) { - throw Error('No login'); - } - - currentLogin = login; - return queueRequest('Get', { - returnValueList: 'personalDetailsList', - }); - }) - .then((data) => { - const allPersonalDetails = formatPersonalDetails(data.personalDetailsList); - - // Get my personal details so they can be easily accessed and subscribed to on their own key - myPersonalDetails = allPersonalDetails[currentLogin] || {}; - - return Ion.merge(IONKEYS.PERSONAL_DETAILS, allPersonalDetails); - }) - .then(() => Ion.merge(IONKEYS.MY_PERSONAL_DETAILS, myPersonalDetails)) - .catch((error) => { - if (error.message === 'No login') { - // eslint-disable-next-line no-console - console.info('No email in store, not fetching personal details.'); - return; - } - - console.error('Error fetching personal details', error); - }); - - // 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 = queueRequest('Get', { + queueRequest('Get', { returnValueList: 'nameValuePairs', name: 'timeZone', }) .then((data) => { - const timezone = data.nameValuePairs.timeZone.selected || 'America/Los_Angeles'; + const timezone = lodashGet(data, 'nameValuePairs.timeZone.selected', 'America/Los_Angeles'); Ion.merge(IONKEYS.MY_PERSONAL_DETAILS, {timezone}); }); // Refresh the timezone every 30 minutes setTimeout(fetchTimezone, 1000 * 60 * 30); - return requestPromise; +} + +/** + * Get the personal details for our organization + */ +function fetch() { + queueRequest('Get', { + returnValueList: 'personalDetailsList', + }) + .then((data) => { + const allPersonalDetails = formatPersonalDetails(data.personalDetailsList); + Ion.merge(IONKEYS.PERSONAL_DETAILS, allPersonalDetails); + + // Get my personal details so they can be easily accessed and subscribed to on their own key + Ion.merge(IONKEYS.MY_PERSONAL_DETAILS, allPersonalDetails[currentUserEmail] || {}); + + // Get the timezone and put it in Ion + fetchTimezone(); + }) + .catch(error => console.error('Error fetching personal details', error)); + + // Refresh the personal details every 30 minutes + setTimeout(fetch, 1000 * 60 * 30); } /** * Get personal details for a list of emails. * * @param {String} emailList - * @returns {Promise} */ function getForEmails(emailList) { - let detailsFormatted; - return queueRequest('PersonalDetails_GetForEmails', { - emailList, - }) - .then((details) => { - detailsFormatted = formatPersonalDetails(details); - return Ion.merge(IONKEYS.PERSONAL_DETAILS, detailsFormatted); - }) - .then(() => detailsFormatted); + queueRequest('PersonalDetails_GetForEmails', {emailList}) + .then((data) => { + const details = _.omit(data, ['jsonCode', 'requestID']); + const formattedDetails = formatPersonalDetails(details); + Ion.merge(IONKEYS.PERSONAL_DETAILS, formattedDetails); + }); } // When the app reconnects from being offline, fetch all of the personal details diff --git a/src/lib/actions/Report.js b/src/lib/actions/Report.js index ab13414258ebb..6a9d0f8890239 100644 --- a/src/lib/actions/Report.js +++ b/src/lib/actions/Report.js @@ -31,7 +31,7 @@ Ion.connect({ callback: val => currentURL = val, }); -// Use a regex pattern here for an exact match so it doesn't also match "my_personal_details" +// Use a regex pattern here for an exact match so it doesn't also match "myPersonalDetails" let personalDetails; Ion.connect({ key: `^${IONKEYS.PERSONAL_DETAILS}$`, @@ -44,6 +44,7 @@ Ion.connect({ callback: val => myPersonalDetails = val, }); +// Keeps track of the max sequence number for each report const reportMaxSequenceNumbers = {}; // List of reportIDs that we define in .env @@ -95,7 +96,7 @@ function getSimplifiedReportObject(report) { reportID: report.reportID, reportName: report.reportName, reportNameValuePairs: report.reportNameValuePairs, - hasUnread: hasUnreadActions(report), + isUnread: hasUnreadActions(report), pinnedReport: configReportIDs.includes(report.reportID), }; } @@ -120,7 +121,7 @@ function getChatReportName(sharedReportList) { * chat report IDs * * @param {Array} chatList - * @return {Promise} + * @return {Promise} only used internally when fetchAll() is called */ function fetchChatReportsByIDs(chatList) { let fetchedReports; @@ -158,9 +159,8 @@ function fetchChatReportsByIDs(chatList) { newReport.reportName = getChatReportName(report.sharedReportList); } - // Merge the data into Ion. Don't use set() here or multiSet() because then that would - // overwrite any existing data (like if they have unread messages) - return Ion.merge(`${IONKEYS.REPORT}_${report.reportID}`, newReport); + // Merge the data into Ion + Ion.merge(`${IONKEYS.REPORT}_${report.reportID}`, newReport); }); return Promise.all(ionPromises); @@ -174,25 +174,19 @@ function fetchChatReportsByIDs(chatList) { * @param {object} reportAction */ function updateReportWithNewAction(reportID, reportAction) { + const previousMaxSequenceNumber = reportMaxSequenceNumbers[reportID]; + const newMaxSequenceNumber = reportAction.sequenceNumber; + const hasNewSequenceNumber = newMaxSequenceNumber > previousMaxSequenceNumber; + // Always merge the reportID into Ion // If the report doesn't exist in Ion yet, then all the rest of the data will be filled out // by handleReportChanged Ion.merge(`${IONKEYS.REPORT}_${reportID}`, { reportID, + isUnread: hasNewSequenceNumber, maxSequenceNumber: reportAction.sequenceNumber, }); - const previousMaxSequenceNumber = reportMaxSequenceNumbers[reportID]; - const newMaxSequenceNumber = reportAction.sequenceNumber; - - // Mark the report as unread if there is a new max sequence number - if (newMaxSequenceNumber > previousMaxSequenceNumber) { - Ion.merge(`${IONKEYS.REPORT}_${reportID}`, { - hasUnread: true, - maxSequenceNumber: newMaxSequenceNumber, - }); - } - // Add the action into Ion Ion.merge(`${IONKEYS.REPORT_ACTIONS}_${reportID}`, { [reportAction.sequenceNumber]: reportAction, @@ -212,7 +206,6 @@ function updateReportWithNewAction(reportID, reportAction) { return; } - console.debug('[NOTIFICATION] Creating notification'); Notification.showCommentNotification({ reportAction, @@ -241,7 +234,7 @@ function subscribeToReportCommentEvents() { * Get all chat reports and provide the proper report name * by fetching sharedReportList and personalDetails * - * @returns {Promise} + * @returns {Promise} only used internally when fetchAll() is called */ function fetchChatReports() { return queueRequest('Get', { @@ -252,12 +245,32 @@ function fetchChatReports() { .then(({chatList}) => fetchChatReportsByIDs(String(chatList).split(','))); } +/** + * Get the actions of a report + * + * @param {number} reportID + */ +function fetchActions(reportID) { + queueRequest('Report_GetHistory', {reportID}) + .then((data) => { + const indexedData = _.indexBy(data.history, 'sequenceNumber'); + const maxSequenceNumber = _.chain(data.history) + .pluck('sequenceNumber') + .max() + .value(); + Ion.merge(`${IONKEYS.REPORT_ACTIONS}_${reportID}`, indexedData); + Ion.merge(`${IONKEYS.REPORT}_${reportID}`, {maxSequenceNumber}); + }); +} + /** * Get all of our reports * - * @returns {Promise} + * @param {boolean} shouldRedirectToFirstReport this is set to false when the network reconnect + * code runs + * @param {boolean} shouldFetchActions whether or not the actions of the reports should also be fetched */ -function fetchAll() { +function fetchAll(shouldRedirectToFirstReport = true, shouldFetchActions = false) { let fetchedReports; // Request each report one at a time to allow individual reports to fail if access to it is prevented by Auth @@ -273,7 +286,7 @@ function fetchAll() { // parallel reportFetchPromises.push(fetchChatReports()); - return promiseAllSettled(reportFetchPromises) + promiseAllSettled(reportFetchPromises) .then((data) => { fetchedReports = _.compact(_.map(data, (promiseResult) => { // Grab the report from the promise result which stores it in the `value` key @@ -284,35 +297,26 @@ function fetchAll() { return _.isEmpty(report) ? null : _.values(report)[0]; })); - // Store the first report ID in Ion - Ion.set(IONKEYS.FIRST_REPORT_ID, _.first(_.pluck(fetchedReports, 'reportID')) || 0); + // Set the first report ID so that the logged in person can be redirected there + // if they are on the home page + if (shouldRedirectToFirstReport && currentURL === '/') { + const firstReportID = _.first(_.pluck(fetchedReports, 'reportID')); + + // If we're on the home page, then redirect to the first report ID + if (firstReportID) { + redirect(`/${firstReportID}`); + } + } _.each(fetchedReports, (report) => { // Merge the data into Ion. Don't use set() here or multiSet() because then that would // overwrite any existing data (like if they have unread messages) Ion.merge(`${IONKEYS.REPORT}_${report.reportID}`, getSimplifiedReportObject(report)); - }); - return fetchedReports; - }); -} - -/** - * Get the actions of a report - * - * @param {number} reportID - * @returns {Promise} - */ -function fetchActions(reportID) { - return queueRequest('Report_GetHistory', {reportID}) - .then((data) => { - const indexedData = _.indexBy(data.history, 'sequenceNumber'); - const maxSequenceNumber = _.chain(data.history) - .pluck('sequenceNumber') - .max() - .value(); - Ion.set(`${IONKEYS.REPORT_ACTIONS}_${reportID}`, indexedData); - Ion.merge(`${IONKEYS.REPORT}_${reportID}`, {maxSequenceNumber}); + if (shouldFetchActions) { + fetchActions(report.reportID); + } + }); }); } @@ -321,12 +325,15 @@ function fetchActions(reportID) { * set of participants * * @param {string[]} participants - * @returns {Promise} resolves with reportID */ function fetchOrCreateChatReport(participants) { let reportID; - return queueRequest('CreateChatReport', { + if (participants.length < 2) { + throw new Error('fetchOrCreateChatReport() must have at least two participants'); + } + + queueRequest('CreateChatReport', { emailList: participants.join(','), }) @@ -354,8 +361,8 @@ function fetchOrCreateChatReport(participants) { // overwrite any existing data (like if they have unread messages) Ion.merge(`${IONKEYS.REPORT}_${reportID}`, newReport); - // Return the reportID as the final return value - return reportID; + // Redirect the logged in person to the new report + redirect(`/${reportID}`); }); } @@ -421,23 +428,26 @@ function addAction(reportID, text) { * Updates the last read action ID on the report. It optimistically makes the change to the store, and then let's the * network layer handle the delayed write. * - * @param {string} accountID * @param {number} reportID * @param {number} sequenceNumber */ -function updateLastReadActionID(accountID, reportID, sequenceNumber) { - // Mark the report as not having any unread items +function updateLastReadActionID(reportID, sequenceNumber) { + const currentMaxSequenceNumber = reportMaxSequenceNumbers[reportID]; + if (sequenceNumber < currentMaxSequenceNumber) { + return; + } + + // Update the lastReadActionID on the report optimistically Ion.merge(`${IONKEYS.REPORT}_${reportID}`, { - hasUnread: false, + isUnread: false, reportNameValuePairs: { - [`lastReadActionID_${accountID}`]: sequenceNumber, + [`lastReadActionID_${currentUserAccountID}`]: sequenceNumber, } }); - - // Update the lastReadActionID on the report optimistically + // Mark the report as not having any unread items queueRequest('Report_SetLastReadActionID', { - accountID, + accountID: currentUserAccountID, reportID, sequenceNumber, }); @@ -451,7 +461,7 @@ function updateLastReadActionID(accountID, reportID, sequenceNumber) { * @param {string} comment */ function saveReportComment(reportID, comment) { - Ion.set(`${IONKEYS.REPORT_DRAFT_COMMENT}_${reportID}`, comment); + Ion.merge(`${IONKEYS.REPORT_DRAFT_COMMENT}_${reportID}`, comment); } /** @@ -461,10 +471,17 @@ function saveReportComment(reportID, comment) { * @param {object} report */ function handleReportChanged(report) { + if (!report) { + return; + } + + // A report can be missing a name if a comment is received via pusher event + // and the report does not yet exist in Ion (eg. a new DM created with the logged in person) if (report.reportName === undefined) { fetchChatReportsByIDs([report.reportID]); } + // Store the max sequence number for each report reportMaxSequenceNumbers[report.reportID] = report.maxSequenceNumber; } Ion.connect({ @@ -474,7 +491,7 @@ Ion.connect({ // When the app reconnects from being offline, fetch all of the reports and their actions onReconnect(() => { - fetchAll().then(reports => _.each(reports, report => fetchActions(report.reportID))); + fetchAll(false, true); }); export { fetchAll, diff --git a/src/lib/actions/Session.js b/src/lib/actions/Session.js index 58cbf610cf383..6a12f0f8c9e0e 100644 --- a/src/lib/actions/Session.js +++ b/src/lib/actions/Session.js @@ -2,9 +2,24 @@ import Ion from '../Ion'; import * as API from '../API'; import IONKEYS from '../../IONKEYS'; import redirectToSignIn from './SignInRedirect'; +import * as Pusher from '../Pusher/pusher'; +let credentials; +Ion.connect({ + key: IONKEYS.CREDENTIALS, + callback: val => credentials = val, +}); + +/** + * Sign in with the API + * + * @param {string} partnerUserID + * @param {string} partnerUserSecret + * @param {string} twoFactorAuthCode + * @param {string} exitTo + */ function signIn(partnerUserID, partnerUserSecret, twoFactorAuthCode = '', exitTo) { - return API.authenticate({ + API.authenticate({ partnerUserID, partnerUserSecret, twoFactorAuthCode, @@ -14,17 +29,14 @@ function signIn(partnerUserID, partnerUserSecret, twoFactorAuthCode = '', exitTo /** * Clears the Ion store and redirects user to the sign in page - * @returns {Promise} */ function signOut() { - return redirectToSignIn() - .then(() => Ion.multiGet([IONKEYS.SESSION, IONKEYS.CREDENTIALS])) - .then(data => API.deleteLogin({ - authToken: data.session.authToken, - partnerUserID: data.credentials.login - })) - .then(Ion.clear) - .catch(err => Ion.merge(IONKEYS.SESSION, {error: err.message})); + redirectToSignIn(); + API.deleteLogin({ + partnerUserID: credentials && credentials.login + }); + Ion.clear(); + Pusher.disconnect(); } export { diff --git a/src/lib/actions/SignInRedirect.js b/src/lib/actions/SignInRedirect.js index cfd1a8ad91db1..6f0715a5d73cc 100644 --- a/src/lib/actions/SignInRedirect.js +++ b/src/lib/actions/SignInRedirect.js @@ -3,30 +3,31 @@ import IONKEYS from '../../IONKEYS'; import ROUTES from '../../ROUTES'; import {redirect} from './App'; +let currentURL; +Ion.connect({ + key: IONKEYS.CURRENT_URL, + callback: val => currentURL = val, +}); + /** * Redirects to the sign in page and handles adding any exitTo params to the URL. * Normally this method would live in Session.js, but that would cause a circular dependency with Network.js. - * - * @returns {Promise} */ function redirectToSignIn() { - return Ion.get(IONKEYS.CURRENT_URL) - .then((url) => { - if (!url) { - return; - } + if (!currentURL) { + return; + } - // If there is already an exitTo, or has the URL of signin, don't redirect - if (url.indexOf('exitTo') !== -1 || url.indexOf('signin') !== -1) { - return; - } + // If there is already an exitTo, or has the URL of signin, don't redirect + if (currentURL.indexOf('exitTo') !== -1 || currentURL.indexOf('signin') !== -1) { + return; + } - // When the URL is at the root of the site, go to sign-in, otherwise add the exitTo - const urlWithExitTo = url === '/' - ? ROUTES.SIGNIN - : `${ROUTES.SIGNIN}/exitTo${url}`; - return redirect(urlWithExitTo); - }); + // When the URL is at the root of the site, go to sign-in, otherwise add the exitTo + const urlWithExitTo = currentURL === '/' + ? ROUTES.SIGNIN + : `${ROUTES.SIGNIN}/exitTo${currentURL}`; + redirect(urlWithExitTo); } export default redirectToSignIn; diff --git a/src/page/SignInPage.js b/src/page/SignInPage.js index a51c99b193883..c989da8948df5 100644 --- a/src/page/SignInPage.js +++ b/src/page/SignInPage.js @@ -25,12 +25,15 @@ const propTypes = { /* Ion Props */ - // Error to display when there is a session error returned - error: PropTypes.string, + // The session of the logged in person + session: PropTypes.shape({ + // Error to display when there is a session error returned + error: PropTypes.string, + }), }; const defaultProps = { - error: null, + session: null, }; class App extends Component { @@ -114,9 +117,9 @@ class App extends Component { > Log In - {this.props.error && ( + {this.props.session && this.props.session.error && ( - {this.props.error} + {this.props.session.error} )} @@ -133,7 +136,6 @@ App.defaultProps = defaultProps; export default compose( withRouter, withIon({ - // Bind this.props.error to the error in the session object - error: {key: IONKEYS.SESSION, path: 'error', defaultValue: null}, + session: {key: IONKEYS.SESSION}, }) )(App); diff --git a/src/page/home/HeaderView.js b/src/page/home/HeaderView.js index 3a74ddf5a6642..d6196c49c31c6 100644 --- a/src/page/home/HeaderView.js +++ b/src/page/home/HeaderView.js @@ -17,13 +17,15 @@ const propTypes = { shouldShowHamburgerButton: PropTypes.bool.isRequired, /* Ion Props */ - - // Name of the report (if we have one) - reportName: PropTypes.string, + // The report currently being looked at + report: PropTypes.shape({ + // Name of the report + reportName: PropTypes.string, + }), }; const defaultProps = { - reportName: null, + report: null, }; const HeaderView = props => ( @@ -41,11 +43,11 @@ const HeaderView = props => ( /> )} - {props.reportName && ( + {props.report && props.report.reportName ? ( - {props.reportName} + {props.report.reportName} - )} + ) : null} ); @@ -57,13 +59,8 @@ HeaderView.defaultProps = defaultProps; export default compose( withRouter, withIon({ - // Map this.props.reportName to the data for a specific report in the store, - // and bind it to the reportName property. - // It uses the data returned from the props path (ie. the reportID) to replace %DATAFROMPROPS% in the key it - // binds to - reportName: { + report: { key: `${IONKEYS.REPORT}_%DATAFROMPROPS%`, - path: 'reportName', pathForProps: 'match.params.reportID', }, }), diff --git a/src/page/home/HomePage.js b/src/page/home/HomePage.js index c40597f240563..7e36a78633566 100644 --- a/src/page/home/HomePage.js +++ b/src/page/home/HomePage.js @@ -12,7 +12,9 @@ import styles, {getSafeAreaPadding} from '../../style/StyleSheet'; import Header from './HeaderView'; import Sidebar from './sidebar/SidebarView'; import Main from './MainView'; -import {subscribeToReportCommentEvents} from '../../lib/actions/Report'; +import {subscribeToReportCommentEvents, fetchAll as fetchAllReports} from '../../lib/actions/Report'; +import {fetch as fetchPersonalDetails} from '../../lib/actions/PersonalDetails'; +import * as Pusher from '../../lib/Pusher/pusher'; const windowSize = Dimensions.get('window'); const widthBreakPoint = 1000; @@ -33,8 +35,13 @@ export default class App extends React.Component { } componentDidMount() { - // Listen for report comment events - subscribeToReportCommentEvents(); + Pusher.init().then(subscribeToReportCommentEvents); + + // Fetch all the personal details + fetchPersonalDetails(); + + // Fetch all the reports + fetchAllReports(); Dimensions.addEventListener('change', this.toggleHamburgerBasedOnDimensions); @@ -163,4 +170,3 @@ export default class App extends React.Component { ); } } -App.displayName = 'App'; diff --git a/src/page/home/MainView.js b/src/page/home/MainView.js index 80b9eb97bfc29..f8bfc2acf4f05 100644 --- a/src/page/home/MainView.js +++ b/src/page/home/MainView.js @@ -79,8 +79,7 @@ export default compose( withIon({ reports: { key: `${IONKEYS.REPORT}_[0-9]+$`, - addAsCollection: true, - collectionID: 'reportID', + indexBy: 'reportID', }, }), )(MainView); diff --git a/src/page/home/report/ReportActionsView.js b/src/page/home/report/ReportActionsView.js index 0c241ed98fa8d..9271e3e64731e 100644 --- a/src/page/home/report/ReportActionsView.js +++ b/src/page/home/report/ReportActionsView.js @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; import lodashGet from 'lodash.get'; import Text from '../../../components/Text'; -import Ion from '../../../lib/Ion'; import withIon from '../../../components/withIon'; import {fetchActions, updateLastReadActionID} from '../../../lib/actions/Report'; import IONKEYS from '../../../IONKEYS'; @@ -36,17 +35,17 @@ class ReportActionsView extends React.Component { constructor(props) { super(props); - this.recordlastReadActionID = _.debounce(this.recordlastReadActionID.bind(this), 1000, true); this.scrollToListBottom = this.scrollToListBottom.bind(this); this.recordMaxAction = this.recordMaxAction.bind(this); } componentDidMount() { this.keyboardEvent = Keyboard.addListener('keyboardDidShow', this.scrollToListBottom); + fetchActions(this.props.reportID); } componentDidUpdate(prevProps) { - const isReportVisible = this.props.reportID === this.props.match.params.reportID; + const isReportVisible = this.props.reportID === parseInt(this.props.match.params.reportID, 10); // When the number of actions change, wait three seconds, then record the max action // This will make the unread indicator go away if you receive comments in the same chat you're looking at @@ -111,28 +110,8 @@ class ReportActionsView extends React.Component { .pluck('sequenceNumber') .max() .value(); - this.recordlastReadActionID(maxVisibleSequenceNumber); - } - /** - * Takes a max seqNum and if it's greater than the last read action, then make a request to the API to - * update the report - * - * @param {number} maxSequenceNumber - */ - recordlastReadActionID(maxSequenceNumber) { - let myAccountID; - Ion.get(IONKEYS.SESSION, 'accountID') - .then((accountID) => { - myAccountID = accountID; - const path = `reportNameValuePairs.lastReadActionID_${accountID}`; - return Ion.get(`${IONKEYS.REPORT}_${this.props.reportID}`, path, 0); - }) - .then((lastReadActionID) => { - if (maxSequenceNumber > lastReadActionID) { - updateLastReadActionID(myAccountID, this.props.reportID, maxSequenceNumber); - } - }); + updateLastReadActionID(this.props.reportID, maxVisibleSequenceNumber); } /** @@ -180,14 +159,11 @@ class ReportActionsView extends React.Component { ReportActionsView.propTypes = propTypes; ReportActionsView.defaultProps = defaultProps; -const key = `${IONKEYS.REPORT_ACTIONS}_%DATAFROMPROPS%`; export default compose( withRouter, withIon({ reportActions: { - key, - loader: fetchActions, - loaderParams: ['%DATAFROMPROPS%'], + key: `${IONKEYS.REPORT_ACTIONS}_%DATAFROMPROPS%`, pathForProps: 'reportID', }, }), diff --git a/src/page/home/sidebar/ChatSwitcherView.js b/src/page/home/sidebar/ChatSwitcherView.js index 92918480a876d..898811a336411 100644 --- a/src/page/home/sidebar/ChatSwitcherView.js +++ b/src/page/home/sidebar/ChatSwitcherView.js @@ -2,14 +2,37 @@ import React from 'react'; import PropTypes from 'prop-types'; import _ from 'underscore'; import withIon from '../../../components/withIon'; -import Ion from '../../../lib/Ion'; import IONKEYS from '../../../IONKEYS'; import Str from '../../../lib/Str'; import KeyboardShortcut from '../../../lib/KeyboardShortcut'; import ChatSwitcherList from './ChatSwitcherList'; import ChatSwitcherSearchForm from './ChatSwitcherSearchForm'; import {fetchOrCreateChatReport} from '../../../lib/actions/Report'; -import {redirect} from '../../../lib/actions/App'; + +const personalDetailsPropTypes = PropTypes.shape({ + // The login of the person (either email or phone number) + login: PropTypes.string.isRequired, + + // The URL of the person's avatar (there should already be a default avatarURL if + // the person doesn't have their own avatar uploaded yet) + avatarURL: PropTypes.string.isRequired, + + // The first name of the person + firstName: PropTypes.string, + + // The last name of the person + lastName: PropTypes.string, + + // The combination of `${firstName} ${lastName}` (could be an empty string) + fullName: PropTypes.string, + + // This is either the user's full name, or their login if full name is an empty string + displayName: PropTypes.string.isRequired, + + // Either the user's full name and their login, or just the login if the full name is empty + // `${fullName} (${login})` + displayNameWithEmail: PropTypes.string.isRequired, +}); const propTypes = { // A method that is triggered when the TextInput gets focus @@ -23,33 +46,17 @@ const propTypes = { // All of the personal details for everyone // The keys of this object are the logins of the users, and the values are an object // with their details - personalDetails: PropTypes.objectOf(PropTypes.shape({ - // The login of the person (either email or phone number) - login: PropTypes.string.isRequired, + personalDetails: PropTypes.objectOf(personalDetailsPropTypes), - // The URL of the person's avatar (there should already be a default avatarURL if - // the person doesn't have their own avatar uploaded yet) - avatarURL: PropTypes.string.isRequired, - - // The first name of the person - firstName: PropTypes.string, - - // The last name of the person - lastName: PropTypes.string, - - // The combination of `${firstName} ${lastName}` (could be an empty string) - fullName: PropTypes.string, - - // This is either the user's full name, or their login if full name is an empty string - displayName: PropTypes.string.isRequired, - - // Either the user's full name and their login, or just the login if the full name is empty - // `${fullName} (${login})` - displayNameWithEmail: PropTypes.string.isRequired, - })), + // The personal details of the person who is currently logged in + session: PropTypes.shape({ + // The email of the person who is currently logged in + email: PropTypes.string.isRequired, + }), }; const defaultProps = { personalDetails: {}, + session: null, }; class ChatSwitcherView extends React.Component { @@ -125,9 +132,7 @@ class ChatSwitcherView extends React.Component { * @param {string} option.value */ fetchChatReportAndRedirect(option) { - Ion.get(IONKEYS.MY_PERSONAL_DETAILS, 'login') - .then(currentLogin => fetchOrCreateChatReport([currentLogin, option.login])) - .then(reportID => redirect(reportID)); + fetchOrCreateChatReport([this.props.session.email, option.login]); this.reset(); } @@ -271,7 +276,10 @@ ChatSwitcherView.defaultProps = defaultProps; export default withIon({ personalDetails: { // Exact match for the personal_details key as we don't want - // my_personal_details to overwrite this value + // myPersonalDetails to overwrite this value key: `^${IONKEYS.PERSONAL_DETAILS}$`, }, + session: { + key: IONKEYS.SESSION, + }, })(ChatSwitcherView); diff --git a/src/page/home/sidebar/SidebarBottom.js b/src/page/home/sidebar/SidebarBottom.js index bc6901ffca9f3..e53f8beb790ba 100644 --- a/src/page/home/sidebar/SidebarBottom.js +++ b/src/page/home/sidebar/SidebarBottom.js @@ -1,12 +1,12 @@ import React from 'react'; -import {Image, View} from 'react-native'; +import {Image, View, StyleSheet} from 'react-native'; import PropTypes from 'prop-types'; +import _ from 'underscore'; import styles, {getSafeAreaMargins} from '../../../style/StyleSheet'; import Text from '../../../components/Text'; import AppLinks from './AppLinks'; import {signOut} from '../../../lib/actions/Session'; import IONKEYS from '../../../IONKEYS'; -import {fetch as getPersonalDetails} from '../../../lib/actions/PersonalDetails'; import withIon from '../../../components/withIon'; import SafeAreaInsetPropTypes from '../../SafeAreaInsetPropTypes'; @@ -25,25 +25,28 @@ const propTypes = { avatarURL: PropTypes.string, }), - // Is this person offline? - isOffline: PropTypes.bool, + // Information about the network + network: PropTypes.shape({ + // Is the network currently offline or not + isOffline: PropTypes.bool, + }) }; const defaultProps = { myPersonalDetails: {}, - isOffline: false, + network: null, }; -const SidebarBottom = ({myPersonalDetails, isOffline, insets}) => { +const SidebarBottom = ({myPersonalDetails, network, insets}) => { const indicatorStyles = [ styles.statusIndicator, - isOffline ? styles.statusIndicatorOffline : styles.statusIndicatorOnline + network && network.isOffline ? styles.statusIndicatorOffline : styles.statusIndicatorOnline ]; // On the very first sign in or after clearing storage these // details will not be present on the first render so we'll just // return nothing for now. - if (!myPersonalDetails) { + if (!myPersonalDetails || _.isEmpty(myPersonalDetails)) { return null; } @@ -54,7 +57,7 @@ const SidebarBottom = ({myPersonalDetails, isOffline, insets}) => { source={{uri: myPersonalDetails.avatarURL}} style={[styles.actionAvatar]} /> - + {myPersonalDetails.displayName && ( @@ -76,15 +79,8 @@ SidebarBottom.defaultProps = defaultProps; SidebarBottom.displayName = 'SidebarBottom'; export default withIon({ - // Map this.props.userDisplayName to the personal details key in the store and bind it to the displayName property - // and load it with data from getPersonalDetails() myPersonalDetails: { key: IONKEYS.MY_PERSONAL_DETAILS, - loader: getPersonalDetails, - }, - isOffline: { - key: IONKEYS.NETWORK, - path: 'isOffline', - defaultValue: false, }, + network: {key: IONKEYS.NETWORK}, })(SidebarBottom); diff --git a/src/page/home/sidebar/SidebarLink.js b/src/page/home/sidebar/SidebarLink.js index 46b44dcf35ed5..df2572341e4ad 100644 --- a/src/page/home/sidebar/SidebarLink.js +++ b/src/page/home/sidebar/SidebarLink.js @@ -25,12 +25,17 @@ const propTypes = { /* Ion Props */ - // Does the report for this link have unread comments? - isUnread: PropTypes.bool, + // The report object for this link + report: PropTypes.shape({ + // Does the report for this link have unread comments? + isUnread: PropTypes.bool, + }), }; const defaultProps = { - isUnread: false, + report: { + isUnread: false, + }, reportName: '', }; @@ -40,7 +45,7 @@ const SidebarLink = (props) => { const linkWrapperActiveStyle = isReportActive && styles.sidebarLinkWrapperActive; const linkActiveStyle = isReportActive ? styles.sidebarLinkActive : styles.sidebarLink; const textActiveStyle = isReportActive ? styles.sidebarLinkActiveText : styles.sidebarLinkText; - const textActiveUnreadStyle = props.isUnread + const textActiveUnreadStyle = props.report.isUnread ? [textActiveStyle, styles.sidebarLinkTextUnread] : [textActiveStyle]; return ( @@ -63,10 +68,8 @@ SidebarLink.defaultProps = defaultProps; export default compose( withRouter, withIon({ - isUnread: { + report: { key: `${IONKEYS.REPORT}_%DATAFROMPROPS%`, - path: 'hasUnread', - defaultValue: false, pathForProps: 'reportID', } }), diff --git a/src/page/home/sidebar/SidebarLinks.js b/src/page/home/sidebar/SidebarLinks.js index 9b4488ccc54d7..71de2c9f0c2fe 100644 --- a/src/page/home/sidebar/SidebarLinks.js +++ b/src/page/home/sidebar/SidebarLinks.js @@ -8,14 +8,11 @@ import Text from '../../../components/Text'; import SidebarLink from './SidebarLink'; import withIon from '../../../components/withIon'; import IONKEYS from '../../../IONKEYS'; -import {fetchAll} from '../../../lib/actions/Report'; -import Ion from '../../../lib/Ion'; import PageTitleUpdater from '../../../lib/PageTitleUpdater'; import ChatSwitcherView from './ChatSwitcherView'; import SafeAreaInsetPropTypes from '../../SafeAreaInsetPropTypes'; import compose from '../../../lib/compose'; import {withRouter} from '../../../lib/Router'; -import {redirect} from '../../../lib/actions/App'; const propTypes = { // These are from withRouter @@ -40,7 +37,7 @@ const propTypes = { reports: PropTypes.objectOf(PropTypes.shape({ reportID: PropTypes.number, reportName: PropTypes.string, - hasUnread: PropTypes.bool, + isUnread: PropTypes.bool, })), }; const defaultProps = { @@ -63,10 +60,10 @@ class SidebarLinks extends React.Component { // Filter the reports so that the only reports shown are pinned, unread, and the one matching the URL // eslint-disable-next-line max-len - const reportsToDisplay = _.filter(sortedReports, report => (report.pinnedReport || report.hasUnread || report.reportID === reportIDInUrl)); + const reportsToDisplay = _.filter(sortedReports, report => (report.pinnedReport || report.isUnread || report.reportID === reportIDInUrl)); // Updates the page title to indicate there are unread reports - PageTitleUpdater(_.any(reports, report => report.hasUnread)); + PageTitleUpdater(_.any(reports, report => report.isUnread)); return ( @@ -97,7 +94,7 @@ class SidebarLinks extends React.Component { key={report.reportID} reportID={report.reportID} reportName={report.reportName} - hasUnread={report.hasUnread} + isUnread={report.isUnread} onLinkClick={onLinkClick} /> ))} @@ -116,20 +113,7 @@ export default compose( withIon({ reports: { key: `${IONKEYS.REPORT}_[0-9]+$`, - addAsCollection: true, - collectionID: 'reportID', - loader: () => fetchAll().then(() => { - // After the reports are loaded for the first time, redirect to the first reportID in the list - Ion.multiGet([IONKEYS.CURRENT_URL, IONKEYS.FIRST_REPORT_ID]).then((values) => { - const currentURL = values[IONKEYS.CURRENT_URL] || ''; - const firstReportID = values[IONKEYS.FIRST_REPORT_ID] || 0; - - // If we're on the home page, then redirect to the first report ID - if (currentURL === '/' && firstReportID) { - redirect(firstReportID); - } - }); - }), + indexBy: 'reportID', } }), )(SidebarLinks);