Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
8b33174
Open a support ticket straight from the Talk to a human link
ishpaul777 Oct 21, 2026
27bbef5
Leave the action route before opening the ticket and justify the hook…
ishpaul777 Oct 21, 2026
8bb3d5b
Fall back to the Help page when a direct link has nothing to go back to
ishpaul777 Oct 21, 2026
37a9c92
Fix the Talk to a human afterTransition type
ishpaul777 Oct 21, 2026
723f39a
Set shouldUseGoBackButton explicitly on the Talk to a human loader
ishpaul777 Oct 21, 2026
7ccb87e
Merge main into ishpaul/684546-talk-to-human-ticket
ishpaul777 Oct 23, 2026
c49ecdd
Restore upstream formatting of files reformatted by the merge commit …
ishpaul777 Oct 23, 2026
ac3d8ec
Return nothing from the support ticket callback so Help rows are not …
ishpaul777 Oct 24, 2026
99d3f02
Drop the redundant displayName and shouldUseGoBackButton from the Tal…
ishpaul777 Oct 24, 2026
81931b2
Show the Help page when the link opens offline and detect direct link…
ishpaul777 Oct 24, 2026
95e8693
Test the Talk to a human page and the support ticket callback
ishpaul777 Oct 24, 2026
46a2b72
Replace the route with Help when opened offline and keep the origin f…
ishpaul777 Oct 24, 2026
fc9d1b6
Cover the offline replace and same-split cases, add Given/When/Then, …
ishpaul777 Oct 24, 2026
bf98376
Simplify the Talk to a human page: read isOffline directly, pass afte…
ishpaul777 Oct 24, 2026
c03c70b
Share the full screen root route count between SplitRouter and the Ta…
ishpaul777 Oct 24, 2026
a7db5c6
Use real Settings screen names in the page test and cover the no-rep …
ishpaul777 Oct 24, 2026
535cd32
Merge branch 'Expensify:main' into ishpaul/684546-talk-to-human-ticket
ishpaul777 Oct 24, 2026
ad34328
Import the support ticket actions through the same alias the Help pag…
ishpaul777 Oct 24, 2026
262ce61
Merge remote-tracking branch 'origin/ishpaul/684546-talk-to-human-tic…
ishpaul777 Oct 24, 2026
8b3c50a
Show the Help page when no support rep is available
ishpaul777 Oct 24, 2026
67accc4
Fail the tests loudly when goBack gets no afterTransition callback
ishpaul777 Oct 24, 2026
747e2ca
Show the Help page offline by leaving the loader first instead of rep…
ishpaul777 Oct 25, 2026
1f304e5
Make Talk to a human a root-level utility page like Concierge and Tra…
ishpaul777 Oct 25, 2026
96f4fb4
Guard the Talk to a human page against unmount and pin its navigation…
ishpaul777 Oct 25, 2026
14fa4ef
Keep the no-rep fallback identical to the Help page row
ishpaul777 Oct 25, 2026
d0a50ee
Only leave the failed ticket screen when it is still open
ishpaul777 Oct 25, 2026
38d3193
Match the failed ticket's own route before leaving it
ishpaul777 Oct 25, 2026
0d4342a
Clear a pending pop to the sidebar before the utility page leaves
ishpaul777 Oct 25, 2026
ed24c9b
Let React Compiler memoize the focus effect
ishpaul777 Oct 26, 2026
ccb4a31
Merge remote-tracking branch 'upstream/main' into ishpaul/684546-talk…
ishpaul777 Oct 26, 2026
c2b47a3
Remove the Help alias now that the route is its own utility page
ishpaul777 Oct 26, 2026
93e9ef0
Test that the Talk to a human link resolves to its own page
ishpaul777 Oct 26, 2026
08d40a7
Run the deep-link test under both layouts
ishpaul777 Oct 26, 2026
124bce8
Run the deep-link parse test once, since it does not depend on layout
ishpaul777 Oct 26, 2026
a89e14c
Hand a pending pop to the sidebar back after the utility page pops
ishpaul777 Oct 27, 2026
e729a4a
Assert goBack ran before checking the sidebar flag order
ishpaul777 Oct 27, 2026
24dbd1c
Disable the Help row offline and cancel stale focus runs
ishpaul777 Oct 27, 2026
c87ad6f
Explain the unmemoized focus effect and drop an unused mock
ishpaul777 Oct 27, 2026
2e8be9c
Fix the spellcheck word and add When sections to the new Help tests
ishpaul777 Oct 27, 2026
a6ae82e
Merge branch 'Expensify:main' into ishpaul/684546-talk-to-human-ticket
ishpaul777 Oct 27, 2026
79dbe49
Stop a failed support ticket from opening Concierge twice
ishpaul777 Oct 27, 2026
1cb5ed9
Send anonymous sessions to sign-in from the Talk to a human page
ishpaul777 Oct 27, 2026
b9957e3
Dismiss the pending ticket screen on every failed ticket request
ishpaul777 Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
63 changes: 63 additions & 0 deletions src/hooks/useOpenSupportTicket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/Report';
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';
import useOnyx from './useOnyx';
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 [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();

// 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)) {
Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')});
dismissPendingTicket(openConcierge);
return;
}

if (response?.jsonCode !== undefined && response.jsonCode !== CONST.JSON_CODE.SUCCESS) {
dismissPendingTicket();
}
Comment thread
ishpaul777 marked this conversation as resolved.
Resolved
})
.catch(() => dismissPendingTicket());
};

return {openSupportTicketOrConcierge};
}

export default useOpenSupportTicket;
6 changes: 6 additions & 0 deletions src/libs/Navigation/AppNavigator/AuthScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const loadAttachmentModalScreen = () => require<ReactComponentModule>('../../../
const loadValidateLoginPage = () => require<ReactComponentModule>('../../../pages/ValidateLoginPage').default;
const loadLogOutPreviousUserPage = () => require<ReactComponentModule>('../../../pages/LogOutPreviousUserPage').default;
const loadConciergePage = () => require<ReactComponentModule>('../../../pages/ConciergePage').default;
const loadTalkToHumanPage = () => require<ReactComponentModule>('../../../pages/TalkToHumanPage').default;
const loadTrackExpensePage = () => require<ReactComponentModule>('../../../pages/TrackExpensePage').default;
const loadSubmitExpensePage = () => require<ReactComponentModule>('../../../pages/SubmitExpensePage').default;
const loadPreMountBufferPage = () => require<ReactComponentModule>('../../../pages/PreMountBufferPage').default;
Expand Down Expand Up @@ -226,6 +227,11 @@ function AuthScreens() {
options={defaultScreenOptions}
getComponent={loadConciergePage}
/>
<RootStack.Screen
name={SCREENS.TALK_TO_HUMAN}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replay the utility deep link after authentication

When this URL is delivered while the user is signed out (for example, an expired native session), linking resolves it to the new root TalkToHuman screen, but a repo-wide search for SCREENS.TALK_TO_HUMAN registrations finds it only in AuthScreens and not in the currently mounted PublicScreens; linkingConfig/subscribe.ts forwards non-report URLs directly to that public navigator. The action is therefore unhandled and no intent is retained, so signing in lands at the normal root without opening a support ticket; retain the destination under the shared tab route or defer/replay it once AuthScreens mounts.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TrackExpensePage is registered only in AuthScreens as well, so signed-out links to root utility pages already behave this way. Keeping the same pattern here rather than adding replay logic for one route.

options={defaultScreenOptions}
getComponent={loadTalkToHumanPage}
/>
<RootStack.Screen
name={SCREENS.TRACK_EXPENSE}
options={defaultScreenOptions}
Expand Down
3 changes: 1 addition & 2 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const config: LinkingOptions<RootNavigatorParamList>['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,
Expand Down Expand Up @@ -2427,8 +2428,6 @@ const config: LinkingOptions<RootNavigatorParamList>['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]: {
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1478,6 +1478,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]: {
Expand Down
68 changes: 68 additions & 0 deletions src/pages/TalkToHumanPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 interceptAnonymousUser from '@libs/interceptAnonymousUser';
import Navigation from '@libs/Navigation/Navigation';

import ROUTES from '@src/ROUTES';

import {useFocusEffect} from '@react-navigation/native';
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();
const {openSupportTicketOrConcierge} = useOpenSupportTicket();
const hasHandledLink = useRef(false);

// 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;

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();
});
});

return () => {
isCancelled = true;
};
});

return (
<ScreenWrapper testID="TalkToHumanPage">
<View style={[styles.borderBottom]}>
<ReportHeaderSkeletonView onBackButtonPress={Navigation.goBack} />
</View>
<ReportActionsSkeletonView />
</ScreenWrapper>
);
}

export default TalkToHumanPage;
7 changes: 7 additions & 0 deletions src/pages/inbox/ReportNavigateAwayHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Comment thread
ishpaul777 marked this conversation as resolved.
Resolved
}

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,
Expand All @@ -251,6 +257,7 @@ function ReportNavigateAwayHandler() {
reportWasDeleted,
previousReportWasDeleted,
isFocused,
isPendingCreationFromRoute,
deletedReportParentID,
conciergeReportID,
introSelected,
Expand Down
20 changes: 4 additions & 16 deletions src/pages/settings/HelpPage/HelpPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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({bodyText: 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;
Expand Down Expand Up @@ -217,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,
Expand Down
133 changes: 133 additions & 0 deletions tests/navigation/TalkToHumanNavigationTests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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 getPathFromState from '@libs/Navigation/helpers/getPathFromState';
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 {findFocusedRoute} 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(<TestNavigationContainer initialState={chatState} />);

// 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('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(<TestNavigationContainer initialState={chatState} />);
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(<TestNavigationContainer initialState={getAdaptedStateFromPath(ROUTES.TALK_TO_HUMAN) as InitialState} />);
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);
});
});

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
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);
expect(getPathFromState(state)).toBe(`/${ROUTES.TALK_TO_HUMAN}`);
});
});
Loading
Loading