From 5af8b4e4417386648e857fda7a07eda807929a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 11 Feb 2026 17:19:00 +0100 Subject: [PATCH 1/4] add concierge pending response improvement poc --- src/ONYXKEYS.ts | 2 + src/hooks/usePendingConciergeResponse.ts | 52 ++++++ src/libs/actions/Report/SuggestedFollowup.ts | 61 +++++-- src/pages/inbox/report/ReportActionsView.tsx | 2 + src/types/onyx/PendingConciergeResponse.ts | 12 ++ src/types/onyx/index.ts | 2 + tests/actions/ReportTest.ts | 21 +-- .../hooks/usePendingConciergeResponse.test.ts | 159 ++++++++++++++++++ 8 files changed, 282 insertions(+), 29 deletions(-) create mode 100644 src/hooks/usePendingConciergeResponse.ts create mode 100644 src/types/onyx/PendingConciergeResponse.ts create mode 100644 tests/unit/hooks/usePendingConciergeResponse.test.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index aac9a8f1056b..116f6496aefc 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -700,6 +700,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_', @@ -1168,6 +1169,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..23828967e0b6 --- /dev/null +++ b/src/hooks/usePendingConciergeResponse.ts @@ -0,0 +1,52 @@ +import {useEffect} from 'react'; +import Onyx from 'react-native-onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useOnyx from './useOnyx'; + +/** + * 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. + * + * This keeps the action layer free of timers — all scheduling state lives in Onyx + * and the delay is managed by React component lifecycle. + */ +function usePendingConciergeResponse(reportID: string) { + const [pendingResponse] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, {canBeMissing: true}); + + useEffect(() => { + if (!pendingResponse) { + return; + } + + const remaining = Math.max(0, pendingResponse.displayAfter - Date.now()); + + const timer = setTimeout(() => { + Onyx.update([ + // Clear the pending response + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, + value: null, + }, + // Clear the typing indicator + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, + value: {[CONST.ACCOUNT_ID.CONCIERGE]: false}, + }, + // Add the concierge action to report actions + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: {[pendingResponse.reportAction.reportActionID]: pendingResponse.reportAction}, + }, + ]); + }, 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..5df75c282c6a 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 = 3000; /** * Resolves a suggested followup by posting the selected question as a comment @@ -89,22 +89,49 @@ function resolveSuggestedFollowup( addOptimisticConciergeActionWithDelay(reportID, optimisticConciergeAction); } -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, - }); - - setTimeout(() => { - // Clear the typing indicator - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, { - [CONST.ACCOUNT_ID.CONCIERGE]: false, - }); +/** + * Cancels any pending optimistic concierge action for the given reportID. + * Clears the pending response from Onyx and the typing indicator atomically. + */ +function cancelPendingConciergeAction(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); +/** + * Queues an optimistic concierge response for delayed display. + * Writes intent to Onyx — the ConciergeResponseScheduler component + * 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) { + 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}, + }, + ]); } -export {resolveSuggestedFollowup, CONCIERGE_RESPONSE_DELAY_MS}; +export {resolveSuggestedFollowup, cancelPendingConciergeAction, 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 45a7739ac775..e0d598107631 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -92,6 +92,7 @@ import type OnyxInputOrEntry from './OnyxInputOrEntry'; import type {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'; @@ -225,6 +226,7 @@ export type { OnyxUpdateEvent, OnyxUpdatesFromServer, Pages, + PendingConciergeResponse, PersonalBankAccount, PersonalDetails, PersonalDetailsList, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index b6aba6fd6981..8b2c497c80c1 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -4189,18 +4189,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 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 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 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..a872c184c413 --- /dev/null +++ b/tests/unit/hooks/usePendingConciergeResponse.test.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +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 for real-timer tests (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() + 5000, + }); + await waitForBatchedUpdates(); + + 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(); + }); + + 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(); + }); + + 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 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(); + }); +}); From 840ebf02b4f68197d83489afad7c0511243f7bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Fri, 13 Feb 2026 18:28:50 +0100 Subject: [PATCH 2/4] cleanup --- src/libs/actions/Report/SuggestedFollowup.ts | 23 ++----------------- tests/actions/ReportTest.ts | 2 +- .../hooks/usePendingConciergeResponse.test.ts | 1 - 3 files changed, 3 insertions(+), 23 deletions(-) diff --git a/src/libs/actions/Report/SuggestedFollowup.ts b/src/libs/actions/Report/SuggestedFollowup.ts index 5df75c282c6a..41b3bd242ae8 100644 --- a/src/libs/actions/Report/SuggestedFollowup.ts +++ b/src/libs/actions/Report/SuggestedFollowup.ts @@ -89,28 +89,9 @@ function resolveSuggestedFollowup( addOptimisticConciergeActionWithDelay(reportID, optimisticConciergeAction); } -/** - * Cancels any pending optimistic concierge action for the given reportID. - * Clears the pending response from Onyx and the typing indicator atomically. - */ -function cancelPendingConciergeAction(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}, - }, - ]); -} - /** * Queues an optimistic concierge response for delayed display. - * Writes intent to Onyx — the ConciergeResponseScheduler component + * Writes action to Onyx — the usePendingConciergeResponse component * handles the actual delay and moves the action to REPORT_ACTIONS * when the time arrives, with proper lifecycle cleanup. */ @@ -134,4 +115,4 @@ function addOptimisticConciergeActionWithDelay(reportID: string, optimisticConci ]); } -export {resolveSuggestedFollowup, cancelPendingConciergeAction, CONCIERGE_RESPONSE_DELAY_MS}; +export {resolveSuggestedFollowup, CONCIERGE_RESPONSE_DELAY_MS}; diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 8b2c497c80c1..87e27c28ca80 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -4181,7 +4181,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(''); diff --git a/tests/unit/hooks/usePendingConciergeResponse.test.ts b/tests/unit/hooks/usePendingConciergeResponse.test.ts index a872c184c413..396953b46ded 100644 --- a/tests/unit/hooks/usePendingConciergeResponse.test.ts +++ b/tests/unit/hooks/usePendingConciergeResponse.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import {renderHook} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; From 178d2536feaa6d27db0d9ab2fb370a1215bae2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Fri, 13 Feb 2026 19:11:27 +0100 Subject: [PATCH 3/4] refactor --- src/hooks/usePendingConciergeResponse.ts | 48 ++++++++----------- src/libs/actions/Report/SuggestedFollowup.ts | 47 +++++++++++++++++- .../hooks/usePendingConciergeResponse.test.ts | 43 +++++++++++++++-- 3 files changed, 104 insertions(+), 34 deletions(-) diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index 23828967e0b6..55538284892a 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -1,16 +1,15 @@ import {useEffect} from 'react'; -import Onyx from 'react-native-onyx'; -import CONST from '@src/CONST'; +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. - * - * This keeps the action layer free of timers — all scheduling state lives in Onyx - * and the delay is managed by React component lifecycle. */ function usePendingConciergeResponse(reportID: string) { const [pendingResponse] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, {canBeMissing: true}); @@ -20,30 +19,21 @@ function usePendingConciergeResponse(reportID: string) { return; } - const remaining = Math.max(0, pendingResponse.displayAfter - Date.now()); - - const timer = setTimeout(() => { - Onyx.update([ - // Clear the pending response - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, - value: null, - }, - // Clear the typing indicator - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, - value: {[CONST.ACCOUNT_ID.CONCIERGE]: false}, - }, - // Add the concierge action to report actions - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: {[pendingResponse.reportAction.reportActionID]: pendingResponse.reportAction}, - }, - ]); - }, remaining); + 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]); diff --git a/src/libs/actions/Report/SuggestedFollowup.ts b/src/libs/actions/Report/SuggestedFollowup.ts index 41b3bd242ae8..3a6f24a9554d 100644 --- a/src/libs/actions/Report/SuggestedFollowup.ts +++ b/src/libs/actions/Report/SuggestedFollowup.ts @@ -91,7 +91,7 @@ function resolveSuggestedFollowup( /** * Queues an optimistic concierge response for delayed display. - * Writes action to Onyx — the usePendingConciergeResponse component + * 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. */ @@ -115,4 +115,47 @@ function addOptimisticConciergeActionWithDelay(reportID: string, optimisticConci ]); } -export {resolveSuggestedFollowup, CONCIERGE_RESPONSE_DELAY_MS}; +/** + * 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}, + }, + ]); +} + +/** + * 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, discardPendingConciergeAction, applyPendingConciergeAction, CONCIERGE_RESPONSE_DELAY_MS}; diff --git a/tests/unit/hooks/usePendingConciergeResponse.test.ts b/tests/unit/hooks/usePendingConciergeResponse.test.ts index 396953b46ded..e69086c9c092 100644 --- a/tests/unit/hooks/usePendingConciergeResponse.test.ts +++ b/tests/unit/hooks/usePendingConciergeResponse.test.ts @@ -10,7 +10,7 @@ import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; const REPORT_ID = '1'; const REPORT_ACTION_ID = '100'; -/** Short delay for real-timer tests (ms) */ +/** Short delay used for tests where we need the timer to actually fire (ms) */ const SHORT_DELAY = 80; const fakeConciergeAction = { @@ -76,11 +76,11 @@ describe('usePendingConciergeResponse', () => { // Given a pending concierge response with a longer delay await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { reportAction: fakeConciergeAction, - displayAfter: Date.now() + 5000, + displayAfter: Date.now() + SHORT_DELAY, }); await waitForBatchedUpdates(); - renderHook(() => usePendingConciergeResponse(REPORT_ID)); + const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID)); await waitForBatchedUpdates(); // When checked immediately (well before the delay) @@ -91,6 +91,9 @@ describe('usePendingConciergeResponse', () => { // 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 () => { @@ -114,6 +117,10 @@ describe('usePendingConciergeResponse', () => { // 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 () => { @@ -143,6 +150,36 @@ describe('usePendingConciergeResponse', () => { 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)); From 26e90ed5b399a7a3aebd2479dc0da09cb51dbcc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Fri, 13 Feb 2026 19:17:18 +0100 Subject: [PATCH 4/4] update timeout to 4s --- src/libs/actions/Report/SuggestedFollowup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report/SuggestedFollowup.ts b/src/libs/actions/Report/SuggestedFollowup.ts index 3a6f24a9554d..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 = 3000; +const CONCIERGE_RESPONSE_DELAY_MS = 4000; /** * Resolves a suggested followup by posting the selected question as a comment