diff --git a/Mobile-Expensify b/Mobile-Expensify index 6be7b9e36f93..10d936295b0b 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 6be7b9e36f93495289c84ad4fef5bea35c76e8d6 +Subproject commit 10d936295b0b0e2fdfe5246a92873a651b77eb6b diff --git a/android/app/build.gradle b/android/app/build.gradle index 8906cda76255..10372f1e5ff5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009008503 - versionName "9.0.85-3" + versionCode 1009008600 + versionName "9.0.86-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 1131589c72ec..c8d825589bfb 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 7FD73CA22B23CE9500420AF3 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7FD73C9B2B23CE9500420AF3 /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 59A21B2405370FDDD847C813 /* libPods-NewExpensify.a */; }; 9E17CB36A6B22BDD4BE53561 /* libPods-NotificationServiceExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9196A72C11B91A52A43D6E8A /* libPods-NotificationServiceExtension.a */; }; + AC131FBB2CF634F20010CE80 /* BackgroundTasks.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AC131FBA2CF634F20010CE80 /* BackgroundTasks.framework */; }; ACA597C323AA39404655647F /* libPods-NewExpensify-NewExpensifyTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = EF33B19FC6A7FE676839430D /* libPods-NewExpensify-NewExpensifyTests.a */; }; BDB853621F354EBB84E619C2 /* ExpensifyNewKansas-MediumItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = D2AFB39EC1D44BF9B91D3227 /* ExpensifyNewKansas-MediumItalic.otf */; }; D27CE6B77196EF3EF450EEAC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0D3F9E814828D91464DF9D35 /* PrivacyInfo.xcprivacy */; }; @@ -141,6 +142,7 @@ 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNeue-BoldItalic.otf"; path = "../assets/fonts/native/ExpensifyNeue-BoldItalic.otf"; sourceTree = ""; }; 8EFE0319D586C1078DB926FD /* Pods-NewExpensify.releaseadhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.releaseadhoc.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.releaseadhoc.xcconfig"; sourceTree = ""; }; 9196A72C11B91A52A43D6E8A /* libPods-NotificationServiceExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NotificationServiceExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + AC131FBA2CF634F20010CE80 /* BackgroundTasks.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = BackgroundTasks.framework; path = System/Library/Frameworks/BackgroundTasks.framework; sourceTree = SDKROOT; }; BBE493797E97F2995E627244 /* Pods-NotificationServiceExtension.debugadhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.debugadhoc.xcconfig"; path = "Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.debugadhoc.xcconfig"; sourceTree = ""; }; BCD444BEDDB0AF1745B39049 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-NewExpensify-NewExpensifyTests/ExpoModulesProvider.swift"; sourceTree = ""; }; BF6A4C5167244B9FB8E4D4E3 /* ExpensifyNeue-Italic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNeue-Italic.otf"; path = "../assets/fonts/native/ExpensifyNeue-Italic.otf"; sourceTree = ""; }; @@ -180,6 +182,7 @@ 383643682B6D4AE2005BB9AE /* DeviceCheck.framework in Frameworks */, E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, + AC131FBB2CF634F20010CE80 /* BackgroundTasks.framework in Frameworks */, 8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -239,6 +242,7 @@ 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { isa = PBXGroup; children = ( + AC131FBA2CF634F20010CE80 /* BackgroundTasks.framework */, 383643672B6D4AE2005BB9AE /* DeviceCheck.framework */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */, ED2971642150620600B7C4FE /* JavaScriptCore.framework */, diff --git a/ios/NewExpensify/AppDelegate.mm b/ios/NewExpensify/AppDelegate.mm index 5608c44823f4..5d419f5a623e 100644 --- a/ios/NewExpensify/AppDelegate.mm +++ b/ios/NewExpensify/AppDelegate.mm @@ -9,6 +9,8 @@ #import "RCTBootSplash.h" #import "RCTStartupTimer.h" #import +#import +#import @interface AppDelegate () @@ -49,6 +51,8 @@ - (BOOL)application:(UIApplication *)application [UIApplication sharedApplication].applicationIconBadgeNumber = 0; [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"isFirstRunComplete"]; } + + [RNBackgroundTaskManager setup]; return YES; } diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 22952c20893a..50f6777232d9 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + com.chat.expensify.backgroundTaskSync + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -19,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.85 + 9.0.86 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +44,7 @@ CFBundleVersion - 9.0.85.3 + 9.0.86.0 FullStory OrgId @@ -97,6 +101,8 @@ UIBackgroundModes remote-notification + fetch + processing UIFileSharingEnabled diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index faebe8bc7337..624b9f40f147 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.85 + 9.0.86 CFBundleSignature ???? CFBundleVersion - 9.0.85.3 + 9.0.86.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 765d5d4b607f..cae2cb516dd4 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.85 + 9.0.86 CFBundleVersion - 9.0.85.3 + 9.0.86.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 58bae61afde8..4ddb40b55336 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -35,6 +35,27 @@ PODS: - EXImageLoader (5.0.0): - ExpoModulesCore - React-Core + - expensify-react-native-background-task (0.0.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - Expo (52.0.14): - ExpoModulesCore - ExpoAsset (11.0.1): @@ -2813,6 +2834,7 @@ DEPENDENCIES: - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - EXAV (from `../node_modules/expo-av/ios`) - EXImageLoader (from `../node_modules/expo-image-loader/ios`) + - "expensify-react-native-background-task (from `../node_modules/@expensify/react-native-background-task`)" - Expo (from `../node_modules/expo`) - ExpoAsset (from `../node_modules/expo-asset/ios`) - ExpoFont (from `../node_modules/expo-font/ios`) @@ -2982,6 +3004,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-av/ios" EXImageLoader: :path: "../node_modules/expo-image-loader/ios" + expensify-react-native-background-task: + :path: "../node_modules/@expensify/react-native-background-task" Expo: :path: "../node_modules/expo" ExpoAsset: @@ -3228,6 +3252,7 @@ SPEC CHECKSUMS: DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 EXAV: 9773c9799767c9925547b05e41a26a0240bb8ef2 EXImageLoader: 759063a65ab016b836f73972d3bb25404888713d + expensify-react-native-background-task: 6f797cf470b627912c246514b1631a205794775d Expo: 0e7b52be71a24a38d5e919e3040d8f51a8739cd0 ExpoAsset: 8138f2a9ec55ae1ad7c3871448379f7d97692d15 ExpoFont: 7522d869d84ee2ee8093ee997fef5b86f85d856b diff --git a/ios/tmp.xcconfig b/ios/tmp.xcconfig new file mode 100644 index 000000000000..ee98b7b0bd8c --- /dev/null +++ b/ios/tmp.xcconfig @@ -0,0 +1,12 @@ +NEW_EXPENSIFY_URL=https:/$()/new.expensify.com/ +SECURE_EXPENSIFY_URL=https:/$()/secure.expensify.com/ +EXPENSIFY_URL=https:/$()/www.expensify.com/ +EXPENSIFY_PARTNER_NAME=chat-expensify-com +EXPENSIFY_PARTNER_PASSWORD=e21965746fd75f82bb66 +PUSHER_APP_KEY=268df511a204fbb60884 +USE_WEB_PROXY=false +ENVIRONMENT=production +SEND_CRASH_REPORTS=true +FB_API_KEY=AIzaSyBrLKgCuo6Vem6Xi5RPokdumssW8HaWBow +FB_APP_ID=1:1008697809946:web:08de4ecb7656b7235445a3 +FB_PROJECT_ID=expensify-mobile-app diff --git a/jest/setup.ts b/jest/setup.ts index c575054f7dac..4db3d945ad8f 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -83,6 +83,11 @@ jest.mock('@src/libs/actions/Timing', () => ({ end: jest.fn(), })); +jest.mock('../modules/background-task/src/NativeReactNativeBackgroundTask', () => ({ + defineTask: jest.fn(), + onBackgroundTaskExecution: jest.fn(), +})); + // This makes FlatList render synchronously for easier testing. jest.mock( '@react-native/virtualized-lists/Interaction/Batchinator', diff --git a/modules/background-task/expensify-react-native-background-task.podspec b/modules/background-task/expensify-react-native-background-task.podspec new file mode 100644 index 000000000000..207cd239c463 --- /dev/null +++ b/modules/background-task/expensify-react-native-background-task.podspec @@ -0,0 +1,20 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) +folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' + +Pod::Spec.new do |s| + s.name = "expensify-react-native-background-task" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => ".git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm,cpp}" + + install_modules_dependencies(s) +end diff --git a/modules/background-task/ios/RNBackgroundTaskManager.h b/modules/background-task/ios/RNBackgroundTaskManager.h new file mode 100644 index 000000000000..38a8a88e734d --- /dev/null +++ b/modules/background-task/ios/RNBackgroundTaskManager.h @@ -0,0 +1,15 @@ +#import +#import +#import + +@interface RNBackgroundTaskManager : NSObject + +@property (nonatomic, copy) void (^ _Nullable taskHandler)(BGTask * _Nonnull); + ++ (instancetype _Nullable )shared; ++ (void)setup; +- (void)setHandlerForIdentifier:(NSString *_Nullable)identifier + completion:(void (^_Nullable)(BGTask * _Nonnull))handler; +- (void (^_Nullable)(BGTask * _Nonnull))handlerForIdentifier:(NSString *_Nullable)identifier; + +@end diff --git a/modules/background-task/ios/RNBackgroundTaskManager.m b/modules/background-task/ios/RNBackgroundTaskManager.m new file mode 100644 index 000000000000..f0bc85dfaa9f --- /dev/null +++ b/modules/background-task/ios/RNBackgroundTaskManager.m @@ -0,0 +1,52 @@ +#import + +@implementation RNBackgroundTaskManager : NSObject { + NSMutableDictionary *_handlers; +} + ++ (instancetype)shared { + static RNBackgroundTaskManager *instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[RNBackgroundTaskManager alloc] init]; + }); + return instance; +} + +- (instancetype)init { + if (self = [super init]) { + _handlers = [NSMutableDictionary new]; + } + return self; +} + +- (void)setHandlerForIdentifier:(NSString *)identifier + completion:(void (^)(BGTask * _Nonnull))handler { + _handlers[identifier] = handler; +} + +- (void (^)(BGTask * _Nonnull))handlerForIdentifier:(NSString *)identifier { + return _handlers[identifier]; +} + ++ (void)setup { + NSArray *backgroundIdentifiers = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"BGTaskSchedulerPermittedIdentifiers"]; + + if (!backgroundIdentifiers || ![backgroundIdentifiers isKindOfClass:[NSArray class]]) { + NSLog(@"[ReactNativeBackgroundTask] No background identifiers found or invalid format"); + } else { + for (NSString *identifier in backgroundIdentifiers) { + [[BGTaskScheduler sharedScheduler] registerForTaskWithIdentifier:identifier + usingQueue:nil + launchHandler:^(BGTask * _Nonnull task) { + NSLog(@"[ReactNativeBackgroundTask] Executing background task: %@", task.identifier); + void (^handler)(BGTask * _Nonnull) = [[RNBackgroundTaskManager shared] handlerForIdentifier:task.identifier]; + if (handler) { + handler(task); + } + }]; + } + } +} + +@end diff --git a/modules/background-task/ios/ReactNativeBackgroundTask.h b/modules/background-task/ios/ReactNativeBackgroundTask.h new file mode 100644 index 000000000000..93d1e83a6878 --- /dev/null +++ b/modules/background-task/ios/ReactNativeBackgroundTask.h @@ -0,0 +1,18 @@ +#ifdef RCT_NEW_ARCH_ENABLED +#import "RNReactNativeBackgroundTaskSpec.h" +#import + +@interface ReactNativeBackgroundTask : NativeReactNativeBackgroundTaskSpecBase +#else +#import +#import + +@interface ReactNativeBackgroundTask : NSObject +#endif + +- (void)defineTask:(NSString *)taskName + taskExecutor:(RCTResponseSenderBlock)taskExecutor + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +@end diff --git a/modules/background-task/ios/ReactNativeBackgroundTask.mm b/modules/background-task/ios/ReactNativeBackgroundTask.mm new file mode 100644 index 000000000000..f963c928a41e --- /dev/null +++ b/modules/background-task/ios/ReactNativeBackgroundTask.mm @@ -0,0 +1,117 @@ +#import "ReactNativeBackgroundTask.h" +#import +#import +#import "RNBackgroundTaskManager.h" + +@implementation ReactNativeBackgroundTask { + NSMutableDictionary *_taskExecutors; +} + +RCT_EXPORT_MODULE() + +- (instancetype)init { + if (self = [super init]) { + _taskExecutors = [NSMutableDictionary new]; + } + return self; +} + +- (BOOL)scheduleNewBackgroundTask:(NSString *)identifier error:(NSError **)outError { + BGAppRefreshTaskRequest *request = [[BGAppRefreshTaskRequest alloc] initWithIdentifier:identifier]; + + // Set earliest begin date to some time in the future + request.earliestBeginDate = [NSDate dateWithTimeIntervalSinceNow:15 * 60]; // 15 minutes from now + + NSError *error = nil; + BOOL success = [[BGTaskScheduler sharedScheduler] submitTaskRequest:request error:&error]; + + if (!success) { + NSLog(@"[ReactNativeBackgroundTask] Failed to schedule task: %@", error.localizedDescription); + if (outError != nil) { + *outError = error; + } + } + + return success; +} + +RCT_EXPORT_METHOD(defineTask:(NSString *)taskName + taskExecutor:(RCTResponseSenderBlock)taskExecutor + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + if (taskName == nil) { + NSLog(@"[ReactNativeBackgroundTask] Failed to define task: taskName is nil"); + reject(@"ERR_INVALID_TASK_NAME", @"Task name must be provided", nil); + return; + } + + if (taskExecutor == nil) { + NSLog(@"[ReactNativeBackgroundTask] Failed to define task: taskExecutor is nil"); + reject(@"ERR_INVALID_TASK_EXECUTOR", @"Task executor must be provided", nil); + return; + } + + NSArray *backgroundIdentifiers = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"BGTaskSchedulerPermittedIdentifiers"]; + + + if (!backgroundIdentifiers || ![backgroundIdentifiers isKindOfClass:[NSArray class]]) { + NSLog(@"[ReactNativeBackgroundTask] No background identifiers found or invalid format"); + reject(@"ERR_INVALID_TASK_SCHEDULER_IDENTIFIER", @"No background identifiers found or invalid format", nil); + return; + } + + NSLog(@"[ReactNativeBackgroundTask] Defining task: %@", taskName); + + BOOL allSuccess = YES; + NSError *taskError = nil; + + for (NSString *identifier in backgroundIdentifiers) { + [[RNBackgroundTaskManager shared] setHandlerForIdentifier:identifier completion:^(BGTask * _Nonnull task) { + NSLog(@"[ReactNativeBackgroundTask] Executing background task's handler"); + + // Execute all registered tasks + [self->_taskExecutors enumerateKeysAndObjectsUsingBlock:^(NSString *taskName, RCTResponseSenderBlock executor, BOOL *stop) { + NSLog(@"[ReactNativeBackgroundTask] Executing task: %@", taskName); + [self emitOnBackgroundTaskExecution:(taskName)]; + }]; + + NSError *scheduleError = nil; + [self scheduleNewBackgroundTask:identifier error:&scheduleError]; + + [task setTaskCompletedWithSuccess:YES]; + }]; + + NSError *scheduleError = nil; + BOOL success = [self scheduleNewBackgroundTask:identifier error:&scheduleError]; + + if (success) { + _taskExecutors[taskName] = taskExecutor; + } else { + allSuccess = NO; + taskError = scheduleError; + break; + } + } + + if (allSuccess) { + resolve(@YES); + } else { + reject(@"ERR_SCHEDULE_TASK_FAILED", + taskError.localizedDescription ?: @"Failed to schedule initial background task", + taskError); + } + + _taskExecutors[taskName] = taskExecutor; +} + +// Don't compile this code when we build for the old architecture. +#ifdef RCT_NEW_ARCH_ENABLED +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} +#endif + +@end diff --git a/modules/background-task/package.json b/modules/background-task/package.json new file mode 100644 index 000000000000..92d464777bae --- /dev/null +++ b/modules/background-task/package.json @@ -0,0 +1,19 @@ +{ + "name": "@expensify/react-native-background-task", + "version": "0.0.0", + "description": "Execute tasks in background", + "main": "src/index", + "codegenConfig": { + "name": "RNReactNativeBackgroundTaskSpec", + "type": "modules", + "jsSrcsDir": "src" + }, + "author": " <> ()", + "license": "UNLICENSED", + "homepage": "#readme", + "create-react-native-library": { + "type": "module-mixed", + "languages": "kotlin-objc", + "version": "0.44.1" + } +} diff --git a/modules/background-task/react-native.config.js b/modules/background-task/react-native.config.js new file mode 100644 index 000000000000..d532440e69b0 --- /dev/null +++ b/modules/background-task/react-native.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('@react-native-community/cli-types').UserDependencyConfig} + */ +module.exports = { + dependency: { + platforms: { + android: null, + }, + }, +}; diff --git a/modules/background-task/src/NativeReactNativeBackgroundTask.ts b/modules/background-task/src/NativeReactNativeBackgroundTask.ts new file mode 100644 index 000000000000..792fe2850552 --- /dev/null +++ b/modules/background-task/src/NativeReactNativeBackgroundTask.ts @@ -0,0 +1,12 @@ +import type {TurboModule} from 'react-native'; +import {TurboModuleRegistry} from 'react-native'; +import type {EventEmitter} from 'react-native/Libraries/Types/CodegenTypes'; + +// We need to export the interface inline for proper TypeScript type inference with TurboModules +// eslint-disable-next-line rulesdir/no-inline-named-export, @typescript-eslint/consistent-type-definitions +export interface Spec extends TurboModule { + defineTask(taskName: string, taskExecutor: (data: unknown) => void | Promise): Promise; + readonly onBackgroundTaskExecution: EventEmitter; +} + +export default TurboModuleRegistry.getEnforcing('ReactNativeBackgroundTask'); diff --git a/modules/background-task/src/index.ts b/modules/background-task/src/index.ts new file mode 100644 index 000000000000..d3afe75a85bc --- /dev/null +++ b/modules/background-task/src/index.ts @@ -0,0 +1,35 @@ +import NativeReactNativeBackgroundTask from './NativeReactNativeBackgroundTask'; + +type TaskManagerTaskExecutor = (data: T) => void | Promise; + +const taskExecutors = new Map(); + +NativeReactNativeBackgroundTask.onBackgroundTaskExecution((taskName) => { + const executor = taskExecutors.get(taskName); + + if (executor) { + executor(taskName); + } +}); + +const TaskManager = { + /** + * Defines a task that can be executed in the background. + * @param taskName - Name of the task. Must be unique and match the name used when registering the task. + * @param taskExecutor - Function that will be executed when the task runs. + */ + defineTask: (taskName: string, taskExecutor: TaskManagerTaskExecutor): Promise => { + if (typeof taskName !== 'string' || taskName.length === 0) { + throw new Error('Task name must be a string'); + } + if (typeof taskExecutor !== 'function') { + throw new Error('Task executor must be a function'); + } + + taskExecutors.set(taskName, taskExecutor); + + return NativeReactNativeBackgroundTask.defineTask(taskName, taskExecutor); + }, +}; + +export default TaskManager; diff --git a/package-lock.json b/package-lock.json index 1aeefc381bd8..a9b423d5e2ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "new.expensify", - "version": "9.0.85-3", + "version": "9.0.86-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.85-3", + "version": "9.0.86-0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", + "@expensify/react-native-background-task": "file:./modules/background-task", "@expensify/react-native-live-markdown": "0.1.210", "@expo/metro-runtime": "^4.0.0", "@firebase/app": "^0.10.10", @@ -285,6 +286,11 @@ "npm": "10.8.2" } }, + "modules/background-task": { + "name": "@expensify/react-native-background-task", + "version": "0.0.0", + "license": "UNLICENSED" + }, "node_modules/@0no-co/graphql.web": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.0.11.tgz", @@ -3630,6 +3636,10 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@expensify/react-native-background-task": { + "resolved": "modules/background-task", + "link": true + }, "node_modules/@expensify/react-native-live-markdown": { "version": "0.1.210", "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.210.tgz", diff --git a/package.json b/package.json index b744f1c679c0..44c2b6ba4da9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.85-3", + "version": "9.0.86-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -77,6 +77,7 @@ "dependencies": { "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-live-markdown": "0.1.210", + "@expensify/react-native-background-task": "file:./modules/background-task", "@expo/metro-runtime": "^4.0.0", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", diff --git a/patches/focus-trap+7.5.4.patch b/patches/focus-trap+7.5.4.patch index c7b2aef2b51f..19ac4432dfaf 100644 --- a/patches/focus-trap+7.5.4.patch +++ b/patches/focus-trap+7.5.4.patch @@ -1,7 +1,22 @@ diff --git a/node_modules/focus-trap/dist/focus-trap.esm.js b/node_modules/focus-trap/dist/focus-trap.esm.js -index 10d56db..a6d76d8 100644 +index 10d56db..975151c 100644 --- a/node_modules/focus-trap/dist/focus-trap.esm.js +++ b/node_modules/focus-trap/dist/focus-trap.esm.js +@@ -71,12 +71,12 @@ var activeFocusTraps = { + trapStack.push(trap); + } + }, +- deactivateTrap: function deactivateTrap(trapStack, trap) { ++ deactivateTrap: function deactivateTrap(trapStack, trap, unpauseOnDeactivate) { + var trapIndex = trapStack.indexOf(trap); + if (trapIndex !== -1) { + trapStack.splice(trapIndex, 1); + } +- if (trapStack.length > 0) { ++ if (trapStack.length > 0 && unpauseOnDeactivate) { + trapStack[trapStack.length - 1].unpause(); + } + } @@ -100,8 +100,8 @@ var isKeyForward = function isKeyForward(e) { var isKeyBackward = function isKeyBackward(e) { return isTabEvent(e) && e.shiftKey; @@ -13,7 +28,15 @@ index 10d56db..a6d76d8 100644 }; // Array.find/findIndex() are not supported on IE; this replicates enough -@@ -283,7 +283,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { +@@ -153,6 +153,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { + var doc = (userOptions === null || userOptions === void 0 ? void 0 : userOptions.document) || document; + var trapStack = (userOptions === null || userOptions === void 0 ? void 0 : userOptions.trapStack) || internalTrapStack; + var config = _objectSpread2({ ++ unpauseOnDeactivate: true, + returnFocusOnDeactivate: true, + escapeDeactivates: true, + delayInitialFocus: true, +@@ -283,7 +284,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { return node; }; var getInitialFocusNode = function getInitialFocusNode() { @@ -22,7 +45,7 @@ index 10d56db..a6d76d8 100644 // false explicitly indicates we want no initialFocus at all if (node === false) { -@@ -744,7 +744,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { +@@ -744,7 +745,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { // that caused the focus trap activation. state.delayInitialFocusTimer = config.delayInitialFocus ? delay(function () { tryFocus(getInitialFocusNode()); @@ -31,7 +54,16 @@ index 10d56db..a6d76d8 100644 doc.addEventListener('focusin', checkFocusIn, true); doc.addEventListener('mousedown', checkPointerDown, { capture: true, -@@ -880,7 +880,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { +@@ -868,7 +869,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { + state.active = false; + state.paused = false; + updateObservedNodes(); +- activeFocusTraps.deactivateTrap(trapStack, trap); ++ activeFocusTraps.deactivateTrap(trapStack, trap, config.unpauseOnDeactivate); + var onDeactivate = getOption(options, 'onDeactivate'); + var onPostDeactivate = getOption(options, 'onPostDeactivate'); + var checkCanReturnFocus = getOption(options, 'checkCanReturnFocus'); +@@ -880,7 +881,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { tryFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation)); } onPostDeactivate === null || onPostDeactivate === void 0 || onPostDeactivate(); @@ -41,7 +73,7 @@ index 10d56db..a6d76d8 100644 if (returnFocus && checkCanReturnFocus) { checkCanReturnFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation)).then(finishDeactivation, finishDeactivation); diff --git a/node_modules/focus-trap/index.d.ts b/node_modules/focus-trap/index.d.ts -index 400db1b..69f4b94 100644 +index 400db1b..78b38d9 100644 --- a/node_modules/focus-trap/index.d.ts +++ b/node_modules/focus-trap/index.d.ts @@ -16,7 +16,7 @@ declare module 'focus-trap' { @@ -53,7 +85,19 @@ index 400db1b..69f4b94 100644 type MouseEventToBoolean = (event: MouseEvent | TouchEvent) => boolean; type KeyboardEventToBoolean = (event: KeyboardEvent) => boolean; -@@ -185,7 +185,7 @@ declare module 'focus-trap' { +@@ -135,6 +135,11 @@ declare module 'focus-trap' { + * in the trap. + */ + fallbackFocus?: FocusTarget; ++ /** ++ * By default, the previous focus trap on the stack will be unpaused ++ * when the current active trap is deactivated. ++ */ ++ unpauseOnDeactivate?: boolean; + /** + * Default: `true`. If `false`, when the trap is deactivated, + * focus will *not* return to the element that had focus before activation. +@@ -185,7 +190,7 @@ declare module 'focus-trap' { * This prevents elements within the focusable element from capturing * the event that triggered the focus trap activation. */ diff --git a/src/App.tsx b/src/App.tsx index e11592609350..420901e4999e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,7 @@ import {ReportIDsContextProvider} from './hooks/useReportIDs'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; import type {Route} from './ROUTES'; +import './setup/backgroundTask'; import {SplashScreenStateContextProvider} from './SplashScreenStateContext'; type AppProps = { diff --git a/src/CONST.ts b/src/CONST.ts index 69d01c6b09d5..36fc2316372e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -72,6 +72,7 @@ const selectableOnboardingChoices = { const backendOnboardingChoices = { ADMIN: 'newDotAdmin', SUBMIT: 'newDotSubmit', + TRACK_WORKSPACE: 'newDotTrackWorkspace', } as const; const onboardingChoices = { @@ -98,6 +99,50 @@ const selfGuidedTourTask: OnboardingTask = { description: ({navatticURL}) => `[Take a self-guided product tour](${navatticURL}) and learn about everything Expensify has to offer.`, }; +const createWorkspaceTask: OnboardingTask = { + type: 'createWorkspace', + autoCompleted: true, + title: 'Create a workspace', + description: + '*Create a workspace* to track expenses, scan receipts, chat, and more.\n' + + '\n' + + 'Here’s how to create a workspace:\n' + + '\n' + + '1. Click the settings tab.\n' + + '2. Click *Workspaces* > *New workspace*.\n' + + '\n' + + '*Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.*', +}; + +const meetGuideTask: OnboardingTask = { + type: 'meetGuide', + autoCompleted: false, + title: 'Meet your setup specialist', + description: ({adminsRoomLink}) => + `Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` + + '\n' + + `Chat with the specialist in your [#admins room](${adminsRoomLink}).`, +}; + +const setupCategoriesTask: OnboardingTask = { + type: 'setupCategories', + autoCompleted: false, + title: 'Set up categories', + description: ({workspaceCategoriesLink}) => + '*Set up categories* so your team can code expenses for easy reporting.\n' + + '\n' + + 'Here’s how to set up categories:\n' + + '\n' + + '1. Click the settings tab.\n' + + '2. Go to *Workspaces*.\n' + + '3. Select your workspace.\n' + + '4. Click *Categories*.\n' + + "5. Disable any categories you don't need.\n" + + '6. Add your own categories in the top right.\n' + + '\n' + + `[Take me to workspace category settings](${workspaceCategoriesLink}).`, +}; + const onboardingEmployerOrSubmitMessage: OnboardingMessage = { message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', video: { @@ -119,7 +164,7 @@ const onboardingEmployerOrSubmitMessage: OnboardingMessage = { 'Here’s how to submit an expense:\n' + '\n' + '1. Click the green *+* button.\n' + - '2. Choose *Submit expense*.\n' + + '2. Choose *Create expense*.\n' + '3. Enter an amount or scan a receipt.\n' + '4. Add your reimburser to the request.\n' + '\n' + @@ -4506,6 +4551,7 @@ const CONST = { TRAVEL_DOT_URL: 'https://travel.expensify.com', STAGING_TRAVEL_DOT_URL: 'https://staging.travel.expensify.com', TRIP_ID_PATH: (tripID?: string) => (tripID ? `trips/${tripID}` : undefined), + TRIP_SUPPORT: '/support', SPOTNANA_TMC_ID: '8e8e7258-1cf3-48c0-9cd1-fe78a6e31eed', STAGING_SPOTNANA_TMC_ID: '7a290c6e-5328-4107-aff6-e48765845b81', SCREEN_READER_STATES: { @@ -5013,30 +5059,9 @@ const CONST = { height: 960, }, tasks: [ - { - type: 'createWorkspace', - autoCompleted: true, - title: 'Create a workspace', - description: - '*Create a workspace* to track expenses, scan receipts, chat, and more.\n' + - '\n' + - 'Here’s how to create a workspace:\n' + - '\n' + - '1. Click the settings tab.\n' + - '2. Click *Workspaces* > *New workspace*.\n' + - '\n' + - '*Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.*', - }, + createWorkspaceTask, selfGuidedTourTask, - { - type: 'meetGuide', - autoCompleted: false, - title: 'Meet your setup specialist', - description: ({adminsRoomLink}) => - `Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` + - '\n' + - `Chat with the specialist in your [#admins room](${adminsRoomLink}).`, - }, + meetGuideTask, { type: 'setupCategoriesAndTags', autoCompleted: false, @@ -5046,24 +5071,7 @@ const CONST = { '\n' + `Import them automatically by [connecting your accounting software](${workspaceAccountingLink}), or set them up manually in your [workspace settings](${workspaceSettingsLink}).`, }, - { - type: 'setupCategories', - autoCompleted: false, - title: 'Set up categories', - description: ({workspaceCategoriesLink}) => - '*Set up categories* so your team can code expenses for easy reporting.\n' + - '\n' + - 'Here’s how to set up categories:\n' + - '\n' + - '1. Click the settings tab.\n' + - '2. Go to *Workspaces*.\n' + - '3. Select your workspace.\n' + - '4. Click *Categories*.\n' + - "5. Disable any categories you don't need.\n" + - '6. Add your own categories in the top right.\n' + - '\n' + - `[Take me to workspace category settings](${workspaceCategoriesLink}).`, - }, + setupCategoriesTask, { type: 'setupTags', autoCompleted: false, @@ -5140,6 +5148,42 @@ const CONST = { }, ], }, + [onboardingChoices.TRACK_WORKSPACE]: { + message: 'Here are some important tasks to help get your workspace set up.', + video: { + url: `${CLOUDFRONT_URL}/videos/guided-setup-manage-team-v2.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-manage-team.jpg`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + createWorkspaceTask, + meetGuideTask, + setupCategoriesTask, + { + type: 'inviteAccountant', + autoCompleted: false, + title: 'Invite your accountant', + description: ({workspaceMembersLink}) => + '*Invite your accountant to Expensify and share your expenses with them to make tax time easier.\n' + + '\n' + + 'Here’s how to invite your accountant:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Go to *Workspaces*.\n' + + '3. Select your workspace.\n' + + '4. Click *Members* > Invite member.\n' + + '5. Enter their email or phone number.\n' + + '6. Add an invite message if you’d like.\n' + + '7. You’ll be set as the expense approver. You can change this to any admin once you invite your team.\n' + + '\n' + + 'That’s it, happy expensing! 😄\n' + + '\n' + + `[View your workspace members](${workspaceMembersLink}).`, + }, + ], + }, [onboardingChoices.PERSONAL_SPEND]: onboardingPersonalSpendMessage, [onboardingChoices.CHAT_SPLIT]: { message: 'Splitting bills with friends is as easy as sending a message. Here’s how.', @@ -5198,15 +5242,7 @@ const CONST = { height: 960, }, tasks: [ - { - type: 'meetSetupSpecialist', - autoCompleted: false, - title: 'Meet your setup specialist', - description: - '*Meet your setup specialist* who can answer any questions as you get started with Expensify. Yes, a real human!' + - '\n' + - 'Chat with them in your #admins room or schedule a call today.', - }, + meetGuideTask, { type: 'reviewWorkspaceSettings', autoCompleted: false, @@ -5229,7 +5265,7 @@ const CONST = { 'Here’s how to submit an expense:\n' + '\n' + '1. Click the green *+* button.\n' + - '2. Choose *Submit expense*.\n' + + '2. Choose *Create expense*.\n' + '3. Enter an amount or scan a receipt.\n' + '4. Add your reimburser to the request.\n' + '\n' + diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index 63a33899822c..a9407cccc00a 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -15,6 +15,7 @@ function FocusTrapForModal({children, active, initialFocus = false}: FocusTrapFo clickOutsideDeactivates: true, initialFocus, fallbackFocus: document.body, + unpauseOnDeactivate: false, setReturnFocus: (element) => { if (ReportActionComposeFocusManager.isFocused()) { return false; diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index 14f14aee8c73..9f14c49929c3 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -50,6 +50,7 @@ function FocusTrapForScreen({children, focusTrapSettings}: FocusTrapProps) { delayInitialFocus: CONST.ANIMATED_TRANSITION, initialFocus: false, setReturnFocus: false, + unpauseOnDeactivate: false, ...(focusTrapSettings?.focusTrapOptions ?? {}), }} > diff --git a/src/components/PushRowWithModal/index.tsx b/src/components/PushRowWithModal/index.tsx index 83128899b50f..4be81cb0f193 100644 --- a/src/components/PushRowWithModal/index.tsx +++ b/src/components/PushRowWithModal/index.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import CONST from '@src/CONST'; @@ -34,6 +34,9 @@ type PushRowWithModalProps = { /** The ID of the input that should be reset when the value changes */ stateInputIDToReset?: string; + + /** Callback to call when the picker modal is dismissed */ + onBlur?: () => void; }; function PushRowWithModal({ @@ -47,10 +50,14 @@ function PushRowWithModal({ errorText, onInputChange = () => {}, stateInputIDToReset, + onBlur = () => {}, }: PushRowWithModalProps) { const [isModalVisible, setIsModalVisible] = useState(false); - + const shouldBlurOnCloseRef = useRef(true); const handleModalClose = () => { + if (shouldBlurOnCloseRef.current) { + onBlur?.(); + } setIsModalVisible(false); }; @@ -60,7 +67,7 @@ function PushRowWithModal({ const handleOptionChange = (optionValue: string) => { onInputChange(optionValue); - + shouldBlurOnCloseRef.current = false; if (stateInputIDToReset) { onInputChange('', stateInputIDToReset); } diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 0d312b5d1a40..097f67f34a60 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -558,7 +558,12 @@ function ReportPreview({ - {previewMessage} + + {previewMessage} + {shouldShowRBR && ( 1; - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); + const displayNamesWithTooltips = getDisplayNamesWithTooltips(getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); const welcomeMessage = SidebarUtils.getWelcomeMessage(report, policy); - const moneyRequestOptions = ReportUtils.temporary_getMoneyRequestOptions(report, policy, participantAccountIDs); - const canEditReportDescription = ReportUtils.canEditReportDescription(report, policy); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, policy, participantAccountIDs); const {canUseCombinedTrackSubmit} = usePermissions(); const filteredOptions = moneyRequestOptions.filter( (item): item is Exclude => @@ -60,8 +73,7 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { )}`, ) .join(', '); - const canEditPolicyDescription = ReportUtils.canEditPolicyDescription(policy); - const reportName = ReportUtils.getReportName(report); + const reportName = getReportName(report); const shouldShowUsePlusButtonText = (moneyRequestOptions.includes(CONST.IOU.TYPE.PAY) || moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT) || @@ -105,75 +117,45 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { {isPolicyExpenseChat && (welcomeMessage?.messageHtml ? ( - { - if (!canEditPolicyDescription) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy?.id)); - }} - style={[styles.renderHTML, canEditPolicyDescription ? styles.cursorPointer : styles.cursorText]} - accessibilityLabel={translate('reportDescriptionPage.roomDescription')} - > + - + ) : ( {welcomeMessage.phrase1} - {ReportUtils.getDisplayNameForParticipant(report?.ownerAccountID)} + {getDisplayNameForParticipant(report?.ownerAccountID)} {welcomeMessage.phrase2} - {ReportUtils.getPolicyName(report)} + {getPolicyName(report)} {welcomeMessage.phrase3} ))} {isInvoiceRoom && !isArchivedRoom && (welcomeMessage?.messageHtml ? ( - { - if (!canEditReportDescription) { - return; - } - const activeRoute = Navigation.getActiveRoute(); - Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report?.reportID, activeRoute)); - }} - style={[styles.renderHTML, canEditReportDescription ? styles.cursorPointer : styles.cursorText]} - accessibilityLabel={translate('reportDescriptionPage.roomDescription')} - > + - + ) : ( {welcomeMessage.phrase1} {report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL ? ( - {ReportUtils.getDisplayNameForParticipant(report?.invoiceReceiver?.accountID)} + {getDisplayNameForParticipant(report?.invoiceReceiver?.accountID)} ) : ( {getPolicy(report?.invoiceReceiver?.policyID)?.name} )} {` ${translate('common.and')} `} - {ReportUtils.getPolicyName(report)} + {getPolicyName(report)} {welcomeMessage.phrase2} ))} {isChatRoom && (!isInvoiceRoom || isArchivedRoom) && (welcomeMessage?.messageHtml ? ( - { - const activeRoute = Navigation.getActiveRoute(); - if (canEditReportDescription) { - Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report?.reportID, activeRoute)); - return; - } - Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID, activeRoute)); - }} - style={styles.renderHTML} - accessibilityLabel={translate('reportDescriptionPage.roomDescription')} - > + - + ) : ( {welcomeMessage.phrase1} @@ -183,7 +165,7 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { onPress={navigateToReport} suppressHighlighting > - {ReportUtils.getReportName(report)} + {getReportName(report)} )} {welcomeMessage.phrase2 !== undefined && {welcomeMessage.phrase2}} @@ -206,7 +188,7 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { // eslint-disable-next-line react/no-array-index-key - {ReportUtils.isOptimisticPersonalDetail(accountID) ? ( + {isOptimisticPersonalDetail(accountID) ? ( {displayName} ) : ( )} {shouldShowUsePlusButtonText && {translate('reportActionsView.usePlusButton', {additionalText})}} - {ReportUtils.isConciergeChatReport(report) && {translate('reportActionsView.askConcierge')}} + {isConciergeChatReport(report) && {translate('reportActionsView.askConcierge')}} ); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index f00454099a03..9533a81257ec 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -334,7 +334,10 @@ function BaseSelectionList( isFocused, }); - const selectedItemIndex = useMemo(() => flattenedSections.allOptions.findIndex((option) => option.isSelected), [flattenedSections.allOptions]); + const selectedItemIndex = useMemo( + () => (initiallyFocusedOptionKey ? flattenedSections.allOptions.findIndex((option) => option.isSelected) : -1), + [flattenedSections.allOptions, initiallyFocusedOptionKey], + ); useEffect(() => { if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex) { diff --git a/src/languages/en.ts b/src/languages/en.ts index e976ae1f67db..e0a11514c1ca 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -361,6 +361,7 @@ const translations = { invalidRateError: 'Please enter a valid rate.', lowRateError: 'Rate must be greater than 0.', email: 'Please enter a valid email address.', + login: 'An error occurred while logging in. Please try again.', }, comma: 'comma', semicolon: 'semicolon', @@ -4177,7 +4178,7 @@ const translations = { from: 'from', }, inviteMessage: { - inviteMessageTitle: 'Add message', + confirmDetails: 'Confirm details', inviteMessagePrompt: 'Make your invitation extra special by adding a message below!', personalMessagePrompt: 'Message', genericFailureMessage: 'An error occurred while inviting the member to the workspace. Please try again.', diff --git a/src/languages/es.ts b/src/languages/es.ts index ae32a90bbcec..48ee727c7fa6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -352,6 +352,7 @@ const translations = { invalidRateError: 'Por favor, introduce una tarifa válida.', lowRateError: 'La tarifa debe ser mayor que 0.', email: 'Por favor, introduzca una dirección de correo electrónico válida.', + login: 'Se produjo un error al iniciar sesión. Por favor intente nuevamente.', }, comma: 'la coma', semicolon: 'el punto y coma', @@ -4222,7 +4223,7 @@ const translations = { from: 'de', }, inviteMessage: { - inviteMessageTitle: 'Añadir un mensaje', + confirmDetails: 'Confirma los detalles', inviteMessagePrompt: '¡Añadir un mensaje para hacer tu invitación destacar!', personalMessagePrompt: 'Mensaje', inviteNoMembersError: 'Por favor, selecciona al menos un miembro a invitar.', diff --git a/src/libs/API/parameters/CreateWorkspaceParams.ts b/src/libs/API/parameters/CreateWorkspaceParams.ts index 91c1039169aa..1b8cfcaeecd5 100644 --- a/src/libs/API/parameters/CreateWorkspaceParams.ts +++ b/src/libs/API/parameters/CreateWorkspaceParams.ts @@ -11,6 +11,7 @@ type CreateWorkspaceParams = { customUnitID: string; customUnitRateID: string; engagementChoice?: string; + guidedSetupData?: string; }; export default CreateWorkspaceParams; diff --git a/src/libs/LoginUtils.ts b/src/libs/LoginUtils.ts index 191fd72db4e9..f496c9de0e6e 100644 --- a/src/libs/LoginUtils.ts +++ b/src/libs/LoginUtils.ts @@ -1,7 +1,11 @@ import {PUBLIC_DOMAINS, Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import * as Session from './actions/Session'; +import Navigation from './Navigation/Navigation'; import {parsePhoneNumber} from './PhoneNumber'; let countryCodeByIP: number; @@ -75,4 +79,26 @@ function areEmailsFromSamePrivateDomain(email1: string, email2: string): boolean return Str.extractEmailDomain(email1).toLowerCase() === Str.extractEmailDomain(email2).toLowerCase(); } -export {getPhoneNumberWithoutSpecialChars, appendCountryCode, isEmailPublicDomain, validateNumber, getPhoneLogin, areEmailsFromSamePrivateDomain}; +function postSAMLLogin(body: FormData): Promise { + return fetch(CONFIG.EXPENSIFY.SAML_URL, { + method: CONST.NETWORK.METHOD.POST, + body, + credentials: 'omit', + }).then((response) => { + if (!response.ok) { + throw new Error('An error occurred while logging in. Please try again'); + } + return response.json() as Promise; + }); +} + +function handleSAMLLoginError(errorMessage: string, cleanSignInData: boolean) { + if (cleanSignInData) { + Session.clearSignInData(); + } + + Session.setAccountError(errorMessage); + Navigation.goBack(ROUTES.HOME); +} + +export {getPhoneNumberWithoutSpecialChars, appendCountryCode, isEmailPublicDomain, validateNumber, getPhoneLogin, areEmailsFromSamePrivateDomain, postSAMLLogin, handleSAMLLoginError}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7b26e6f6f2a9..a8959b3a01d7 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4263,7 +4263,7 @@ function getReportName( } if (isInvoiceRoom(report)) { - formattedName = getInvoicesChatName(report, invoiceReceiverPolicy); + formattedName = getInvoicesChatName(report, invoiceReceiverPolicy, personalDetails); } if (isArchivedNonExpenseReport(report, getReportNameValuePairs(report?.reportID))) { diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 0ac396709b07..fb5a1265fc5c 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -2707,7 +2707,7 @@ function getTrackExpenseInformation( let createdWorkspaceParams: CreateWorkspaceParams | undefined; if (isDraftReportLocal) { - const workspaceData = buildPolicyData(undefined, policy?.makeMeAdmin, policy?.name, policy?.id, chatReport?.reportID); + const workspaceData = buildPolicyData(undefined, policy?.makeMeAdmin, policy?.name, policy?.id, chatReport?.reportID, CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE); createdWorkspaceParams = workspaceData.params; optimisticData.push(...workspaceData.optimisticData); successData.push(...workspaceData.successData); @@ -3876,6 +3876,8 @@ function categorizeTrackedExpense(trackedExpenseParams: CategorizeTrackedExpense policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, + engagementChoice: createdWorkspaceParams?.engagementChoice, + guidedSetupData: createdWorkspaceParams?.guidedSetupData, }; API.write(WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); @@ -3957,6 +3959,8 @@ function shareTrackedExpense( policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, + engagementChoice: createdWorkspaceParams?.engagementChoice, + guidedSetupData: createdWorkspaceParams?.guidedSetupData, }; API.write(WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index da910af2c8ef..44b0a71dc72c 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -667,7 +667,7 @@ function clearWorkspaceOwnerChangeFlow(policyID: string) { * Adds members to the specified workspace/policyID * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details */ -function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string, policyMemberAccountIDs: number[]) { +function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string, policyMemberAccountIDs: number[], role: string) { const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin)); const accountIDs = Object.values(invitedEmailsToAccountIDs); @@ -689,7 +689,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount optimisticMembersState[email] = { email, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - role: CONST.POLICY.ROLE.USER, + role, submitsTo: getDefaultApprover(allPolicies?.[policyKey]), }; successMembersState[email] = {pendingAction: null}; @@ -737,7 +737,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount failureData.push(...membersChats.onyxFailureData, ...announceRoomChat.onyxFailureData, ...announceRoomMembers.onyxFailureData); const params: AddMembersToWorkspaceParams = { - employees: JSON.stringify(logins.map((login) => ({email: login}))), + employees: JSON.stringify(logins.map((login) => ({email: login, role}))), ...(optimisticAnnounceChat.announceChatReportID ? {announceChatReportID: optimisticAnnounceChat.announceChatReportID} : {}), ...(optimisticAnnounceChat.announceChatReportActionID ? {announceCreatedReportActionID: optimisticAnnounceChat.announceChatReportActionID} : {}), welcomeNote: Parser.replace(welcomeNote, { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index f28a82bea9bb..c9c42903ab8a 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5,6 +5,7 @@ import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-nat import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {ReportExportType} from '@components/ButtonWithDropdownMenu/types'; +import {prepareOnboardingOnyxData} from '@libs/actions/Report'; import * as API from '@libs/API'; import type { AddBillingCardAndRequestWorkspaceOwnerChangeParams, @@ -78,9 +79,11 @@ import {getAllReportTransactions} from '@libs/TransactionUtils'; import type {PolicySelector} from '@pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover'; import * as PaymentMethods from '@userActions/PaymentMethods'; import * as PersistedRequests from '@userActions/PersistedRequests'; +import type {OnboardingPurpose} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type { + IntroSelected, InvitedEmailsToAccountIDs, PersonalDetailsList, Policy, @@ -1680,6 +1683,12 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol Onyx.update(optimisticData); } +let introSelected: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_INTRO_SELECTED, + callback: (value) => (introSelected = value), +}); + /** * Generates onyx data for creating a new workspace * @@ -1689,7 +1698,7 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol * @param [policyID] custom policy id we will use for created workspace * @param [expenseReportId] the reportID of the expense report that is being used to create the workspace */ -function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), expenseReportId?: string, engagementChoice?: string) { +function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), expenseReportId?: string, engagementChoice?: OnboardingPurpose) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); @@ -1951,9 +1960,24 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName expenseCreatedReportActionID, customUnitID, customUnitRateID, - engagementChoice, }; + if (!introSelected?.createWorkspace && engagementChoice) { + const { + guidedSetupData, + optimisticData: taskOptimisticData, + successData: taskSuccessData, + failureData: taskFailureData, + } = prepareOnboardingOnyxData(engagementChoice, CONST.ONBOARDING_MESSAGES[engagementChoice], expenseChatReportID, policyID); + + params.guidedSetupData = JSON.stringify(guidedSetupData); + params.engagementChoice = engagementChoice; + + optimisticData.push(...taskOptimisticData); + successData.push(...taskSuccessData); + failureData.push(...taskFailureData); + } + return {successData, optimisticData, failureData, params}; } @@ -1966,7 +1990,13 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName * @param [policyID] custom policy id we will use for created workspace * @param [engagementChoice] Purpose of using application selected by user in guided setup flow */ -function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), engagementChoice = ''): CreateWorkspaceParams { +function createWorkspace( + policyOwnerEmail = '', + makeMeAdmin = false, + policyName = '', + policyID = generatePolicyID(), + engagementChoice: OnboardingPurpose = CONST.ONBOARDING_CHOICES.MANAGE_TEAM, +): CreateWorkspaceParams { const {optimisticData, failureData, successData, params} = buildPolicyData(policyOwnerEmail, makeMeAdmin, policyName, policyID, undefined, engagementChoice); API.write(WRITE_COMMANDS.CREATE_WORKSPACE, params, {optimisticData, successData, failureData}); diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index 3514367845b3..46719f31b0d8 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -25,7 +25,7 @@ function queueOnyxUpdates(updates: OnyxUpdate[]): Promise { } function flushQueue(): Promise { - if (!currentAccountID && !CONFIG.IS_TEST_ENV) { + if (!currentAccountID && !CONFIG.IS_TEST_ENV && !CONFIG.E2E_TESTING) { const preservedKeys: OnyxKey[] = [ ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 783a2c80d10e..bf3587bd8e52 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -933,7 +933,7 @@ function openReport( onboardingMessage.tasks = updatedTasks; } - const onboardingData = prepareOnboardingOptimisticData(choice, onboardingMessage); + const onboardingData = prepareOnboardingOnyxData(choice, onboardingMessage); optimisticData.push(...onboardingData.optimisticData, { onyxMethod: Onyx.METHOD.MERGE, @@ -3593,7 +3593,7 @@ function getReportPrivateNote(reportID: string | undefined) { API.read(READ_COMMANDS.GET_REPORT_PRIVATE_NOTE, parameters, {optimisticData, successData, failureData}); } -function prepareOnboardingOptimisticData( +function prepareOnboardingOnyxData( engagementChoice: OnboardingPurpose, data: ValueOf, adminsChatReportID?: string, @@ -3656,6 +3656,7 @@ function prepareOnboardingOptimisticData( reportComment: videoComment.commentText, }; } + let createWorkspaceTaskReportID; const tasksData = data.tasks .filter((task) => { if (['setupCategories', 'setupTags'].includes(task.type) && userReportedIntegration) { @@ -3665,6 +3666,16 @@ function prepareOnboardingOptimisticData( if (['addAccountingIntegration', 'setupCategoriesAndTags'].includes(task.type) && !userReportedIntegration) { return false; } + type SkipViewTourOnboardingChoices = 'newDotSubmit' | 'newDotSplitChat' | 'newDotPersonalSpend' | 'newDotEmployer'; + if ( + task.type === 'viewTour' && + [CONST.ONBOARDING_CHOICES.EMPLOYER, CONST.ONBOARDING_CHOICES.PERSONAL_SPEND, CONST.ONBOARDING_CHOICES.SUBMIT, CONST.ONBOARDING_CHOICES.CHAT_SPLIT].includes( + introSelected?.choice as SkipViewTourOnboardingChoices, + ) && + engagementChoice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM + ) { + return false; + } return true; }) .map((task, index) => { @@ -3705,6 +3716,9 @@ function prepareOnboardingOptimisticData( const completedTaskReportAction = task.autoCompleted ? buildOptimisticTaskReportAction(currentTask.reportID, CONST.REPORT.ACTIONS.TYPE.TASK_COMPLETED, 'marked as complete', actorAccountID, 2) : null; + if (task.type === 'createWorkspace') { + createWorkspaceTaskReportID = currentTask.reportID; + } return { task, @@ -3881,7 +3895,6 @@ function prepareOnboardingOptimisticData( const optimisticData: OnyxUpdate[] = [...tasksForOptimisticData]; const lastVisibleActionCreated = welcomeSignOffCommentAction.created; - optimisticData.push( { onyxMethod: Onyx.METHOD.MERGE, @@ -3896,7 +3909,10 @@ function prepareOnboardingOptimisticData( { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_INTRO_SELECTED, - value: {choice: engagementChoice}, + value: { + choice: engagementChoice, + createWorkspace: createWorkspaceTaskReportID, + }, }, ); @@ -3962,7 +3978,10 @@ function prepareOnboardingOptimisticData( { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_INTRO_SELECTED, - value: {choice: null}, + value: { + choice: null, + createWorkspace: null, + }, }, ); // If we post tasks in the #admins room, it means that a guide is assigned and all messages except tasks are handled by the backend @@ -4125,33 +4144,36 @@ function prepareOnboardingOptimisticData( } } - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [welcomeSignOffCommentAction.reportActionID]: welcomeSignOffCommentAction as ReportAction, - }, - }); + guidedSetupData.push(...tasksForParameters); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [welcomeSignOffCommentAction.reportActionID]: {pendingAction: null}, - }, - }); + if (!introSelected?.choice) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [welcomeSignOffCommentAction.reportActionID]: welcomeSignOffCommentAction as ReportAction, + }, + }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [welcomeSignOffCommentAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), - } as ReportAction, - }, - }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [welcomeSignOffCommentAction.reportActionID]: {pendingAction: null}, + }, + }); - guidedSetupData.push(...tasksForParameters, {type: 'message', ...welcomeSignOffMessage}); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [welcomeSignOffCommentAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), + } as ReportAction, + }, + }); + guidedSetupData.push({type: 'message', ...welcomeSignOffMessage}); + } return {optimisticData, successData, failureData, guidedSetupData, actorAccountID, selfDMParameters}; } @@ -4168,7 +4190,7 @@ function completeOnboarding( userReportedIntegration?: OnboardingAccounting, wasInvited?: boolean, ) { - const {optimisticData, successData, failureData, guidedSetupData, actorAccountID, selfDMParameters} = prepareOnboardingOptimisticData( + const {optimisticData, successData, failureData, guidedSetupData, actorAccountID, selfDMParameters} = prepareOnboardingOnyxData( engagementChoice, data, adminsChatReportID, @@ -4718,4 +4740,5 @@ export { getConciergeReportID, setDeleteTransactionNavigateBackUrl, clearDeleteTransactionNavigateBackUrl, + prepareOnboardingOnyxData, }; diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index 56cb49b4163c..bb70e885e36e 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -8,7 +8,7 @@ let connection: Connection; /** * Makes a backup copy of a transaction object that can be restored when the user cancels editing a transaction. */ -function createBackupTransaction(transaction: OnyxEntry) { +function createBackupTransaction(transaction: OnyxEntry, isDraft: boolean) { if (!transaction) { return; } @@ -20,9 +20,20 @@ function createBackupTransaction(transaction: OnyxEntry) { const newTransaction = { ...transaction, }; - - // Use set so that it will always fully overwrite any backup transaction that could have existed before - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transaction.transactionID}`, newTransaction); + const conn = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transaction.transactionID}`, + callback: (transactionBackup) => { + Onyx.disconnect(conn); + if (transactionBackup) { + // If the transactionBackup exists it means we haven't properly restored original value on unmount + // such as on page refresh, so we will just restore the transaction from the transactionBackup here. + Onyx.set(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transactionBackup); + return; + } + // Use set so that it will always fully overwrite any backup transaction that could have existed before + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transaction.transactionID}`, newTransaction); + }, + }); } /** diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index 845722909b2c..5669a98fd484 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -49,6 +49,8 @@ function LogInWithShortLivedAuthTokenPage({route}: LogInWithShortLivedAuthTokenP // For HybridApp we have separate logic to handle transitions. if (!NativeModules.HybridAppModule && exitTo) { Navigation.isNavigationReady().then(() => { + // We must call goBack() to remove the /transition route from history + Navigation.goBack(); Navigation.navigate(exitTo as Route); }); } diff --git a/src/pages/Travel/TripDetailsPage.tsx b/src/pages/Travel/TripDetailsPage.tsx index 6236fb249001..2e9df5a6a5e6 100644 --- a/src/pages/Travel/TripDetailsPage.tsx +++ b/src/pages/Travel/TripDetailsPage.tsx @@ -128,7 +128,7 @@ function TripDetailsPage({route}: TripDetailsPageProps) { shouldShowRightIcon onPress={() => { setIsTripSupportLoading(true); - Link.openTravelDotLink(activePolicyID, CONST.TRIP_ID_PATH(tripID))?.finally(() => { + Link.openTravelDotLink(activePolicyID, CONST.TRIP_SUPPORT)?.finally(() => { setIsTripSupportLoading(false); }); }} diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx index 47b3e09e6a50..ebfab1acaace 100644 --- a/src/pages/home/HeaderView.tsx +++ b/src/pages/home/HeaderView.tsx @@ -8,8 +8,7 @@ import CaretWrapper from '@components/CaretWrapper'; import ConfirmModal from '@components/ConfirmModal'; import DisplayNames from '@components/DisplayNames'; import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import {FallbackAvatar} from '@components/Icon/Expensicons'; +import {BackArrow, DotIndicator, FallbackAvatar} from '@components/Icon/Expensicons'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ParentNavigationSubtitle from '@components/ParentNavigationSubtitle'; @@ -26,18 +25,43 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; -import * as ReportUtils from '@libs/ReportUtils'; +import { + canJoinChat, + getChatRoomSubtitle, + getDisplayNamesWithTooltips, + getIcons, + getParentNavigationSubtitle, + getParticipantsAccountIDsForDisplay, + getPolicyDescriptionText, + getPolicyName, + getReportDescription, + getReportName, + hasReportNameError, + isChatRoom as isChatRoomReportUtils, + isChatThread as isChatThreadReportUtils, + isChatUsedForOnboarding as isChatUsedForOnboardingReportUtils, + isCurrentUserSubmitter, + isDeprecatedGroupDM, + isExpenseRequest, + isGroupChat as isGroupChatReportUtils, + isOpenTaskReport, + isPolicyExpenseChat as isPolicyExpenseChatReportUtils, + isSelfDM as isSelfDMReportUtils, + isTaskReport as isTaskReportReportUtils, + navigateToDetailsPage, + shouldDisableDetailPage as shouldDisableDetailPageReportUtils, + shouldReportShowSubscript, +} from '@libs/ReportUtils'; import FreeTrial from '@pages/settings/Subscription/FreeTrial'; -import * as Report from '@userActions/Report'; -import * as Session from '@userActions/Session'; -import * as Task from '@userActions/Task'; +import {joinRoom} from '@userActions/Report'; +import {checkIfActionIsAllowed} from '@userActions/Session'; +import {deleteTask} from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; +import type {Report, ReportAction} from '@src/types/onyx'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -46,10 +70,10 @@ type HeaderViewProps = { onNavigationMenuButtonClicked: () => void; /** The report currently being looked at */ - report: OnyxEntry; + report: OnyxEntry; /** The report action the transaction is tied to from the parent report */ - parentReportAction: OnyxEntry | null; + parentReportAction: OnyxEntry | null; /** The reportID of the current report */ reportID: string; @@ -65,7 +89,7 @@ const fallbackIcon: IconType = { id: -1, }; -function HeaderView({report, parentReportAction, reportID, onNavigationMenuButtonClicked, shouldUseNarrowLayout = false}: HeaderViewProps) { +function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked, shouldUseNarrowLayout = false}: HeaderViewProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const route = useRoute(); @@ -81,28 +105,28 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const isSelfDM = ReportUtils.isSelfDM(report); - const isGroupChat = ReportUtils.isGroupChat(report) || ReportUtils.isDeprecatedGroupDM(report); + const isSelfDM = isSelfDMReportUtils(report); + const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report); - const participants = ReportUtils.getParticipantsAccountIDsForDisplay(report, false, true).slice(0, 5); + const participants = getParticipantsAccountIDsForDisplay(report, false, true).slice(0, 5); const isMultipleParticipant = participants.length > 1; - const participantPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, personalDetails); - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant, undefined, isSelfDM); + const participantPersonalDetails = getPersonalDetailsForAccountIDs(participants, personalDetails); + const displayNamesWithTooltips = getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant, undefined, isSelfDM); - const isChatThread = ReportUtils.isChatThread(report); - const isChatRoom = ReportUtils.isChatRoom(report); - const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); - const isTaskReport = ReportUtils.isTaskReport(report); + const isChatThread = isChatThreadReportUtils(report); + const isChatRoom = isChatRoomReportUtils(report); + const isPolicyExpenseChat = isPolicyExpenseChatReportUtils(report); + const isTaskReport = isTaskReportReportUtils(report); const reportHeaderData = !isTaskReport && !isChatThread && report?.parentReportID ? parentReport : report; // Use sorted display names for the title for group chats on native small screen widths - const title = ReportUtils.getReportName(reportHeaderData, policy, parentReportAction, personalDetails, invoiceReceiverPolicy); - const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData); - const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData); - const reportDescription = Parser.htmlToText(ReportUtils.getReportDescription(report)); - const policyName = ReportUtils.getPolicyName(report, true); - const policyDescription = ReportUtils.getPolicyDescriptionText(policy); - const isPersonalExpenseChat = isPolicyExpenseChat && ReportUtils.isCurrentUserSubmitter(report?.reportID); + const title = getReportName(reportHeaderData, policy, parentReportAction, personalDetails, invoiceReceiverPolicy); + const subtitle = getChatRoomSubtitle(reportHeaderData); + const parentNavigationSubtitleData = getParentNavigationSubtitle(reportHeaderData); + const reportDescription = Parser.htmlToText(getReportDescription(report)); + const policyName = getPolicyName(report, true); + const policyDescription = getPolicyDescriptionText(policy); + const isPersonalExpenseChat = isPolicyExpenseChat && isCurrentUserSubmitter(report?.reportID); const shouldShowSubtitle = () => { if (!subtitle) { return false; @@ -116,9 +140,9 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto return true; }; - const join = Session.checkIfActionIsAllowed(() => Report.joinRoom(report)); + const join = checkIfActionIsAllowed(() => joinRoom(report)); - const canJoin = ReportUtils.canJoinChat(report, parentReportAction, policy); + const canJoin = canJoinChat(report, parentReportAction, policy); const joinButton = (