diff --git a/src/components/CenteredModalLayout.tsx b/src/components/CenteredModalLayout.tsx new file mode 100644 index 000000000000..a4ba0273b9aa --- /dev/null +++ b/src/components/CenteredModalLayout.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import type {MouseEvent} from 'react'; +import type {DimensionValue} from 'react-native'; +import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +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; + + /** Width of the inner card on wide layout */ + height?: DimensionValue; + + /** Called when the backdrop is pressed, before navigating back */ + onBackdropPress?: () => void; +}; + +function CenteredModalLayout({children, width, height, onBackdropPress}: CenteredModalLayoutProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {translate} = useLocalize(); + + 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/ExplanationModal.tsx b/src/components/ExplanationModal.tsx deleted file mode 100644 index 5768d184fffa..000000000000 --- a/src/components/ExplanationModal.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import useLocalize from '@hooks/useLocalize'; -import * as Welcome from '@userActions/Welcome'; -import CONST from '@src/CONST'; -import FeatureTrainingModal from './FeatureTrainingModal'; - -function ExplanationModal() { - const {translate} = useLocalize(); - - return ( - - ); -} - -export default ExplanationModal; diff --git a/src/components/ExplanationModalScreen.tsx b/src/components/ExplanationModalScreen.tsx new file mode 100644 index 000000000000..4ef4f649f160 --- /dev/null +++ b/src/components/ExplanationModalScreen.tsx @@ -0,0 +1,61 @@ +import React, {useRef} from 'react'; +import {View} from 'react-native'; +import useBeforeRemove from '@hooks/useBeforeRemove'; +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; +import Navigation from '@libs/Navigation/Navigation'; +import {completeHybridAppOnboarding} from '@userActions/Welcome'; +import CONST from '@src/CONST'; +import CenteredModalLayout from './CenteredModalLayout'; +import FeatureTrainingContent from './FeatureTrainingContent'; + +function ExplanationModalScreen() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {windowWidth, windowHeight} = useWindowDimensions(); + + // In landscape mode the content is rendered in a ScrollView which handles the bottom safe area padding itself + const isContentScrollable = isInLandscapeModeUtil(windowWidth, windowHeight); + + const contentStyle = useBottomSafeSafeAreaPaddingStyle({ + addBottomSafeAreaPadding: !isContentScrollable, + style: [shouldUseNarrowLayout && styles.pt2, !isContentScrollable && styles.pb5], + }); + + // Mark hybrid-app onboarding complete however this screen is dismissed. + const hasCompletedOnboarding = useRef(false); + useBeforeRemove(() => { + if (hasCompletedOnboarding.current) { + return; + } + hasCompletedOnboarding.current = true; + completeHybridAppOnboarding(); + }); + + const handleClose = () => Navigation.goBack(); + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, handleClose, {shouldBubble: false}); + + return ( + + + + + + ); +} + +export default ExplanationModalScreen; diff --git a/src/components/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent.tsx new file mode 100644 index 000000000000..111e9ba1ea8e --- /dev/null +++ b/src/components/FeatureTrainingContent.tsx @@ -0,0 +1,390 @@ +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) => { + const next = !prev; + return next; + }); + }; + + 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 && ( +