diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d812524bc692..6f5721114fed 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -685,6 +685,7 @@ const ONYXKEYS = { REPORT_DRAFT_COMMENT: 'reportDraftComment_', REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_', REPORT_USER_IS_TYPING: 'reportUserIsTyping_', + PENDING_CONCIERGE_RESPONSE: 'pendingConciergeResponse_', REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_', REPORT_VIOLATIONS: 'reportViolations_', SECURITY_GROUP: 'securityGroup_', @@ -1164,6 +1165,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT]: string; [ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE]: boolean; [ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING]: OnyxTypes.ReportUserIsTyping; + [ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE]: OnyxTypes.PendingConciergeResponse; [ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM]: boolean; [ONYXKEYS.COLLECTION.REPORT_VIOLATIONS]: OnyxTypes.ReportViolations; [ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup; diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts new file mode 100644 index 000000000000..55538284892a --- /dev/null +++ b/src/hooks/usePendingConciergeResponse.ts @@ -0,0 +1,42 @@ +import {useEffect} from 'react'; +import {applyPendingConciergeAction, discardPendingConciergeAction} from '@libs/actions/Report/SuggestedFollowup'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useOnyx from './useOnyx'; + +/** If displayAfter is more than this far in the past, the response is stale (e.g. app was killed and restarted) */ +const STALE_THRESHOLD_MS = 10_000; + +/** + * Processes pending concierge responses stored in Onyx for a given report. + * When a pending response exists, schedules the action to be moved to REPORT_ACTIONS + * after the remaining delay, with automatic cleanup on unmount via useEffect. + */ +function usePendingConciergeResponse(reportID: string) { + const [pendingResponse] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, {canBeMissing: true}); + + useEffect(() => { + if (!pendingResponse) { + return; + } + + const remaining = pendingResponse.displayAfter - Date.now(); + + // If the pending response is stale (e.g. app was killed/restarted), discard it + // instead of displaying a phantom message that was never confirmed by the server. + if (remaining < -STALE_THRESHOLD_MS) { + discardPendingConciergeAction(reportID); + return; + } + + const timer = setTimeout( + () => { + applyPendingConciergeAction(reportID, pendingResponse.reportAction); + }, + Math.max(0, remaining), + ); + + return () => clearTimeout(timer); + }, [pendingResponse, reportID]); +} + +export default usePendingConciergeResponse; diff --git a/src/libs/actions/Report/SuggestedFollowup.ts b/src/libs/actions/Report/SuggestedFollowup.ts index 2f093cbc850d..0b14bb3e5025 100644 --- a/src/libs/actions/Report/SuggestedFollowup.ts +++ b/src/libs/actions/Report/SuggestedFollowup.ts @@ -11,7 +11,7 @@ import type {Timezone} from '@src/types/onyx/PersonalDetails'; import {addComment, buildOptimisticResolvedFollowups} from '.'; /** Delay before showing pre-generated Concierge response (in milliseconds) */ -const CONCIERGE_RESPONSE_DELAY_MS = 1500; +const CONCIERGE_RESPONSE_DELAY_MS = 4000; /** * Resolves a suggested followup by posting the selected question as a comment @@ -89,22 +89,73 @@ function resolveSuggestedFollowup( addOptimisticConciergeActionWithDelay(reportID, optimisticConciergeAction); } +/** + * Queues an optimistic concierge response for delayed display. + * Writes action to Onyx — the usePendingConciergeResponse hook + * handles the actual delay and moves the action to REPORT_ACTIONS + * when the time arrives, with proper lifecycle cleanup. + */ function addOptimisticConciergeActionWithDelay(reportID: string, optimisticConciergeAction: OptimisticReportAction) { - // Show "Concierge is typing..." indicator - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, { - [CONST.ACCOUNT_ID.CONCIERGE]: true, - }); + Onyx.update([ + // Store the pending response for the scheduler to process + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, + value: { + reportAction: optimisticConciergeAction.reportAction, + displayAfter: Date.now() + CONCIERGE_RESPONSE_DELAY_MS, + }, + }, + // Show "Concierge is typing..." indicator + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, + value: {[CONST.ACCOUNT_ID.CONCIERGE]: true}, + }, + ]); +} - setTimeout(() => { - // Clear the typing indicator - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, { - [CONST.ACCOUNT_ID.CONCIERGE]: false, - }); +/** + * Discards a stale pending concierge response and clears the typing indicator. + * Called when the response has been pending too long (e.g. app was killed and restarted). + */ +function discardPendingConciergeAction(reportID: string) { + Onyx.update([ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, + value: {[CONST.ACCOUNT_ID.CONCIERGE]: false}, + }, + ]); +} - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [optimisticConciergeAction.reportAction.reportActionID]: optimisticConciergeAction.reportAction, - }); - }, CONCIERGE_RESPONSE_DELAY_MS); +/** + * Applies a pending concierge response by moving it to REPORT_ACTIONS + * and clearing the pending state and typing indicator. + */ +function applyPendingConciergeAction(reportID: string, reportAction: ReportAction) { + Onyx.update([ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, + value: {[CONST.ACCOUNT_ID.CONCIERGE]: false}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: {[reportAction.reportActionID]: reportAction}, + }, + ]); } -export {resolveSuggestedFollowup, CONCIERGE_RESPONSE_DELAY_MS}; +export {resolveSuggestedFollowup, discardPendingConciergeAction, applyPendingConciergeAction, CONCIERGE_RESPONSE_DELAY_MS}; diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index a1e2c00310d7..9b8816d97912 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -7,6 +7,7 @@ import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useLoadReportActions from '@hooks/useLoadReportActions'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; import usePrevious from '@hooks/usePrevious'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -81,6 +82,7 @@ function ReportActionsView({ isReportTransactionThread, }: ReportActionsViewProps) { useCopySelectionHelper(); + usePendingConciergeResponse(report.reportID); const route = useRoute>(); const isReportArchived = useReportIsArchived(report?.reportID); const canPerformWriteAction = useMemo(() => canUserPerformWriteAction(report, isReportArchived), [report, isReportArchived]); diff --git a/src/types/onyx/PendingConciergeResponse.ts b/src/types/onyx/PendingConciergeResponse.ts new file mode 100644 index 000000000000..4d473c54474a --- /dev/null +++ b/src/types/onyx/PendingConciergeResponse.ts @@ -0,0 +1,12 @@ +import type ReportAction from './ReportAction'; + +/** Pending concierge response queued for delayed display in a report */ +type PendingConciergeResponse = { + /** The optimistic report action to add after the delay */ + reportAction: ReportAction; + + /** Timestamp (ms) after which the response should be displayed */ + displayAfter: number; +}; + +export default PendingConciergeResponse; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 2d0793344551..da331d4cd826 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -94,6 +94,7 @@ import type OnyxInputOrEntry from './OnyxInputOrEntry'; import type {AnyOnyxUpdatesFromServer, OnyxUpdateEvent, OnyxUpdatesFromServer} from './OnyxUpdatesFromServer'; import type {DecisionName, OriginalMessageIOU} from './OriginalMessage'; import type Pages from './Pages'; +import type PendingConciergeResponse from './PendingConciergeResponse'; import type {PendingContactAction} from './PendingContactAction'; import type PersonalBankAccount from './PersonalBankAccount'; import type {PersonalDetailsList, PersonalDetailsMetadata} from './PersonalDetails'; @@ -231,6 +232,7 @@ export type { OnyxUpdatesFromServer, AnyOnyxUpdatesFromServer, Pages, + PendingConciergeResponse, PersonalBankAccount, PersonalDetails, PersonalDetailsList, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 0592afed8ece..7fb51a9cf157 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -4190,7 +4190,7 @@ describe('actions/Report', () => { await waitForBatchedUpdates(); // Verify the followup-list was marked as selected - let reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + 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(''); @@ -4198,18 +4198,15 @@ describe('actions/Report', () => { // With pre-generated response, the API call should include the optimistic Concierge response params TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); - // Wait for the delayed Concierge response (1500ms delay in SuggestedFollowup.ts) - await new Promise((resolve) => { - setTimeout(resolve, CONCIERGE_RESPONSE_DELAY_MS + 100); - }); - await waitForBatchedUpdates(); + // Verify the pending concierge response was written to Onyx (the hook will process it) + const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const); + expect(pendingResponse).not.toBeNull(); + expect(pendingResponse?.reportAction.actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE); + expect(pendingResponse?.displayAfter).toBeGreaterThan(Date.now() - CONCIERGE_RESPONSE_DELAY_MS); - // Verify an optimistic Concierge report action was created - reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); - const allReportActions = Object.values(reportActions ?? {}); - const conciergeActions = allReportActions.filter((action) => action?.actorAccountID === CONST.ACCOUNT_ID.CONCIERGE); - // Should have 2 Concierge actions: the original one and the optimistic response - expect(conciergeActions.length).toBe(2); + // Verify the typing indicator was set + const typingStatus = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${REPORT_ID}` as const); + expect(typingStatus?.[CONST.ACCOUNT_ID.CONCIERGE]).toBe(true); }); }); diff --git a/tests/unit/hooks/usePendingConciergeResponse.test.ts b/tests/unit/hooks/usePendingConciergeResponse.test.ts new file mode 100644 index 000000000000..e69086c9c092 --- /dev/null +++ b/tests/unit/hooks/usePendingConciergeResponse.test.ts @@ -0,0 +1,195 @@ +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; +import getOnyxValue from '../../utils/getOnyxValue'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const REPORT_ID = '1'; +const REPORT_ACTION_ID = '100'; + +/** Short delay used for tests where we need the timer to actually fire (ms) */ +const SHORT_DELAY = 80; + +const fakeConciergeAction = { + reportActionID: REPORT_ACTION_ID, + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: 'To set up QuickBooks, go to Settings...', text: 'To set up QuickBooks, go to Settings...', type: CONST.REPORT.MESSAGE.TYPE.COMMENT}], +} as ReportAction; + +/** Wait for a given number of ms (real timer) */ +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +describe('usePendingConciergeResponse', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('should move the pending action to REPORT_ACTIONS after the delay', async () => { + // Given a pending concierge response with a short future delay + await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { + reportAction: fakeConciergeAction, + displayAfter: Date.now() + SHORT_DELAY, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${REPORT_ID}`, { + [CONST.ACCOUNT_ID.CONCIERGE]: true, + }); + await waitForBatchedUpdates(); + + renderHook(() => usePendingConciergeResponse(REPORT_ID)); + await waitForBatchedUpdates(); + + // When the delay elapses + await delay(SHORT_DELAY + 50); + await waitForBatchedUpdates(); + + // Then the action should be in REPORT_ACTIONS + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect(reportActions?.[REPORT_ACTION_ID]?.actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE); + + // And the pending response should be cleared + const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const); + expect(pendingResponse).toBeUndefined(); + + // And the typing indicator should be cleared + const typingStatus = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${REPORT_ID}` as const); + expect(typingStatus?.[CONST.ACCOUNT_ID.CONCIERGE]).toBe(false); + }); + + it('should not move the action before the delay elapses', async () => { + // Given a pending concierge response with a longer delay + await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { + reportAction: fakeConciergeAction, + displayAfter: Date.now() + SHORT_DELAY, + }); + await waitForBatchedUpdates(); + + const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID)); + await waitForBatchedUpdates(); + + // When checked immediately (well before the delay) + // Then the action should NOT be in REPORT_ACTIONS yet + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect(reportActions?.[REPORT_ACTION_ID]).toBeUndefined(); + + // And the pending response should still exist + const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const); + expect(pendingResponse).not.toBeUndefined(); + + // Clean up to avoid dangling timer + unmount(); + }); + + it('should cancel the timer on unmount and not apply the action', async () => { + // Given a pending concierge response + await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { + reportAction: fakeConciergeAction, + displayAfter: Date.now() + SHORT_DELAY, + }); + await waitForBatchedUpdates(); + + const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID)); + await waitForBatchedUpdates(); + + // When the hook unmounts before the delay + unmount(); + + // And we wait past the delay + await delay(SHORT_DELAY + 50); + await waitForBatchedUpdates(); + + // Then the action should NOT be in REPORT_ACTIONS (timer was cleaned up) + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect(reportActions?.[REPORT_ACTION_ID]).toBeUndefined(); + + // And the pending response should still exist (nothing consumed it) + const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const); + expect(pendingResponse).not.toBeUndefined(); + }); + + it('should fire immediately when displayAfter is already in the past', async () => { + // Given a pending concierge response with a past displayAfter (e.g., user navigated back) + await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { + reportAction: fakeConciergeAction, + displayAfter: Date.now() - 1000, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${REPORT_ID}`, { + [CONST.ACCOUNT_ID.CONCIERGE]: true, + }); + await waitForBatchedUpdates(); + + renderHook(() => usePendingConciergeResponse(REPORT_ID)); + await waitForBatchedUpdates(); + + // When we wait just one tick (remaining = 0 → setTimeout(fn, 0)) + await delay(50); + await waitForBatchedUpdates(); + + // Then the action should already be in REPORT_ACTIONS + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect(reportActions?.[REPORT_ACTION_ID]?.actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE); + + // And pending response should be cleared + const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const); + expect(pendingResponse).toBeUndefined(); + }); + + it('should discard stale pending responses instead of displaying them', async () => { + // Given a pending concierge response from a previous session (well past the stale threshold) + await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { + reportAction: fakeConciergeAction, + displayAfter: Date.now() - 30_000, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${REPORT_ID}`, { + [CONST.ACCOUNT_ID.CONCIERGE]: true, + }); + await waitForBatchedUpdates(); + + renderHook(() => usePendingConciergeResponse(REPORT_ID)); + await waitForBatchedUpdates(); + + await delay(50); + await waitForBatchedUpdates(); + + // Then the action should NOT be in REPORT_ACTIONS (stale response was discarded) + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect(reportActions?.[REPORT_ACTION_ID]).toBeUndefined(); + + // And the pending response should be cleared + const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const); + expect(pendingResponse).toBeUndefined(); + + // And the typing indicator should be cleared + const typingStatus = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${REPORT_ID}` as const); + expect(typingStatus?.[CONST.ACCOUNT_ID.CONCIERGE]).toBe(false); + }); + + it('should do nothing when there is no pending response', async () => { + // Given no pending concierge response + renderHook(() => usePendingConciergeResponse(REPORT_ID)); + await waitForBatchedUpdates(); + + await delay(SHORT_DELAY + 50); + await waitForBatchedUpdates(); + + // Then REPORT_ACTIONS should remain empty + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect(reportActions).toBeUndefined(); + }); +});