diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx index 334a3e79a285..998b8f21bea9 100644 --- a/src/components/AutoSubmitModal.tsx +++ b/src/components/AutoSubmitModal.tsx @@ -1,17 +1,21 @@ import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; +import useBeforeRemove from '@hooks/useBeforeRemove'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import {dismissASAPSubmitExplanation} from '@userActions/User'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import FeatureTrainingModal from './FeatureTrainingModal'; +import CenteredModalLayout from './CenteredModalLayout'; +import FeatureTrainingContent from './FeatureTrainingContent'; import Icon from './Icon'; import Text from './Text'; @@ -37,61 +41,75 @@ function AutoSubmitModal() { [illustrations.PaperAirplane, illustrations.Pencil], ); - // Defer the Onyx write until after the modal close animation finishes. The ref is set in onConfirm - // and consumed in onClose, which FeatureTrainingModal fires from onModalHide (after the close animation completes). const willShowAgainRef = useRef(null); - const onConfirm = (willShowAgain: boolean) => { - willShowAgainRef.current = willShowAgain; - }; - - const onClose = () => { + const persistDismiss = () => { if (willShowAgainRef.current === null) { return; } - dismissASAPSubmitExplanation(!willShowAgainRef.current); + + const shouldDismiss = !willShowAgainRef.current; willShowAgainRef.current = null; + + // Defer the Onyx write until after the close transition finishes. This prevents potential checkbox flicker. + TransitionTracker.runAfterTransitions({ + callback: () => dismissASAPSubmitExplanation(shouldDismiss), + waitForUpcomingTransition: true, + }); + }; + + useBeforeRemove(persistDismiss); + + const handleClose = () => Navigation.goBack(); + + const onConfirm = (willShowAgain: boolean) => { + willShowAgainRef.current = willShowAgain; }; return ( - - {menuSections.map((section) => ( - - - - {translate(section.titleTranslationKey as TranslationPaths)} - {translate(section.descriptionTranslationKey as TranslationPaths)} + + {menuSections.map((section) => ( + + + + {translate(section.titleTranslationKey as TranslationPaths)} + {translate(section.descriptionTranslationKey as TranslationPaths)} + - - ))} - + ))} + + ); } diff --git a/src/components/CenteredModalLayout.tsx b/src/components/CenteredModalLayout.tsx new file mode 100644 index 000000000000..97e3db9203da --- /dev/null +++ b/src/components/CenteredModalLayout.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import type {MouseEvent} from 'react'; +import type {DimensionValue, StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; +import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; +import CONST from '@src/CONST'; +import FocusTrapForScreen from './FocusTrap/FocusTrapForScreen'; +import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; + +type CenteredModalLayoutProps = { + children: React.ReactNode; + + /** Width of the inner card on wide layouts (defaults to featureTrainingModalWidth) */ + width?: number; + + /** Height of the inner card on wide layout */ + height?: DimensionValue; + + /** Called when the backdrop is pressed, before navigating back */ + onBackdropPress: () => void; + + /** Extra styles merged into the safe-area content wrapper */ + contentStyle?: StyleProp; +}; + +function CenteredModalLayout({children, width, height, onBackdropPress, contentStyle}: CenteredModalLayoutProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {windowWidth, windowHeight} = useWindowDimensions(); + + const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); + const safeAreaStyle = useBottomSafeSafeAreaPaddingStyle({ + addBottomSafeAreaPadding: !isInLandscapeMode, + style: [shouldUseNarrowLayout && styles.pt2, !isInLandscapeMode && styles.pb5, contentStyle], + }); + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, onBackdropPress, {shouldBubble: false}); + + const handleInnerClick = (e: MouseEvent) => e.stopPropagation(); + + return ( + <> + + + + true} + onClick={handleInnerClick} + style={styles.getCenteredModalInnerView(shouldUseNarrowLayout, width, height)} + > + {children} + + + + + ); +} + +export default CenteredModalLayout; diff --git a/src/components/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent.tsx new file mode 100644 index 000000000000..aa11f5299517 --- /dev/null +++ b/src/components/FeatureTrainingContent.tsx @@ -0,0 +1,387 @@ +import type {ImageContentFit} from 'expo-image'; +import type {SourceLoadEventPayload} from 'expo-video'; +import React, {useEffect, useRef, useState} from 'react'; +import {Image, View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports -- type-only import from react-native +import type {ImageResizeMode, ImageSourcePropType, LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import {GestureHandlerRootView} from 'react-native-gesture-handler'; +import type {MergeExclusive} from 'type-fest'; +import useKeyboardState from '@hooks/useKeyboardState'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import Accessibility from '@libs/Accessibility'; +import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; +import {getIsOffline} from '@libs/NetworkState'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; +import Button from './Button'; +import CheckboxWithLabel from './CheckboxWithLabel'; +import FormAlertWithSubmitButton from './FormAlertWithSubmitButton'; +import ImageSVG from './ImageSVG'; +import type ImageSVGProps from './ImageSVG/types'; +import Lottie from './Lottie'; +import LottieAnimations from './LottieAnimations'; +import type DotLottieAnimation from './LottieAnimations/types'; +import OfflineIndicator from './OfflineIndicator'; +import RenderHTML from './RenderHTML'; +import ScrollView from './ScrollView'; +import Text from './Text'; +import VideoPlayer from './VideoPlayer'; + +const VIDEO_ASPECT_RATIO = 1280 / 960; + +const CONTENT_PADDING = variables.spacing2; + +type VideoStatus = 'video' | 'animation'; + +type BaseFeatureTrainingContentProps = { + /** The aspect ratio to preserve for the icon, video or animation */ + illustrationAspectRatio?: number; + + /** Style for the inner container of the animation */ + illustrationInnerContainerStyle?: StyleProp; + + /** Style for the outer container of the animation */ + illustrationOuterContainerStyle?: StyleProp; + + /** Title for the modal */ + title?: string | React.ReactNode; + + /** Describe what is showing */ + description?: string; + + /** Secondary description rendered with additional space */ + secondaryDescription?: string; + + /** Style for the title */ + titleStyles?: StyleProp; + + /** Whether to show `Don't show me this again` option */ + shouldShowDismissModalOption?: boolean; + + /** Text to show on primary button */ + confirmText: string; + + /** A callback to call when user confirms */ + onConfirm?: (willShowAgain: boolean) => void; + + /** A callback to call when content wants to close */ + onClose?: () => void; + + /** Called whenever the "don't show again" checkbox value changes */ + onWillShowAgainChange?: (willShowAgain: boolean) => void; + + /** Text to show on secondary button */ + helpText?: string; + + /** Link to navigate to when user wants to learn more */ + onHelp?: () => void; + + /** Styles for the content container */ + contentInnerContainerStyles?: StyleProp; + + /** Styles for the content outer container */ + contentOuterContainerStyles?: StyleProp; + + /** Children to show below title and description and above buttons */ + children?: React.ReactNode; + + /** Content width for wide layouts */ + width?: number; + + /** Whether the image is a SVG */ + shouldRenderSVG?: boolean; + + /** Whether the description is written in HTML */ + shouldRenderHTMLDescription?: boolean; + + /** Whether closing should happen on confirm */ + shouldCloseOnConfirm?: boolean; + + /** Whether the content is scrollable */ + shouldUseScrollView?: boolean; + + /** Whether to show a confirmation loading spinner */ + shouldShowConfirmationLoader?: boolean; + + /** Whether the user can confirm while offline */ + canConfirmWhileOffline?: boolean; + + /** Sentry label for the help/skip button */ + helpSentryLabel?: string; + + /** Sentry label for the confirm/submit button */ + confirmSentryLabel?: string; +}; + +type FeatureTrainingContentVideoProps = { + /** Animation to show when video is unavailable */ + animation?: DotLottieAnimation; + + /** Additional styles for the animation */ + animationStyle?: StyleProp; + + /** URL for the video */ + videoURL?: string; +}; + +type FeatureTrainingContentSVGProps = { + /** Expensicon for the page */ + image: IconAsset; + + /** Determines how the image should be resized to fit its container */ + contentFitImage?: ImageContentFit; + + /** The width of the image */ + imageWidth?: ImageSVGProps['width']; + + /** The height of the image */ + imageHeight?: ImageSVGProps['height']; +}; + +type FeatureTrainingContentProps = BaseFeatureTrainingContentProps & MergeExclusive; + +const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7; + +/** + * Once the device has been online, lock to 'video' permanently. + * While it has never been online, show 'animation' as a fallback. + */ +function useVideoStatus(): VideoStatus { + const [isLockedToVideo, setIsLockedToVideo] = useState(() => !getIsOffline()); + const {isOffline} = useNetwork({ + onReconnect: () => setIsLockedToVideo(true), + }); + + return isLockedToVideo || !isOffline ? 'video' : 'animation'; +} + +function FeatureTrainingContent({ + animation, + animationStyle, + illustrationInnerContainerStyle, + illustrationOuterContainerStyle, + videoURL, + illustrationAspectRatio: illustrationAspectRatioProp, + image, + contentFitImage, + width = variables.featureTrainingModalWidth, + title = '', + description = '', + secondaryDescription = '', + titleStyles, + shouldShowDismissModalOption = false, + confirmText = '', + onConfirm, + onClose, + onWillShowAgainChange, + helpText = '', + onHelp, + children, + contentInnerContainerStyles, + contentOuterContainerStyles, + imageWidth, + imageHeight, + shouldRenderSVG = true, + shouldRenderHTMLDescription = false, + shouldCloseOnConfirm = true, + shouldUseScrollView: shouldUseScrollViewProp = false, + shouldShowConfirmationLoader = false, + canConfirmWhileOffline = true, + helpSentryLabel, + confirmSentryLabel, +}: FeatureTrainingContentProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const isReduceMotionEnabled = Accessibility.useReducedMotion(); + const illustrations = useMemoizedLazyIllustrations(['Hands']); + const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); + const {windowHeight, windowWidth} = useWindowDimensions(); + const [willShowAgain, setWillShowAgain] = useState(true); + const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const videoStatus = useVideoStatus(); + const scrollViewRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(0); + const [contentHeight, setContentHeight] = useState(0); + const insets = useSafeAreaInsets(); + const {isKeyboardActive} = useKeyboardState(); + const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); + + const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode; + + const setAspectRatio = (event: SourceLoadEventPayload) => { + const track = event.availableVideoTracks.at(0); + + if (!track) { + return; + } + + setIllustrationAspectRatio(track.size.width / track.size.height); + }; + + const renderIllustration = () => { + const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO; + + return ( + + {!!image && + (shouldRenderSVG ? ( + + ) : ( + + ))} + {!!videoURL && videoStatus === 'video' && ( + + + + )} + {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && ( + + {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? ( + + ) : ( + + )} + + )} + + ); + }; + + const toggleWillShowAgain = () => { + onWillShowAgainChange?.(!willShowAgain); + setWillShowAgain((prev) => !prev); + }; + + const handleConfirm = () => { + onConfirm?.(willShowAgain); + if (shouldCloseOnConfirm) { + onClose?.(); + } + }; + + useEffect(() => { + if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) { + return; + } + scrollViewRef.current?.scrollToEnd({animated: false}); + }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]); + + const Wrapper = shouldUseScrollView ? ScrollView : View; + + const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {}; + + return ( + setContainerHeight(e.nativeEvent.layout.height) : undefined} + onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined} + // eslint-disable-next-line react/forbid-component-props -- fsClass is required for FullStory session masking + fsClass={CONST.FULLSTORY.CLASS.UNMASK} + > + + {renderIllustration()} + + + {!!title && !!description && ( + + {typeof title === 'string' ? {title} : title} + {shouldRenderHTMLDescription ? ( + + + + ) : ( + {description} + )} + {secondaryDescription.length > 0 && {secondaryDescription}} + {children} + + )} + {shouldShowDismissModalOption && ( + + )} + {!!helpText && ( +