diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 05f2b0fed3be..c59532aed02d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -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, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 37ab59480ea1..e127f5b09b47 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -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', @@ -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>; [ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null; [ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined; }; diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx index 44dea7d7784c..eaeeb85a3964 100644 --- a/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx @@ -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 {children}; } diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx b/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx index a3d2e6379ea6..4bf10ca2cdad 100644 --- a/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx @@ -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 {children}; + } return ( {children} diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/types.ts b/src/components/GrowlNotification/GrowlNotificationContainer/types.ts index 5f137f6d3c48..bd935c9bc279 100644 --- a/src/components/GrowlNotification/GrowlNotificationContainer/types.ts +++ b/src/components/GrowlNotification/GrowlNotificationContainer/types.ts @@ -2,7 +2,14 @@ import type {SharedValue} from 'react-native-reanimated'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; type GrowlNotificationContainerProps = ChildrenProps & { - translateY: SharedValue; + /** Normalized visibility (0 = fully offscreen, 1 = fully visible). Translated to a pixel offset at render time. */ + progress: SharedValue; + + /** 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; diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx new file mode 100644 index 000000000000..fdfb7fdc59ee --- /dev/null +++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx @@ -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 | 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, + { + icon: React.FC | 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 ( + + + triggerDismiss()} + > + + + {type === CONST.GROWL.LOADING ? ( + + ) : ( + + )} + {bodyText} + {!!action && ( +