Skip to content
Closed
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
3778ac8
inital commits
borys3kk May 15, 2026
5d256cb
merge main
borys3kk May 19, 2026
034aaab
creating expense and navigating to it works reliably
borys3kk May 20, 2026
8dae063
fix styles in Growl
borys3kk May 20, 2026
6192f2f
show growl instantly when adding outside of search/inbox
borys3kk May 20, 2026
85082c4
implement navigation first then show growl
borys3kk May 20, 2026
3c7077f
merge main
borys3kk May 20, 2026
b1a7d78
fix errors
borys3kk May 20, 2026
d16069f
fix failing tests, prettier, eslint, typecheck
borys3kk May 20, 2026
7ab84dc
reposition + unify navigation path for "View" action
borys3kk May 22, 2026
765bd05
fix showing growl on native platforms
borys3kk May 22, 2026
8526f56
fix positiong after resize from narrow to wide and from wide to narrow
borys3kk May 25, 2026
0f563bc
fix growl styles in native, minimize re-renders
borys3kk May 26, 2026
8e426f2
Merge branch 'main' of github.com:Expensify/App into borys3kk-growl-poc
borys3kk May 26, 2026
2d46511
fix growl timeout, fix react compiler compliance
borys3kk May 26, 2026
de2fb6d
remove searchHighlightAndScroll
borys3kk May 26, 2026
ba654a8
fix lint with original justification
borys3kk May 27, 2026
7d66275
merge main
borys3kk May 27, 2026
905fe1d
fix failing tests
borys3kk May 27, 2026
4f56a9c
fix growl not showing on spend page
borys3kk May 29, 2026
90c15d9
show growls on every tab, adjust styling
borys3kk Jun 3, 2026
c719901
merge main
borys3kk Jun 11, 2026
4e894c0
update growl to super wide rhp of report and then rhp of the expense
borys3kk Jun 12, 2026
2c54256
fix eslint errors
borys3kk Jun 12, 2026
63b111d
merge main
borys3kk Jun 12, 2026
0f1ca9d
remove unused eslint disable directive
borys3kk Jun 12, 2026
5ae4c8b
fix double growl showing
borys3kk Jun 12, 2026
b09881c
merge main
borys3kk Jun 12, 2026
63804a1
fix failing submission tests with growls
borys3kk Jun 12, 2026
74302af
merge main
borys3kk Jun 15, 2026
84e2a4e
fix lint
borys3kk Jun 15, 2026
1af768f
fix preserve dismiss-first navigation ownership
borys3kk Jun 15, 2026
56cf623
Show tracked-expense growl immediately when the thread ID is known
borys3kk Jun 15, 2026
5c84b30
add translations for each expense type
borys3kk Jun 15, 2026
13685ed
merge main
borys3kk Jun 15, 2026
13a286e
fix after merge errors
borys3kk Jun 15, 2026
5977ddb
merge main
borys3kk Jun 16, 2026
b27f347
merge main
borys3kk Jun 16, 2026
4140a3a
Merge branch 'main' of github.com:Expensify/App into borys3kk-growl-poc
borys3kk Jun 16, 2026
337cd0f
remove all previous highlight logic
borys3kk Jun 16, 2026
245045e
fix double rhp opened in empty report
borys3kk Jun 17, 2026
fbba6d0
Merge branch 'main' into borys3kk-growl-poc
borys3kk Jun 17, 2026
fe3e189
merge main
borys3kk Jun 18, 2026
74b3194
fix eslint
borys3kk Jun 18, 2026
a349e62
merge main
borys3kk Jun 19, 2026
cb37ad3
batch submissions to one navigation
borys3kk Jun 19, 2026
7cb6def
remove redundant useMemo
borys3kk Jun 19, 2026
fcfc4cd
move growl duration to const
borys3kk Jun 19, 2026
828b875
preserve dismiss first navigation ownership pt2
borys3kk Jun 19, 2026
c3fb533
fix waiting for timeout in no iouReportID
borys3kk Jun 19, 2026
cae8608
fix for allTransactions reset
borys3kk Jun 19, 2026
d6a8d12
fix higlighting in report table
borys3kk Jun 24, 2026
0753105
narrow growl types
borys3kk Jun 25, 2026
3413367
merge main
borys3kk Jun 25, 2026
95e0afc
fix root lookup
borys3kk Jun 26, 2026
50665ca
merge main
borys3kk Jun 26, 2026
1e63b7a
fix failing tests
borys3kk Jun 29, 2026
02df4a6
Merge branch 'main' of github.com:Expensify/App into borys3kk-growl-poc
borys3kk Jun 29, 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
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3703,8 +3703,11 @@ const CONST = {
SUCCESS: 'success',
ERROR: 'error',
WARNING: 'warning',
LOADING: 'loading',
DURATION: 2000,
DURATION_LONG: 3500,
// Longer duration for growls with an actionable button (e.g. "View"), giving the user enough time to tap it.
DURATION_WITH_ACTION: 6000,
},

LOCALES,
Expand Down
4 changes: 0 additions & 4 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,9 +761,6 @@ 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',

/** The report ID to be highlighted when returning to the workspace rooms page */
ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage',

Expand Down Expand Up @@ -1669,7 +1666,6 @@ 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.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null;
[ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import type GrowlNotificationContainerProps from './types';

function GrowlNotificationContainer({children, translateY}: GrowlNotificationContainerProps) {
function GrowlNotificationContainer({children, progress, inactiveY}: GrowlNotificationContainerProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const insets = useSafeAreaInsets();
const animatedStyles = useAnimatedStyle(() => styles.growlNotificationTranslateY(translateY));
const animatedStyles = useAnimatedStyle(() => ({transform: [{translateY: inactiveY * (1 - progress.get())}]}), [inactiveY]);

return <Animated.View style={[StyleUtils.getPlatformSafeAreaPadding(insets), styles.growlNotificationContainer, animatedStyles]}>{children}</Animated.View>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import type GrowlNotificationContainerProps from './types';

function GrowlNotificationContainer({children, translateY}: GrowlNotificationContainerProps) {
function GrowlNotificationContainer({children, progress, inactiveY, useBottomPosition}: GrowlNotificationContainerProps) {
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const animatedStyles = useAnimatedStyle(() => styles.growlNotificationTranslateY(translateY));
const animatedStyles = useAnimatedStyle(() => ({transform: [{translateY: inactiveY * (1 - progress.get())}]}), [inactiveY]);

if (useBottomPosition) {
return <Animated.View style={[styles.growlNotificationContainerBottomRight, animatedStyles]}>{children}</Animated.View>;
}

return (
<Animated.View style={[styles.growlNotificationContainer, styles.growlNotificationDesktopContainer, animatedStyles, shouldUseNarrowLayout && styles.mwn]}>{children}</Animated.View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import type {SharedValue} from 'react-native-reanimated';
import type ChildrenProps from '@src/types/utils/ChildrenProps';

type GrowlNotificationContainerProps = ChildrenProps & {
translateY: SharedValue<number>;
/** Normalized visibility (0 = fully offscreen, 1 = fully visible). Translated to a pixel offset at render time. */
progress: SharedValue<number>;

/** Pixel offset that corresponds to the current "offscreen" position for the active anchor. */
inactiveY: number;

/** When true, position the growl at the bottom-right (wide screens with an action button). */
useBottomPosition?: boolean;
};

export default GrowlNotificationContainerProps;
215 changes: 215 additions & 0 deletions src/components/GrowlNotification/GrowlNotificationContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {Directions, Gesture, GestureDetector} from 'react-native-gesture-handler';
import {useSharedValue, withSpring} from 'react-native-reanimated';
import type {SvgProps} from 'react-native-svg';
import ActivityIndicator from '@components/ActivityIndicator';
import Button from '@components/Button';
import Icon from '@components/Icon';
import * as Pressables from '@components/Pressable';
import Text from '@components/Text';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import type {GrowlAction, GrowlType} from '@libs/Growl';
import CONST from '@src/CONST';
import type IconAsset from '@src/types/utils/IconAsset';
import GrowlNotificationContainer from './GrowlNotificationContainer';

const INACTIVE_OFFSET = 255;
const INACTIVE_POSITION_Y = -INACTIVE_OFFSET;
// Approximate time for the slide spring to settle. Used to delay onDismissed
// until the slide-out animation has visibly finished.
const SLIDE_DURATION_MS = 400;

const PressableWithoutFeedback = Pressables.PressableWithoutFeedback;

type GrowlNotificationContentProps = {
bodyText: string;
type: GrowlType;
duration: number;
action?: GrowlAction;
onDismissed: () => void;
};

function GrowlNotificationContent({bodyText, type, duration, action, onDismissed}: GrowlNotificationContentProps) {
// Normalized: 0 = fully offscreen for the current anchor, 1 = fully visible. The container
// multiplies this against the live `inactiveY`, so the offscreen position stays correct
// even when the responsive layout flips after the growl is dismissed.
const progress = useSharedValue(0);
// Guards against double-firing the action's onPress while the slide-out animation
// is still on screen. Reset whenever new growl content arrives.
const isActionPressedRef = useRef(false);
// Holds the post-fling-out setTimeout so the content-change effect can cancel a
// pending unmount when a new growl arrives mid-dismissal. Only touched inside effects.
const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Counter that triggers the dismiss-after-slide-out effect. Kept in state (rather than
// a ref) so triggerDismiss doesn't transitively read a ref — that would make the React
// Compiler reject the gesture's useMemo as "ref access during render".
const [dismissNonce, setDismissNonce] = useState(0);

const theme = useTheme();
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const icons = useMemoizedLazyExpensifyIcons(['Exclamation', 'Checkmark']);

// Derived live so that resizing the window flips position + slide direction during display.
const useBottomPosition = !!action && !shouldUseNarrowLayout;
const inactiveY = useBottomPosition ? INACTIVE_OFFSET : INACTIVE_POSITION_Y;

// Every non-loading growl variant must have an icon mapping. Loading is rendered separately
// (ActivityIndicator), so it's excluded here — keeping this map exhaustive over the rest.
type GrowlIconTypes = Record<
Exclude<GrowlType, typeof CONST.GROWL.LOADING>,
{
icon: React.FC<SvgProps> | IconAsset;
iconColor: string;
}
>;

const types: GrowlIconTypes = {
[CONST.GROWL.SUCCESS]: {
icon: icons.Checkmark,
iconColor: theme.success,
},
[CONST.GROWL.ERROR]: {
icon: icons.Exclamation,
iconColor: theme.danger,
},
[CONST.GROWL.WARNING]: {
icon: icons.Exclamation,
iconColor: theme.warning,
},
};

/**
* Animate growl notification. `targetProgress` is 0 for offscreen, 1 for visible.
*/
const fling = useCallback(
(targetProgress = 0) => {
'worklet';

progress.set(
withSpring(targetProgress, {
overshootClamping: false,
}),
);
},
[progress],
);

/**
* Slide the growl off-screen and schedule its unmount via the dismissNonce effect.
*/
const triggerDismiss = useCallback(() => {
fling(0);
setDismissNonce((n) => n + 1);
}, [fling]);

// Schedule unmount once a dismiss has been requested. Effect cleanup cancels the timer
// when this effect re-runs or the component unmounts.
useEffect(() => {
if (dismissNonce === 0) {
return;
}
const timeoutId = setTimeout(onDismissed, SLIDE_DURATION_MS);
dismissTimeoutRef.current = timeoutId;
return () => {
clearTimeout(timeoutId);
dismissTimeoutRef.current = null;
};
}, [dismissNonce, onDismissed]);

useEffect(() => {
isActionPressedRef.current = false;
// New content arrived — cancel any in-flight unmount-after-slide-out from the previous
// growl. (We can't reset dismissNonce here because setState in an effect body is
// disallowed by lint; clearing the timer directly via the ref achieves the same result.)
if (dismissTimeoutRef.current) {
clearTimeout(dismissTimeoutRef.current);
dismissTimeoutRef.current = null;
}

// Snap to fully offscreen before sliding in so the slide-in direction matches the
// current placement (from above for top, from below for bottom).
progress.set(0);
fling(1);

if (duration <= 0) {
// Indefinite (loading) growl - slide in but don't auto-dismiss.
return;
}

const autoDismissTimeoutId = setTimeout(triggerDismiss, duration);
return () => clearTimeout(autoDismissTimeoutId);
}, [bodyText, type, action, duration, fling, progress, triggerDismiss]);

// GestureDetector by default runs callbacks on UI thread using Reanimated. In this
// case we want to trigger an RN's Animated animation, which needs to be done on JS thread.
// Wrapped in useMemo so the React Compiler doesn't flag the gesture builder's internal
// mutable state as ref access during render.
const flingGesture = useMemo(
() =>
Gesture.Fling()
.direction(useBottomPosition ? Directions.DOWN : Directions.UP)
.runOnJS(true)
.onStart(() => {
triggerDismiss();
}),
[useBottomPosition, triggerDismiss],
);

return (
<View style={[styles.growlNotificationWrapper]}>
<GrowlNotificationContainer
progress={progress}
inactiveY={inactiveY}
useBottomPosition={useBottomPosition}
>
<PressableWithoutFeedback
accessibilityLabel={bodyText}
sentryLabel="GrowlNotification-Dismiss"
onPress={() => triggerDismiss()}
>
<GestureDetector gesture={flingGesture}>
<View style={styles.growlNotificationBox}>
{type === CONST.GROWL.LOADING ? (
<ActivityIndicator reasonAttributes={{context: 'GrowlNotification.Loading'}} />
) : (
<Icon
src={types[type].icon}
fill={types[type].iconColor}
/>
)}
Comment thread
borys3kk marked this conversation as resolved.
<Text style={[styles.growlNotificationText, action ? styles.growlNotificationTextWithAction : styles.growlNotificationTextWithoutAction]}>{bodyText}</Text>
{!!action && (
<Button
medium
text={action.label}
accessibilityLabel={action.label}
sentryLabel="GrowlNotification-Action"
onPress={() => {
if (isActionPressedRef.current) {
return;
}
isActionPressedRef.current = true;
triggerDismiss();
action.onPress();
}}
innerStyles={styles.bgTransparent}
textStyles={styles.growlNotificationActionText}
shouldUseDefaultHover={false}
hoverStyles={styles.growlNotificationActionHovered}
isNested
/>
)}
</View>
</GestureDetector>
</PressableWithoutFeedback>
</GrowlNotificationContainer>
</View>
);
}

export default GrowlNotificationContent;
Loading
Loading