diff --git a/Gemfile.lock b/Gemfile.lock
index 93dab195ebdd..fcf4f878e2de 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -81,7 +81,8 @@ GEM
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
- domain_name (0.6.20231109)
+ domain_name (0.5.20190701)
+ unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
escape (0.0.4)
@@ -261,6 +262,9 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
+ unf (0.1.4)
+ unf_ext
+ unf_ext (0.0.9.1)
unicode-display_width (2.5.0)
webrick (1.8.1)
word_wrap (1.0.0)
@@ -294,4 +298,4 @@ RUBY VERSION
ruby 2.6.10p210
BUNDLED WITH
- 2.1.4
+ 2.4.7
diff --git a/__mocks__/@ua/react-native-airship.js b/__mocks__/@ua/react-native-airship.js
index 29be662e96a1..65e7c1a8b97e 100644
--- a/__mocks__/@ua/react-native-airship.js
+++ b/__mocks__/@ua/react-native-airship.js
@@ -28,6 +28,7 @@ const Airship = {
enableUserNotifications: () => Promise.resolve(false),
clearNotifications: jest.fn(),
getNotificationStatus: () => Promise.resolve({airshipOptIn: false, systemEnabled: false}),
+ getActiveNotifications: () => Promise.resolve([]),
},
contact: {
identify: jest.fn(),
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 00ba8df0ad97..6ad9e7f28407 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,8 +91,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001041303
- versionName "1.4.13-3"
+ versionCode 1001041306
+ versionName "1.4.13-6"
}
flavorDimensions "default"
diff --git a/assets/animations/Fireworks.lottie b/assets/animations/Fireworks.lottie
index f5a782c62f3a..142efdcd8fdc 100644
Binary files a/assets/animations/Fireworks.lottie and b/assets/animations/Fireworks.lottie differ
diff --git a/assets/animations/ReviewingBankInfo.lottie b/assets/animations/ReviewingBankInfo.lottie
index 93addc052e8b..a9974366cae7 100644
Binary files a/assets/animations/ReviewingBankInfo.lottie and b/assets/animations/ReviewingBankInfo.lottie differ
diff --git a/docs/articles/expensify-classic/workspace-and-domain-settings/Budgets.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Budgets.md
new file mode 100644
index 000000000000..3c5bc0fe2421
--- /dev/null
+++ b/docs/articles/expensify-classic/workspace-and-domain-settings/Budgets.md
@@ -0,0 +1,56 @@
+---
+title: Budgets
+description: Track employee spending across categories and tags by using Expensify's Budgets feature.
+---
+
+# About
+Expensify’s Budgets feature allows you to:
+- Set monthly and yearly budgets
+- Track spending across categories and tags on an individual and workspace basis
+- Get notified when a budget has met specific thresholds
+
+# How-to
+## Category Budgets
+1. Navigate to **Settings > Group > [Workspace Name] > Categories**
+2. Click the **Edit Rules** button for the category you want to add a budget to
+3. Select the **Budget** tab at the top of the modal that opens
+4. Click the switch next to **Enable Budget**
+5. Once enabled, you will see additional settings to configure:
+ - **Budget frequency**: you can select if you want this to be a monthly or a yearly budget
+ - **Total workspace budget**: you can enter an amount if you want to set a budget for the entire workspace
+ - **Per individual budget**: you can enter an amount if you want to set a budget per person
+ - **Notification threshold** - this is the % in which you will be notified as the budgets are hit
+
+## Single-level Tag Budgets
+1. Navigate to **Settings > Group > [Workspace Name] > Tags**
+2. Click **Edit Budget** next to the tag you want to add a budget to
+3. Click the switch next to **Enable Budget**
+4. Once enabled, you will see additional settings to configure:
+ - **Budget frequency**: you can select if you want this to be a monthly or a yearly budget
+ - **Total workspace budget**: you can enter an amount if you want to set a budget for the entire workspace
+ - **Per individual budget**: you can enter an amount if you want to set a budget per person
+ - **Notification threshold** - this is the % in which you will be notified as the budgets are hit
+
+## Multi-level Tag Budgets
+1. Navigate to **Settings > Group > [Workspace Name] > Tags**
+2. Click the **Edit Tags** button
+3. Click the **Edit Budget** button next to the subtag you want to apply a budget to
+4. Click the switch next to **Enable Budget**
+5. Once enabled, you will see additional settings to configure:
+ - **Budget frequency**: you can select if you want this to be a monthly or a yearly budget
+ - **Total workspace budget**: you can enter an amount if you want to set a budget for the entire workspace
+ - **Per individual budget**: you can enter an amount if you want to set a budget per person
+ - **Notification threshold** - this is the % in which you will be notified as the budgets are hit
+
+# FAQ
+## Can I import budgets as a CSV?
+At this time, you cannot import budgets via CSV since we don’t import categories or tags from direct accounting integrations.
+
+## When will I be notified as a budget is hit?
+Notifications are sent twice:
+ - When your notification threshold is hit (i.e, if you set this as 50%, you’ll be notified when 50% of the budget is met)
+ - When 100% of the budget is met
+
+## How will I be notified when a budget is hit?
+A message will be sent in the #admins room of the Workspace.
+
diff --git a/docs/articles/new-expensify/get-paid-back/Referral-Program.md b/docs/articles/new-expensify/get-paid-back/Referral-Program.md
index 34a35f5dc7c8..6ffb923aeb76 100644
--- a/docs/articles/new-expensify/get-paid-back/Referral-Program.md
+++ b/docs/articles/new-expensify/get-paid-back/Referral-Program.md
@@ -12,13 +12,16 @@ As a thank you, every time you bring a new customer into New Expensify, you'll g
# How to get paid to refer anyone to New Expensify
-The sky's the limit for this referral program! Your referral can be anyone - a friend, family member, boss, coworker, neighbor, or even social media follower. We're making it as easy as possible to get that cold hard referral $$$.
+The sky's the limit for this referral program! Your referral can be anyone - a friend, family member, boss, coworker, neighbor, or even social media follower. We're making it as easy as possible to get that cold hard $$$.
-1. There are a bunch of different ways to kick off a referral in New Expensify:
+1. There are a bunch of different ways to refer someone to New Expensify:
- Start a chat
- Request money
- Send money
- - @ mention someone
+ - Split a bill
+ - Assign them a task
+ - @ mention them
+ - Invite them to a room
- Add them to a workspace
2. You'll get $250 for each referral as long as:
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index d93eeff2d49d..8989d0ef3542 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.13.3
+ 1.4.13.6
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index fd6edef4c713..95659bf6908d 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.13.3
+ 1.4.13.6
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 390511397b0e..eebd6ad532d4 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1,25 +1,25 @@
PODS:
- - Airship (16.11.3):
- - Airship/Automation (= 16.11.3)
- - Airship/Basement (= 16.11.3)
- - Airship/Core (= 16.11.3)
- - Airship/ExtendedActions (= 16.11.3)
- - Airship/MessageCenter (= 16.11.3)
- - Airship/Automation (16.11.3):
+ - Airship (16.12.1):
+ - Airship/Automation (= 16.12.1)
+ - Airship/Basement (= 16.12.1)
+ - Airship/Core (= 16.12.1)
+ - Airship/ExtendedActions (= 16.12.1)
+ - Airship/MessageCenter (= 16.12.1)
+ - Airship/Automation (16.12.1):
- Airship/Core
- - Airship/Basement (16.11.3)
- - Airship/Core (16.11.3):
+ - Airship/Basement (16.12.1)
+ - Airship/Core (16.12.1):
- Airship/Basement
- - Airship/ExtendedActions (16.11.3):
+ - Airship/ExtendedActions (16.12.1):
- Airship/Core
- - Airship/MessageCenter (16.11.3):
+ - Airship/MessageCenter (16.12.1):
- Airship/Core
- - Airship/PreferenceCenter (16.11.3):
+ - Airship/PreferenceCenter (16.12.1):
- Airship/Core
- - AirshipFrameworkProxy (2.0.8):
- - Airship (= 16.11.3)
- - Airship/MessageCenter (= 16.11.3)
- - Airship/PreferenceCenter (= 16.11.3)
+ - AirshipFrameworkProxy (2.1.1):
+ - Airship (= 16.12.1)
+ - Airship/MessageCenter (= 16.12.1)
+ - Airship/PreferenceCenter (= 16.12.1)
- AppAuth (1.6.2):
- AppAuth/Core (= 1.6.2)
- AppAuth/ExternalUserAgent (= 1.6.2)
@@ -558,8 +558,8 @@ PODS:
- React-jsinspector (0.72.4)
- React-logger (0.72.4):
- glog
- - react-native-airship (15.2.6):
- - AirshipFrameworkProxy (= 2.0.8)
+ - react-native-airship (15.3.1):
+ - AirshipFrameworkProxy (= 2.1.1)
- React-Core
- react-native-blob-util (0.17.3):
- React-Core
@@ -777,35 +777,10 @@ PODS:
- React-Core
- RNReactNativeHapticFeedback (1.14.0):
- React-Core
- - RNReanimated (3.5.4):
- - DoubleConversion
- - FBLazyVector
- - glog
- - hermes-engine
- - RCT-Folly
- - RCTRequired
- - RCTTypeSafety
- - React-callinvoker
+ - RNReanimated (3.6.1):
+ - RCT-Folly (= 2021.07.22.00)
- React-Core
- - React-Core/DevSupport
- - React-Core/RCTWebSocket
- - React-CoreModules
- - React-cxxreact
- - React-hermes
- - React-jsi
- - React-jsiexecutor
- - React-jsinspector
- - React-RCTActionSheet
- - React-RCTAnimation
- - React-RCTAppDelegate
- - React-RCTBlob
- - React-RCTImage
- - React-RCTLinking
- - React-RCTNetwork
- - React-RCTSettings
- - React-RCTText
- ReactCommon/turbomodule/core
- - Yoga
- RNScreens (3.21.0):
- React-Core
- React-RCTImage
@@ -1160,8 +1135,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
- Airship: c70eed50e429f97f5adb285423c7291fb7a032ae
- AirshipFrameworkProxy: 7bc4130c668c6c98e2d4c60fe4c9eb61a999be99
+ Airship: 2f4510b497a8200780752a5e0304a9072bfffb6d
+ AirshipFrameworkProxy: ea1b6c665c798637b93c465b5e505be3011f1d9d
AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570
boost: 57d2868c099736d80fcd648bf211b4431e51a558
BVLinearGradient: 421743791a59d259aec53f4c58793aad031da2ca
@@ -1224,7 +1199,7 @@ SPEC CHECKSUMS:
React-jsiexecutor: c7f826e40fa9cab5d37cab6130b1af237332b594
React-jsinspector: aaed4cf551c4a1c98092436518c2d267b13a673f
React-logger: da1ebe05ae06eb6db4b162202faeafac4b435e77
- react-native-airship: 5d19f4ba303481cf4101ff9dee9249ef6a8a6b64
+ react-native-airship: 6ded22e4ca54f2f80db80b7b911c2b9b696d9335
react-native-blob-util: 99f4d79189252f597fe0d810c57a3733b1b1dea6
react-native-cameraroll: 8ffb0af7a5e5de225fd667610e2979fc1f0c2151
react-native-config: 7cd105e71d903104e8919261480858940a6b9c0e
@@ -1280,7 +1255,7 @@ SPEC CHECKSUMS:
rnmapbox-maps: 6f638ec002aa6e906a6f766d69cd45f968d98e64
RNPermissions: 9b086c8f05b2e2faa587fdc31f4c5ab4509728aa
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
- RNReanimated: ab2e96c6d5591c3dfbb38a464f54c8d17fb34a87
+ RNReanimated: fdbaa9c964bbab7fac50c90862b6cc5f041679b9
RNScreens: d037903436160a4b039d32606668350d2a808806
RNSVG: d00c8f91c3cbf6d476451313a18f04d220d4f396
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
diff --git a/package-lock.json b/package-lock.json
index 03fafb4bda7d..5206b9bf8618 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.13-3",
+ "version": "1.4.13-6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.13-3",
+ "version": "1.4.13-6",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -41,7 +41,7 @@
"@rnmapbox/maps": "^10.0.11",
"@shopify/flash-list": "^1.6.1",
"@types/node": "^18.14.0",
- "@ua/react-native-airship": "^15.2.6",
+ "@ua/react-native-airship": "^15.3.1",
"awesome-phonenumber": "^5.4.0",
"babel-polyfill": "^6.26.0",
"canvas-size": "^1.2.6",
@@ -100,7 +100,7 @@
"react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
- "react-native-reanimated": "3.5.4",
+ "react-native-reanimated": "^3.6.1",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.4.1",
"react-native-screens": "3.21.0",
@@ -20437,9 +20437,9 @@
}
},
"node_modules/@ua/react-native-airship": {
- "version": "15.2.6",
- "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.2.6.tgz",
- "integrity": "sha512-dVlBPPYXD/4SEshv/X7mmt3xF8WfnNqiSNzCyqJSLAZ1aJuPpP9Z5WemCYsa2iv6goRZvtJSE4P79QKlfoTwXw==",
+ "version": "15.3.1",
+ "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.3.1.tgz",
+ "integrity": "sha512-g5YX4/fpBJ0ml//7ave8HE68uF4QFriCuei0xJwK+ClzbTDWYB6OldvE/wj5dMpgQ95ZFSbr5LU77muYScxXLQ==",
"engines": {
"node": ">= 16.0.0"
},
@@ -44561,9 +44561,9 @@
}
},
"node_modules/react-native-reanimated": {
- "version": "3.5.4",
- "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.5.4.tgz",
- "integrity": "sha512-8we9LLDO1o4Oj9/DICeEJ2K1tjfqkJagqQUglxeUAkol/HcEJ6PGxIrpBcNryLqCDYEcu6FZWld/FzizBIw6bg==",
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.6.1.tgz",
+ "integrity": "sha512-F4vG9Yf9PKmE3GaWtVGUpzj3SM6YY2cx1yRHCwiMd1uY7W0gU017LfcVUorboJnj0y5QZqEriEK1Usq2Y8YZqg==",
"dependencies": {
"@babel/plugin-transform-object-assign": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
@@ -67439,9 +67439,9 @@
}
},
"@ua/react-native-airship": {
- "version": "15.2.6",
- "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.2.6.tgz",
- "integrity": "sha512-dVlBPPYXD/4SEshv/X7mmt3xF8WfnNqiSNzCyqJSLAZ1aJuPpP9Z5WemCYsa2iv6goRZvtJSE4P79QKlfoTwXw==",
+ "version": "15.3.1",
+ "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.3.1.tgz",
+ "integrity": "sha512-g5YX4/fpBJ0ml//7ave8HE68uF4QFriCuei0xJwK+ClzbTDWYB6OldvE/wj5dMpgQ95ZFSbr5LU77muYScxXLQ==",
"requires": {}
},
"@vercel/ncc": {
@@ -84883,9 +84883,9 @@
"requires": {}
},
"react-native-reanimated": {
- "version": "3.5.4",
- "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.5.4.tgz",
- "integrity": "sha512-8we9LLDO1o4Oj9/DICeEJ2K1tjfqkJagqQUglxeUAkol/HcEJ6PGxIrpBcNryLqCDYEcu6FZWld/FzizBIw6bg==",
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.6.1.tgz",
+ "integrity": "sha512-F4vG9Yf9PKmE3GaWtVGUpzj3SM6YY2cx1yRHCwiMd1uY7W0gU017LfcVUorboJnj0y5QZqEriEK1Usq2Y8YZqg==",
"requires": {
"@babel/plugin-transform-object-assign": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
diff --git a/package.json b/package.json
index 9a7d298709d4..8432a773fdf7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.13-3",
+ "version": "1.4.13-6",
"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.",
@@ -89,7 +89,7 @@
"@rnmapbox/maps": "^10.0.11",
"@shopify/flash-list": "^1.6.1",
"@types/node": "^18.14.0",
- "@ua/react-native-airship": "^15.2.6",
+ "@ua/react-native-airship": "^15.3.1",
"awesome-phonenumber": "^5.4.0",
"babel-polyfill": "^6.26.0",
"canvas-size": "^1.2.6",
@@ -148,7 +148,7 @@
"react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
- "react-native-reanimated": "3.5.4",
+ "react-native-reanimated": "^3.6.1",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.4.1",
"react-native-screens": "3.21.0",
diff --git a/patches/react-native-fast-image+8.6.3.patch b/patches/react-native-fast-image+8.6.3+001+initial.patch
similarity index 100%
rename from patches/react-native-fast-image+8.6.3.patch
rename to patches/react-native-fast-image+8.6.3+001+initial.patch
diff --git a/patches/react-native-fast-image+8.6.3+002+bitmap-downsampling.patch b/patches/react-native-fast-image+8.6.3+002+bitmap-downsampling.patch
new file mode 100644
index 000000000000..a626d5b16b2f
--- /dev/null
+++ b/patches/react-native-fast-image+8.6.3+002+bitmap-downsampling.patch
@@ -0,0 +1,62 @@
+diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java
+index 1339f5c..9dfec0c 100644
+--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java
++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java
+@@ -176,7 +176,8 @@ class FastImageViewWithUrl extends AppCompatImageView {
+ .apply(FastImageViewConverter
+ .getOptions(context, imageSource, mSource)
+ .placeholder(mDefaultSource) // show until loaded
+- .fallback(mDefaultSource)); // null will not be treated as error
++ .fallback(mDefaultSource))
++ .transform(new ResizeTransformation());
+
+ if (key != null)
+ builder.listener(new FastImageRequestListener(key));
+diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/ResizeTransformation.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/ResizeTransformation.java
+new file mode 100644
+index 0000000..1daa227
+--- /dev/null
++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/ResizeTransformation.java
+@@ -0,0 +1,41 @@
++package com.dylanvann.fastimage;
++
++ import android.content.Context;
++ import android.graphics.Bitmap;
++
++ import androidx.annotation.NonNull;
++
++ import com.bumptech.glide.load.Transformation;
++ import com.bumptech.glide.load.engine.Resource;
++ import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool;
++ import com.bumptech.glide.load.resource.bitmap.BitmapResource;
++
++ import java.security.MessageDigest;
++
++ public class ResizeTransformation implements Transformation {
++
++ private final double MAX_BYTES = 25000000.0;
++
++ @NonNull
++ @Override
++ public Resource transform(@NonNull Context context, @NonNull Resource resource, int outWidth, int outHeight) {
++ Bitmap toTransform = resource.get();
++
++ if (toTransform.getByteCount() > MAX_BYTES) {
++ double scaleFactor = Math.sqrt(MAX_BYTES / (double) toTransform.getByteCount());
++ int newHeight = (int) (outHeight * scaleFactor);
++ int newWidth = (int) (outWidth * scaleFactor);
++
++ BitmapPool pool = GlideApp.get(context).getBitmapPool();
++ Bitmap scaledBitmap = Bitmap.createScaledBitmap(toTransform, newWidth, newHeight, true);
++ return BitmapResource.obtain(scaledBitmap, pool);
++ }
++
++ return resource;
++ }
++
++ @Override
++ public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) {
++ messageDigest.update(("ResizeTransformation").getBytes());
++ }
++ }
+\ No newline at end of file
diff --git a/src/CONST.ts b/src/CONST.ts
index 2733da56e597..219807587a25 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -464,6 +464,7 @@ const CONST = {
ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/',
// Use Environment.getEnvironmentURL to get the complete URL with port number
DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:',
+ EXPENSIFY_INBOX_URL: 'https://www.expensify.com/inbox',
SIGN_IN_FORM_WIDTH: 300,
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index 13b3b9f1e836..b1af96561ef5 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -4,6 +4,7 @@ import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Animated, Keyboard, View} from 'react-native';
+import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import useLocalize from '@hooks/useLocalize';
@@ -425,78 +426,80 @@ function AttachmentModal(props) {
}}
propagateSwipe
>
- {props.isSmallScreenWidth && }
- downloadAttachment(source)}
- shouldShowCloseButton={!props.isSmallScreenWidth}
- shouldShowBackButton={props.isSmallScreenWidth}
- onBackButtonPress={closeModal}
- onCloseButtonPress={closeModal}
- shouldShowThreeDotsButton={shouldShowThreeDotsButton}
- threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)}
- threeDotsMenuItems={threeDotsMenuItems}
- shouldOverlay
- />
-
- {!_.isEmpty(props.report) && !props.isReceiptAttachment ? (
-
- ) : (
- Boolean(sourceForAttachmentView) &&
- shouldLoadAttachment && (
-
+ {props.isSmallScreenWidth && }
+ downloadAttachment(source)}
+ shouldShowCloseButton={!props.isSmallScreenWidth}
+ shouldShowBackButton={props.isSmallScreenWidth}
+ onBackButtonPress={closeModal}
+ onCloseButtonPress={closeModal}
+ shouldShowThreeDotsButton={shouldShowThreeDotsButton}
+ threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)}
+ threeDotsMenuItems={threeDotsMenuItems}
+ shouldOverlay
+ />
+
+ {!_.isEmpty(props.report) && !props.isReceiptAttachment ? (
+
- )
- )}
-
- {/* If we have an onConfirm method show a confirmation button */}
- {Boolean(props.onConfirm) && (
-
- {({safeAreaPaddingBottomStyle}) => (
-
-
-
+ )
)}
-
- )}
- {props.isReceiptAttachment && (
-
- )}
+
+ {/* If we have an onConfirm method show a confirmation button */}
+ {Boolean(props.onConfirm) && (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+ )}
+
+ )}
+ {props.isReceiptAttachment && (
+
+ )}
+
{!props.isReceiptAttachment && (
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js
deleted file mode 100644
index 7a083d71b591..000000000000
--- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js
+++ /dev/null
@@ -1,189 +0,0 @@
-/* eslint-disable es/no-optional-chaining */
-import PropTypes from 'prop-types';
-import React, {useContext, useEffect, useRef, useState} from 'react';
-import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native';
-import * as AttachmentsPropTypes from '@components/Attachments/propTypes';
-import Image from '@components/Image';
-import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext';
-import ImageTransformer from './ImageTransformer';
-import ImageWrapper from './ImageWrapper';
-
-function getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}) {
- const imageScaleX = canvasWidth / imageWidth;
- const imageScaleY = canvasHeight / imageHeight;
-
- return {imageScaleX, imageScaleY};
-}
-
-const cachedDimensions = new Map();
-
-const pagePropTypes = {
- /** Whether source url requires authentication */
- isAuthTokenRequired: PropTypes.bool,
-
- /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */
- source: AttachmentsPropTypes.attachmentSourcePropType.isRequired,
-
- isActive: PropTypes.bool.isRequired,
-};
-
-const defaultProps = {
- isAuthTokenRequired: false,
-};
-
-function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialIsActive}) {
- const {canvasWidth, canvasHeight} = useContext(AttachmentCarouselPagerContext);
-
- const dimensions = cachedDimensions.get(source);
-
- const [isActive, setIsActive] = useState(initialIsActive);
- // We delay setting a page to active state by a (few) millisecond(s),
- // to prevent the image transformer from flashing while still rendering
- // Instead, we show the fallback image while the image transformer is loading the image
- useEffect(() => {
- if (initialIsActive) {
- setTimeout(() => setIsActive(true), 1);
- } else {
- setIsActive(false);
- }
- }, [initialIsActive]);
-
- const [initialActivePageLoad, setInitialActivePageLoad] = useState(isActive);
- const isImageLoaded = useRef(null);
- const [isImageLoading, setIsImageLoading] = useState(false);
- const [isFallbackLoading, setIsFallbackLoading] = useState(false);
- const [showFallback, setShowFallback] = useState(true);
-
- // We delay hiding the fallback image while image transformer is still rendering
- useEffect(() => {
- if (isImageLoading || showFallback) {
- setShowFallback(true);
- } else {
- setTimeout(() => setShowFallback(false), 100);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isImageLoading]);
-
- return (
- <>
- {isActive && (
-
-
- {
- setIsImageLoading(true);
- }}
- onLoadEnd={() => {
- setShowFallback(false);
- setIsImageLoading(false);
- isImageLoaded.current = true;
- }}
- onLoad={(evt) => {
- const imageWidth = (evt.nativeEvent?.width || 0) / PixelRatio.get();
- const imageHeight = (evt.nativeEvent?.height || 0) / PixelRatio.get();
-
- const {imageScaleX, imageScaleY} = getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight});
-
- // Don't update the dimensions if they are already set
- if (
- dimensions?.imageWidth !== imageWidth ||
- dimensions?.imageHeight !== imageHeight ||
- dimensions?.imageScaleX !== imageScaleX ||
- dimensions?.imageScaleY !== imageScaleY
- ) {
- cachedDimensions.set(source, {
- ...dimensions,
- imageWidth,
- imageHeight,
- imageScaleX,
- imageScaleY,
- });
- }
-
- // On the initial render of the active page, the onLoadEnd event is never fired.
- // That's why we instead set isImageLoading to false in the onLoad event.
- if (initialActivePageLoad) {
- setInitialActivePageLoad(false);
- setIsImageLoading(false);
- setTimeout(() => setShowFallback(false), 100);
- isImageLoaded.current = true;
- }
- }}
- />
-
-
- )}
-
- {/* Keep rendering the image without gestures as fallback while ImageTransformer is loading the image */}
- {(showFallback || !isActive) && (
-
- {
- setIsImageLoading(true);
- if (isImageLoaded.current) {
- return;
- }
- setIsFallbackLoading(true);
- }}
- onLoadEnd={() => {
- if (isImageLoaded.current) {
- return;
- }
- setIsFallbackLoading(false);
- }}
- onLoad={(evt) => {
- const imageWidth = evt.nativeEvent.width;
- const imageHeight = evt.nativeEvent.height;
-
- const {imageScaleX, imageScaleY} = getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight});
- const minImageScale = Math.min(imageScaleX, imageScaleY);
-
- const scaledImageWidth = imageWidth * minImageScale;
- const scaledImageHeight = imageHeight * minImageScale;
-
- // Don't update the dimensions if they are already set
- if (dimensions?.scaledImageWidth === scaledImageWidth && dimensions?.scaledImageHeight === scaledImageHeight) {
- return;
- }
-
- cachedDimensions.set(source, {
- ...dimensions,
- scaledImageWidth,
- scaledImageHeight,
- });
- }}
- style={dimensions == null ? undefined : {width: dimensions.scaledImageWidth, height: dimensions.scaledImageHeight}}
- />
-
- )}
-
- {/* Show activity indicator while ImageTransfomer is still loading the image. */}
- {isActive && isFallbackLoading && !isImageLoaded.current && (
-
- )}
- >
- );
-}
-
-AttachmentCarouselPage.propTypes = pagePropTypes;
-AttachmentCarouselPage.defaultProps = defaultProps;
-AttachmentCarouselPage.displayName = 'AttachmentCarouselPage';
-
-export default AttachmentCarouselPage;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js
index 39535288e22d..abaf06900853 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js
+++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js
@@ -1,5 +1,5 @@
import {createContext} from 'react';
-const AttachmentCarouselContextPager = createContext(null);
+const AttachmentCarouselPagerContext = createContext(null);
-export default AttachmentCarouselContextPager;
+export default AttachmentCarouselPagerContext;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js
deleted file mode 100644
index b639eb291bb1..000000000000
--- a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {StyleSheet} from 'react-native';
-import Animated from 'react-native-reanimated';
-import useThemeStyles from '@hooks/useThemeStyles';
-
-const imageWrapperPropTypes = {
- children: PropTypes.node.isRequired,
-};
-
-function ImageWrapper({children}) {
- const styles = useThemeStyles();
- return (
-
- {children}
-
- );
-}
-
-ImageWrapper.propTypes = imageWrapperPropTypes;
-ImageWrapper.displayName = 'ImageWrapper';
-
-export default ImageWrapper;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js
index 15c98ece62cb..553e963a3461 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/index.js
+++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js
@@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
-import React, {useImperativeHandle, useMemo, useRef, useState} from 'react';
+import React, {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
-import {createNativeWrapper, GestureHandlerRootView} from 'react-native-gesture-handler';
+import {createNativeWrapper} from 'react-native-gesture-handler';
import PagerView from 'react-native-pager-view';
import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useEvent, useHandler, useSharedValue} from 'react-native-reanimated';
import _ from 'underscore';
@@ -51,8 +51,6 @@ const pagerPropTypes = {
onSwipeDown: PropTypes.func,
onPinchGestureChange: PropTypes.func,
forwardedRef: refPropTypes,
- containerWidth: PropTypes.number.isRequired,
- containerHeight: PropTypes.number.isRequired,
};
const pagerDefaultProps = {
@@ -66,20 +64,7 @@ const pagerDefaultProps = {
forwardedRef: null,
};
-function AttachmentCarouselPager({
- items,
- renderItem,
- initialIndex,
- onPageSelected,
- onTap,
- onSwipe = noopWorklet,
- onSwipeSuccess,
- onSwipeDown,
- onPinchGestureChange,
- forwardedRef,
- containerWidth,
- containerHeight,
-}) {
+function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelected, onTap, onSwipe = noopWorklet, onSwipeSuccess, onSwipeDown, onPinchGestureChange, forwardedRef}) {
const styles = useThemeStyles();
const shouldPagerScroll = useSharedValue(true);
const pagerRef = useRef(null);
@@ -101,6 +86,11 @@ function AttachmentCarouselPager({
const [activePage, setActivePage] = useState(initialIndex);
+ useEffect(() => {
+ setActivePage(initialIndex);
+ activeIndex.value = initialIndex;
+ }, [activeIndex, initialIndex]);
+
// we use reanimated for this since onPageSelected is called
// in the middle of the pager animation
useAnimatedReaction(
@@ -128,8 +118,6 @@ function AttachmentCarouselPager({
const contextValue = useMemo(
() => ({
- canvasWidth: containerWidth,
- canvasHeight: containerHeight,
isScrolling,
pagerRef,
shouldPagerScroll,
@@ -139,33 +127,31 @@ function AttachmentCarouselPager({
onSwipeSuccess,
onSwipeDown,
}),
- [containerWidth, containerHeight, isScrolling, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeSuccess, onSwipeDown],
+ [isScrolling, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeSuccess, onSwipeDown],
);
return (
-
-
-
- {_.map(items, (item, index) => (
-
- {renderItem({item, index, isActive: index === activePage})}
-
- ))}
-
-
-
+
+
+ {_.map(items, (item, index) => (
+
+ {renderItem({item, index, isActive: index === activePage})}
+
+ ))}
+
+
);
}
diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.js
index 8f225d426dca..974bb92bf3c8 100644
--- a/src/components/Attachments/AttachmentCarousel/index.js
+++ b/src/components/Attachments/AttachmentCarousel/index.js
@@ -139,10 +139,11 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
setShouldShowArrows(!shouldShowArrows) : undefined}
/>
),
- [activeSource, canUseTouchScreen, setShouldShowArrows, shouldShowArrows],
+ [activeSource, attachments.length, canUseTouchScreen, setShouldShowArrows, shouldShowArrows],
);
return (
diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js
index 374b2d47d12d..f5479b73abdb 100644
--- a/src/components/Attachments/AttachmentCarousel/index.native.js
+++ b/src/components/Attachments/AttachmentCarousel/index.native.js
@@ -1,8 +1,9 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
-import {Keyboard, PixelRatio, View} from 'react-native';
+import {Keyboard, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import BlockingView from '@components/BlockingViews/BlockingView';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import * as Illustrations from '@components/Icon/Illustrations';
import withLocalize from '@components/withLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -20,13 +21,11 @@ import useCarouselArrows from './useCarouselArrows';
function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, translate, onClose}) {
const styles = useThemeStyles();
const pagerRef = useRef(null);
-
- const [containerDimensions, setContainerDimensions] = useState({width: 0, height: 0});
- const [page, setPage] = useState(0);
+ const [page, setPage] = useState();
const [attachments, setAttachments] = useState([]);
- const [activeSource, setActiveSource] = useState(source);
const [isPinchGestureRunning, setIsPinchGestureRunning] = useState(true);
const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows();
+ const [activeSource, setActiveSource] = useState(source);
const compareImage = useCallback((attachment) => attachment.source === source, [source]);
@@ -95,61 +94,63 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
* @returns {JSX.Element}
*/
const renderItem = useCallback(
- ({item, isActive}) => (
+ ({item, index, isActive}) => (
setShouldShowArrows(!shouldShowArrows)}
/>
),
- [activeSource, setShouldShowArrows, shouldShowArrows],
+ [activeSource, attachments.length, page, setShouldShowArrows, shouldShowArrows],
);
return (
- setContainerDimensions({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)})
- }
onMouseEnter={() => setShouldShowArrows(true)}
onMouseLeave={() => setShouldShowArrows(false)}
>
- {page === -1 ? (
-
+ {page == null ? (
+
) : (
<>
- cycleThroughAttachments(-1)}
- onForward={() => cycleThroughAttachments(1)}
- autoHideArrow={autoHideArrows}
- cancelAutoHideArrow={cancelAutoHideArrows}
- />
-
- {containerDimensions.width > 0 && containerDimensions.height > 0 && (
- updatePage(newPage)}
- onPinchGestureChange={(newIsPinchGestureRunning) => {
- setIsPinchGestureRunning(newIsPinchGestureRunning);
- if (!newIsPinchGestureRunning && !shouldShowArrows) {
- setShouldShowArrows(true);
- }
- }}
- onSwipeDown={onClose}
- containerWidth={containerDimensions.width}
- containerHeight={containerDimensions.height}
- ref={pagerRef}
+ {page === -1 ? (
+
+ ) : (
+ <>
+ cycleThroughAttachments(-1)}
+ onForward={() => cycleThroughAttachments(1)}
+ autoHideArrow={autoHideArrows}
+ cancelAutoHideArrow={cancelAutoHideArrows}
+ />
+
+ updatePage(newPage)}
+ onPinchGestureChange={(newIsPinchGestureRunning) => {
+ setIsPinchGestureRunning(newIsPinchGestureRunning);
+ if (!newIsPinchGestureRunning && !shouldShowArrows) {
+ setShouldShowArrows(true);
+ }
+ }}
+ onSwipeDown={onClose}
+ ref={pagerRef}
+ />
+ >
)}
>
)}
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
index 78b69be077aa..f53b993f6053 100755
--- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
@@ -12,17 +12,38 @@ const propTypes = {
...withLocalizePropTypes,
};
-function AttachmentViewImage({source, file, isAuthTokenRequired, loadComplete, onPress, isImage, onScaleChanged, translate, onError}) {
+function AttachmentViewImage({
+ source,
+ file,
+ isAuthTokenRequired,
+ isUsedInCarousel,
+ isSingleCarouselItem,
+ carouselItemIndex,
+ carouselActiveItemIndex,
+ isFocused,
+ loadComplete,
+ onPress,
+ onError,
+ isImage,
+ onScaleChanged,
+ translate,
+}) {
const styles = useThemeStyles();
const children = (
);
+
return onPress ? (
- ) : (
-
- );
-
- return onPress ? (
-
- {children}
-
- ) : (
- children
- );
-}
-
-AttachmentViewImage.propTypes = propTypes;
-AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps;
-AttachmentViewImage.displayName = 'AttachmentViewImage';
-
-export default compose(memo, withLocalize)(AttachmentViewImage);
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index 79d1b6f407b9..94faa13fbb0f 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -63,7 +63,6 @@ function AttachmentView({
source,
file,
isAuthTokenRequired,
- isUsedInCarousel,
onPress,
shouldShowLoadingSpinnerIcon,
shouldShowDownloadIcon,
@@ -72,10 +71,14 @@ function AttachmentView({
onToggleKeyboard,
translate,
isFocused,
+ isUsedInCarousel,
+ isSingleCarouselItem,
+ carouselItemIndex,
+ carouselActiveItemIndex,
+ isUsedInAttachmentModal,
isWorkspaceAvatar,
fallbackSource,
transaction,
- isUsedInAttachmentModal,
}) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -132,10 +135,11 @@ function AttachmentView({
{},
- isUsedInAttachmentModal: false,
};
export {attachmentViewPropTypes, attachmentViewDefaultProps};
diff --git a/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx
similarity index 68%
rename from src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js
rename to src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx
index 622767b8a5f8..c404ff5fa71f 100644
--- a/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js
+++ b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx
@@ -1,40 +1,34 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
+import {OnyxEntry, withOnyx} from 'react-native-onyx';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import * as OnyxTypes from '@src/types/onyx';
-const propTypes = {
- openLinkInBrowser: PropTypes.func.isRequired,
-
- session: PropTypes.shape({
- /** Currently logged-in user email */
- email: PropTypes.string,
- }),
-
- ...withLocalizePropTypes,
+type DeeplinkRedirectLoadingIndicatorOnyxProps = {
+ /** Current user session */
+ session: OnyxEntry;
};
-const defaultProps = {
- session: {
- email: '',
- },
+type DeeplinkRedirectLoadingIndicatorProps = DeeplinkRedirectLoadingIndicatorOnyxProps & {
+ /** Opens the link in the browser */
+ openLinkInBrowser: (value: boolean) => void;
};
-function DeeplinkRedirectLoadingIndicator({translate, openLinkInBrowser, session}) {
+function DeeplinkRedirectLoadingIndicator({openLinkInBrowser, session}: DeeplinkRedirectLoadingIndicatorProps) {
+ const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
+
return (
@@ -46,8 +40,8 @@ function DeeplinkRedirectLoadingIndicator({translate, openLinkInBrowser, session
/>
{translate('deeplinkWrapper.launching')}
-
- {translate('deeplinkWrapper.loggedInAs', {email: session.email})}
+
+ {translate('deeplinkWrapper.loggedInAs', {email: session?.email ?? ''})}
{translate('deeplinkWrapper.doNotSeePrompt')} openLinkInBrowser(true)}>{translate('deeplinkWrapper.tryAgain')}
{translate('deeplinkWrapper.or')} Navigation.navigate(ROUTES.HOME)}>{translate('deeplinkWrapper.continueInWeb')}.
@@ -66,15 +60,10 @@ function DeeplinkRedirectLoadingIndicator({translate, openLinkInBrowser, session
);
}
-DeeplinkRedirectLoadingIndicator.propTypes = propTypes;
-DeeplinkRedirectLoadingIndicator.defaultProps = defaultProps;
DeeplinkRedirectLoadingIndicator.displayName = 'DeeplinkRedirectLoadingIndicator';
-export default compose(
- withLocalize,
- withOnyx({
- session: {
- key: ONYXKEYS.SESSION,
- },
- }),
-)(DeeplinkRedirectLoadingIndicator);
+export default withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+})(DeeplinkRedirectLoadingIndicator);
diff --git a/src/components/DeeplinkWrapper/index.js b/src/components/DeeplinkWrapper/index.js
deleted file mode 100644
index de50d9bdf134..000000000000
--- a/src/components/DeeplinkWrapper/index.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import PropTypes from 'prop-types';
-
-const propTypes = {
- /** Children to render. */
- children: PropTypes.node.isRequired,
-};
-
-function DeeplinkWrapper({children}) {
- return children;
-}
-
-DeeplinkWrapper.propTypes = propTypes;
-
-export default DeeplinkWrapper;
diff --git a/src/components/DeeplinkWrapper/index.tsx b/src/components/DeeplinkWrapper/index.tsx
new file mode 100644
index 000000000000..4b0382bd6b14
--- /dev/null
+++ b/src/components/DeeplinkWrapper/index.tsx
@@ -0,0 +1,9 @@
+import DeeplinkWrapperProps from './types';
+
+function DeeplinkWrapper({children}: DeeplinkWrapperProps) {
+ return children;
+}
+
+DeeplinkWrapper.displayName = 'DeeplinkWrapper';
+
+export default DeeplinkWrapper;
diff --git a/src/components/DeeplinkWrapper/index.website.js b/src/components/DeeplinkWrapper/index.website.tsx
similarity index 79%
rename from src/components/DeeplinkWrapper/index.website.js
rename to src/components/DeeplinkWrapper/index.website.tsx
index d81c99657dd8..2cae91e2f2a0 100644
--- a/src/components/DeeplinkWrapper/index.website.js
+++ b/src/components/DeeplinkWrapper/index.website.tsx
@@ -1,7 +1,5 @@
import Str from 'expensify-common/lib/str';
-import PropTypes from 'prop-types';
import {useEffect, useRef, useState} from 'react';
-import _ from 'underscore';
import * as Browser from '@libs/Browser';
import Navigation from '@libs/Navigation/Navigation';
import navigationRef from '@libs/Navigation/navigationRef';
@@ -10,17 +8,9 @@ import * as App from '@userActions/App';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
+import DeeplinkWrapperProps from './types';
-const propTypes = {
- /** Children to render. */
- children: PropTypes.node.isRequired,
- /** User authentication status */
- isAuthenticated: PropTypes.bool.isRequired,
- /** The auto authentication status */
- autoAuthState: PropTypes.string,
-};
-
-function isMacOSWeb() {
+function isMacOSWeb(): boolean {
return !Browser.isMobile() && typeof navigator === 'object' && typeof navigator.userAgent === 'string' && /Mac/i.test(navigator.userAgent) && !/Electron/i.test(navigator.userAgent);
}
@@ -38,10 +28,11 @@ function promptToOpenInDesktopApp() {
App.beginDeepLinkRedirect(!isMagicLink);
}
}
-function DeeplinkWrapper({children, isAuthenticated, autoAuthState}) {
- const [currentScreen, setCurrentScreen] = useState();
+
+function DeeplinkWrapper({children, isAuthenticated, autoAuthState}: DeeplinkWrapperProps) {
+ const [currentScreen, setCurrentScreen] = useState();
const [hasShownPrompt, setHasShownPrompt] = useState(false);
- const removeListener = useRef();
+ const removeListener = useRef<() => void>();
useEffect(() => {
// If we've shown the prompt and still have a listener registered,
@@ -55,21 +46,21 @@ function DeeplinkWrapper({children, isAuthenticated, autoAuthState}) {
setHasShownPrompt(false);
Navigation.isNavigationReady().then(() => {
// Get initial route
- const initialRoute = navigationRef.current.getCurrentRoute();
- setCurrentScreen(initialRoute.name);
+ const initialRoute = navigationRef.current?.getCurrentRoute();
+ setCurrentScreen(initialRoute?.name);
- removeListener.current = navigationRef.current.addListener('state', (event) => {
+ removeListener.current = navigationRef.current?.addListener('state', (event) => {
setCurrentScreen(Navigation.getRouteNameFromStateEvent(event));
});
});
}
}, [hasShownPrompt, isAuthenticated]);
+
useEffect(() => {
// According to the design, we don't support unlink in Desktop app https://github.com/Expensify/App/issues/19681#issuecomment-1610353099
- const isUnsupportedDeeplinkRoute = _.some([CONST.REGEX.ROUTES.UNLINK_LOGIN], (unsupportRouteRegex) => {
- const routeRegex = new RegExp(unsupportRouteRegex);
- return routeRegex.test(window.location.pathname);
- });
+ const routeRegex = new RegExp(CONST.REGEX.ROUTES.UNLINK_LOGIN);
+ const isUnsupportedDeeplinkRoute = routeRegex.test(window.location.pathname);
+
// Making a few checks to exit early before checking authentication status
if (!isMacOSWeb() || isUnsupportedDeeplinkRoute || hasShownPrompt || CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV || autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED) {
return;
@@ -99,5 +90,6 @@ function DeeplinkWrapper({children, isAuthenticated, autoAuthState}) {
return children;
}
-DeeplinkWrapper.propTypes = propTypes;
+DeeplinkWrapper.displayName = 'DeeplinkWrapper';
+
export default DeeplinkWrapper;
diff --git a/src/components/DeeplinkWrapper/types.ts b/src/components/DeeplinkWrapper/types.ts
new file mode 100644
index 000000000000..dfd56b62573d
--- /dev/null
+++ b/src/components/DeeplinkWrapper/types.ts
@@ -0,0 +1,11 @@
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+
+type DeeplinkWrapperProps = ChildrenProps & {
+ /** User authentication status */
+ isAuthenticated: boolean;
+
+ /** The auto authentication status */
+ autoAuthState?: string;
+};
+
+export default DeeplinkWrapperProps;
diff --git a/src/components/FloatingActionButton.js b/src/components/FloatingActionButton.js
deleted file mode 100644
index 791eb150f8c9..000000000000
--- a/src/components/FloatingActionButton.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {PureComponent} from 'react';
-import {Animated, Easing, View} from 'react-native';
-import compose from '@libs/compose';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
-import PressableWithFeedback from './Pressable/PressableWithFeedback';
-import Tooltip from './Tooltip/PopoverAnchorTooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-import withStyleUtils, {withStyleUtilsPropTypes} from './withStyleUtils';
-import withTheme, {withThemePropTypes} from './withTheme';
-import withThemeStyles, {withThemeStylesPropTypes} from './withThemeStyles';
-
-const AnimatedIcon = Animated.createAnimatedComponent(Icon);
-AnimatedIcon.displayName = 'AnimatedIcon';
-
-const AnimatedPressable = Animated.createAnimatedComponent(PressableWithFeedback);
-AnimatedPressable.displayName = 'AnimatedPressable';
-
-const propTypes = {
- // Callback to fire on request to toggle the FloatingActionButton
- onPress: PropTypes.func.isRequired,
-
- // Current state (active or not active) of the component
- isActive: PropTypes.bool.isRequired,
-
- // Ref for the button
- buttonRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
-
- ...withLocalizePropTypes,
- ...withThemePropTypes,
- ...withThemeStylesPropTypes,
- ...withStyleUtilsPropTypes,
-};
-
-const defaultProps = {
- buttonRef: () => {},
-};
-
-class FloatingActionButton extends PureComponent {
- constructor(props) {
- super(props);
- this.animatedValue = new Animated.Value(props.isActive ? 1 : 0);
- }
-
- componentDidUpdate(prevProps) {
- if (prevProps.isActive === this.props.isActive) {
- return;
- }
-
- this.animateFloatingActionButton();
- }
-
- /**
- * Animates the floating action button
- * Method is called when the isActive prop changes
- */
- animateFloatingActionButton() {
- const animationFinalValue = this.props.isActive ? 1 : 0;
-
- Animated.timing(this.animatedValue, {
- toValue: animationFinalValue,
- duration: 340,
- easing: Easing.inOut(Easing.ease),
- useNativeDriver: false,
- }).start();
- }
-
- render() {
- const rotate = this.animatedValue.interpolate({
- inputRange: [0, 1],
- outputRange: ['0deg', '135deg'],
- });
-
- const backgroundColor = this.animatedValue.interpolate({
- inputRange: [0, 1],
- outputRange: [this.props.theme.success, this.props.theme.buttonDefaultBG],
- });
-
- const fill = this.animatedValue.interpolate({
- inputRange: [0, 1],
- outputRange: [this.props.theme.textLight, this.props.theme.textDark],
- });
-
- return (
-
-
- {
- this.fabPressable = el;
- if (this.props.buttonRef) {
- this.props.buttonRef.current = el;
- }
- }}
- accessibilityLabel={this.props.accessibilityLabel}
- role={this.props.role}
- pressDimmingValue={1}
- onPress={(e) => {
- // Drop focus to avoid blue focus ring.
- this.fabPressable.blur();
- this.props.onPress(e);
- }}
- onLongPress={() => {}}
- style={[this.props.themeStyles.floatingActionButton, this.props.StyleUtils.getAnimatedFABStyle(rotate, backgroundColor)]}
- >
-
-
-
-
- );
- }
-}
-
-FloatingActionButton.propTypes = propTypes;
-FloatingActionButton.defaultProps = defaultProps;
-
-const FloatingActionButtonWithLocalize = withLocalize(FloatingActionButton);
-
-const FloatingActionButtonWithLocalizeWithRef = React.forwardRef((props, ref) => (
-
-));
-
-FloatingActionButtonWithLocalizeWithRef.displayName = 'FloatingActionButtonWithLocalizeWithRef';
-
-export default compose(withThemeStyles, withTheme, withStyleUtils)(FloatingActionButtonWithLocalizeWithRef);
diff --git a/src/components/FloatingActionButton/FabPlusIcon.js b/src/components/FloatingActionButton/FabPlusIcon.js
new file mode 100644
index 000000000000..09afa00f119d
--- /dev/null
+++ b/src/components/FloatingActionButton/FabPlusIcon.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, {useEffect} from 'react';
+import Animated, {Easing, interpolateColor, useAnimatedProps, useSharedValue, withTiming} from 'react-native-reanimated';
+import Svg, {Path} from 'react-native-svg';
+import useTheme from '@hooks/useTheme';
+
+const AnimatedPath = Animated.createAnimatedComponent(Path);
+
+const propTypes = {
+ /* Current state (active or not active) of the component */
+ isActive: PropTypes.bool.isRequired,
+};
+
+function FabPlusIcon({isActive}) {
+ const theme = useTheme();
+ const animatedValue = useSharedValue(isActive ? 1 : 0);
+
+ useEffect(() => {
+ animatedValue.value = withTiming(isActive ? 1 : 0, {
+ duration: 340,
+ easing: Easing.inOut(Easing.ease),
+ });
+ }, [isActive, animatedValue]);
+
+ const animatedProps = useAnimatedProps(() => {
+ const fill = interpolateColor(animatedValue.value, [0, 1], [theme.textLight, theme.textDark]);
+
+ return {
+ fill,
+ };
+ });
+
+ return (
+
+ );
+}
+
+FabPlusIcon.propTypes = propTypes;
+FabPlusIcon.displayName = 'FabPlusIcon';
+
+export default FabPlusIcon;
diff --git a/src/components/FloatingActionButton/index.js b/src/components/FloatingActionButton/index.js
new file mode 100644
index 000000000000..d341396c44b7
--- /dev/null
+++ b/src/components/FloatingActionButton/index.js
@@ -0,0 +1,85 @@
+import PropTypes from 'prop-types';
+import React, {useEffect, useRef} from 'react';
+import {View} from 'react-native';
+import Animated, {Easing, interpolateColor, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import Tooltip from '@components/Tooltip/PopoverAnchorTooltip';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import FabPlusIcon from './FabPlusIcon';
+
+const AnimatedPressable = Animated.createAnimatedComponent(PressableWithFeedback);
+AnimatedPressable.displayName = 'AnimatedPressable';
+
+const propTypes = {
+ /* Callback to fire on request to toggle the FloatingActionButton */
+ onPress: PropTypes.func.isRequired,
+
+ /* Current state (active or not active) of the component */
+ isActive: PropTypes.bool.isRequired,
+
+ /* An accessibility label for the button */
+ accessibilityLabel: PropTypes.string.isRequired,
+
+ /* An accessibility role for the button */
+ role: PropTypes.string.isRequired,
+};
+
+const FloatingActionButton = React.forwardRef(({onPress, isActive, accessibilityLabel, role}, ref) => {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const fabPressable = useRef(null);
+ const animatedValue = useSharedValue(isActive ? 1 : 0);
+ const buttonRef = ref;
+
+ useEffect(() => {
+ animatedValue.value = withTiming(isActive ? 1 : 0, {
+ duration: 340,
+ easing: Easing.inOut(Easing.ease),
+ });
+ }, [isActive, animatedValue]);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ const backgroundColor = interpolateColor(animatedValue.value, [0, 1], [theme.success, theme.buttonDefaultBG]);
+
+ return {
+ transform: [{rotate: `${animatedValue.value * 135}deg`}],
+ backgroundColor,
+ borderRadius: styles.floatingActionButton.borderRadius,
+ };
+ });
+
+ return (
+
+
+ {
+ fabPressable.current = el;
+ if (buttonRef) {
+ buttonRef.current = el;
+ }
+ }}
+ accessibilityLabel={accessibilityLabel}
+ role={role}
+ pressDimmingValue={1}
+ onPress={(e) => {
+ // Drop focus to avoid blue focus ring.
+ fabPressable.current.blur();
+ onPress(e);
+ }}
+ onLongPress={() => {}}
+ style={[styles.floatingActionButton, animatedStyle]}
+ >
+
+
+
+
+ );
+});
+
+FloatingActionButton.propTypes = propTypes;
+FloatingActionButton.displayName = 'FloatingActionButton';
+
+export default FloatingActionButton;
diff --git a/src/components/FormAlertWrapper.js b/src/components/FormAlertWrapper.js
deleted file mode 100644
index 6062ea8f7803..000000000000
--- a/src/components/FormAlertWrapper.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import FormHelpMessage from './FormHelpMessage';
-import networkPropTypes from './networkPropTypes';
-import {withNetwork} from './OnyxProvider';
-import RenderHTML from './RenderHTML';
-import Text from './Text';
-import TextLink from './TextLink';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-
-const propTypes = {
- /** Wrapped child components */
- children: PropTypes.func.isRequired,
-
- /** Styles for container element */
- // eslint-disable-next-line react/forbid-prop-types
- containerStyles: PropTypes.arrayOf(PropTypes.object),
-
- /** Whether to show the alert text */
- isAlertVisible: PropTypes.bool,
-
- /** Whether message is in html format */
- isMessageHtml: PropTypes.bool,
-
- /** Error message to display above button */
- message: PropTypes.string,
-
- /** Props to detect online status */
- network: networkPropTypes.isRequired,
-
- /** Callback fired when the "fix the errors" link is pressed */
- onFixTheErrorsLinkPressed: PropTypes.func,
-
- /** Style for the error message for submit button */
- errorMessageStyle: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- containerStyles: [],
- errorMessageStyle: [],
- isAlertVisible: false,
- isMessageHtml: false,
- message: '',
- onFixTheErrorsLinkPressed: () => {},
-};
-
-// The FormAlertWrapper offers a standardized way of showing error messages and offline functionality.
-//
-// This component takes other components as a child prop. It will then render any wrapped components as a function using "render props",
-// and passes it a (bool) isOffline parameter. Child components can then use the isOffline variable to determine offline behavior.
-function FormAlertWrapper(props) {
- const styles = useThemeStyles();
- let children;
- if (_.isEmpty(props.message)) {
- children = (
-
- {`${props.translate('common.please')} `}
-
- {props.translate('common.fixTheErrors')}
-
- {` ${props.translate('common.inTheFormBeforeContinuing')}.`}
-
- );
- } else if (props.isMessageHtml) {
- children = ${props.message}`} />;
- }
- return (
-
- {props.isAlertVisible && (
-
- {children}
-
- )}
- {props.children(props.network.isOffline)}
-
- );
-}
-
-FormAlertWrapper.propTypes = propTypes;
-FormAlertWrapper.defaultProps = defaultProps;
-FormAlertWrapper.displayName = 'FormAlertWrapper';
-
-export default compose(withLocalize, withNetwork())(FormAlertWrapper);
diff --git a/src/components/FormAlertWrapper.tsx b/src/components/FormAlertWrapper.tsx
new file mode 100644
index 000000000000..a144bf069502
--- /dev/null
+++ b/src/components/FormAlertWrapper.tsx
@@ -0,0 +1,90 @@
+import React, {ReactNode} from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Network from '@src/types/onyx/Network';
+import FormHelpMessage from './FormHelpMessage';
+import {withNetwork} from './OnyxProvider';
+import RenderHTML from './RenderHTML';
+import Text from './Text';
+import TextLink from './TextLink';
+
+type FormAlertWrapperProps = {
+ /** Wrapped child components */
+ children: (isOffline?: boolean) => ReactNode;
+
+ /** Styles for container element */
+ containerStyles?: StyleProp;
+
+ /** Style for the error message for submit button */
+ errorMessageStyle?: StyleProp;
+
+ /** Whether to show the alert text */
+ isAlertVisible?: boolean;
+
+ /** Whether message is in html format */
+ isMessageHtml?: boolean;
+
+ /** Error message to display above button */
+ message?: string;
+
+ /** Props to detect online status */
+ network: Network;
+
+ /** Callback fired when the "fix the errors" link is pressed */
+ onFixTheErrorsLinkPressed?: () => void;
+};
+
+// The FormAlertWrapper offers a standardized way of showing error messages and offline functionality.
+//
+// This component takes other components as a child prop. It will then render any wrapped components as a function using "render props",
+// and passes it a (bool) isOffline parameter. Child components can then use the isOffline variable to determine offline behavior.
+function FormAlertWrapper({
+ children,
+ containerStyles,
+ errorMessageStyle,
+ isAlertVisible = false,
+ isMessageHtml = false,
+ message = '',
+ network,
+ onFixTheErrorsLinkPressed = () => {},
+}: FormAlertWrapperProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ let content;
+ if (!message?.length) {
+ content = (
+
+ {`${translate('common.please')} `}
+
+ {translate('common.fixTheErrors')}
+
+ {` ${translate('common.inTheFormBeforeContinuing')}.`}
+
+ );
+ } else if (isMessageHtml) {
+ content = ${message}`} />;
+ }
+
+ return (
+
+ {isAlertVisible && (
+
+ {content}
+
+ )}
+ {children(!!network.isOffline)}
+
+ );
+}
+
+FormAlertWrapper.displayName = 'FormAlertWrapper';
+
+export default withNetwork()(FormAlertWrapper);
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index b47daf0711b2..66a162bf2e5f 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -86,6 +86,7 @@ import Menu from '@assets/images/menu.svg';
import MoneyBag from '@assets/images/money-bag.svg';
import MoneyCircle from '@assets/images/money-circle.svg';
import Monitor from '@assets/images/monitor.svg';
+import NewExpensify from '@assets/images/new-expensify.svg';
import NewWindow from '@assets/images/new-window.svg';
import NewWorkspace from '@assets/images/new-workspace.svg';
import OfflineCloud from '@assets/images/offline-cloud.svg';
@@ -221,6 +222,7 @@ export {
MoneyBag,
MoneyCircle,
Monitor,
+ NewExpensify,
NewWindow,
NewWorkspace,
Offline,
diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx
index 80abe1872c12..5f82421c0e8e 100644
--- a/src/components/Icon/index.tsx
+++ b/src/components/Icon/index.tsx
@@ -1,8 +1,8 @@
-import React, {PureComponent} from 'react';
+import React from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
-import withStyleUtils, {WithStyleUtilsProps} from '@components/withStyleUtils';
-import withTheme, {WithThemeProps} from '@components/withTheme';
-import withThemeStyles, {type WithThemeStylesProps} from '@components/withThemeStyles';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import IconWrapperStyles from './IconWrapperStyles';
@@ -41,65 +41,63 @@ type IconProps = {
/** Additional styles to add to the Icon */
additionalStyles?: StyleProp;
-} & WithThemeStylesProps &
- WithThemeProps &
- WithStyleUtilsProps;
-
-// We must use a class component to create an animatable component with the Animated API
-// eslint-disable-next-line react/prefer-stateless-function
-class Icon extends PureComponent {
- // eslint-disable-next-line react/static-property-placement
- public static defaultProps = {
- width: variables.iconSizeNormal,
- height: variables.iconSizeNormal,
- fill: undefined,
- small: false,
- inline: false,
- additionalStyles: [],
- hovered: false,
- pressed: false,
- };
-
- render() {
- const width = this.props.small ? variables.iconSizeSmall : this.props.width;
- const height = this.props.small ? variables.iconSizeSmall : this.props.height;
- const iconStyles = [this.props.StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, this.props.themeStyles.pAbsolute, this.props.additionalStyles];
- const fill = this.props.fill ?? this.props.theme.icon;
+};
- if (this.props.inline) {
- return (
-
-
-
-
-
- );
- }
+function Icon({
+ src,
+ width = variables.iconSizeNormal,
+ height = variables.iconSizeNormal,
+ fill = undefined,
+ small = false,
+ inline = false,
+ hovered = false,
+ pressed = false,
+ additionalStyles = [],
+}: IconProps) {
+ const theme = useTheme();
+ const StyleUtils = useStyleUtils();
+ const styles = useThemeStyles();
+ const iconWidth = small ? variables.iconSizeSmall : width;
+ const iconHeight = small ? variables.iconSizeSmall : height;
+ const iconStyles = [StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, styles.pAbsolute, additionalStyles];
+ const iconFill = fill ?? theme.icon;
+ const IconComponent = src;
+ if (inline) {
return (
-
+
+
+
);
}
+
+ return (
+
+
+
+ );
}
-export default withTheme(withThemeStyles(withStyleUtils(Icon)));
+Icon.displayName = 'Icon';
+
+export default Icon;
diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.js
index 0db1f87cd704..f16b37f328f5 100644
--- a/src/components/ImageView/index.js
+++ b/src/components/ImageView/index.js
@@ -1,4 +1,3 @@
-import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {View} from 'react-native';
import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
@@ -8,28 +7,7 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import CONST from '@src/CONST';
-
-const propTypes = {
- /** Whether source url requires authentication */
- isAuthTokenRequired: PropTypes.bool,
-
- /** Handles scale changed event in image zoom component. Used on native only */
- // eslint-disable-next-line react/no-unused-prop-types
- onScaleChanged: PropTypes.func.isRequired,
-
- /** URL to full-sized image */
- url: PropTypes.string.isRequired,
-
- /** image file name */
- fileName: PropTypes.string.isRequired,
-
- onError: PropTypes.func,
-};
-
-const defaultProps = {
- isAuthTokenRequired: false,
- onError: () => {},
-};
+import {imageViewDefaultProps, imageViewPropTypes} from './propTypes';
function ImageView({isAuthTokenRequired, url, fileName, onError}) {
const styles = useThemeStyles();
@@ -283,8 +261,8 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) {
);
}
-ImageView.propTypes = propTypes;
-ImageView.defaultProps = defaultProps;
+ImageView.propTypes = imageViewPropTypes;
+ImageView.defaultProps = imageViewDefaultProps;
ImageView.displayName = 'ImageView';
export default ImageView;
diff --git a/src/components/ImageView/index.native.js b/src/components/ImageView/index.native.js
index 6c04a1c83ee5..98349b213aa5 100644
--- a/src/components/ImageView/index.native.js
+++ b/src/components/ImageView/index.native.js
@@ -1,26 +1,15 @@
import PropTypes from 'prop-types';
-import React, {useEffect, useRef, useState} from 'react';
-import {PanResponder, View} from 'react-native';
-import ImageZoom from 'react-native-image-pan-zoom';
-import _ from 'underscore';
-import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
-import Image from '@components/Image';
-import useThemeStyles from '@hooks/useThemeStyles';
-import useWindowDimensions from '@hooks/useWindowDimensions';
-import variables from '@styles/variables';
+import React from 'react';
+import Lightbox from '@components/Lightbox';
+import {zoomRangeDefaultProps, zoomRangePropTypes} from '@components/MultiGestureCanvas/propTypes';
+import {imageViewDefaultProps, imageViewPropTypes} from './propTypes';
/**
* On the native layer, we use a image library to handle zoom functionality
*/
const propTypes = {
- /** Whether source url requires authentication */
- isAuthTokenRequired: PropTypes.bool,
-
- /** URL to full-sized image */
- url: PropTypes.string.isRequired,
-
- /** Handles scale changed event in image zoom component. Used on native only */
- onScaleChanged: PropTypes.func.isRequired,
+ ...imageViewPropTypes,
+ ...zoomRangePropTypes,
/** Function for handle on press */
onPress: PropTypes.func,
@@ -30,214 +19,29 @@ const propTypes = {
};
const defaultProps = {
- isAuthTokenRequired: false,
+ ...imageViewDefaultProps,
+ ...zoomRangeDefaultProps,
+
onPress: () => {},
style: {},
};
-// Use the default double click interval from the ImageZoom library
-// https://github.com/ascoders/react-native-image-zoom/blob/master/src/image-zoom/image-zoom.type.ts#L79
-const DOUBLE_CLICK_INTERVAL = 175;
-
-function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style}) {
- const styles = useThemeStyles();
- const {windowWidth, windowHeight} = useWindowDimensions();
-
- const [isLoading, setIsLoading] = useState(true);
- const [imageDimensions, setImageDimensions] = useState({
- width: 0,
- height: 0,
- });
- const [containerHeight, setContainerHeight] = useState(null);
-
- const imageZoomScale = useRef(1);
- const lastClickTime = useRef(0);
- const numberOfTouches = useRef(0);
- const zoom = useRef(null);
-
- /**
- * Updates the amount of active touches on the PanResponder on our ImageZoom overlay View
- *
- * @param {Event} e
- * @param {GestureState} gestureState
- * @returns {Boolean}
- */
- const updatePanResponderTouches = (e, gestureState) => {
- if (_.isNumber(gestureState.numberActiveTouches)) {
- numberOfTouches.current = gestureState.numberActiveTouches;
- }
-
- // We don't need to set the panResponder since all we care about is checking the gestureState, so return false
- return false;
- };
-
- // PanResponder used to capture how many touches are active on the attachment image
- const panResponder = useRef(
- PanResponder.create({
- onStartShouldSetPanResponder: updatePanResponderTouches,
- }),
- ).current;
-
- /**
- * When the url changes and the image must load again,
- * this resets the zoom to ensure the next image loads with the correct dimensions.
- */
- const resetImageZoom = () => {
- if (imageZoomScale.current !== 1) {
- imageZoomScale.current = 1;
- }
-
- if (zoom.current) {
- zoom.current.centerOn({
- x: 0,
- y: 0,
- scale: 1,
- duration: 0,
- });
- }
- };
-
- const imageLoadingStart = () => {
- if (isLoading) {
- return;
- }
-
- resetImageZoom();
- setImageDimensions({
- width: 0,
- height: 0,
- });
- setIsLoading(true);
- };
-
- useEffect(() => {
- imageLoadingStart();
- // eslint-disable-next-line react-hooks/exhaustive-deps -- this effect only needs to run when the url changes
- }, [url]);
+function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style, zoomRange, onError, isUsedInCarousel, isSingleCarouselItem, carouselItemIndex, carouselActiveItemIndex}) {
+ const hasSiblingCarouselItems = isUsedInCarousel && !isSingleCarouselItem;
- /**
- * The `ImageZoom` component requires image dimensions which
- * are calculated here from the natural image dimensions produced by
- * the `onLoad` event
- *
- * @param {Object} nativeEvent
- */
- const configureImageZoom = ({nativeEvent}) => {
- let imageZoomWidth = nativeEvent.width;
- let imageZoomHeight = nativeEvent.height;
- const roundedContainerWidth = Math.round(windowWidth);
- const roundedContainerHeight = Math.round(containerHeight || windowHeight);
-
- const aspectRatio = Math.min(roundedContainerHeight / imageZoomHeight, roundedContainerWidth / imageZoomWidth);
-
- imageZoomHeight *= aspectRatio;
- imageZoomWidth *= aspectRatio;
-
- // Resize the image to max dimensions possible on the Native platforms to prevent crashes on Android. To keep the same behavior, apply to IOS as well.
- const maxDimensionsScale = 11;
- imageZoomWidth = Math.min(imageZoomWidth, roundedContainerWidth * maxDimensionsScale);
- imageZoomHeight = Math.min(imageZoomHeight, roundedContainerHeight * maxDimensionsScale);
-
- setImageDimensions({
- height: imageZoomHeight,
- width: imageZoomWidth,
- });
- setIsLoading(false);
- };
-
- const configurePanResponder = () => {
- const currentTimestamp = new Date().getTime();
- const isDoubleClick = currentTimestamp - lastClickTime.current <= DOUBLE_CLICK_INTERVAL;
- lastClickTime.current = currentTimestamp;
-
- // Let ImageZoom handle the event if the tap is more than one touchPoint or if we are zoomed in
- if (numberOfTouches.current === 2 || imageZoomScale.current !== 1) {
- return true;
- }
-
- // When we have a double click and the zoom scale is 1 then programmatically zoom the image
- // but let the tap fall through to the parent so we can register a swipe down to dismiss
- if (isDoubleClick) {
- zoom.current.centerOn({
- x: 0,
- y: 0,
- scale: 2,
- duration: 100,
- });
-
- // onMove will be called after the zoom animation.
- // So it's possible to zoom and swipe and stuck in between the images.
- // Sending scale just when we actually trigger the animation makes this nearly impossible.
- // you should be really fast to catch in between state updates.
- // And this lucky case will be fixed by migration to UI thread only code
- // with gesture handler and reanimated.
- onScaleChanged(2);
- }
-
- // We must be either swiping down or double tapping since we are at zoom scale 1
- return false;
- };
-
- // Default windowHeight accounts for the modal header height
- const calculatedWindowHeight = windowHeight - variables.contentHeaderHeight;
- const hasImageDimensions = imageDimensions.width !== 0 && imageDimensions.height !== 0;
- const shouldShowLoadingIndicator = isLoading || !hasImageDimensions;
-
- // Zoom view should be loaded only after measuring actual image dimensions, otherwise it causes blurred images on Android
return (
- {
- const layout = event.nativeEvent.layout;
- setContainerHeight(layout.height);
- }}
- >
- {Boolean(containerHeight) && (
- {
- onScaleChanged(scale);
- imageZoomScale.current = scale;
- }}
- >
-
-
- {/**
- Create an invisible view on top of the image so we can capture and set the amount of touches before
- the ImageZoom's PanResponder does. Children will be triggered first, so this needs to be inside the
- ImageZoom to work
- */}
-
-
- )}
- {shouldShowLoadingIndicator && }
-
+
);
}
diff --git a/src/components/ImageView/propTypes.js b/src/components/ImageView/propTypes.js
new file mode 100644
index 000000000000..3809d9aed043
--- /dev/null
+++ b/src/components/ImageView/propTypes.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+
+const imageViewPropTypes = {
+ /** Whether source url requires authentication */
+ isAuthTokenRequired: PropTypes.bool,
+
+ /** Handles scale changed event in image zoom component. Used on native only */
+ // eslint-disable-next-line react/no-unused-prop-types
+ onScaleChanged: PropTypes.func.isRequired,
+
+ /** URL to full-sized image */
+ url: PropTypes.string.isRequired,
+
+ /** image file name */
+ fileName: PropTypes.string.isRequired,
+
+ /** Handles errors while displaying the image */
+ onError: PropTypes.func,
+
+ /** Whether this view is the active screen */
+ isFocused: PropTypes.bool,
+
+ /** Whether this AttachmentView is shown as part of a AttachmentCarousel */
+ isUsedInCarousel: PropTypes.bool,
+
+ /** When "isUsedInCarousel" is set to true, determines whether there is only one item in the carousel */
+ isSingleCarouselItem: PropTypes.bool,
+
+ /** The index of the carousel item */
+ carouselItemIndex: PropTypes.number,
+
+ /** The index of the currently active carousel item */
+ carouselActiveItemIndex: PropTypes.number,
+};
+
+const imageViewDefaultProps = {
+ isAuthTokenRequired: false,
+ onError: () => {},
+ isFocused: true,
+ isUsedInCarousel: false,
+ isSingleCarouselItem: false,
+ carouselItemIndex: 0,
+ carouselActiveItemIndex: 0,
+};
+
+export {imageViewPropTypes, imageViewDefaultProps};
diff --git a/src/components/Lightbox.js b/src/components/Lightbox.js
new file mode 100644
index 000000000000..94cd1156bc7b
--- /dev/null
+++ b/src/components/Lightbox.js
@@ -0,0 +1,233 @@
+/* eslint-disable es/no-optional-chaining */
+import PropTypes from 'prop-types';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native';
+import useStyleUtils from '@hooks/useStyleUtils';
+import * as AttachmentsPropTypes from './Attachments/propTypes';
+import Image from './Image';
+import MultiGestureCanvas from './MultiGestureCanvas';
+import getCanvasFitScale from './MultiGestureCanvas/getCanvasFitScale';
+import {zoomRangeDefaultProps, zoomRangePropTypes} from './MultiGestureCanvas/propTypes';
+
+// Increase/decrease this number to change the number of concurrent lightboxes
+// The more concurrent lighboxes, the worse performance gets (especially on low-end devices)
+// -1 means unlimited
+const NUMBER_OF_CONCURRENT_LIGHTBOXES = 3;
+
+const cachedDimensions = new Map();
+
+/**
+ * On the native layer, we use a image library to handle zoom functionality
+ */
+const propTypes = {
+ ...zoomRangePropTypes,
+
+ /** Function for handle on press */
+ onPress: PropTypes.func,
+
+ /** Handles errors while displaying the image */
+ onError: PropTypes.func,
+
+ /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */
+ source: AttachmentsPropTypes.attachmentSourcePropType.isRequired,
+
+ /** Whether source url requires authentication */
+ isAuthTokenRequired: PropTypes.bool,
+
+ /** Whether the Lightbox is used within a carousel component and there are other sibling elements */
+ hasSiblingCarouselItems: PropTypes.bool,
+
+ /** The index of the carousel item */
+ index: PropTypes.number,
+
+ /** The index of the currently active carousel item */
+ activeIndex: PropTypes.number,
+
+ /** Additional styles to add to the component */
+ style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+};
+
+const defaultProps = {
+ ...zoomRangeDefaultProps,
+
+ isAuthTokenRequired: false,
+ index: 0,
+ activeIndex: 0,
+ hasSiblingCarouselItems: false,
+ onPress: () => {},
+ onError: () => {},
+ style: {},
+};
+
+function Lightbox({isAuthTokenRequired, source, onScaleChanged, onPress, onError, style, index, activeIndex, hasSiblingCarouselItems, zoomRange}) {
+ const StyleUtils = useStyleUtils();
+
+ const [containerSize, setContainerSize] = useState({width: 0, height: 0});
+ const isContainerLoaded = containerSize.width !== 0 && containerSize.height !== 0;
+
+ const [imageDimensions, _setImageDimensions] = useState(() => cachedDimensions.get(source));
+ const setImageDimensions = (newDimensions) => {
+ _setImageDimensions(newDimensions);
+ cachedDimensions.set(source, newDimensions);
+ };
+
+ const isItemActive = index === activeIndex;
+ const [isActive, setActive] = useState(isItemActive);
+ const [isImageLoaded, setImageLoaded] = useState(false);
+
+ const isInactiveCarouselItem = hasSiblingCarouselItems && !isActive;
+ const [isFallbackVisible, setFallbackVisible] = useState(isInactiveCarouselItem);
+ const [isFallbackLoaded, setFallbackLoaded] = useState(false);
+
+ const isLightboxLoaded = imageDimensions?.lightboxSize != null;
+ const isLightboxInRange = useMemo(() => {
+ if (NUMBER_OF_CONCURRENT_LIGHTBOXES === -1) {
+ return true;
+ }
+
+ const indexCanvasOffset = Math.floor((NUMBER_OF_CONCURRENT_LIGHTBOXES - 1) / 2) || 0;
+ const indexOutOfRange = index > activeIndex + indexCanvasOffset || index < activeIndex - indexCanvasOffset;
+ return !indexOutOfRange;
+ }, [activeIndex, index]);
+ const isLightboxVisible = isLightboxInRange && (isActive || isLightboxLoaded || isFallbackLoaded);
+
+ const isLoading = isActive && (!isContainerLoaded || !isImageLoaded);
+
+ const updateCanvasSize = useCallback(
+ ({nativeEvent}) => setContainerSize({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)}),
+ [],
+ );
+
+ // We delay setting a page to active state by a (few) millisecond(s),
+ // to prevent the image transformer from flashing while still rendering
+ // Instead, we show the fallback image while the image transformer is loading the image
+ useEffect(() => {
+ if (isItemActive) {
+ setTimeout(() => setActive(true), 1);
+ } else {
+ setActive(false);
+ }
+ }, [isItemActive]);
+
+ useEffect(() => {
+ if (isLightboxVisible) {
+ return;
+ }
+ setImageLoaded(false);
+ }, [isLightboxVisible]);
+
+ useEffect(() => {
+ if (!hasSiblingCarouselItems) {
+ return;
+ }
+
+ if (isActive) {
+ if (isImageLoaded && isFallbackVisible) {
+ // We delay hiding the fallback image while image transformer is still rendering
+ setTimeout(() => {
+ setFallbackVisible(false);
+ setFallbackLoaded(false);
+ }, 100);
+ }
+ } else {
+ if (isLightboxVisible && isLightboxLoaded) {
+ return;
+ }
+
+ // Show fallback when the image goes out of focus or when the image is loading
+ setFallbackVisible(true);
+ }
+ }, [hasSiblingCarouselItems, isActive, isImageLoaded, isFallbackVisible, isLightboxLoaded, isLightboxVisible]);
+
+ const fallbackSize = useMemo(() => {
+ if (!hasSiblingCarouselItems || (imageDimensions?.lightboxSize == null && imageDimensions?.fallbackSize == null) || containerSize.width === 0 || containerSize.height === 0) {
+ return;
+ }
+
+ const imageSize = imageDimensions.lightboxSize || imageDimensions.fallbackSize;
+
+ const {minScale} = getCanvasFitScale({canvasSize: containerSize, contentSize: imageSize});
+
+ return {
+ width: PixelRatio.roundToNearestPixel(imageSize.width * minScale),
+ height: PixelRatio.roundToNearestPixel(imageSize.height * minScale),
+ };
+ }, [containerSize, hasSiblingCarouselItems, imageDimensions]);
+
+ return (
+
+ {isContainerLoaded && (
+ <>
+ {isLightboxVisible && (
+
+
+ setImageLoaded(true)}
+ onLoad={(e) => {
+ const width = (e.nativeEvent?.width || 0) / PixelRatio.get();
+ const height = (e.nativeEvent?.height || 0) / PixelRatio.get();
+ setImageDimensions({...imageDimensions, lightboxSize: {width, height}});
+ }}
+ />
+
+
+ )}
+
+ {/* Keep rendering the image without gestures as fallback if the carousel item is not active and while the lightbox is loading the image */}
+ {isFallbackVisible && (
+
+ setFallbackLoaded(true)}
+ onLoad={(e) => {
+ const width = e.nativeEvent?.width || 0;
+ const height = e.nativeEvent?.height || 0;
+
+ if (imageDimensions?.lightboxSize != null) {
+ return;
+ }
+
+ setImageDimensions({...imageDimensions, fallbackSize: {width, height}});
+ }}
+ />
+
+ )}
+
+ {/* Show activity indicator while the lightbox is still loading the image. */}
+ {isLoading && (
+
+ )}
+ >
+ )}
+
+ );
+}
+
+Lightbox.propTypes = propTypes;
+Lightbox.defaultProps = defaultProps;
+Lightbox.displayName = 'Lightbox';
+
+export default Lightbox;
diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx
index 2fa4e1c749e6..2e68197bbaf0 100644
--- a/src/components/LocaleContextProvider.tsx
+++ b/src/components/LocaleContextProvider.tsx
@@ -36,7 +36,7 @@ type LocaleContextProps = {
datetimeToRelative: (datetime: string) => string;
/** Formats a datetime to local date and time string */
- datetimeToCalendarTime: (datetime: string, includeTimezone: boolean, isLowercase: boolean) => string;
+ datetimeToCalendarTime: (datetime: string, includeTimezone: boolean, isLowercase?: boolean) => string;
/** Updates date-fns internal locale */
updateLocale: () => void;
diff --git a/src/components/MultiGestureCanvas/getCanvasFitScale.ts b/src/components/MultiGestureCanvas/getCanvasFitScale.ts
new file mode 100644
index 000000000000..e3e402fb066b
--- /dev/null
+++ b/src/components/MultiGestureCanvas/getCanvasFitScale.ts
@@ -0,0 +1,22 @@
+type GetCanvasFitScale = (props: {
+ canvasSize: {
+ width: number;
+ height: number;
+ };
+ contentSize: {
+ width: number;
+ height: number;
+ };
+}) => {scaleX: number; scaleY: number; minScale: number; maxScale: number};
+
+const getCanvasFitScale: GetCanvasFitScale = ({canvasSize, contentSize}) => {
+ const scaleX = canvasSize.width / contentSize.width;
+ const scaleY = canvasSize.height / contentSize.height;
+
+ const minScale = Math.min(scaleX, scaleY);
+ const maxScale = Math.max(scaleX, scaleY);
+
+ return {scaleX, scaleY, minScale, maxScale};
+};
+
+export default getCanvasFitScale;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js b/src/components/MultiGestureCanvas/index.js
similarity index 71%
rename from src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js
rename to src/components/MultiGestureCanvas/index.js
index 4bce03b6f25e..c5fd2632c22d 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js
+++ b/src/components/MultiGestureCanvas/index.js
@@ -1,5 +1,3 @@
-/* eslint-disable es/no-optional-chaining */
-import PropTypes from 'prop-types';
import React, {useContext, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
@@ -15,18 +13,19 @@ import Animated, {
withDecay,
withSpring,
} from 'react-native-reanimated';
+import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext';
+import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
-import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext';
-import ImageWrapper from './ImageWrapper';
-
-const MIN_ZOOM_SCALE_WITHOUT_BOUNCE = 1;
-const MAX_ZOOM_SCALE_WITHOUT_BOUNCE = 20;
-
-const MIN_ZOOM_SCALE_WITH_BOUNCE = MIN_ZOOM_SCALE_WITHOUT_BOUNCE * 0.7;
-const MAX_ZOOM_SCALE_WITH_BOUNCE = MAX_ZOOM_SCALE_WITHOUT_BOUNCE * 1.5;
+import getCanvasFitScale from './getCanvasFitScale';
+import {defaultZoomRange, multiGestureCanvasDefaultProps, multiGestureCanvasPropTypes} from './propTypes';
const DOUBLE_TAP_SCALE = 3;
+const zoomScaleBounceFactors = {
+ min: 0.7,
+ max: 1.5,
+};
+
const SPRING_CONFIG = {
mass: 1,
stiffness: 1000,
@@ -39,44 +38,54 @@ function clamp(value, lowerBound, upperBound) {
return Math.min(Math.max(lowerBound, value), upperBound);
}
-const imageTransformerPropTypes = {
- imageWidth: PropTypes.number,
- imageHeight: PropTypes.number,
- imageScaleX: PropTypes.number,
- imageScaleY: PropTypes.number,
- scaledImageWidth: PropTypes.number,
- scaledImageHeight: PropTypes.number,
- isActive: PropTypes.bool.isRequired,
- children: PropTypes.node.isRequired,
-};
+function getDeepDefaultProps({contentSize: contentSizeProp = {}, zoomRange: zoomRangeProp = {}}) {
+ const contentSize = {
+ width: contentSizeProp.width == null ? 1 : contentSizeProp.width,
+ height: contentSizeProp.height == null ? 1 : contentSizeProp.height,
+ };
-const imageTransformerDefaultProps = {
- imageWidth: 0,
- imageHeight: 0,
- imageScaleX: 1,
- imageScaleY: 1,
- scaledImageWidth: 0,
- scaledImageHeight: 0,
-};
+ const zoomRange = {
+ min: zoomRangeProp.min == null ? defaultZoomRange.min : zoomRangeProp.min,
+ max: zoomRangeProp.max == null ? defaultZoomRange.max : zoomRangeProp.max,
+ };
-function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, scaledImageWidth, scaledImageHeight, isActive, children}) {
+ return {contentSize, zoomRange};
+}
+
+function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, children, ...props}) {
const styles = useThemeStyles();
- const {canvasWidth, canvasHeight, onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isScrolling, onPinchGestureChange} = useContext(AttachmentCarouselPagerContext);
+ const StyleUtils = useStyleUtils();
+ const {contentSize, zoomRange} = getDeepDefaultProps(props);
+
+ const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext);
+
+ const pagerRefFallback = useRef(null);
+ const {onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isScrolling, onPinchGestureChange} = attachmentCarouselPagerContext || {
+ onTap: () => undefined,
+ onSwipe: () => undefined,
+ onSwipeSuccess: () => undefined,
+ onPinchGestureChange: () => undefined,
+ pagerRef: pagerRefFallback,
+ shouldPagerScroll: false,
+ isScrolling: false,
+ ...props,
+ };
- const minImageScale = useMemo(() => Math.min(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]);
- const maxImageScale = useMemo(() => Math.max(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]);
+ const {minScale: minContentScale, maxScale: maxContentScale} = useMemo(() => getCanvasFitScale({canvasSize, contentSize}), [canvasSize, contentSize]);
+ const scaledWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]);
+ const scaledHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]);
// On double tap zoom to fill, but at least 3x zoom
- const doubleTapScale = useMemo(() => Math.max(maxImageScale / minImageScale, DOUBLE_TAP_SCALE), [maxImageScale, minImageScale]);
+ const doubleTapScale = useMemo(() => Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale), [maxContentScale, minContentScale]);
const zoomScale = useSharedValue(1);
- // Adding together the pinch zoom scale and the initial scale to fit the image into the canvas
- // Using the smaller imageScale, so that the immage is not bigger than the canvas
+ // Adding together the pinch zoom scale and the initial scale to fit the content into the canvas
+ // Using the smaller content scale, so that the immage is not bigger than the canvas
// and not smaller than needed to fit
- const totalScale = useDerivedValue(() => zoomScale.value * minImageScale, [minImageScale]);
+ const totalScale = useDerivedValue(() => zoomScale.value * minContentScale, [minContentScale]);
- const zoomScaledImageWidth = useDerivedValue(() => imageWidth * totalScale.value, [imageWidth]);
- const zoomScaledImageHeight = useDerivedValue(() => imageHeight * totalScale.value, [imageHeight]);
+ const zoomScaledContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]);
+ const zoomScaledContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]);
// used for pan gesture
const translateY = useSharedValue(0);
@@ -104,22 +113,22 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
// store scale in between gestures
const pinchScaleOffset = useSharedValue(1);
- // disable pan vertically when image is smaller than screen
- const canPanVertically = useDerivedValue(() => canvasHeight < zoomScaledImageHeight.value, [canvasHeight]);
+ // disable pan vertically when content is smaller than screen
+ const canPanVertically = useDerivedValue(() => canvasSize.height < zoomScaledContentHeight.value, [canvasSize.height]);
- // calculates bounds of the scaled image
+ // calculates bounds of the scaled content
// can we pan left/right/up/down
// can be used to limit gesture or implementing tension effect
const getBounds = useWorkletCallback(() => {
let rightBoundary = 0;
let topBoundary = 0;
- if (canvasWidth < zoomScaledImageWidth.value) {
- rightBoundary = Math.abs(canvasWidth - zoomScaledImageWidth.value) / 2;
+ if (canvasSize.width < zoomScaledContentWidth.value) {
+ rightBoundary = Math.abs(canvasSize.width - zoomScaledContentWidth.value) / 2;
}
- if (canvasHeight < zoomScaledImageHeight.value) {
- topBoundary = Math.abs(zoomScaledImageHeight.value - canvasHeight) / 2;
+ if (canvasSize.height < zoomScaledContentHeight.value) {
+ topBoundary = Math.abs(zoomScaledContentHeight.value - canvasSize.height) / 2;
}
const maxVector = {x: rightBoundary, y: topBoundary};
@@ -142,7 +151,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
canPanLeft: target.x < maxVector.x,
canPanRight: target.x > minVector.x,
};
- }, [canvasWidth, canvasHeight]);
+ }, [canvasSize.width, canvasSize.height]);
const afterPanGesture = useWorkletCallback(() => {
const {target, isInBoundaryX, isInBoundaryY, minVector, maxVector} = getBounds();
@@ -166,7 +175,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
const deceleration = 0.9915;
if (isInBoundaryX) {
- if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE) {
+ if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= zoomRange.max) {
offsetX.value = withDecay({
velocity: panVelocityX.value,
clamp: [minVector.x, maxVector.x],
@@ -181,8 +190,8 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
if (isInBoundaryY) {
if (
Math.abs(panVelocityY.value) > 0 &&
- zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE &&
- // limit vertical pan only when image is smaller than screen
+ zoomScale.value <= zoomRange.max &&
+ // limit vertical pan only when content is smaller than screen
offsetY.value !== minVector.y &&
offsetY.value !== maxVector.y
) {
@@ -210,42 +219,42 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
stopAnimation();
- const canvasOffsetX = Math.max(0, (canvasWidth - scaledImageWidth) / 2);
- const canvasOffsetY = Math.max(0, (canvasHeight - scaledImageHeight) / 2);
+ const canvasOffsetX = Math.max(0, (canvasSize.width - scaledWidth) / 2);
+ const canvasOffsetY = Math.max(0, (canvasSize.height - scaledHeight) / 2);
- const imageFocal = {
- x: clamp(canvasFocalX - canvasOffsetX, 0, scaledImageWidth),
- y: clamp(canvasFocalY - canvasOffsetY, 0, scaledImageHeight),
+ const contentFocal = {
+ x: clamp(canvasFocalX - canvasOffsetX, 0, scaledWidth),
+ y: clamp(canvasFocalY - canvasOffsetY, 0, scaledHeight),
};
const canvasCenter = {
- x: canvasWidth / 2,
- y: canvasHeight / 2,
+ x: canvasSize.width / 2,
+ y: canvasSize.height / 2,
};
- const originImageCenter = {
- x: scaledImageWidth / 2,
- y: scaledImageHeight / 2,
+ const originContentCenter = {
+ x: scaledWidth / 2,
+ y: scaledHeight / 2,
};
- const targetImageSize = {
- width: scaledImageWidth * doubleTapScale,
- height: scaledImageHeight * doubleTapScale,
+ const targetContentSize = {
+ width: scaledWidth * doubleTapScale,
+ height: scaledHeight * doubleTapScale,
};
- const targetImageCenter = {
- x: targetImageSize.width / 2,
- y: targetImageSize.height / 2,
+ const targetContentCenter = {
+ x: targetContentSize.width / 2,
+ y: targetContentSize.height / 2,
};
const currentOrigin = {
- x: (targetImageCenter.x - canvasCenter.x) * -1,
- y: (targetImageCenter.y - canvasCenter.y) * -1,
+ x: (targetContentCenter.x - canvasCenter.x) * -1,
+ y: (targetContentCenter.y - canvasCenter.y) * -1,
};
const koef = {
- x: (1 / originImageCenter.x) * imageFocal.x - 1,
- y: (1 / originImageCenter.y) * imageFocal.y - 1,
+ x: (1 / originContentCenter.x) * contentFocal.x - 1,
+ y: (1 / originContentCenter.y) * contentFocal.y - 1,
};
const target = {
@@ -253,7 +262,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
y: currentOrigin.y * koef.y,
};
- if (targetImageSize.height < canvasHeight) {
+ if (targetContentSize.height < canvasSize.height) {
target.y = 0;
}
@@ -262,7 +271,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG);
pinchScaleOffset.value = doubleTapScale;
},
- [scaledImageWidth, scaledImageHeight, canvasWidth, canvasHeight],
+ [scaledWidth, scaledHeight, canvasSize, doubleTapScale],
);
const reset = useWorkletCallback((animated) => {
@@ -295,6 +304,10 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
} else {
zoomToCoordinates(evt.x, evt.y);
}
+
+ if (onScaleChanged != null) {
+ runOnJS(onScaleChanged)(zoomScale.value);
+ }
});
const panGestureRef = useRef(Gesture.Pan());
@@ -396,7 +409,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
};
offsetY.value = withSpring(
- maybeInvert(imageHeight * 2),
+ maybeInvert(contentSize.height * 2),
{
stiffness: 50,
damping: 30,
@@ -423,10 +436,10 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
const getAdjustedFocal = useWorkletCallback(
(focalX, focalY) => ({
- x: focalX - (canvasWidth / 2 + offsetX.value),
- y: focalY - (canvasHeight / 2 + offsetY.value),
+ x: focalX - (canvasSize.width / 2 + offsetX.value),
+ y: focalY - (canvasSize.height / 2 + offsetY.value),
}),
- [canvasWidth, canvasHeight],
+ [canvasSize.width, canvasSize.height],
);
// used to store event scale value when we limit scale
@@ -455,7 +468,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
.onChange((evt) => {
const newZoomScale = pinchScaleOffset.value * evt.scale;
- if (zoomScale.value >= MIN_ZOOM_SCALE_WITH_BOUNCE && zoomScale.value <= MAX_ZOOM_SCALE_WITH_BOUNCE) {
+ if (zoomScale.value >= zoomRange.min * zoomScaleBounceFactors.min && zoomScale.value <= zoomRange.max * zoomScaleBounceFactors.max) {
zoomScale.value = newZoomScale;
pinchGestureScale.value = evt.scale;
}
@@ -464,7 +477,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
const newPinchTranslateX = adjustedFocal.x + pinchGestureScale.value * origin.x.value * -1;
const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * origin.y.value * -1;
- if (zoomScale.value >= MIN_ZOOM_SCALE_WITHOUT_BOUNCE && zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE) {
+ if (zoomScale.value >= zoomRange.min && zoomScale.value <= zoomRange.max) {
pinchTranslateX.value = newPinchTranslateX;
pinchTranslateY.value = newPinchTranslateY;
} else {
@@ -480,12 +493,12 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
pinchScaleOffset.value = zoomScale.value;
pinchGestureScale.value = 1;
- if (pinchScaleOffset.value < MIN_ZOOM_SCALE_WITHOUT_BOUNCE) {
- pinchScaleOffset.value = MIN_ZOOM_SCALE_WITHOUT_BOUNCE;
- zoomScale.value = withSpring(MIN_ZOOM_SCALE_WITHOUT_BOUNCE, SPRING_CONFIG);
- } else if (pinchScaleOffset.value > MAX_ZOOM_SCALE_WITHOUT_BOUNCE) {
- pinchScaleOffset.value = MAX_ZOOM_SCALE_WITHOUT_BOUNCE;
- zoomScale.value = withSpring(MAX_ZOOM_SCALE_WITHOUT_BOUNCE, SPRING_CONFIG);
+ if (pinchScaleOffset.value < zoomRange.min) {
+ pinchScaleOffset.value = zoomRange.min;
+ zoomScale.value = withSpring(zoomRange.min, SPRING_CONFIG);
+ } else if (pinchScaleOffset.value > zoomRange.max) {
+ pinchScaleOffset.value = zoomRange.max;
+ zoomScale.value = withSpring(zoomRange.max, SPRING_CONFIG);
}
if (pinchBounceTranslateX.value !== 0 || pinchBounceTranslateY.value !== 0) {
@@ -494,6 +507,10 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
}
pinchGestureRunning.value = false;
+
+ if (onScaleChanged != null) {
+ runOnJS(onScaleChanged)(zoomScale.value);
+ }
});
const [isPinchGestureInUse, setIsPinchGestureInUse] = useState(false);
@@ -556,25 +573,30 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
style={[
styles.flex1,
{
- width: canvasWidth,
+ width: canvasSize.width,
+ overflow: 'hidden',
},
]}
>
-
+
{children}
-
+
);
}
-ImageTransformer.propTypes = imageTransformerPropTypes;
-ImageTransformer.defaultProps = imageTransformerDefaultProps;
-ImageTransformer.displayName = 'ImageTransformer';
+MultiGestureCanvas.propTypes = multiGestureCanvasPropTypes;
+MultiGestureCanvas.defaultProps = multiGestureCanvasDefaultProps;
+MultiGestureCanvas.displayName = 'MultiGestureCanvas';
-export default ImageTransformer;
+export default MultiGestureCanvas;
+export {defaultZoomRange, zoomScaleBounceFactors};
diff --git a/src/components/MultiGestureCanvas/propTypes.js b/src/components/MultiGestureCanvas/propTypes.js
new file mode 100644
index 000000000000..f1961ec0e156
--- /dev/null
+++ b/src/components/MultiGestureCanvas/propTypes.js
@@ -0,0 +1,73 @@
+import PropTypes from 'prop-types';
+
+const defaultZoomRange = {
+ min: 1,
+ max: 20,
+};
+
+const zoomRangePropTypes = {
+ /** Range of zoom that can be applied to the content by pinching or double tapping. */
+ zoomRange: PropTypes.shape({
+ min: PropTypes.number,
+ max: PropTypes.number,
+ }),
+};
+
+const zoomRangeDefaultProps = {
+ zoomRange: {
+ min: defaultZoomRange.min,
+ max: defaultZoomRange.max,
+ },
+};
+
+const multiGestureCanvasPropTypes = {
+ ...zoomRangePropTypes,
+
+ /**
+ * Wheter the canvas is currently active (in the screen) or not.
+ * Disables certain gestures and functionality
+ */
+ isActive: PropTypes.bool,
+
+ /** Handles scale changed event */
+ onScaleChanged: PropTypes.func,
+
+ /** The width and height of the canvas.
+ * This is needed in order to properly scale the content in the canvas
+ */
+ canvasSize: PropTypes.shape({
+ width: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ }).isRequired,
+
+ /** The width and height of the content.
+ * This is needed in order to properly scale the content in the canvas
+ */
+ contentSize: PropTypes.shape({
+ width: PropTypes.number,
+ height: PropTypes.number,
+ }),
+
+ /** The scale factors (scaleX, scaleY) that are used to scale the content (width/height) to the canvas size.
+ * `scaledWidth` and `scaledHeight` reflect the actual size of the content after scaling.
+ */
+ contentScaling: PropTypes.shape({
+ scaleX: PropTypes.number,
+ scaleY: PropTypes.number,
+ scaledWidth: PropTypes.number,
+ scaledHeight: PropTypes.number,
+ }),
+
+ /** Content that should be transformed inside the canvas (images, pdf, ...) */
+ children: PropTypes.node.isRequired,
+};
+
+const multiGestureCanvasDefaultProps = {
+ isActive: true,
+ onScaleChanged: () => undefined,
+ contentSize: undefined,
+ contentScaling: undefined,
+ zoomRange: undefined,
+};
+
+export {defaultZoomRange, zoomRangePropTypes, zoomRangeDefaultProps, multiGestureCanvasPropTypes, multiGestureCanvasDefaultProps};
diff --git a/src/components/Picker/BasePicker.tsx b/src/components/Picker/BasePicker.tsx
index 6f587f350b79..c6b5026b1938 100644
--- a/src/components/Picker/BasePicker.tsx
+++ b/src/components/Picker/BasePicker.tsx
@@ -9,6 +9,8 @@ import Text from '@components/Text';
import useScrollContext from '@hooks/useScrollContext';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import getOperatingSystem from '@libs/getOperatingSystem';
+import CONST from '@src/CONST';
import type {BasePickerHandle, BasePickerProps} from './types';
type IconToRender = () => ReactElement;
@@ -47,7 +49,7 @@ function BasePicker(
// Windows will reuse the text color of the select for each one of the options
// so we might need to color accordingly so it doesn't blend with the background.
- const pickerPlaceholder = Object.keys(placeholder).length > 0 ? {...placeholder, color: theme.pickerOptionsTextColor} : {};
+ const pickerPlaceholder = Object.keys(placeholder).length > 0 ? {...placeholder, color: theme.text} : {};
useEffect(() => {
if (!!value || !items || items.length !== 1 || !onInputChange) {
@@ -136,6 +138,17 @@ function BasePicker(
},
}));
+ /**
+ * We pass light text on Android, since Android Native alerts have a dark background in all themes for now.
+ */
+ const itemColor = useMemo(() => {
+ if (getOperatingSystem() === CONST.OS.ANDROID) {
+ return theme.textLight;
+ }
+
+ return theme.text;
+ }, [theme]);
+
const hasError = !!errorText;
if (isDisabled) {
@@ -165,7 +178,7 @@ function BasePicker(
({...item, color: theme.pickerOptionsTextColor}))}
+ items={items.map((item) => ({...item, color: itemColor}))}
style={size === 'normal' ? styles.picker(isDisabled, backgroundColor) : styles.pickerSmall(backgroundColor)}
useNativeAndroidPickerStyle={false}
placeholder={pickerPlaceholder}
diff --git a/src/components/PinButton.js b/src/components/PinButton.js
deleted file mode 100644
index 182e49054100..000000000000
--- a/src/components/PinButton.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import reportPropTypes from '@pages/reportPropTypes';
-import * as Report from '@userActions/Report';
-import * as Session from '@userActions/Session';
-import CONST from '@src/CONST';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
-import PressableWithFeedback from './Pressable/PressableWithFeedback';
-import Tooltip from './Tooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-
-const propTypes = {
- /** Report to pin */
- report: reportPropTypes,
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- report: null,
-};
-
-function PinButton(props) {
- const theme = useTheme();
- const styles = useThemeStyles();
- return (
-
- Report.togglePinnedState(props.report.reportID, props.report.isPinned))}
- style={[styles.touchableButtonImage]}
- ariaChecked={props.report.isPinned}
- accessibilityLabel={props.report.isPinned ? props.translate('common.unPin') : props.translate('common.pin')}
- role={CONST.ROLE.BUTTON}
- >
-
-
-
- );
-}
-
-PinButton.displayName = 'PinButton';
-PinButton.propTypes = propTypes;
-PinButton.defaultProps = defaultProps;
-
-export default withLocalize(PinButton);
diff --git a/src/components/PinButton.tsx b/src/components/PinButton.tsx
new file mode 100644
index 000000000000..2ae74853d571
--- /dev/null
+++ b/src/components/PinButton.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as ReportActions from '@userActions/Report';
+import * as Session from '@userActions/Session';
+import CONST from '@src/CONST';
+import type {Report} from '@src/types/onyx';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+import PressableWithFeedback from './Pressable/PressableWithFeedback';
+import Tooltip from './Tooltip';
+
+type PinButtonProps = {
+ /** Report to pin */
+ report: Report;
+};
+
+function PinButton({report}: PinButtonProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ return (
+
+ ReportActions.togglePinnedState(report.reportID, report.isPinned ?? false))}
+ style={styles.touchableButtonImage}
+ accessibilityLabel={report.isPinned ? translate('common.unPin') : translate('common.pin')}
+ role={CONST.ROLE.BUTTON}
+ >
+
+
+
+ );
+}
+
+PinButton.displayName = 'PinButton';
+
+export default PinButton;
diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js
index ed875bb04af2..a7728045f407 100644
--- a/src/components/ReportActionItem/TaskPreview.js
+++ b/src/components/ReportActionItem/TaskPreview.js
@@ -54,6 +54,12 @@ const propTypes = {
ownerAccountID: PropTypes.number,
}),
+ /** The policy of root parent report */
+ rootParentReportpolicy: PropTypes.shape({
+ /** The role of current user */
+ role: PropTypes.string,
+ }),
+
/** The chat report associated with taskReport */
chatReportID: PropTypes.string.isRequired,
@@ -72,6 +78,7 @@ const propTypes = {
const defaultProps = {
...withCurrentUserPersonalDetailsDefaultProps,
taskReport: {},
+ rootParentReportpolicy: {},
isHovered: false,
};
@@ -116,7 +123,7 @@ function TaskPreview(props) {
style={[styles.mr2]}
containerStyle={[styles.taskCheckbox]}
isChecked={isTaskCompleted}
- disabled={!Task.canModifyTask(props.taskReport, props.currentUserPersonalDetails.accountID)}
+ disabled={!Task.canModifyTask(props.taskReport, props.currentUserPersonalDetails.accountID, lodashGet(props.rootParentReportpolicy, 'role', ''))}
onPress={Session.checkIfActionIsAllowed(() => {
if (isTaskCompleted) {
Task.reopenTask(props.taskReport);
@@ -149,5 +156,9 @@ export default compose(
key: ({taskReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`,
initialValue: {},
},
+ rootParentReportpolicy: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID || '0'}`,
+ selector: (policy) => _.pick(policy, ['role']),
+ },
}),
)(TaskPreview);
diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js
index bb8945495018..7f7b177136ed 100644
--- a/src/components/ReportActionItem/TaskView.js
+++ b/src/components/ReportActionItem/TaskView.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import React, {useEffect} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
import Checkbox from '@components/Checkbox';
import Hoverable from '@components/Hoverable';
import Icon from '@components/Icon';
@@ -36,6 +37,12 @@ const propTypes = {
/** The report currently being looked at */
report: reportPropTypes.isRequired,
+ /** The policy of root parent report */
+ policy: PropTypes.shape({
+ /** The role of current user */
+ role: PropTypes.string,
+ }),
+
/** Whether we should display the horizontal rule below the component */
shouldShowHorizontalRule: PropTypes.bool.isRequired,
@@ -44,6 +51,10 @@ const propTypes = {
...withCurrentUserPersonalDetailsPropTypes,
};
+const defaultProps = {
+ policy: {},
+};
+
function TaskView(props) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -55,7 +66,7 @@ function TaskView(props) {
const assigneeTooltipDetails = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs([props.report.managerID], props.personalDetails), false);
const isCompleted = ReportUtils.isCompletedTaskReport(props.report);
const isOpen = ReportUtils.isOpenTaskReport(props.report);
- const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID);
+ const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID, lodashGet(props.policy, 'role', ''));
const disableState = !canModifyTask;
const isDisableInteractive = !canModifyTask || !isOpen;
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
@@ -188,6 +199,7 @@ function TaskView(props) {
}
TaskView.propTypes = propTypes;
+TaskView.defaultProps = defaultProps;
TaskView.displayName = 'TaskView';
export default compose(
@@ -198,5 +210,12 @@ export default compose(
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
+ policy: {
+ key: ({report}) => {
+ const rootParentReport = ReportUtils.getRootParentReport(report);
+ return `${ONYXKEYS.COLLECTION.POLICY}${rootParentReport ? rootParentReport.policyID : '0'}`;
+ },
+ selector: (policy) => _.pick(policy, ['role']),
+ },
}),
)(TaskView);
diff --git a/src/components/Search.tsx b/src/components/Search.tsx
new file mode 100644
index 000000000000..10820f44738d
--- /dev/null
+++ b/src/components/Search.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+import {PressableWithFeedback} from './Pressable';
+import Text from './Text';
+import Tooltip from './Tooltip';
+
+type SearchProps = {
+ // Callback fired when component is pressed
+ onPress: (event?: GestureResponderEvent | KeyboardEvent) => void;
+
+ // Text explaining what the user can search for
+ placeholder?: string;
+
+ // Text showing up in a tooltip when component is hovered
+ tooltip?: string;
+
+ // Styles to apply on the outer element
+ style?: StyleProp;
+};
+
+function Search({onPress, placeholder, tooltip, style}: SearchProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ return (
+
+
+ {({hovered}) => (
+
+
+
+ {placeholder ?? translate('common.searchWithThreeDots')}
+
+
+ )}
+
+
+ );
+}
+
+Search.displayName = 'Search';
+
+export type {SearchProps};
+export default Search;
diff --git a/src/components/TaskHeaderActionButton.tsx b/src/components/TaskHeaderActionButton.tsx
index 47975fd7fc1e..b1a32b356ae1 100644
--- a/src/components/TaskHeaderActionButton.tsx
+++ b/src/components/TaskHeaderActionButton.tsx
@@ -13,6 +13,9 @@ import Button from './Button';
type TaskHeaderActionButtonOnyxProps = {
/** Current user session */
session: OnyxEntry;
+
+ /** The policy of root parent report */
+ policy: OnyxEntry;
};
type TaskHeaderActionButtonProps = TaskHeaderActionButtonOnyxProps & {
@@ -20,7 +23,7 @@ type TaskHeaderActionButtonProps = TaskHeaderActionButtonOnyxProps & {
report: OnyxTypes.Report;
};
-function TaskHeaderActionButton({report, session}: TaskHeaderActionButtonProps) {
+function TaskHeaderActionButton({report, session, policy}: TaskHeaderActionButtonProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -28,7 +31,7 @@ function TaskHeaderActionButton({report, session}: TaskHeaderActionButtonProps)