diff --git a/assets/images/scan.svg b/assets/images/scan.svg new file mode 100644 index 000000000000..629dc3823a12 --- /dev/null +++ b/assets/images/scan.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/src/CONST.ts b/src/CONST.ts index 639d34808ba8..1e8716b3c1a0 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3130,6 +3130,13 @@ const CONST = { REPORT: 'REPORT', }, + INTRO_CHOICES: { + TRACK: 'newDotTrack', + SUBMIT: 'newDotSubmit', + MANAGE_TEAM: 'newDotManageTeam', + CHAT_SPLIT: 'newDotSplitChat', + }, + MINI_CONTEXT_MENU_MAX_ITEMS: 4, REPORT_FIELD_TITLE_FIELD_ID: 'text_title', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index ea915c25a6ab..26424af8056c 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -110,6 +110,12 @@ const ONYXKEYS = { /** This NVP holds to most recent waypoints that a person has used when creating a distance request */ NVP_RECENT_WAYPOINTS: 'expensify_recentWaypoints', + /** This NVP will be `true` if the user has ever dismissed the engagement modal on either OldDot or NewDot. If it becomes true it should stay true forever. */ + NVP_HAS_DISMISSED_IDLE_PANEL: 'hasDismissedIdlePanel', + + /** This NVP contains the choice that the user made on the engagement modal */ + NVP_INTRO_SELECTED: 'introSelected', + /** Does this user have push notifications enabled for this device? */ PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', @@ -395,6 +401,8 @@ type OnyxValues = { [ONYXKEYS.FOCUS_MODE_NOTIFICATION]: boolean; [ONYXKEYS.NVP_LAST_PAYMENT_METHOD]: Record; [ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[]; + [ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL]: boolean; + [ONYXKEYS.NVP_INTRO_SELECTED]: OnyxTypes.IntroSelected; [ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED]: boolean; [ONYXKEYS.PLAID_DATA]: OnyxTypes.PlaidData; [ONYXKEYS.IS_PLAID_DISABLED]: boolean; diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index bd8d535e540f..346ff19987ef 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -458,7 +458,7 @@ function AttachmentModal(props) { shouldShowThreeDotsButton={shouldShowThreeDotsButton} threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)} threeDotsMenuItems={threeDotsMenuItems} - shouldOverlay + shouldOverlayDots /> {!_.isEmpty(props.report) && !props.isReceiptAttachment ? ( diff --git a/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js b/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js index 109e60adf672..2f7ac48b558b 100644 --- a/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js +++ b/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js @@ -96,6 +96,9 @@ const propTypes = { /** Whether we should navigate to report page when the route have a topMostReport */ shouldNavigateToTopMostReport: PropTypes.bool, + + /** Whether we should overlay the 3 dots menu */ + shouldOverlayDots: PropTypes.bool, }; export default propTypes; diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 6cbfde0645de..a0f24b06db7f 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {Keyboard, View} from 'react-native'; +import {Keyboard, StyleSheet, View} from 'react-native'; import AvatarWithDisplayName from '@components/AvatarWithDisplayName'; import Header from '@components/Header'; import Icon from '@components/Icon'; @@ -52,6 +52,7 @@ function HeaderWithBackButton({ threeDotsMenuItems = [], shouldEnableDetailPageNavigation = false, children = null, + shouldOverlayDots = false, shouldOverlay = false, singleExecution = (func) => func, shouldNavigateToTopMostReport = false, @@ -69,7 +70,7 @@ function HeaderWithBackButton({ // Hover on some part of close icons will not work on Electron if dragArea is true // https://github.com/Expensify/App/issues/29598 dataSet={{dragArea: false}} - style={[styles.headerBar, shouldShowBorderBottom && styles.borderBottom, shouldShowBackButton && styles.pl2]} + style={[styles.headerBar, shouldShowBorderBottom && styles.borderBottom, shouldShowBackButton && styles.pl2, shouldOverlay && StyleSheet.absoluteFillObject]} > {shouldShowBackButton && ( @@ -163,7 +164,7 @@ function HeaderWithBackButton({ menuItems={threeDotsMenuItems} onIconPress={onThreeDotsButtonPress} anchorPosition={threeDotsAnchorPosition} - shouldOverlay={shouldOverlay} + shouldOverlay={shouldOverlayDots} /> )} {shouldShowCloseButton && ( diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 55cc9e708771..832351b2b70e 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -108,6 +108,9 @@ type HeaderWithBackButtonProps = Partial & { /** Whether we should enable detail page navigation */ shouldEnableDetailPageNavigation?: boolean; + + /** Whether we should overlay the 3 dots menu */ + shouldOverlayDots?: boolean; }; export type {ThreeDotsMenuItem}; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 797e6f34fc75..364fb03a2055 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -107,6 +107,7 @@ import ReceiptSearch from '@assets/images/receipt-search.svg'; import Receipt from '@assets/images/receipt.svg'; import Rotate from '@assets/images/rotate-image.svg'; import RotateLeft from '@assets/images/rotate-left.svg'; +import Scan from '@assets/images/scan.svg'; import Send from '@assets/images/send.svg'; import Shield from '@assets/images/shield.svg'; import AppleLogo from '@assets/images/signIn/apple-logo.svg'; @@ -243,6 +244,7 @@ export { ReceiptSearch, Rotate, RotateLeft, + Scan, Send, Shield, Sync, diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 6e5b4eddae9e..a250e21c0021 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -9,6 +9,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ComposerFocusManager from '@libs/ComposerFocusManager'; +import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; import useNativeDriver from '@libs/useNativeDriver'; import variables from '@styles/variables'; import * as Modal from '@userActions/Modal'; @@ -38,6 +39,7 @@ function BaseModal( onLayout, avoidKeyboard = false, children, + shouldUseCustomBackdrop = false, }: BaseModalProps, ref: React.ForwardedRef, ) { @@ -185,7 +187,7 @@ function BaseModal( swipeDirection={swipeDirection} isVisible={isVisible} backdropColor={theme.overlay} - backdropOpacity={hideBackdrop ? 0 : variables.overlayOpacity} + backdropOpacity={!shouldUseCustomBackdrop && hideBackdrop ? 0 : variables.overlayOpacity} backdropTransitionOutTiming={0} hasBackdrop={fullscreen} coverScreen={fullscreen} @@ -201,6 +203,7 @@ function BaseModal( statusBarTranslucent={statusBarTranslucent} onLayout={onLayout} avoidKeyboard={avoidKeyboard} + customBackdrop={shouldUseCustomBackdrop ? : undefined} > & { * See: https://github.com/react-native-modal/react-native-modal/pull/116 * */ hideModalContentWhileAnimating?: boolean; + + /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ + shouldUseCustomBackdrop?: boolean; }; export default BaseModalProps; diff --git a/src/components/PurposeForUsingExpensifyModal.tsx b/src/components/PurposeForUsingExpensifyModal.tsx new file mode 100644 index 000000000000..a8cab171ffca --- /dev/null +++ b/src/components/PurposeForUsingExpensifyModal.tsx @@ -0,0 +1,178 @@ +import {useNavigation} from '@react-navigation/native'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {ScrollView, View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as Report from '@userActions/Report'; +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import HeaderWithBackButton from './HeaderWithBackButton'; +import * as Expensicons from './Icon/Expensicons'; +import Lottie from './Lottie'; +import LottieAnimations from './LottieAnimations'; +import type {MenuItemProps} from './MenuItem'; +import MenuItemList from './MenuItemList'; +import Modal from './Modal'; +import Text from './Text'; + +// This is not translated because it is a message coming from concierge, which only supports english +const messageCopy = { + [CONST.INTRO_CHOICES.TRACK]: + 'Great! To track your expenses, I suggest you create a workspace to keep everything contained:\n' + + '\n' + + '1. Press your avatar icon\n' + + '2. Choose Workspaces\n' + + '3. Choose New Workspace\n' + + '4. Name your workspace something meaningful (eg, "My Business Expenses")\n' + + '\n' + + 'Once you have your workspace set up, you can add expenses to it as follows:\n' + + '\n' + + '1. Choose My Business Expenses (or whatever you named it) in the list of chat rooms\n' + + '2. Choose the + button in the chat compose window\n' + + '3. Choose Request money\n' + + "4. Choose what kind of expense you'd like to log, whether a manual expense, scanned receipt, or tracked distance.\n" + + '\n' + + "That'll be stored in your My Business Expenses room for your later access. Thanks for asking, and let me know how it goes!", + [CONST.INTRO_CHOICES.SUBMIT]: + 'Hi there, to submit expenses for reimbursement, please:\n' + + '\n' + + '1. Press the big green + button\n' + + '2. Choose Request money\n' + + '3. Indicate how much to request, either manually, by scanning a receipt, or by tracking distance\n' + + '4. Enter the email address or phone number of your boss\n' + + '\n' + + "And we'll take it from there to get you paid back. Please give it a shot and let me know how it goes!", + [CONST.INTRO_CHOICES.MANAGE_TEAM]: + "Great! To manage your team's expenses, create a workspace to keep everything contained:\n" + + '\n' + + '1. Press your avatar icon\n' + + '2. Choose Workspaces\n' + + '3. Choose New Workspace\n' + + '4. Name your workspace something meaningful (eg, "Galaxy Food Inc.")\n' + + '\n' + + 'Once you have your workspace set up, you can invite your team to it via the Members pane and connect a business bank account to reimburse them!', + [CONST.INTRO_CHOICES.CHAT_SPLIT]: + 'Hi there, to split an expense such as with a friend, please:\n' + + '\n' + + 'Press the big green + button\n' + + 'Choose *Request money*\n' + + 'Indicate how much was spent, either manually, by scanning a receipt, or by tracking distance\n' + + 'Enter the email address or phone number of your friend\n' + + 'Press *Split* next to their name\n' + + 'Repeat as many times as you like for each of your friends\n' + + 'Press *Add to split* when done adding friends\n' + + 'Press Split to split the bill\n' + + '\n' + + "This will send a money request to each of your friends for however much they owe you, and we'll take care of getting you paid back. Thanks for asking, and let me know how it goes!", +}; + +const menuIcons = { + [CONST.INTRO_CHOICES.TRACK]: Expensicons.ReceiptSearch, + [CONST.INTRO_CHOICES.SUBMIT]: Expensicons.Scan, + [CONST.INTRO_CHOICES.MANAGE_TEAM]: Expensicons.MoneyBag, + [CONST.INTRO_CHOICES.CHAT_SPLIT]: Expensicons.Briefcase, +}; + +function PurposeForUsingExpensifyModal() { + const {translate} = useLocalize(); + const StyleUtils = useStyleUtils(); + const styles = useThemeStyles(); + const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); + const navigation = useNavigation(); + const [isModalOpen, setIsModalOpen] = useState(false); + const theme = useTheme(); + + useEffect(() => { + const navigationState = navigation.getState(); + const routes = navigationState.routes; + const currentRoute = routes[navigationState.index]; + if (currentRoute && NAVIGATORS.CENTRAL_PANE_NAVIGATOR !== currentRoute.name && currentRoute.name !== SCREENS.HOME) { + return; + } + + Welcome.show(routes, () => setIsModalOpen(true)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const closeModal = useCallback(() => { + Report.dismissEngagementModal(); + setIsModalOpen(false); + }, []); + + const completeModalAndClose = useCallback((message: string, choice: ValueOf) => { + Report.completeEngagementModal(message, choice); + setIsModalOpen(false); + Report.navigateToConciergeChat(); + }, []); + + const menuItems: MenuItemProps[] = useMemo( + () => + Object.values(CONST.INTRO_CHOICES).map((choice) => { + const translationKey = `purposeForExpensify.${choice}` as const; + return { + key: translationKey, + title: translate(translationKey), + icon: menuIcons[choice], + iconRight: Expensicons.ArrowRight, + onPress: () => completeModalAndClose(messageCopy[choice], choice), + shouldShowRightIcon: true, + numberOfLinesTitle: 2, + }; + }), + [completeModalAndClose, translate], + ); + + return ( + + + + + + + + + + {translate('purposeForExpensify.welcomeMessage')} + + {translate('purposeForExpensify.welcomeSubtitle')} + + + + + + ); +} + +PurposeForUsingExpensifyModal.displayName = 'PurposeForUsingExpensifyModal'; + +export default PurposeForUsingExpensifyModal; diff --git a/src/languages/en.ts b/src/languages/en.ts index 0a754a883d07..bb22c7a7856a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2074,6 +2074,14 @@ export default { }, copyReferralLink: 'Copy invite link', }, + purposeForExpensify: { + [CONST.INTRO_CHOICES.TRACK]: 'Track business spend for taxes', + [CONST.INTRO_CHOICES.SUBMIT]: 'Get paid back by my employer', + [CONST.INTRO_CHOICES.MANAGE_TEAM]: "Manage my team's expenses", + [CONST.INTRO_CHOICES.CHAT_SPLIT]: 'Chat and split bills with friends', + welcomeMessage: 'Welcome to Expensify', + welcomeSubtitle: 'What would you like to do?', + }, violations: { allTagLevelsRequired: 'All tags required', autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 2478c8ba8bd2..858fe29a8faf 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2561,6 +2561,14 @@ export default { }, copyReferralLink: 'Copiar enlace de invitación', }, + purposeForExpensify: { + [CONST.INTRO_CHOICES.TRACK]: 'Seguimiento de los gastos de empresa para fines fiscales', + [CONST.INTRO_CHOICES.SUBMIT]: 'Reclamar gastos a mi empleador', + [CONST.INTRO_CHOICES.MANAGE_TEAM]: 'Gestionar los gastos de mi equipo', + [CONST.INTRO_CHOICES.CHAT_SPLIT]: 'Chatea y divide gastos con tus amigos', + welcomeMessage: 'Bienvenido a Expensify', + welcomeSubtitle: '¿Qué te gustaría hacer?', + }, violations: { allTagLevelsRequired: 'Todas las etiquetas son obligatorias', autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx index a3fe1c657f34..5462b6c0ce4e 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx @@ -27,19 +27,21 @@ function Overlay({onPress, isModalOnTheLeft = false}: OverlayProps) { we have 30px draggable ba at the top and the rest of the dimmed area is clickable. On other devices, everything behaves normally like one big pressable */} diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 5b0464c58898..511406a8a09c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2499,12 +2499,13 @@ function getParsedComment(text: string): string { return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : lodashEscape(text); } -function buildOptimisticAddCommentReportAction(text?: string, file?: File): OptimisticReportAction { +function buildOptimisticAddCommentReportAction(text?: string, file?: File, actorAccountID?: number): OptimisticReportAction { const parser = new ExpensiMark(); const commentText = getParsedComment(text ?? ''); const isAttachment = !text && file !== undefined; const attachmentInfo = isAttachment ? file : {}; const htmlForNewComment = isAttachment ? CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML : commentText; + const accountID = actorAccountID ?? currentUserAccountID; // Remove HTML from text when applying optimistic offline comment const textForNewComment = isAttachment ? CONST.ATTACHMENT_MESSAGE_TEXT : parser.htmlToText(htmlForNewComment); @@ -2513,16 +2514,16 @@ function buildOptimisticAddCommentReportAction(text?: string, file?: File): Opti reportAction: { reportActionID: NumberUtils.rand64(), actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, - actorAccountID: currentUserAccountID, + actorAccountID: accountID, person: [ { style: 'strong', - text: allPersonalDetails?.[currentUserAccountID ?? -1]?.displayName ?? currentUserEmail, + text: allPersonalDetails?.[accountID ?? -1]?.displayName ?? currentUserEmail, type: 'TEXT', }, ], automatic: false, - avatar: allPersonalDetails?.[currentUserAccountID ?? -1]?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID), + avatar: allPersonalDetails?.[accountID ?? -1]?.avatar ?? UserUtils.getDefaultAvatarURL(accountID), created: DateUtils.getDBTimeWithSkew(), message: [ { diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 2ac85dfafa27..a3cd8f52a411 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2499,6 +2499,115 @@ function getReportPrivateNote(reportID: string) { API.read('GetReportPrivateNote', parameters, {optimisticData, successData, failureData}); } +/** + * Completes the engagement modal that new NewDot users see when they first sign up/log in by doing the following: + * + * - Sets the introSelected NVP to the choice the user made + * - Creates an optimistic report comment from concierge + */ +function completeEngagementModal(text: string, choice: ValueOf) { + const commandName = 'CompleteEngagementModal'; + const conciergeAccountID = PersonalDetailsUtils.getAccountIDsByLogins([CONST.EMAIL.CONCIERGE])[0]; + const reportComment = ReportUtils.buildOptimisticAddCommentReportAction(text, undefined, conciergeAccountID); + const reportCommentAction: OptimisticAddCommentReportAction = reportComment.reportAction; + const lastComment = reportCommentAction?.message?.[0]; + const lastCommentText = ReportUtils.formatReportLastMessageText(lastComment?.text ?? ''); + const reportCommentText = reportComment.commentText; + const currentTime = DateUtils.getDBTime(); + + const optimisticReport: Partial = { + lastVisibleActionCreated: currentTime, + lastMessageTranslationKey: lastComment?.translationKey ?? '', + lastMessageText: lastCommentText, + lastMessageHtml: lastCommentText, + lastActorAccountID: currentUserAccountID, + lastReadTime: currentTime, + }; + + const conciergeChatReport = ReportUtils.getChatByParticipants([conciergeAccountID]); + conciergeChatReportID = conciergeChatReport?.reportID; + + const report = ReportUtils.getReport(conciergeChatReportID); + + if (!isEmptyObject(report) && ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + optimisticReport.notificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + } + + // Optimistically add the new actions to the store before waiting to save them to the server + const optimisticReportActions: OnyxCollection = {}; + if (reportCommentAction?.reportActionID) { + optimisticReportActions[reportCommentAction.reportActionID] = reportCommentAction; + } + + type CompleteEngagementParameters = { + reportID: string; + reportActionID?: string; + commentReportActionID?: string | null; + reportComment?: string; + engagementChoice: string; + timezone?: string; + }; + + const parameters: CompleteEngagementParameters = { + reportID: conciergeChatReportID ?? '', + reportActionID: reportCommentAction.reportActionID, + reportComment: reportCommentText, + engagementChoice: choice, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${conciergeChatReportID}`, + value: optimisticReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${conciergeChatReportID}`, + value: optimisticReportActions as ReportActions, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_INTRO_SELECTED, + value: {choice}, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${conciergeChatReportID}`, + value: {[reportCommentAction.reportActionID ?? '']: {pendingAction: null}}, + }, + ]; + + API.write(commandName, parameters, { + optimisticData, + successData, + }); + notifyNewAction(conciergeChatReportID ?? '', reportCommentAction.actorAccountID, reportCommentAction.reportActionID); +} + +function dismissEngagementModal() { + const commandName = 'SetNameValuePair'; + const parameters = { + name: ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL, + value: true, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL, + value: true, + }, + ]; + + API.write(commandName, parameters, { + optimisticData, + }); +} + /** Loads necessary data for rendering the RoomMembersPage */ function openRoomMembersPage(reportID: string) { type OpenRoomMembersPageParameters = { @@ -2709,6 +2818,8 @@ export { hasErrorInPrivateNotes, getOlderActions, getNewerActions, + completeEngagementModal, + dismissEngagementModal, openRoomMembersPage, savePrivateNotesDraft, getDraftPrivateNote, diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts index 3e3cba49480d..3f6b2dc99a8f 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome.ts @@ -1,13 +1,16 @@ +import type {NavigationState} from '@react-navigation/native'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; +import type {RootStackParamList} from '@navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type OnyxPolicy from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; +import type {EmptyObject} from '@src/types/utils/EmptyObject'; import * as Policy from './Policy'; let resolveIsReadyPromise: (value?: Promise) => void | undefined; @@ -16,20 +19,11 @@ let isReadyPromise = new Promise((resolve) => { }); let isFirstTimeNewExpensifyUser: boolean | undefined; +let hasDismissedModal: boolean | undefined; +let hasSelectedChoice: boolean | undefined; let isLoadingReportData = true; let currentUserAccountID: number | undefined; -type Route = { - name: string; - params?: {path: string; exitTo?: string; openOnAdminRoom?: boolean}; -}; - -type ShowParams = { - routes: Route[]; - showCreateMenu?: () => void; - showPopoverMenu?: () => boolean; -}; - /** * Check that a few requests have completed so that the welcome action can proceed: * @@ -38,7 +32,7 @@ type ShowParams = { * - Whether we have loaded all reports the server knows about */ function checkOnReady() { - if (isFirstTimeNewExpensifyUser === undefined || isLoadingReportData) { + if (isFirstTimeNewExpensifyUser === undefined || isLoadingReportData || hasSelectedChoice === undefined || hasDismissedModal === undefined) { return; } @@ -58,6 +52,26 @@ Onyx.connect({ }, }); +Onyx.connect({ + key: ONYXKEYS.NVP_INTRO_SELECTED, + initWithStoredValues: true, + callback: (value) => { + hasSelectedChoice = !!value; + + checkOnReady(); + }, +}); + +Onyx.connect({ + key: ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL, + initWithStoredValues: true, + callback: (value) => { + hasDismissedModal = value ?? false; + + checkOnReady(); + }, +}); + Onyx.connect({ key: ONYXKEYS.IS_LOADING_REPORT_DATA, initWithStoredValues: false, @@ -80,7 +94,7 @@ Onyx.connect({ }, }); -const allPolicies: OnyxCollection = {}; +const allPolicies: OnyxCollection | EmptyObject = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, callback: (val, key) => { @@ -111,7 +125,7 @@ Onyx.connect({ /** * Shows a welcome action on first login */ -function show({routes, showCreateMenu = () => {}, showPopoverMenu = () => false}: ShowParams) { +function show(routes: NavigationState['routes'], showEngagementModal = () => {}) { isReadyPromise.then(() => { if (!isFirstTimeNewExpensifyUser) { return; @@ -119,16 +133,14 @@ function show({routes, showCreateMenu = () => {}, showPopoverMenu = () => false} // If we are rendering the SidebarScreen at the same time as a workspace route that means we've already created a workspace via workspace/new and should not open the global // create menu right now. We should also stay on the workspace page if that is our destination. - const topRoute = routes.length > 0 ? routes[routes.length - 1] : undefined; - const isWorkspaceRoute = topRoute !== undefined && topRoute.name === SCREENS.RIGHT_MODAL.SETTINGS && topRoute.params?.path.includes('workspace'); - const transitionRoute = routes.find((route) => route.name === SCREENS.TRANSITION_BETWEEN_APPS); - const exitingToWorkspaceRoute = transitionRoute?.params?.exitTo === 'workspace/new'; - const openOnAdminRoom = topRoute?.params?.openOnAdminRoom ?? false; - const isDisplayingWorkspaceRoute = isWorkspaceRoute ?? exitingToWorkspaceRoute; + const transitionRoute = routes.find( + (route): route is NavigationState>['routes'][number] => route.name === SCREENS.TRANSITION_BETWEEN_APPS, + ); + const isExitingToWorkspaceRoute = transitionRoute?.params?.exitTo === 'workspace/new'; // If we already opened the workspace settings or want the admin room to stay open, do not // navigate away to the workspace chat report - const shouldNavigateToWorkspaceChat = !isDisplayingWorkspaceRoute && !openOnAdminRoom; + const shouldNavigateToWorkspaceChat = !isExitingToWorkspaceRoute; const workspaceChatReport = Object.values(allReports ?? {}).find((report) => { if (report) { @@ -137,7 +149,7 @@ function show({routes, showCreateMenu = () => {}, showPopoverMenu = () => false} return false; }); - if (workspaceChatReport ?? openOnAdminRoom) { + if (workspaceChatReport) { // This key is only updated when we call ReconnectApp, setting it to false now allows the user to navigate normally instead of always redirecting to the workspace chat Onyx.set(ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, false); } @@ -147,19 +159,17 @@ function show({routes, showCreateMenu = () => {}, showPopoverMenu = () => false} Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(workspaceChatReport.reportID)); } - // If showPopoverMenu exists and returns true then it opened the Popover Menu successfully, and we can update isFirstTimeNewExpensifyUser - // so the Welcome logic doesn't run again - if (showPopoverMenu?.()) { - isFirstTimeNewExpensifyUser = false; - } + // New user has been redirected to their workspace chat, and we won't show them the engagement modal. + // So we update isFirstTimeNewExpensifyUser to prevent the Welcome logic from running again + isFirstTimeNewExpensifyUser = false; return; } // If user is not already an admin of a free policy and we are not navigating them to their workspace or creating a new workspace via workspace/new then - // we will show the create menu. - if (!Policy.isAdminOfFreePolicy(allPolicies ?? undefined) && !isDisplayingWorkspaceRoute) { - showCreateMenu(); + // we will show the engagement modal. + if (!Policy.isAdminOfFreePolicy(allPolicies ?? undefined) && !isExitingToWorkspaceRoute && !hasSelectedChoice && !hasDismissedModal && Object.keys(allPolicies ?? {}).length === 1) { + showEngagementModal(); } // Update isFirstTimeNewExpensifyUser so the Welcome logic doesn't run again diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 9aeabefd645d..bbcdc5cebef4 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -1,4 +1,3 @@ -import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -20,12 +19,9 @@ import * as IOU from '@userActions/IOU'; import * as Policy from '@userActions/Policy'; import * as Session from '@userActions/Session'; import * as Task from '@userActions/Task'; -import * as Welcome from '@userActions/Welcome'; 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'; /** * @param {Object} [policy] @@ -142,18 +138,6 @@ function FloatingActionButtonAndPopover(props) { } }; - useEffect(() => { - const navigationState = props.navigation.getState(); - const routes = lodashGet(navigationState, 'routes', []); - const currentRoute = routes[navigationState.index]; - if (currentRoute && ![NAVIGATORS.CENTRAL_PANE_NAVIGATOR, SCREENS.HOME].includes(currentRoute.name)) { - return; - } - - Welcome.show({routes, showCreateMenu}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.isLoading]); - useEffect(() => { if (!didScreenBecomeInactive()) { return; diff --git a/src/pages/home/sidebar/SidebarScreen/index.js b/src/pages/home/sidebar/SidebarScreen/index.js index 0b4c520c78a2..e823d24b87fe 100755 --- a/src/pages/home/sidebar/SidebarScreen/index.js +++ b/src/pages/home/sidebar/SidebarScreen/index.js @@ -1,4 +1,5 @@ import React, {useCallback, useRef} from 'react'; +import PurposeForUsingExpensifyModal from '@components/PurposeForUsingExpensifyModal'; import useWindowDimensions from '@hooks/useWindowDimensions'; import FreezeWrapper from '@libs/Navigation/FreezeWrapper'; import BaseSidebarScreen from './BaseSidebarScreen'; @@ -44,6 +45,7 @@ function SidebarScreen(props) { onShowCreateMenu={createDragoverListener} onHideCreateMenu={removeDragoverListener} /> + ); diff --git a/src/pages/home/sidebar/SidebarScreen/index.native.js b/src/pages/home/sidebar/SidebarScreen/index.native.js index 36724c02d278..7f36e4ebfa22 100755 --- a/src/pages/home/sidebar/SidebarScreen/index.native.js +++ b/src/pages/home/sidebar/SidebarScreen/index.native.js @@ -1,4 +1,5 @@ import React from 'react'; +import PurposeForUsingExpensifyModal from '@components/PurposeForUsingExpensifyModal'; import useWindowDimensions from '@hooks/useWindowDimensions'; import FreezeWrapper from '@libs/Navigation/FreezeWrapper'; import BaseSidebarScreen from './BaseSidebarScreen'; @@ -14,6 +15,7 @@ function SidebarScreen(props) { {...props} > + ); diff --git a/src/styles/index.ts b/src/styles/index.ts index aace13c34594..9bfe407593df 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2875,6 +2875,10 @@ const styles = (theme: ThemeColors) => outlineStyle: 'none', }, + boxShadowNone: { + boxShadow: 'none', + }, + cardStyleNavigator: { overflow: 'hidden', height: '100%', diff --git a/src/types/onyx/IntroSelected.ts b/src/types/onyx/IntroSelected.ts new file mode 100644 index 000000000000..f0047ac134ee --- /dev/null +++ b/src/types/onyx/IntroSelected.ts @@ -0,0 +1,9 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type IntroSelected = { + /** The choice that the user selected in the engagement modal */ + choice: ValueOf; +}; + +export default IntroSelected; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 6ff0ef88be08..8e54f97874e3 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -14,6 +14,7 @@ import type Form from './Form'; import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji'; import type {FundList} from './Fund'; import type Fund from './Fund'; +import type IntroSelected from './IntroSelected'; import type IOU from './IOU'; import type Locale from './Locale'; import type {LoginList} from './Login'; @@ -85,6 +86,7 @@ export type { FrequentlyUsedEmoji, Fund, FundList, + IntroSelected, IOU, Locale, Login, diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js index e1c11fbb8ca8..3aab3a13c1c3 100644 --- a/tests/unit/CalendarPickerTest.js +++ b/tests/unit/CalendarPickerTest.js @@ -7,6 +7,7 @@ import DateUtils from '../../src/libs/DateUtils'; const monthNames = DateUtils.getMonthNames(CONST.LOCALES.EN); jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({navigate: jest.fn()}), createNavigationContainerRef: jest.fn(), }));