-
Notifications
You must be signed in to change notification settings - Fork 3.9k
[No QA] Growl: add action button and loading variant #94865
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mountiny
merged 18 commits into
Expensify:main
from
software-mansion-labs:borys3kk-introduce-growls-pr1
Jul 3, 2026
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
ab770c0
[No QA] Growl: add action button and loading variant
borys3kk b0d860f
add explanatory comment
borys3kk e77e084
add withSpring instead of fixed timeout, add comment explaining the I…
borys3kk bc957c8
remove useCallbacks
borys3kk 5d12eaf
remove array wrappers
borys3kk 4c36043
remove useCallbacks
borys3kk f9f500f
make the api symmetric for error growls, add duration resolving
borys3kk ed1f236
resolve remaining review comments
borys3kk c6a3422
Merge branch 'main' of github.com:Expensify/App into borys3kk-introdu…
borys3kk 36cb579
resolve remaining review comments
borys3kk bec756a
remove loading variant (unused in the final pr), removed manual memoi…
borys3kk 85bc577
make growlNotificationInset shared const for growls top and bottom po…
borys3kk bacfb7c
fix spellcheck
borys3kk 90c585d
Merge branch 'main' of github.com:Expensify/App into borys3kk-introdu…
borys3kk 4b2a06c
fix last review comments
borys3kk 46171be
merge main
borys3kk c877a7e
fix fmt
borys3kk d9bb03d
fix codex review comment
borys3kk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
183 changes: 183 additions & 0 deletions
183
src/components/GrowlNotification/GrowlNotificationContent.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
borys3kk marked this conversation as resolved.
|
||
| const inactiveY = useBottomPosition ? INACTIVE_OFFSET : INACTIVE_POSITION_Y; | ||
|
|
||
| const types: GrowlIconTypes = { | ||
|
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) => { | ||
|
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(() => { | ||
|
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; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.