From 3e1e6c1d6127f7ac33273a54edef353eccea5456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 26 Jan 2026 12:51:19 -0800 Subject: [PATCH 01/13] POC of release 1 of Concierge follow ups sprint project --- src/libs/ReportActionsUtils.ts | 52 +++++++- src/libs/actions/Report.ts | 86 ++++++++++++- .../home/report/PureReportActionItem.tsx | 19 ++- tests/actions/ReportTest.ts | 94 +++++++++++++++ tests/unit/ReportActionsUtilsTest.ts | 113 ++++++++++++++++++ 5 files changed, 358 insertions(+), 6 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 5b3007779a380..0ecd7bc990f1d 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -65,6 +65,10 @@ type MemberChangeMessageRoomReferenceElement = { type MemberChangeMessageElement = MessageTextElement | MemberChangeMessageUserMentionElement | MemberChangeMessageRoomReferenceElement; +type Followup = { + text: string; +}; + let allReportActions: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, @@ -1722,6 +1726,48 @@ function getMemberChangeMessageElements( ]; } +const followUpListRegex = /]*)?>[\s\S]*?<\/followup-list>/i; +const followUpSelectedListRegex = /]*\sselected[\s>]/i; +const followUpTextRegex = /([^<]*)<\/followup-text><\/followup>/gi; +/** + * Parses followup data from a HTML element. + * @param html - The HTML string to parse for elements + * @returns null if no exists, empty array [] if the followup-list has the 'selected' attribute (resolved state), or an array of followup objects if unresolved + */ +function parseFollowupsFromHtml(html: string): Followup[] | null { + const followupListMatch = html.match(followUpListRegex); + if (!followupListMatch) { + return null; + } + + // There is only one follow up list + const followupListHtml = followupListMatch[0]; + const hasSelectedAttribute = followUpSelectedListRegex.test(followupListHtml); + if (hasSelectedAttribute) { + return []; + } + + const followups: Followup[] = []; + let match = followUpTextRegex.exec(followupListHtml); + while (match !== null) { + followups.push({text: match[1]}); + match = followUpTextRegex.exec(followupListHtml); + } + return followups; +} + +/** + * Used for generating preview text in LHN and other places where followups should not be displayed. + * @param html message.html from the report COMMENT actions + * @returns html with the element and its contents stripped out or undefined if html is undefined + */ +function stripFollowupListFromHtml(html?: string): string | undefined { + if (!html) { + return; + } + return html.replace(followUpListRegex, '').trim(); +} + function getReportActionHtml(reportAction: PartialReportAction): string { return getReportActionMessage(reportAction)?.html ?? ''; } @@ -1730,7 +1776,7 @@ function getReportActionText(reportAction: PartialReportAction): string { const message = getReportActionMessage(reportAction); // Sometime html can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const text = (message?.html || message?.text) ?? ''; + const text = stripFollowupListFromHtml(message?.html) || (message?.text ?? ''); return text ? Parser.htmlToText(text) : ''; } @@ -3908,6 +3954,8 @@ export { withDEWRoutedActionsArray, withDEWRoutedActionsObject, getReportActionActorAccountID, + parseFollowupsFromHtml, + stripFollowupListFromHtml, }; -export type {LastVisibleMessage}; +export type {LastVisibleMessage, Followup}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 27300501d23da..5a9520731eaa8 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -103,6 +103,7 @@ import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import Pusher from '@libs/Pusher'; import type {UserIsLeavingRoomEvent, UserIsTypingEvent} from '@libs/Pusher/types'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {getLastVisibleAction} from '@libs/ReportActionsUtils'; import {updateTitleFieldToMatchPolicy} from '@libs/ReportTitleUtils'; import type {Ancestor, OptimisticAddCommentReportAction, OptimisticChatReport, SelfDMParameters} from '@libs/ReportUtils'; import { @@ -532,6 +533,37 @@ function notifyNewAction(reportID: string | undefined, accountID: number | undef actionSubscriber.callback(isFromCurrentUser, reportAction); } +/** + * Builds an optimistic report action with resolved followups (followup-list marked as selected). + * Returns null if the action doesn't have unresolved followups. + * @param reportAction - The report action to check and potentially resolve + * @returns The updated report action with resolved followups, or null if no followups to resolve + */ +function buildOptimisticResolvedFollowups(reportAction: OnyxEntry): ReportAction | null { + if (!reportAction) { + return null; + } + + const message = ReportActionsUtils.getReportActionMessage(reportAction); + const html = message?.html ?? ''; + const followups = ReportActionsUtils.parseFollowupsFromHtml(html); + + if (!message || !followups || followups.length === 0) { + return null; + } + + const updatedHtml = html.replace(/]*)?>/, ''); + return { + ...reportAction, + message: [ + { + ...message, + html: updatedHtml, + }, + ], + }; +} + /** * Add up to two report actions to a report. This method can be called for the following situations: * @@ -597,7 +629,7 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors } // Optimistically add the new actions to the store before waiting to save them to the server - const optimisticReportActions: OnyxCollection = {}; + const optimisticReportActions: OnyxCollection = {}; // Only add the reportCommentAction when there is no file attachment. If there is both a file attachment and text, that will all be contained in the attachmentAction. if (text && reportCommentAction?.reportActionID && !file) { @@ -606,6 +638,17 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors if (file && attachmentAction?.reportActionID) { optimisticReportActions[attachmentAction.reportActionID] = attachmentAction; } + + // Check if the last visible action is from Concierge with unresolved followups + // If so, optimistically resolve them by adding the updated action to optimisticReportActions + const lastVisibleAction = getLastVisibleAction(reportID); + if (lastVisibleAction?.actorAccountID === CONST.ACCOUNT_ID.CONCIERGE) { + const resolvedAction = buildOptimisticResolvedFollowups(lastVisibleAction); + if (resolvedAction) { + optimisticReportActions[lastVisibleAction.reportActionID] = resolvedAction; + } + } + const parameters: AddCommentOrAttachmentParams = { reportID, reportActionID: file ? attachmentAction?.reportActionID : reportCommentAction?.reportActionID, @@ -761,6 +804,7 @@ function addComment(report: OnyxEntry, notifyReportID: string, ancestors if (shouldPlaySound) { playSound(SOUNDS.DONE); } + addActions(report, notifyReportID, ancestors, timezoneParam, text, undefined, isInSidePanel); } @@ -6476,6 +6520,44 @@ function resolveConciergeDescriptionOptions( resolveConciergeOptions(report, notifyReportID, reportActionID, selectedDescription, timezoneParam, 'selectedDescription', ancestors); } +/** + * Resolves a suggested followup by posting the selected question as a comment + * and optimistically updating the HTML to mark the followup-list as resolved. + * @param report - The report where the action exists + * @param notifyReportID - The report ID to notify for new actions + * @param reportAction - The report action containing the followup-list + * @param selectedFollowup - The followup question selected by the user + * @param timezoneParam - The user's timezone + * @param ancestors - Array of ancestor reports for proper threading + */ +function resolveSuggestedFollowup( + report: OnyxEntry, + notifyReportID: string | undefined, + reportAction: OnyxEntry, + selectedFollowup: string, + timezoneParam: Timezone, + ancestors: Ancestor[] = [], +) { + if (!report?.reportID || !reportAction?.reportActionID) { + return; + } + + const reportID = report.reportID; + const resolvedAction = buildOptimisticResolvedFollowups(reportAction); + + if (!resolvedAction) { + return; + } + + // Optimistically update the HTML to mark followup-list as resolved + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [reportAction.reportActionID]: resolvedAction, + } as Partial); + + // Post the selected followup question as a comment + addComment(report, notifyReportID ?? reportID, ancestors, selectedFollowup, timezoneParam); +} + /** * Enhances existing transaction thread reports with additional context for navigation * @@ -6515,6 +6597,7 @@ export { broadcastUserIsLeavingRoom, broadcastUserIsTyping, buildOptimisticChangePolicyData, + buildOptimisticResolvedFollowups, clearAddRoomMemberError, clearAvatarErrors, clearDeleteTransactionNavigateBackUrl, @@ -6575,6 +6658,7 @@ export { resolveActionableReportMentionWhisper, resolveConciergeCategoryOptions, resolveConciergeDescriptionOptions, + resolveSuggestedFollowup, savePrivateNotesDraft, saveReportActionDraft, saveReportDraftComment, diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index c9e6e1f3638d7..89b2e9488311f 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -161,6 +161,7 @@ import { isTripPreview, isUnapprovedAction, isWhisperActionTargetedToOthers, + parseFollowupsFromHtml, useTableReportViewActionRenderConditionals, } from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; @@ -200,6 +201,7 @@ import { resolveActionableMentionConfirmWhisper, resolveConciergeCategoryOptions, resolveConciergeDescriptionOptions, + resolveSuggestedFollowup, } from '@userActions/Report'; import type {IgnoreDirection} from '@userActions/ReportActions'; import {isAnonymousUser, signOutAndRedirectToSignIn} from '@userActions/Session'; @@ -872,6 +874,20 @@ function PureReportActionItem({ }, })); } + const messageHtml = getReportActionMessage(action)?.html; + if (messageHtml && reportActionReport) { + const followups = parseFollowupsFromHtml(messageHtml); + if (followups && followups.length > 0) { + return followups.map((followup) => ({ + text: followup.text, + shouldUseLocalization: false, + key: `${action.reportActionID}-followup-${followup.text}`, + onPress: () => { + resolveSuggestedFollowup(reportActionReport, reportID, action, followup.text, personalDetail.timezone ?? CONST.DEFAULT_TIME_ZONE); + }, + })); + } + } if (!isActionableWhisper && !isActionableCardFraudAlert(action) && (!isActionableJoinRequest(action) || getOriginalMessage(action)?.choice !== ('' as JoinWorkspaceResolution))) { return []; @@ -1553,7 +1569,6 @@ function PureReportActionItem({ {actionableItemButtons.length > 0 && ( )} @@ -1618,7 +1633,6 @@ function PureReportActionItem({ {actionableItemButtons.length > 0 && ( )} @@ -1678,7 +1692,6 @@ function PureReportActionItem({ ? 'vertical' : 'horizontal' } - shouldUseLocalization={!isConciergeOptions} primaryTextNumberOfLines={isConciergeOptions ? 2 : 1} textStyles={isConciergeOptions ? styles.textAlignLeft : undefined} /> diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 3f7d5bf58faac..c9fd2f3df9e7f 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -26,6 +26,7 @@ import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; +import type {Message} from '@src/types/onyx/ReportAction'; import createCollection from '../utils/collections/createCollection'; import createRandomPolicy from '../utils/collections/policies'; import createRandomReportAction from '../utils/collections/reportActions'; @@ -3406,4 +3407,97 @@ describe('actions/Report', () => { expect(mockNavigation.navigate).toHaveBeenCalledWith(expect.stringContaining(providedConciergeReportID), undefined); }); }); + + describe('buildOptimisticResolvedFollowups', () => { + it('should return null when reportAction is undefined', () => { + const result = Report.buildOptimisticResolvedFollowups(undefined); + expect(result).toBeNull(); + }); + + it('should return null when reportAction has no followup-list', () => { + const reportAction = { + reportActionID: '123', + message: [{html: '

Hello world

', text: 'Hello world', type: CONST.REPORT.MESSAGE.TYPE.COMMENT}], + } as OnyxTypes.ReportAction; + + const result = Report.buildOptimisticResolvedFollowups(reportAction); + expect(result).toBeNull(); + }); + + it('should return null when followup-list is already resolved (has selected attribute)', () => { + const reportAction = { + reportActionID: '123', + message: [ + { + html: '

Message

Question?', + text: 'Message', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + } as OnyxTypes.ReportAction; + + const result = Report.buildOptimisticResolvedFollowups(reportAction); + expect(result).toBeNull(); + }); + + it('should return updated action with resolved followup-list when unresolved followups exist', () => { + const reportAction = { + reportActionID: '123', + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + message: [ + { + html: '

Here is some help

How do I set up QuickBooks?', + text: 'Here is some help', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + } as OnyxTypes.ReportAction; + + const result = Report.buildOptimisticResolvedFollowups(reportAction); + + expect(result).not.toBeNull(); + + expect(result?.reportActionID).toBe('123'); + expect((result?.message as Array).at(0)?.html).toContain(''); + expect((result?.message as Array).at(0)?.html).not.toMatch(//); + }); + + it('should handle followup-list with attributes before adding selected', () => { + const reportAction = { + reportActionID: '456', + message: [ + { + html: '

Help

Question 1Question 2', + text: 'Help', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + } as OnyxTypes.ReportAction; + + const result = Report.buildOptimisticResolvedFollowups(reportAction); + + expect(result).not.toBeNull(); + expect((result?.message as Message[]).at(0)?.html).toContain(''); + }); + + it('should preserve other message properties when resolving followups', () => { + const reportAction = { + reportActionID: '789', + message: [ + { + html: '

Help

Question', + text: 'Help', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + isEdited: true, + }, + ], + } as OnyxTypes.ReportAction; + + const result = Report.buildOptimisticResolvedFollowups(reportAction); + + expect(result).not.toBeNull(); + expect((result?.message as Array).at(0)?.text).toBe('Help'); + expect((result?.message as Array).at(0)?.type).toBe(CONST.REPORT.MESSAGE.TYPE.COMMENT); + }); + }); }); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index ce88db33d2235..359e1ba58fc83 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -25,6 +25,8 @@ import { getSendMoneyFlowAction, getUpdateACHAccountMessage, isIOUActionMatchingTransactionList, + parseFollowupsFromHtml, + stripFollowupListFromHtml, } from '../../src/libs/ReportActionsUtils'; import {buildOptimisticCreatedReportForUnapprovedAction} from '../../src/libs/ReportUtils'; import ONYXKEYS from '../../src/ONYXKEYS'; @@ -3011,4 +3013,115 @@ describe('ReportActionsUtils', () => { expect(result).toBe('set the invoice company website to "https://newwebsite.com"'); }); }); + + describe('parseFollowupsFromHtml', () => { + it('should return null when no followup-list exists', () => { + const html = '

Hello world

'; + expect(parseFollowupsFromHtml(html)).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(parseFollowupsFromHtml('')).toBeNull(); + }); + + it('should return empty array when followup-list has selected attribute', () => { + const html = `

Some message

+ + How do I set up QuickBooks? +`; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + + it('should return empty array when followup-list has selected attribute with other attributes', () => { + const html = ` + Question 1 +`; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + + it('should parse single followup from unresolved list', () => { + const html = `

Hello

+ + How do I set up QuickBooks? +`; + expect(parseFollowupsFromHtml(html)).toEqual([{text: 'How do I set up QuickBooks?'}]); + }); + + it('should parse multiple followups from unresolved list', () => { + const html = ` + How do I set up QuickBooks? + What is the Expensify Card cashback? +`; + expect(parseFollowupsFromHtml(html)).toEqual([{text: 'How do I set up QuickBooks?'}, {text: 'What is the Expensify Card cashback?'}]); + }); + + it('should handle followup-list with whitespace attributes', () => { + const html = ` + Question +`; + expect(parseFollowupsFromHtml(html)).toEqual([{text: 'Question'}]); + }); + + it('should return empty array for followup-list with selected but no followups', () => { + const html = ''; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + + it('should return empty array for unresolved followup-list with no followups', () => { + const html = ''; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + }); + + describe('stripFollowupListFromHtml', () => { + it('should return original string when no followup-list exists', () => { + const html = '

Hello world

'; + expect(stripFollowupListFromHtml(html)).toBe('

Hello world

'); + }); + + it('should return empty string for empty input', () => { + expect(stripFollowupListFromHtml('')).toBe(''); + }); + + it('should strip followup-list and trim result', () => { + const html = `

Some message

+ + How do I set up QuickBooks? +`; + expect(stripFollowupListFromHtml(html)).toBe('

Some message

'); + }); + + it('should strip resolved followup-list with selected attribute', () => { + const html = `

Answer to your question

+ + Old question +`; + expect(stripFollowupListFromHtml(html)).toBe('

Answer to your question

'); + }); + + it('should strip followup-list with multiple followups', () => { + const html = `

Hello

+ + Question 1 + Question 2 +`; + expect(stripFollowupListFromHtml(html)).toBe('

Hello

'); + }); + + it('should handle content before and after followup-list', () => { + const html = `

Before

+ + Question + +

After

`; + expect(stripFollowupListFromHtml(html)).toBe(`

Before

+ +

After

`); + }); + + it('should strip empty followup-list', () => { + const html = '

Message

'; + expect(stripFollowupListFromHtml(html)).toBe('

Message

'); + }); + }); }); From 82921523ec519e68c81cd9033fd85678aa73cc4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 26 Jan 2026 13:18:25 -0800 Subject: [PATCH 02/13] fix lint --- src/libs/actions/Report.ts | 1 - tests/actions/ReportTest.ts | 8 ++++---- tests/unit/ReportActionsUtilsTest.ts | 17 ++--------------- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 5a9520731eaa8..6bd40924fdc8e 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -705,7 +705,6 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors }; const {lastMessageText = ''} = ReportActionsUtils.getLastVisibleMessage(reportID); if (lastMessageText) { - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID); const lastVisibleActionCreated = lastVisibleAction?.created; const lastActorAccountID = lastVisibleAction?.actorAccountID; failureReport = { diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index c9fd2f3df9e7f..f16d85ae7be64 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -3458,8 +3458,8 @@ describe('actions/Report', () => { expect(result).not.toBeNull(); expect(result?.reportActionID).toBe('123'); - expect((result?.message as Array).at(0)?.html).toContain(''); - expect((result?.message as Array).at(0)?.html).not.toMatch(//); + expect((result?.message as Message[]).at(0)?.html).toContain(''); + expect((result?.message as Message[]).at(0)?.html).not.toMatch(//); }); it('should handle followup-list with attributes before adding selected', () => { @@ -3496,8 +3496,8 @@ describe('actions/Report', () => { const result = Report.buildOptimisticResolvedFollowups(reportAction); expect(result).not.toBeNull(); - expect((result?.message as Array).at(0)?.text).toBe('Help'); - expect((result?.message as Array).at(0)?.type).toBe(CONST.REPORT.MESSAGE.TYPE.COMMENT); + expect((result?.message as Message[]).at(0)?.text).toBe('Help'); + expect((result?.message as Message[]).at(0)?.type).toBe(CONST.REPORT.MESSAGE.TYPE.COMMENT); }); }); }); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 359e1ba58fc83..054690c246ecc 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -3079,8 +3079,8 @@ describe('ReportActionsUtils', () => { expect(stripFollowupListFromHtml(html)).toBe('

Hello world

'); }); - it('should return empty string for empty input', () => { - expect(stripFollowupListFromHtml('')).toBe(''); + it('should return undefined for empty input', () => { + expect(stripFollowupListFromHtml('')).not.toBeDefined(); }); it('should strip followup-list and trim result', () => { @@ -3099,15 +3099,6 @@ describe('ReportActionsUtils', () => { expect(stripFollowupListFromHtml(html)).toBe('

Answer to your question

'); }); - it('should strip followup-list with multiple followups', () => { - const html = `

Hello

- - Question 1 - Question 2 -`; - expect(stripFollowupListFromHtml(html)).toBe('

Hello

'); - }); - it('should handle content before and after followup-list', () => { const html = `

Before

@@ -3119,9 +3110,5 @@ describe('ReportActionsUtils', () => {

After

`); }); - it('should strip empty followup-list', () => { - const html = '

Message

'; - expect(stripFollowupListFromHtml(html)).toBe('

Message

'); - }); }); }); From 3949e350b9649e17f0c4905fb572b79ce80900f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 26 Jan 2026 15:20:11 -0800 Subject: [PATCH 03/13] add test for resolveSuggestedFollowup, run prettier --- tests/actions/ReportTest.ts | 68 ++++++++++++++++++++++++++++ tests/unit/ReportActionsUtilsTest.ts | 1 - 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index f16d85ae7be64..9fbbb2d85cf64 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -3500,4 +3500,72 @@ describe('actions/Report', () => { expect((result?.message as Message[]).at(0)?.type).toBe(CONST.REPORT.MESSAGE.TYPE.COMMENT); }); }); + + describe('resolveSuggestedFollowup', () => { + const REPORT_ID = '12345'; + const REPORT_ACTION_ID = '67890'; + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + } as OnyxTypes.Report; + + it('should do nothing when reportAction has no unresolved followups', async () => { + const htmlMessage = '

Just a regular message

'; + const reportAction = { + reportActionID: REPORT_ACTION_ID, + message: [ + { + html: htmlMessage, + text: 'Just a regular message', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + } as OnyxTypes.ReportAction; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + }); + await waitForBatchedUpdates(); + + Report.resolveSuggestedFollowup(report, undefined, reportAction, 'test question', CONST.DEFAULT_TIME_ZONE); + await waitForBatchedUpdates(); + + // The report action should remain unchanged (no followup-list to resolve) + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect((reportActions?.[REPORT_ACTION_ID]?.message as Message[])?.at(0)?.html).toBe(htmlMessage); + }); + + it('should optimistically resolve followups and post comment when unresolved followups exist', async () => { + const reportAction = { + reportActionID: REPORT_ACTION_ID, + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + message: [ + { + html: '

Here is help

How do I set up QuickBooks?', + text: 'Here is help', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + } as OnyxTypes.ReportAction; + + // Set up initial Onyx state + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + }); + await waitForBatchedUpdates(); + + Report.resolveSuggestedFollowup(report, undefined, reportAction, 'How do I set up QuickBooks?', CONST.DEFAULT_TIME_ZONE); + await waitForBatchedUpdates(); + + // Verify the followup-list was marked as selected + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + const updatedHtml = (reportActions?.[REPORT_ACTION_ID]?.message as Message[])?.at(0)?.html; + expect(updatedHtml).toContain(''); + + // Verify addComment was called (which triggers ADD_COMMENT API call) + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + }); + }); }); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 054690c246ecc..a88587bfef749 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -3109,6 +3109,5 @@ describe('ReportActionsUtils', () => {

After

`); }); - }); }); From 1b8ba3bdae902c8fe1ce9abbea88256061a4cfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 26 Jan 2026 15:27:02 -0800 Subject: [PATCH 04/13] add tests for pure report action item --- tests/ui/PureReportActionItemTest.tsx | 135 ++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/tests/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx index 6174c013fe8f9..36c2f5ec9ff72 100644 --- a/tests/ui/PureReportActionItemTest.tsx +++ b/tests/ui/PureReportActionItemTest.tsx @@ -356,4 +356,139 @@ describe('PureReportActionItem', () => { expect(screen.queryByText(translateLocal('iou.queuedToSubmitViaDEW'))).not.toBeOnTheScreen(); }); }); + + describe('Followup list buttons', () => { + it('should display followup buttons when message contains unresolved followup-list', async () => { + const followupQuestion1 = 'How do I set up QuickBooks?'; + const followupQuestion2 = 'What is the Expensify Card cashback?'; + + const action = { + reportActionID: '12345', + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + created: '2025-07-12 09:03:17.653', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + automatic: false, + shouldShow: true, + avatar: '', + person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}], + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + html: `

Here is some helpful information.

+ + ${followupQuestion1} + ${followupQuestion2} +`, + text: 'Here is some helpful information.', + }, + ], + originalMessage: {}, + } as ReportAction; + + const report = { + reportID: 'testReport', + type: CONST.REPORT.TYPE.CHAT, + }; + + render( + + + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Verify followup buttons are displayed + expect(screen.getByText(followupQuestion1)).toBeOnTheScreen(); + expect(screen.getByText(followupQuestion2)).toBeOnTheScreen(); + }); + + it('should not display followup buttons when followup-list is resolved (has selected attribute)', async () => { + const followupQuestion = 'How do I set up QuickBooks?'; + + const action = { + reportActionID: '12345', + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + created: '2025-07-12 09:03:17.653', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + automatic: false, + shouldShow: true, + avatar: '', + person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}], + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + html: `

Here is some helpful information.

+ + ${followupQuestion} +`, + text: 'Here is some helpful information.', + }, + ], + originalMessage: {}, + } as ReportAction; + + const report = { + reportID: 'testReport', + type: CONST.REPORT.TYPE.CHAT, + }; + + render( + + + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Verify followup buttons are NOT displayed (resolved state) + expect(screen.queryByText(followupQuestion)).not.toBeOnTheScreen(); + }); + }); }); From c517fe8d2a0ee32bf8cfa754dec03e06b8737ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 26 Jan 2026 15:48:43 -0800 Subject: [PATCH 05/13] make buttons display vertical --- src/libs/ReportActionsUtils.ts | 24 ++++++++ .../home/report/PureReportActionItem.tsx | 4 +- tests/unit/ReportActionsUtilsTest.ts | 58 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 6fb46c761696c..86a1c0c2c4029 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -959,6 +959,29 @@ function isActionableMentionWhisper(reportAction: OnyxInputOrEntry return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_MENTION_WHISPER); } +/** + * Checks if a report action contains actionable (unresolved) followup suggestions. + * @param reportAction - The report action to check + * @returns true if the action is an ADD_COMMENT with unresolved followups, false otherwise + */ +function containsActionableFollowUps(reportAction: OnyxInputOrEntry): boolean { + const isActionAComment = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + if (!isActionAComment) { + return false; + } + const messageHtml = getReportActionMessage(reportAction)?.html; + if (!messageHtml) { + return false; + } + const followups = parseFollowupsFromHtml(messageHtml); + + if (!followups || followups?.length === 0) { + return false; + } + + return true; +} + function isActionableMentionInviteToSubmitExpenseConfirmWhisper( reportAction: OnyxEntry, ): reportAction is ReportAction { @@ -3983,6 +4006,7 @@ export { getReportActionActorAccountID, parseFollowupsFromHtml, stripFollowupListFromHtml, + containsActionableFollowUps, }; export type {LastVisibleMessage, Followup}; diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 89b2e9488311f..d6e9d8f1fe744 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -64,6 +64,7 @@ import Permissions from '@libs/Permissions'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getCleanedTagName, hasDynamicExternalWorkflow, isPolicyAdmin, isPolicyMember, isPolicyOwner} from '@libs/PolicyUtils'; import { + containsActionableFollowUps, extractLinksFromMessageHtml, getActionableCardFraudAlertMessage, getActionableMentionWhisperMessage, @@ -1688,7 +1689,8 @@ function PureReportActionItem({ isActionableTrackExpense(action) || isConciergeCategoryOptions(action) || isConciergeDescriptionOptions(action) || - isActionableMentionWhisper(action) + isActionableMentionWhisper(action) || + containsActionableFollowUps(action) ? 'vertical' : 'horizontal' } diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index a88587bfef749..24c8fa6feddce 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -11,6 +11,7 @@ import {chatReportR14932 as mockChatReport, iouReportR14932 as mockIOUReport} fr import CONST from '../../src/CONST'; import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils'; import { + containsActionableFollowUps, getCardIssuedMessage, getCompanyAddressUpdateMessage, getCreatedReportForUnapprovedTransactionsMessage, @@ -3110,4 +3111,61 @@ describe('ReportActionsUtils', () => {

After

`); }); }); + + describe('containsActionableFollowUps', () => { + it('should return false for null/undefined reportAction', () => { + expect(containsActionableFollowUps(null)).toBe(false); + expect(containsActionableFollowUps(undefined)).toBe(false); + }); + + it('should return false for non-ADD_COMMENT action types', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + message: [{html: 'Question', text: '', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return false for ADD_COMMENT without message html', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{text: 'Just text', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return false for ADD_COMMENT without followup-list', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: '

Regular message

', text: 'Regular message', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return false for ADD_COMMENT with resolved followup-list (selected attribute)', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: '

Message

Question', text: 'Message', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return true for ADD_COMMENT with unresolved followup-list', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: '

Message

Question', text: 'Message', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(true); + }); + }); }); From 4898de469ce01a44b688353b73854f10b5d453a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 27 Jan 2026 10:20:20 -0800 Subject: [PATCH 06/13] self review --- src/libs/ReportActionsUtils.ts | 2 +- src/libs/actions/Report.ts | 26 +++++++++++-------- .../home/report/PureReportActionItem.tsx | 6 ++++- tests/actions/ReportTest.ts | 20 -------------- 4 files changed, 21 insertions(+), 33 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 86a1c0c2c4029..318b15ee7686f 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1770,7 +1770,7 @@ function parseFollowupsFromHtml(html: string): Followup[] | null { return null; } - // There is only one follow up list + // There will be only one follow up list const followupListHtml = followupListMatch[0]; const hasSelectedAttribute = followUpSelectedListRegex.test(followupListHtml); if (hasSelectedAttribute) { diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 6bd40924fdc8e..972cab0bfe5f4 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -535,9 +535,8 @@ function notifyNewAction(reportID: string | undefined, accountID: number | undef /** * Builds an optimistic report action with resolved followups (followup-list marked as selected). - * Returns null if the action doesn't have unresolved followups. * @param reportAction - The report action to check and potentially resolve - * @returns The updated report action with resolved followups, or null if no followups to resolve + * @returns Null if the action doesn't have unresolved followups or the updated report action with resolved followups. */ function buildOptimisticResolvedFollowups(reportAction: OnyxEntry): ReportAction | null { if (!reportAction) { @@ -545,10 +544,13 @@ function buildOptimisticResolvedFollowups(reportAction: OnyxEntry) } const message = ReportActionsUtils.getReportActionMessage(reportAction); + if (!message) { + return null; + } const html = message?.html ?? ''; const followups = ReportActionsUtils.parseFollowupsFromHtml(html); - if (!message || !followups || followups.length === 0) { + if (!followups || followups.length === 0) { return null; } @@ -642,10 +644,12 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors // Check if the last visible action is from Concierge with unresolved followups // If so, optimistically resolve them by adding the updated action to optimisticReportActions const lastVisibleAction = getLastVisibleAction(reportID); - if (lastVisibleAction?.actorAccountID === CONST.ACCOUNT_ID.CONCIERGE) { + const lastActorAccountID = lastVisibleAction?.actorAccountID; + const lastActionReportActionID = lastVisibleAction?.reportActionID; + if (lastActorAccountID === CONST.ACCOUNT_ID.CONCIERGE && lastActionReportActionID) { const resolvedAction = buildOptimisticResolvedFollowups(lastVisibleAction); if (resolvedAction) { - optimisticReportActions[lastVisibleAction.reportActionID] = resolvedAction; + optimisticReportActions[lastActionReportActionID] = resolvedAction; } } @@ -706,7 +710,6 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors const {lastMessageText = ''} = ReportActionsUtils.getLastVisibleMessage(reportID); if (lastMessageText) { const lastVisibleActionCreated = lastVisibleAction?.created; - const lastActorAccountID = lastVisibleAction?.actorAccountID; failureReport = { lastMessageText, lastVisibleActionCreated, @@ -803,7 +806,6 @@ function addComment(report: OnyxEntry, notifyReportID: string, ancestors if (shouldPlaySound) { playSound(SOUNDS.DONE); } - addActions(report, notifyReportID, ancestors, timezoneParam, text, undefined, isInSidePanel); } @@ -6537,11 +6539,13 @@ function resolveSuggestedFollowup( timezoneParam: Timezone, ancestors: Ancestor[] = [], ) { - if (!report?.reportID || !reportAction?.reportActionID) { + const reportID = report?.reportID; + const reportActionID = reportAction?.reportActionID; + + if (!reportID || !reportActionID) { return; } - const reportID = report.reportID; const resolvedAction = buildOptimisticResolvedFollowups(reportAction); if (!resolvedAction) { @@ -6550,8 +6554,8 @@ function resolveSuggestedFollowup( // Optimistically update the HTML to mark followup-list as resolved Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [reportAction.reportActionID]: resolvedAction, - } as Partial); + [reportActionID]: resolvedAction, + }); // Post the selected followup question as a comment addComment(report, notifyReportID ?? reportID, ancestors, selectedFollowup, timezoneParam); diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index d6e9d8f1fe744..47d6c80994efc 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1558,6 +1558,7 @@ function PureReportActionItem({ {actionableItemButtons.length > 0 && ( )} @@ -1634,6 +1635,7 @@ function PureReportActionItem({ {actionableItemButtons.length > 0 && ( )} @@ -1650,6 +1652,7 @@ function PureReportActionItem({ ![CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING].some((item) => item === moderationDecision) && !isPendingRemove(action); const isConciergeOptions = isConciergeCategoryOptions(action) || isConciergeDescriptionOptions(action); + const actionContainsFollowUps = containsActionableFollowUps(action); children = ( @@ -1690,10 +1693,11 @@ function PureReportActionItem({ isConciergeCategoryOptions(action) || isConciergeDescriptionOptions(action) || isActionableMentionWhisper(action) || - containsActionableFollowUps(action) + actionContainsFollowUps ? 'vertical' : 'horizontal' } + shouldUseLocalization={!isConciergeOptions && !actionContainsFollowUps} primaryTextNumberOfLines={isConciergeOptions ? 2 : 1} textStyles={isConciergeOptions ? styles.textAlignLeft : undefined} /> diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 9fbbb2d85cf64..c0b1e450a8c5a 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -3479,26 +3479,6 @@ describe('actions/Report', () => { expect(result).not.toBeNull(); expect((result?.message as Message[]).at(0)?.html).toContain(''); }); - - it('should preserve other message properties when resolving followups', () => { - const reportAction = { - reportActionID: '789', - message: [ - { - html: '

Help

Question', - text: 'Help', - type: CONST.REPORT.MESSAGE.TYPE.COMMENT, - isEdited: true, - }, - ], - } as OnyxTypes.ReportAction; - - const result = Report.buildOptimisticResolvedFollowups(reportAction); - - expect(result).not.toBeNull(); - expect((result?.message as Message[]).at(0)?.text).toBe('Help'); - expect((result?.message as Message[]).at(0)?.type).toBe(CONST.REPORT.MESSAGE.TYPE.COMMENT); - }); }); describe('resolveSuggestedFollowup', () => { From fe867ac17c161bec46350ab64df6907f8ee8ca9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 27 Jan 2026 11:39:15 -0800 Subject: [PATCH 07/13] update number of lines for pill button --- src/pages/home/report/PureReportActionItem.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 47d6c80994efc..0ebfd672d8df6 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1653,6 +1653,13 @@ function PureReportActionItem({ const isConciergeOptions = isConciergeCategoryOptions(action) || isConciergeDescriptionOptions(action); const actionContainsFollowUps = containsActionableFollowUps(action); + let actionableButtonsNoLines = 1; + if (isConciergeOptions) { + actionableButtonsNoLines = 2; + } + if (actionContainsFollowUps) { + actionableButtonsNoLines = 0; + } children = ( @@ -1698,8 +1705,8 @@ function PureReportActionItem({ : 'horizontal' } shouldUseLocalization={!isConciergeOptions && !actionContainsFollowUps} - primaryTextNumberOfLines={isConciergeOptions ? 2 : 1} - textStyles={isConciergeOptions ? styles.textAlignLeft : undefined} + primaryTextNumberOfLines={actionableButtonsNoLines} + textStyles={isConciergeOptions || actionContainsFollowUps ? styles.textAlignLeft : undefined} /> )} From c9eddebc8877f3cca9b827099ad24948d5a4e7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 27 Jan 2026 11:49:51 -0800 Subject: [PATCH 08/13] fix prettier --- tests/unit/ReportActionsUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 4aa8d7616d2b1..9c8347b35282a 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -11,9 +11,9 @@ import {chatReportR14932 as mockChatReport, iouReportR14932 as mockIOUReport} fr import CONST from '../../src/CONST'; import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils'; import { + containsActionableFollowUps, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, - containsActionableFollowUps, getCardIssuedMessage, getCompanyAddressUpdateMessage, getCreatedReportForUnapprovedTransactionsMessage, From 5f3a85377be989f70be136e4e17be407f3062693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 27 Jan 2026 13:07:36 -0800 Subject: [PATCH 09/13] action on some ai comments --- src/libs/ReportActionsUtils.ts | 13 ++++++------- src/pages/home/report/PureReportActionItem.tsx | 1 + 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index f9aeff58c090b..21a097d536011 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -975,11 +975,7 @@ function containsActionableFollowUps(reportAction: OnyxInputOrEntry 0; } function isActionableMentionInviteToSubmitExpenseConfirmWhisper( @@ -1756,9 +1752,8 @@ function getMemberChangeMessageElements( ]; } +// Matches a HTML element and its entire contents. (Question?) const followUpListRegex = /]*)?>[\s\S]*?<\/followup-list>/i; -const followUpSelectedListRegex = /]*\sselected[\s>]/i; -const followUpTextRegex = /([^<]*)<\/followup-text><\/followup>/gi; /** * Parses followup data from a HTML element. * @param html - The HTML string to parse for elements @@ -1772,12 +1767,16 @@ function parseFollowupsFromHtml(html: string): Followup[] | null { // There will be only one follow up list const followupListHtml = followupListMatch[0]; + // Matches a element that has the "selected" attribute (...). + const followUpSelectedListRegex = /]*\sselected[\s>]/i; const hasSelectedAttribute = followUpSelectedListRegex.test(followupListHtml); if (hasSelectedAttribute) { return []; } const followups: Followup[] = []; + // Matches individual ... elements + const followUpTextRegex = /([^<]*)<\/followup-text><\/followup>/gi; let match = followUpTextRegex.exec(followupListHtml); while (match !== null) { followups.push({text: match[1]}); diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 9d1bc39e4a46a..c32dd1a6dea05 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1581,6 +1581,7 @@ function PureReportActionItem({ {actionableItemButtons.length > 0 && ( )} From d9f3f00f380b98b534dd1cbb668935b088440491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 27 Jan 2026 13:51:49 -0800 Subject: [PATCH 10/13] add optimistic action revert if the request fails --- src/libs/actions/Report.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index b6968bb683b2d..d65da90c621ed 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -646,11 +646,9 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors const lastVisibleAction = getLastVisibleAction(reportID); const lastActorAccountID = lastVisibleAction?.actorAccountID; const lastActionReportActionID = lastVisibleAction?.reportActionID; - if (lastActorAccountID === CONST.ACCOUNT_ID.CONCIERGE && lastActionReportActionID) { - const resolvedAction = buildOptimisticResolvedFollowups(lastVisibleAction); - if (resolvedAction) { - optimisticReportActions[lastActionReportActionID] = resolvedAction; - } + const resolvedAction = buildOptimisticResolvedFollowups(lastVisibleAction); + if (lastActorAccountID === CONST.ACCOUNT_ID.CONCIERGE && lastActionReportActionID && resolvedAction) { + optimisticReportActions[lastActionReportActionID] = resolvedAction; } const parameters: AddCommentOrAttachmentParams = { @@ -717,7 +715,7 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors }; } - const failureReportActions: Record = {}; + const failureReportActions: Record = {}; for (const [actionKey, action] of Object.entries(optimisticReportActions)) { failureReportActions[actionKey] = { @@ -726,6 +724,10 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors errors: getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), }; } + // In case of error bring back the follow up buttons to the cast comment + if (lastActorAccountID === CONST.ACCOUNT_ID.CONCIERGE && lastActionReportActionID) { + failureReportActions[lastActionReportActionID] = lastVisibleAction; + } const failureData: Array> = [ { From 8a28de2696bd053212f6505f25d838b55cf81707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 27 Jan 2026 13:53:07 -0800 Subject: [PATCH 11/13] revert flag --- src/pages/home/report/PureReportActionItem.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 7f52bebeabd34..3aeae73c41634 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1568,7 +1568,6 @@ function PureReportActionItem({ {actionableItemButtons.length > 0 && ( )} From 0e0267e033ac163e1093d7e278289d81ce5fecb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 27 Jan 2026 14:14:37 -0800 Subject: [PATCH 12/13] Move Follow up utils to a separate file --- src/libs/ReportActionsFollowupUtils.ts | 57 +++++++ src/libs/ReportActionsUtils.ts | 57 +------ src/libs/actions/Report.ts | 3 +- .../home/report/PureReportActionItem.tsx | 3 +- tests/unit/FollowupUtilsTest.ts | 160 ++++++++++++++++++ tests/unit/ReportActionsUtilsTest.ts | 157 ----------------- 6 files changed, 223 insertions(+), 214 deletions(-) create mode 100644 src/libs/ReportActionsFollowupUtils.ts create mode 100644 tests/unit/FollowupUtilsTest.ts diff --git a/src/libs/ReportActionsFollowupUtils.ts b/src/libs/ReportActionsFollowupUtils.ts new file mode 100644 index 0000000000000..e068a3935daea --- /dev/null +++ b/src/libs/ReportActionsFollowupUtils.ts @@ -0,0 +1,57 @@ +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; +import type {Followup} from './ReportActionsUtils'; +import {getReportActionMessage, isActionOfType} from './ReportActionsUtils'; + +/** + * Checks if a report action contains actionable (unresolved) followup suggestions. + * @param reportAction - The report action to check + * @returns true if the action is an ADD_COMMENT with unresolved followups, false otherwise + */ +function containsActionableFollowUps(reportAction: OnyxInputOrEntry): boolean { + const isActionAComment = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + if (!isActionAComment) { + return false; + } + const messageHtml = getReportActionMessage(reportAction)?.html; + if (!messageHtml) { + return false; + } + const followups = parseFollowupsFromHtml(messageHtml); + + return !!followups && followups.length > 0; +} + +// Matches a HTML element and its entire contents. (Question?) +const followUpListRegex = /]*)?>[\s\S]*?<\/followup-list>/i; +/** + * Parses followup data from a HTML element. + * @param html - The HTML string to parse for elements + * @returns null if no exists, empty array [] if the followup-list has the 'selected' attribute (resolved state), or an array of followup objects if unresolved + */ +function parseFollowupsFromHtml(html: string): Followup[] | null { + const followupListMatch = html.match(followUpListRegex); + if (!followupListMatch) { + return null; + } + + // There will be only one follow up list + const followupListHtml = followupListMatch[0]; + // Matches a element that has the "selected" attribute (...). + const followUpSelectedListRegex = /]*\sselected[\s>]/i; + const hasSelectedAttribute = followUpSelectedListRegex.test(followupListHtml); + if (hasSelectedAttribute) { + return []; + } + + const followups: Followup[] = []; + // Matches individual ... elements + const followUpTextRegex = /([^<]*)<\/followup-text><\/followup>/gi; + let match = followUpTextRegex.exec(followupListHtml); + while (match !== null) { + followups.push({text: match[1]}); + match = followUpTextRegex.exec(followupListHtml); + } + return followups; +} +export {containsActionableFollowUps, parseFollowupsFromHtml}; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 21a097d536011..f6814a8a7c9aa 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -959,25 +959,6 @@ function isActionableMentionWhisper(reportAction: OnyxInputOrEntry return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_MENTION_WHISPER); } -/** - * Checks if a report action contains actionable (unresolved) followup suggestions. - * @param reportAction - The report action to check - * @returns true if the action is an ADD_COMMENT with unresolved followups, false otherwise - */ -function containsActionableFollowUps(reportAction: OnyxInputOrEntry): boolean { - const isActionAComment = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); - if (!isActionAComment) { - return false; - } - const messageHtml = getReportActionMessage(reportAction)?.html; - if (!messageHtml) { - return false; - } - const followups = parseFollowupsFromHtml(messageHtml); - - return !!followups && followups.length > 0; -} - function isActionableMentionInviteToSubmitExpenseConfirmWhisper( reportAction: OnyxEntry, ): reportAction is ReportAction { @@ -1752,41 +1733,9 @@ function getMemberChangeMessageElements( ]; } -// Matches a HTML element and its entire contents. (Question?) -const followUpListRegex = /]*)?>[\s\S]*?<\/followup-list>/i; -/** - * Parses followup data from a HTML element. - * @param html - The HTML string to parse for elements - * @returns null if no exists, empty array [] if the followup-list has the 'selected' attribute (resolved state), or an array of followup objects if unresolved - */ -function parseFollowupsFromHtml(html: string): Followup[] | null { - const followupListMatch = html.match(followUpListRegex); - if (!followupListMatch) { - return null; - } - - // There will be only one follow up list - const followupListHtml = followupListMatch[0]; - // Matches a element that has the "selected" attribute (...). - const followUpSelectedListRegex = /]*\sselected[\s>]/i; - const hasSelectedAttribute = followUpSelectedListRegex.test(followupListHtml); - if (hasSelectedAttribute) { - return []; - } - - const followups: Followup[] = []; - // Matches individual ... elements - const followUpTextRegex = /([^<]*)<\/followup-text><\/followup>/gi; - let match = followUpTextRegex.exec(followupListHtml); - while (match !== null) { - followups.push({text: match[1]}); - match = followUpTextRegex.exec(followupListHtml); - } - return followups; -} - /** * Used for generating preview text in LHN and other places where followups should not be displayed. + * Implemented here instead of ReportActionFollowupUtils due to circular ref * @param html message.html from the report COMMENT actions * @returns html with the element and its contents stripped out or undefined if html is undefined */ @@ -1794,6 +1743,8 @@ function stripFollowupListFromHtml(html?: string): string | undefined { if (!html) { return; } + // Matches a HTML element and its entire contents. (Question?) + const followUpListRegex = /]*)?>[\s\S]*?<\/followup-list>/i; return html.replace(followUpListRegex, '').trim(); } @@ -4040,9 +3991,7 @@ export { withDEWRoutedActionsArray, withDEWRoutedActionsObject, getReportActionActorAccountID, - parseFollowupsFromHtml, stripFollowupListFromHtml, - containsActionableFollowUps, }; export type {LastVisibleMessage, Followup}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index d65da90c621ed..baec2d016ff50 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -103,6 +103,7 @@ import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import Pusher from '@libs/Pusher'; import type {UserIsLeavingRoomEvent, UserIsTypingEvent} from '@libs/Pusher/types'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportActionsFollowupUtils from '@libs/ReportActionsFollowupUtils'; import {getLastVisibleAction} from '@libs/ReportActionsUtils'; import {updateTitleFieldToMatchPolicy} from '@libs/ReportTitleUtils'; import type {Ancestor, OptimisticAddCommentReportAction, OptimisticChatReport, SelfDMParameters} from '@libs/ReportUtils'; @@ -548,7 +549,7 @@ function buildOptimisticResolvedFollowups(reportAction: OnyxEntry) return null; } const html = message?.html ?? ''; - const followups = ReportActionsUtils.parseFollowupsFromHtml(html); + const followups = ReportActionsFollowupUtils.parseFollowupsFromHtml(html); if (!followups || followups.length === 0) { return null; diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 3aeae73c41634..d8fdf0e1669e6 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -63,8 +63,8 @@ import Parser from '@libs/Parser'; import Permissions from '@libs/Permissions'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getCleanedTagName, hasDynamicExternalWorkflow, isPolicyAdmin, isPolicyMember, isPolicyOwner} from '@libs/PolicyUtils'; +import {containsActionableFollowUps, parseFollowupsFromHtml} from '@libs/ReportActionsFollowupUtils'; import { - containsActionableFollowUps, extractLinksFromMessageHtml, getActionableCardFraudAlertMessage, getActionableMentionWhisperMessage, @@ -164,7 +164,6 @@ import { isTripPreview, isUnapprovedAction, isWhisperActionTargetedToOthers, - parseFollowupsFromHtml, useTableReportViewActionRenderConditionals, } from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; diff --git a/tests/unit/FollowupUtilsTest.ts b/tests/unit/FollowupUtilsTest.ts new file mode 100644 index 0000000000000..0124097b454ef --- /dev/null +++ b/tests/unit/FollowupUtilsTest.ts @@ -0,0 +1,160 @@ +import CONST from '../../src/CONST'; +import {containsActionableFollowUps, parseFollowupsFromHtml} from '../../src/libs/ReportActionsFollowupUtils'; +import {stripFollowupListFromHtml} from '../../src/libs/ReportActionsUtils'; +import type {ReportAction} from '../../src/types/onyx'; + +describe('FollowupUtils', () => { + describe('parseFollowupsFromHtml', () => { + it('should return null when no followup-list exists', () => { + const html = '

Hello world

'; + expect(parseFollowupsFromHtml(html)).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(parseFollowupsFromHtml('')).toBeNull(); + }); + + it('should return empty array when followup-list has selected attribute', () => { + const html = `

Some message

+ + How do I set up QuickBooks? +`; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + + it('should return empty array when followup-list has selected attribute with other attributes', () => { + const html = ` + Question 1 +`; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + + it('should parse single followup from unresolved list', () => { + const html = `

Hello

+ + How do I set up QuickBooks? +`; + expect(parseFollowupsFromHtml(html)).toEqual([{text: 'How do I set up QuickBooks?'}]); + }); + + it('should parse multiple followups from unresolved list', () => { + const html = ` + How do I set up QuickBooks? + What is the Expensify Card cashback? +`; + expect(parseFollowupsFromHtml(html)).toEqual([{text: 'How do I set up QuickBooks?'}, {text: 'What is the Expensify Card cashback?'}]); + }); + + it('should handle followup-list with whitespace attributes', () => { + const html = ` + Question +`; + expect(parseFollowupsFromHtml(html)).toEqual([{text: 'Question'}]); + }); + + it('should return empty array for followup-list with selected but no followups', () => { + const html = ''; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + + it('should return empty array for unresolved followup-list with no followups', () => { + const html = ''; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + }); + + describe('stripFollowupListFromHtml', () => { + it('should return original string when no followup-list exists', () => { + const html = '

Hello world

'; + expect(stripFollowupListFromHtml(html)).toBe('

Hello world

'); + }); + + it('should return undefined for empty input', () => { + expect(stripFollowupListFromHtml('')).not.toBeDefined(); + }); + + it('should strip followup-list and trim result', () => { + const html = `

Some message

+ + How do I set up QuickBooks? +`; + expect(stripFollowupListFromHtml(html)).toBe('

Some message

'); + }); + + it('should strip resolved followup-list with selected attribute', () => { + const html = `

Answer to your question

+ + Old question +`; + expect(stripFollowupListFromHtml(html)).toBe('

Answer to your question

'); + }); + + it('should handle content before and after followup-list', () => { + const html = `

Before

+ + Question + +

After

`; + expect(stripFollowupListFromHtml(html)).toBe(`

Before

+ +

After

`); + }); + }); + + describe('containsActionableFollowUps', () => { + it('should return false for null/undefined reportAction', () => { + expect(containsActionableFollowUps(null)).toBe(false); + expect(containsActionableFollowUps(undefined)).toBe(false); + }); + + it('should return false for non-ADD_COMMENT action types', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + message: [{html: 'Question', text: '', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return false for ADD_COMMENT without message html', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{text: 'Just text', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return false for ADD_COMMENT without followup-list', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: '

Regular message

', text: 'Regular message', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return false for ADD_COMMENT with resolved followup-list (selected attribute)', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: '

Message

Question', text: 'Message', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return true for ADD_COMMENT with unresolved followup-list', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: '

Message

Question', text: 'Message', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(true); + }); + }); +}); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 9c8347b35282a..11a705651e776 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -11,7 +11,6 @@ import {chatReportR14932 as mockChatReport, iouReportR14932 as mockIOUReport} fr import CONST from '../../src/CONST'; import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils'; import { - containsActionableFollowUps, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, getCardIssuedMessage, @@ -28,8 +27,6 @@ import { getSendMoneyFlowAction, getUpdateACHAccountMessage, isIOUActionMatchingTransactionList, - parseFollowupsFromHtml, - stripFollowupListFromHtml, } from '../../src/libs/ReportActionsUtils'; import {buildOptimisticCreatedReportForUnapprovedAction} from '../../src/libs/ReportUtils'; import ONYXKEYS from '../../src/ONYXKEYS'; @@ -3101,158 +3098,4 @@ describe('ReportActionsUtils', () => { expect(result).toBe('changed the auto-pay approved reports threshold to "$1,000.00" (previously "$500.00")'); }); }); - - describe('parseFollowupsFromHtml', () => { - it('should return null when no followup-list exists', () => { - const html = '

Hello world

'; - expect(parseFollowupsFromHtml(html)).toBeNull(); - }); - - it('should return null for empty string', () => { - expect(parseFollowupsFromHtml('')).toBeNull(); - }); - - it('should return empty array when followup-list has selected attribute', () => { - const html = `

Some message

- - How do I set up QuickBooks? -`; - expect(parseFollowupsFromHtml(html)).toEqual([]); - }); - - it('should return empty array when followup-list has selected attribute with other attributes', () => { - const html = ` - Question 1 -`; - expect(parseFollowupsFromHtml(html)).toEqual([]); - }); - - it('should parse single followup from unresolved list', () => { - const html = `

Hello

- - How do I set up QuickBooks? -`; - expect(parseFollowupsFromHtml(html)).toEqual([{text: 'How do I set up QuickBooks?'}]); - }); - - it('should parse multiple followups from unresolved list', () => { - const html = ` - How do I set up QuickBooks? - What is the Expensify Card cashback? -`; - expect(parseFollowupsFromHtml(html)).toEqual([{text: 'How do I set up QuickBooks?'}, {text: 'What is the Expensify Card cashback?'}]); - }); - - it('should handle followup-list with whitespace attributes', () => { - const html = ` - Question -`; - expect(parseFollowupsFromHtml(html)).toEqual([{text: 'Question'}]); - }); - - it('should return empty array for followup-list with selected but no followups', () => { - const html = ''; - expect(parseFollowupsFromHtml(html)).toEqual([]); - }); - - it('should return empty array for unresolved followup-list with no followups', () => { - const html = ''; - expect(parseFollowupsFromHtml(html)).toEqual([]); - }); - }); - - describe('stripFollowupListFromHtml', () => { - it('should return original string when no followup-list exists', () => { - const html = '

Hello world

'; - expect(stripFollowupListFromHtml(html)).toBe('

Hello world

'); - }); - - it('should return undefined for empty input', () => { - expect(stripFollowupListFromHtml('')).not.toBeDefined(); - }); - - it('should strip followup-list and trim result', () => { - const html = `

Some message

- - How do I set up QuickBooks? -`; - expect(stripFollowupListFromHtml(html)).toBe('

Some message

'); - }); - - it('should strip resolved followup-list with selected attribute', () => { - const html = `

Answer to your question

- - Old question -`; - expect(stripFollowupListFromHtml(html)).toBe('

Answer to your question

'); - }); - - it('should handle content before and after followup-list', () => { - const html = `

Before

- - Question - -

After

`; - expect(stripFollowupListFromHtml(html)).toBe(`

Before

- -

After

`); - }); - }); - - describe('containsActionableFollowUps', () => { - it('should return false for null/undefined reportAction', () => { - expect(containsActionableFollowUps(null)).toBe(false); - expect(containsActionableFollowUps(undefined)).toBe(false); - }); - - it('should return false for non-ADD_COMMENT action types', () => { - const action = { - reportActionID: '123', - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - message: [{html: 'Question', text: '', type: 'COMMENT'}], - } as ReportAction; - - expect(containsActionableFollowUps(action)).toBe(false); - }); - - it('should return false for ADD_COMMENT without message html', () => { - const action = { - reportActionID: '123', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - message: [{text: 'Just text', type: 'COMMENT'}], - } as ReportAction; - - expect(containsActionableFollowUps(action)).toBe(false); - }); - - it('should return false for ADD_COMMENT without followup-list', () => { - const action = { - reportActionID: '123', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - message: [{html: '

Regular message

', text: 'Regular message', type: 'COMMENT'}], - } as ReportAction; - - expect(containsActionableFollowUps(action)).toBe(false); - }); - - it('should return false for ADD_COMMENT with resolved followup-list (selected attribute)', () => { - const action = { - reportActionID: '123', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - message: [{html: '

Message

Question', text: 'Message', type: 'COMMENT'}], - } as ReportAction; - - expect(containsActionableFollowUps(action)).toBe(false); - }); - - it('should return true for ADD_COMMENT with unresolved followup-list', () => { - const action = { - reportActionID: '123', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - message: [{html: '

Message

Question', text: 'Message', type: 'COMMENT'}], - } as ReportAction; - - expect(containsActionableFollowUps(action)).toBe(true); - }); - }); }); From be04dd8f4591a7cf32f5f1f6c3c00bdb8157107b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 27 Jan 2026 14:24:38 -0800 Subject: [PATCH 13/13] fix prettier --- src/libs/actions/Report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index baec2d016ff50..fb359ebd85c73 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -102,8 +102,8 @@ import { import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import Pusher from '@libs/Pusher'; import type {UserIsLeavingRoomEvent, UserIsTypingEvent} from '@libs/Pusher/types'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportActionsFollowupUtils from '@libs/ReportActionsFollowupUtils'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {getLastVisibleAction} from '@libs/ReportActionsUtils'; import {updateTitleFieldToMatchPolicy} from '@libs/ReportTitleUtils'; import type {Ancestor, OptimisticAddCommentReportAction, OptimisticChatReport, SelfDMParameters} from '@libs/ReportUtils';