Skip to content

Commit 58904a0

Browse files
authored
Merge pull request #86149 from Expensify/youssef_concierge_anywhere_report_context
[Payment due @abzokhattab] Make Concierge aware of the active report and/or selected transactions
2 parents e097189 + 4b3b2a4 commit 58904a0

9 files changed

Lines changed: 348 additions & 26 deletions

File tree

src/hooks/useCurrentReportID.tsx

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import type {NavigationState} from '@react-navigation/native';
1+
import type {NavigationState, PartialState} from '@react-navigation/native';
22
import React, {createContext, startTransition, useCallback, useContext, useMemo, useRef, useState} from 'react';
33
import Navigation from '@libs/Navigation/Navigation';
4+
import NAVIGATORS from '@src/NAVIGATORS';
45

56
type CurrentReportIDStateContextType = {
67
currentReportID: string | undefined;
8+
currentRHPReportID?: string | undefined;
79
};
810

911
type CurrentReportIDActionsContextType = {
@@ -20,28 +22,46 @@ type CurrentReportIDContextProviderProps = {
2022
onSetCurrentReportID?: (reportID: string | undefined) => void;
2123
};
2224

25+
/**
26+
* Traverse the focused route at each level of the navigation state to find a reportID param.
27+
* This handles modal navigators (e.g. RightModalNavigator > ExpenseReport) that carry a reportID
28+
* in their screen params but are not part of the ReportsSplitNavigator hierarchy.
29+
*/
30+
function getFocusedRouteReportID(state: NavigationState | PartialState<NavigationState>): string | undefined {
31+
const index = state.index ?? state.routes.length - 1;
32+
const focusedRoute = state.routes[index];
33+
if (!focusedRoute) {
34+
return;
35+
}
36+
if (focusedRoute.params && 'reportID' in focusedRoute.params && typeof focusedRoute.params.reportID === 'string') {
37+
return focusedRoute.params.reportID;
38+
}
39+
if (focusedRoute.state) {
40+
return getFocusedRouteReportID(focusedRoute.state);
41+
}
42+
}
43+
2344
const defaultCurrentReportIDActionsContext: CurrentReportIDActionsContextType = {
2445
updateCurrentReportID: () => {},
2546
};
2647

27-
const CurrentReportIDStateContext = createContext<CurrentReportIDStateContextType>({currentReportID: undefined});
48+
const CurrentReportIDStateContext = createContext<CurrentReportIDStateContextType>({currentReportID: undefined, currentRHPReportID: undefined});
2849

2950
const CurrentReportIDActionsContext = createContext<CurrentReportIDActionsContextType>(defaultCurrentReportIDActionsContext);
3051

3152
function CurrentReportIDContextProvider(props: CurrentReportIDContextProviderProps) {
3253
const [currentReportID, setCurrentReportID] = useState<string | undefined>('');
54+
const [currentRHPReportID, setCurrentRHPReportID] = useState<string | undefined>(undefined);
3355
// Tracks the most recently requested reportID synchronously so the dedupe
3456
// check below stays accurate even while a startTransition is pending.
3557
const pendingReportIDRef = useRef<string | undefined>('');
3658

3759
/**
38-
* This function is used to update the currentReportID
60+
* This function is used to update the currentReportID and currentRHPReportID
3961
* @param state root navigation state
4062
*/
4163
const updateCurrentReportID = useCallback(
4264
(state: NavigationState) => {
43-
const reportID = Navigation.getTopmostReportId(state);
44-
4565
/*
4666
* Make sure we don't make the reportID undefined when switching between the chat list and settings tab.
4767
* This helps prevent unnecessary re-renders.
@@ -50,25 +70,31 @@ function CurrentReportIDContextProvider(props: CurrentReportIDContextProviderPro
5070
if (params && 'screen' in params && typeof params.screen === 'string' && params.screen.indexOf('Settings_') !== -1) {
5171
return;
5272
}
53-
if (pendingReportIDRef.current === reportID) {
54-
return;
55-
}
5673

57-
if (!pendingReportIDRef.current && !reportID) {
58-
return;
74+
const reportID = Navigation.getTopmostReportId(state);
75+
76+
if (pendingReportIDRef.current !== reportID) {
77+
if (pendingReportIDRef.current || reportID) {
78+
pendingReportIDRef.current = reportID;
79+
props.onSetCurrentReportID?.(reportID);
80+
// Mark the report ID update as a non-urgent transition so React can keep the
81+
// UI responsive to user input while the (potentially expensive) report screen
82+
// re-render is processed in the background.
83+
startTransition(() => {
84+
setCurrentReportID(reportID);
85+
});
86+
}
5987
}
6088

61-
pendingReportIDRef.current = reportID;
62-
props.onSetCurrentReportID?.(reportID);
63-
// Mark the report ID update as a non-urgent transition so React can keep the
64-
// UI responsive to user input while the (potentially expensive) report screen
65-
// re-render is processed in the background.
66-
startTransition(() => {
67-
setCurrentReportID(reportID);
68-
});
89+
const focusedTopRoute = state.routes[state.index];
90+
const modalReportID = focusedTopRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && focusedTopRoute.state ? getFocusedRouteReportID(focusedTopRoute.state) : undefined;
91+
92+
if (currentRHPReportID !== modalReportID && (currentRHPReportID || modalReportID)) {
93+
setCurrentRHPReportID(modalReportID);
94+
}
6995
},
7096
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to re-render when onSetCurrentReportID changes
71-
[setCurrentReportID],
97+
[setCurrentReportID, setCurrentRHPReportID, currentRHPReportID],
7298
);
7399

74100
const actionsContextValue = useMemo<CurrentReportIDActionsContextType>(
@@ -81,8 +107,9 @@ function CurrentReportIDContextProvider(props: CurrentReportIDContextProviderPro
81107
const stateContextValue = useMemo<CurrentReportIDStateContextType>(
82108
() => ({
83109
currentReportID,
110+
currentRHPReportID,
84111
}),
85-
[currentReportID],
112+
[currentReportID, currentRHPReportID],
86113
);
87114

88115
return (

src/libs/API/parameters/AddCommentOrAttachmentParams.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type AddCommentOrAttachmentParams = {
1414
pageHTML?: string;
1515
optimisticConciergeReportActionID?: string;
1616
pregeneratedResponse?: string;
17+
sidePanelContext?: string;
1718
};
1819

1920
export default AddCommentOrAttachmentParams;

src/libs/actions/Report/index.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ import type {
220220
ReportAttributesDerivedValue,
221221
ReportNextStepDeprecated,
222222
ReportUserIsTyping,
223+
SidePanelContext,
223224
Transaction,
224225
TransactionViolations,
225226
VisibleReportActionsDerivedValue,
@@ -357,6 +358,7 @@ type AddCommentParams = {
357358
currentUserAccountID: number;
358359
shouldPlaySound?: boolean;
359360
isInSidePanel?: boolean;
361+
sidePanelContext?: SidePanelContext;
360362
pregeneratedResponseParams?: PregeneratedResponseParams;
361363
reportActionID?: string;
362364
delegateAccountID: number | undefined;
@@ -371,6 +373,7 @@ type AddActionsParams = {
371373
text?: string;
372374
file?: FileObject;
373375
isInSidePanel?: boolean;
376+
sidePanelContext?: SidePanelContext;
374377
pregeneratedResponseParams?: PregeneratedResponseParams;
375378
reportActionID?: string;
376379
delegateAccountID: number | undefined;
@@ -387,6 +390,7 @@ type AddAttachmentWithCommentParams = {
387390
shouldPlaySound?: boolean;
388391
isInSidePanel?: boolean;
389392
delegateAccountID: number | undefined;
393+
sidePanelContext?: SidePanelContext;
390394
};
391395

392396
const addNewMessageWithText = new Set<string>([WRITE_COMMANDS.ADD_COMMENT, WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]);
@@ -684,6 +688,7 @@ function addActions({
684688
text = '',
685689
file,
686690
isInSidePanel = false,
691+
sidePanelContext,
687692
pregeneratedResponseParams,
688693
reportActionID,
689694
delegateAccountID,
@@ -799,21 +804,26 @@ function addActions({
799804
idempotencyKey: Str.guid(),
800805
};
801806

802-
if (reportIDDeeplinkedFromOldDot === reportID && isConciergeChatReport(report)) {
807+
const isConciergeChat = isConciergeChatReport(report);
808+
if (reportIDDeeplinkedFromOldDot === reportID && isConciergeChat) {
803809
parameters.isOldDotConciergeChat = true;
804810
}
805811

806812
if (file) {
807813
parameters.attachmentID = attachmentID;
808814
}
809815

810-
if (isInSidePanel && (isConciergeChatReport(report) || isAdminRoom(report))) {
816+
if (isInSidePanel && (isConciergeChat || isAdminRoom(report))) {
811817
const pageHTML = capturePageHTML();
812818
if (pageHTML) {
813819
parameters.pageHTML = pageHTML;
814820
}
815821
}
816822

823+
if (isInSidePanel && isConciergeChat && sidePanelContext && commandName === WRITE_COMMANDS.ADD_COMMENT) {
824+
parameters.sidePanelContext = JSON.stringify(sidePanelContext);
825+
}
826+
817827
// Add pregenerated params
818828
if (pregeneratedResponseParams) {
819829
parameters.optimisticConciergeReportActionID = pregeneratedResponseParams.optimisticConciergeReportActionID;
@@ -939,6 +949,7 @@ function addAttachmentWithComment({
939949
shouldPlaySound = false,
940950
isInSidePanel = false,
941951
delegateAccountID,
952+
sidePanelContext,
942953
}: AddAttachmentWithCommentParams) {
943954
if (!report?.reportID) {
944955
return;
@@ -953,7 +964,7 @@ function addAttachmentWithComment({
953964

954965
// Single attachment
955966
if (!Array.isArray(attachments)) {
956-
addActions({report, notifyReportID, ancestors, timezoneParam: timezone, currentUserAccountID, text, file: attachments, isInSidePanel, delegateAccountID});
967+
addActions({report, notifyReportID, ancestors, timezoneParam: timezone, currentUserAccountID, text, file: attachments, isInSidePanel, delegateAccountID, sidePanelContext});
957968
handlePlaySound();
958969
return;
959970
}
@@ -963,7 +974,18 @@ function addAttachmentWithComment({
963974

964975
// Remaining: attachment-only actions (no text duplication)
965976
for (let i = 1; i < attachments?.length; i += 1) {
966-
addActions({report, notifyReportID, ancestors, timezoneParam: timezone, currentUserAccountID, text: '', file: attachments?.at(i), isInSidePanel, delegateAccountID});
977+
addActions({
978+
report,
979+
notifyReportID,
980+
ancestors,
981+
timezoneParam: timezone,
982+
currentUserAccountID,
983+
text: '',
984+
file: attachments?.at(i),
985+
isInSidePanel,
986+
delegateAccountID,
987+
sidePanelContext,
988+
});
967989
}
968990

969991
// Play sound once
@@ -980,14 +1002,27 @@ function addComment({
9801002
currentUserAccountID,
9811003
shouldPlaySound,
9821004
isInSidePanel,
1005+
sidePanelContext,
9831006
pregeneratedResponseParams,
9841007
reportActionID,
9851008
delegateAccountID,
9861009
}: AddCommentParams) {
9871010
if (shouldPlaySound) {
9881011
playSound(SOUNDS.DONE);
9891012
}
990-
addActions({report, notifyReportID, ancestors, timezoneParam, currentUserAccountID, text, isInSidePanel, pregeneratedResponseParams, reportActionID, delegateAccountID});
1013+
addActions({
1014+
report,
1015+
notifyReportID,
1016+
ancestors,
1017+
timezoneParam,
1018+
currentUserAccountID,
1019+
text,
1020+
isInSidePanel,
1021+
pregeneratedResponseParams,
1022+
reportActionID,
1023+
delegateAccountID,
1024+
sidePanelContext,
1025+
});
9911026
}
9921027

9931028
function reportActionsExist(reportID: string): boolean {

src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import CONST from '@src/CONST';
2626
import ONYXKEYS from '@src/ONYXKEYS';
2727
import type * as OnyxTypes from '@src/types/onyx';
2828
import {useComposerMeta} from './ComposerContext';
29+
import useSidePanelContext from './useSidePanelContext';
2930

3031
function useComposerSubmit(reportID: string): (comment: string) => void {
3132
const {isOffline} = useNetwork();
@@ -34,6 +35,7 @@ function useComposerSubmit(reportID: string): (comment: string) => void {
3435
const personalDetails = usePersonalDetails();
3536
const {availableLoginsList} = useShortMentionsList();
3637
const isInSidePanel = useIsInSidePanel();
38+
const sidePanelContext = useSidePanelContext(reportID);
3739
const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE);
3840
const delegateAccountID = useDelegateAccountID();
3941

@@ -75,6 +77,7 @@ function useComposerSubmit(reportID: string): (comment: string) => void {
7577
shouldPlaySound: true,
7678
isInSidePanel,
7779
delegateAccountID,
80+
sidePanelContext,
7881
});
7982
attachmentFileRef.current = null;
8083
return;
@@ -145,6 +148,7 @@ function useComposerSubmit(reportID: string): (comment: string) => void {
145148
currentUserAccountID: currentUserPersonalDetails.accountID,
146149
shouldPlaySound: true,
147150
isInSidePanel,
151+
sidePanelContext,
148152
reportActionID: optimisticReportActionID,
149153
delegateAccountID,
150154
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {useMemo} from 'react';
2+
import {useSearchStateContext} from '@components/Search/SearchContext';
3+
import {useCurrentReportIDState} from '@hooks/useCurrentReportID';
4+
import useIsInSidePanel from '@hooks/useIsInSidePanel';
5+
import useOnyx from '@hooks/useOnyx';
6+
import CONST from '@src/CONST';
7+
import ONYXKEYS from '@src/ONYXKEYS';
8+
import type * as OnyxTypes from '@src/types/onyx';
9+
import {isEmptyObject} from '@src/types/utils/EmptyObject';
10+
11+
function useSidePanelContext(reportID: string): OnyxTypes.SidePanelContext | undefined {
12+
const isInSidePanel = useIsInSidePanel();
13+
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
14+
const {currentReportID, currentRHPReportID} = useCurrentReportIDState();
15+
const {currentSearchQueryJSON, selectedTransactionIDs, selectedTransactions, selectedReports} = useSearchStateContext();
16+
17+
return useMemo(() => {
18+
if (conciergeReportID !== reportID || !isInSidePanel) {
19+
return undefined;
20+
}
21+
22+
const contextReportID = currentRHPReportID ?? currentReportID ?? undefined;
23+
24+
// selectedTransactions (map) is populated from the Search list; selectedTransactionIDs (array)
25+
// is populated from the report table view. The two are mutually exclusive.
26+
const txIDsFromMap = !isEmptyObject(selectedTransactions)
27+
? Object.entries(selectedTransactions)
28+
.filter(([, info]) => info.isSelected && !!info.transaction)
29+
.map(([id]) => id)
30+
: [];
31+
const allTransactionIDs = txIDsFromMap.length > 0 ? txIDsFromMap : selectedTransactionIDs;
32+
const selectedTransactionIDsForContext = allTransactionIDs.length > 0 ? allTransactionIDs.join(',') : undefined;
33+
34+
const selectedReportIDsForContext =
35+
selectedReports.length > 0
36+
? selectedReports
37+
.map((r) => r.reportID)
38+
.filter((id): id is string => !!id)
39+
.join(',') || undefined
40+
: undefined;
41+
42+
// This condition is reached when we are either in the global Reports => Reports page, or within a single expense report having multiple transactions.
43+
// If we have selectedReportIDs, that means we're in the Reports page, otherwise we're in the expense report RHP.
44+
if (currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) {
45+
return selectedReportIDsForContext ? {selectedReportIDs: selectedReportIDsForContext} : {reportID: contextReportID, selectedTransactionIDs: selectedTransactionIDsForContext};
46+
}
47+
48+
if (!contextReportID && !selectedTransactionIDsForContext && !selectedReportIDsForContext) {
49+
return undefined;
50+
}
51+
52+
return {reportID: contextReportID, selectedTransactionIDs: selectedTransactionIDsForContext, selectedReportIDs: selectedReportIDsForContext};
53+
}, [conciergeReportID, reportID, isInSidePanel, currentSearchQueryJSON?.type, currentRHPReportID, currentReportID, selectedTransactionIDs, selectedTransactions, selectedReports]);
54+
}
55+
56+
export default useSidePanelContext;

src/types/onyx/SidePanel.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,11 @@ type SidePanel = {
66
openNarrowScreen: boolean;
77
};
88

9+
/**
10+
* Describes the context of what the user was viewing when they sent a message from the Side Panel.
11+
* Sent to the backend so Concierge can tailor its response to the user's current context.
12+
*/
13+
type SidePanelContext = {reportID?: string; selectedTransactionIDs?: string; selectedReportIDs?: string};
14+
915
export default SidePanel;
16+
export type {SidePanelContext};

src/types/onyx/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ import type Session from './Session';
159159
import type ShareBankAccount from './ShareBankAccount';
160160
import type ShareTempFile from './ShareTempFile';
161161
import type SidePanel from './SidePanel';
162+
import type {SidePanelContext} from './SidePanel';
162163
import type StripeCustomerID from './StripeCustomerID';
163164
import type SupportalPermissionDenied from './SupportalPermissionDenied';
164165
import type Task from './Task';
@@ -368,6 +369,7 @@ export type {
368369
DismissedProductTraining,
369370
TravelProvisioning,
370371
SidePanel,
372+
SidePanelContext,
371373
LastPaymentMethodType,
372374
ReportAttributesDerivedValue,
373375
LastSearchParams,

tests/unit/SuggestionMentionTest.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ describe('SuggestionMention', () => {
121121

122122
mockUsePersonalDetails.mockImplementation(() => mockPersonalDetails);
123123
mockUseArrowKeyFocusManager.mockReturnValue([0, mockSetHighlightedMentionIndex]);
124-
mockUseCurrentReportIDState.mockReturnValue({currentReportID: ''});
124+
mockUseCurrentReportIDState.mockReturnValue({currentReportID: '', currentRHPReportID: ''});
125125
mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 1, login: 'current@gmail.com'});
126126
mockUseDebounce.mockImplementation((callback) => {
127127
const callbackRef = React.useRef(callback);

0 commit comments

Comments
 (0)