diff --git a/src/libs/NumberUtils.js b/src/libs/NumberUtils.js index a63396d0569c..f2117a36980f 100644 --- a/src/libs/NumberUtils.js +++ b/src/libs/NumberUtils.js @@ -38,20 +38,20 @@ function rand64() { /** * @returns {Number} */ -function generateReportActionSequenceNumber() { +function generateReportActionClientID() { // Generate a clientID so we can save the optimistic action to storage with the clientID as key. Later, we will // remove the optimistic action when we add the real action created in the server. We do this because it's not // safe to assume that this will use the very next sequenceNumber. An action created by another can overwrite that // sequenceNumber if it is created before this one. We use a combination of current epoch timestamp (milliseconds) - // and a random number so that the probability of someone else having the same optimisticReportActionID is + // and a random number so that the probability of someone else having the same clientID is // extremely low even if they left the comment at the same moment as another user on the same report. The random // number is 3 digits because if we go any higher JS will convert the digits after the 16th position to 0's in - // optimisticReportActionID. + // clientID. const randomNumber = Math.floor((Math.random() * (999 - 100)) + 100); return parseInt(`${Date.now()}${randomNumber}`, 10); } export { rand64, - generateReportActionSequenceNumber, + generateReportActionClientID, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 019d35bb58c7..210c992960fc 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -3,6 +3,7 @@ import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import Onyx from 'react-native-onyx'; import moment from 'moment'; +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; import * as Localize from './Localize'; @@ -46,10 +47,14 @@ Onyx.connect({ }, }); +let allPersonalDetails; let currentUserPersonalDetails; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS, - callback: val => currentUserPersonalDetails = lodashGet(val, currentUserEmail), + callback: (val) => { + currentUserPersonalDetails = lodashGet(val, currentUserEmail); + allPersonalDetails = val; + }, }); /** @@ -573,6 +578,63 @@ function hasReportNameError(report) { return !_.isEmpty(lodashGet(report, 'errorFields.reportName', {})); } +/** + * @param {Number} sequenceNumber sequenceNumber must be provided and it must be a number. It cannot and should not be a clientID, + * reportActionID, or anything else besides an estimate of what the next sequenceNumber will be for the + * optimistic report action. Until we deprecate sequenceNumbers please assume that all report actions + * have them and they should be numbers. + * @param {String} [text] + * @param {File} [file] + * @returns {Object} + */ +function buildOptimisticReportAction(sequenceNumber, text, file) { + // For comments shorter than 10k chars, convert the comment from MD into HTML because that's how it is stored in the database + // For longer comments, skip parsing and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!! + const parser = new ExpensiMark(); + const commentText = text.length < 10000 ? parser.replace(text) : text; + const isAttachment = _.isEmpty(text) && file !== undefined; + const attachmentInfo = isAttachment ? file : {}; + const htmlForNewComment = isAttachment ? 'Uploading Attachment...' : commentText; + + // Remove HTML from text when applying optimistic offline comment + const textForNewComment = isAttachment ? '[Attachment]' + : parser.htmlToText(htmlForNewComment); + + return { + commentText, + reportAction: { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + actorEmail: currentUserEmail, + actorAccountID: currentUserAccountID, + person: [ + { + style: 'strong', + text: lodashGet(allPersonalDetails, [currentUserEmail, 'displayName'], currentUserEmail), + type: 'TEXT', + }, + ], + automatic: false, + sequenceNumber, + clientID: NumberUtils.generateReportActionClientID(), + avatar: lodashGet(allPersonalDetails, [currentUserEmail, 'avatar'], getDefaultAvatar(currentUserEmail)), + timestamp: moment().unix(), + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + html: htmlForNewComment, + text: textForNewComment, + }, + ], + isFirstItem: false, + isAttachment, + attachmentInfo, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + shouldShow: true, + }, + }; +} + /* * Builds an optimistic IOU report with a randomly generated reportID */ @@ -599,6 +661,7 @@ function buildOptimisticIOUReport(ownerEmail, recipientEmail, total, chatReportI /** * Builds an optimistic IOU reportAction object * + * @param {Number} sequenceNumber - Caller is responsible for providing a best guess at what the next sequenceNumber will be. * @param {String} type - IOUReportAction type. Can be oneOf(create, decline, cancel, pay). * @param {Number} amount - IOU amount in cents. * @param {String} comment - User comment for the IOU. @@ -608,11 +671,10 @@ function buildOptimisticIOUReport(ownerEmail, recipientEmail, total, chatReportI * * @returns {Object} */ -function buildOptimisticIOUReportAction(type, amount, comment, paymentType = '', existingIOUTransactionID = '', existingIOUReportID = 0) { +function buildOptimisticIOUReportAction(sequenceNumber, type, amount, comment, paymentType = '', existingIOUTransactionID = '', existingIOUReportID = 0) { const currency = lodashGet(currentUserPersonalDetails, 'localCurrencyCode'); const IOUTransactionID = existingIOUTransactionID || NumberUtils.rand64(); const IOUReportID = existingIOUReportID || generateReportID(); - const sequenceNumber = NumberUtils.generateReportActionSequenceNumber(); const originalMessage = { amount, comment, @@ -637,10 +699,7 @@ function buildOptimisticIOUReportAction(type, amount, comment, paymentType = '', actorEmail: currentUserEmail, automatic: false, avatar: lodashGet(currentUserPersonalDetails, 'avatar', getDefaultAvatar(currentUserEmail)), - - // For now, the clientID and sequenceNumber are the same. - // We are changing that as we roll out the optimisticReportAction IDs and related refactors. - clientID: sequenceNumber, + clientID: NumberUtils.generateReportActionClientID(), isAttachment: false, originalMessage, person: [{ @@ -656,6 +715,132 @@ function buildOptimisticIOUReportAction(type, amount, comment, paymentType = '', }; } +/** + * Builds an optimistic chat report with a randomly generated reportID and as much information as we currently have + * + * @param {Array} participantList + * @param {String} reportName + * @param {String} chatType + * @param {String} policyID + * @param {String} ownerEmail + * @param {Boolean} isOwnPolicyExpenseChat + * @param {String} oldPolicyName + * @param {String} visibility + * @returns {Object} + */ +function buildOptimisticChatReport( + participantList, + reportName = 'Chat Report', + chatType = '', + policyID = CONST.POLICY.OWNER_EMAIL_FAKE, + ownerEmail = CONST.REPORT.OWNER_EMAIL_FAKE, + isOwnPolicyExpenseChat = false, + oldPolicyName = '', + visibility = undefined, +) { + return { + chatType, + hasOutstandingIOU: false, + isOwnPolicyExpenseChat, + isPinned: false, + lastActorEmail: '', + lastMessageHtml: '', + lastMessageText: null, + lastReadSequenceNumber: 0, + lastMessageTimestamp: 0, + lastVisitedTimestamp: 0, + maxSequenceNumber: 0, + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY, + oldPolicyName, + ownerEmail, + participants: participantList, + policyID, + reportID: generateReportID(), + reportName, + stateNum: 0, + statusNum: 0, + visibility, + }; +} + +/** + * Returns the necessary reportAction onyx data to indicate that the chat has been created optimistically + * @param {String} ownerEmail + * @returns {Object} + */ +function buildOptimisticCreatedReportAction(ownerEmail) { + return { + 0: { + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: ownerEmail === currentUserEmail ? 'You' : ownerEmail, + }, + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'normal', + text: ' created this report', + }, + ], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: lodashGet(allPersonalDetails, [currentUserEmail, 'displayName'], currentUserEmail), + }, + ], + automatic: false, + sequenceNumber: 0, + avatar: lodashGet(allPersonalDetails, [currentUserEmail, 'avatar'], getDefaultAvatar(currentUserEmail)), + timestamp: moment().unix(), + shouldShow: true, + }, + }; +} + +/** + * @param {String} policyID + * @param {String} policyName + * @returns {Object} + */ +function buildOptimisticWorkspaceChats(policyID, policyName) { + const announceChatData = buildOptimisticChatReport( + [currentUserEmail], + CONST.REPORT.WORKSPACE_CHAT_ROOMS.ANNOUNCE, + CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, + policyID, + null, + false, + policyName, + ); + const announceChatReportID = announceChatData.reportID; + const announceReportActionData = buildOptimisticCreatedReportAction(announceChatData.ownerEmail); + + const adminsChatData = buildOptimisticChatReport([currentUserEmail], CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS, CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, policyID, null, false, policyName); + const adminsChatReportID = adminsChatData.reportID; + const adminsReportActionData = buildOptimisticCreatedReportAction(adminsChatData.ownerEmail); + + const expenseChatData = buildOptimisticChatReport([currentUserEmail], '', CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, policyID, currentUserEmail, true, policyName); + const expenseChatReportID = expenseChatData.reportID; + const expenseReportActionData = buildOptimisticCreatedReportAction(expenseChatData.ownerEmail); + + return { + announceChatReportID, + announceChatData, + announceReportActionData, + adminsChatReportID, + adminsChatData, + adminsReportActionData, + expenseChatReportID, + expenseChatData, + expenseReportActionData, + }; +} + /** * @param {Object} report * @returns {Boolean} @@ -697,7 +882,11 @@ export { navigateToDetailsPage, generateReportID, hasReportNameError, + isUnread, + buildOptimisticWorkspaceChats, + buildOptimisticChatReport, + buildOptimisticCreatedReportAction, buildOptimisticIOUReport, buildOptimisticIOUReportAction, - isUnread, + buildOptimisticReportAction, }; diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index b5b873ca2907..1e9f618b3903 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -17,6 +17,7 @@ import * as OptionsListUtils from '../OptionsListUtils'; import * as Report from './Report'; import * as Pusher from '../Pusher/pusher'; import DateUtils from '../DateUtils'; +import * as ReportUtils from '../ReportUtils'; const allPolicies = {}; Onyx.connect({ @@ -814,7 +815,7 @@ function createWorkspace() { expenseChatReportID, expenseChatData, expenseReportActionData, - } = Report.buildOptimisticWorkspaceChats(policyID, workspaceName); + } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); API.write('CreateWorkspace', { policyID, diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 5e242bb395b3..6788e9cfe34c 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -25,7 +25,6 @@ import Growl from '../Growl'; import * as Localize from '../Localize'; import DateUtils from '../DateUtils'; import * as ReportActionsUtils from '../ReportActionsUtils'; -import * as NumberUtils from '../NumberUtils'; let currentUserEmail; let currentUserAccountID; @@ -48,12 +47,6 @@ Onyx.connect({ callback: val => lastViewedReportID = val ? Number(val) : null, }); -let personalDetails; -Onyx.connect({ - key: ONYXKEYS.PERSONAL_DETAILS, - callback: val => personalDetails = val, -}); - const allReports = {}; let conciergeChatReportID; const typingWatchTimers = {}; @@ -583,190 +576,6 @@ function fetchAllReports( }); } -/** - * Builds an optimistic chat report with a randomly generated reportID and as much information as we currently have - * - * @param {Array} participantList - * @param {String} reportName - * @param {String} chatType - * @param {String} policyID - * @param {String} ownerEmail - * @param {Boolean} isOwnPolicyExpenseChat - * @param {String} oldPolicyName - * @param {String} visibility - * @returns {Object} - */ -function buildOptimisticChatReport( - participantList, - reportName = 'Chat Report', - chatType = '', - policyID = CONST.POLICY.OWNER_EMAIL_FAKE, - ownerEmail = CONST.REPORT.OWNER_EMAIL_FAKE, - isOwnPolicyExpenseChat = false, - oldPolicyName = '', - visibility = undefined, -) { - return { - chatType, - hasOutstandingIOU: false, - isOwnPolicyExpenseChat, - isPinned: false, - lastActorEmail: '', - lastMessageHtml: '', - lastMessageText: null, - lastReadSequenceNumber: 0, - lastMessageTimestamp: 0, - lastVisitedTimestamp: 0, - maxSequenceNumber: 0, - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY, - oldPolicyName, - ownerEmail, - participants: participantList, - policyID, - reportID: ReportUtils.generateReportID(), - reportName, - stateNum: 0, - statusNum: 0, - visibility, - }; -} - -/** - * Returns the necessary reportAction onyx data to indicate that the chat has been created optimistically - * @param {String} ownerEmail - * @returns {Object} - */ -function buildOptimisticCreatedReportAction(ownerEmail) { - return { - 0: { - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - actorAccountID: currentUserAccountID, - message: [ - { - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - style: 'strong', - text: ownerEmail === currentUserEmail ? 'You' : ownerEmail, - }, - { - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - style: 'normal', - text: ' created this report', - }, - ], - person: [ - { - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - style: 'strong', - text: lodashGet(personalDetails, [currentUserEmail, 'displayName'], currentUserEmail), - }, - ], - automatic: false, - sequenceNumber: 0, - avatar: lodashGet(personalDetails, [currentUserEmail, 'avatar'], ReportUtils.getDefaultAvatar(currentUserEmail)), - timestamp: moment().unix(), - shouldShow: true, - }, - }; -} - -/** - * @param {String} policyID - * @param {String} policyName - * @returns {Object} - */ -function buildOptimisticWorkspaceChats(policyID, policyName) { - const announceChatData = buildOptimisticChatReport( - [currentUserEmail], - CONST.REPORT.WORKSPACE_CHAT_ROOMS.ANNOUNCE, - CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, - policyID, - null, - false, - policyName, - ); - const announceChatReportID = announceChatData.reportID; - const announceReportActionData = buildOptimisticCreatedReportAction(announceChatData.ownerEmail); - - const adminsChatData = buildOptimisticChatReport([currentUserEmail], CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS, CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, policyID, null, false, policyName); - const adminsChatReportID = adminsChatData.reportID; - const adminsReportActionData = buildOptimisticCreatedReportAction(adminsChatData.ownerEmail); - - const expenseChatData = buildOptimisticChatReport([currentUserEmail], '', CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, policyID, currentUserEmail, true, policyName); - const expenseChatReportID = expenseChatData.reportID; - const expenseReportActionData = buildOptimisticCreatedReportAction(expenseChatData.ownerEmail); - - return { - announceChatReportID, - announceChatData, - announceReportActionData, - adminsChatReportID, - adminsChatData, - adminsReportActionData, - expenseChatReportID, - expenseChatData, - expenseReportActionData, - }; -} - -/** - * @param {Number} reportID - * @param {String} [text] - * @param {File} [file] - * @returns {Object} - */ -function createOptimisticReportAction(reportID, text, file) { - // For comments shorter than 10k chars, convert the comment from MD into HTML because that's how it is stored in the database - // For longer comments, skip parsing and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!! - const parser = new ExpensiMark(); - const commentText = text.length < 10000 ? parser.replace(text) : text; - const isAttachment = _.isEmpty(text) && file !== undefined; - const attachmentInfo = isAttachment ? file : {}; - const htmlForNewComment = isAttachment ? 'Uploading Attachment...' : commentText; - - // Remove HTML from text when applying optimistic offline comment - const textForNewComment = isAttachment ? '[Attachment]' - : parser.htmlToText(htmlForNewComment); - - const optimisticReportActionSequenceNumber = NumberUtils.generateReportActionSequenceNumber(); - - return { - commentText, - reportAction: { - reportActionID: NumberUtils.rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, - actorEmail: currentUserEmail, - actorAccountID: currentUserAccountID, - person: [ - { - style: 'strong', - text: lodashGet(personalDetails, [currentUserEmail, 'displayName'], currentUserEmail), - type: 'TEXT', - }, - ], - automatic: false, - - // Use the client generated ID as a optimistic action ID so we can remove it later - sequenceNumber: optimisticReportActionSequenceNumber, - clientID: optimisticReportActionSequenceNumber, - avatar: lodashGet(personalDetails, [currentUserEmail, 'avatar'], ReportUtils.getDefaultAvatar(currentUserEmail)), - timestamp: moment().unix(), - message: [ - { - type: CONST.REPORT.MESSAGE.TYPE.COMMENT, - html: htmlForNewComment, - text: textForNewComment, - }, - ], - isFirstItem: false, - isAttachment, - attachmentInfo, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - shouldShow: true, - }, - }; -} - /** * Add up to two report actions to a report. This method can be called for the following situations: * @@ -784,38 +593,32 @@ function addActions(reportID, text = '', file) { let attachmentAction; let commandName = 'AddComment'; + const highestSequenceNumber = getMaxSequenceNumber(reportID); + if (text) { - const reportComment = createOptimisticReportAction(reportID, text); + const nextSequenceNumber = highestSequenceNumber + 1; + const reportComment = ReportUtils.buildOptimisticReportAction(nextSequenceNumber, text); reportCommentAction = reportComment.reportAction; reportCommentText = reportComment.commentText; } if (file) { + const nextSequenceNumber = (text && file) ? highestSequenceNumber + 2 : highestSequenceNumber + 1; + // When we are adding an attachment we will call AddAttachment. // It supports sending an attachment with an optional comment and AddComment supports adding a single text comment only. commandName = 'AddAttachment'; - const attachment = createOptimisticReportAction(reportID, '', file); + const attachment = ReportUtils.buildOptimisticReportAction(nextSequenceNumber, '', file); attachmentAction = attachment.reportAction; } // Always prefer the file as the last action over text const lastAction = attachmentAction || reportCommentAction; - // We need a newSequenceNumber that is n larger than the current depending on how many actions we are adding. - const highestSequenceNumber = getMaxSequenceNumber(reportID); + // Our report needs a new maxSequenceNumber that is n larger than the current depending on how many actions we are adding. const actionCount = text && file ? 2 : 1; const newSequenceNumber = highestSequenceNumber + actionCount; - // We're giving our best guess at what these sequenceNumbers are to enable marking as unread while offline. - if (text && file) { - reportCommentAction.sequenceNumber = highestSequenceNumber + 1; - attachmentAction.sequenceNumber = highestSequenceNumber + 2; - } else if (file) { - attachmentAction.sequenceNumber = highestSequenceNumber + 1; - } else { - reportCommentAction.sequenceNumber = highestSequenceNumber + 1; - } - // Update the report in Onyx to have the new sequence number const optimisticReport = { maxSequenceNumber: newSequenceNumber, @@ -1460,7 +1263,7 @@ function createPolicyRoom(policyID, reportName, visibility) { function addPolicyReport(policy, reportName, visibility) { // The participants include the current user (admin) and the employees. Participants must not be empty. const participants = [currentUserEmail, ...policy.employeeList]; - const policyReport = buildOptimisticChatReport( + const policyReport = ReportUtils.buildOptimisticChatReport( participants, reportName, CONST.REPORT.CHAT_TYPE.POLICY_ROOM, @@ -1489,7 +1292,7 @@ function addPolicyReport(policy, reportName, visibility) { { onyxMethod: CONST.ONYX.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyReport.reportID}`, - value: buildOptimisticCreatedReportAction(policyReport.ownerEmail), + value: ReportUtils.buildOptimisticCreatedReportAction(policyReport.ownerEmail), }, ]; const successData = [ @@ -1612,16 +1415,14 @@ function setIsComposerFullSize(reportID, isComposerFullSize) { */ function viewNewReportAction(reportID, action) { const isFromCurrentUser = action.actorAccountID === currentUserAccountID; - const lastReadSequenceNumber = getLastReadSequenceNumber(reportID); const updatedReportObject = {}; // When handling an action from the current user we can assume that their last read actionID has been updated in the server, // but not necessarily reflected locally so we will update the lastReadSequenceNumber to mark the report as read. + updatedReportObject.maxSequenceNumber = action.sequenceNumber; if (isFromCurrentUser) { updatedReportObject.lastVisitedTimestamp = Date.now(); - updatedReportObject.lastReadSequenceNumber = action.pendingAction ? lastReadSequenceNumber : action.sequenceNumber; - updatedReportObject.maxSequenceNumber = action.sequenceNumber; - } else { + updatedReportObject.lastReadSequenceNumber = action.sequenceNumber; updatedReportObject.maxSequenceNumber = action.sequenceNumber; } @@ -1753,9 +1554,6 @@ export { readOldestAction, openReport, openPaymentDetailsPage, - buildOptimisticWorkspaceChats, - buildOptimisticChatReport, - buildOptimisticCreatedReportAction, updatePolicyRoomName, clearPolicyRoomNameErrors, clearIOUError,