Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
bd95724
Surface "Create a rule" prompt on expense edits
Krishna2323 Aug 21, 2026
3e1ea38
Render the merchant rule prompt as a product training tooltip
Krishna2323 Aug 21, 2026
dbf97a6
Merge branch 'main' into krishna2323/issue-96354
Krishna2323 Aug 27, 2026
9029ae6
Render the merchant rule prompt as a product training tooltip
Krishna2323 Aug 27, 2026
08162e8
Fix the merchant rule tooltip's highlight color and scroll behavior
Krishna2323 Aug 27, 2026
840858a
Align the merchant rule tooltip with the product training conventions
Krishna2323 Aug 27, 2026
e22352d
Use a link color and bold weight for the tooltip's highlighted phrase
Krishna2323 Aug 27, 2026
23f8c24
Retire the merchant rule offer once it has been seen
Krishna2323 Aug 28, 2026
b166e9e
Drop the deprecated backTo param from the merchant rule flow
Krishna2323 Aug 28, 2026
3797282
Scope the merchant rule callout to one expense at a time
Krishna2323 Aug 28, 2026
ab0ddbe
Persist merchant rule callout dismissals per expense
Krishna2323 Aug 28, 2026
c00c76a
Scope merchant rule callout dismissals to the session
Krishna2323 Sep 1, 2026
dd08701
Render the merchant rule prompt as a pinned banner
Krishna2323 Sep 1, 2026
b1630d9
Keep navigating away from re-offering, without silencing the expense
Krishna2323 Sep 1, 2026
39c61b4
Stop the merchant rule callout rendering twice in a wide RHP
Krishna2323 Sep 1, 2026
b8b1fc5
Float the composer callout instead of giving it layout height
Krishna2323 Sep 1, 2026
6307b9b
Compose the merchant rule overlays from the positioning helpers
Krishna2323 Sep 1, 2026
ccba862
Retire the merchant rule callout when the offer is taken
Krishna2323 Sep 1, 2026
8813e3e
Merge branch 'Expensify:main' into krishna2323/issue-96354
Krishna2323 Sep 1, 2026
29b4e12
Simplify the merchant rule callout comments and tighten two rule viol…
Krishna2323 Sep 1, 2026
00e0a1b
Slide the merchant rule callout in and out
Krishna2323 Sep 1, 2026
cf4ec20
Stop the merchant rule callout breaking mid-word
Krishna2323 Sep 1, 2026
efd5d87
Drop the deprecated Onyx.connectWithoutView from the merchant rule ac…
Krishna2323 Sep 1, 2026
5d7a035
Guard the wide RHP lookup against a missing route
Krishna2323 Sep 1, 2026
97d84f6
update translations.
Krishna2323 Sep 1, 2026
99d98e1
Match the callout link's size and open it to everyone who can write r…
Krishna2323 Sep 1, 2026
3fd6dc4
Merge branch 'Expensify:main' into krishna2323/issue-96354
Krishna2323 Sep 1, 2026
98be3f1
Only record a merchant rule offer where a rule could exist
Krishna2323 Sep 1, 2026
ff6842e
Open the merchant rule flow as a suffix on the expense that started it
Krishna2323 Sep 1, 2026
5341423
fix knip check.
Krishna2323 Sep 1, 2026
99208d3
Use the reversed link color and the label typography on the callout
Krishna2323 Sep 1, 2026
2cb0570
Record every field edited on an expense, not just the last one
Krishna2323 Sep 1, 2026
3912c8b
Merge branch 'Expensify:main' into krishna2323/issue-96354
Krishna2323 Sep 1, 2026
51ff413
Address review: fix the match type back button and tighten the callout
Krishna2323 Sep 2, 2026
ce0991f
Coerce the tag page's orderWeight, which dynamic routes deliver as a …
Krishna2323 Sep 2, 2026
c5210c2
Retire the rule suggestion when the report is left, not when the call…
Krishna2323 Sep 2, 2026
45280fb
Derive the merchant rule fields from EDIT_REQUEST_FIELD and use the s…
Krishna2323 Sep 2, 2026
9c7a62e
Shorten and simplify the comments added in this PR
Krishna2323 Sep 2, 2026
0c6799a
Seed a merchant rule with only the tag levels the user actually edited
Krishna2323 Sep 2, 2026
6562772
Take the edited tag level from the caller, and clear it once the rule…
Krishna2323 Sep 2, 2026
9b1fd7f
fix merge conflicts.
Krishna2323 Sep 6, 2026
7d84ba0
Retire the offer when the expense page is left, and skip offers that …
Krishna2323 Sep 6, 2026
062c134
Drop the callout instantly when the composer expands, without animati…
Krishna2323 Sep 6, 2026
4281f48
Seed the rule type from the callout so it opens the merchant editor d…
Krishna2323 Sep 6, 2026
3b31b89
Paint the create-a-rule callout above the new-messages pill
Krishna2323 Sep 7, 2026
fd93496
Hide the callout on Collect, where the rule page is out of reach
Krishna2323 Sep 8, 2026
8050a3e
Hold the create-a-rule callout behind the rules revamp beta
Krishna2323 Sep 8, 2026
009198a
Merge branch 'main' into krishna2323/issue-96354
Krishna2323 Sep 9, 2026
530ecdd
Stop the callout flickering in, and show it only on the focused repor…
Krishna2323 Sep 9, 2026
167aeea
Spring the callout in instead of fading it with a layout animation
Krishna2323 Sep 9, 2026
4880d5a
Keep hidden offers unseen, and stop the retire hook waking the report…
Krishna2323 Sep 9, 2026
acea7d9
Roll back offers on failed edits, skip unused back-path work, and cov…
Krishna2323 Sep 9, 2026
135ec85
fix spell check.
Krishna2323 Sep 9, 2026
eebf269
fix ESLint.
Krishna2323 Sep 9, 2026
6a94c72
Merge branch 'main' into krishna2323/issue-96354
Krishna2323 Sep 10, 2026
8e16905
Scope offer retirement to the report that showed it, and stop trackin…
Krishna2323 Sep 10, 2026
24ed985
Track edits only on write success, drop the failure rollback
Krishna2323 Sep 10, 2026
8828ee1
Track edits optimistically again, so the offer shows offline
Krishna2323 Sep 10, 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 cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,7 @@
"tosorted",
"touchables",
"tranid",
"travelled",
"trinet",
"trivago",
"trustcacerts",
Expand Down
50 changes: 32 additions & 18 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,27 @@ const EMAIL = {
QA_GUIDE: 'qa.guide@team.expensify.com',
};

// Declared above CONST so subsets can be built from its members instead of repeating the strings. Spliced in below
// as `EDIT_REQUEST_FIELD`.
const editRequestFields = {
AMOUNT: 'amount',
CURRENCY: 'currency',
DATE: 'date',
DESCRIPTION: 'description',
MERCHANT: 'merchant',
CATEGORY: 'category',
RECEIPT: 'receipt',
DISTANCE: 'distance',
DISTANCE_RATE: 'distanceRate',
TAG: 'tag',
TAX_RATE: 'taxRate',
TAX_AMOUNT: 'taxAmount',
REIMBURSABLE: 'reimbursable',
ATTENDEES: 'attendees',
BILLABLE: 'billable',
REPORT: 'report',
} as const;

const CONST = {
HEIC_SIGNATURES: [
'6674797068656963', // 'ftypheic' - Indicates standard HEIC file
Expand Down Expand Up @@ -527,6 +548,16 @@ const CONST = {

MERCHANT_NAME_MAX_BYTES: 255,

/** The subset of EDIT_REQUEST_FIELD a merchant rule can govern, whose edit shows the "Create a rule" callout */
MERCHANT_RULE_SUGGESTION_FIELDS: {
CATEGORY: editRequestFields.CATEGORY,
TAG: editRequestFields.TAG,
TAX: editRequestFields.TAX_RATE,
DESCRIPTION: editRequestFields.DESCRIPTION,
BILLABLE: editRequestFields.BILLABLE,
REIMBURSABLE: editRequestFields.REIMBURSABLE,
},

MASKED_PAN_PREFIX: 'XXXXXXXXXXXX',

REQUEST_PREVIEW: {
Expand Down Expand Up @@ -5659,24 +5690,7 @@ const CONST = {
SHARE: 'share',
},
},
EDIT_REQUEST_FIELD: {
AMOUNT: 'amount',
CURRENCY: 'currency',
DATE: 'date',
DESCRIPTION: 'description',
MERCHANT: 'merchant',
CATEGORY: 'category',
RECEIPT: 'receipt',
DISTANCE: 'distance',
DISTANCE_RATE: 'distanceRate',
TAG: 'tag',
TAX_RATE: 'taxRate',
TAX_AMOUNT: 'taxAmount',
REIMBURSABLE: 'reimbursable',
ATTENDEES: 'attendees',
BILLABLE: 'billable',
REPORT: 'report',
},
EDIT_REQUEST_FIELD: editRequestFields,
FOOTER: {
EXPENSE_MANAGEMENT_URL: `${USE_EXPENSIFY_URL}/expense-management`,
SPEND_MANAGEMENT_URL: `${USE_EXPENSIFY_URL}/spend-management`,
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,9 @@ const ONYXKEYS = {
/** Session-scoped flag: user dismissed the "enable notifications" banner in the Concierge chat */
RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER: 'hasDismissedConciergeNotificationBanner',

/** Session-scoped record of the latest expense edit that could become a merchant rule, driving the "Create a rule" callout */
RAM_ONLY_MERCHANT_RULE_SUGGESTION: 'merchantRuleSuggestion',

NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails',

/** Stores the information about duplicated workspace */
Expand Down Expand Up @@ -1787,6 +1790,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.ASSIGN_CARD]: OnyxTypes.AssignCard;
[ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE]: boolean;
[ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER]: boolean;
[ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION]: OnyxTypes.MerchantRuleSuggestion;
[ONYXKEYS.DUPLICATE_WORKSPACE]: OnyxTypes.DuplicateWorkspace;
[ONYXKEYS.COPY_POLICY_SETTINGS]: OnyxTypes.CopyPolicySettings;
[ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string;
Expand Down
51 changes: 51 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,57 @@ const DYNAMIC_ROUTES = {
path: 'rules/require-fields',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS, SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS],
},
RULES_MERCHANT_NEW_FROM_EXPENSE: {
path: 'merchant-rule/new',
entryScreens: [SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT],
getRoute: (policyID: string) => getUrlWithParams('merchant-rule/new', {policyID}),
queryParams: ['policyID'],
},
RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE: {
path: 'rule-merchant-to-match',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW],
},
RULES_MERCHANT_MATCH_TYPE_FROM_EXPENSE: {
path: 'rule-match-type',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH],
},
RULES_MERCHANT_MERCHANT_FROM_EXPENSE: {
path: 'rule-merchant',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW],
},
RULES_MERCHANT_CATEGORY_FROM_EXPENSE: {
path: 'rule-category',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW],
},
RULES_MERCHANT_TAG_FROM_EXPENSE: {
path: 'rule-tag/:orderWeight',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW],
getRoute: (orderWeight: number) => `rule-tag/${orderWeight}` as const,
},
RULES_MERCHANT_TAX_FROM_EXPENSE: {
path: 'rule-tax',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW],
},
RULES_MERCHANT_VENDOR_FROM_EXPENSE: {
path: 'rule-vendor',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW],
},
RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE: {
path: 'rule-description',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW],
},
RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE: {
path: 'rule-reimbursable',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW],
},
RULES_MERCHANT_BILLABLE_FROM_EXPENSE: {
path: 'rule-billable',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW],
},
RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE: {
path: 'rule-matches',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW],
},
NOTIFICATION_PREFERENCES: {
// `reportID` is intentionally carried as a distinct path param (`notificationReportID`) rather than
// `reportID`, so it never collides with a `reportID` inherited from the surrounding report chain's
Expand Down
12 changes: 12 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,18 @@ const SCREENS = {
RULES_CATEGORY_TO_MATCH: 'Rules_Category_To_Match',
RULES_CATEGORY_TAX_EDIT: 'Rules_Category_Tax_Edit',
RULES_MERCHANT_EDIT: 'Rules_Merchant_Edit',
DYNAMIC_RULES_MERCHANT_NEW: 'Dynamic_Rules_Merchant_New',
DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH: 'Dynamic_Rules_Merchant_Merchant_To_Match',
DYNAMIC_RULES_MERCHANT_MATCH_TYPE: 'Dynamic_Rules_Merchant_Match_Type',
DYNAMIC_RULES_MERCHANT_MERCHANT: 'Dynamic_Rules_Merchant_Merchant',
DYNAMIC_RULES_MERCHANT_CATEGORY: 'Dynamic_Rules_Merchant_Category',
DYNAMIC_RULES_MERCHANT_TAG: 'Dynamic_Rules_Merchant_Tag',
DYNAMIC_RULES_MERCHANT_TAX: 'Dynamic_Rules_Merchant_Tax',
DYNAMIC_RULES_MERCHANT_VENDOR: 'Dynamic_Rules_Merchant_Vendor',
DYNAMIC_RULES_MERCHANT_DESCRIPTION: 'Dynamic_Rules_Merchant_Description',
DYNAMIC_RULES_MERCHANT_REIMBURSABLE: 'Dynamic_Rules_Merchant_Reimbursable',
DYNAMIC_RULES_MERCHANT_BILLABLE: 'Dynamic_Rules_Merchant_Billable',
DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES: 'Dynamic_Rules_Merchant_Preview_Matches',
RULES_SPEND_MERCHANTS: 'Rules_Spend_Merchants',
RULES_SPEND_MERCHANT_EDIT: 'Rules_Spend_Merchant_Edit',
RULES_AGENT_NEW: 'Rules_Agent_New',
Expand Down
212 changes: 212 additions & 0 deletions src/components/MerchantRuleSuggestionBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion';
import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import {clearMerchantRuleSuggestionFields, dismissMerchantRuleSuggestion, markMerchantRuleSuggestionSeen, retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion';
import {setDraftMerchantRule} from '@libs/actions/User';
import {getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
import TransitionTracker from '@libs/Navigation/TransitionTracker';

import variables from '@styles/variables';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {DYNAMIC_ROUTES} from '@src/ROUTES';

import type {StyleProp, ViewStyle} from 'react-native';

import {useIsFocused, useRoute} from '@react-navigation/native';
import React, {useEffect} from 'react';
import {View} from 'react-native';
import Animated, {Easing, useAnimatedStyle, useSharedValue, withSpring, withTiming} from 'react-native-reanimated';

import Banner from './Banner';
import Icon from './Icon';
import Text from './Text';
import TextLink from './TextLink';
import {useWideRHPState} from './WideRHPContextProvider';

/** How far the callout travels on its way in, far enough for the spring to read as movement rather than a nudge */
const CALLOUT_SLIDE_DISTANCE = 40;

// The spring carries the movement, so the fade only has to stop the callout appearing before it has travelled. Linear
// because that is what reanimated's own fade keyframes run at on web.
const CALLOUT_FADE_CONFIG = {duration: CONST.ANIMATED_TRANSITION, easing: Easing.linear};

type MerchantRuleSuggestionBannerProps = {
/** The report hosting the expense detail view: a transaction thread, its expense report, or the chat it lives in */
reportID: string | undefined;

/** The workspace the expense belongs to */
policyID: string | undefined;

/** Styles for the banner container */
containerStyles?: StyleProp<ViewStyle>;

/** When set, floats the callout in a wrapper carrying these styles instead of laying it out inline */
overlayStyles?: StyleProp<ViewStyle>;

/**
* Whether this is the mount above the composer. Sets the edge the callout slides in from, and which layouts it
* serves: the composer takes the wide ones, the report list the narrow ones.
*/
isAnchoredToBottom?: boolean;
};

type MerchantRuleSuggestionBannerContentProps = MerchantRuleSuggestionBannerProps & {
/** Whether the composer is expanded, which leaves no room for the callout */
isComposerFullSize: boolean;
};

function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyles, overlayStyles, isAnchoredToBottom, isComposerFullSize}: MerchantRuleSuggestionBannerContentProps) {
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']);
const {suggestion, fields, editedTagLevels, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID);
const isShowing = !!suggestion && !!policyID;

// Sprung in from the edge it is pinned to, the way FloatingMessageCounter moves its pill. A reanimated entering
// animation looked right when the callout arrived with a fresh page, but a toggle edit leaves the user on the page
// it appears in, and there the animation fought the surrounding layout and flickered.
const slideOffset = isAnchoredToBottom ? CALLOUT_SLIDE_DISTANCE : -CALLOUT_SLIDE_DISTANCE;
const translateY = useSharedValue(slideOffset);
const opacity = useSharedValue(0);

// Held until any navigation transition finishes. Coming back from a field's edit page mounts the callout while the
// page is still sliding, and the slide would be over before the page arrived. Nothing is transitioning after a
// toggle edit, where the callout appears on the page the user is already on, so there it starts at once.
useEffect(() => {
const handle = TransitionTracker.runAfterTransitions({
callback: () => {
translateY.set(withSpring(0));
opacity.set(withTiming(1, CALLOUT_FADE_CONFIG));
},
});
return handle.cancel;
}, [translateY, opacity]);

const slideStyle = useAnimatedStyle(() => ({
opacity: opacity.get(),
transform: [{translateY: translateY.get()}],
}));

// Recorded so leaving this report can retire the offer. The report cannot work this out for itself, because the
// one showing an expense is not always the one the edit was recorded against.
//
// A full-size composer hides the callout, and composer size is remembered per report. Marking it seen there would
// retire an offer the user never had a chance to read.
useEffect(() => {
if (!isShowing || !reportID || isComposerFullSize || suggestion?.seenInReportID === reportID) {
return;
}
markMerchantRuleSuggestionSeen(reportID);
}, [isShowing, reportID, isComposerFullSize, suggestion?.seenInReportID]);

if (!suggestion || !policyID) {
return null;
}

const dismiss = () => dismissMerchantRuleSuggestion(suggestion);

const createRule = () => {
const draft = getMerchantRuleDraftFromTransaction(transaction, fields, policy, editedTagLevels);
if (!draft) {
return;
}
// Opened as a suffix on the expense's own path, so the expense stays under the modal and the flow returns
// here rather than to the workspace Rules page.
setDraftMerchantRule(draft);
// The offer was taken, so it must not still be asking on the way back, and the recording ends here. Editing
// the expense again starts fresh instead of repeating fields already in this rule.
clearMerchantRuleSuggestionFields(suggestion.transactionID);
retireMerchantRuleSuggestion();
Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.getRoute(policyID)));
};

// The composer check sits inside rather than around this wrapper on purpose. Expanding the composer should take
// the callout away at once, and emptying the wrapper does that without disturbing the slide.
return (
<Animated.View style={[overlayStyles, slideStyle]}>
{!isComposerFullSize && (
<Banner
containerStyles={[styles.merchantRuleCalloutContainer, styles.p4, containerStyles]}
shouldShowCloseButton
onClose={dismiss}
content={
<>
<View style={styles.mr3}>
<Icon
src={icons.Lightbulb}
fill={theme.tooltipHighlightText}
width={variables.iconSizeNormal}
height={variables.iconSizeNormal}
/>
</View>
<Text style={[styles.flex1, styles.merchantRuleCalloutText, styles.mr3]}>
<TextLink
style={styles.merchantRuleCalloutAction}
onPress={createRule}
>
{translate('workspace.rules.merchantRules.createRuleFromExpenseAction')}
</TextLink>
{` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`}
</Text>
</>
}
/>
)}
</Animated.View>
);
}

/**
* Offers the chance to turn an expense edit into a merchant rule, right on the expense that was just edited. Renders
* nothing unless there is a qualifying edit to act on.
*/
function MerchantRuleSuggestionBanner({reportID, policyID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) {
const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION);
// A full-size composer leaves no room for the callout, and on narrow layouts it would sit over the button that
// collapses the composer again. Handled inside the content rather than here, so the callout goes at once instead
// of animating out over the composer as it grows.
const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`);
const {shouldUseNarrowLayout} = useResponsiveLayout();
// A wide RHP reports a narrow layout but looks wide, so it belongs to the composer mount.
const route = useRoute();
const {wideRHPRouteKeys} = useWideRHPState();
const isInWideRHP = !!route?.key && wideRHPRouteKeys.includes(route.key);

// Both mounts always render; this picks the one for the layout. Deciding here keeps the two halves from drifting,
// and keeps the navigation-state subscription out of the report actions list, which re-renders far more often.
const isMountForThisLayout = isAnchoredToBottom ? !shouldUseNarrowLayout || isInWideRHP : shouldUseNarrowLayout && !isInWideRHP;

// The same report can be mounted twice at once, in the central pane and in the RHP over it. Each tree measures its
// own layout, so the layout check alone elects a mount in both and the callout appears twice, each fading in on its
// own beat. Only the view the user is actually on should offer it.
const isFocused = useIsFocused();

// Nothing is stored for most of a session, so skip the inner component and its Onyx subscriptions until there is
// an edit to offer.
if (!isFocused || !isMountForThisLayout || !isMerchantRuleSuggestionLive(storedSuggestion)) {
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This gate re-implements the isRetired / dismissed / transactionID checks from useMerchantRulesSuggestion here and subscribes to the same Onyx key a second time.
One of the two should own the decision.

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.

isMerchantRuleSuggestionLive owns the predicate now, so the inline checks are gone. The duplicate subscription is deliberate, it’s a cheap gate on a RAM-only key that keeps the inner hook’s subscriptions unmounted until there’s an edit.


return (
<MerchantRuleSuggestionBannerContent
reportID={reportID}
policyID={policyID}
containerStyles={containerStyles}
overlayStyles={overlayStyles}
isAnchoredToBottom={isAnchoredToBottom}
isComposerFullSize={isComposerFullSize}
/>
);
}

export default MerchantRuleSuggestionBanner;
1 change: 1 addition & 0 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,7 @@ function MoneyRequestView({
parentReport,
iouReportOwnerLogin,
tag: updatedTag,
tagListIndex,
policy,
policyTagList,
policyRecentlyUsedTags: undefined,
Expand Down
Loading
Loading