From 8b33174b58337575a6a0da8f1e7ce164216d4d3d Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Wed, 21 Oct 2026 18:36:40 +0530 Subject: [PATCH 01/38] Open a support ticket straight from the Talk to a human link --- src/ROUTES.ts | 1 + src/SCREENS.ts | 1 + src/hooks/useOpenSupportTicket.ts | 32 ++++++++++++++ .../Navigators/SettingsSplitNavigator.tsx | 1 + src/libs/Navigation/linkingConfig/config.ts | 4 ++ src/libs/Navigation/types.ts | 1 + src/pages/settings/HelpPage/HelpPage.tsx | 18 ++------ .../settings/HelpPage/TalkToHumanPage.tsx | 42 +++++++++++++++++++ 8 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 src/hooks/useOpenSupportTicket.ts create mode 100644 src/pages/settings/HelpPage/TalkToHumanPage.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7a045d50074d..138f7085b031 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2786,6 +2786,7 @@ const ROUTES = { SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', SETTINGS_TROUBLESHOOT_SERVER: 'settings/troubleshoot/server', SETTINGS_HELP: 'settings/help', + SETTINGS_HELP_TALK_TO_HUMAN: 'settings/help/talk-to-human', SETTINGS_SAVE_THE_WORLD: 'settings/teachersunite', SETTINGS_SAVE_THE_WORLD_ADD_PAYMENT_CARD: 'settings/teachersunite/add-payment-card', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1bd0adb6f895..6e96e5b31596 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -98,6 +98,7 @@ const SCREENS = { TROUBLESHOOT: 'Settings_Troubleshoot', TROUBLESHOOT_SERVER: 'Settings_Troubleshoot_Server', HELP: 'Settings_Help', + HELP_TALK_TO_HUMAN: 'Settings_Help_TalkToHuman', EMAIL_ISSUE: 'Settings_Email_Issue', DYNAMIC_VERIFY_ACCOUNT: 'Dynamic_Verify_Account', DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT: 'Dynamic_Add_Bank_Account_Verify_Account', diff --git a/src/hooks/useOpenSupportTicket.ts b/src/hooks/useOpenSupportTicket.ts new file mode 100644 index 000000000000..fe9294d3dfbf --- /dev/null +++ b/src/hooks/useOpenSupportTicket.ts @@ -0,0 +1,32 @@ +import Growl from '@libs/Growl'; +import Navigation from '@libs/Navigation/Navigation'; + +import {isNoSupportRepAvailableResponse, openSupportTicket} from '@userActions/Report'; + +import useLocalize from './useLocalize'; +import useOpenConciergeAnywhere from './useOpenConciergeAnywhere'; + +/** + * Returns a callback that opens a support ticket, falling back to Concierge when no support rep is free. + * Shared by the Help page row and the Talk to a human route so both behave the same. + */ +function useOpenSupportTicket() { + const {translate} = useLocalize(); + const {openConciergeAnywhere} = useOpenConciergeAnywhere(); + + const openSupportTicketOrConcierge = () => + openSupportTicket() + .then((response) => { + if (!isNoSupportRepAvailableResponse(response)) { + return; + } + + Growl.error(translate('supportTicket.noSupportRepAvailable')); + Navigation.goBack(undefined, {afterTransition: () => openConciergeAnywhere({forceConcierge: true})}); + }) + .catch(() => undefined); + + return {openSupportTicketOrConcierge}; +} + +export default useOpenSupportTicket; diff --git a/src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx index 38fde12730d9..9e620a1c5374 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx @@ -25,6 +25,7 @@ const CENTRAL_PANE_SETTINGS_SCREENS = { [SCREENS.SETTINGS.AGENTS.ROOT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/AgentsPage').default), [SCREENS.SETTINGS.RULES.ROOT]: () => require('../../../../pages/settings/Rules/ExpenseRulesPage').default, [SCREENS.SETTINGS.HELP]: () => require('../../../../pages/settings/HelpPage/HelpPage').default, + [SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]: () => require('../../../../pages/settings/HelpPage/TalkToHumanPage').default, [SCREENS.SETTINGS.ABOUT]: () => require('../../../../pages/settings/AboutPage/AboutPage').default, [SCREENS.SETTINGS.TROUBLESHOOT]: () => require('../../../../pages/settings/Troubleshoot/TroubleshootPage').default, [SCREENS.SETTINGS.SAVE_THE_WORLD]: () => require('../../../../pages/TeachersUnite/SaveTheWorldPage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 1bdfa5e07ddb..878a31682b36 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -2469,6 +2469,10 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_HELP, exact: true, }, + [SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]: { + path: ROUTES.SETTINGS_HELP_TALK_TO_HUMAN, + exact: true, + }, [SCREENS.SETTINGS.TROUBLESHOOT]: { path: ROUTES.SETTINGS_TROUBLESHOOT, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index fecf0231d182..10771b42d82f 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1042,6 +1042,7 @@ type SettingsSplitNavigatorParamList = { [SCREENS.SETTINGS.RULES.ROOT]: undefined; [SCREENS.SETTINGS.ABOUT]: undefined; [SCREENS.SETTINGS.HELP]: undefined; + [SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]: undefined; [SCREENS.SETTINGS.TROUBLESHOOT]: undefined; [SCREENS.SETTINGS.SAVE_THE_WORLD]: undefined; [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]?: { diff --git a/src/pages/settings/HelpPage/HelpPage.tsx b/src/pages/settings/HelpPage/HelpPage.tsx index 97a6d58d298b..7958aeff77c4 100644 --- a/src/pages/settings/HelpPage/HelpPage.tsx +++ b/src/pages/settings/HelpPage/HelpPage.tsx @@ -13,6 +13,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; +import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -21,8 +22,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {openHelpPage} from '@libs/actions/Help'; import {openExternalLink} from '@libs/actions/Link'; -import {isNoSupportRepAvailableResponse, navigateToAndOpenReportWithAccountIDs, openSupportTicket} from '@libs/actions/Report'; -import Growl from '@libs/Growl'; +import {navigateToAndOpenReportWithAccountIDs} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import colors from '@styles/theme/colors'; @@ -64,21 +64,9 @@ function HelpPage() { const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {openConciergeAnywhere} = useOpenConciergeAnywhere(); + const {openSupportTicketOrConcierge} = useOpenSupportTicket(); const {isOffline} = useNetwork(); - const openSupportTicketOrConcierge = () => { - openSupportTicket() - .then((response) => { - if (!isNoSupportRepAvailableResponse(response)) { - return; - } - - Growl.error(translate('supportTicket.noSupportRepAvailable')); - Navigation.goBack(undefined, {afterTransition: () => openConciergeAnywhere({forceConcierge: true})}); - }) - .catch(() => undefined); - }; - // Remove the row's accessibility grouping so native (iOS/Android) screen readers can announce the nested // Book a call button as its own element; on web this prop is a no-op and the button is reached via keyboard Tab instead const shouldBeAccessibleWithBookCallButton = (calendarLink: string | undefined) => !calendarLink; diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx new file mode 100644 index 000000000000..5f2b98be0bbb --- /dev/null +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -0,0 +1,42 @@ +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; + +import useNetwork from '@hooks/useNetwork'; +import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; + +import Navigation from '@libs/Navigation/Navigation'; + +import ROUTES from '@src/ROUTES'; + +import React, {useEffect, useRef} from 'react'; + +/** + * Opens a support ticket straight away, so the Talk to a human link in a Concierge message reaches a + * person without the customer having to find the row on the Help page. + */ +function TalkToHumanPage() { + const {openSupportTicketOrConcierge} = useOpenSupportTicket(); + const {isOffline} = useNetwork(); + const hasRequestedTicket = useRef(false); + + useEffect(() => { + // Creating a ticket needs the network, so send the customer to the Help page to try again once they are back online. + if (isOffline) { + Navigation.goBack(ROUTES.SETTINGS_HELP); + return; + } + + // A remount would otherwise open a second ticket that a support rep has to close. + if (hasRequestedTicket.current) { + return; + } + hasRequestedTicket.current = true; + openSupportTicketOrConcierge(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOffline]); + + return ; +} + +TalkToHumanPage.displayName = 'TalkToHumanPage'; + +export default TalkToHumanPage; From 27bbef5ff4b41d704fb56db8f21f6ba285c23a85 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Wed, 21 Oct 2026 20:33:02 +0530 Subject: [PATCH 02/38] Leave the action route before opening the ticket and justify the hook suppression --- src/pages/settings/HelpPage/TalkToHumanPage.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx index 5f2b98be0bbb..5f040dda635c 100644 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -30,11 +30,17 @@ function TalkToHumanPage() { return; } hasRequestedTicket.current = true; - openSupportTicketOrConcierge(); + + // Leave this route before creating the ticket. The ticket stores the route it was opened from as its back target, + // so opening it from here would make Back return to this loader instead of the chat the link was pressed in. + Navigation.goBack(undefined, {afterTransition: openSupportTicketOrConcierge}); + + // openSupportTicketOrConcierge is a new function on every render and hasRequestedTicket already stops a second + // request, so this effect only needs to react to the connection changing. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOffline]); - return ; + return ; } TalkToHumanPage.displayName = 'TalkToHumanPage'; From 8bb3d5b37bedf67ebe2a11066839cf232e1fe33a Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Wed, 21 Oct 2026 20:34:04 +0530 Subject: [PATCH 03/38] Fall back to the Help page when a direct link has nothing to go back to --- src/pages/settings/HelpPage/TalkToHumanPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx index 5f040dda635c..fa470773346b 100644 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -4,6 +4,7 @@ import useNetwork from '@hooks/useNetwork'; import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; import ROUTES from '@src/ROUTES'; @@ -33,7 +34,10 @@ function TalkToHumanPage() { // Leave this route before creating the ticket. The ticket stores the route it was opened from as its back target, // so opening it from here would make Back return to this loader instead of the chat the link was pressed in. - Navigation.goBack(undefined, {afterTransition: openSupportTicketOrConcierge}); + // A direct link has nothing behind it to return to and goBack would then skip afterTransition, so it falls back + // to the Help page and opens the ticket from there. + const fallbackRoute = navigationRef.current?.canGoBack() ? undefined : ROUTES.SETTINGS_HELP; + Navigation.goBack(fallbackRoute, {afterTransition: openSupportTicketOrConcierge}); // openSupportTicketOrConcierge is a new function on every render and hasRequestedTicket already stops a second // request, so this effect only needs to react to the connection changing. From 37a9c923c90dff74a337548b68908c34f774fd8b Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 22 Oct 2026 03:31:52 +0530 Subject: [PATCH 04/38] Fix the Talk to a human afterTransition type --- src/pages/settings/HelpPage/TalkToHumanPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx index fa470773346b..bfa43e9ae757 100644 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -37,7 +37,11 @@ function TalkToHumanPage() { // A direct link has nothing behind it to return to and goBack would then skip afterTransition, so it falls back // to the Help page and opens the ticket from there. const fallbackRoute = navigationRef.current?.canGoBack() ? undefined : ROUTES.SETTINGS_HELP; - Navigation.goBack(fallbackRoute, {afterTransition: openSupportTicketOrConcierge}); + Navigation.goBack(fallbackRoute, { + afterTransition: () => { + openSupportTicketOrConcierge(); + }, + }); // openSupportTicketOrConcierge is a new function on every render and hasRequestedTicket already stops a second // request, so this effect only needs to react to the connection changing. From 723f39a201f7b87298696421a48e3a92814d45eb Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 22 Oct 2026 03:44:34 +0530 Subject: [PATCH 05/38] Set shouldUseGoBackButton explicitly on the Talk to a human loader --- src/pages/settings/HelpPage/TalkToHumanPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx index bfa43e9ae757..c2c3be5c906c 100644 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -48,7 +48,7 @@ function TalkToHumanPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOffline]); - return ; + return ; } TalkToHumanPage.displayName = 'TalkToHumanPage'; From c49ecdd81ba8993feecd8ba5952400bb01e6be46 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 23 Oct 2026 23:17:26 +0530 Subject: [PATCH 06/38] Restore upstream formatting of files reformatted by the merge commit hook --- .../AppNavigator/AuthScreensInitHandler.tsx | 16 ++------------ src/libs/TransactionUtils/index.ts | 14 +++++++------ .../ComposerWithSuggestions.tsx | 21 +++++++++++-------- 3 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index 067e69f89324..b46f91efc8bb 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -160,13 +160,7 @@ function AuthScreensInitHandler() { return; } // This means sign in in RHP was successful, so we can subscribe to user events - initializePusher( - session?.accountID, - session?.email, - () => topmostOneTransactionThreadReportIDRef.current, - formatPhoneNumber, - () => reportAttributesRef.current, - ); + initializePusher(session?.accountID, session?.email, () => topmostOneTransactionThreadReportIDRef.current, formatPhoneNumber, () => reportAttributesRef.current); // eslint-disable-next-line react-hooks/exhaustive-deps -- This handler should only be re-registered when the session changes. }, [session?.accountID, session?.email]); @@ -195,13 +189,7 @@ function AuthScreensInitHandler() { }); PusherConnectionManager.init(); - initializePusher( - session?.accountID, - session?.email, - () => topmostOneTransactionThreadReportIDRef.current, - formatPhoneNumber, - () => reportAttributesRef.current, - ).finally(() => { + initializePusher(session?.accountID, session?.email, () => topmostOneTransactionThreadReportIDRef.current, formatPhoneNumber, () => reportAttributesRef.current).finally(() => { endSpan(CONST.TELEMETRY.SPAN_NAVIGATION.PUSHER_INIT); }); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 89bd0fbaee47..2c8630740931 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1566,12 +1566,14 @@ function getChildTransactions(transactions: OnyxCollection, origina function createUnreportedExpenses(transactions: Array | undefined>): UnreportedExpenseListItemType[] { return transactions .filter((t): t is Transaction => t !== undefined) - .map((transaction): UnreportedExpenseListItemType => ({ - ...transaction, - isDisabled: isTransactionPendingDelete(transaction), - keyForList: transaction.transactionID, - errors: transaction.errors as Errors | undefined, - })); + .map( + (transaction): UnreportedExpenseListItemType => ({ + ...transaction, + isDisabled: isTransactionPendingDelete(transaction), + keyForList: transaction.transactionID, + errors: transaction.errors as Errors | undefined, + }), + ); } type GetEligibleTransactionsToAddParams = { diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index c883603c1a6d..c274290c2ff1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -956,15 +956,18 @@ function ComposerWithSuggestions({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useImperativeHandle(ref, (): ComposerWithSuggestionsRef => ({ - focus, - replaceSelectionWithText, - getCurrentText, - clearWorklet, - resetHeight, - blur: () => composerRef.current?.blur(), - isFocused: () => composerRef.current?.isFocused() ?? false, - })); + useImperativeHandle( + ref, + (): ComposerWithSuggestionsRef => ({ + focus, + replaceSelectionWithText, + getCurrentText, + clearWorklet, + resetHeight, + blur: () => composerRef.current?.blur(), + isFocused: () => composerRef.current?.isFocused() ?? false, + }), + ); const onClear = useCallback( (textOnClear: string) => { From ac3d8ece3b40aaf31cff4c30bbf0cd683db1a2b1 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 03:29:46 +0530 Subject: [PATCH 07/38] Return nothing from the support ticket callback so Help rows are not held disabled on native --- src/hooks/useOpenSupportTicket.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useOpenSupportTicket.ts b/src/hooks/useOpenSupportTicket.ts index c38b8101bb46..1741c33acf92 100644 --- a/src/hooks/useOpenSupportTicket.ts +++ b/src/hooks/useOpenSupportTicket.ts @@ -14,7 +14,7 @@ function useOpenSupportTicket() { const {translate} = useLocalize(); const {openConciergeAnywhere} = useOpenConciergeAnywhere(); - const openSupportTicketOrConcierge = () => + const openSupportTicketOrConcierge = () => { openSupportTicket() .then((response) => { if (!isNoSupportRepAvailableResponse(response)) { @@ -25,6 +25,7 @@ function useOpenSupportTicket() { Navigation.goBack(undefined, {afterTransition: () => openConciergeAnywhere({forceConcierge: true})}); }) .catch(() => undefined); + }; return {openSupportTicketOrConcierge}; } From 99d3f0242c12d076cd6056743452af6f24a3c70d Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 03:29:50 +0530 Subject: [PATCH 08/38] Drop the redundant displayName and shouldUseGoBackButton from the Talk to a human page --- src/pages/settings/HelpPage/TalkToHumanPage.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx index c2c3be5c906c..51d0d8cf259d 100644 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -48,9 +48,7 @@ function TalkToHumanPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOffline]); - return ; + return ; } -TalkToHumanPage.displayName = 'TalkToHumanPage'; - export default TalkToHumanPage; From 81931b20bb4c2ab9c3591359026d3d8bcef9c3cf Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 03:34:33 +0530 Subject: [PATCH 09/38] Show the Help page when the link opens offline and detect direct links from the root routes --- .../settings/HelpPage/TalkToHumanPage.tsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx index 51d0d8cf259d..96f007c7e5f8 100644 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -3,12 +3,15 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import useNetwork from '@hooks/useNetwork'; import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; +import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import ROUTES from '@src/ROUTES'; -import React, {useEffect, useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; + +import HelpPage from './HelpPage'; /** * Opens a support ticket straight away, so the Talk to a human link in a Concierge message reaches a @@ -17,36 +20,35 @@ import React, {useEffect, useRef} from 'react'; function TalkToHumanPage() { const {openSupportTicketOrConcierge} = useOpenSupportTicket(); const {isOffline} = useNetwork(); + + // Creating a ticket needs the network, so a customer who opens the link offline gets the Help page to try again once they are back online. + const [wasOfflineOnOpen] = useState(isOffline); const hasRequestedTicket = useRef(false); useEffect(() => { - // Creating a ticket needs the network, so send the customer to the Help page to try again once they are back online. - if (isOffline) { - Navigation.goBack(ROUTES.SETTINGS_HELP); - return; - } - - // A remount would otherwise open a second ticket that a support rep has to close. - if (hasRequestedTicket.current) { + // Stops a second request if this effect runs again while the screen stays mounted. + if (wasOfflineOnOpen || hasRequestedTicket.current) { return; } hasRequestedTicket.current = true; // Leave this route before creating the ticket. The ticket stores the route it was opened from as its back target, // so opening it from here would make Back return to this loader instead of the chat the link was pressed in. - // A direct link has nothing behind it to return to and goBack would then skip afterTransition, so it falls back - // to the Help page and opens the ticket from there. - const fallbackRoute = navigationRef.current?.canGoBack() ? undefined : ROUTES.SETTINGS_HELP; + // A link pressed in the app pushes a second full screen route under this one. A direct link only has the Settings + // sidebar that the split router adds behind it, so there is no page to return to and goBack would skip afterTransition. + // It falls back to the Help page and opens the ticket from there. + const fullScreenRouteCount = navigationRef.current?.getRootState().routes.filter((route) => !isSideModalNavigator(route.name)).length ?? 0; + const fallbackRoute = fullScreenRouteCount > 1 ? undefined : ROUTES.SETTINGS_HELP; Navigation.goBack(fallbackRoute, { afterTransition: () => { openSupportTicketOrConcierge(); }, }); + }, [wasOfflineOnOpen, openSupportTicketOrConcierge]); - // openSupportTicketOrConcierge is a new function on every render and hasRequestedTicket already stops a second - // request, so this effect only needs to react to the connection changing. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOffline]); + if (wasOfflineOnOpen) { + return ; + } return ; } From 95e8693957ba943af380cb5e7656b556c0e8967d Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 03:34:33 +0530 Subject: [PATCH 10/38] Test the Talk to a human page and the support ticket callback --- tests/ui/TalkToHumanPageTest.tsx | 106 +++++++++++++++++++++++++ tests/unit/useOpenSupportTicketTest.ts | 31 ++++++++ 2 files changed, 137 insertions(+) create mode 100644 tests/ui/TalkToHumanPageTest.tsx create mode 100644 tests/unit/useOpenSupportTicketTest.ts diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx new file mode 100644 index 000000000000..2b64c025b029 --- /dev/null +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -0,0 +1,106 @@ +import {render, screen} from '@testing-library/react-native'; + +import useNetwork from '@hooks/useNetwork'; + +import Navigation from '@libs/Navigation/Navigation'; + +import TalkToHumanPage from '@pages/settings/HelpPage/TalkToHumanPage'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; + +import type React from 'react'; + +const mockOpenSupportTicketOrConcierge = jest.fn(); +const mockGetRootState = jest.fn<{routes: Array<{name: string}>}, []>(); + +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), +})); +jest.mock('@libs/Navigation/navigationRef', () => ({ + current: {getRootState: () => mockGetRootState()}, +})); +jest.mock('@hooks/useNetwork', () => jest.fn()); +jest.mock('@hooks/useOpenSupportTicket', () => () => ({openSupportTicketOrConcierge: mockOpenSupportTicketOrConcierge})); +jest.mock('@pages/settings/HelpPage/HelpPage', () => () => jest.requireActual('react').createElement('Text', null, 'Help page')); +jest.mock('@components/FullscreenLoadingIndicator', () => () => jest.requireActual('react').createElement('Text', null, 'Loading')); + +const mockUseNetwork = jest.mocked(useNetwork); +const mockGoBack = jest.mocked(Navigation.goBack); + +function setRootRoutes(names: string[]) { + mockGetRootState.mockReturnValue({routes: names.map((name) => ({name}))}); +} + +describe('TalkToHumanPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); + }); + + it('goes back to the page the link was pressed on and opens the ticket after the transition', () => { + // Given a link pressed in the app, which leaves a second full screen route under this one + setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); + + render(); + + // Then it leaves the loader without a fallback route + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockGoBack.mock.calls.at(0)?.at(0)).toBeUndefined(); + + // And the ticket is only requested once the transition has finished + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); + const goBackOptions: {afterTransition?: () => void} | undefined = mockGoBack.mock.calls.at(0)?.at(1); + goBackOptions?.afterTransition?.(); + expect(mockOpenSupportTicketOrConcierge).toHaveBeenCalledTimes(1); + }); + + it('falls back to the Help page for a direct link, where only one full screen route exists', () => { + setRootRoutes([NAVIGATORS.TAB_NAVIGATOR]); + + render(); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockGoBack.mock.calls.at(0)?.at(0)).toBe(ROUTES.SETTINGS_HELP); + }); + + it('does not count a side modal as a page to return to', () => { + setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.RIGHT_MODAL_NAVIGATOR]); + + render(); + + expect(mockGoBack.mock.calls.at(0)?.at(0)).toBe(ROUTES.SETTINGS_HELP); + }); + + it('shows the Help page and creates no ticket when the link is opened offline', () => { + mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); + setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); + + render(); + + expect(screen.getByText('Help page')).toBeOnTheScreen(); + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); + }); + + it('keeps showing the Help page when the connection returns after an offline open', () => { + mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); + setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); + const {rerender} = render(); + + mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); + rerender(); + + expect(screen.getByText('Help page')).toBeOnTheScreen(); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('does not request a second ticket when it re-renders while mounted', () => { + setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); + const {rerender} = render(); + + rerender(); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts new file mode 100644 index 000000000000..0782e8a02479 --- /dev/null +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -0,0 +1,31 @@ +import {renderHook} from '@testing-library/react-native'; + +import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; + +import {openSupportTicket} from '@userActions/Report'; + +jest.mock('@userActions/Report', () => ({ + openSupportTicket: jest.fn(), + isNoSupportRepAvailableResponse: jest.fn(() => false), +})); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@libs/Growl', () => ({error: jest.fn()})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: jest.fn()})); + +const mockOpenSupportTicket = jest.mocked(openSupportTicket); + +describe('useOpenSupportTicket', () => { + it('returns nothing from the callback so a menu row does not wait on the ticket request', () => { + // Given a ticket request that has not finished + mockOpenSupportTicket.mockReturnValue(new Promise(() => {})); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs + const returnValue: unknown = result.current.openSupportTicketOrConcierge(); + + // Then it returns nothing, so single execution on native clears as soon as the transition ends + expect(returnValue).toBeUndefined(); + expect(mockOpenSupportTicket).toHaveBeenCalledTimes(1); + }); +}); From 46a2b7265259cee8f45ee0cce6d6a2d5f37a3303 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 03:56:48 +0530 Subject: [PATCH 11/38] Replace the route with Help when opened offline and keep the origin for same-split presses --- .../settings/HelpPage/TalkToHumanPage.tsx | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx index 96f007c7e5f8..ba5e9e03a541 100644 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -9,10 +9,9 @@ import navigationRef from '@libs/Navigation/navigationRef'; import ROUTES from '@src/ROUTES'; +import {useNavigation} from '@react-navigation/native'; import React, {useEffect, useRef, useState} from 'react'; -import HelpPage from './HelpPage'; - /** * Opens a support ticket straight away, so the Talk to a human link in a Concierge message reaches a * person without the customer having to find the row on the Help page. @@ -20,35 +19,38 @@ import HelpPage from './HelpPage'; function TalkToHumanPage() { const {openSupportTicketOrConcierge} = useOpenSupportTicket(); const {isOffline} = useNetwork(); + const navigation = useNavigation(); - // Creating a ticket needs the network, so a customer who opens the link offline gets the Help page to try again once they are back online. + // Creating a ticket needs the network, so a customer who opens the link offline lands on the Help page to try again once they are back online. const [wasOfflineOnOpen] = useState(isOffline); - const hasRequestedTicket = useRef(false); + const hasHandledLink = useRef(false); useEffect(() => { // Stops a second request if this effect runs again while the screen stays mounted. - if (wasOfflineOnOpen || hasRequestedTicket.current) { + if (hasHandledLink.current) { + return; + } + hasHandledLink.current = true; + + // Replace this route so the Help page keeps its own URL, which a ticket opened from it stores as its back target. + if (wasOfflineOnOpen) { + Navigation.navigate(ROUTES.SETTINGS_HELP, {forceReplace: true}); return; } - hasRequestedTicket.current = true; // Leave this route before creating the ticket. The ticket stores the route it was opened from as its back target, - // so opening it from here would make Back return to this loader instead of the chat the link was pressed in. - // A link pressed in the app pushes a second full screen route under this one. A direct link only has the Settings - // sidebar that the split router adds behind it, so there is no page to return to and goBack would skip afterTransition. - // It falls back to the Help page and opens the ticket from there. + // so opening it from here would make Back return to this loader instead of the page the link was pressed on. + // A direct link has only the Settings sidebar that the split router adds behind it, so there is no page to return + // to and goBack would skip afterTransition. It falls back to the Help page and opens the ticket from there. + // A link pressed in the app has a second full screen route under this one, or an earlier page in this Settings split. const fullScreenRouteCount = navigationRef.current?.getRootState().routes.filter((route) => !isSideModalNavigator(route.name)).length ?? 0; - const fallbackRoute = fullScreenRouteCount > 1 ? undefined : ROUTES.SETTINGS_HELP; - Navigation.goBack(fallbackRoute, { + const hasPageToReturnTo = fullScreenRouteCount > 1 || (navigation.getState()?.routes.length ?? 0) > 2; + Navigation.goBack(hasPageToReturnTo ? undefined : ROUTES.SETTINGS_HELP, { afterTransition: () => { openSupportTicketOrConcierge(); }, }); - }, [wasOfflineOnOpen, openSupportTicketOrConcierge]); - - if (wasOfflineOnOpen) { - return ; - } + }, [wasOfflineOnOpen, navigation, openSupportTicketOrConcierge]); return ; } From fc9d1b60cce1023d05bae86bd20bd77e80a0a649 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 03:56:48 +0530 Subject: [PATCH 12/38] Cover the offline replace and same-split cases, add Given/When/Then, and fix the test typing --- tests/navigation/NavigateTests.tsx | 42 ++++++++++++++ tests/ui/TalkToHumanPageTest.tsx | 75 ++++++++++++++++++++----- tests/utils/TestNavigationContainer.tsx | 8 +++ 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/tests/navigation/NavigateTests.tsx b/tests/navigation/NavigateTests.tsx index b1d578794426..5d4f508cd26b 100644 --- a/tests/navigation/NavigateTests.tsx +++ b/tests/navigation/NavigateTests.tsx @@ -156,6 +156,48 @@ describe('Navigate', () => { expect(settingsSplitAfterGoBack?.state?.routes.at(-1)?.name).toBe(SCREENS.SETTINGS.ABOUT); }); + it('replaces the Talk to a human route with the Help page', () => { + // Given the Talk to a human route open on top of the Settings sidebar + render( + , + ); + + // When the page is replaced by the Help page + act(() => { + Navigation.navigate(ROUTES.SETTINGS_HELP, {forceReplace: true}); + }); + + // Then the Help page takes its place and nothing is left behind it + const settingsSplit = navigationRef.current?.getRootState().routes.at(0)?.state?.routes.at(4); + expect(settingsSplit?.state?.routes.map((route) => route.name)).toEqual([SCREENS.SETTINGS.ROOT, SCREENS.SETTINGS.HELP]); + }); + it('returns to the Workspaces list after directly opening a Workspace subpage without its sidebar', () => { render( }, []>(); +const mockGetNavigatorState = jest.fn<{routes: Array<{name: string}>}, []>(); jest.mock('@libs/Navigation/Navigation', () => ({ goBack: jest.fn(), + navigate: jest.fn(), })); jest.mock('@libs/Navigation/navigationRef', () => ({ current: {getRootState: () => mockGetRootState()}, })); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({getState: () => mockGetNavigatorState()}), +})); jest.mock('@hooks/useNetwork', () => jest.fn()); jest.mock('@hooks/useOpenSupportTicket', () => () => ({openSupportTicketOrConcierge: mockOpenSupportTicketOrConcierge})); -jest.mock('@pages/settings/HelpPage/HelpPage', () => () => jest.requireActual('react').createElement('Text', null, 'Help page')); jest.mock('@components/FullscreenLoadingIndicator', () => () => jest.requireActual('react').createElement('Text', null, 'Loading')); const mockUseNetwork = jest.mocked(useNetwork); const mockGoBack = jest.mocked(Navigation.goBack); +const mockNavigate = jest.mocked(Navigation.navigate); function setRootRoutes(names: string[]) { mockGetRootState.mockReturnValue({routes: names.map((name) => ({name}))}); } +function setSettingsSplitRoutes(names: string[]) { + mockGetNavigatorState.mockReturnValue({routes: names.map((name) => ({name}))}); +} + +function getGoBackFallbackRoute() { + return mockGoBack.mock.calls.at(0)?.at(0); +} + describe('TalkToHumanPage', () => { beforeEach(() => { jest.clearAllMocks(); mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); + setSettingsSplitRoutes(['talkToHuman']); }); it('goes back to the page the link was pressed on and opens the ticket after the transition', () => { // Given a link pressed in the app, which leaves a second full screen route under this one setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); + // When the page opens render(); // Then it leaves the loader without a fallback route expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(mockGoBack.mock.calls.at(0)?.at(0)).toBeUndefined(); + expect(getGoBackFallbackRoute()).toBeUndefined(); // And the ticket is only requested once the transition has finished expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); - const goBackOptions: {afterTransition?: () => void} | undefined = mockGoBack.mock.calls.at(0)?.at(1); - goBackOptions?.afterTransition?.(); + const goBackOptions = mockGoBack.mock.calls.at(0)?.at(1); + if (typeof goBackOptions === 'object') { + goBackOptions.afterTransition?.(); + } expect(mockOpenSupportTicketOrConcierge).toHaveBeenCalledTimes(1); }); - it('falls back to the Help page for a direct link, where only one full screen route exists', () => { + it('falls back to the Help page for a direct link, where only the injected Settings sidebar sits behind it', () => { + // Given a cold load with one full screen route and the Settings sidebar added under this page setRootRoutes([NAVIGATORS.TAB_NAVIGATOR]); + setSettingsSplitRoutes(['settingsRoot', 'talkToHuman']); + // When the page opens render(); + // Then it falls back to the Help page expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(mockGoBack.mock.calls.at(0)?.at(0)).toBe(ROUTES.SETTINGS_HELP); + expect(getGoBackFallbackRoute()).toBe(ROUTES.SETTINGS_HELP); }); it('does not count a side modal as a page to return to', () => { + // Given a direct link with a side modal open next to the only full screen route setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.RIGHT_MODAL_NAVIGATOR]); + setSettingsSplitRoutes(['settingsRoot', 'talkToHuman']); + + // When the page opens + render(); + // Then it still falls back to the Help page + expect(getGoBackFallbackRoute()).toBe(ROUTES.SETTINGS_HELP); + }); + + it('goes back to the earlier Settings page when the link was pressed from another page in the same Settings split', () => { + // Given a single full screen route whose Settings split holds an earlier page under this one + setRootRoutes([NAVIGATORS.TAB_NAVIGATOR]); + setSettingsSplitRoutes(['settingsRoot', 'profile', 'talkToHuman']); + + // When the page opens render(); - expect(mockGoBack.mock.calls.at(0)?.at(0)).toBe(ROUTES.SETTINGS_HELP); + // Then it leaves the loader without a fallback route + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(getGoBackFallbackRoute()).toBeUndefined(); }); - it('shows the Help page and creates no ticket when the link is opened offline', () => { + it('replaces itself with the Help page and creates no ticket when the link is opened offline', () => { + // Given the link opens while the device is offline mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); + // When the page opens render(); - expect(screen.getByText('Help page')).toBeOnTheScreen(); + // Then the route is replaced by the Help page so that a ticket opened from there does not store this link as its back target + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_HELP, {forceReplace: true}); expect(mockGoBack).not.toHaveBeenCalled(); expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); }); - it('keeps showing the Help page when the connection returns after an offline open', () => { + it('does nothing more when the connection returns after an offline open', () => { + // Given the link opened offline mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); const {rerender} = render(); + // When the connection comes back mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); rerender(); - expect(screen.getByText('Help page')).toBeOnTheScreen(); + // Then no ticket is created and no other navigation happens + expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); }); it('does not request a second ticket when it re-renders while mounted', () => { + // Given the page has already left the loader setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); const {rerender} = render(); + // When it re-renders rerender(); + // Then it only went back once expect(mockGoBack).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/utils/TestNavigationContainer.tsx b/tests/utils/TestNavigationContainer.tsx index ec4183408b09..7850c9934143 100644 --- a/tests/utils/TestNavigationContainer.tsx +++ b/tests/utils/TestNavigationContainer.tsx @@ -204,6 +204,14 @@ function TestSettingsSplitNavigator() { name={SCREENS.SETTINGS.SUBSCRIPTION.ROOT} getComponent={getEmptyComponent} /> + + ); } From bf9837691f70c867baf7513ed7eb9153ffbbc92f Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 04:15:53 +0530 Subject: [PATCH 13/38] Simplify the Talk to a human page: read isOffline directly, pass afterTransition as is, trim comments --- .../settings/HelpPage/TalkToHumanPage.tsx | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx index ba5e9e03a541..82e5eb79d52e 100644 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -10,7 +10,7 @@ import navigationRef from '@libs/Navigation/navigationRef'; import ROUTES from '@src/ROUTES'; import {useNavigation} from '@react-navigation/native'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef} from 'react'; /** * Opens a support ticket straight away, so the Talk to a human link in a Concierge message reaches a @@ -20,37 +20,26 @@ function TalkToHumanPage() { const {openSupportTicketOrConcierge} = useOpenSupportTicket(); const {isOffline} = useNetwork(); const navigation = useNavigation(); - - // Creating a ticket needs the network, so a customer who opens the link offline lands on the Help page to try again once they are back online. - const [wasOfflineOnOpen] = useState(isOffline); const hasHandledLink = useRef(false); useEffect(() => { - // Stops a second request if this effect runs again while the screen stays mounted. if (hasHandledLink.current) { return; } hasHandledLink.current = true; - // Replace this route so the Help page keeps its own URL, which a ticket opened from it stores as its back target. - if (wasOfflineOnOpen) { + // Creating a ticket needs the network, so replace this route with the Help page and let the customer retry from there. + if (isOffline) { Navigation.navigate(ROUTES.SETTINGS_HELP, {forceReplace: true}); return; } - // Leave this route before creating the ticket. The ticket stores the route it was opened from as its back target, - // so opening it from here would make Back return to this loader instead of the page the link was pressed on. - // A direct link has only the Settings sidebar that the split router adds behind it, so there is no page to return - // to and goBack would skip afterTransition. It falls back to the Help page and opens the ticket from there. - // A link pressed in the app has a second full screen route under this one, or an earlier page in this Settings split. + // Leave this route first, because the ticket stores the route it opens from as its Back target. + // A direct link has only the injected Settings sidebar behind it, so fall back to the Help page. const fullScreenRouteCount = navigationRef.current?.getRootState().routes.filter((route) => !isSideModalNavigator(route.name)).length ?? 0; const hasPageToReturnTo = fullScreenRouteCount > 1 || (navigation.getState()?.routes.length ?? 0) > 2; - Navigation.goBack(hasPageToReturnTo ? undefined : ROUTES.SETTINGS_HELP, { - afterTransition: () => { - openSupportTicketOrConcierge(); - }, - }); - }, [wasOfflineOnOpen, navigation, openSupportTicketOrConcierge]); + Navigation.goBack(hasPageToReturnTo ? undefined : ROUTES.SETTINGS_HELP, {afterTransition: openSupportTicketOrConcierge}); + }, [isOffline, navigation, openSupportTicketOrConcierge]); return ; } From c03c70b6a26d6c4545c5ff6f28db0f1d128f7a9b Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 04:17:27 +0530 Subject: [PATCH 14/38] Share the full screen root route count between SplitRouter and the Talk to a human page --- .../createSplitNavigator/SplitRouter.ts | 5 ++- .../helpers/getFullScreenRootRouteCount.ts | 11 ++++++ .../settings/HelpPage/TalkToHumanPage.tsx | 5 ++- tests/unit/getFullScreenRootRouteCountTest.ts | 36 +++++++++++++++++++ 4 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 src/libs/Navigation/helpers/getFullScreenRootRouteCount.ts create mode 100644 tests/unit/getFullScreenRootRouteCountTest.ts diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts index 0af832696e4d..c9305674c3a7 100644 --- a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts @@ -1,7 +1,7 @@ import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; +import getFullScreenRootRouteCount from '@libs/Navigation/helpers/getFullScreenRootRouteCount'; import getParamsFromRoute from '@libs/Navigation/helpers/getParamsFromRoute'; -import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator'; import navigationRef from '@libs/Navigation/navigationRef'; import CONST from '@src/CONST'; @@ -79,8 +79,7 @@ function adaptStateIfNecessary({state, options: {sidebarScreen, defaultCentralSc const routes = [...stateWithoutMarker.routes]; let modified = stateWithoutMarker !== state; - // A side modal (RHP) is drawn on top of the full screen route instead of replacing it, so it must not be counted as a root route here. - const hasSingleFullScreenRootRoute = rootState?.routes.filter((route) => !isSideModalNavigator(route.name)).length === 1; + const hasSingleFullScreenRootRoute = getFullScreenRootRouteCount(rootState) === 1; // Despite the name, this is true for any navigation while TAB_NAVIGATOR is the only full screen root route, not only during app startup. // `shouldSkipInitialSidebar` lets a direct narrow-layout navigation opt out while wide layouts continue to keep the sidebar. diff --git a/src/libs/Navigation/helpers/getFullScreenRootRouteCount.ts b/src/libs/Navigation/helpers/getFullScreenRootRouteCount.ts new file mode 100644 index 000000000000..d4a1f6a7aa0c --- /dev/null +++ b/src/libs/Navigation/helpers/getFullScreenRootRouteCount.ts @@ -0,0 +1,11 @@ +import isSideModalNavigator from './isSideModalNavigator'; + +/** + * Counts the root routes that fill the screen. A side modal (RHP) is drawn on top of the full screen route + * instead of replacing it, so it is not counted. + */ +function getFullScreenRootRouteCount(rootState: {routes: Array<{name: string}>} | undefined): number { + return rootState?.routes.filter((route) => !isSideModalNavigator(route.name)).length ?? 0; +} + +export default getFullScreenRootRouteCount; diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx index 82e5eb79d52e..5756c9af5c80 100644 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -3,7 +3,7 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import useNetwork from '@hooks/useNetwork'; import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; -import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator'; +import getFullScreenRootRouteCount from '@libs/Navigation/helpers/getFullScreenRootRouteCount'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; @@ -36,8 +36,7 @@ function TalkToHumanPage() { // Leave this route first, because the ticket stores the route it opens from as its Back target. // A direct link has only the injected Settings sidebar behind it, so fall back to the Help page. - const fullScreenRouteCount = navigationRef.current?.getRootState().routes.filter((route) => !isSideModalNavigator(route.name)).length ?? 0; - const hasPageToReturnTo = fullScreenRouteCount > 1 || (navigation.getState()?.routes.length ?? 0) > 2; + const hasPageToReturnTo = getFullScreenRootRouteCount(navigationRef.current?.getRootState()) > 1 || (navigation.getState()?.routes.length ?? 0) > 2; Navigation.goBack(hasPageToReturnTo ? undefined : ROUTES.SETTINGS_HELP, {afterTransition: openSupportTicketOrConcierge}); }, [isOffline, navigation, openSupportTicketOrConcierge]); diff --git a/tests/unit/getFullScreenRootRouteCountTest.ts b/tests/unit/getFullScreenRootRouteCountTest.ts new file mode 100644 index 000000000000..0734909e6c83 --- /dev/null +++ b/tests/unit/getFullScreenRootRouteCountTest.ts @@ -0,0 +1,36 @@ +import getFullScreenRootRouteCount from '@libs/Navigation/helpers/getFullScreenRootRouteCount'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +describe('getFullScreenRootRouteCount', () => { + it('returns 0 when there is no root state yet', () => { + // Given no root state + // When the full screen routes are counted + const count = getFullScreenRootRouteCount(undefined); + + // Then there are none + expect(count).toBe(0); + }); + + it('counts every full screen route', () => { + // Given two tab navigators in the root state + const rootState = {routes: [{name: NAVIGATORS.TAB_NAVIGATOR}, {name: NAVIGATORS.TAB_NAVIGATOR}]}; + + // When the full screen routes are counted + const count = getFullScreenRootRouteCount(rootState); + + // Then both are counted + expect(count).toBe(2); + }); + + it('does not count a side modal', () => { + // Given a tab navigator with a side modal drawn on top of it + const rootState = {routes: [{name: NAVIGATORS.TAB_NAVIGATOR}, {name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}]}; + + // When the full screen routes are counted + const count = getFullScreenRootRouteCount(rootState); + + // Then only the tab navigator is counted + expect(count).toBe(1); + }); +}); From a7db5c6e5e286361c5412f4cededf8f87af1e23e Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 04:17:27 +0530 Subject: [PATCH 15/38] Use real Settings screen names in the page test and cover the no-rep fallback in the hook test --- tests/ui/TalkToHumanPageTest.tsx | 9 +-- tests/unit/useOpenSupportTicketTest.ts | 76 ++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx index b2b073495c6e..7979072a1434 100644 --- a/tests/ui/TalkToHumanPageTest.tsx +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -8,6 +8,7 @@ import TalkToHumanPage from '@pages/settings/HelpPage/TalkToHumanPage'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import React from 'react'; @@ -49,7 +50,7 @@ describe('TalkToHumanPage', () => { beforeEach(() => { jest.clearAllMocks(); mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); - setSettingsSplitRoutes(['talkToHuman']); + setSettingsSplitRoutes([SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]); }); it('goes back to the page the link was pressed on and opens the ticket after the transition', () => { @@ -75,7 +76,7 @@ describe('TalkToHumanPage', () => { it('falls back to the Help page for a direct link, where only the injected Settings sidebar sits behind it', () => { // Given a cold load with one full screen route and the Settings sidebar added under this page setRootRoutes([NAVIGATORS.TAB_NAVIGATOR]); - setSettingsSplitRoutes(['settingsRoot', 'talkToHuman']); + setSettingsSplitRoutes([SCREENS.SETTINGS.ROOT, SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]); // When the page opens render(); @@ -88,7 +89,7 @@ describe('TalkToHumanPage', () => { it('does not count a side modal as a page to return to', () => { // Given a direct link with a side modal open next to the only full screen route setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.RIGHT_MODAL_NAVIGATOR]); - setSettingsSplitRoutes(['settingsRoot', 'talkToHuman']); + setSettingsSplitRoutes([SCREENS.SETTINGS.ROOT, SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]); // When the page opens render(); @@ -100,7 +101,7 @@ describe('TalkToHumanPage', () => { it('goes back to the earlier Settings page when the link was pressed from another page in the same Settings split', () => { // Given a single full screen route whose Settings split holds an earlier page under this one setRootRoutes([NAVIGATORS.TAB_NAVIGATOR]); - setSettingsSplitRoutes(['settingsRoot', 'profile', 'talkToHuman']); + setSettingsSplitRoutes([SCREENS.SETTINGS.ROOT, SCREENS.SETTINGS.PROFILE.ROOT, SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]); // When the page opens render(); diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts index 0782e8a02479..6958afac2fec 100644 --- a/tests/unit/useOpenSupportTicketTest.ts +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -1,21 +1,31 @@ -import {renderHook} from '@testing-library/react-native'; +import {act, renderHook} from '@testing-library/react-native'; import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; -import {openSupportTicket} from '@userActions/Report'; +import Growl from '@libs/Growl'; +import Navigation from '@libs/Navigation/Navigation'; + +import {isNoSupportRepAvailableResponse, openSupportTicket} from '@userActions/Report'; + +const mockOpenConciergeAnywhere = jest.fn(); jest.mock('@userActions/Report', () => ({ openSupportTicket: jest.fn(), - isNoSupportRepAvailableResponse: jest.fn(() => false), + isNoSupportRepAvailableResponse: jest.fn(), })); jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); jest.mock('@libs/Growl', () => ({error: jest.fn()})); jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); -jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: jest.fn()})); +jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: mockOpenConciergeAnywhere})); const mockOpenSupportTicket = jest.mocked(openSupportTicket); +const mockIsNoSupportRepAvailableResponse = jest.mocked(isNoSupportRepAvailableResponse); describe('useOpenSupportTicket', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('returns nothing from the callback so a menu row does not wait on the ticket request', () => { // Given a ticket request that has not finished mockOpenSupportTicket.mockReturnValue(new Promise(() => {})); @@ -28,4 +38,62 @@ describe('useOpenSupportTicket', () => { expect(returnValue).toBeUndefined(); expect(mockOpenSupportTicket).toHaveBeenCalledTimes(1); }); + + it('does not show an error or open Concierge when a support rep takes the ticket', async () => { + // Given a ticket request that a rep picks up + mockOpenSupportTicket.mockResolvedValue(undefined); + mockIsNoSupportRepAvailableResponse.mockReturnValue(false); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request finishes + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then there is no error toast and no fallback navigation + expect(Growl.error).not.toHaveBeenCalled(); + expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); + }); + + it('shows an error and falls back to Concierge when no support rep is available', async () => { + // Given a ticket request that finds no available rep + mockOpenSupportTicket.mockResolvedValue(undefined); + mockIsNoSupportRepAvailableResponse.mockReturnValue(true); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request finishes + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then the customer sees the error toast and leaves the pending ticket + expect(Growl.error).toHaveBeenCalledWith({bodyText: 'supportTicket.noSupportRepAvailable'}); + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + + // And Concierge opens once that transition has finished + const goBackOptions = jest.mocked(Navigation.goBack).mock.calls.at(0)?.at(1); + if (typeof goBackOptions === 'object') { + goBackOptions.afterTransition?.(); + } + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + }); + + it('ignores a failed ticket request', async () => { + // Given a ticket request that fails + mockOpenSupportTicket.mockRejectedValue(new Error('network')); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request settles + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then nothing else happens + expect(Growl.error).not.toHaveBeenCalled(); + expect(Navigation.goBack).not.toHaveBeenCalled(); + }); }); From ad34328d42dfd9d691687008bd0f4df9d7b316d3 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 04:26:25 +0530 Subject: [PATCH 16/38] Import the support ticket actions through the same alias the Help page used --- src/hooks/useOpenSupportTicket.ts | 3 +-- tests/unit/useOpenSupportTicketTest.ts | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/hooks/useOpenSupportTicket.ts b/src/hooks/useOpenSupportTicket.ts index 1741c33acf92..2ef7fa495596 100644 --- a/src/hooks/useOpenSupportTicket.ts +++ b/src/hooks/useOpenSupportTicket.ts @@ -1,8 +1,7 @@ +import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/Report'; import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; -import {isNoSupportRepAvailableResponse, openSupportTicket} from '@userActions/Report'; - import useLocalize from './useLocalize'; import useOpenConciergeAnywhere from './useOpenConciergeAnywhere'; diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts index 6958afac2fec..30c36ea961ed 100644 --- a/tests/unit/useOpenSupportTicketTest.ts +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -2,14 +2,13 @@ import {act, renderHook} from '@testing-library/react-native'; import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; +import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/Report'; import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; -import {isNoSupportRepAvailableResponse, openSupportTicket} from '@userActions/Report'; - const mockOpenConciergeAnywhere = jest.fn(); -jest.mock('@userActions/Report', () => ({ +jest.mock('@libs/actions/Report', () => ({ openSupportTicket: jest.fn(), isNoSupportRepAvailableResponse: jest.fn(), })); From 8b3c50a80f08a3e9e9484644442704deba261462 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 04:38:21 +0530 Subject: [PATCH 17/38] Show the Help page when no support rep is available --- src/hooks/useOpenSupportTicket.ts | 11 +++++++++-- tests/unit/useOpenSupportTicketTest.ts | 8 ++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/hooks/useOpenSupportTicket.ts b/src/hooks/useOpenSupportTicket.ts index 2ef7fa495596..5b910f08d3ba 100644 --- a/src/hooks/useOpenSupportTicket.ts +++ b/src/hooks/useOpenSupportTicket.ts @@ -2,11 +2,13 @@ import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/ import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; +import ROUTES from '@src/ROUTES'; + import useLocalize from './useLocalize'; import useOpenConciergeAnywhere from './useOpenConciergeAnywhere'; /** - * Returns a callback that opens a support ticket, falling back to Concierge when no support rep is free. + * Returns a callback that opens a support ticket. When no support rep is free it shows the Help page and opens Concierge. * Shared by the Help page row and the Talk to a human route so both behave the same. */ function useOpenSupportTicket() { @@ -21,7 +23,12 @@ function useOpenSupportTicket() { } Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')}); - Navigation.goBack(undefined, {afterTransition: () => openConciergeAnywhere({forceConcierge: true})}); + Navigation.goBack(undefined, { + afterTransition: () => { + Navigation.navigate(ROUTES.SETTINGS_HELP); + openConciergeAnywhere({forceConcierge: true}); + }, + }); }) .catch(() => undefined); }; diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts index 30c36ea961ed..be254e1ae59e 100644 --- a/tests/unit/useOpenSupportTicketTest.ts +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -6,13 +6,15 @@ import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/ import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; +import ROUTES from '@src/ROUTES'; + const mockOpenConciergeAnywhere = jest.fn(); jest.mock('@libs/actions/Report', () => ({ openSupportTicket: jest.fn(), isNoSupportRepAvailableResponse: jest.fn(), })); -jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn(), navigate: jest.fn()})); jest.mock('@libs/Growl', () => ({error: jest.fn()})); jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: mockOpenConciergeAnywhere})); @@ -53,6 +55,7 @@ describe('useOpenSupportTicket', () => { // Then there is no error toast and no fallback navigation expect(Growl.error).not.toHaveBeenCalled(); expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); }); @@ -72,11 +75,12 @@ describe('useOpenSupportTicket', () => { expect(Growl.error).toHaveBeenCalledWith({bodyText: 'supportTicket.noSupportRepAvailable'}); expect(Navigation.goBack).toHaveBeenCalledTimes(1); - // And Concierge opens once that transition has finished + // And once that transition has finished the Help page is shown and Concierge opens const goBackOptions = jest.mocked(Navigation.goBack).mock.calls.at(0)?.at(1); if (typeof goBackOptions === 'object') { goBackOptions.afterTransition?.(); } + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_HELP); expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); }); From 67accc44c3cfbaee0d9776f33e988a02bdb6d351 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 04:39:24 +0530 Subject: [PATCH 18/38] Fail the tests loudly when goBack gets no afterTransition callback --- tests/ui/TalkToHumanPageTest.tsx | 6 ++++-- tests/unit/useOpenSupportTicketTest.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx index 7979072a1434..d3287dc09f18 100644 --- a/tests/ui/TalkToHumanPageTest.tsx +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -67,9 +67,11 @@ describe('TalkToHumanPage', () => { // And the ticket is only requested once the transition has finished expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); const goBackOptions = mockGoBack.mock.calls.at(0)?.at(1); - if (typeof goBackOptions === 'object') { - goBackOptions.afterTransition?.(); + if (typeof goBackOptions !== 'object') { + throw new Error('goBack should receive options with an afterTransition callback'); } + expect(goBackOptions.afterTransition).toEqual(expect.any(Function)); + goBackOptions.afterTransition?.(); expect(mockOpenSupportTicketOrConcierge).toHaveBeenCalledTimes(1); }); diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts index be254e1ae59e..bcb9aff5fe06 100644 --- a/tests/unit/useOpenSupportTicketTest.ts +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -77,9 +77,11 @@ describe('useOpenSupportTicket', () => { // And once that transition has finished the Help page is shown and Concierge opens const goBackOptions = jest.mocked(Navigation.goBack).mock.calls.at(0)?.at(1); - if (typeof goBackOptions === 'object') { - goBackOptions.afterTransition?.(); + if (typeof goBackOptions !== 'object') { + throw new Error('goBack should receive options with an afterTransition callback'); } + expect(goBackOptions.afterTransition).toEqual(expect.any(Function)); + goBackOptions.afterTransition?.(); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_HELP); expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); }); From 747e2ca880e1c7e2329544340d1f1303ff0025b5 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 17:38:19 +0530 Subject: [PATCH 19/38] Show the Help page offline by leaving the loader first instead of replacing the pushed tab --- .../settings/HelpPage/TalkToHumanPage.tsx | 12 ++--- tests/navigation/NavigateTests.tsx | 42 --------------- tests/ui/TalkToHumanPageTest.tsx | 51 ++++++++++++++----- tests/utils/TestNavigationContainer.tsx | 8 --- 4 files changed, 42 insertions(+), 71 deletions(-) diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx index 5756c9af5c80..ade98e37f2e2 100644 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ b/src/pages/settings/HelpPage/TalkToHumanPage.tsx @@ -28,16 +28,14 @@ function TalkToHumanPage() { } hasHandledLink.current = true; - // Creating a ticket needs the network, so replace this route with the Help page and let the customer retry from there. - if (isOffline) { - Navigation.navigate(ROUTES.SETTINGS_HELP, {forceReplace: true}); - return; - } - // Leave this route first, because the ticket stores the route it opens from as its Back target. // A direct link has only the injected Settings sidebar behind it, so fall back to the Help page. const hasPageToReturnTo = getFullScreenRootRouteCount(navigationRef.current?.getRootState()) > 1 || (navigation.getState()?.routes.length ?? 0) > 2; - Navigation.goBack(hasPageToReturnTo ? undefined : ROUTES.SETTINGS_HELP, {afterTransition: openSupportTicketOrConcierge}); + + // Creating a ticket needs the network, so offline the customer gets the Help page to retry from instead. + Navigation.goBack(hasPageToReturnTo ? undefined : ROUTES.SETTINGS_HELP, { + afterTransition: isOffline ? () => Navigation.navigate(ROUTES.SETTINGS_HELP) : openSupportTicketOrConcierge, + }); }, [isOffline, navigation, openSupportTicketOrConcierge]); return ; diff --git a/tests/navigation/NavigateTests.tsx b/tests/navigation/NavigateTests.tsx index 5d4f508cd26b..b1d578794426 100644 --- a/tests/navigation/NavigateTests.tsx +++ b/tests/navigation/NavigateTests.tsx @@ -156,48 +156,6 @@ describe('Navigate', () => { expect(settingsSplitAfterGoBack?.state?.routes.at(-1)?.name).toBe(SCREENS.SETTINGS.ABOUT); }); - it('replaces the Talk to a human route with the Help page', () => { - // Given the Talk to a human route open on top of the Settings sidebar - render( - , - ); - - // When the page is replaced by the Help page - act(() => { - Navigation.navigate(ROUTES.SETTINGS_HELP, {forceReplace: true}); - }); - - // Then the Help page takes its place and nothing is left behind it - const settingsSplit = navigationRef.current?.getRootState().routes.at(0)?.state?.routes.at(4); - expect(settingsSplit?.state?.routes.map((route) => route.name)).toEqual([SCREENS.SETTINGS.ROOT, SCREENS.SETTINGS.HELP]); - }); - it('returns to the Workspaces list after directly opening a Workspace subpage without its sidebar', () => { render( ({name}))}); } +function callAfterTransition() { + const goBackOptions = mockGoBack.mock.calls.at(0)?.at(1); + if (typeof goBackOptions !== 'object') { + throw new Error('goBack should receive options with an afterTransition callback'); + } + expect(goBackOptions.afterTransition).toEqual(expect.any(Function)); + goBackOptions.afterTransition?.(); +} + function getGoBackFallbackRoute() { return mockGoBack.mock.calls.at(0)?.at(0); } @@ -66,12 +75,7 @@ describe('TalkToHumanPage', () => { // And the ticket is only requested once the transition has finished expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); - const goBackOptions = mockGoBack.mock.calls.at(0)?.at(1); - if (typeof goBackOptions !== 'object') { - throw new Error('goBack should receive options with an afterTransition callback'); - } - expect(goBackOptions.afterTransition).toEqual(expect.any(Function)); - goBackOptions.afterTransition?.(); + callAfterTransition(); expect(mockOpenSupportTicketOrConcierge).toHaveBeenCalledTimes(1); }); @@ -113,18 +117,38 @@ describe('TalkToHumanPage', () => { expect(getGoBackFallbackRoute()).toBeUndefined(); }); - it('replaces itself with the Help page and creates no ticket when the link is opened offline', () => { - // Given the link opens while the device is offline + it('leaves the loader and then shows the Help page, creating no ticket, when the link is opened offline', () => { + // Given the link opens in the app while the device is offline mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); // When the page opens render(); - // Then the route is replaced by the Help page so that a ticket opened from there does not store this link as its back target + // Then it goes back to the page the link was pressed on + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(getGoBackFallbackRoute()).toBeUndefined(); + expect(mockNavigate).not.toHaveBeenCalled(); + + // And once that transition has finished the Help page is shown, without replacing the route and without a ticket + callAfterTransition(); expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_HELP, {forceReplace: true}); - expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_HELP); + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); + }); + + it('falls back to the Help page for a direct link opened offline', () => { + // Given a cold load while the device is offline + mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); + setRootRoutes([NAVIGATORS.TAB_NAVIGATOR]); + setSettingsSplitRoutes([SCREENS.SETTINGS.ROOT, SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]); + + // When the page opens + render(); + + // Then it falls back to the Help page and creates no ticket + expect(getGoBackFallbackRoute()).toBe(ROUTES.SETTINGS_HELP); + callAfterTransition(); expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); }); @@ -138,9 +162,8 @@ describe('TalkToHumanPage', () => { mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); rerender(); - // Then no ticket is created and no other navigation happens - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockGoBack).not.toHaveBeenCalled(); + // Then it only went back once and no ticket is created + expect(mockGoBack).toHaveBeenCalledTimes(1); expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); }); diff --git a/tests/utils/TestNavigationContainer.tsx b/tests/utils/TestNavigationContainer.tsx index 7850c9934143..ec4183408b09 100644 --- a/tests/utils/TestNavigationContainer.tsx +++ b/tests/utils/TestNavigationContainer.tsx @@ -204,14 +204,6 @@ function TestSettingsSplitNavigator() { name={SCREENS.SETTINGS.SUBSCRIPTION.ROOT} getComponent={getEmptyComponent} /> - - ); } From 1f304e5412b03e589ccae998ba3d82cb513e435b Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 17:51:38 +0530 Subject: [PATCH 20/38] Make Talk to a human a root-level utility page like Concierge and Track expense The page pops itself and then runs the same action as the Help page row, so it no longer needs to detect what sits behind it or handle a pushed tab. --- src/ROUTES.ts | 2 +- src/SCREENS.ts | 2 +- .../Navigation/AppNavigator/AuthScreens.tsx | 6 + .../Navigators/SettingsSplitNavigator.tsx | 1 - .../createSplitNavigator/SplitRouter.ts | 5 +- .../helpers/getFullScreenRootRouteCount.ts | 11 -- src/libs/Navigation/linkingConfig/config.ts | 5 +- src/libs/Navigation/types.ts | 2 +- src/pages/TalkToHumanPage.tsx | 52 ++++++ .../settings/HelpPage/TalkToHumanPage.tsx | 44 ----- tests/ui/TalkToHumanPageTest.tsx | 165 +++++------------- tests/unit/getFullScreenRootRouteCountTest.ts | 36 ---- 12 files changed, 104 insertions(+), 227 deletions(-) delete mode 100644 src/libs/Navigation/helpers/getFullScreenRootRouteCount.ts create mode 100644 src/pages/TalkToHumanPage.tsx delete mode 100644 src/pages/settings/HelpPage/TalkToHumanPage.tsx delete mode 100644 tests/unit/getFullScreenRootRouteCountTest.ts diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 803a48128dd1..bb1c476af396 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2351,6 +2351,7 @@ const ROUTES = { // This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated CONCIERGE: 'concierge', + TALK_TO_HUMAN: 'settings/help/talk-to-human', TRACK_EXPENSE: 'track-expense', SUBMIT_EXPENSE: 'submit-expense', // This is a special validation URL that will take the user to /workspace/new after validation. This is used @@ -2788,7 +2789,6 @@ const ROUTES = { SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', SETTINGS_TROUBLESHOOT_SERVER: 'settings/troubleshoot/server', SETTINGS_HELP: 'settings/help', - SETTINGS_HELP_TALK_TO_HUMAN: 'settings/help/talk-to-human', SETTINGS_SAVE_THE_WORLD: 'settings/teachersunite', SETTINGS_SAVE_THE_WORLD_ADD_PAYMENT_CARD: 'settings/teachersunite/add-payment-card', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index abbef2e4d275..0ccf1fe9a11f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -8,6 +8,7 @@ const PROTECTED_SCREENS = { HOME: 'Home', INBOX: 'Inbox', CONCIERGE: 'Concierge', + TALK_TO_HUMAN: 'TalkToHuman', REPORT_ATTACHMENTS: 'ReportAttachments', REPORT_ADD_ATTACHMENT: 'ReportAddAttachment', TRACK_EXPENSE: 'TrackExpense', @@ -99,7 +100,6 @@ const SCREENS = { TROUBLESHOOT: 'Settings_Troubleshoot', TROUBLESHOOT_SERVER: 'Settings_Troubleshoot_Server', HELP: 'Settings_Help', - HELP_TALK_TO_HUMAN: 'Settings_Help_TalkToHuman', EMAIL_ISSUE: 'Settings_Email_Issue', DYNAMIC_VERIFY_ACCOUNT: 'Dynamic_Verify_Account', DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT: 'Dynamic_Add_Bank_Account_Verify_Account', diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 3fa3462768a5..b2be2c3a6d05 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -86,6 +86,7 @@ const loadAttachmentModalScreen = () => require('../../../ const loadValidateLoginPage = () => require('../../../pages/ValidateLoginPage').default; const loadLogOutPreviousUserPage = () => require('../../../pages/LogOutPreviousUserPage').default; const loadConciergePage = () => require('../../../pages/ConciergePage').default; +const loadTalkToHumanPage = () => require('../../../pages/TalkToHumanPage').default; const loadTrackExpensePage = () => require('../../../pages/TrackExpensePage').default; const loadSubmitExpensePage = () => require('../../../pages/SubmitExpensePage').default; const loadPreMountBufferPage = () => require('../../../pages/PreMountBufferPage').default; @@ -226,6 +227,11 @@ function AuthScreens() { options={defaultScreenOptions} getComponent={loadConciergePage} /> + require('../../../../pages/settings/Agents/AgentsPage').default), [SCREENS.SETTINGS.RULES.ROOT]: () => require('../../../../pages/settings/Rules/ExpenseRulesPage').default, [SCREENS.SETTINGS.HELP]: () => require('../../../../pages/settings/HelpPage/HelpPage').default, - [SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]: () => require('../../../../pages/settings/HelpPage/TalkToHumanPage').default, [SCREENS.SETTINGS.ABOUT]: () => require('../../../../pages/settings/AboutPage/AboutPage').default, [SCREENS.SETTINGS.TROUBLESHOOT]: () => require('../../../../pages/settings/Troubleshoot/TroubleshootPage').default, [SCREENS.SETTINGS.SAVE_THE_WORLD]: () => require('../../../../pages/TeachersUnite/SaveTheWorldPage').default, diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts index c9305674c3a7..0af832696e4d 100644 --- a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts @@ -1,7 +1,7 @@ import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; -import getFullScreenRootRouteCount from '@libs/Navigation/helpers/getFullScreenRootRouteCount'; import getParamsFromRoute from '@libs/Navigation/helpers/getParamsFromRoute'; +import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator'; import navigationRef from '@libs/Navigation/navigationRef'; import CONST from '@src/CONST'; @@ -79,7 +79,8 @@ function adaptStateIfNecessary({state, options: {sidebarScreen, defaultCentralSc const routes = [...stateWithoutMarker.routes]; let modified = stateWithoutMarker !== state; - const hasSingleFullScreenRootRoute = getFullScreenRootRouteCount(rootState) === 1; + // A side modal (RHP) is drawn on top of the full screen route instead of replacing it, so it must not be counted as a root route here. + const hasSingleFullScreenRootRoute = rootState?.routes.filter((route) => !isSideModalNavigator(route.name)).length === 1; // Despite the name, this is true for any navigation while TAB_NAVIGATOR is the only full screen root route, not only during app startup. // `shouldSkipInitialSidebar` lets a direct narrow-layout navigation opt out while wide layouts continue to keep the sidebar. diff --git a/src/libs/Navigation/helpers/getFullScreenRootRouteCount.ts b/src/libs/Navigation/helpers/getFullScreenRootRouteCount.ts deleted file mode 100644 index d4a1f6a7aa0c..000000000000 --- a/src/libs/Navigation/helpers/getFullScreenRootRouteCount.ts +++ /dev/null @@ -1,11 +0,0 @@ -import isSideModalNavigator from './isSideModalNavigator'; - -/** - * Counts the root routes that fill the screen. A side modal (RHP) is drawn on top of the full screen route - * instead of replacing it, so it is not counted. - */ -function getFullScreenRootRouteCount(rootState: {routes: Array<{name: string}>} | undefined): number { - return rootState?.routes.filter((route) => !isSideModalNavigator(route.name)).length ?? 0; -} - -export default getFullScreenRootRouteCount; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 5a0e2987a580..d2d3e03d2215 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -24,6 +24,7 @@ const config: LinkingOptions['config'] = { [SCREENS.CONNECTION_COMPLETE]: ROUTES.CONNECTION_COMPLETE, [SCREENS.BANK_CONNECTION_COMPLETE]: ROUTES.BANK_CONNECTION_COMPLETE, [SCREENS.CONCIERGE]: ROUTES.CONCIERGE, + [SCREENS.TALK_TO_HUMAN]: ROUTES.TALK_TO_HUMAN, [SCREENS.TRACK_EXPENSE]: ROUTES.TRACK_EXPENSE, [SCREENS.SUBMIT_EXPENSE]: ROUTES.SUBMIT_EXPENSE, [SCREENS.SAML_SIGN_IN]: ROUTES.SAML_SIGN_IN, @@ -2487,10 +2488,6 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_HELP, exact: true, }, - [SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]: { - path: ROUTES.SETTINGS_HELP_TALK_TO_HUMAN, - exact: true, - }, [SCREENS.SETTINGS.TROUBLESHOOT]: { path: ROUTES.SETTINGS_TROUBLESHOOT, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index f196f5144aa5..2237ca968241 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1042,7 +1042,6 @@ type SettingsSplitNavigatorParamList = { [SCREENS.SETTINGS.RULES.ROOT]: undefined; [SCREENS.SETTINGS.ABOUT]: undefined; [SCREENS.SETTINGS.HELP]: undefined; - [SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]: undefined; [SCREENS.SETTINGS.TROUBLESHOOT]: undefined; [SCREENS.SETTINGS.SAVE_THE_WORLD]: undefined; [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]?: { @@ -1480,6 +1479,7 @@ type AttachmentModalScreensParamList = { type AuthScreensParamList = SharedScreensParamList & AttachmentModalScreensParamList & { [SCREENS.CONCIERGE]: undefined; + [SCREENS.TALK_TO_HUMAN]: undefined; [SCREENS.TRACK_EXPENSE]: undefined; [SCREENS.SUBMIT_EXPENSE]: undefined; [SCREENS.WORKSPACE_JOIN_USER]: { diff --git a/src/pages/TalkToHumanPage.tsx b/src/pages/TalkToHumanPage.tsx new file mode 100644 index 000000000000..6c6274813041 --- /dev/null +++ b/src/pages/TalkToHumanPage.tsx @@ -0,0 +1,52 @@ +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useNetwork from '@hooks/useNetwork'; +import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; + +import ROUTES from '@src/ROUTES'; + +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useRef} from 'react'; +import {View} from 'react-native'; + +// Utility page: goes back, then does what the Help page's Talk to a human row does (offline it shows Help instead). +function TalkToHumanPage() { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {openSupportTicketOrConcierge} = useOpenSupportTicket(); + const hasHandledLink = useRef(false); + + useFocusEffect( + useCallback(() => { + Navigation.isNavigationReady().then(() => { + if (hasHandledLink.current) { + return; + } + hasHandledLink.current = true; + + Navigation.goBack(); + if (isOffline) { + Navigation.navigate(ROUTES.SETTINGS_HELP); + return; + } + openSupportTicketOrConcierge(); + }); + }, [isOffline, openSupportTicketOrConcierge]), + ); + + return ( + + + + + + + ); +} + +export default TalkToHumanPage; diff --git a/src/pages/settings/HelpPage/TalkToHumanPage.tsx b/src/pages/settings/HelpPage/TalkToHumanPage.tsx deleted file mode 100644 index ade98e37f2e2..000000000000 --- a/src/pages/settings/HelpPage/TalkToHumanPage.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; - -import useNetwork from '@hooks/useNetwork'; -import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; - -import getFullScreenRootRouteCount from '@libs/Navigation/helpers/getFullScreenRootRouteCount'; -import Navigation from '@libs/Navigation/Navigation'; -import navigationRef from '@libs/Navigation/navigationRef'; - -import ROUTES from '@src/ROUTES'; - -import {useNavigation} from '@react-navigation/native'; -import React, {useEffect, useRef} from 'react'; - -/** - * Opens a support ticket straight away, so the Talk to a human link in a Concierge message reaches a - * person without the customer having to find the row on the Help page. - */ -function TalkToHumanPage() { - const {openSupportTicketOrConcierge} = useOpenSupportTicket(); - const {isOffline} = useNetwork(); - const navigation = useNavigation(); - const hasHandledLink = useRef(false); - - useEffect(() => { - if (hasHandledLink.current) { - return; - } - hasHandledLink.current = true; - - // Leave this route first, because the ticket stores the route it opens from as its Back target. - // A direct link has only the injected Settings sidebar behind it, so fall back to the Help page. - const hasPageToReturnTo = getFullScreenRootRouteCount(navigationRef.current?.getRootState()) > 1 || (navigation.getState()?.routes.length ?? 0) > 2; - - // Creating a ticket needs the network, so offline the customer gets the Help page to retry from instead. - Navigation.goBack(hasPageToReturnTo ? undefined : ROUTES.SETTINGS_HELP, { - afterTransition: isOffline ? () => Navigation.navigate(ROUTES.SETTINGS_HELP) : openSupportTicketOrConcierge, - }); - }, [isOffline, navigation, openSupportTicketOrConcierge]); - - return ; -} - -export default TalkToHumanPage; diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx index 9cf70af4b106..92725330997c 100644 --- a/tests/ui/TalkToHumanPageTest.tsx +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -1,181 +1,94 @@ -import {render} from '@testing-library/react-native'; +import {act, render} from '@testing-library/react-native'; import useNetwork from '@hooks/useNetwork'; import Navigation from '@libs/Navigation/Navigation'; -import TalkToHumanPage from '@pages/settings/HelpPage/TalkToHumanPage'; +import TalkToHumanPage from '@pages/TalkToHumanPage'; -import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import React from 'react'; const mockOpenSupportTicketOrConcierge = jest.fn(); -const mockGetRootState = jest.fn<{routes: Array<{name: string}>}, []>(); -const mockGetNavigatorState = jest.fn<{routes: Array<{name: string}>}, []>(); jest.mock('@libs/Navigation/Navigation', () => ({ goBack: jest.fn(), navigate: jest.fn(), -})); -jest.mock('@libs/Navigation/navigationRef', () => ({ - current: {getRootState: () => mockGetRootState()}, + isNavigationReady: jest.fn(() => Promise.resolve()), })); jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({getState: () => mockGetNavigatorState()}), + useFocusEffect: (effect: () => void) => jest.requireActual('react').useEffect(effect, [effect]), })); jest.mock('@hooks/useNetwork', () => jest.fn()); +jest.mock('@hooks/useThemeStyles', () => () => ({borderBottom: {}})); jest.mock('@hooks/useOpenSupportTicket', () => () => ({openSupportTicketOrConcierge: mockOpenSupportTicketOrConcierge})); -jest.mock('@components/FullscreenLoadingIndicator', () => () => jest.requireActual('react').createElement('Text', null, 'Loading')); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/ReportHeaderSkeletonView', () => () => null); +jest.mock('@components/ReportActionsSkeletonView', () => () => null); const mockUseNetwork = jest.mocked(useNetwork); const mockGoBack = jest.mocked(Navigation.goBack); const mockNavigate = jest.mocked(Navigation.navigate); -function setRootRoutes(names: string[]) { - mockGetRootState.mockReturnValue({routes: names.map((name) => ({name}))}); -} - -function setSettingsSplitRoutes(names: string[]) { - mockGetNavigatorState.mockReturnValue({routes: names.map((name) => ({name}))}); -} - -function callAfterTransition() { - const goBackOptions = mockGoBack.mock.calls.at(0)?.at(1); - if (typeof goBackOptions !== 'object') { - throw new Error('goBack should receive options with an afterTransition callback'); - } - expect(goBackOptions.afterTransition).toEqual(expect.any(Function)); - goBackOptions.afterTransition?.(); -} - -function getGoBackFallbackRoute() { - return mockGoBack.mock.calls.at(0)?.at(0); +async function renderPage() { + const view = render(); + await act(async () => { + await Promise.resolve(); + }); + return view; } describe('TalkToHumanPage', () => { beforeEach(() => { jest.clearAllMocks(); mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); - setSettingsSplitRoutes([SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]); }); - it('goes back to the page the link was pressed on and opens the ticket after the transition', () => { - // Given a link pressed in the app, which leaves a second full screen route under this one - setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); - + it('goes back and then opens a support ticket', async () => { + // Given the link is opened while online // When the page opens - render(); + await renderPage(); - // Then it leaves the loader without a fallback route + // Then it leaves the page first and opens the ticket from the page the link was pressed on expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(getGoBackFallbackRoute()).toBeUndefined(); - - // And the ticket is only requested once the transition has finished - expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); - callAfterTransition(); expect(mockOpenSupportTicketOrConcierge).toHaveBeenCalledTimes(1); + expect(mockGoBack.mock.invocationCallOrder.at(0)).toBeLessThan(mockOpenSupportTicketOrConcierge.mock.invocationCallOrder.at(0) ?? 0); + expect(mockNavigate).not.toHaveBeenCalled(); }); - it('falls back to the Help page for a direct link, where only the injected Settings sidebar sits behind it', () => { - // Given a cold load with one full screen route and the Settings sidebar added under this page - setRootRoutes([NAVIGATORS.TAB_NAVIGATOR]); - setSettingsSplitRoutes([SCREENS.SETTINGS.ROOT, SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]); - - // When the page opens - render(); - - // Then it falls back to the Help page - expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(getGoBackFallbackRoute()).toBe(ROUTES.SETTINGS_HELP); - }); - - it('does not count a side modal as a page to return to', () => { - // Given a direct link with a side modal open next to the only full screen route - setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.RIGHT_MODAL_NAVIGATOR]); - setSettingsSplitRoutes([SCREENS.SETTINGS.ROOT, SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]); - - // When the page opens - render(); - - // Then it still falls back to the Help page - expect(getGoBackFallbackRoute()).toBe(ROUTES.SETTINGS_HELP); - }); - - it('goes back to the earlier Settings page when the link was pressed from another page in the same Settings split', () => { - // Given a single full screen route whose Settings split holds an earlier page under this one - setRootRoutes([NAVIGATORS.TAB_NAVIGATOR]); - setSettingsSplitRoutes([SCREENS.SETTINGS.ROOT, SCREENS.SETTINGS.PROFILE.ROOT, SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]); - - // When the page opens - render(); - - // Then it leaves the loader without a fallback route - expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(getGoBackFallbackRoute()).toBeUndefined(); - }); - - it('leaves the loader and then shows the Help page, creating no ticket, when the link is opened offline', () => { - // Given the link opens in the app while the device is offline + it('goes back and shows the Help page without creating a ticket when offline', async () => { + // Given the link is opened while the device is offline mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); - setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); // When the page opens - render(); + await renderPage(); - // Then it goes back to the page the link was pressed on + // Then it leaves the page and shows the Help page, where the Talk to a human row is disabled expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(getGoBackFallbackRoute()).toBeUndefined(); - expect(mockNavigate).not.toHaveBeenCalled(); - - // And once that transition has finished the Help page is shown, without replacing the route and without a ticket - callAfterTransition(); - expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_HELP); expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); }); - it('falls back to the Help page for a direct link opened offline', () => { - // Given a cold load while the device is offline - mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); - setRootRoutes([NAVIGATORS.TAB_NAVIGATOR]); - setSettingsSplitRoutes([SCREENS.SETTINGS.ROOT, SCREENS.SETTINGS.HELP_TALK_TO_HUMAN]); - - // When the page opens - render(); - - // Then it falls back to the Help page and creates no ticket - expect(getGoBackFallbackRoute()).toBe(ROUTES.SETTINGS_HELP); - callAfterTransition(); - expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); - }); + it('does not open a second ticket when it re-renders before it is removed', async () => { + // Given the page has already handled the link + const {rerender} = await renderPage(); - it('does nothing more when the connection returns after an offline open', () => { - // Given the link opened offline + // When it re-renders, for example because the network state changed mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); - setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); - const {rerender} = render(); - - // When the connection comes back - mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); - rerender(); - - // Then it only went back once and no ticket is created - expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); - }); - - it('does not request a second ticket when it re-renders while mounted', () => { - // Given the page has already left the loader - setRootRoutes([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR]); - const {rerender} = render(); - - // When it re-renders rerender(); + await act(async () => { + await Promise.resolve(); + }); - // Then it only went back once + // Then the link is only handled once expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockOpenSupportTicketOrConcierge).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/getFullScreenRootRouteCountTest.ts b/tests/unit/getFullScreenRootRouteCountTest.ts deleted file mode 100644 index 0734909e6c83..000000000000 --- a/tests/unit/getFullScreenRootRouteCountTest.ts +++ /dev/null @@ -1,36 +0,0 @@ -import getFullScreenRootRouteCount from '@libs/Navigation/helpers/getFullScreenRootRouteCount'; - -import NAVIGATORS from '@src/NAVIGATORS'; - -describe('getFullScreenRootRouteCount', () => { - it('returns 0 when there is no root state yet', () => { - // Given no root state - // When the full screen routes are counted - const count = getFullScreenRootRouteCount(undefined); - - // Then there are none - expect(count).toBe(0); - }); - - it('counts every full screen route', () => { - // Given two tab navigators in the root state - const rootState = {routes: [{name: NAVIGATORS.TAB_NAVIGATOR}, {name: NAVIGATORS.TAB_NAVIGATOR}]}; - - // When the full screen routes are counted - const count = getFullScreenRootRouteCount(rootState); - - // Then both are counted - expect(count).toBe(2); - }); - - it('does not count a side modal', () => { - // Given a tab navigator with a side modal drawn on top of it - const rootState = {routes: [{name: NAVIGATORS.TAB_NAVIGATOR}, {name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}]}; - - // When the full screen routes are counted - const count = getFullScreenRootRouteCount(rootState); - - // Then only the tab navigator is counted - expect(count).toBe(1); - }); -}); From 96f4fb436d99dcf17a6cc9d59a81341492e3204c Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 17:55:53 +0530 Subject: [PATCH 21/38] Guard the Talk to a human page against unmount and pin its navigation behavior --- src/pages/TalkToHumanPage.tsx | 12 ++- .../navigation/TalkToHumanNavigationTests.tsx | 99 +++++++++++++++++++ tests/utils/TestNavigationContainer.tsx | 4 + 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 tests/navigation/TalkToHumanNavigationTests.tsx diff --git a/src/pages/TalkToHumanPage.tsx b/src/pages/TalkToHumanPage.tsx index 6c6274813041..70b46d667029 100644 --- a/src/pages/TalkToHumanPage.tsx +++ b/src/pages/TalkToHumanPage.tsx @@ -11,7 +11,7 @@ import Navigation from '@libs/Navigation/Navigation'; import ROUTES from '@src/ROUTES'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useRef} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; // Utility page: goes back, then does what the Help page's Talk to a human row does (offline it shows Help instead). @@ -20,11 +20,12 @@ function TalkToHumanPage() { const {isOffline} = useNetwork(); const {openSupportTicketOrConcierge} = useOpenSupportTicket(); const hasHandledLink = useRef(false); + const isUnmounted = useRef(false); useFocusEffect( useCallback(() => { Navigation.isNavigationReady().then(() => { - if (hasHandledLink.current) { + if (hasHandledLink.current || isUnmounted.current) { return; } hasHandledLink.current = true; @@ -39,6 +40,13 @@ function TalkToHumanPage() { }, [isOffline, openSupportTicketOrConcierge]), ); + useEffect(() => { + isUnmounted.current = false; + return () => { + isUnmounted.current = true; + }; + }, []); + return ( diff --git a/tests/navigation/TalkToHumanNavigationTests.tsx b/tests/navigation/TalkToHumanNavigationTests.tsx new file mode 100644 index 000000000000..3a9ec4e6bf12 --- /dev/null +++ b/tests/navigation/TalkToHumanNavigationTests.tsx @@ -0,0 +1,99 @@ +import {act, render} from '@testing-library/react-native'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {InitialState} from '@react-navigation/native'; + +import React from 'react'; + +import TestNavigationContainer from '../utils/TestNavigationContainer'; + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +jest.mock('@libs/getIsNarrowLayout', () => jest.fn()); +jest.mock('@pages/inbox/sidebar/NavigationTabBarAvatar'); + +const CHAT_REPORT_ID = '100'; + +const chatState = { + index: 0, + routes: [ + { + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + index: 1, + routes: [ + {name: SCREENS.HOME}, + { + name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + state: {index: 1, routes: [{name: SCREENS.INBOX}, {name: SCREENS.REPORT, params: {reportID: CHAT_REPORT_ID}}]}, + }, + {name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}, + {name: SCREENS.INSIGHTS}, + {name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}, + {name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + ], + }, + }, + ], +}; + +/** + * The Talk to a human page pops itself before opening a support ticket, and the ticket stores the route it was + * opened from as its Back target. These pin that the pop leaves a real page behind, so the ticket never records + * the loader itself. + */ +describe.each([ + ['wide layout', false], + ['narrow layout', true], +])('Talk to a human route on a %s', (_name, isNarrow) => { + beforeEach(() => { + jest.mocked(getIsNarrowLayout).mockReturnValue(isNarrow); + jest.mocked(useResponsiveLayout).mockReturnValue({ + ...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, + shouldUseNarrowLayout: isNarrow, + isSmallScreenWidth: isNarrow, + }); + }); + + it('returns to the chat the link was pressed in', () => { + // Given a chat open in the app + render(); + + // When the link is pressed and the page pops itself + act(() => { + Navigation.navigate(ROUTES.TALK_TO_HUMAN); + }); + expect(Navigation.getActiveRoute()).toBe(`/${ROUTES.TALK_TO_HUMAN}`); + act(() => { + Navigation.goBack(); + }); + + // Then the chat is the active route, so the ticket records it as its Back target + expect(Navigation.getActiveRoute()).toBe(`/r/${CHAT_REPORT_ID}`); + }); + + it('leaves a page behind when the link is opened directly', () => { + // Given the route opened from a cold link, which has no page of its own to return to + render(); + expect(Navigation.getActiveRoute()).toBe(`/${ROUTES.TALK_TO_HUMAN}`); + + // When the page pops itself + act(() => { + Navigation.goBack(); + }); + + // Then it lands on a real page instead of stranding the customer on the loader + expect(Navigation.getActiveRoute()).not.toBe(`/${ROUTES.TALK_TO_HUMAN}`); + expect(navigationRef.current?.getRootState().routes.at(-1)?.name).toBe(NAVIGATORS.TAB_NAVIGATOR); + }); +}); diff --git a/tests/utils/TestNavigationContainer.tsx b/tests/utils/TestNavigationContainer.tsx index ec4183408b09..43127dce1c6d 100644 --- a/tests/utils/TestNavigationContainer.tsx +++ b/tests/utils/TestNavigationContainer.tsx @@ -282,6 +282,10 @@ function TestNavigationContainer({initialState, onBeforeRemove}: TestNavigationC name={SCREENS.VALIDATE_LOGIN} component={getEmptyComponent()} /> + Date: Sun, 25 Oct 2026 17:59:09 +0530 Subject: [PATCH 22/38] Keep the no-rep fallback identical to the Help page row Navigating to Help first landed somewhere different depending on whether Help was already in the Settings history. --- src/hooks/useOpenSupportTicket.ts | 11 ++--------- tests/ui/TalkToHumanPageTest.tsx | 23 +++++++++++++++++++++++ tests/unit/useOpenSupportTicketTest.ts | 8 ++------ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/hooks/useOpenSupportTicket.ts b/src/hooks/useOpenSupportTicket.ts index 5b910f08d3ba..2ef7fa495596 100644 --- a/src/hooks/useOpenSupportTicket.ts +++ b/src/hooks/useOpenSupportTicket.ts @@ -2,13 +2,11 @@ import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/ import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; -import ROUTES from '@src/ROUTES'; - import useLocalize from './useLocalize'; import useOpenConciergeAnywhere from './useOpenConciergeAnywhere'; /** - * Returns a callback that opens a support ticket. When no support rep is free it shows the Help page and opens Concierge. + * Returns a callback that opens a support ticket, falling back to Concierge when no support rep is free. * Shared by the Help page row and the Talk to a human route so both behave the same. */ function useOpenSupportTicket() { @@ -23,12 +21,7 @@ function useOpenSupportTicket() { } Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')}); - Navigation.goBack(undefined, { - afterTransition: () => { - Navigation.navigate(ROUTES.SETTINGS_HELP); - openConciergeAnywhere({forceConcierge: true}); - }, - }); + Navigation.goBack(undefined, {afterTransition: () => openConciergeAnywhere({forceConcierge: true})}); }) .catch(() => undefined); }; diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx index 92725330997c..70fbd5672a90 100644 --- a/tests/ui/TalkToHumanPageTest.tsx +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -75,6 +75,29 @@ describe('TalkToHumanPage', () => { expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); }); + it('does nothing when the customer leaves before navigation is ready', async () => { + // Given navigation that is not ready yet + let resolveNavigationReady = () => {}; + jest.mocked(Navigation.isNavigationReady).mockReturnValue( + new Promise((resolve) => { + resolveNavigationReady = resolve; + }), + ); + const {unmount} = render(); + + // When the customer leaves the page and navigation becomes ready afterwards + unmount(); + await act(async () => { + resolveNavigationReady(); + await Promise.resolve(); + }); + + // Then no ticket is opened and nothing navigates behind them + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); + }); + it('does not open a second ticket when it re-renders before it is removed', async () => { // Given the page has already handled the link const {rerender} = await renderPage(); diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts index bcb9aff5fe06..1d18710b0ad7 100644 --- a/tests/unit/useOpenSupportTicketTest.ts +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -6,15 +6,13 @@ import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/ import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; -import ROUTES from '@src/ROUTES'; - const mockOpenConciergeAnywhere = jest.fn(); jest.mock('@libs/actions/Report', () => ({ openSupportTicket: jest.fn(), isNoSupportRepAvailableResponse: jest.fn(), })); -jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn(), navigate: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); jest.mock('@libs/Growl', () => ({error: jest.fn()})); jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: mockOpenConciergeAnywhere})); @@ -55,7 +53,6 @@ describe('useOpenSupportTicket', () => { // Then there is no error toast and no fallback navigation expect(Growl.error).not.toHaveBeenCalled(); expect(Navigation.goBack).not.toHaveBeenCalled(); - expect(Navigation.navigate).not.toHaveBeenCalled(); expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); }); @@ -75,14 +72,13 @@ describe('useOpenSupportTicket', () => { expect(Growl.error).toHaveBeenCalledWith({bodyText: 'supportTicket.noSupportRepAvailable'}); expect(Navigation.goBack).toHaveBeenCalledTimes(1); - // And once that transition has finished the Help page is shown and Concierge opens + // And Concierge opens once that transition has finished const goBackOptions = jest.mocked(Navigation.goBack).mock.calls.at(0)?.at(1); if (typeof goBackOptions !== 'object') { throw new Error('goBack should receive options with an afterTransition callback'); } expect(goBackOptions.afterTransition).toEqual(expect.any(Function)); goBackOptions.afterTransition?.(); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_HELP); expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); }); From d0a50eea018205660e13cc02039c4ed31fb2a5c6 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 18:15:29 +0530 Subject: [PATCH 23/38] Only leave the failed ticket screen when it is still open The screen takes itself down when creation fails, so the extra goBack sometimes popped the page behind it and landed on Home. --- src/hooks/useOpenSupportTicket.ts | 8 ++++++++ tests/unit/useOpenSupportTicketTest.ts | 28 ++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/hooks/useOpenSupportTicket.ts b/src/hooks/useOpenSupportTicket.ts index 2ef7fa495596..bd94ec10c536 100644 --- a/src/hooks/useOpenSupportTicket.ts +++ b/src/hooks/useOpenSupportTicket.ts @@ -14,6 +14,7 @@ function useOpenSupportTicket() { const {openConciergeAnywhere} = useOpenConciergeAnywhere(); const openSupportTicketOrConcierge = () => { + const routeBeforeTicket = Navigation.getActiveRoute(); openSupportTicket() .then((response) => { if (!isNoSupportRepAvailableResponse(response)) { @@ -21,6 +22,13 @@ function useOpenSupportTicket() { } Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')}); + + // A ticket that was never created takes its own screen down, and going back again would pop the page behind it. + if (Navigation.getActiveRoute() === routeBeforeTicket) { + openConciergeAnywhere({forceConcierge: true}); + return; + } + Navigation.goBack(undefined, {afterTransition: () => openConciergeAnywhere({forceConcierge: true})}); }) .catch(() => undefined); diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts index 1d18710b0ad7..e896b963189e 100644 --- a/tests/unit/useOpenSupportTicketTest.ts +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -12,7 +12,7 @@ jest.mock('@libs/actions/Report', () => ({ openSupportTicket: jest.fn(), isNoSupportRepAvailableResponse: jest.fn(), })); -jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn(), getActiveRoute: jest.fn(() => '/r/1')})); jest.mock('@libs/Growl', () => ({error: jest.fn()})); jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: mockOpenConciergeAnywhere})); @@ -56,10 +56,11 @@ describe('useOpenSupportTicket', () => { expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); }); - it('shows an error and falls back to Concierge when no support rep is available', async () => { - // Given a ticket request that finds no available rep + it('shows an error and opens Concierge without navigating when the failed ticket screen is already gone', async () => { + // Given a ticket request that finds no rep, after its screen has taken itself down mockOpenSupportTicket.mockResolvedValue(undefined); mockIsNoSupportRepAvailableResponse.mockReturnValue(true); + jest.mocked(Navigation.getActiveRoute).mockReturnValue('/r/1'); const {result} = renderHook(() => useOpenSupportTicket()); // When the callback runs and the request finishes @@ -68,7 +69,26 @@ describe('useOpenSupportTicket', () => { await Promise.resolve(); }); - // Then the customer sees the error toast and leaves the pending ticket + // Then the customer sees the error and stays put, because going back would pop the page behind the ticket + expect(Growl.error).toHaveBeenCalledWith({bodyText: 'supportTicket.noSupportRepAvailable'}); + expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + }); + + it('leaves the failed ticket screen when it is still open, then opens Concierge', async () => { + // Given a ticket request that finds no rep while its screen is still open + mockOpenSupportTicket.mockResolvedValue(undefined); + mockIsNoSupportRepAvailableResponse.mockReturnValue(true); + jest.mocked(Navigation.getActiveRoute).mockReturnValueOnce('/r/1').mockReturnValue('/r/2?isPendingCreation=true'); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request finishes + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then the ticket screen is left behind expect(Growl.error).toHaveBeenCalledWith({bodyText: 'supportTicket.noSupportRepAvailable'}); expect(Navigation.goBack).toHaveBeenCalledTimes(1); From 38d3193c51204c34ed48a5e02c2a6b7c5686dac4 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 18:17:18 +0530 Subject: [PATCH 24/38] Match the failed ticket's own route before leaving it --- src/hooks/useOpenSupportTicket.ts | 12 ++++++++---- tests/unit/useOpenSupportTicketTest.ts | 14 +++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/hooks/useOpenSupportTicket.ts b/src/hooks/useOpenSupportTicket.ts index bd94ec10c536..23c84e577468 100644 --- a/src/hooks/useOpenSupportTicket.ts +++ b/src/hooks/useOpenSupportTicket.ts @@ -14,8 +14,11 @@ function useOpenSupportTicket() { const {openConciergeAnywhere} = useOpenConciergeAnywhere(); const openSupportTicketOrConcierge = () => { - const routeBeforeTicket = Navigation.getActiveRoute(); - openSupportTicket() + const request = openSupportTicket(); + + // openSupportTicket opens the pending ticket before it resolves, so this is that screen's own route. + const pendingTicketRoute = Navigation.getActiveRoute(); + request .then((response) => { if (!isNoSupportRepAvailableResponse(response)) { return; @@ -23,8 +26,9 @@ function useOpenSupportTicket() { Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')}); - // A ticket that was never created takes its own screen down, and going back again would pop the page behind it. - if (Navigation.getActiveRoute() === routeBeforeTicket) { + // A ticket that was never created takes its own screen down. Leaving any other screen would pop + // the page the customer is on instead. + if (Navigation.getActiveRoute() !== pendingTicketRoute) { openConciergeAnywhere({forceConcierge: true}); return; } diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts index e896b963189e..99e86d1d8ba1 100644 --- a/tests/unit/useOpenSupportTicketTest.ts +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -12,12 +12,16 @@ jest.mock('@libs/actions/Report', () => ({ openSupportTicket: jest.fn(), isNoSupportRepAvailableResponse: jest.fn(), })); -jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn(), getActiveRoute: jest.fn(() => '/r/1')})); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn(), getActiveRoute: jest.fn()})); jest.mock('@libs/Growl', () => ({error: jest.fn()})); jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: mockOpenConciergeAnywhere})); const mockOpenSupportTicket = jest.mocked(openSupportTicket); +const mockGetActiveRoute = jest.mocked(Navigation.getActiveRoute); + +const CHAT_ROUTE = '/r/1'; +const PENDING_TICKET_ROUTE = '/r/2/?isPendingCreation=true'; const mockIsNoSupportRepAvailableResponse = jest.mocked(isNoSupportRepAvailableResponse); describe('useOpenSupportTicket', () => { @@ -28,6 +32,7 @@ describe('useOpenSupportTicket', () => { it('returns nothing from the callback so a menu row does not wait on the ticket request', () => { // Given a ticket request that has not finished mockOpenSupportTicket.mockReturnValue(new Promise(() => {})); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); const {result} = renderHook(() => useOpenSupportTicket()); // When the callback runs @@ -42,6 +47,7 @@ describe('useOpenSupportTicket', () => { // Given a ticket request that a rep picks up mockOpenSupportTicket.mockResolvedValue(undefined); mockIsNoSupportRepAvailableResponse.mockReturnValue(false); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); const {result} = renderHook(() => useOpenSupportTicket()); // When the callback runs and the request finishes @@ -60,7 +66,8 @@ describe('useOpenSupportTicket', () => { // Given a ticket request that finds no rep, after its screen has taken itself down mockOpenSupportTicket.mockResolvedValue(undefined); mockIsNoSupportRepAvailableResponse.mockReturnValue(true); - jest.mocked(Navigation.getActiveRoute).mockReturnValue('/r/1'); + // The ticket screen is open when the request starts, and gone by the time it resolves + mockGetActiveRoute.mockReturnValueOnce(PENDING_TICKET_ROUTE).mockReturnValue(CHAT_ROUTE); const {result} = renderHook(() => useOpenSupportTicket()); // When the callback runs and the request finishes @@ -79,7 +86,7 @@ describe('useOpenSupportTicket', () => { // Given a ticket request that finds no rep while its screen is still open mockOpenSupportTicket.mockResolvedValue(undefined); mockIsNoSupportRepAvailableResponse.mockReturnValue(true); - jest.mocked(Navigation.getActiveRoute).mockReturnValueOnce('/r/1').mockReturnValue('/r/2?isPendingCreation=true'); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); const {result} = renderHook(() => useOpenSupportTicket()); // When the callback runs and the request finishes @@ -105,6 +112,7 @@ describe('useOpenSupportTicket', () => { it('ignores a failed ticket request', async () => { // Given a ticket request that fails mockOpenSupportTicket.mockRejectedValue(new Error('network')); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); const {result} = renderHook(() => useOpenSupportTicket()); // When the callback runs and the request settles From 0d4342a17a3f82107abfab1cf7886b838b45f849 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sun, 25 Oct 2026 18:52:09 +0530 Subject: [PATCH 25/38] Clear a pending pop to the sidebar before the utility page leaves A wide to narrow resize sets that flag, and goBack consumed it here without popping, so the page stayed open and the ticket recorded it as its Back target. --- src/pages/TalkToHumanPage.tsx | 3 +++ .../navigation/TalkToHumanNavigationTests.tsx | 20 +++++++++++++++++++ tests/ui/TalkToHumanPageTest.tsx | 6 ++++++ 3 files changed, 29 insertions(+) diff --git a/src/pages/TalkToHumanPage.tsx b/src/pages/TalkToHumanPage.tsx index 70b46d667029..6698a5a2dbf3 100644 --- a/src/pages/TalkToHumanPage.tsx +++ b/src/pages/TalkToHumanPage.tsx @@ -30,6 +30,9 @@ function TalkToHumanPage() { } hasHandledLink.current = true; + // A wide to narrow resize leaves a pending pop to the sidebar, which targets the page underneath + // rather than this one. goBack would consume it here and leave this page open. + Navigation.setShouldPopToSidebar(false); Navigation.goBack(); if (isOffline) { Navigation.navigate(ROUTES.SETTINGS_HELP); diff --git a/tests/navigation/TalkToHumanNavigationTests.tsx b/tests/navigation/TalkToHumanNavigationTests.tsx index 3a9ec4e6bf12..1e4cac981042 100644 --- a/tests/navigation/TalkToHumanNavigationTests.tsx +++ b/tests/navigation/TalkToHumanNavigationTests.tsx @@ -82,6 +82,26 @@ describe.each([ expect(Navigation.getActiveRoute()).toBe(`/r/${CHAT_REPORT_ID}`); }); + it('still pops when a wide to narrow resize left a pending pop to the sidebar', () => { + // Given a chat open in the app and the link pressed + render(); + act(() => { + Navigation.navigate(ROUTES.TALK_TO_HUMAN); + }); + + // When the resize flag is pending and the page clears it before leaving, as TalkToHumanPage does + act(() => { + Navigation.setShouldPopToSidebar(true); + }); + act(() => { + Navigation.setShouldPopToSidebar(false); + Navigation.goBack(); + }); + + // Then the page is gone, so the ticket does not record this route as its Back target + expect(Navigation.getActiveRoute()).toBe(`/r/${CHAT_REPORT_ID}`); + }); + it('leaves a page behind when the link is opened directly', () => { // Given the route opened from a cold link, which has no page of its own to return to render(); diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx index 70fbd5672a90..3d61764797f8 100644 --- a/tests/ui/TalkToHumanPageTest.tsx +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -15,6 +15,7 @@ const mockOpenSupportTicketOrConcierge = jest.fn(); jest.mock('@libs/Navigation/Navigation', () => ({ goBack: jest.fn(), navigate: jest.fn(), + setShouldPopToSidebar: jest.fn(), isNavigationReady: jest.fn(() => Promise.resolve()), })); jest.mock('@react-navigation/native', () => ({ @@ -34,6 +35,7 @@ jest.mock('@components/ReportActionsSkeletonView', () => () => null); const mockUseNetwork = jest.mocked(useNetwork); const mockGoBack = jest.mocked(Navigation.goBack); +const mockSetShouldPopToSidebar = jest.mocked(Navigation.setShouldPopToSidebar); const mockNavigate = jest.mocked(Navigation.navigate); async function renderPage() { @@ -57,6 +59,10 @@ describe('TalkToHumanPage', () => { // Then it leaves the page first and opens the ticket from the page the link was pressed on expect(mockGoBack).toHaveBeenCalledTimes(1); + + // And a pending pop to the sidebar is cleared first, or goBack would consume it and leave this page open + expect(mockSetShouldPopToSidebar).toHaveBeenCalledWith(false); + expect(mockSetShouldPopToSidebar.mock.invocationCallOrder.at(0)).toBeLessThan(mockGoBack.mock.invocationCallOrder.at(0) ?? 0); expect(mockOpenSupportTicketOrConcierge).toHaveBeenCalledTimes(1); expect(mockGoBack.mock.invocationCallOrder.at(0)).toBeLessThan(mockOpenSupportTicketOrConcierge.mock.invocationCallOrder.at(0) ?? 0); expect(mockNavigate).not.toHaveBeenCalled(); From ed24c9b581746360170b0c331908da0524ed65a8 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Mon, 26 Oct 2026 17:59:47 +0530 Subject: [PATCH 26/38] Let React Compiler memoize the focus effect --- src/pages/TalkToHumanPage.tsx | 40 +++++++++++++++++------------------ 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/pages/TalkToHumanPage.tsx b/src/pages/TalkToHumanPage.tsx index 6698a5a2dbf3..2cfe23bb075b 100644 --- a/src/pages/TalkToHumanPage.tsx +++ b/src/pages/TalkToHumanPage.tsx @@ -11,7 +11,7 @@ import Navigation from '@libs/Navigation/Navigation'; import ROUTES from '@src/ROUTES'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; // Utility page: goes back, then does what the Help page's Talk to a human row does (offline it shows Help instead). @@ -22,26 +22,24 @@ function TalkToHumanPage() { const hasHandledLink = useRef(false); const isUnmounted = useRef(false); - useFocusEffect( - useCallback(() => { - Navigation.isNavigationReady().then(() => { - if (hasHandledLink.current || isUnmounted.current) { - return; - } - hasHandledLink.current = true; - - // A wide to narrow resize leaves a pending pop to the sidebar, which targets the page underneath - // rather than this one. goBack would consume it here and leave this page open. - Navigation.setShouldPopToSidebar(false); - Navigation.goBack(); - if (isOffline) { - Navigation.navigate(ROUTES.SETTINGS_HELP); - return; - } - openSupportTicketOrConcierge(); - }); - }, [isOffline, openSupportTicketOrConcierge]), - ); + useFocusEffect(() => { + Navigation.isNavigationReady().then(() => { + if (hasHandledLink.current || isUnmounted.current) { + return; + } + hasHandledLink.current = true; + + // A wide to narrow resize leaves a pending pop to the sidebar, which targets the page underneath + // rather than this one. goBack would consume it here and leave this page open. + Navigation.setShouldPopToSidebar(false); + Navigation.goBack(); + if (isOffline) { + Navigation.navigate(ROUTES.SETTINGS_HELP); + return; + } + openSupportTicketOrConcierge(); + }); + }); useEffect(() => { isUnmounted.current = false; From c2b47a3651a4434c586005f9ae89e4f5dfb82dea Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 03:49:06 +0530 Subject: [PATCH 27/38] Remove the Help alias now that the route is its own utility page #105072 aliased settings/help/talk-to-human to the Help screen so the route exists before the link opens a ticket. The root-level Talk to a human page owns the path now. --- src/libs/Navigation/linkingConfig/config.ts | 2 -- .../talkToHumanLinkLandsOnHelpTests.ts | 33 ------------------- 2 files changed, 35 deletions(-) delete mode 100644 tests/navigation/talkToHumanLinkLandsOnHelpTests.ts diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 4943be11c9d4..d2d3e03d2215 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -2486,8 +2486,6 @@ const config: LinkingOptions['config'] = { }, [SCREENS.SETTINGS.HELP]: { path: ROUTES.SETTINGS_HELP, - // The Talk to a human link in Concierge replies lands on the Help page, where the Talk to a human row is. - alias: [{path: ROUTES.TALK_TO_HUMAN, exact: true}], exact: true, }, [SCREENS.SETTINGS.TROUBLESHOOT]: { diff --git a/tests/navigation/talkToHumanLinkLandsOnHelpTests.ts b/tests/navigation/talkToHumanLinkLandsOnHelpTests.ts deleted file mode 100644 index c700c263e3c4..000000000000 --- a/tests/navigation/talkToHumanLinkLandsOnHelpTests.ts +++ /dev/null @@ -1,33 +0,0 @@ -import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; -import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; - -import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; - -import {findFocusedRoute} from '@react-navigation/native'; - -function getFocusedScreenName(path: string): string | undefined { - return findFocusedRoute(getAdaptedStateFromPath(path, undefined))?.name; -} - -describe('Talk to a human link', () => { - it('lands on the Help page', () => { - // Given the link that Concierge replies point to - // When it is opened - // Then the focused screen is Account > Help, not Not Found - expect(getFocusedScreenName(`/${ROUTES.TALK_TO_HUMAN}`)).toBe(SCREENS.SETTINGS.HELP); - }); - - it('serializes back to the canonical Help path', () => { - // Given the link opened as a URL - const state = getAdaptedStateFromPath(`/${ROUTES.TALK_TO_HUMAN}`, undefined); - - // When the state is written back to a path - // Then it is the Help path, so refreshing or sharing the URL keeps working - expect(getPathFromState(state)).toBe(`/${ROUTES.SETTINGS_HELP}`); - }); - - it('keeps the Help page reachable under its own path', () => { - expect(getFocusedScreenName(`/${ROUTES.SETTINGS_HELP}`)).toBe(SCREENS.SETTINGS.HELP); - }); -}); From 93e9ef0c5133a1ef2b3cc475808c687109aeacc5 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 03:49:49 +0530 Subject: [PATCH 28/38] Test that the Talk to a human link resolves to its own page --- tests/navigation/TalkToHumanNavigationTests.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/navigation/TalkToHumanNavigationTests.tsx b/tests/navigation/TalkToHumanNavigationTests.tsx index 1e4cac981042..826e23f5648b 100644 --- a/tests/navigation/TalkToHumanNavigationTests.tsx +++ b/tests/navigation/TalkToHumanNavigationTests.tsx @@ -4,6 +4,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; @@ -14,6 +15,7 @@ import SCREENS from '@src/SCREENS'; import type {InitialState} from '@react-navigation/native'; +import {findFocusedRoute} from '@react-navigation/native'; import React from 'react'; import TestNavigationContainer from '../utils/TestNavigationContainer'; @@ -117,3 +119,15 @@ describe.each([ expect(navigationRef.current?.getRootState().routes.at(-1)?.name).toBe(NAVIGATORS.TAB_NAVIGATOR); }); }); + +describe('Talk to a human deep link', () => { + it('resolves to the Talk to a human page rather than Not Found or the Help page', () => { + // Given the link that Concierge replies point to + // When it is opened as a URL + const state = getAdaptedStateFromPath(`/${ROUTES.TALK_TO_HUMAN}`, undefined); + + // Then the focused screen is the Talk to a human page, and the URL survives a round trip + expect(findFocusedRoute(state)?.name).toBe(SCREENS.TALK_TO_HUMAN); + expect(getPathFromState(state)).toBe(`/${ROUTES.TALK_TO_HUMAN}`); + }); +}); From 08d40a7703ae7f849c8e240f93cf450e2283ff16 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 03:50:35 +0530 Subject: [PATCH 29/38] Run the deep-link test under both layouts --- tests/navigation/TalkToHumanNavigationTests.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/navigation/TalkToHumanNavigationTests.tsx b/tests/navigation/TalkToHumanNavigationTests.tsx index 826e23f5648b..71334266020b 100644 --- a/tests/navigation/TalkToHumanNavigationTests.tsx +++ b/tests/navigation/TalkToHumanNavigationTests.tsx @@ -118,13 +118,11 @@ describe.each([ expect(Navigation.getActiveRoute()).not.toBe(`/${ROUTES.TALK_TO_HUMAN}`); expect(navigationRef.current?.getRootState().routes.at(-1)?.name).toBe(NAVIGATORS.TAB_NAVIGATOR); }); -}); -describe('Talk to a human deep link', () => { - it('resolves to the Talk to a human page rather than Not Found or the Help page', () => { + it('resolves the link to the Talk to a human page', () => { // Given the link that Concierge replies point to // When it is opened as a URL - const state = getAdaptedStateFromPath(`/${ROUTES.TALK_TO_HUMAN}`, undefined); + const state = getAdaptedStateFromPath(`/${ROUTES.TALK_TO_HUMAN}`); // Then the focused screen is the Talk to a human page, and the URL survives a round trip expect(findFocusedRoute(state)?.name).toBe(SCREENS.TALK_TO_HUMAN); From 124bce8e1f9c4edce4f7b1a39fd5b3ddbf2afa43 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 03:51:27 +0530 Subject: [PATCH 30/38] Run the deep-link parse test once, since it does not depend on layout --- tests/navigation/TalkToHumanNavigationTests.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/navigation/TalkToHumanNavigationTests.tsx b/tests/navigation/TalkToHumanNavigationTests.tsx index 71334266020b..e352a28b4696 100644 --- a/tests/navigation/TalkToHumanNavigationTests.tsx +++ b/tests/navigation/TalkToHumanNavigationTests.tsx @@ -118,7 +118,9 @@ describe.each([ expect(Navigation.getActiveRoute()).not.toBe(`/${ROUTES.TALK_TO_HUMAN}`); expect(navigationRef.current?.getRootState().routes.at(-1)?.name).toBe(NAVIGATORS.TAB_NAVIGATOR); }); +}); +describe('Talk to a human deep link', () => { it('resolves the link to the Talk to a human page', () => { // Given the link that Concierge replies point to // When it is opened as a URL From a89e14c7b09e6af703d7862cd24b79213030f7a2 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 16:55:24 +0530 Subject: [PATCH 31/38] Hand a pending pop to the sidebar back after the utility page pops --- src/pages/TalkToHumanPage.tsx | 5 ++++- tests/ui/TalkToHumanPageTest.tsx | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/pages/TalkToHumanPage.tsx b/src/pages/TalkToHumanPage.tsx index 2cfe23bb075b..43f6ffe8b5f8 100644 --- a/src/pages/TalkToHumanPage.tsx +++ b/src/pages/TalkToHumanPage.tsx @@ -30,9 +30,12 @@ function TalkToHumanPage() { hasHandledLink.current = true; // A wide to narrow resize leaves a pending pop to the sidebar, which targets the page underneath - // rather than this one. goBack would consume it here and leave this page open. + // rather than this one. goBack would consume it here and leave this page open, so it is set aside + // while this page pops and handed back to the next Back. + const hadPendingPopToSidebar = Navigation.getShouldPopToSidebar(); Navigation.setShouldPopToSidebar(false); Navigation.goBack(); + Navigation.setShouldPopToSidebar(hadPendingPopToSidebar); if (isOffline) { Navigation.navigate(ROUTES.SETTINGS_HELP); return; diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx index 3d61764797f8..ed9fb3175a9c 100644 --- a/tests/ui/TalkToHumanPageTest.tsx +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -16,6 +16,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ goBack: jest.fn(), navigate: jest.fn(), setShouldPopToSidebar: jest.fn(), + getShouldPopToSidebar: jest.fn(() => false), isNavigationReady: jest.fn(() => Promise.resolve()), })); jest.mock('@react-navigation/native', () => ({ @@ -36,6 +37,7 @@ jest.mock('@components/ReportActionsSkeletonView', () => () => null); const mockUseNetwork = jest.mocked(useNetwork); const mockGoBack = jest.mocked(Navigation.goBack); const mockSetShouldPopToSidebar = jest.mocked(Navigation.setShouldPopToSidebar); +const mockGetShouldPopToSidebar = jest.mocked(Navigation.getShouldPopToSidebar); const mockNavigate = jest.mocked(Navigation.navigate); async function renderPage() { @@ -50,6 +52,7 @@ describe('TalkToHumanPage', () => { beforeEach(() => { jest.clearAllMocks(); mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); + mockGetShouldPopToSidebar.mockReturnValue(false); }); it('goes back and then opens a support ticket', async () => { @@ -68,6 +71,32 @@ describe('TalkToHumanPage', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); + it('hands a pending pop to the sidebar back after it leaves, so the next Back still pops to the sidebar', async () => { + // Given a wide to narrow resize left a pending pop to the sidebar + mockGetShouldPopToSidebar.mockReturnValue(true); + + // When the page opens + await renderPage(); + + // Then the flag is cleared while the page pops, and set again afterwards + expect(mockSetShouldPopToSidebar.mock.calls).toEqual([[false], [true]]); + const [clearCall, restoreCall] = mockSetShouldPopToSidebar.mock.invocationCallOrder; + const goBackCall = mockGoBack.mock.invocationCallOrder.at(0) ?? 0; + expect(clearCall).toBeLessThan(goBackCall); + expect(restoreCall).toBeGreaterThan(goBackCall); + }); + + it('does not arm a pop to the sidebar when none was pending', async () => { + // Given no pending pop to the sidebar + mockGetShouldPopToSidebar.mockReturnValue(false); + + // When the page opens + await renderPage(); + + // Then nothing is armed after the page leaves + expect(mockSetShouldPopToSidebar).not.toHaveBeenCalledWith(true); + }); + it('goes back and shows the Help page without creating a ticket when offline', async () => { // Given the link is opened while the device is offline mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); From e729a4aa51e92c660c10942824e3c9a9f236cd41 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 16:55:57 +0530 Subject: [PATCH 32/38] Assert goBack ran before checking the sidebar flag order --- tests/ui/TalkToHumanPageTest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx index ed9fb3175a9c..45bd1177269a 100644 --- a/tests/ui/TalkToHumanPageTest.tsx +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -79,6 +79,7 @@ describe('TalkToHumanPage', () => { await renderPage(); // Then the flag is cleared while the page pops, and set again afterwards + expect(mockGoBack).toHaveBeenCalledTimes(1); expect(mockSetShouldPopToSidebar.mock.calls).toEqual([[false], [true]]); const [clearCall, restoreCall] = mockSetShouldPopToSidebar.mock.invocationCallOrder; const goBackCall = mockGoBack.mock.invocationCallOrder.at(0) ?? 0; From 24dbd1cfa127af7963a102b77c58bf3798715e1c Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 18:12:47 +0530 Subject: [PATCH 33/38] Disable the Help row offline and cancel stale focus runs MenuItemList reads disabled, not isDisabled, so the Talk to a human row stayed pressable offline and queued tickets that appeared after reconnecting. The page now cancels each focus run's pending navigation callback, so a stale isOffline from an earlier run cannot win. --- src/pages/TalkToHumanPage.tsx | 14 ++++++------- src/pages/settings/HelpPage/HelpPage.tsx | 2 +- tests/ui/HelpPageTest.tsx | 26 ++++++++++++++++++++++++ tests/ui/TalkToHumanPageTest.tsx | 23 +++++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/pages/TalkToHumanPage.tsx b/src/pages/TalkToHumanPage.tsx index 43f6ffe8b5f8..3836aedaf634 100644 --- a/src/pages/TalkToHumanPage.tsx +++ b/src/pages/TalkToHumanPage.tsx @@ -11,7 +11,7 @@ import Navigation from '@libs/Navigation/Navigation'; import ROUTES from '@src/ROUTES'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useEffect, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; // Utility page: goes back, then does what the Help page's Talk to a human row does (offline it shows Help instead). @@ -20,11 +20,12 @@ function TalkToHumanPage() { const {isOffline} = useNetwork(); const {openSupportTicketOrConcierge} = useOpenSupportTicket(); const hasHandledLink = useRef(false); - const isUnmounted = useRef(false); useFocusEffect(() => { + let isCancelled = false; + Navigation.isNavigationReady().then(() => { - if (hasHandledLink.current || isUnmounted.current) { + if (isCancelled || hasHandledLink.current) { return; } hasHandledLink.current = true; @@ -42,14 +43,11 @@ function TalkToHumanPage() { } openSupportTicketOrConcierge(); }); - }); - useEffect(() => { - isUnmounted.current = false; return () => { - isUnmounted.current = true; + isCancelled = true; }; - }, []); + }); return ( diff --git a/src/pages/settings/HelpPage/HelpPage.tsx b/src/pages/settings/HelpPage/HelpPage.tsx index 7958aeff77c4..0d61b35bcbd6 100644 --- a/src/pages/settings/HelpPage/HelpPage.tsx +++ b/src/pages/settings/HelpPage/HelpPage.tsx @@ -205,7 +205,7 @@ function HelpPage() { icon: icons.ChatBubbles, iconType: CONST.ICON_TYPE_ICON, onPress: openSupportTicketOrConcierge, - isDisabled: isOffline, + disabled: isOffline, shouldShowRightIcon: true, wrapperStyle: [styles.sectionMenuItemTopDescription], sentryLabel: CONST.SENTRY_LABEL.SETTINGS_HELP.SUPPORT_TICKET, diff --git a/tests/ui/HelpPageTest.tsx b/tests/ui/HelpPageTest.tsx index b6c318d9e42c..90ec3a5db6c7 100644 --- a/tests/ui/HelpPageTest.tsx +++ b/tests/ui/HelpPageTest.tsx @@ -6,6 +6,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useNetwork from '@hooks/useNetwork'; import {openExternalLink} from '@libs/actions/Link'; @@ -31,11 +32,16 @@ jest.mock('@libs/Navigation/Navigation', () => ({ })); jest.mock('@libs/actions/Help', () => ({openHelpPage: jest.fn()})); jest.mock('@libs/actions/Link', () => ({openExternalLink: jest.fn()})); +const mockOpenSupportTicketOrConcierge = jest.fn(); + jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: jest.fn()})); +jest.mock('@hooks/useOpenSupportTicket', () => () => ({openSupportTicketOrConcierge: mockOpenSupportTicketOrConcierge})); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); jest.mock('@hooks/useIsPaidPolicyAdmin', () => () => true); jest.mock('@hooks/useCurrentUserPersonalDetails'); const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); +const mockUseNetwork = jest.mocked(useNetwork); const mockOpenExternalLink = jest.mocked(openExternalLink); const ACCOUNT_MANAGER_ACCOUNT_ID = 42; @@ -49,6 +55,7 @@ describe('HelpPage', () => { beforeEach(() => { mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 1}); + mockUseNetwork.mockReturnValue({isOffline: false}); }); afterEach(async () => { @@ -84,6 +91,25 @@ describe('HelpPage', () => { expect(mockOpenExternalLink).toHaveBeenCalledWith(CALENDAR_LINK); }); + it('disables the Talk to a human row while offline, so no ticket is queued to appear after reconnecting', async () => { + // Given the Help page while offline + mockUseNetwork.mockReturnValue({isOffline: true}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Talk to a human row is disabled + expect(screen.getByText(translateLocal('initialSettingsPage.talkToAHuman'))).toBeDisabled(); + }); + + it('keeps the Talk to a human row enabled while online', async () => { + // Given the Help page while online + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Talk to a human row can be pressed + expect(screen.getByText(translateLocal('initialSettingsPage.talkToAHuman'))).not.toBeDisabled(); + }); + it('hides the duplicate account manager row when the account manager is Concierge', async () => { // Given Concierge assigned as the account manager await Onyx.merge(ONYXKEYS.ACCOUNT, {accountManagerAccountID: CONST.ACCOUNT_ID.CONCIERGE}); diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx index 45bd1177269a..b6132f92f616 100644 --- a/tests/ui/TalkToHumanPageTest.tsx +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -134,6 +134,29 @@ describe('TalkToHumanPage', () => { expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); }); + it('uses the connection state from when navigation became ready, not a stale one', async () => { + // Given navigation that is not ready yet, and a page that rendered online + let resolveNavigationReady = () => {}; + jest.mocked(Navigation.isNavigationReady).mockReturnValue( + new Promise((resolve) => { + resolveNavigationReady = resolve; + }), + ); + const {rerender} = render(); + + // When the device turns out to be offline before navigation is ready + mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); + rerender(); + await act(async () => { + resolveNavigationReady(); + await Promise.resolve(); + }); + + // Then the page shows Help and does not try to create a ticket + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_HELP); + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); + }); + it('does not open a second ticket when it re-renders before it is removed', async () => { // Given the page has already handled the link const {rerender} = await renderPage(); From c87ad6fbfb55737c898f13be973d1c0f0b627f11 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 18:13:33 +0530 Subject: [PATCH 34/38] Explain the unmemoized focus effect and drop an unused mock --- src/pages/TalkToHumanPage.tsx | 2 ++ tests/ui/HelpPageTest.tsx | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/TalkToHumanPage.tsx b/src/pages/TalkToHumanPage.tsx index 3836aedaf634..9da727a9b18b 100644 --- a/src/pages/TalkToHumanPage.tsx +++ b/src/pages/TalkToHumanPage.tsx @@ -21,6 +21,8 @@ function TalkToHumanPage() { const {openSupportTicketOrConcierge} = useOpenSupportTicket(); const hasHandledLink = useRef(false); + // Left unmemoized on purpose: the effect re-runs when isOffline changes, and the cleanup cancels the earlier run, + // so only the run that saw the latest connection state handles the link. useFocusEffect(() => { let isCancelled = false; diff --git a/tests/ui/HelpPageTest.tsx b/tests/ui/HelpPageTest.tsx index 90ec3a5db6c7..39950d1d950b 100644 --- a/tests/ui/HelpPageTest.tsx +++ b/tests/ui/HelpPageTest.tsx @@ -32,10 +32,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ })); jest.mock('@libs/actions/Help', () => ({openHelpPage: jest.fn()})); jest.mock('@libs/actions/Link', () => ({openExternalLink: jest.fn()})); -const mockOpenSupportTicketOrConcierge = jest.fn(); - jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: jest.fn()})); -jest.mock('@hooks/useOpenSupportTicket', () => () => ({openSupportTicketOrConcierge: mockOpenSupportTicketOrConcierge})); jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); jest.mock('@hooks/useIsPaidPolicyAdmin', () => () => true); jest.mock('@hooks/useCurrentUserPersonalDetails'); From 2e8be9c660dada8984cebc1e81cebfca3923f988 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 18:35:44 +0530 Subject: [PATCH 35/38] Fix the spellcheck word and add When sections to the new Help tests --- src/pages/TalkToHumanPage.tsx | 2 +- tests/ui/HelpPageTest.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pages/TalkToHumanPage.tsx b/src/pages/TalkToHumanPage.tsx index 9da727a9b18b..42d1ec90b57e 100644 --- a/src/pages/TalkToHumanPage.tsx +++ b/src/pages/TalkToHumanPage.tsx @@ -21,7 +21,7 @@ function TalkToHumanPage() { const {openSupportTicketOrConcierge} = useOpenSupportTicket(); const hasHandledLink = useRef(false); - // Left unmemoized on purpose: the effect re-runs when isOffline changes, and the cleanup cancels the earlier run, + // Not wrapped in useCallback on purpose: the effect re-runs when isOffline changes, and the cleanup cancels the earlier run, // so only the run that saw the latest connection state handles the link. useFocusEffect(() => { let isCancelled = false; diff --git a/tests/ui/HelpPageTest.tsx b/tests/ui/HelpPageTest.tsx index 39950d1d950b..12a998f69a08 100644 --- a/tests/ui/HelpPageTest.tsx +++ b/tests/ui/HelpPageTest.tsx @@ -89,8 +89,10 @@ describe('HelpPage', () => { }); it('disables the Talk to a human row while offline, so no ticket is queued to appear after reconnecting', async () => { - // Given the Help page while offline + // Given the device is offline mockUseNetwork.mockReturnValue({isOffline: true}); + + // When the Help page is shown renderPage(); await waitForBatchedUpdatesWithAct(); @@ -99,7 +101,8 @@ describe('HelpPage', () => { }); it('keeps the Talk to a human row enabled while online', async () => { - // Given the Help page while online + // Given the device is online + // When the Help page is shown renderPage(); await waitForBatchedUpdatesWithAct(); From 79dbe499778f7c3a846e18dc2e9dd919aaeb490d Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 20:05:53 +0530 Subject: [PATCH 36/38] Stop a failed support ticket from opening Concierge twice --- src/hooks/useOpenSupportTicket.ts | 16 ++++++++-- src/pages/inbox/ReportNavigateAwayHandler.tsx | 7 +++++ tests/unit/useOpenSupportTicketTest.ts | 29 ++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/hooks/useOpenSupportTicket.ts b/src/hooks/useOpenSupportTicket.ts index 23c84e577468..c47c3777b392 100644 --- a/src/hooks/useOpenSupportTicket.ts +++ b/src/hooks/useOpenSupportTicket.ts @@ -2,7 +2,10 @@ import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/ import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; +import ONYXKEYS from '@src/ONYXKEYS'; + import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; import useOpenConciergeAnywhere from './useOpenConciergeAnywhere'; /** @@ -12,6 +15,15 @@ import useOpenConciergeAnywhere from './useOpenConciergeAnywhere'; function useOpenSupportTicket() { const {translate} = useLocalize(); const {openConciergeAnywhere} = useOpenConciergeAnywhere(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + + const openConcierge = () => { + // The customer is already in the Concierge chat, so opening it again would show it twice. + if (conciergeReportID && Navigation.getFocusedReportId() === conciergeReportID) { + return; + } + openConciergeAnywhere({forceConcierge: true}); + }; const openSupportTicketOrConcierge = () => { const request = openSupportTicket(); @@ -29,11 +41,11 @@ function useOpenSupportTicket() { // A ticket that was never created takes its own screen down. Leaving any other screen would pop // the page the customer is on instead. if (Navigation.getActiveRoute() !== pendingTicketRoute) { - openConciergeAnywhere({forceConcierge: true}); + openConcierge(); return; } - Navigation.goBack(undefined, {afterTransition: () => openConciergeAnywhere({forceConcierge: true})}); + Navigation.goBack(undefined, {afterTransition: openConcierge}); }) .catch(() => undefined); }; diff --git a/src/pages/inbox/ReportNavigateAwayHandler.tsx b/src/pages/inbox/ReportNavigateAwayHandler.tsx index a6a43484c86c..8d6b32ad7704 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -97,6 +97,7 @@ function ReportNavigateAwayHandler() { // a temporary Onyx SET that wipes the report; by the time effects fire, report is undefined // so we must persist the value in a ref updated synchronously during render. See issue #84248. const isCurrentRouteOwnWorkspaceChatRef = useIsOwnWorkspaceChatRef(report, reportIDFromRoute); + const isPendingCreationFromRoute = route.name === SCREENS.REPORT ? route.params?.isPendingCreation === 'true' : false; const firstRender = useRef(true); @@ -230,6 +231,11 @@ function ReportNavigateAwayHandler() { return; } + // A report still pending creation was never created, so its removal is a failed create that the opener handles. + if (isPendingCreationFromRoute) { + return; + } + Navigation.isNavigationReady().then(() => { // Drop the deleted report from the split navigator stack before navigating away. Otherwise, it stays // mounted underneath the destination, and tapping back refocuses it and re-triggers this effect, @@ -251,6 +257,7 @@ function ReportNavigateAwayHandler() { reportWasDeleted, previousReportWasDeleted, isFocused, + isPendingCreationFromRoute, deletedReportParentID, conciergeReportID, introSelected, diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts index 99e86d1d8ba1..e670a9a4f1c3 100644 --- a/tests/unit/useOpenSupportTicketTest.ts +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -7,18 +7,21 @@ import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; const mockOpenConciergeAnywhere = jest.fn(); +const CONCIERGE_REPORT_ID = '9'; jest.mock('@libs/actions/Report', () => ({ openSupportTicket: jest.fn(), isNoSupportRepAvailableResponse: jest.fn(), })); -jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn(), getActiveRoute: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn(), getActiveRoute: jest.fn(), getFocusedReportId: jest.fn()})); jest.mock('@libs/Growl', () => ({error: jest.fn()})); jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useOnyx', () => () => [CONCIERGE_REPORT_ID]); jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: mockOpenConciergeAnywhere})); const mockOpenSupportTicket = jest.mocked(openSupportTicket); const mockGetActiveRoute = jest.mocked(Navigation.getActiveRoute); +const mockGetFocusedReportId = jest.mocked(Navigation.getFocusedReportId); const CHAT_ROUTE = '/r/1'; const PENDING_TICKET_ROUTE = '/r/2/?isPendingCreation=true'; @@ -109,6 +112,30 @@ describe('useOpenSupportTicket', () => { expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); }); + it('does not open Concierge again when the customer is already in the Concierge chat', async () => { + // Given a ticket request that finds no rep while the customer is in the Concierge chat + mockOpenSupportTicket.mockResolvedValue(undefined); + mockIsNoSupportRepAvailableResponse.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); + mockGetFocusedReportId.mockReturnValueOnce(CONCIERGE_REPORT_ID); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs, the request finishes and the ticket screen is left + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + const goBackOptions = jest.mocked(Navigation.goBack).mock.calls.at(0)?.at(1); + if (typeof goBackOptions !== 'object') { + throw new Error('goBack should receive options with an afterTransition callback'); + } + goBackOptions.afterTransition?.(); + + // Then the error is shown and Concierge is not opened a second time + expect(Growl.error).toHaveBeenCalledWith({bodyText: 'supportTicket.noSupportRepAvailable'}); + expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); + }); + it('ignores a failed ticket request', async () => { // Given a ticket request that fails mockOpenSupportTicket.mockRejectedValue(new Error('network')); From 1cb5ed9f42f0f8b38975709702a45605e6363ca0 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 20:05:54 +0530 Subject: [PATCH 37/38] Send anonymous sessions to sign-in from the Talk to a human page --- src/pages/TalkToHumanPage.tsx | 38 ++++++++++++++++++-------------- tests/ui/TalkToHumanPageTest.tsx | 17 ++++++++++++++ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/pages/TalkToHumanPage.tsx b/src/pages/TalkToHumanPage.tsx index 42d1ec90b57e..04d28ea4a07e 100644 --- a/src/pages/TalkToHumanPage.tsx +++ b/src/pages/TalkToHumanPage.tsx @@ -6,6 +6,7 @@ import useNetwork from '@hooks/useNetwork'; import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; import useThemeStyles from '@hooks/useThemeStyles'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import ROUTES from '@src/ROUTES'; @@ -15,6 +16,7 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; // Utility page: goes back, then does what the Help page's Talk to a human row does (offline it shows Help instead). +// Anonymous sessions are sent to sign-in instead. function TalkToHumanPage() { const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -26,24 +28,26 @@ function TalkToHumanPage() { useFocusEffect(() => { let isCancelled = false; - Navigation.isNavigationReady().then(() => { - if (isCancelled || hasHandledLink.current) { - return; - } - hasHandledLink.current = true; + interceptAnonymousUser(() => { + Navigation.isNavigationReady().then(() => { + if (isCancelled || hasHandledLink.current) { + return; + } + hasHandledLink.current = true; - // A wide to narrow resize leaves a pending pop to the sidebar, which targets the page underneath - // rather than this one. goBack would consume it here and leave this page open, so it is set aside - // while this page pops and handed back to the next Back. - const hadPendingPopToSidebar = Navigation.getShouldPopToSidebar(); - Navigation.setShouldPopToSidebar(false); - Navigation.goBack(); - Navigation.setShouldPopToSidebar(hadPendingPopToSidebar); - if (isOffline) { - Navigation.navigate(ROUTES.SETTINGS_HELP); - return; - } - openSupportTicketOrConcierge(); + // A wide to narrow resize leaves a pending pop to the sidebar, which targets the page underneath + // rather than this one. goBack would consume it here and leave this page open, so it is set aside + // while this page pops and handed back to the next Back. + const hadPendingPopToSidebar = Navigation.getShouldPopToSidebar(); + Navigation.setShouldPopToSidebar(false); + Navigation.goBack(); + Navigation.setShouldPopToSidebar(hadPendingPopToSidebar); + if (isOffline) { + Navigation.navigate(ROUTES.SETTINGS_HELP); + return; + } + openSupportTicketOrConcierge(); + }); }); return () => { diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx index b6132f92f616..7f376243157c 100644 --- a/tests/ui/TalkToHumanPageTest.tsx +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -2,6 +2,7 @@ import {act, render} from '@testing-library/react-native'; import useNetwork from '@hooks/useNetwork'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import TalkToHumanPage from '@pages/TalkToHumanPage'; @@ -22,6 +23,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ jest.mock('@react-navigation/native', () => ({ useFocusEffect: (effect: () => void) => jest.requireActual('react').useEffect(effect, [effect]), })); +jest.mock('@libs/interceptAnonymousUser', () => jest.fn()); jest.mock('@hooks/useNetwork', () => jest.fn()); jest.mock('@hooks/useThemeStyles', () => () => ({borderBottom: {}})); jest.mock('@hooks/useOpenSupportTicket', () => () => ({openSupportTicketOrConcierge: mockOpenSupportTicketOrConcierge})); @@ -34,6 +36,7 @@ jest.mock( jest.mock('@components/ReportHeaderSkeletonView', () => () => null); jest.mock('@components/ReportActionsSkeletonView', () => () => null); +const mockInterceptAnonymousUser = jest.mocked(interceptAnonymousUser); const mockUseNetwork = jest.mocked(useNetwork); const mockGoBack = jest.mocked(Navigation.goBack); const mockSetShouldPopToSidebar = jest.mocked(Navigation.setShouldPopToSidebar); @@ -51,6 +54,7 @@ async function renderPage() { describe('TalkToHumanPage', () => { beforeEach(() => { jest.clearAllMocks(); + mockInterceptAnonymousUser.mockImplementation((callback) => callback()); mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); mockGetShouldPopToSidebar.mockReturnValue(false); }); @@ -173,4 +177,17 @@ describe('TalkToHumanPage', () => { expect(mockOpenSupportTicketOrConcierge).toHaveBeenCalledTimes(1); expect(mockNavigate).not.toHaveBeenCalled(); }); + + it('does not leave the page or open a ticket for an anonymous session', async () => { + // Given an anonymous session, which is sent to sign-in instead of running the callback + mockInterceptAnonymousUser.mockImplementation(() => {}); + + // When the page opens + await renderPage(); + + // Then nothing navigates and no ticket is opened + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); + }); }); From b9957e30f5d6f8dfeae20cdf61c5f16419b40736 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 27 Oct 2026 20:37:42 +0530 Subject: [PATCH 38/38] Dismiss the pending ticket screen on every failed ticket request --- src/hooks/useOpenSupportTicket.ts | 29 +++++++++++------- tests/unit/useOpenSupportTicketTest.ts | 41 ++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/hooks/useOpenSupportTicket.ts b/src/hooks/useOpenSupportTicket.ts index c47c3777b392..fb9e815abd84 100644 --- a/src/hooks/useOpenSupportTicket.ts +++ b/src/hooks/useOpenSupportTicket.ts @@ -2,6 +2,7 @@ import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/ import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import useLocalize from './useLocalize'; @@ -30,24 +31,30 @@ function useOpenSupportTicket() { // openSupportTicket opens the pending ticket before it resolves, so this is that screen's own route. const pendingTicketRoute = Navigation.getActiveRoute(); + + // A ticket that was never created leaves its screen on a missing report. Leaving any other screen would pop + // the page the customer is on instead. + const dismissPendingTicket = (afterTransition?: () => void) => { + if (Navigation.getActiveRoute() !== pendingTicketRoute) { + afterTransition?.(); + return; + } + Navigation.goBack(undefined, {afterTransition}); + }; + request .then((response) => { - if (!isNoSupportRepAvailableResponse(response)) { + if (isNoSupportRepAvailableResponse(response)) { + Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')}); + dismissPendingTicket(openConcierge); return; } - Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')}); - - // A ticket that was never created takes its own screen down. Leaving any other screen would pop - // the page the customer is on instead. - if (Navigation.getActiveRoute() !== pendingTicketRoute) { - openConcierge(); - return; + if (response?.jsonCode !== undefined && response.jsonCode !== CONST.JSON_CODE.SUCCESS) { + dismissPendingTicket(); } - - Navigation.goBack(undefined, {afterTransition: openConcierge}); }) - .catch(() => undefined); + .catch(() => dismissPendingTicket()); }; return {openSupportTicketOrConcierge}; diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts index e670a9a4f1c3..10df138d33c7 100644 --- a/tests/unit/useOpenSupportTicketTest.ts +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -136,8 +136,8 @@ describe('useOpenSupportTicket', () => { expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); }); - it('ignores a failed ticket request', async () => { - // Given a ticket request that fails + it('leaves the pending ticket screen when the request fails', async () => { + // Given a ticket request that fails while its screen is still open mockOpenSupportTicket.mockRejectedValue(new Error('network')); mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); const {result} = renderHook(() => useOpenSupportTicket()); @@ -148,8 +148,43 @@ describe('useOpenSupportTicket', () => { await Promise.resolve(); }); - // Then nothing else happens + // Then the ticket screen is left without an error toast or Concierge + expect(Navigation.goBack).toHaveBeenCalledTimes(1); expect(Growl.error).not.toHaveBeenCalled(); + expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); + }); + + it('leaves the pending ticket screen when the server returns another error', async () => { + // Given a ticket request answered with an error that is not the no-rep response + mockOpenSupportTicket.mockResolvedValue({jsonCode: 666, message: 'Something else'}); + mockIsNoSupportRepAvailableResponse.mockReturnValue(false); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request finishes + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then the ticket screen is left and Concierge is not opened + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); + }); + + it('does not navigate when the request fails after the ticket screen is already gone', async () => { + // Given a ticket request that fails after the customer has left the ticket screen + mockOpenSupportTicket.mockRejectedValue(new Error('network')); + mockGetActiveRoute.mockReturnValueOnce(PENDING_TICKET_ROUTE).mockReturnValue(CHAT_ROUTE); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request settles + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then nothing is popped from the page they are on expect(Navigation.goBack).not.toHaveBeenCalled(); }); });