diff --git a/README.md b/README.md index 8dad49886e700..2bb7df4c7596a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 1. Install `node` & `npm`: `brew install node` 2. Install `watchman`: `brew install watchman` 3. Install dependencies: `npm install` -4. (_Optional, but recommended_) Start ngrok (`Expensidev/script/ngrok.sh`), replace value in `Network.js` with your ngrok value +4. (_Optional, but recommended_) Start ngrok (`Expensidev/script/ngrok.sh`), replace `expensify.com.dev` value in `src/CONFIG.js` with your ngrok value ## Running the web app 💻 * To run a **Development Server**: `npm run web` diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 063304170df67..a510617ced23b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -236,6 +236,8 @@ PODS: - React-cxxreact (= 0.63.2) - React-jsi (= 0.63.2) - React-jsinspector (0.63.2) + - react-native-netinfo (5.9.5): + - React - React-RCTActionSheet (0.63.2): - React-Core/RCTActionSheetHeaders (= 0.63.2) - React-RCTAnimation (0.63.2): @@ -339,6 +341,7 @@ DEPENDENCIES: - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`) + - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) @@ -398,6 +401,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/jsiexecutor" React-jsinspector: :path: "../node_modules/react-native/ReactCommon/jsinspector" + react-native-netinfo: + :path: "../node_modules/@react-native-community/netinfo" React-RCTActionSheet: :path: "../node_modules/react-native/Libraries/ActionSheetIOS" React-RCTAnimation: @@ -450,6 +455,7 @@ SPEC CHECKSUMS: React-jsi: 54245e1d5f4b690dec614a73a3795964eeef13a8 React-jsiexecutor: 8ca588cc921e70590820ce72b8789b02c67cce38 React-jsinspector: b14e62ebe7a66e9231e9581279909f2fc3db6606 + react-native-netinfo: 7f3f3ed9e8f0e7ab3e7cac00cbfdc6997e25ecaf React-RCTActionSheet: 910163b6b09685a35c4ebbc52b66d1bfbbe39fc5 React-RCTAnimation: 9a883bbe1e9d2e158d4fb53765ed64c8dc2200c6 React-RCTBlob: 39cf0ece1927996c4466510e25d2105f67010e13 @@ -464,6 +470,6 @@ SPEC CHECKSUMS: Yoga: 7740b94929bbacbddda59bf115b5317e9a161598 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a -PODFILE CHECKSUM: c62ffd9a9a0605d86953b81048739f14f44c80a7 +PODFILE CHECKSUM: ebd627ee482cdc4819e853d5f3b81d787114e3ca COCOAPODS: 1.9.3 diff --git a/package-lock.json b/package-lock.json index 50a521f40b2d3..0c9ec2cf8be9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1701,6 +1701,11 @@ "integrity": "sha512-W/J0fNYVO01tioHjvYWQ9m6RgndVtbElzYozBq1ZPrHO/iCzlqoySHl4gO/fpCl9QEFjvJfjPgtPMTMlsoq5DQ==", "dev": true }, + "@react-native-community/netinfo": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-5.9.5.tgz", + "integrity": "sha512-PbSsRmhRwYIMdeVJTf9gJtvW0TVq/hmgz1xyjsrTIsQ7QS7wbMEiv1Eb/M/y6AEEsdUped5Axm5xykq9TGISHg==" + }, "@sinonjs/commons": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.1.tgz", @@ -11630,6 +11635,21 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "pusher-js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-7.0.0.tgz", + "integrity": "sha512-2ZSw8msMe6EKNTebQSthRInrWUK9bo3zXPmQx0bfeDFJdSnTWUROhdAhmpRQREHzqrL+l4imv/3uwgIQHUO0oQ==", + "requires": { + "tweetnacl": "^1.0.3" + }, + "dependencies": { + "tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + } + } + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", diff --git a/package.json b/package.json index 485a8b6122fa3..7a0fbd270444e 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,14 @@ }, "dependencies": { "@react-native-community/async-storage": "^1.11.0", + "@react-native-community/netinfo": "^5.9.5", "html-entities": "^1.3.1", "jquery": "^3.5.1", "lodash.get": "^4.4.2", "moment": "^2.27.0", "moment-timezone": "^0.5.31", "prop-types": "^15.7.2", + "pusher-js": "^7.0.0", "react": "^16.13.1", "react-beforeunload": "^2.2.2", "react-dom": "^16.13.1", diff --git a/src/lib/Network.js b/src/lib/Network.js index f6f21e97f8974..47be13226a865 100644 --- a/src/lib/Network.js +++ b/src/lib/Network.js @@ -30,7 +30,7 @@ function request(command, data, type = 'post') { if (responseData.jsonCode === 200) { return responseData; } - console.error('[API] Error', responseData); + console.info('[API] Error', responseData); }) // eslint-disable-next-line no-unused-vars .catch(() => isAppOffline = true); diff --git a/src/lib/Pusher/library/index.js b/src/lib/Pusher/library/index.js new file mode 100644 index 0000000000000..5127108eb6f8d --- /dev/null +++ b/src/lib/Pusher/library/index.js @@ -0,0 +1,3 @@ +import Pusher from 'pusher-js'; + +export default Pusher; diff --git a/src/lib/Pusher/library/index.native.js b/src/lib/Pusher/library/index.native.js new file mode 100644 index 0000000000000..dffbcfb4b4e05 --- /dev/null +++ b/src/lib/Pusher/library/index.native.js @@ -0,0 +1,3 @@ +import Pusher from 'pusher-js/react-native'; + +export default Pusher; diff --git a/src/lib/Pusher/pusher.js b/src/lib/Pusher/pusher.js new file mode 100644 index 0000000000000..ac9c994853977 --- /dev/null +++ b/src/lib/Pusher/pusher.js @@ -0,0 +1,295 @@ +import _ from 'underscore'; +import Pusher from './library'; +import CONFIG from '../../CONFIG'; + +let socket; + +/** + * Initialize our pusher lib + * @param {String} appKey + * @param {Object} [params] + * @public + */ +function init(appKey, params) { + if (!socket) { + // Use this for debugging + // Pusher.log = (message) => { + // if (window.console && window.console.log) { + // window.console.log(message); + // } + // }; + socket = new Pusher(CONFIG.PUSHER.APP_KEY, { + cluster: CONFIG.PUSHER.CLUSTER, + authEndpoint: `${CONFIG.PUSHER.AUTH_URL}/api.php?command=Push_Authenticate`, + }); + + // If we want to pass params in our requests to api.php we'll need to add it to socket.config.auth.params + // as per the documentation (https://pusher.com/docs/channels/using_channels/connection#channels-options-parameter). + // Any param mentioned here will show up in $_REQUEST when we call "Push_Authenticate". Params passed here need + // to pass our inputRules to show up in the request. + if (params) { + socket.config.auth = {}; + socket.config.auth.params = params; + } + + // Listen for connection errors and log them + socket.connection.bind('error', (error) => { + console.error('[Pusher] error', error); + }); + + socket.connection.bind('connected', () => { + console.debug('[Pusher] connected'); + }); + + socket.connection.bind('disconnected', () => { + console.debug('[Pusher] disconnected'); + }); + + socket.connection.bind('state_change', (states) => { + console.debug('[Pusher] state changed', states); + }); + } +} + +/** + * Returns a Pusher channel for a channel name + * + * @param {String} channelName + * + * @returns {Channel} + */ +function getChannel(channelName) { + return socket.channel(channelName); +} + +/** + * Binds an event callback to a channel + eventName + * @param {Pusher.Channel} channel + * @param {String} eventName + * @param {Function} [eventCallback] + * @param {Boolean} [isChunked] Do we expect this channel to send chunked/separate blocks of data that need recombining? + * + * @private + */ +function bindEventToChannel(channel, eventName, eventCallback = () => {}, isChunked = false) { + if (!eventName) { + return; + } + + const chunkedDataEvents = {}; + const callback = (eventData) => { + if (!isChunked) { + let data; + + try { + data = _.isObject(eventData) ? eventData : JSON.parse(eventData); + } catch (err) { + console.error('Unable to parse JSON response from Pusher', 0, {error: err, eventData}); + return; + } + + eventCallback(data); + return; + } + + // If we are chunking the requests, we need to construct a rolling list of all packets that have come through Pusher. + // If we've completed one of these full packets, we'll combine the data and act on the event that it's assigned to. + + // If we haven't seen this eventID yet, initialize it into our rolling list of packets. + if (!chunkedDataEvents[eventData.id]) { + chunkedDataEvents[eventData.id] = {chunks: [], receivedFinal: false}; + } + + // Add it to the rolling list. + const chunkedEvent = chunkedDataEvents[eventData.id]; + chunkedEvent.chunks[eventData.index] = eventData.chunk; + + // If this is the last packet, mark that we've hit the end. + if (eventData.final) { + chunkedEvent.receivedFinal = true; + } + + // Only call the event callback if we've received the last packet and we don't have any holes in the complete packet. + if (chunkedEvent.receivedFinal && chunkedEvent.chunks.length === Object.keys(chunkedEvent.chunks).length) { + eventCallback(JSON.parse(chunkedEvent.chunks.join(''))); + try { + eventCallback(JSON.parse(chunkedEvent.chunks.join(''))); + } catch (err) { + console.error('[Pusher] Unable to parse chunked JSON response from Pusher', 0, {error: err, eventData: chunkedEvent.chunks.join('')}); + } + + delete chunkedDataEvents[eventData.id]; + } + }; + + channel.bind(eventName, callback); +} + +/** + * Subscribe to a channel and an event + * + * @param {String} channelName + * @param {String} eventName + * @param {Function} [eventCallback] + * @param {Boolean} [isChunked] This parameters tells us whether or not we expect the result to come in individual pieces/chunks (because it exceeds + * the 10kB limit that pusher has). + * + * @return {Promise} + * + * @public + */ +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. + if (!socket) { + throw new Error('[Pusher] instance not found. Pusher.subscribe() most likely has been called before Pusher.init()'); + } + + console.debug('[Pusher] Attempting to subscribe to channel', true, {channelName, eventName}); + let channel = getChannel(channelName); + + if (!channel || !channel.subscribed) { + channel = socket.subscribe(channelName); + channel.bind('pusher:subscription_succeeded', () => { + bindEventToChannel(channel, eventName, eventCallback, isChunked); + resolve(); + }); + + channel.bind('pusher:subscription_error', (status) => { + if (status === 403) { + console.debug('[Pusher] Issue authenticating with Pusher during subscribe attempt.', 0, {channelName, status}); + } + + reject(status); + }); + } else { + bindEventToChannel(channel, eventName, eventCallback, isChunked); + resolve(); + } + }); +} + +/** + * Waits for the subscription_succeeded event to fire before returning members or + * returns the current members if the subscription has already succeeded. + * + * @param {String} channelName + * + * @return {Promise} + */ +function getChannelMembersAsync(channelName) { + return new Promise((resolve, reject) => subscribe(channelName) + .done(() => { + const channel = getChannel(channelName); + resolve(channel.members.members); + }) + .fail(() => { + console.debug('[Pusher] Unable to subscribe to presence channel while getting channel members async'); + reject(); + })); +} + +/** + * Unsubscribe from a channel and optionally a specific event + * + * @param {String} channelName + * @param {String} [eventName] + * @public + */ +function unsubscribe(channelName, eventName = '') { + const channel = getChannel(channelName); + + if (!channel) { + console.debug('[Pusher] Attempted to unsubscribe or unbind from a channel, but Pusher-JS has no knowledge of it', 0, {channelName, eventName}); + return; + } + + if (eventName) { + console.debug('[Pusher] Unbinding event', true, {eventName, channelName}); + channel.unbind(eventName); + } else { + if (!channel.subscribed) { + console.warn('[Pusher] Attempted to unsubscribe from channel, but we are not subscribed to begin with', 0, {channelName}); + return; + } + + channel.unbind(); + socket.unsubscribe(channelName); + } +} + +/** + * Are we already in the process of subscribing to this channel? + * + * @param {String} channelName + * + * @returns {Boolean} + */ +function isAlreadySubscribing(channelName) { + if (!socket) { + return false; + } + + const channel = getChannel(channelName); + return channel ? channel.subscriptionPending : false; +} + +/** + * Are we already subscribed to this channel? + * + * @param {String} channelName + * + * @returns {Boolean} + */ +function isSubscribed(channelName) { + if (!socket) { + return false; + } + + const channel = getChannel(channelName); + return channel ? channel.subscribed : false; +} + +/** + * Sends an event over a specific event/channel in pusher. + * + * @param {String} channelName + * @param {String} eventName + * @param {Object} payload + */ +function sendEvent(channelName, eventName, payload) { + socket.send_event(eventName, payload, channelName); +} + +/** + * Sends an event across multiple pieces over a channel. + * + * @param {String} channelName + * @param {String} eventName + * @param {Object} payload + */ +function sendChunkedEvent(channelName, eventName, payload) { + const chunkSize = 9000; + const payloadString = JSON.stringify(payload); + const msgId = Math.random().toString(); + for (let i = 0; i * chunkSize < payloadString.length; i++) { + socket.send_event(eventName, { + id: msgId, + index: i, + chunk: payloadString.substr(i * chunkSize, chunkSize), + final: chunkSize * (i + 1) >= payloadString.length + }, channelName); + } +} + +export { + init, + subscribe, + unsubscribe, + getChannelMembersAsync, + getChannel, + isSubscribed, + isAlreadySubscribing, + sendEvent, + sendChunkedEvent, +}; diff --git a/src/page/HomePage/HomePage.js b/src/page/HomePage/HomePage.js index 1e6ba115569ee..d1032062898e2 100644 --- a/src/page/HomePage/HomePage.js +++ b/src/page/HomePage/HomePage.js @@ -9,22 +9,43 @@ import styles from '../../style/StyleSheet'; import Header from './HeaderView'; import Sidebar from './SidebarView'; import Main from './MainView'; +import * as Store from '../../store/Store'; +import STOREKEYS from '../../store/STOREKEYS'; +import {initPusher} from '../../store/actions/ReportActions'; +import * as pusher from '../../lib/Pusher/pusher'; -const App = () => ( - <> - - - - -
- - -
+export default class App extends React.Component { + componentDidMount() { + Store.get(STOREKEYS.SESSION, 'authToken').then((authToken) => { + if (authToken) { + // Initialize the pusher connection + pusher.init(null, { + authToken, + }); + + // Setup the report action handler to subscribe to pusher + initPusher(); + } + }); + } + + render() { + return ( + <> + + + + +
+ + +
+ + - - - - -); + + + ); + } +} App.displayName = 'App'; -export default App; diff --git a/src/store/actions/PersonalDetailsActions.js b/src/store/actions/PersonalDetailsActions.js index e0d72a94b77fc..482be1f42b0e2 100644 --- a/src/store/actions/PersonalDetailsActions.js +++ b/src/store/actions/PersonalDetailsActions.js @@ -32,6 +32,10 @@ function fetch() { let currentLogin; const requestPromise = Store.get(STOREKEYS.SESSION, 'email') .then((login) => { + if (!login) { + throw Error('No login'); + } + currentLogin = login; return request('Get', { returnValueList: 'personalDetailsList', @@ -59,11 +63,19 @@ function fetch() { } }; }, {}); - const myPersonalDetails = allPersonalDetails[currentLogin]; + const myPersonalDetails = allPersonalDetails[currentLogin] || {}; return Store.multiSet({ [STOREKEYS.PERSONAL_DETAILS]: allPersonalDetails, [STOREKEYS.MY_PERSONAL_DETAILS]: myPersonalDetails, }); + }) + .catch((error) => { + if (error.message === 'No login') { + 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 diff --git a/src/store/actions/ReportActions.js b/src/store/actions/ReportActions.js index 8cde6d2632970..7de76b682e3a4 100644 --- a/src/store/actions/ReportActions.js +++ b/src/store/actions/ReportActions.js @@ -1,4 +1,4 @@ -/* globals moment */ +import moment from 'moment'; import _ from 'underscore'; import * as Store from '../Store'; import {request, delayedWrite} from '../../lib/Network'; @@ -6,9 +6,7 @@ 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'; +import * as pusher from '../../lib/Pusher/pusher'; /** * Sorts the report actions so that the newest actions are at the bottom @@ -27,34 +25,32 @@ function sortReportActions(firstReport, secondReport) { * @param {string} reportID * @param {object} reportAction */ -// function updateReportWithNewAction(reportID, reportAction) { -// // Get the comments for this report, and add the comment (being sure to sort and filter properly) -// let foundExistingReportHistoryItem = false; -// -// Store.get(`${STOREKEYS.REPORT}_${reportID}_history`) -// -// // Use a reducer to replace an existing report history item if there is one -// .then(reportHistory => _.map(reportHistory, (reportHistoryItem) => { -// // If there is an existing reportHistoryItem, replace it -// if (reportHistoryItem.sequenceNumber === reportAction.sequenceNumber) { -// foundExistingReportHistoryItem = true; -// return reportAction; -// } -// return reportHistoryItem; -// })) -// .then((reportHistory) => { -// // If there was no existing history item, -// // add it to the report history and mark the report for having unread -// // items -// if (!foundExistingReportHistoryItem) { -// reportHistory.push(reportAction); -// Store.merge(`${STOREKEYS.REPORT}_${reportID}`, {hasUnread: true}); -// } -// return reportHistory; -// }) -// .then(reportHistory => Store.set(`${STOREKEYS.REPORT}_${reportID}_history`, -// reportHistory.sort(sortReportActions))); -// } +function updateReportWithNewAction(reportID, reportAction) { + // Get the comments for this report, and add the comment (being sure to sort and filter properly) + let foundExistingReportHistoryItem = false; + + Store.get(`${STOREKEYS.REPORT}_${reportID}_history`) + + // Use a reducer to replace an existing report history item if there is one + .then(reportHistory => _.map(reportHistory, (reportHistoryItem) => { + // If there is an existing reportHistoryItem, replace it + if (reportHistoryItem.sequenceNumber === reportAction.sequenceNumber) { + foundExistingReportHistoryItem = true; + return reportAction; + } + return reportHistoryItem; + })) + .then((reportHistory) => { + // If there was no existing history item, add it to the report history and mark the report for having unread + // items + if (!foundExistingReportHistoryItem) { + reportHistory.push(reportAction); + Store.merge(`${STOREKEYS.REPORT}_${reportID}`, {hasUnread: true}); + } + return reportHistory; + }) + .then(reportHistory => Store.set(`${STOREKEYS.REPORT}_${reportID}_history`, reportHistory.sort(sortReportActions))); +} /** * Checks the report to see if there are any unread history items @@ -87,12 +83,9 @@ function hasUnreadHistoryItems(accountID, report) { */ function initPusher() { return Store.get(STOREKEYS.SESSION, 'accountID') - .then(() => { - // @TODO: need to implement pusher - // return pusher.subscribe(`private-user-accountID-${accountID}`, 'reportComment', (pushJSON) => { - // updateReportWithNewAction(pushJSON.reportID, pushJSON.reportAction); - // }); - }); + .then(accountID => pusher.subscribe(`private-user-accountID-${accountID}`, 'reportComment', (pushJSON) => { + updateReportWithNewAction(pushJSON.reportID, pushJSON.reportAction); + })); } /** @@ -137,7 +130,8 @@ 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.reports))) + .catch((error) => { console.log('Error fetching report actions', error); }); } return request('Get', { @@ -151,7 +145,8 @@ function fetchAll() { _.each(data.reportListBeta, report => fetch(report.reportID)); return data; }) - .then(data => Store.set(STOREKEYS.REPORTS, _.values(data.reportListBeta))); + .then(data => Store.set(STOREKEYS.REPORTS, _.values(data.reportListBeta))) + .catch((error) => { console.log('Error fetching report actions', error); }); } /** diff --git a/src/store/actions/SessionActions.js b/src/store/actions/SessionActions.js index ce7eb35dbf7b6..65702e78a76cd 100644 --- a/src/store/actions/SessionActions.js +++ b/src/store/actions/SessionActions.js @@ -148,7 +148,7 @@ function verifyAuthToken() { } return request('Get', {returnValueList: 'account'}).then((data) => { - if (data.jsonCode === 200) { + if (data && data.jsonCode === 200) { return Store.merge(STOREKEYS.SESSION, data); } diff --git a/src/style/StyleSheet.js b/src/style/StyleSheet.js index e0ce99b4f6d78..9eb2708273565 100644 --- a/src/style/StyleSheet.js +++ b/src/style/StyleSheet.js @@ -89,10 +89,9 @@ const styles = { flexGrow: 1, }, historyItemAvatar: { - borderRadius: '50%', + borderRadius: 20, height: 40, width: 40, - lineHeight: 40, }, historyItemHeaderTimestamp: { color: '#7d8b8f',