diff --git a/assets/animations/CustomAgents.lottie b/assets/animations/CustomAgents.lottie
new file mode 100644
index 000000000000..6f6b40b8c463
Binary files /dev/null and b/assets/animations/CustomAgents.lottie differ
diff --git a/assets/animations/ExpenseAssistant.lottie b/assets/animations/ExpenseAssistant.lottie
new file mode 100644
index 000000000000..e2c0d9729eae
Binary files /dev/null and b/assets/animations/ExpenseAssistant.lottie differ
diff --git a/assets/animations/SpendAnalysis.lottie b/assets/animations/SpendAnalysis.lottie
new file mode 100644
index 000000000000..f1cc7ab61cb5
Binary files /dev/null and b/assets/animations/SpendAnalysis.lottie differ
diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 70b186393a83..56d6dad47613 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -171,8 +171,8 @@
"../../src/components/EmptySelectionListContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/EnvironmentBadge.tsx" "no-restricted-syntax" 1
"../../src/components/ExplanationModal.tsx" "no-restricted-syntax" 1
-"../../src/components/FeatureTrainingModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/components/FeatureTrainingModal.tsx" "react-hooks/set-state-in-effect" 1
+"../../src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
+"../../src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/FeedbackSurvey.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/FilePicker/index.native.tsx" "react-hooks/refs" 1
"../../src/components/FilePicker/index.tsx" "react-hooks/refs" 1
@@ -670,6 +670,7 @@
"../../src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/Navigation/PlatformStackNavigation/navigationOptions/animation/withAnimation.ts" "@typescript-eslint/no-unsafe-type-assertion" 6
+"../../src/libs/Navigation/guards/AIFeaturesPromoGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/Navigation/guards/MigratedUserWelcomeModalGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/Navigation/guards/OnboardingGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/Navigation/helpers/createNormalizedConfigs.ts" "@typescript-eslint/no-deprecated/escape" 1
diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts
index cf6a137be8e3..4f1a66081887 100644
--- a/jest/setupAfterEnv.ts
+++ b/jest/setupAfterEnv.ts
@@ -6,6 +6,16 @@ import ONYXKEYS from '@src/ONYXKEYS';
jest.useRealTimers();
+// Globally short-circuit AIFeaturesPromoGuard in tests. The real guard proactively
+// navigates any authenticated session to /ai-features-promo unless the dismissal NVP
+// is set, which would otherwise intercept navigation in unrelated UI tests. Tests
+// that need the real guard can override this mock locally.
+jest.mock('@libs/Navigation/guards/AIFeaturesPromoGuard', () => ({
+ __esModule: true,
+ default: {name: 'AIFeaturesPromoGuard', evaluate: () => ({type: 'ALLOW'})},
+ onSessionOrLoadingAppChanged: jest.fn(),
+}));
+
// Patch Keyboard.addListener to return a subscription object with .remove() so that
// @react-navigation/bottom-tabs useIsKeyboardShown hook doesn't crash on cleanup.
if (Keyboard && typeof Keyboard.addListener === 'function') {
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 6af591bed322..811031a296e8 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -7258,6 +7258,7 @@ const CONST = {
SCREENS.SAML_SIGN_IN,
SCREENS.VALIDATE_LOGIN,
SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT,
+ SCREENS.AI_FEATURES_PROMO_MODAL.ROOT,
SCREENS.MONEY_REQUEST.STEP_SCAN,
SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP,
...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION),
@@ -7777,6 +7778,14 @@ const CONST = {
MIGRATED_USER_WELCOME_MODAL: 'migratedUserWelcomeModal',
+ AI_FEATURES_PROMO_MODAL: 'aiFeaturesPromoModal',
+
+ AI_FEATURES_PROMO_LEARN_MORE_URLS: {
+ SPEND_ANALYSIS: 'https://help.expensify.com/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend',
+ EXPENSE_ASSISTANT: 'https://help.expensify.com/articles/new-expensify/concierge-ai/Expense-Assistant',
+ BUILD_AGENTS: 'https://help.expensify.com/articles/new-expensify/ai-agents/Create-Agent-Rules',
+ },
+
BASE_LIST_ITEM_TEST_ID: 'base-list-item-',
SELECTION_BUTTON_TEST_ID: 'selection-button-',
PRODUCT_TRAINING_TOOLTIP_NAMES: {
diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts
index 7f24aedd382e..3a99812352d3 100644
--- a/src/NAVIGATORS.ts
+++ b/src/NAVIGATORS.ts
@@ -8,6 +8,7 @@ export default {
ONBOARDING_MODAL_NAVIGATOR: 'OnboardingModalNavigator',
FEATURE_TRAINING_MODAL_NAVIGATOR: 'FeatureTrainingModalNavigator',
MIGRATED_USER_MODAL_NAVIGATOR: 'MigratedUserModalNavigator',
+ AI_FEATURES_PROMO_MODAL_NAVIGATOR: 'AIFeaturesPromoModalNavigator',
TEST_DRIVE_MODAL_NAVIGATOR: 'TestDriveModalNavigator',
TEST_DRIVE_DEMO_NAVIGATOR: 'TestDriveDemoNavigator',
REPORTS_SPLIT_NAVIGATOR: 'ReportsSplitNavigator',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index d9790039e6ab..8cc6bdcf979d 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -3375,6 +3375,7 @@ const ROUTES = {
getRoute: (backTo?: string) => getUrlWithBackToParam('onboarding/migrated-user-welcome', backTo, false),
},
+ AI_FEATURES_PROMO_MODAL: 'ai-features-promo',
TRANSACTION_RECEIPT: {
route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 5ff83221799a..e448e95c7b71 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -970,6 +970,10 @@ const SCREENS = {
ROOT: 'MigratedUserWelcomeModal_Root',
},
+ AI_FEATURES_PROMO_MODAL: {
+ ROOT: 'AIFeaturesPromoModal_Root',
+ },
+
TEST_DRIVE_MODAL: {
ROOT: 'TestDrive_Modal_Root',
},
diff --git a/src/components/AIFeaturesPromoModal/index.tsx b/src/components/AIFeaturesPromoModal/index.tsx
new file mode 100644
index 000000000000..a161b7688029
--- /dev/null
+++ b/src/components/AIFeaturesPromoModal/index.tsx
@@ -0,0 +1,92 @@
+import React, {useRef} from 'react';
+import {View} from 'react-native';
+import Badge from '@components/Badge';
+import FeatureTrainingModal from '@components/FeatureTrainingModal';
+import type {FeatureTrainingModalPageProps} from '@components/FeatureTrainingModal';
+import LottieAnimations from '@components/LottieAnimations';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {dismissProductTraining} from '@libs/actions/Welcome';
+import Log from '@libs/Log';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+
+function AIFeaturesPromoModal() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {isBetaEnabled} = usePermissions();
+ const canUseCustomAgent = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT);
+
+ const customAgentPromoTitle = (
+
+ {translate('aiFeaturesPromoModal.customAgents.title')}
+
+
+ );
+
+ const pages: FeatureTrainingModalPageProps[] = [
+ {
+ animation: LottieAnimations.SpendAnalysis,
+ title: translate('aiFeaturesPromoModal.spendAnalysis.title'),
+ subtitle: translate('aiFeaturesPromoModal.subtitle'),
+ description: translate('aiFeaturesPromoModal.spendAnalysis.description'),
+ confirmText: translate('common.next'),
+ },
+ {
+ animation: LottieAnimations.ExpenseAssistant,
+ title: translate('aiFeaturesPromoModal.expenseAssistant.title'),
+ subtitle: translate('aiFeaturesPromoModal.subtitle'),
+ description: translate('aiFeaturesPromoModal.expenseAssistant.description'),
+ confirmText: canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText'),
+ },
+ ...(canUseCustomAgent
+ ? [
+ {
+ animation: LottieAnimations.CustomAgents,
+ title: customAgentPromoTitle,
+ subtitle: translate('aiFeaturesPromoModal.subtitle'),
+ description: translate('aiFeaturesPromoModal.customAgents.description'),
+ confirmText: translate('aiFeaturesPromoModal.confirmText'),
+ },
+ ]
+ : []),
+ ];
+
+ const wasDismissedViaConfirmRef = useRef(false);
+
+ const onConfirm = () => {
+ Log.hmmm('[AIFeaturesPromoModal] onConfirm called, recording click dismissal');
+ wasDismissedViaConfirmRef.current = true;
+ };
+
+ const onClose = () => {
+ const isCloseButtonDismissal = !wasDismissedViaConfirmRef.current;
+ Log.hmmm(`[AIFeaturesPromoModal] onClose called, dismissing product training via ${isCloseButtonDismissal ? 'x' : 'click'}`);
+ dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isCloseButtonDismissal);
+ };
+
+ return (
+
+ );
+}
+
+export default AIFeaturesPromoModal;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx
new file mode 100644
index 000000000000..8bbcc63c56d1
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import {View} from 'react-native';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useStyleUtils from '@hooks/useStyleUtils';
+import FeatureTrainingModalContent from './FeatureTrainingModalContent';
+import FeatureTrainingModalIllustration from './FeatureTrainingModalIllustration';
+import type {BaseFeatureTrainingModalProps, FeatureTrainingModalPageProps} from './index';
+
+type FeatureTrainingModalBodyProps = BaseFeatureTrainingModalProps &
+ FeatureTrainingModalPageProps & {
+ /** Padding for the modal */
+ modalPadding: number;
+
+ /** Whether the modal should be shown again */
+ willShowAgain: boolean;
+
+ /** A callback to call when the modal should be shown again */
+ toggleWillShowAgain: () => void;
+
+ /** A callback to call when we want to close the modal */
+ closeModal: (didPressHelpButton?: boolean) => void;
+
+ /** A callback to call when we want to close the modal and confirm */
+ confirmModal: () => void;
+
+ /** Whether to show the back button to navigate back to the previous page in carousel mode */
+ shouldShowBackButton?: boolean;
+
+ /** A callback to call when we want to navigate back to the previous page in carousel mode */
+ onBack?: () => void;
+ };
+
+function FeatureTrainingModalBody({
+ illustrationInnerContainerStyle,
+ illustrationOuterContainerStyle,
+ illustrationAspectRatio: illustrationAspectRatioProp,
+ width,
+ title = '',
+ subtitle = '',
+ description = '',
+ secondaryDescription = '',
+ titleStyles,
+ shouldShowDismissModalOption = false,
+ confirmText = '',
+ helpText = '',
+ onHelp = () => {},
+ children,
+ contentInnerContainerStyles,
+ contentOuterContainerStyles,
+ shouldRenderSVG = true,
+ shouldRenderHTMLDescription = false,
+ shouldShowConfirmationLoader = false,
+ canConfirmWhileOffline = true,
+ shouldCallOnHelpWhenModalHidden = false,
+ helpSentryLabel,
+ confirmSentryLabel,
+ modalPadding,
+ willShowAgain = true,
+ toggleWillShowAgain,
+ closeModal,
+ confirmModal,
+ shouldShowBackButton = false,
+ onBack,
+ ...props
+}: FeatureTrainingModalBodyProps) {
+ const StyleUtils = useStyleUtils();
+ const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+export default FeatureTrainingModalBody;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
new file mode 100644
index 000000000000..e66c3e4d01c0
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
@@ -0,0 +1,312 @@
+import React, {useEffect, useRef, useState} from 'react';
+import {FlatList, Platform, View} from 'react-native';
+import type {LayoutChangeEvent, FlatList as RNFlatList, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native';
+import Icon from '@components/Icon';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import Tooltip from '@components/Tooltip';
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import FeatureTrainingModalContent from './FeatureTrainingModalContent';
+import FeatureTrainingModalIllustration from './FeatureTrainingModalIllustration';
+import FeatureTrainingModalTextContent from './FeatureTrainingModalTextContent';
+import type {BaseFeatureTrainingModalProps, FeatureTrainingModalCarouselProps, FeatureTrainingModalPageProps} from './index';
+
+// A page is considered "viewable" — and `currentPage` updates — only once it occupies at least
+// 95% of the viewport. The viewability event fires for both user swipes and programmatic
+// scrollToIndex once the scroll has practically settled on a new page.
+const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThreshold: 95};
+const CAROUSEL_DOT_SIZE = 6;
+const PAGINATION_DOTS_BOTTOM_OFFSET = 5;
+
+// react-native-web translates `pagingEnabled` to CSS scroll-snap, but `scroll-snap-align` only
+// lands on the ScrollView's direct children — our per-page View (inside `renderItem`) isn't a
+// direct child, so the FlatList ends up with a single snap point and fast flings skip pages.
+// Applying `scrollSnapAlign: 'start'` on each page wrapper turns every page into a snap point.
+// Native ignores this CSS-only key, so we still gate it on Platform.OS === 'web' for clarity.
+const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start'} as ViewStyle) : {};
+
+type FeatureTrainingModalCarouselBodyProps = Pick<
+ BaseFeatureTrainingModalProps,
+ | 'illustrationAspectRatio'
+ | 'illustrationInnerContainerStyle'
+ | 'illustrationOuterContainerStyle'
+ | 'titleStyles'
+ | 'shouldRenderSVG'
+ | 'shouldRenderHTMLDescription'
+ | 'shouldShowDismissModalOption'
+ | 'helpText'
+ | 'onHelp'
+ | 'shouldCallOnHelpWhenModalHidden'
+ | 'helpSentryLabel'
+ | 'confirmSentryLabel'
+ | 'shouldShowConfirmationLoader'
+ | 'canConfirmWhileOffline'
+ | 'contentInnerContainerStyles'
+ | 'contentOuterContainerStyles'
+ | 'width'
+> &
+ FeatureTrainingModalCarouselProps & {
+ /** Padding for the modal */
+ modalPadding: number;
+
+ /** Whether the modal should be shown again */
+ willShowAgain: boolean;
+
+ /** Callback when the "Don't show me this again" option is toggled */
+ toggleWillShowAgain: () => void;
+
+ /** Callback to close the modal */
+ closeModal: (didPressHelpButton?: boolean) => void;
+
+ /** Callback fired when the user presses the confirm button on the LAST page */
+ onConfirm: () => void;
+
+ /** Called when the user swipes to a different page */
+ onPageChange?: (index: number) => void;
+ };
+
+function FeatureTrainingModalCarouselBody({
+ pages,
+ modalPadding,
+ width = variables.featureTrainingModalWidth,
+ titleStyles,
+ illustrationAspectRatio,
+ illustrationInnerContainerStyle,
+ illustrationOuterContainerStyle,
+ shouldRenderSVG = true,
+ shouldRenderHTMLDescription = false,
+ shouldShowDismissModalOption = false,
+ helpText = '',
+ onHelp = () => {},
+ shouldCallOnHelpWhenModalHidden = false,
+ helpSentryLabel,
+ confirmSentryLabel,
+ shouldShowConfirmationLoader = false,
+ canConfirmWhileOffline = true,
+ contentInnerContainerStyles,
+ contentOuterContainerStyles,
+ willShowAgain,
+ toggleWillShowAgain,
+ closeModal,
+ onConfirm,
+ onPageChange,
+}: FeatureTrainingModalCarouselBodyProps) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const theme = useTheme();
+ const {translate} = useLocalize();
+ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']);
+ const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+
+ const [currentPage, setCurrentPage] = useState(0);
+ const [carouselViewportWidth, setCarouselViewportWidth] = useState(0);
+ const horizontalListRef = useRef>(null);
+ const lastReportedPage = useRef(0);
+
+ const [contentMinHeight, setContentMinHeight] = useState(undefined);
+ const measuredHeightsRef = useRef>({});
+ const handleProbeLayout = (index: number) => (event: LayoutChangeEvent) => {
+ const measured = event.nativeEvent.layout.height;
+ if (measuredHeightsRef.current[index] === measured) {
+ return;
+ }
+ measuredHeightsRef.current[index] = measured;
+ if (Object.keys(measuredHeightsRef.current).length < pages.length) {
+ return;
+ }
+ setContentMinHeight(Math.max(...Object.values(measuredHeightsRef.current)));
+ };
+
+ // FlatList's `onViewableItemsChanged` must keep a stable identity (it errors otherwise).
+ // The handler reads the latest `onPageChange` via a ref so the callback identity never changes.
+ const onPageChangeRef = useRef(onPageChange);
+ useEffect(() => {
+ onPageChangeRef.current = onPageChange;
+ }, [onPageChange]);
+
+ const onViewableItemsChanged = ({viewableItems}: {viewableItems: ViewToken[]}) => {
+ const entry = viewableItems.at(0);
+ if (entry?.index == null || entry.index === lastReportedPage.current) {
+ return;
+ }
+ lastReportedPage.current = entry.index;
+ setCurrentPage(entry.index);
+ onPageChangeRef.current?.(entry.index);
+ };
+
+ const advanceCarousel = () => {
+ horizontalListRef.current?.scrollToIndex({index: Math.min(currentPage + 1, pages.length - 1), animated: true});
+ };
+
+ const goBack = () => {
+ if (currentPage <= 0) {
+ return;
+ }
+ horizontalListRef.current?.scrollToIndex({index: Math.max(currentPage - 1, 0), animated: true});
+ };
+
+ const handleConfirmPress = () => {
+ if (currentPage < pages.length - 1) {
+ advanceCarousel();
+ return;
+ }
+ onConfirm();
+ };
+
+ const carouselPaginationDots = pages.map((_page, index) => (
+
+ ));
+
+ const currentPageData = pages.at(currentPage);
+
+ return (
+ 0`), so without `w100` it
+ // collapses to 0×0 inside the `fit-content` modal sheet — `onLayout` then never
+ // fires with a positive width and the carousel never renders, leaving the modal
+ // backdrop visible with no content. `w100` makes the View stretch to the sheet's
+ // known full width and lets `onLayout` resolve immediately. On medium+ screens the
+ // explicit `getWidthStyle(width)` continues to apply.
+ style={[onboardingIsMediumOrLargerScreenWidth ? StyleUtils.getWidthStyle(width) : styles.w100]}
+ onLayout={(e: LayoutChangeEvent) => {
+ const newWidth = e.nativeEvent.layout.width;
+ if (newWidth === carouselViewportWidth || newWidth <= 0) {
+ return;
+ }
+ setCarouselViewportWidth(newWidth);
+ }}
+ >
+ {carouselViewportWidth > 0 && contentMinHeight === undefined && (
+ // Probe layer is used to measure the tallest page to lock the modal height
+ // when moving between pages with different content lengths.
+
+ {pages.map((page, index) => (
+
+
+
+ ))}
+
+ )}
+ {carouselViewportWidth > 0 && (
+ <>
+
+ `FeatureTrainingModalIllustration-${index}`}
+ horizontal
+ pagingEnabled
+ // Defense-in-depth on native: `pagingEnabled` is honored by the
+ // platform, but these props arrest momentum precisely at each page
+ // so fast flings never coast over a middle page. On web they're
+ // no-ops; `WEB_CAROUSEL_PAGE_SNAP_STYLE` below does the equivalent
+ // work via CSS scroll-snap.
+ disableIntervalMomentum
+ snapToInterval={carouselViewportWidth}
+ decelerationRate="fast"
+ showsHorizontalScrollIndicator={false}
+ keyboardShouldPersistTaps="handled"
+ viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG}
+ onViewableItemsChanged={onViewableItemsChanged}
+ getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})}
+ renderItem={({item: page, index}) => (
+
+
+
+ )}
+ />
+
+ {carouselPaginationDots}
+
+
+ 0}
+ onBack={goBack}
+ shouldShowConfirmationLoader={shouldShowConfirmationLoader}
+ canConfirmWhileOffline={canConfirmWhileOffline}
+ titleStyles={titleStyles}
+ contentInnerContainerStyles={[contentInnerContainerStyles, contentMinHeight !== undefined && {minHeight: contentMinHeight}]}
+ contentOuterContainerStyles={contentOuterContainerStyles}
+ shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+ />
+
+
+ closeModal()}
+ role={CONST.ROLE.BUTTON}
+ accessibilityLabel={translate('common.close')}
+ sentryLabel="FeatureTrainingModal-Carousel-Close"
+ style={[styles.p2, styles.opacitySemiTransparent]}
+ >
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+export default FeatureTrainingModalCarouselBody;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx
new file mode 100644
index 000000000000..57d4f258a4b4
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import CheckboxWithLabel from '@components/CheckboxWithLabel';
+import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
+import OfflineIndicator from '@components/OfflineIndicator';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import FeatureTrainingModalTextContent from './FeatureTrainingModalTextContent';
+import type {FeatureTrainingModalContentProps as BaseFeatureTrainingModalContentProps, BaseFeatureTrainingModalProps} from './index';
+
+type FeatureTrainingModalContentProps = Pick<
+ BaseFeatureTrainingModalProps,
+ | 'helpText'
+ | 'onHelp'
+ | 'shouldCallOnHelpWhenModalHidden'
+ | 'helpSentryLabel'
+ | 'confirmSentryLabel'
+ | 'shouldShowDismissModalOption'
+ | 'shouldShowConfirmationLoader'
+ | 'canConfirmWhileOffline'
+ | 'titleStyles'
+ | 'contentInnerContainerStyles'
+ | 'contentOuterContainerStyles'
+ | 'shouldRenderHTMLDescription'
+ | 'children'
+> &
+ BaseFeatureTrainingModalContentProps & {
+ /** Whether the modal should be shown again (drives the dismiss checkbox state) */
+ willShowAgain: boolean;
+
+ /** Callback when the "Don't show me this again" option is toggled */
+ toggleWillShowAgain: () => void;
+
+ /** Callback to close the modal */
+ closeModal: (didPressHelpButton?: boolean) => void;
+
+ /** Callback when the user presses the confirm button */
+ confirmModal: () => void;
+
+ /** Whether to render a Back button (carousel mode, non-first pages) */
+ shouldShowBackButton?: boolean;
+
+ /** Callback when the Back button is pressed */
+ onBack?: () => void;
+ };
+
+function FeatureTrainingModalContent({
+ title = '',
+ subtitle = '',
+ description = '',
+ secondaryDescription = '',
+ confirmText,
+ helpText = '',
+ onHelp = () => {},
+ shouldCallOnHelpWhenModalHidden = false,
+ helpSentryLabel,
+ confirmSentryLabel,
+ shouldShowDismissModalOption = false,
+ willShowAgain,
+ toggleWillShowAgain,
+ closeModal,
+ confirmModal,
+ shouldShowBackButton = false,
+ onBack,
+ shouldShowConfirmationLoader = false,
+ canConfirmWhileOffline = true,
+ titleStyles,
+ contentInnerContainerStyles,
+ contentOuterContainerStyles,
+ shouldRenderHTMLDescription = false,
+ children,
+}: FeatureTrainingModalContentProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ return (
+
+
+ {children}
+
+ {shouldShowDismissModalOption && (
+
+ )}
+ {!!helpText && (
+
+ );
+}
+
+export default FeatureTrainingModalContent;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx
new file mode 100644
index 000000000000..52aa025ea005
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx
@@ -0,0 +1,179 @@
+import type {SourceLoadEventPayload} from 'expo-video';
+import type LottieView from 'lottie-react-native';
+import React, {useEffect, useRef, useState} from 'react';
+import {Image, View} from 'react-native';
+import type {ImageResizeMode, ImageSourcePropType} from 'react-native';
+import {GestureHandlerRootView} from 'react-native-gesture-handler';
+import ImageSVG from '@components/ImageSVG';
+import Lottie from '@components/Lottie';
+import LottieAnimations from '@components/LottieAnimations';
+import VideoPlayer from '@components/VideoPlayer';
+import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useNetwork from '@hooks/useNetwork';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import Accessibility from '@libs/Accessibility';
+import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
+import CONST from '@src/CONST';
+import type {FeatureTrainingModalIllustrationProps as BaseFeatureTrainingModalIllustrationProps, BaseFeatureTrainingModalProps} from './index';
+
+// Aspect ratio and height of the video.
+// Useful before video loads to reserve space.
+const VIDEO_ASPECT_RATIO = 1280 / 960;
+
+const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7;
+
+type VideoStatus = 'video' | 'animation';
+
+type FeatureTrainingModalIllustrationProps = Pick<
+ BaseFeatureTrainingModalProps,
+ 'shouldRenderSVG' | 'illustrationAspectRatio' | 'illustrationInnerContainerStyle' | 'illustrationOuterContainerStyle'
+> &
+ BaseFeatureTrainingModalIllustrationProps & {
+ /** Padding for the modal */
+ modalPadding: number;
+
+ /** Whether this illustration belongs to the currently-visible carousel page */
+ isFocused?: boolean;
+
+ /** Whether this illustration is part of a carousel */
+ isCarousel?: boolean;
+ };
+
+function FeatureTrainingModalIllustration({
+ animation,
+ animationStyle,
+ videoURL,
+ image,
+ contentFitImage,
+ imageWidth,
+ imageHeight,
+ illustrationAspectRatio: illustrationAspectRatioProp,
+ illustrationInnerContainerStyle,
+ illustrationOuterContainerStyle,
+ shouldRenderSVG = true,
+ modalPadding,
+ isFocused = true,
+ isCarousel = false,
+}: FeatureTrainingModalIllustrationProps) {
+ const styles = useThemeStyles();
+ const isReduceMotionEnabled = Accessibility.useReducedMotion();
+ const illustrations = useMemoizedLazyIllustrations(['Hands']);
+ const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
+ const {windowHeight, windowWidth} = useWindowDimensions();
+ const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO);
+ const {isOffline} = useNetwork();
+ const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
+
+ const animationRef = useRef(null);
+ useEffect(() => {
+ if (!isCarousel || !animationRef.current || isReduceMotionEnabled) {
+ return;
+ }
+ if (isFocused) {
+ animationRef.current.play(0);
+ } else {
+ animationRef.current.reset();
+ }
+ }, [isFocused, isCarousel, isReduceMotionEnabled]);
+
+ const [videoStatus, setVideoStatus] = useState('video');
+ const [isVideoStatusLocked, setIsVideoStatusLocked] = useState(false);
+
+ useEffect(() => {
+ if (isVideoStatusLocked) {
+ return;
+ }
+
+ if (isOffline) {
+ setVideoStatus('animation');
+ } else if (!isOffline) {
+ setVideoStatus('video');
+ setIsVideoStatusLocked(true);
+ }
+ }, [isOffline, isVideoStatusLocked]);
+
+ const setAspectRatio = (event: SourceLoadEventPayload) => {
+ const track = event.availableVideoTracks.at(0);
+ if (!track) {
+ return;
+ }
+ setIllustrationAspectRatio(track.size.width / track.size.height);
+ };
+
+ const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO;
+
+ return (
+
+
+ {!!image &&
+ (shouldRenderSVG ? (
+
+ ) : (
+
+ ))}
+ {!!videoURL && videoStatus === 'video' && (
+
+
+
+ )}
+ {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && (
+
+ {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+}
+
+export default FeatureTrainingModalIllustration;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalTextContent.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalTextContent.tsx
new file mode 100644
index 000000000000..512e3e3800d3
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalTextContent.tsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import {View} from 'react-native';
+import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native';
+import RenderHTML from '@components/RenderHTML';
+import Text from '@components/Text';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+type FeatureTrainingModalTextContentProps = {
+ /** Title for the modal */
+ title?: string | React.ReactNode;
+
+ /** Subtitle for the modal */
+ subtitle?: string;
+
+ /** Describe what is showing */
+ description?: string;
+
+ /** Secondary description rendered with additional space */
+ secondaryDescription?: string;
+
+ /** Style for the title */
+ titleStyles?: StyleProp;
+
+ /** Styles applied to the inner text container */
+ contentInnerContainerStyles?: StyleProp;
+
+ /** Whether description is HTML markup */
+ shouldRenderHTMLDescription?: boolean;
+
+ /** Children rendered below the description (single-page mode only) */
+ children?: React.ReactNode;
+
+ /** onLayout hook — used by the carousel probe to measure the tallest page */
+ onLayout?: (event: LayoutChangeEvent) => void;
+};
+
+function FeatureTrainingModalTextContent({
+ title = '',
+ subtitle = '',
+ description = '',
+ secondaryDescription = '',
+ titleStyles,
+ contentInnerContainerStyles,
+ shouldRenderHTMLDescription = false,
+ children,
+ onLayout,
+}: FeatureTrainingModalTextContentProps) {
+ const styles = useThemeStyles();
+ const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+
+ if (!title || !description) {
+ return null;
+ }
+
+ return (
+
+ {!!subtitle && {subtitle}}
+ {typeof title === 'string' ? {title} : title}
+ {shouldRenderHTMLDescription ? (
+
+
+
+ ) : (
+ {description}
+ )}
+ {!!secondaryDescription && {secondaryDescription}}
+ {children}
+
+ );
+}
+
+export default FeatureTrainingModalTextContent;
diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal/index.tsx
similarity index 56%
rename from src/components/FeatureTrainingModal.tsx
rename to src/components/FeatureTrainingModal/index.tsx
index 1e96566a5841..ccfb43ddf846 100644
--- a/src/components/FeatureTrainingModal.tsx
+++ b/src/components/FeatureTrainingModal/index.tsx
@@ -1,21 +1,19 @@
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';
+import {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 {LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, TextStyle, ViewStyle} from 'react-native';
import type {MergeExclusive} from 'type-fest';
+import type ImageSVGProps from '@components/ImageSVG/types';
+import type DotLottieAnimation from '@components/LottieAnimations/types';
+import Modal from '@components/Modal';
+import ScrollView from '@components/ScrollView';
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 Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
@@ -25,29 +23,11 @@ import {setNameValuePair} from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
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 Modal from './Modal';
-import OfflineIndicator from './OfflineIndicator';
-import RenderHTML from './RenderHTML';
-import ScrollView from './ScrollView';
-import Text from './Text';
-import VideoPlayer from './VideoPlayer';
-
-// Aspect ratio and height of the video.
-// Useful before video loads to reserve space.
-const VIDEO_ASPECT_RATIO = 1280 / 960;
+import FeatureTrainingModalBody from './FeatureTrainingModalBody';
+import FeatureTrainingModalCarouselBody from './FeatureTrainingModalCarouselBody';
const MODAL_PADDING = variables.spacing2;
-type VideoStatus = 'video' | 'animation';
-
type BaseFeatureTrainingModalProps = {
/** The aspect ratio to preserve for the icon, video or animation */
illustrationAspectRatio?: number;
@@ -58,24 +38,12 @@ type BaseFeatureTrainingModalProps = {
/** 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 the tutorial */
onConfirm?: (willShowAgain: boolean) => void;
@@ -97,7 +65,7 @@ type BaseFeatureTrainingModalProps = {
/** Styles for the modal inner container */
modalInnerContainerStyle?: ViewStyle;
- /** Children to show below title and description and above buttons */
+ /** Children to show below title and description and above buttons (single-page mode only) */
children?: React.ReactNode;
/** Modal width */
@@ -140,6 +108,23 @@ type BaseFeatureTrainingModalProps = {
confirmSentryLabel?: string;
};
+type FeatureTrainingModalContentProps = {
+ /** Title for the modal */
+ title?: string | React.ReactNode;
+
+ /** Subtitle for the modal */
+ subtitle?: string;
+
+ /** Describe what is showing */
+ description?: string;
+
+ /** Secondary description rendered with additional space */
+ secondaryDescription?: string;
+
+ /** Text to show on primary button */
+ confirmText: string;
+};
+
type FeatureTrainingModalVideoProps = {
/** Animation to show when video is unavailable. Useful when app is offline */
animation?: DotLottieAnimation;
@@ -165,22 +150,32 @@ type FeatureTrainingModalSVGProps = {
imageHeight?: ImageSVGProps['height'];
};
-// This page requires either an icon or a video/animation, but not both
-type FeatureTrainingModalProps = BaseFeatureTrainingModalProps & MergeExclusive;
+// This page requires either an icon or a video/animation, but not both.
+type FeatureTrainingModalIllustrationProps = MergeExclusive;
+
+type FeatureTrainingModalPageProps = FeatureTrainingModalIllustrationProps & FeatureTrainingModalContentProps;
-const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7;
+type FeatureTrainingModalCarouselProps = {
+ /**
+ * When provided (and length > 1), the modal renders a horizontal paging carousel.
+ * The primary button advances to the next page until the last page, where it fires `onConfirm`.
+ */
+ pages: FeatureTrainingModalPageProps[];
+
+ /** Called when the user swipes to a different page. */
+ onPageChange?: (index: number) => void;
+};
+
+// Either single-page content fields OR carousel pages, but not both.
+type FeatureTrainingModalProps = BaseFeatureTrainingModalProps & MergeExclusive;
function FeatureTrainingModal({
- animation,
- animationStyle,
illustrationInnerContainerStyle,
illustrationOuterContainerStyle,
- videoURL,
illustrationAspectRatio: illustrationAspectRatioProp,
- image,
- contentFitImage,
width = variables.featureTrainingModalWidth,
title = '',
+ subtitle = '',
description = '',
secondaryDescription = '',
titleStyles,
@@ -194,8 +189,6 @@ function FeatureTrainingModal({
contentInnerContainerStyles,
contentOuterContainerStyles,
modalInnerContainerStyle,
- imageWidth,
- imageHeight,
isModalDisabled = true,
shouldRenderSVG = true,
shouldRenderHTMLDescription = false,
@@ -208,21 +201,16 @@ function FeatureTrainingModal({
shouldCallOnHelpWhenModalHidden = false,
helpSentryLabel,
confirmSentryLabel,
+ pages,
+ onPageChange,
+ ...props
}: FeatureTrainingModalProps) {
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 [isModalVisible, setIsModalVisible] = useState(false);
const [willShowAgain, setWillShowAgain] = useState(true);
- const [videoStatus, setVideoStatus] = useState('video');
- const [isVideoStatusLocked, setIsVideoStatusLocked] = useState(false);
- const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO);
- const {shouldUseNarrowLayout} = useResponsiveLayout();
- const {isOffline} = useNetwork();
const hasHelpButtonBeenPressed = useRef(false);
const pendingCloseRef = useRef(false);
const scrollViewRef = useRef(null);
@@ -232,6 +220,8 @@ function FeatureTrainingModal({
const {isKeyboardActive} = useKeyboardState();
const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
+ const isCarousel = !!pages && pages.length > 1;
+
const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode;
useEffect(() => {
@@ -249,97 +239,6 @@ function FeatureTrainingModal({
return () => handle.cancel();
}, [isModalDisabled]);
- useEffect(() => {
- if (isVideoStatusLocked) {
- return;
- }
-
- if (isOffline) {
- setVideoStatus('animation');
- } else if (!isOffline) {
- setVideoStatus('video');
- setIsVideoStatusLocked(true);
- }
- }, [isOffline, isVideoStatusLocked]);
-
- 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 pendingCloseModalAction = () => {
@@ -356,7 +255,10 @@ function FeatureTrainingModal({
}
};
- const closeModal = () => {
+ const closeModal = (didPressHelpButton?: boolean) => {
+ if (didPressHelpButton) {
+ hasHelpButtonBeenPressed.current = true;
+ }
Log.hmmm(`[FeatureTrainingModal] closeModal called - willShowAgain: ${willShowAgain}, shouldGoBack: ${shouldGoBack}, hasOnClose: ${!!onClose}`);
if (!willShowAgain) {
@@ -446,69 +348,67 @@ function FeatureTrainingModal({
// 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 && (
- {
- if (shouldCallOnHelpWhenModalHidden) {
- setIsModalVisible(false);
- hasHelpButtonBeenPressed.current = true;
- return;
- }
- onHelp();
- }}
- text={helpText}
- sentryLabel={helpSentryLabel}
- />
- )}
-
- {!canConfirmWhileOffline && }
-
+ ) : (
+
+ {children}
+
+ )}
);
@@ -516,4 +416,11 @@ function FeatureTrainingModal({
export default FeatureTrainingModal;
-export type {FeatureTrainingModalProps};
+export type {
+ BaseFeatureTrainingModalProps,
+ FeatureTrainingModalProps,
+ FeatureTrainingModalCarouselProps,
+ FeatureTrainingModalContentProps,
+ FeatureTrainingModalPageProps,
+ FeatureTrainingModalIllustrationProps,
+};
diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx
index dbdd4a65cf0c..500a7dc08442 100644
--- a/src/components/Lottie/index.tsx
+++ b/src/components/Lottie/index.tsx
@@ -1,6 +1,7 @@
import {NavigationContainerRefContext, NavigationContext} from '@react-navigation/native';
import type {AnimationObject, LottieViewProps} from 'lottie-react-native';
import LottieView from 'lottie-react-native';
+import type {ForwardedRef} from 'react';
import React, {useContext, useEffect, useRef, useState} from 'react';
import {View} from 'react-native';
import type DotLottieAnimation from '@components/LottieAnimations/types';
@@ -15,11 +16,12 @@ import CONST from '@src/CONST';
import {useSplashScreenState} from '@src/SplashScreenStateContext';
type Props = {
+ ref?: ForwardedRef;
source: DotLottieAnimation;
shouldLoadAfterInteractions?: boolean;
} & Omit;
-function Lottie({source, webStyle, shouldLoadAfterInteractions, ...props}: Props) {
+function Lottie({ref, source, webStyle, shouldLoadAfterInteractions, ...props}: Props) {
const animationRef = useRef(null);
const appState = useAppState();
const {splashScreenState} = useSplashScreenState();
@@ -121,8 +123,14 @@ function Lottie({source, webStyle, shouldLoadAfterInteractions, ...props}: Props
key={`${hasNavigatedAway}`}
ref={(newRef) => {
animationRef.current = newRef;
+ if (typeof ref === 'function') {
+ ref(newRef);
+ } else if (ref && 'current' in ref) {
+ // eslint-disable-next-line no-param-reassign
+ ref.current = newRef;
+ }
}}
- autoPlay={!isReduceMotionEnabled}
+ autoPlay={props.autoPlay && !isReduceMotionEnabled}
style={[aspectRatioStyle, props.style]}
webStyle={{...aspectRatioStyle, ...webStyle}}
onAnimationFailure={() => setIsError(true)}
diff --git a/src/components/LottieAnimations/index.tsx b/src/components/LottieAnimations/index.tsx
index b86a3f86ec13..a5cc9af24cd6 100644
--- a/src/components/LottieAnimations/index.tsx
+++ b/src/components/LottieAnimations/index.tsx
@@ -94,6 +94,22 @@ const DotLottieAnimations = {
w: 204,
h: 204,
},
+ SpendAnalysis: {
+ file: require('@assets/animations/SpendAnalysis.lottie'),
+ w: 440,
+ h: 240,
+ backgroundColor: colors.pink700,
+ },
+ ExpenseAssistant: {
+ file: require('@assets/animations/ExpenseAssistant.lottie'),
+ w: 440,
+ h: 240,
+ },
+ CustomAgents: {
+ file: require('@assets/animations/CustomAgents.lottie'),
+ w: 440,
+ h: 240,
+ },
} satisfies Record;
export default DotLottieAnimations;
diff --git a/src/components/Search/SearchRouter/SearchRouterContext.tsx b/src/components/Search/SearchRouter/SearchRouterContext.tsx
index 165c638a3475..3376618195fb 100644
--- a/src/components/Search/SearchRouter/SearchRouterContext.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterContext.tsx
@@ -97,9 +97,6 @@ function SearchRouterContextProvider({children}: ChildrenProps) {
const openSearchRouter = (query?: string) => {
pendingRouterQuery = query ?? '';
- if (isBrowserWithHistory) {
- window.history.pushState({isSearchModalOpen: true} satisfies HistoryState, '');
- }
startSpan(CONST.TELEMETRY.SPAN_SEARCH_ROUTER_MODAL_CLOSE_WAIT, {
name: CONST.TELEMETRY.SPAN_SEARCH_ROUTER_MODAL_CLOSE_WAIT,
op: 'ui.modal.wait',
@@ -108,6 +105,14 @@ function SearchRouterContextProvider({children}: ChildrenProps) {
close(
() => {
endSpan(CONST.TELEMETRY.SPAN_SEARCH_ROUTER_MODAL_CLOSE_WAIT);
+ // Push the history entry only after pre-existing modals have finished closing —
+ // some modal close paths (e.g. FeatureTrainingModal with shouldGoBack=true) fire
+ // Navigation.goBack(), which pops history. If we pushState'd before close(),
+ // that pop would consume our entry and the resulting popstate listener would
+ // immediately mark the search router as closed.
+ if (isBrowserWithHistory) {
+ window.history.pushState({isSearchModalOpen: true} satisfies HistoryState, '');
+ }
startListRenderSpan();
openSearch(setIsSearchRouterDisplayed);
searchRouterDisplayedRef.current = true;
diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx
index b9d210735278..f951af2dc4d6 100644
--- a/src/components/Section/index.tsx
+++ b/src/components/Section/index.tsx
@@ -166,6 +166,7 @@ function Section({
style={styles.h100}
webStyle={styles.h100}
loop
+ autoPlay
shouldLoadAfterInteractions={shouldUseNarrowLayout}
/>
) : (
diff --git a/src/languages/de.ts b/src/languages/de.ts
index adf0efaa4e16..37bf76d67d77 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -9889,5 +9889,21 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`,
negativeButton: 'Nicht wirklich',
},
monthPickerPage: {month: 'Monat', selectMonth: 'Bitte wählen Sie einen Monat aus'},
+ aiFeaturesPromoModal: {
+ subtitle: 'Neu bei Concierge AI',
+ confirmText: 'Los geht’s!',
+ spendAnalysis: {
+ title: 'Interaktive Ausgabenanalyse',
+ description: `Concierge zeigt monatliche Ausgabenanalysen an und ermöglicht es Ihnen, die Details hinter jeder Zahl genauer zu betrachten. Mehr erfahren.`,
+ },
+ expenseAssistant: {
+ title: 'Lernen Sie Ihre neue Spesenassistenz kennen',
+ description: `Chatten Sie mit Concierge, um Ausgaben direkt in der App oder per E-Mail oder SMS zu erstellen und zu aktualisieren. Mehr erfahren.`,
+ },
+ customAgents: {
+ title: 'Erstellen Sie Ihre eigenen Agenten',
+ description: `Erstellen Sie benutzerdefinierte Agenten, die Ausgaben anhand Ihrer Regeln prüfen, genehmigen und weiterleiten. Mehr erfahren.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 3f62abc1c451..f2eb40b0e731 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -9650,6 +9650,22 @@ const translations = {
chat: 'Chat on any expense to resolve questions quickly',
},
},
+ aiFeaturesPromoModal: {
+ subtitle: 'New to Concierge AI',
+ confirmText: "Let's go!",
+ spendAnalysis: {
+ title: 'Interactive spend analysis',
+ description: `Concierge surfaces monthly spend insights and lets you drill into the details behind every number. Learn more.`,
+ },
+ expenseAssistant: {
+ title: 'Meet your new expense assistant',
+ description: `Chat with Concierge to create and update expenses, right in the app or by email or text. Learn more.`,
+ },
+ customAgents: {
+ title: 'Build your own agents',
+ description: `Create custom agents to review, approve, and route expenses based on rules you set. Learn more.`,
+ },
+ },
productTrainingTooltip: {
// TODO: CONCIERGE_LHN_GBR tooltip will be replaced by a tooltip in the #admins room
// https://github.com/Expensify/App/issues/57045#issuecomment-2701455668
diff --git a/src/languages/es.ts b/src/languages/es.ts
index b5e71c16e493..be4ceb4ee610 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -10130,5 +10130,21 @@ ${amount} para ${merchant} - ${date}`,
lockScreenTrackingText: 'Siguiendo...',
},
},
+ aiFeaturesPromoModal: {
+ subtitle: 'Nuevo en Concierge AI',
+ confirmText: '¡Vamos!',
+ spendAnalysis: {
+ title: 'Análisis interactivo del gasto',
+ description: `Concierge muestra información mensual sobre gastos y te permite profundizar en los detalles detrás de cada cifra. Más información.`,
+ },
+ expenseAssistant: {
+ title: 'Conoce a tu nuevo asistente de gastos',
+ description: `Chatea con Concierge para crear y actualizar gastos, directamente en la aplicación o por correo electrónico o mensaje de texto. Más información.`,
+ },
+ customAgents: {
+ title: 'Crea tus propios agentes',
+ description: `Crea agentes personalizados para revisar, aprobar y asignar gastos según las reglas que configures. Más información.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 9c72ab439b7b..e7aec90fcf27 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -9925,5 +9925,21 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`,
negativeButton: 'Pas vraiment',
},
monthPickerPage: {month: 'Mois', selectMonth: 'Veuillez sélectionner un mois'},
+ aiFeaturesPromoModal: {
+ subtitle: 'Nouveau dans Concierge IA',
+ confirmText: 'Allons-y !',
+ spendAnalysis: {
+ title: 'Analyse interactive des dépenses',
+ description: `Concierge met en avant des analyses mensuelles des dépenses et vous permet d’examiner en détail chaque chiffre. En savoir plus.`,
+ },
+ expenseAssistant: {
+ title: 'Découvrez votre nouvel assistant de dépenses',
+ description: `Discutez avec Concierge pour créer et mettre à jour des dépenses, directement dans l’application ou par e-mail ou SMS. En savoir plus.`,
+ },
+ customAgents: {
+ title: 'Créez vos propres agents',
+ description: `Créez des agents personnalisés pour examiner, approuver et acheminer les dépenses selon les règles que vous définissez. En savoir plus.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 179bf66e10f4..32c03fa461f7 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -9879,5 +9879,21 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`,
negativeButton: 'Non proprio',
},
monthPickerPage: {month: 'Mese', selectMonth: 'Seleziona un mese'},
+ aiFeaturesPromoModal: {
+ subtitle: 'Nuovo in Concierge AI',
+ confirmText: 'Andiamo!',
+ spendAnalysis: {
+ title: 'Analisi interattiva delle spese',
+ description: `Concierge mette in evidenza approfondimenti sulla spesa mensile e ti permette di approfondire i dettagli dietro ogni numero. Scopri di più.`,
+ },
+ expenseAssistant: {
+ title: 'Incontra il tuo nuovo assistente per le spese',
+ description: `Chatta con Concierge per creare e aggiornare le spese direttamente nell’app o via email o SMS. Scopri di più.`,
+ },
+ customAgents: {
+ title: 'Crea i tuoi agenti',
+ description: `Crea agenti personalizzati per verificare, approvare e instradare le spese in base alle regole che imposti. Scopri di più.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 1fda90299a72..727eaa34d5b9 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -9749,5 +9749,21 @@ ${reportName}`,
negativeButton: 'そうでもありません',
},
monthPickerPage: {month: '月', selectMonth: '月を選択してください'},
+ aiFeaturesPromoModal: {
+ subtitle: 'はじめての Concierge AI',
+ confirmText: '始めましょう!',
+ spendAnalysis: {
+ title: 'インタラクティブな支出分析',
+ description: `Concierge は毎月の支出インサイトを提示し、すべての数値の内訳を詳しく確認できるようにします。詳しく見る。`,
+ },
+ expenseAssistant: {
+ title: '新しい経費アシスタントをご紹介します',
+ description: `アプリ内やメール、テキストメッセージでConciergeとチャットして、経費を作成・更新できます。詳しく見る。`,
+ },
+ customAgents: {
+ title: '独自のエージェントを作成する',
+ description: `設定したルールに基づいて経費を確認、承認、振り分けるカスタムエージェントを作成できます。さらに詳しく。`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index e87b17360733..7c1791147e79 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -9842,5 +9842,21 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`,
negativeButton: 'Niet echt',
},
monthPickerPage: {month: 'Maand', selectMonth: 'Selecteer een maand'},
+ aiFeaturesPromoModal: {
+ subtitle: 'Nieuw bij Concierge AI',
+ confirmText: 'Laten we gaan!',
+ spendAnalysis: {
+ title: 'Interactieve uitgavenanalyse',
+ description: `Concierge toont maandelijkse uitgaveninzichten en laat je inzoomen op de details achter elk getal. Meer informatie.`,
+ },
+ expenseAssistant: {
+ title: 'Maak kennis met je nieuwe declaratie-assistent',
+ description: `Chat met Concierge om uitgaven aan te maken en bij te werken, rechtstreeks in de app of via e-mail of sms. Meer informatie.`,
+ },
+ customAgents: {
+ title: 'Bouw je eigen agents',
+ description: `Maak aangepaste agents om uitgaven te beoordelen, goed te keuren en door te sturen op basis van regels die jij instelt. Meer informatie.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index b236e88f4d70..fd40b3d31436 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -9825,5 +9825,21 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`,
negativeButton: 'Niekoniecznie',
},
monthPickerPage: {month: 'Miesiąc', selectMonth: 'Wybierz miesiąc'},
+ aiFeaturesPromoModal: {
+ subtitle: 'Nowość w Concierge AI',
+ confirmText: 'Jedziemy!',
+ spendAnalysis: {
+ title: 'Interaktywna analiza wydatków',
+ description: `Concierge przedstawia miesięczne informacje o wydatkach i pozwala zagłębić się w szczegóły stojące za każdą liczbą. Dowiedz się więcej.`,
+ },
+ expenseAssistant: {
+ title: 'Poznaj swojego nowego asystenta wydatków',
+ description: `Rozmawiaj z Concierge, aby tworzyć i aktualizować wydatki bezpośrednio w aplikacji, e-mailem lub SMS-em. Dowiedz się więcej.`,
+ },
+ customAgents: {
+ title: 'Zbuduj własne agentów',
+ description: `Twórz niestandardowych agentów do przeglądania, zatwierdzania i kierowania wydatków na podstawie ustalonych przez siebie zasad. Dowiedz się więcej.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 011d86edb647..2ec8e401a2ef 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -9831,5 +9831,21 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`,
negativeButton: 'Na verdade, não',
},
monthPickerPage: {month: 'Mês', selectMonth: 'Selecione um mês por favor'},
+ aiFeaturesPromoModal: {
+ subtitle: 'Novo no Concierge AI',
+ confirmText: 'Vamos lá!',
+ spendAnalysis: {
+ title: 'Análise interativa de gastos',
+ description: `O Concierge apresenta insights mensais de gastos e permite que você aprofunde nos detalhes por trás de cada número. Saiba mais.`,
+ },
+ expenseAssistant: {
+ title: 'Conheça seu novo assistente de despesas',
+ description: `Converse com o Concierge para criar e atualizar despesas, direto no app ou por e-mail ou SMS. Saiba mais.`,
+ },
+ customAgents: {
+ title: 'Crie seus próprios agentes',
+ description: `Crie agentes personalizados para revisar, aprovar e direcionar despesas com base nas regras que você definir. Saiba mais.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 378553929223..c2c6919bd921 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -9562,5 +9562,21 @@ ${reportName}`,
},
proactiveAppReview: {title: '喜欢全新的 Expensify 吗?', description: '请告诉我们,这样我们就能帮助您让报销体验变得更好。', positiveButton: '太棒了!', negativeButton: '不太是'},
monthPickerPage: {month: '月份', selectMonth: '请选择月份'},
+ aiFeaturesPromoModal: {
+ subtitle: 'Concierge AI 新手指南',
+ confirmText: '出发吧!',
+ spendAnalysis: {
+ title: '交互式支出分析',
+ description: `Concierge 会提供每月支出洞察,并让你深入查看每个数字背后的详细信息。了解详情。`,
+ },
+ expenseAssistant: {
+ title: '认识你的全新报销助手',
+ description: `在应用内或通过电子邮件、短信与 Concierge 聊天来创建和更新报销。了解更多。`,
+ },
+ customAgents: {
+ title: '构建你自己的代理',
+ description: `创建自定义代理,根据你设置的规则审核、批准和分配报销。了解更多。`,
+ },
+ },
};
export default translations;
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 1b4ac7b2c2aa..571ef4c9402f 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -56,6 +56,7 @@ import DelegatorConnectGuard from './DelegatorConnectGate';
import hideKeyboardOnSwipe from './hideKeyboardOnSwipe';
import KeyboardShortcutsHandler from './KeyboardShortcutsHandler';
import {ShareModalStackNavigator} from './ModalStackNavigators';
+import AIFeaturesPromoModalNavigator from './Navigators/AIFeaturesPromoModalNavigator';
import FeatureTrainingModalNavigator from './Navigators/FeatureTrainingModalNavigator';
import MigratedUserWelcomeModalNavigator from './Navigators/MigratedUserWelcomeModalNavigator';
import MultifactorAuthenticationModalNavigator from './Navigators/MultifactorAuthenticationModalNavigator';
@@ -306,6 +307,11 @@ function AuthScreens() {
options={rootNavigatorScreenOptions.basicModalNavigator}
component={MigratedUserWelcomeModalNavigator}
/>
+
();
+
+function AIFeaturesPromoModalNavigator() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default AIFeaturesPromoModalNavigator;
diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
new file mode 100644
index 000000000000..cfdb72079d64
--- /dev/null
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -0,0 +1,268 @@
+import type {NavigationAction, NavigationState, PartialState} from '@react-navigation/native';
+import {findFocusedRoute} from '@react-navigation/native';
+import {isActingAsDelegateSelector} from '@selectors/Account';
+import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding';
+import Onyx from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
+import Log from '@libs/Log';
+import Navigation from '@libs/Navigation/Navigation';
+import navigationRef from '@libs/Navigation/navigationRef';
+import TransitionTracker from '@libs/Navigation/TransitionTracker';
+import isProductTrainingElementDismissed from '@libs/TooltipUtils';
+import CONST from '@src/CONST';
+import NAVIGATORS from '@src/NAVIGATORS';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
+import type {DismissedProductTraining, Onboarding, Session} from '@src/types/onyx';
+import type {GuardResult, NavigationGuard} from './types';
+
+let session: OnyxEntry;
+let isLoadingApp = true;
+
+let dismissedProductTraining: OnyxEntry;
+let isDismissedProductTrainingLoaded = false;
+
+let hasBeenAddedToNudgeMigration = false;
+let isTryNewDotLoaded = false;
+
+let onboarding: OnyxEntry;
+let isOnboardingLoaded = false;
+
+// A copilot (delegate) session must never re-prompt the host user's one-time AI promo —
+// `clearOnyxForDelegateTransition` wipes most NVPs when switching accounts (NVP_DISMISSED_PRODUCT_TRAINING
+// is not in `KEYS_TO_PRESERVE_DELEGATE_ACCESS`), so without this gate the modal would re-show
+// for every copilot switch. Mirrors `ProductTrainingContext`'s `isActingAsDelegate` short-circuit.
+let isActingAsDelegate = false;
+
+let hasRedirectedToAIFeaturesPromoModal = false;
+let isWaitingForProtectedRoutes = false;
+
+/**
+ * This modal must not appear in the same session as the migration welcome modal, the onboarding flow,
+ * or the HybridApp explanation modal. These flags trip when we observe any of those navigators mounting
+ * during this process lifetime, and suppress the AI promo for the rest of the session.
+ */
+let observedActiveMigrationModalThisSession = false;
+let observedActiveOnboardingThisSession = false;
+
+function containsNavigator(state: NavigationState | PartialState | undefined, navigatorName: string): boolean {
+ if (!state?.routes) {
+ return false;
+ }
+ return state.routes.some((route) => route.name === navigatorName || containsNavigator(route.state, navigatorName));
+}
+
+function snapshotActiveModalsFromNavigationState() {
+ if (!navigationRef.isReady?.()) {
+ return;
+ }
+ const rootState = navigationRef.getRootState?.();
+ if (!rootState) {
+ return;
+ }
+ if (containsNavigator(rootState, NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR)) {
+ observedActiveOnboardingThisSession = true;
+ }
+ if (containsNavigator(rootState, NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR)) {
+ observedActiveMigrationModalThisSession = true;
+ }
+}
+
+// Attach lazily — the listener can only be added once react-navigation's ref is ready,
+// which generally happens after this module is loaded.
+let isStateListenerAttached = false;
+function attachNavigationStateListener() {
+ if (isStateListenerAttached || !navigationRef.isReady?.()) {
+ return;
+ }
+ isStateListenerAttached = true;
+ snapshotActiveModalsFromNavigationState();
+ navigationRef.addListener('state', snapshotActiveModalsFromNavigationState);
+}
+
+function isEligibleToShowAIFeaturesPromoModal(): boolean {
+ return (
+ !!session?.authToken &&
+ !isLoadingApp &&
+ !isActingAsDelegate &&
+ !hasRedirectedToAIFeaturesPromoModal &&
+ isDismissedProductTrainingLoaded &&
+ isTryNewDotLoaded &&
+ isOnboardingLoaded &&
+ !isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) &&
+ !observedActiveMigrationModalThisSession &&
+ !observedActiveOnboardingThisSession
+ );
+}
+
+/**
+ * Proactively navigate to the AI features promo modal when all conditions are met.
+ */
+function navigateToAIFeaturesPromoModalIfReady() {
+ // Sync the modal-active flags from the current navigation state (and attach a listener
+ // for future state changes) before checking, so a freshly-mounted onboarding navigator
+ // is reflected immediately.
+ attachNavigationStateListener();
+ snapshotActiveModalsFromNavigationState();
+
+ if (isWaitingForProtectedRoutes || !isEligibleToShowAIFeaturesPromoModal()) {
+ return;
+ }
+
+ isWaitingForProtectedRoutes = true;
+ // Defer until any in-flight navigation transition (splash → home, etc.)
+ // has fully settled, then wait for the protected stack to be in the nav tree.
+ TransitionTracker.runAfterTransitions({
+ callback: () => {
+ Navigation.waitForProtectedRoutes().then(() => {
+ isWaitingForProtectedRoutes = false;
+ snapshotActiveModalsFromNavigationState();
+ if (!isEligibleToShowAIFeaturesPromoModal()) {
+ return;
+ }
+ Log.info('[AIFeaturesPromoGuard] Proactively navigating to AI features promo modal');
+ hasRedirectedToAIFeaturesPromoModal = true;
+ Navigation.navigate(ROUTES.AI_FEATURES_PROMO_MODAL);
+ });
+ },
+ waitForUpcomingTransition: true,
+ });
+}
+
+/**
+ * Called by guards/index.ts when session or loading app state changes.
+ * Reuses the shared Onyx subscriptions from guards/index.ts to avoid duplicate connections.
+ */
+function onSessionOrLoadingAppChanged(sessionValue: OnyxEntry, isLoadingAppValue: boolean) {
+ session = sessionValue;
+ isLoadingApp = isLoadingAppValue;
+ navigateToAIFeaturesPromoModalIfReady();
+}
+
+Onyx.connectWithoutView({
+ key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
+ callback: (value) => {
+ dismissedProductTraining = value;
+ isDismissedProductTrainingLoaded = true;
+ if (isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, value)) {
+ hasRedirectedToAIFeaturesPromoModal = false;
+ }
+ // If the migration welcome modal is currently still pending, suppress AI promo this session.
+ if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, value)) {
+ observedActiveMigrationModalThisSession = true;
+ }
+ navigateToAIFeaturesPromoModalIfReady();
+ },
+});
+
+Onyx.connectWithoutView({
+ key: ONYXKEYS.NVP_ONBOARDING,
+ callback: (value) => {
+ onboarding = value;
+ isOnboardingLoaded = true;
+ if (hasCompletedGuidedSetupFlowSelector(onboarding) === false) {
+ observedActiveOnboardingThisSession = true;
+ }
+ navigateToAIFeaturesPromoModalIfReady();
+ },
+});
+
+Onyx.connectWithoutView({
+ key: ONYXKEYS.NVP_TRY_NEW_DOT,
+ callback: (value) => {
+ const result = value ? tryNewDotOnyxSelector(value) : undefined;
+ hasBeenAddedToNudgeMigration = result?.hasBeenAddedToNudgeMigration ?? false;
+ isTryNewDotLoaded = true;
+ if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, dismissedProductTraining)) {
+ observedActiveMigrationModalThisSession = true;
+ }
+ navigateToAIFeaturesPromoModalIfReady();
+ },
+});
+
+Onyx.connectWithoutView({
+ key: ONYXKEYS.ACCOUNT,
+ callback: (value) => {
+ isActingAsDelegate = isActingAsDelegateSelector(value);
+ navigateToAIFeaturesPromoModalIfReady();
+ },
+});
+
+/**
+ * Block navigation while the AI features promo modal is active (on top of the stack).
+ *
+ * The block exists only to keep an underlying in-app tab swipe from racing the modal overlay
+ * (the original use case mirrored from MigratedUserWelcomeModalGuard). It must NOT swallow
+ * top-level intents that come from outside the app — share intents and report deep-links arrive
+ * as `RESET` actions (via `getAdaptedStateFromPath`) or as `NAVIGATE`/`PUSH` actions whose
+ * `payload.name` is a sibling root-stack navigator (e.g. `SHARE_MODAL_NAVIGATOR`). Blocking
+ * those silently drops the navigation and strands the user on the modal.
+ */
+function shouldBlockWhileModalActive(state: NavigationState, action: NavigationAction): boolean {
+ if (
+ !hasRedirectedToAIFeaturesPromoModal ||
+ isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) ||
+ state.routes.at(-1)?.name !== NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR
+ ) {
+ return false;
+ }
+
+ // Internal modal-close actions always allowed.
+ if (action.type === CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL || action.type === CONST.NAVIGATION.ACTION_TYPE.GO_BACK) {
+ return false;
+ }
+
+ // RESET actions are how deep links / share intents enter the app — never block them.
+ if (action.type === 'RESET') {
+ return false;
+ }
+
+ // For NAVIGATE/PUSH/REPLACE actions, only block if the target lives inside the AI promo
+ // navigator (i.e. an in-modal sub-route). If the target is any other navigator/screen the
+ // navigation should proceed — the new screen will overlay or replace our modal naturally.
+ const targetName = (action.payload as {name?: string} | undefined)?.name;
+ if (targetName && targetName !== NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR) {
+ return false;
+ }
+
+ return true;
+}
+
+/** Prevents redirect loops by detecting when we're already on or resetting to the modal. */
+function isNavigatingToAIFeaturesPromoModal(state: NavigationState, action: NavigationAction): boolean {
+ const isOnModal = findFocusedRoute(state)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.ROOT;
+ const isResettingToModal = action.type === 'RESET' && !!action.payload && findFocusedRoute(action.payload as NavigationState)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.ROOT;
+
+ return isOnModal || isResettingToModal;
+}
+
+/**
+ * AIFeaturesPromoGuard surfaces the one-time AI features promo modal.
+ *
+ * This guard relies on the proactive Onyx-driven path (navigateToAIFeaturesPromoModalIfReady)
+ * rather than redirecting from evaluate(), because it needs to wait for higher-priority guards
+ * (Onboarding, MigratedUserWelcomeModal) to settle before deciding whether to fire.
+ */
+const AIFeaturesPromoGuard: NavigationGuard = {
+ name: 'AIFeaturesPromoGuard',
+
+ evaluate: (state: NavigationState, action: NavigationAction, context): GuardResult => {
+ if (context.isLoading) {
+ return {type: 'ALLOW'};
+ }
+
+ if (shouldBlockWhileModalActive(state, action)) {
+ return {type: 'BLOCK', reason: '[AIFeaturesPromoGuard] Blocking navigation while AI features promo modal is active'};
+ }
+
+ if (isNavigatingToAIFeaturesPromoModal(state, action) || hasRedirectedToAIFeaturesPromoModal) {
+ return {type: 'ALLOW'};
+ }
+
+ return {type: 'ALLOW'};
+ },
+};
+
+export default AIFeaturesPromoGuard;
+export {onSessionOrLoadingAppChanged};
diff --git a/src/libs/Navigation/guards/index.ts b/src/libs/Navigation/guards/index.ts
index fdc43fd6440a..783b8be533b3 100644
--- a/src/libs/Navigation/guards/index.ts
+++ b/src/libs/Navigation/guards/index.ts
@@ -4,7 +4,8 @@ import type {OnyxEntry} from 'react-native-onyx';
import getCurrentUrl from '@libs/Navigation/currentUrl';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Session} from '@src/types/onyx';
-import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged} from './MigratedUserWelcomeModalGuard';
+import AIFeaturesPromoGuard, {onSessionOrLoadingAppChanged as onAIFeaturesPromoSessionOrLoadingAppChanged} from './AIFeaturesPromoGuard';
+import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged as onMigratedUserWelcomeModalSessionOrLoadingAppChanged} from './MigratedUserWelcomeModalGuard';
import OnboardingGuard from './OnboardingGuard';
import type {GuardContext, GuardResult, NavigationGuard} from './types';
@@ -19,7 +20,8 @@ Onyx.connectWithoutView({
key: ONYXKEYS.SESSION,
callback: (value) => {
session = value;
- onSessionOrLoadingAppChanged(session, isLoadingApp);
+ onMigratedUserWelcomeModalSessionOrLoadingAppChanged(session, isLoadingApp);
+ onAIFeaturesPromoSessionOrLoadingAppChanged(session, isLoadingApp);
},
});
@@ -27,7 +29,8 @@ Onyx.connectWithoutView({
key: ONYXKEYS.IS_LOADING_APP,
callback: (value) => {
isLoadingApp = value ?? true;
- onSessionOrLoadingAppChanged(session, isLoadingApp);
+ onMigratedUserWelcomeModalSessionOrLoadingAppChanged(session, isLoadingApp);
+ onAIFeaturesPromoSessionOrLoadingAppChanged(session, isLoadingApp);
},
});
@@ -103,5 +106,6 @@ function clearGuards(): void {
registerGuard(OnboardingGuard);
registerGuard(MigratedUserWelcomeModalGuard);
+registerGuard(AIFeaturesPromoGuard);
export {registerGuard, createGuardContext, evaluateGuards, getRegisteredGuards, clearGuards};
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 962d7b5c59fb..ea703901268c 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -53,6 +53,15 @@ const config: LinkingOptions['config'] = {
},
},
+ [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: {
+ screens: {
+ [SCREENS.AI_FEATURES_PROMO_MODAL.ROOT]: {
+ path: ROUTES.AI_FEATURES_PROMO_MODAL,
+ exact: true,
+ },
+ },
+ },
+
[NAVIGATORS.TEST_DRIVE_MODAL_NAVIGATOR]: {
screens: {
[SCREENS.TEST_DRIVE_MODAL.ROOT]: {
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index b69da1026616..4cfaa093c44f 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -2931,6 +2931,10 @@ type MigratedUserModalNavigatorParamList = {
[SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT]: undefined;
};
+type AIFeaturesPromoModalNavigatorParamList = {
+ [SCREENS.AI_FEATURES_PROMO_MODAL.ROOT]: undefined;
+};
+
type TestDriveModalNavigatorParamList = {
[SCREENS.TEST_DRIVE_MODAL.ROOT]: {
bossEmail?: string;
@@ -3105,6 +3109,7 @@ type AuthScreensParamList = SharedScreensParamList &
[NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.FEATURE_TRAINING_MODAL_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR]: NavigatorScreenParams;
+ [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.TEST_DRIVE_MODAL_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.TEST_DRIVE_DEMO_NAVIGATOR]: NavigatorScreenParams;
[SCREENS.CONNECTION_COMPLETE]: undefined;
@@ -3386,6 +3391,7 @@ export type {
WorkspaceSplitNavigatorParamList,
WorkspaceNavigatorParamList,
MigratedUserModalNavigatorParamList,
+ AIFeaturesPromoModalNavigatorParamList,
WorkspaceConfirmationNavigatorParamList,
WorkspaceDuplicateNavigatorParamList,
PolicyCopySettingsNavigatorParamList,
diff --git a/src/styles/index.ts b/src/styles/index.ts
index d6ee11a23678..94b55443fb79 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5890,6 +5890,12 @@ const staticStyles = (theme: ThemeColors) =>
borderTopRightRadius: variables.componentBorderRadiusLarge,
},
+ featureTrainingModalNavButtons: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ gap: variables.spacing2,
+ },
+
twoColumnLayoutCol: {
flexGrow: 1,
flexShrink: 1,
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index bf057c634fd8..cbcb7e399dde 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -2112,6 +2112,15 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
styleObj[key] = null;
return styleObj;
}, {} as Nullable) as K,
+ getFeatureTrainingCarouselDotStyle: (size: number, color: string, isActive: boolean): ViewStyle => ({
+ width: size,
+ height: size,
+ borderRadius: size / 2,
+ marginHorizontal: size,
+ backgroundColor: color,
+ opacity: isActive ? 1 : 0.3,
+ }),
+
getScrollableFeatureTrainingModalStyles: (
insets: EdgeInsets,
isKeyboardOpen = false,
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 762b49859251..dcd41e4662b6 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -278,6 +278,7 @@ export default {
photoUploadPopoverWidth: 335,
featureTrainingModalWidth: 500,
onboardingModalWidth: 640,
+ aiFeaturesPromoModalWidth: 440,
holdEducationModalWidth: 400,
changePolicyEducationModalWidth: 400,
wideConfirmModalWidth: 400,
diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts
index 958cb35a1650..904da3d460a8 100644
--- a/src/types/onyx/DismissedProductTraining.ts
+++ b/src/types/onyx/DismissedProductTraining.ts
@@ -31,6 +31,11 @@ type DismissedProductTraining = {
*/
[CONST.MIGRATED_USER_WELCOME_MODAL]: DismissedProductTrainingElement;
+ /**
+ * When user dismisses the AI features promo modal, we store the timestamp here.
+ */
+ [CONST.AI_FEATURES_PROMO_MODAL]: DismissedProductTrainingElement;
+
// TODO: CONCIERGE_LHN_GBR tooltip will be replaced by a tooltip in the #admins room
// https://github.com/Expensify/App/issues/57045#issuecomment-2701455668
/**