Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3d6b637
Address some simple comments from previous PR review
tgolen Sep 11, 2020
8814c20
Implement multi-tab functionality
tgolen Sep 11, 2020
fa99401
Add a warning when there can be undefined behavior
tgolen Sep 11, 2020
0da38df
Remove TODO
tgolen Sep 11, 2020
61aaa85
Fix mobile
tgolen Sep 11, 2020
1720bad
Rename storage event export
tgolen Sep 14, 2020
1fa3262
Merge branch 'master' into tgolen-refactor-cleanup
tgolen Sep 14, 2020
21ce6dc
Move active client manager to a platform specific lib
tgolen Sep 14, 2020
aa4811f
Move add storage event listener into platform specific code
tgolen Sep 14, 2020
fe8fc83
Revert "Move add storage event listener into platform specific code"
tgolen Sep 14, 2020
47d0ca1
Rename method to be more clear
tgolen Sep 14, 2020
cb67d7e
Improve Ion.merge() to work better with arrays
tgolen Sep 14, 2020
abc9cb0
Simplify handling unload event
tgolen Sep 14, 2020
457f1bb
Fix inconsistent native API
tgolen Sep 14, 2020
2992aa0
Merge branch 'master' into tgolen-refactor-cleanup
tgolen Sep 16, 2020
228bc2d
Protect against null values
tgolen Sep 16, 2020
232192d
Remove onbeforeunload event
tgolen Sep 17, 2020
20b9be5
Merge branch 'master' into tgolen-refactor-cleanup
tgolen Sep 21, 2020
a418d90
Merge branch 'master' into tgolen-refactor-cleanup
tgolen Sep 22, 2020
38c19e1
Catch errors when trying to parse JSON
tgolen Sep 23, 2020
3166611
Don't double-parse
tgolen Sep 23, 2020
90ea5a4
Only trigger the callback when there was a valid value that was parsed
tgolen Sep 23, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This application is built with the following principles.
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.
- Libraries subscribe to Ion (with `Ion.connect()`) and any change to the Ion data is published to the 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:
Expand All @@ -27,7 +27,7 @@ This application is built with the following principles.
- 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.
- 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
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@
"prop-types": "^15.7.2",
"pusher-js": "^7.0.0",
"react": "^16.13.1",
"react-beforeunload": "^2.2.2",
"react-dom": "^16.13.1",
"react-native": "0.63.2",
"react-native-config": "^1.3.3",
Expand Down
7 changes: 0 additions & 7 deletions src/Expensify.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import React, {Component} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {recordCurrentRoute} from './lib/actions/App';

// import {Beforeunload} from 'react-beforeunload';
import SignInPage from './page/SignInPage';
import HomePage from './page/home/HomePage';
import Ion from './lib/Ion';
Expand Down Expand Up @@ -78,9 +76,6 @@ class Expensify extends Component {
}
const redirectTo = !this.state.authToken ? ROUTES.SIGNIN : this.props.redirectTo;
return (

// TODO: Mobile does not support Beforeunload
// <Beforeunload onBeforeunload={ActiveClientManager.removeClient}>
<Router>
{/* If there is ever a property for redirecting, we do the redirect here */}
{/* Leave this as a ternary or else iOS throws an error about text not being wrapped in <Text> */}
Expand All @@ -101,8 +96,6 @@ class Expensify extends Component {
<Route path={['/home', '/']} component={HomePage} />
</Switch>
</Router>

// </Beforeunload>
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/IONKEYS.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* This is a file containing constants for all the top level keys in our store
*/
export default {
ACTIVE_CLIENTS: 'activeClients',
ACTIVE_CLIENTS: 'activeClients2',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it was intentional... and I'm open to a better solution. The problem is that these were stored as an object before, and sense we are only calling Ion.merge() for this, moving from an object to an array is not possible and will result in really bad values being stored.

One possible solution is to rename it to maybe activeClientIDs, then add some code which clears out activeClients, and then after a week, we can go back and set it back to activeClients. Whatever solution we think of here, we should try to find some kind of method that is fairly reproducible because I think we will need to do this occassionally.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok changing the name works for now!

I think learn term we need a way to perform localStorage migrations for when keys change by adding some kind of instructions before the app updates or inits. That might mean we have some migration code running that based on the version will alter the localStorage schema or simply blow it away and start over from scratch minus session information.

Seems like a similar problem people run into when implementing a ServiceWorker

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this. The way I imagine this would work is something like

  • If the new version of the app renames a key or wants to clear old keys
  • When the app inits
  • Clear local storage (like signout does)
  • Re-authenticate
  • Load everything from scratch

That should all be transparent to the user

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think the catch there is that process wouldn't be exactly "transparent" to the user, particularly the "load everything from scratch" part. I'm going to spin up an issue so we can discuss this further and plan on leaving this code the way it is for now.

APP_REDIRECT_TO: 'appRedirectTo',
CURRENT_URL: 'currentURL',
CREDENTIALS: 'credentials',
Expand Down
1 change: 0 additions & 1 deletion src/components/withIon.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ export default function (mapIonToState) {
* @param {object} mapping
* @param {string|function} mapping.key key to connect to. can be a string or a
* function that takes this.props as an argument and returns a string
*
* @param {string} statePropertyName the name of the state property that Ion will add the data to
* @param {boolean} [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the
* component
Expand Down
48 changes: 0 additions & 48 deletions src/lib/ActiveClientManager.js

This file was deleted.

41 changes: 41 additions & 0 deletions src/lib/ActiveClientManager/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import _ from 'underscore';
import Guid from '../Guid';
import Ion from '../Ion';
import IONKEYS from '../../IONKEYS';

const clientID = Guid();
const maxClients = 20;

let activeClients;
Ion.connect({
key: IONKEYS.ACTIVE_CLIENTS,

callback: (val) => {
activeClients = val;
if (activeClients.length >= maxClients) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need to update active clients in Ion in the else case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why we would, no. Can you explain why you're thinking it would be necessary? If you added a set() in the else, then the value of active clients in Ion wouldn't be changing at all.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I fully understood how this works yesterday, but I think I get it now. When init is called, we just add clients. If somebody calls init and we reach the max of clients, we remove the first one that was added 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! Exactly.

activeClients.shift();
Ion.set(IONKEYS.ACTIVE_CLIENTS, activeClients);
}
},
});

/**
* Add our client ID to the list of active IDs
*/
function init() {
Ion.merge(IONKEYS.ACTIVE_CLIENTS, [clientID]);
}

/**
* The last GUID is the most recent GUID, so that should be the leader
*
* @returns {boolean}
*/
function isClientTheLeader() {
return _.last(activeClients) === clientID;
}

export {
init,
isClientTheLeader
};
14 changes: 14 additions & 0 deletions src/lib/ActiveClientManager/index.native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* For native devices, there will never be more than one
* client running at a time, so this lib is a big no-op
*/

function init() {}
function isClientTheLeader() {
return true;
}

export {
init,
isClientTheLeader
};
43 changes: 27 additions & 16 deletions src/lib/Ion.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,17 @@
import _ from 'underscore';
import AsyncStorage from '@react-native-community/async-storage';
import addStorageEventHandler from './addStorageEventHandler';
import Str from './Str';
import IONKEYS from '../IONKEYS';

// Keeps track of the last connectionID that was used so we can keep incrementing it
let lastConnectionID = 0;

/**
* Initialize the store with actions and listening for storage events
*/
function init() {
// Subscribe to the storage event so changes to local storage can be captured
// TODO: Refactor window events
// window.addEventListener('storage', (e) => {
// try {
// keyChanged(e.key, JSON.parse(e.newValue));
// } catch (e) {
// console.error(`Could not parse value from local storage. Key: ${e.key}`);
// }
// });
}

// Holds a mapping of all the react components that want their state subscribed to a store key
const callbackToStateMapping = {};

/**
* When a key change happens, search for any callbacks matching the regex pattern and trigger those callbacks
* Get some data from the store
*
* @param {string} key
Expand Down Expand Up @@ -97,6 +84,13 @@ function keyChanged(key, data) {
});
}

/**
* Initialize the store with actions and listening for storage events
*/
function init() {
addStorageEventHandler((key, newValue) => keyChanged(key, newValue));
}

/**
* Sends the data obtained from the keys to the connection. It either:
* - sets state on the withIonInstances
Expand Down Expand Up @@ -238,7 +232,24 @@ function clear() {
* @param {*} val
*/
function merge(key, val) {
// Values that are objects can be merged into storage
// Arrays need to be manually merged because the AsyncStorage behavior
// is not desired when merging arrays. `AsyncStorage.mergeItem('test', [1]);
// will result in `{0: 1}` being set in storage, when `[1]` is what is expected
if (_.isArray(val)) {
let newArray;
get(key)
.then((prevVal) => {
const previousValue = prevVal || [];
newArray = [...previousValue, ...val];
return AsyncStorage.setItem(key, JSON.stringify(newArray));
})
.then(() => {
keyChanged(key, newArray);
});
return;
}

// Values that are objects are merged normally into storage
if (_.isObject(val)) {
AsyncStorage.mergeItem(key, JSON.stringify(val))
.then(() => get(key))
Expand Down
7 changes: 0 additions & 7 deletions src/lib/Notification/BrowserNotifications.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// Web and desktop implementation only. Do not import for direct use. Use Notification.

import Str from '../Str';
import CONST from '../../CONST';
import * as ActiveClientManager from '../ActiveClientManager';

const EXPENSIFY_ICON_URL = `${CONST.CLOUDFRONT_URL}/images/favicon-2019.png`;
const DEFAULT_DELAY = 4000;
Expand Down Expand Up @@ -105,11 +103,6 @@ export default {
* @param {Function} params.onClick
*/
pushReportCommentNotification({reportAction, onClick}) {
if (!ActiveClientManager.isClientTheLeader()) {
console.debug('[BrowserNotifications] Skipping notification because this client is not the leader');
return;
}

const {person, message} = reportAction;
const plainTextPerson = Str.htmlDecode(person.map(f => f.text).join());

Expand Down
11 changes: 6 additions & 5 deletions src/lib/actions/PersonalDetails.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,17 @@ function fetch() {
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
// Set 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
// Refresh the personal details every 30 minutes because there is no
// pusher event that sends updated personal details data yet
// See https://github.com/Expensify/ReactNativeChat/issues/468
setTimeout(fetch, 1000 * 60 * 30);
}

Expand All @@ -108,9 +110,8 @@ function fetch() {
function getForEmails(emailList) {
queueRequest('PersonalDetails_GetForEmails', {emailList})
.then((data) => {
const details = _.omit(data, ['jsonCode', 'requestID']);
const formattedDetails = formatPersonalDetails(details);
Ion.merge(IONKEYS.PERSONAL_DETAILS, formattedDetails);
const details = _.pick(data, emailList.split(','));
Ion.merge(IONKEYS.PERSONAL_DETAILS, formatPersonalDetails(details));
});
}

Expand Down
6 changes: 6 additions & 0 deletions src/lib/actions/Report.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ExpensiMark from '../ExpensiMark';
import Notification from '../Notification';
import * as PersonalDetails from './PersonalDetails';
import {redirect} from './App';
import * as ActiveClientManager from '../ActiveClientManager';
import Visibility from '../Visibility';

let currentUserEmail;
Expand Down Expand Up @@ -192,6 +193,11 @@ function updateReportWithNewAction(reportID, reportAction) {
[reportAction.sequenceNumber]: reportAction,
});

if (!ActiveClientManager.isClientTheLeader()) {
console.debug('[NOTIFICATION] Skipping notification because this client is not the leader');
return;
}

// If this comment is from the current user we don't want to parrot whatever they wrote back to them.
if (reportAction.actorEmail === currentUserEmail) {
console.debug('[NOTIFICATION] No notification because comment is from the currently logged in user');
Expand Down
11 changes: 8 additions & 3 deletions src/lib/actions/Session.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,16 @@ function signIn(partnerUserID, partnerUserSecret, twoFactorAuthCode = '', exitTo
*/
function signOut() {
redirectToSignIn();
API.deleteLogin({
partnerUserID: credentials && credentials.login
});
Ion.clear();
Pusher.disconnect();

if (!credentials || !credentials.login) {
return;
}

API.deleteLogin({
partnerUserID: credentials.login
});
}

export {
Expand Down
19 changes: 19 additions & 0 deletions src/lib/addStorageEventHandler/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Listens for storage events so that multiple tabs can keep track of what
* other tabs are doing
*
* @param {function} callback
*/
function addStorageEventHandler(callback) {
window.addEventListener('storage', (e) => {
let newValue;
try {
newValue = JSON.parse(e.newValue);
} catch (err) {
console.error('Could not parse the newValue of the storage event', err, e);
}
callback(e.key, JSON.parse(newValue));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be callback(e.key, newValue);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, haha 🤦

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(and tested)

});
}

export default addStorageEventHandler;
8 changes: 8 additions & 0 deletions src/lib/addStorageEventHandler/index.native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Native clients don't have storage events and that's OK because
* you can't have multiple native clients open at the same time on the same
* device
*/
function addStorageEventHandler() {}

export default addStorageEventHandler;
1 change: 0 additions & 1 deletion src/page/home/report/ReportActionCompose.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ class ReportActionCompose extends React.Component {
const trimmedComment = this.comment.trim();

// Don't submit empty comments
// @TODO show an error in the UI
if (!trimmedComment) {
return;
}
Expand Down