Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3759,6 +3759,11 @@ const CONST = {
WARNING: 'warning',
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,
Comment thread
borys3kk marked this conversation as resolved.
// Pixel distance used to park the growl fully offscreen before it slides in. It only needs to
// exceed the growl's height + margins; the exact value isn't tied to a measured dimension.
OFFSCREEN_OFFSET: 255,
},

LOCALES,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import Animated, {useAnimatedStyle} from 'react-native-reanimated';

import type GrowlNotificationContainerProps from './types';

function GrowlNotificationContainer({children, translateY}: GrowlNotificationContainerProps) {
function GrowlNotificationContainer({children, progress, inactiveY}: GrowlNotificationContainerProps) {
Comment thread
borys3kk marked this conversation as resolved.
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const insets = useSafeAreaInsets();
const animatedStyles = useAnimatedStyle(() => styles.growlNotificationTranslateY(translateY));
// `progress` is a SharedValue read via .get() inside the worklet, so it's intentionally
// omitted from the deps array — only the plain JS value `inactiveY` needs to invalidate the style.
const animatedStyles = useAnimatedStyle(() => ({transform: [{translateY: inactiveY * (1 - progress.get())}]}), [inactiveY]);
Comment thread
borys3kk marked this conversation as resolved.

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 @@ -6,10 +6,16 @@ import Animated, {useAnimatedStyle} from 'react-native-reanimated';

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));
// `progress` is a SharedValue read via .get() inside the worklet, so it's intentionally
// omitted from the deps array — only the plain JS value `inactiveY` needs to invalidate the style.
const animatedStyles = useAnimatedStyle(() => ({transform: [{translateY: inactiveY * (1 - progress.get())}]}), [inactiveY]);
Comment thread
borys3kk marked this conversation as resolved.

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 @@ -3,7 +3,14 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps';
import type {SharedValue} from 'react-native-reanimated';

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;
183 changes: 183 additions & 0 deletions src/components/GrowlNotification/GrowlNotificationContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
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 type {SvgProps} from 'react-native-svg';

import React, {useEffect, useRef} from 'react';
import {View} from 'react-native';
import {Directions, Gesture, GestureDetector} from 'react-native-gesture-handler';
import {useSharedValue, withSpring} from 'react-native-reanimated';
import {scheduleOnRN} from 'react-native-worklets';

import GrowlNotificationContainer from './GrowlNotificationContainer';

const INACTIVE_OFFSET = CONST.GROWL.OFFSCREEN_OFFSET;
const INACTIVE_POSITION_Y = -INACTIVE_OFFSET;

const PressableWithoutFeedback = Pressables.PressableWithoutFeedback;

type GrowlNotificationContentProps = {
bodyText: string;
type: GrowlType;
duration: number;
action?: GrowlAction;

/** Identifies this growl instance; passed back through onDismissed so the parent can ignore stale dismissals. */
nonce: number;
onDismissed: (dismissedNonce: number) => void;
};
Comment on lines +31 to +40

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.

lets add docs to all type props


// Every growl variant must have an icon mapping; keeping this map exhaustive over GrowlType.
type GrowlIconTypes = Record<
GrowlType,
{
icon: React.FC<SvgProps> | IconAsset;
iconColor: string;
Comment on lines +46 to +47

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.

docs

}
>;

function GrowlNotificationContent({bodyText, type, duration, action, nonce, 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. Fresh per growl — the parent remounts this component for every show().
const isActionPressedRef = useRef(false);

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;
Comment thread
borys3kk marked this conversation as resolved.
const inactiveY = useBottomPosition ? INACTIVE_OFFSET : INACTIVE_POSITION_Y;

const types: GrowlIconTypes = {
Comment thread
borys3kk marked this conversation as resolved.
[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 = (targetProgress = 0, onSettled?: () => void) => {
Comment thread
borys3kk marked this conversation as resolved.
'worklet';

progress.set(
withSpring(targetProgress, {overshootClamping: false}, (finished) => {
// Reanimated calls this with finished=false if the spring is interrupted
// (e.g. a new withSpring on this shared value before the slide-out completes).
// A replacement growl remounts with its own shared value though, so this guard
// alone can't stop a stale slide-out from settling; the parent additionally
// ignores dismissals whose nonce no longer matches the current growl.
if (!finished || !onSettled) {
return;
}
scheduleOnRN(onSettled);
}),
);
};

/**
* Slide the growl off-screen; the spring's completion callback reports this growl's
* nonce through onDismissed so the parent can ignore it if a newer growl took over.
*/
const triggerDismiss = () => {
fling(0, () => onDismissed(nonce));
};

useEffect(() => {
Comment thread
borys3kk marked this conversation as resolved.
fling(1);

const autoDismissTimeoutId = setTimeout(triggerDismiss, duration);
return () => clearTimeout(autoDismissTimeoutId);
// Mount-only: the parent remounts this component (key=nonce) for every new growl, so the
// slide-in + auto-dismiss timer must arm exactly once per growl. Re-running on dep identity
// changes (e.g. theme/layout re-renders recreating `fling`/`triggerDismiss`) would replay
// the slide-in and reset the timer.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// 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.
const flingGesture = Gesture.Fling()
.direction(useBottomPosition ? Directions.DOWN : Directions.UP)
.runOnJS(true)
.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
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}
/>
)}
</View>
</GestureDetector>
</GrowlNotificationContainer>
</View>
);
}

export default GrowlNotificationContent;
Loading
Loading