Skip to content

Commit 53c42f2

Browse files
authored
Merge pull request Expensify#74880 from Expensify/marcaaron-proactiveReview
Add Proactive Review Modal
2 parents 2a0d7fe + 246d66c commit 53c42f2

17 files changed

Lines changed: 424 additions & 2 deletions

ios/Podfile.lock

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ PODS:
171171
- Yoga
172172
- ExpoSecureStore (14.2.4):
173173
- ExpoModulesCore
174+
- ExpoStoreReview (9.0.8):
175+
- ExpoModulesCore
174176
- fast_float (8.0.0)
175177
- FBLazyVector (0.81.4)
176178
- Firebase/Analytics (11.13.0):
@@ -4172,6 +4174,7 @@ DEPENDENCIES:
41724174
- ExpoImageManipulator (from `../node_modules/expo-image-manipulator/ios`)
41734175
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
41744176
- ExpoSecureStore (from `../node_modules/expo-secure-store/ios`)
4177+
- ExpoStoreReview (from `../node_modules/expo-store-review/ios`)
41754178
- fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
41764179
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
41774180
- fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`)
@@ -4384,6 +4387,8 @@ EXTERNAL SOURCES:
43844387
:path: "../node_modules/expo-modules-core"
43854388
ExpoSecureStore:
43864389
:path: "../node_modules/expo-secure-store/ios"
4390+
ExpoStoreReview:
4391+
:path: "../node_modules/expo-store-review/ios"
43874392
fast_float:
43884393
:podspec: "../node_modules/react-native/third-party-podspecs/fast_float.podspec"
43894394
FBLazyVector:
@@ -4660,6 +4665,7 @@ SPEC CHECKSUMS:
46604665
ExpoImageManipulator: 7cb78b082bcc767b97867d833ce614ace424cd97
46614666
ExpoModulesCore: e1b5401a7af4c7dbf4fe26b535918a72c6ed8a7b
46624667
ExpoSecureStore: 3f1b632d6d40bcc62b4983ef9199cd079592a50a
4668+
ExpoStoreReview: 15f19e0d6cb6e00330ba1b356485bf47ef19c39a
46634669
fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6
46644670
FBLazyVector: 941bef1c8eeabd9fe1f501e30a5220beee913886
46654671
Firebase: 3435bc66b4d494c2f22c79fd3aae4c1db6662327
@@ -4708,7 +4714,7 @@ SPEC CHECKSUMS:
47084714
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
47094715
pusher-websocket-react-native: 31b5fdd632bfa6d417f9ad0bceefe403ab41825d
47104716
PusherSwift: fa3d5f6587c20ad5790de87a5c9b150367b5f0f5
4711-
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
4717+
RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f
47124718
RCTDeprecation: c0ed3249a97243002615517dff789bf4666cf585
47134719
RCTRequired: 58719f5124f9267b5f9649c08bf23d9aea845b23
47144720
RCTTypeSafety: 4aefa8328ab1f86da273f08517f1f6b343f6c2cc

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
"expo-image-manipulator": "^13.1.5",
145145
"expo-modules-core": "3.0.18",
146146
"expo-secure-store": "~14.2.4",
147+
"expo-store-review": "~9.0.8",
147148
"fast-equals": "^5.2.2",
148149
"focus-trap-react": "^11.0.3",
149150
"group-ib-fp": "file:modules/group-ib-fp",

src/Expensify.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import DeeplinkWrapper from './components/DeeplinkWrapper';
1111
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
1212
import GrowlNotification from './components/GrowlNotification';
1313
import {InitialURLContext} from './components/InitialURLContextProvider';
14+
import ProactiveAppReviewModalManager from './components/ProactiveAppReviewModalManager';
1415
import AppleAuthWrapper from './components/SignInButtons/AppleAuthWrapper';
1516
import SplashScreenHider from './components/SplashScreenHider';
1617
import UpdateAppModal from './components/UpdateAppModal';
@@ -371,6 +372,8 @@ function Expensify() {
371372
<EmojiPicker ref={EmojiPickerAction.emojiPickerRef} />
372373
{/* We include the modal for showing a new update at the top level so the option is always present. */}
373374
{updateAvailable && !updateRequired ? <UpdateAppModal /> : null}
375+
{/* Proactive app review modal shown when user has completed a trigger action */}
376+
<ProactiveAppReviewModalManager />
374377
{screenShareRequest ? (
375378
<ConfirmModal
376379
title={translate('guides.screenShare')}

src/ONYXKEYS.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,9 @@ const ONYXKEYS = {
555555
/** Stores the information about the state of side panel */
556556
NVP_SIDE_PANEL: 'nvp_sidePanel',
557557

558+
/** Stores the user's app review prompt state and response */
559+
NVP_APP_REVIEW: 'nvp_appReview',
560+
558561
/** Information about vacation delegate */
559562
NVP_PRIVATE_VACATION_DELEGATE: 'nvp_private_vacationDelegate',
560563

@@ -1295,6 +1298,7 @@ type OnyxValuesMapping = {
12951298
[ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING]: boolean | undefined;
12961299
[ONYXKEYS.BILLING_RECEIPT_DETAILS]: OnyxTypes.BillingReceiptDetails;
12971300
[ONYXKEYS.NVP_SIDE_PANEL]: OnyxTypes.SidePanel;
1301+
[ONYXKEYS.NVP_APP_REVIEW]: OnyxTypes.AppReview;
12981302
[ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION]: boolean;
12991303
[ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE]: OnyxTypes.VacationDelegate;
13001304
[ONYXKEYS.SCHEDULE_CALL_DRAFT]: OnyxTypes.ScheduleCallDraft;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import React from 'react';
2+
import {View} from 'react-native';
3+
import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
4+
import useResponsiveLayout from '@hooks/useResponsiveLayout';
5+
import useThemeStyles from '@hooks/useThemeStyles';
6+
import CONST from '@src/CONST';
7+
import Button from './Button';
8+
import ImageSVG from './ImageSVG';
9+
import Modal from './Modal';
10+
import Text from './Text';
11+
12+
type ProactiveAppReviewModalProps = {
13+
/** Whether modal is visible */
14+
isVisible: boolean;
15+
16+
/** Callback for when user selects "Yeah!" */
17+
onPositive: () => void;
18+
19+
/** Callback for when user selects "Not really." */
20+
onNegative: () => void;
21+
22+
/** Callback for closing/skipping modal */
23+
onSkip: () => void;
24+
};
25+
26+
function ProactiveAppReviewModal({isVisible, onPositive, onNegative, onSkip}: ProactiveAppReviewModalProps) {
27+
const {shouldUseNarrowLayout} = useResponsiveLayout();
28+
const styles = useThemeStyles();
29+
const illustrations = useMemoizedLazyIllustrations(['ToddWithPhones'] as const);
30+
31+
return (
32+
<Modal
33+
onClose={onSkip}
34+
isVisible={isVisible}
35+
type={shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
36+
innerContainerStyle={styles.pv0}
37+
>
38+
<View style={[styles.m5]}>
39+
{/* Todd with phones illustration */}
40+
<View style={[styles.alignItemsCenter, styles.mb3]}>
41+
<ImageSVG
42+
src={illustrations.ToddWithPhones}
43+
contentFit="contain"
44+
width={200}
45+
height={200}
46+
/>
47+
</View>
48+
49+
{/* Title and prompt */}
50+
<View>
51+
<Text style={[styles.textHeadlineH1, styles.textAlignCenter, styles.mb3]}>Enjoying New Expensify?</Text>
52+
<Text style={[styles.textAlignCenter, styles.textSupporting]}>{'Let us know so we can help make your\nexpensing experience even better.'}</Text>
53+
</View>
54+
55+
{/* Buttons */}
56+
<Button
57+
success
58+
style={[styles.mt5]}
59+
onPress={onPositive}
60+
pressOnEnter
61+
text="Yeah!"
62+
large
63+
/>
64+
<Button
65+
style={[styles.mt3, styles.noSelect]}
66+
onPress={onNegative}
67+
text="Not really"
68+
large
69+
/>
70+
</View>
71+
</Modal>
72+
);
73+
}
74+
75+
ProactiveAppReviewModal.displayName = 'ProactiveAppReviewModal';
76+
77+
export default ProactiveAppReviewModal;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React, {useCallback} from 'react';
2+
import useOnyx from '@hooks/useOnyx';
3+
import useProactiveAppReview from '@hooks/useProactiveAppReview';
4+
import requestStoreReview from '@libs/actions/StoreReview';
5+
import {respondToProactiveAppReview} from '@libs/actions/User';
6+
import Navigation from '@libs/Navigation/Navigation';
7+
import ONYXKEYS from '@src/ONYXKEYS';
8+
import ROUTES from '@src/ROUTES';
9+
import type {AppReviewResponse} from '@src/types/onyx/AppReview';
10+
import ProactiveAppReviewModal from './ProactiveAppReviewModal';
11+
12+
const CONCIERGE_POSITIVE_MESSAGE = "Hi there! I'm glad to hear you're enjoying Expensify. What's your favorite thing about the app? Thanks!";
13+
const CONCIERGE_NEGATIVE_MESSAGE = "Hi there! I'm sorry to hear you aren't fully satisfied with Expensify. What's your #1 frustration? Thanks!";
14+
15+
function ProactiveAppReviewModalManager() {
16+
const {shouldShowModal, proactiveAppReview} = useProactiveAppReview();
17+
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID, {canBeMissing: true});
18+
19+
const handleResponse = useCallback(
20+
(response: AppReviewResponse, message?: string) => {
21+
// Call the action which will create an optimistic comment (if the message is provided) and call the API
22+
respondToProactiveAppReview(response, proactiveAppReview, message, conciergeReportID);
23+
24+
// Navigate to Concierge DM if we have a report ID and this wasn't a skip
25+
if (conciergeReportID && response !== 'skip') {
26+
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID));
27+
}
28+
},
29+
[conciergeReportID, proactiveAppReview],
30+
);
31+
32+
const handlePositive = useCallback(() => {
33+
handleResponse('positive', CONCIERGE_POSITIVE_MESSAGE);
34+
35+
// Trigger native app store review prompt
36+
requestStoreReview();
37+
}, [handleResponse]);
38+
39+
const handleNegative = useCallback(() => {
40+
handleResponse('negative', CONCIERGE_NEGATIVE_MESSAGE);
41+
}, [handleResponse]);
42+
43+
const handleSkip = useCallback(() => {
44+
handleResponse('skip');
45+
}, [handleResponse]);
46+
47+
return (
48+
<ProactiveAppReviewModal
49+
isVisible={shouldShowModal}
50+
onPositive={handlePositive}
51+
onNegative={handleNegative}
52+
onSkip={handleSkip}
53+
/>
54+
);
55+
}
56+
57+
ProactiveAppReviewModalManager.displayName = 'ProactiveAppReviewModalManager';
58+
59+
export default ProactiveAppReviewModalManager;

src/hooks/useProactiveAppReview.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {useMemo} from 'react';
2+
import ONYXKEYS from '@src/ONYXKEYS';
3+
import type ProactiveAppReview from '@src/types/onyx/AppReview';
4+
import useOnyx from './useOnyx';
5+
6+
const THIRTY_DAYS_IN_MS = 30 * 24 * 60 * 60 * 1000;
7+
8+
type UseProactiveAppReviewReturn = {
9+
/** Whether the modal should be shown */
10+
shouldShowModal: boolean;
11+
12+
/** The current proactive app review data */
13+
proactiveAppReview: ProactiveAppReview | null | undefined;
14+
};
15+
16+
/**
17+
* Hook to check if the proactive app review modal should be shown
18+
*/
19+
function useProactiveAppReview(): UseProactiveAppReviewReturn {
20+
const [proactiveAppReview] = useOnyx(ONYXKEYS.NVP_APP_REVIEW, {canBeMissing: true});
21+
22+
const shouldShowModal = useMemo(() => {
23+
if (!proactiveAppReview) {
24+
return false;
25+
}
26+
27+
// Don't show if trigger is not set
28+
if (!proactiveAppReview.trigger) {
29+
return false;
30+
}
31+
32+
// Don't show if user gave a definitive answer (positive/negative)
33+
// Only allow re-prompting if they skipped
34+
if (proactiveAppReview.response && proactiveAppReview.response !== 'skip') {
35+
return false;
36+
}
37+
38+
// Check if lastPrompt is missing or older than 30 days
39+
if (proactiveAppReview.lastPrompt) {
40+
const lastPromptTime = new Date(proactiveAppReview.lastPrompt).getTime();
41+
const now = Date.now();
42+
const daysSinceLastPrompt = (now - lastPromptTime) / THIRTY_DAYS_IN_MS;
43+
44+
if (daysSinceLastPrompt < 1) {
45+
return false;
46+
}
47+
}
48+
49+
return true;
50+
}, [proactiveAppReview]);
51+
52+
return {
53+
shouldShowModal,
54+
proactiveAppReview,
55+
};
56+
}
57+
58+
export default useProactiveAppReview;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
type RespondToProactiveAppReviewParams = {
2+
response: 'positive' | 'negative' | 'skip';
3+
optimisticReportActionID?: string;
4+
conciergeChatReportID?: string;
5+
};
6+
7+
export default RespondToProactiveAppReviewParams;

src/libs/API/parameters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ export type {default as FinishCorpayBankAccountOnboardingParams} from './FinishC
430430
export type {default as RejectMoneyRequestParams} from './RejectMoneyRequestParams';
431431
export type {default as MarkTransactionViolationAsResolvedParams} from './MarkTransactionViolationAsResolvedParams';
432432
export type {default as LockAccountParams} from './LockAccountParams';
433+
export type {default as RespondToProactiveAppReviewParams} from './RespondToProactiveAppReviewParams';
433434
export type {default as SetVacationDelegateParams} from './SetVacationDelegateParams';
434435
export type {default as ExportMultiLevelTagsSpreadSheetParams} from './ExportMultiLevelTagsSpreadSheetParams';
435436
export type {default as ReopenReportParams} from './ReopenReportParams';

0 commit comments

Comments
 (0)