From 9e0a7833b6644b8fe232bde8d98dd5ac0c8c1662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 11 Jun 2026 15:45:50 +0200 Subject: [PATCH 01/15] Migrate ExplanationModal to CenteredModalLayout --- src/components/AutoSubmitModal.tsx | 21 +- src/components/CenteredModalLayout.tsx | 61 +++ src/components/ExplanationModal.tsx | 36 +- src/components/FeatureTrainingContent.tsx | 387 ++++++++++++++++ src/components/FeatureTrainingModal.tsx | 435 ++---------------- .../Navigation/AppNavigator/AuthScreens.tsx | 2 +- .../Navigators/ExplanationModalNavigator.tsx | 10 +- .../useRootNavigatorScreenOptions.ts | 20 + src/pages/TrackTrainingPage.tsx | 13 +- src/styles/index.ts | 25 +- src/styles/variables.ts | 1 + 11 files changed, 568 insertions(+), 443 deletions(-) create mode 100644 src/components/CenteredModalLayout.tsx create mode 100644 src/components/FeatureTrainingContent.tsx diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx index 334a3e79a28..0ef39ec5773 100644 --- a/src/components/AutoSubmitModal.tsx +++ b/src/components/AutoSubmitModal.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useRef} from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -37,21 +37,7 @@ 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 = () => { - if (willShowAgainRef.current === null) { - return; - } - dismissASAPSubmitExplanation(!willShowAgainRef.current); - willShowAgainRef.current = null; - }; + const onPersistDismiss = () => dismissASAPSubmitExplanation(true); return ( void; +}; + +function CenteredModalLayout({children, width, height, onBackdropPress}: CenteredModalLayoutProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {translate} = useLocalize(); + + const handleOuterPress = () => { + onBackdropPress?.(); + Navigation.goBack(); + }; + + 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 index 5768d184fff..b95b2a3955e 100644 --- a/src/components/ExplanationModal.tsx +++ b/src/components/ExplanationModal.tsx @@ -1,21 +1,37 @@ import React from 'react'; import useLocalize from '@hooks/useLocalize'; -import * as Welcome from '@userActions/Welcome'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import {completeHybridAppOnboarding} from '@userActions/Welcome'; import CONST from '@src/CONST'; -import FeatureTrainingModal from './FeatureTrainingModal'; +import FeatureTrainingContent from './FeatureTrainingContent'; +import ScreenWrapper from './ScreenWrapper'; function ExplanationModal() { const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const handleClose = () => { + completeHybridAppOnboarding(); + Navigation.goBack(); + }; return ( - + + + ); } diff --git a/src/components/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent.tsx new file mode 100644 index 00000000000..b0798c02403 --- /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 +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?: () => void; + + /** A callback to call when content wants to close */ + onClose?: () => void; + + /** Called when the user dismisses with "don't show again" checked — caller provides the specific persistence action */ + onPersistDismiss?: () => 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, + onPersistDismiss, + 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 = () => setWillShowAgain((prevWillShowAgain) => !prevWillShowAgain); + + const handleConfirm = () => { + if (shouldShowDismissModalOption && !willShowAgain) { + onPersistDismiss?.(); + } + onConfirm?.(); + 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={CONST.FULLSTORY.CLASS.UNMASK} + > + + {renderIllustration()} + + + {!!title && !!description && ( + + {typeof title === 'string' ? {title} : title} + {shouldRenderHTMLDescription ? ( + + + + ) : ( + {description} + )} + {secondaryDescription.length > 0 && {secondaryDescription}} + {children} + + )} + {shouldShowDismissModalOption && ( + + )} + {!!helpText && ( +