Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e207d6f
Reapply "Confirm new expenses with a “View” toast instead of scrollin…
mhawryluk Sep 1, 2026
fd766b6
Fix conflict resolution
mhawryluk Sep 1, 2026
b90f5bf
fix: Expense - Report on central pane changes to not here page after …
mhawryluk Sep 1, 2026
f8c10c3
fix: Expense added growl is not shown after creating per diem expense…
mhawryluk Sep 1, 2026
aaed79a
fix: Android - Expense - Report is opened a second time when tapping …
mhawryluk Sep 1, 2026
1bb1dc8
fix: Expense - Expense added growl is hidden behind Concierge side panel
mhawryluk Sep 1, 2026
a78dca1
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Sep 2, 2026
2dc4d7b
Fix after merge
mhawryluk Sep 2, 2026
92431c5
fix: Android - Workspace chat opens briefly after opening expense fro…
mhawryluk Sep 2, 2026
208954f
fix: Report - WS avatar disappears when moving expense opened via gro…
mhawryluk Sep 2, 2026
3daf9a3
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Sep 3, 2026
2eaed47
Add more tests for expense added growls
mhawryluk Sep 3, 2026
edaf067
Fix eslint error
mhawryluk Sep 3, 2026
78022dc
Use getSearchRowBackgroundStyle in ChatListItem and TaskListItem
mhawryluk Sep 4, 2026
1519ca3
Use getFocusedReportId for checking current report in ExpenseAddedGrowl
mhawryluk Sep 9, 2026
1358757
Move navigateToCreatedExpense to a separate file
mhawryluk Sep 9, 2026
63cde1b
Drop broder-radius:0 in TransactionListItemNarrow wrapper style
mhawryluk Sep 9, 2026
e290ed3
Make EXPENSE_ADDED_GROWL_TRANSACTION_IDS onyx key ram only
mhawryluk Sep 9, 2026
00be077
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Sep 9, 2026
8089abf
Fix after merge
mhawryluk Sep 9, 2026
c01860c
Fix test
mhawryluk Sep 9, 2026
b72298d
Fix formatting
mhawryluk Sep 9, 2026
a4474d7
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Sep 10, 2026
576165c
Clear pending growl in dismissModalAndOpenReportInInboxTab when navig…
mhawryluk Sep 10, 2026
df79307
Ensure growl for split and duplicate shows up in Spend only
mhawryluk Sep 10, 2026
2c87e05
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Sep 10, 2026
560dd21
Fix after merge
mhawryluk Sep 10, 2026
b6639c1
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Sep 16, 2026
7a33bee
Replace ONYXKEYS.PERSONAL_DETAILS_LIST usage in ExpenseAddedGrowlContent
mhawryluk Sep 16, 2026
0d2fa31
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Sep 17, 2026
564a15e
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Sep 18, 2026
b6f1eee
Remove unused getSearchTableHighlightBorderRadius
mhawryluk Sep 21, 2026
d0a86cc
Add comment explaining dropped backTo in navigateToCreatedExpense
mhawryluk Sep 21, 2026
20b6ec7
Expand the comment for signalExpenseAddedGrowl
mhawryluk Sep 21, 2026
93711ee
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Sep 22, 2026
46220ca
Fix after merge
mhawryluk Sep 22, 2026
c0fc03a
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Sep 25, 2026
84e8c0f
Fix after merge
mhawryluk Sep 25, 2026
99ee7a1
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk Oct 1, 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: 0 additions & 1 deletion config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,6 @@
"../../src/hooks/useReviewDuplicatesNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useReviewDuplicatesNavigation.tsx" "react-hooks/set-state-in-effect" 1
"../../src/hooks/useSearchBulkActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 9
"../../src/hooks/useSearchHighlightAndScroll.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../src/hooks/useSidebarOrderedReports.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/refs" 5
"../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/set-state-in-effect" 1
Expand Down
4 changes: 4 additions & 0 deletions src/GlobalModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import React, {startTransition, useEffect, useState} from 'react';

import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider';
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
import ExpenseAddedGrowl from './components/ExpenseAddedGrowl';
import GrowlNotification from './components/GrowlNotification';
import LazyModalSlot from './components/LazyModalSlot';
import useIsAuthenticated from './hooks/useIsAuthenticated';
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
import {growlRef} from './libs/Growl';
import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu';
Expand All @@ -22,6 +24,7 @@ const IDLE_CALLBACK_TIMEOUT_MS = 2000;
function GlobalModals() {
const [shouldRenderContextMenu, setShouldRenderContextMenu] = useState(false);
const [shouldRenderDeferredModals, setShouldRenderDeferredModals] = useState(false);
const isAuthenticated = useIsAuthenticated();

// Defer loading the context menu and rare-condition modals until after startup to avoid
// pulling in their dependencies (ContextMenuActions, ReportUtils, ModifiedExpenseMessage,
Expand All @@ -48,6 +51,7 @@ function GlobalModals() {
return (
<>
<GrowlNotification ref={growlRef} />
{isAuthenticated && <ExpenseAddedGrowl />}
Comment thread
mhawryluk marked this conversation as resolved.
Resolved
<DelegateNoAccessModalProvider>
{shouldRenderContextMenu && (
<LazyModalSlot>
Expand Down
7 changes: 4 additions & 3 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {Attendee, DistanceExpenseType, Participant} from './types/onyx/IOU'
import type Onboarding from './types/onyx/Onboarding';
import type {AnyOnyxUpdate} from './types/onyx/Request';
import type {SavedCSVColumnLayoutList} from './types/onyx/SavedCSVColumnLayout';
import type {SearchDataTypes} from './types/onyx/SearchResults';
import type AssertTypesEqual from './types/utils/AssertTypesEqual';
import type DeepValueOf from './types/utils/DeepValueOf';

Expand Down Expand Up @@ -887,8 +888,8 @@ const ONYXKEYS = {
/** Whether the user has denied the contact import permission prompt */
HAS_DENIED_CONTACT_IMPORT_PROMPT: 'hasDeniedContactImportPrompt',

/** The transaction IDs to be highlighted when opening the Expenses search route page */
TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute',
/** Maps each newly-added transaction ID to its search data type, flagging it for the "Expense added" growl */
RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS: 'expenseAddedGrowlTransactionIDs',

/** The report ID to be highlighted when returning to the workspace rooms page */
ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage',
Expand Down Expand Up @@ -1907,7 +1908,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[];
[ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
[ONYXKEYS.PERSONAL_POLICY_ID]: string;
[ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record<string, Record<string, boolean>>;
[ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record<string, SearchDataTypes>;
[ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null;
[ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined;
};
Expand Down
168 changes: 168 additions & 0 deletions src/components/ExpenseAddedGrowl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails';
import useReportTransactions from '@hooks/useReportTransactions';

import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report';
import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction';
import Log from '@libs/Log';
import navigateToCreatedExpense from '@libs/Navigation/helpers/navigateToCreatedExpense';
import Navigation from '@libs/Navigation/Navigation';
import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils';
import {findSelfDMReportID, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';

import type {Dispatch, SetStateAction} from 'react';

import {useEffect, useRef, useState} from 'react';

import GrowlNotificationContent from './GrowlNotification/GrowlNotificationContent';

type ActiveGrowl = {
/** ID of the transaction the growl was raised for */
transactionID: string;

/** Search data type the growl belongs to (expense, invoice, etc.) */
dataType: SearchDataTypes;

/** Identifies each growl instance so a stale slide-out dismissal can't clear a newer growl */
nonce: number;
};

type ExpenseAddedGrowlContentProps = {
/** Transaction to read for the growl: the active growl's transaction, or the latest pending signal ID */
transactionID: string;

/** Pending expense-added notifications, keyed by transaction ID with its search data type as the value */
signal: Record<string, SearchDataTypes> | undefined;

/** Currently displayed growl */
active: ActiveGrowl | null;

/** Setter for the active growl */
setActive: Dispatch<SetStateAction<ActiveGrowl | null>>;
};

/** Watches the "an expense was just added" Onyx signal and shows an "Expense added" growl with a "View" action. */
function ExpenseAddedGrowl() {
const [active, setActive] = useState<ActiveGrowl | null>(null);
const [signal] = useOnyx(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
const transactionID = active?.transactionID ?? Object.keys(signal ?? {}).at(-1);

if (!transactionID) {
return null;
}

return (
<ExpenseAddedGrowlContent
transactionID={transactionID}
signal={signal}
active={active}
setActive={setActive}
/>
);
}

function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: ExpenseAddedGrowlContentProps) {
const nonceRef = useRef(0);

const {translate} = useLocalize();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`);
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
const reportID = transaction?.reportID;
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);

// A tracked expense's IOU action lives on the self-DM report, not on transaction.reportID, so read actions
// from the report that actually hosts it so "View" can resolve the real transaction thread.
const isUnreportedExpense = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
const selfDMReportID = isUnreportedExpense ? findSelfDMReportID() : undefined;
const hostReportID = isUnreportedExpense ? selfDMReportID : reportID;
const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`);
const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID);
const [personalDetails] = usePersonalDetailsByIDs([currentUserPersonalDetails?.accountID, iouAction?.actorAccountID]);

// A tracked expense lives in a chat, not an expense report. Leaving iouReportID undefined tells
// navigateToCreatedExpense to open the transaction thread directly instead of an expense report RHP.
const iouReport = isMoneyRequestReport(report) || isInvoiceReport(report) ? report : undefined;
const iouReportID = iouReport?.reportID;
const reportTransactions = useReportTransactions(iouReportID);

useEffect(() => {
if (active) {
return;
}
const pendingTransactionIDs = Object.keys(signal ?? {});

// The last key is the last-created transaction: IDs are large (rand64), so they exceed the array-index
// range and JS preserves insertion order rather than sorting them numerically ascending.
const latestTransactionID = pendingTransactionIDs.at(-1);
const dataType = latestTransactionID ? signal?.[latestTransactionID] : undefined;
if (!latestTransactionID || !dataType || !transaction) {
return;
}

// Clear every pending ID but surface only the latest: rapid creations that pile up before this runs
// collapse into a single growl for the newest expense.
mergeExpenseAddedGrowlTransactionIDs(Object.fromEntries(pendingTransactionIDs.map((id) => [id, null])));

// Suppress the growl when the user is already viewing the expense's money-request report
if (Navigation.getFocusedReportId() === transaction.reportID) {
return;
}
nonceRef.current += 1;
setActive({transactionID: latestTransactionID, dataType, nonce: nonceRef.current});
}, [signal, active, transaction, setActive]);

if (!active) {
return null;
}

const isInvoice = active.dataType === CONST.SEARCH.DATA_TYPES.INVOICE;

// Build the thread on press rather than when the growl shows, so it is only created if the user taps
// "View" and is built against the freshest Onyx data.
const navigateToExpense = () => {
let threadReportID = transaction?.transactionThreadReportID ?? iouAction?.childReportID;
if (threadReportID) {
setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID);
} else {
const optimisticThread = createTransactionThreadReport({
introSelected,
conciergeChat,
currentUserLogin: currentUserPersonalDetails?.login ?? '',
currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID,
iouReport,
iouReportAction: iouAction,
transaction,
personalDetails,
});
threadReportID = optimisticThread?.reportID;
}
if (!threadReportID) {
Log.warn('[ExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.');
return;
}
navigateToCreatedExpense({threadReportID, transactionID: active.transactionID, iouReportID, reportTransactions});
};

return (
<GrowlNotificationContent
key={active.nonce}
nonce={active.nonce}
type={CONST.GROWL.SUCCESS}
duration={CONST.GROWL.DURATION_WITH_ACTION}
bodyText={translate(isInvoice ? 'iou.invoiceSent' : 'iou.expenseAdded')}
action={{label: translate('common.view'), onPress: navigateToExpense}}
onDismissed={(dismissedNonce) => setActive((prev) => (prev?.nonce === dismissedNonce ? null : prev))}
/>
);
}

export default ExpenseAddedGrowl;
91 changes: 47 additions & 44 deletions src/components/GrowlNotification/GrowlNotificationContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {useSharedValue, withSpring} from 'react-native-reanimated';
import {scheduleOnRN} from 'react-native-worklets';

import GrowlNotificationContainer from './GrowlNotificationContainer';
import GrowlNotificationPortal from './GrowlNotificationPortal';

const INACTIVE_OFFSET = CONST.GROWL.OFFSCREEN_OFFSET;
const INACTIVE_POSITION_Y = -INACTIVE_OFFSET;
Expand Down Expand Up @@ -130,51 +131,53 @@ function GrowlNotificationContent({bodyText, type, duration, action, nonce, onDi
.onStart(triggerDismiss);

return (
<View style={styles.growlNotificationWrapper}>
<GrowlNotificationContainer
progress={progress}
inactiveY={inactiveY}
useBottomPosition={useBottomPosition}
>
<GestureDetector gesture={flingGesture}>
<View style={[styles.growlNotificationBox, action ? styles.growlNotificationBoxWithAction : styles.growlNotificationBoxWithoutAction]}>
{/* The dismiss target covers only the icon + text; the action Button is a sibling
(not nested inside a pressable) so it stays independently focusable for screen
readers and its press can't bubble into a dismiss. */}
<PressableWithoutFeedback
accessibilityLabel={bodyText}
sentryLabel="GrowlNotification-Dismiss"
onPress={triggerDismiss}
style={[styles.flex1, styles.flexRow, styles.alignItemsCenter, styles.gap3]}
>
<Icon
src={types[type].icon}
fill={types[type].iconColor}
/>
<Text style={styles.growlNotificationText}>{bodyText}</Text>
</PressableWithoutFeedback>
{!!action && (
<Button
accessibilityLabel={action.label}
sentryLabel="GrowlNotification-Action"
onPress={() => {
if (isActionPressedRef.current) {
return;
}
isActionPressedRef.current = true;
triggerDismiss();
action.onPress();
}}
innerStyles={styles.bgTransparent}
hoverStyles={styles.growlNotificationActionHovered}
<GrowlNotificationPortal>
<View style={styles.growlNotificationWrapper}>
<GrowlNotificationContainer
progress={progress}
inactiveY={inactiveY}
useBottomPosition={useBottomPosition}
>
<GestureDetector gesture={flingGesture}>
<View style={[styles.growlNotificationBox, action ? styles.growlNotificationBoxWithAction : styles.growlNotificationBoxWithoutAction]}>
{/* The dismiss target covers only the icon + text; the action Button is a sibling
(not nested inside a pressable) so it stays independently focusable for screen
readers and its press can't bubble into a dismiss. */}
<PressableWithoutFeedback
accessibilityLabel={bodyText}
sentryLabel="GrowlNotification-Dismiss"
onPress={triggerDismiss}
style={[styles.flex1, styles.flexRow, styles.alignItemsCenter, styles.gap3]}
>
<Button.Text style={styles.growlNotificationActionText}>{action.label}</Button.Text>
</Button>
)}
</View>
</GestureDetector>
</GrowlNotificationContainer>
</View>
<Icon
src={types[type].icon}
fill={types[type].iconColor}
/>
<Text style={styles.growlNotificationText}>{bodyText}</Text>
</PressableWithoutFeedback>
{!!action && (
<Button
accessibilityLabel={action.label}
sentryLabel="GrowlNotification-Action"
onPress={() => {
if (isActionPressedRef.current) {
return;
}
isActionPressedRef.current = true;
triggerDismiss();
action.onPress();
}}
innerStyles={styles.bgTransparent}
hoverStyles={styles.growlNotificationActionHovered}
>
<Button.Text style={styles.growlNotificationActionText}>{action.label}</Button.Text>
</Button>
)}
</View>
</GestureDetector>
</GrowlNotificationContainer>
</View>
</GrowlNotificationPortal>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type React from 'react';

/** Native has no shared DOM stacking context to escape, so growls render where they are mounted. */
function GrowlNotificationPortal({children}: React.PropsWithChildren) {
return children;
}

export default GrowlNotificationPortal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type React from 'react';

import {createPortal} from 'react-dom';

/**
* Renders growls at the document root. Inside the app's own stacking context they are covered by overlays that
* portal to the body themselves, such as the Side Panel, no matter how high their z-index is.
*/
function GrowlNotificationPortal({children}: React.PropsWithChildren) {
return createPortal(children, document.body);
}

export default GrowlNotificationPortal;
6 changes: 1 addition & 5 deletions src/components/Search/ChatSearchView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import CONST from '@src/CONST';

import type {NativeSyntheticEvent} from 'react-native';

import React, {useImperativeHandle} from 'react';
import React from 'react';

import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps} from './searchViewProps';
Expand Down Expand Up @@ -51,7 +51,6 @@ function ChatSearchView({
onViewableItemsChanged,
contentContainerStyle,
containerStyle,
ref,
}: ChatSearchViewProps) {
const {type} = queryJSON;

Expand All @@ -67,9 +66,6 @@ function ChatSearchView({
const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;

// Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);

const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent<ExtendedTargetedEvent>) => void) => (
// Chat rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
<AnimatedExitRow
Expand Down
Loading
Loading