diff --git a/android/app/build.gradle b/android/app/build.gradle
index d687ccfb0cc3..9c5db608a846 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -98,8 +98,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001045500
- versionName "1.4.55-0"
+ versionCode 1001045503
+ versionName "1.4.55-3"
}
flavorDimensions "default"
diff --git a/docs/articles/expensify-classic/expenses/Export-expenses.md b/docs/articles/expensify-classic/expenses/Export-expenses.md
new file mode 100644
index 000000000000..14c1532f84b5
--- /dev/null
+++ b/docs/articles/expensify-classic/expenses/Export-expenses.md
@@ -0,0 +1,13 @@
+---
+title: Export expenses
+description: Export expenses to a CSV
+---
+
+
+1. Click the **Expenses** tab.
+2. Select the expenses you want to export by checking the box to the left of each expense or selecting them all.
+3. Click **Export To** in the right corner and select either:
+ - **Default CSV**: Use Expensify’s default template
+ - **Create new CSV export layout**: Create your own custom CSV template
+
+
diff --git a/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md b/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md
new file mode 100644
index 000000000000..9037e58661d1
--- /dev/null
+++ b/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md
@@ -0,0 +1,30 @@
+---
+title: Assign billing owner and payment account
+description: Determine who will cover the cost of the workspace and link a payment method
+---
+
+
+The person who creates a workspace will automatically be responsible for the billing for that workspace. However, the existing billing owner can transfer the workspace’s billing ownership to any Admin on the workspace.
+
+{% include info.html %}
+There can only be one billing owner at a time. Assigning a new billing owner will automatically un-assign the existing billing owner. However, billing owners are also workspace admins by default, and the previous billing owner will remain a workspace admin unless manually updated.
+{% include end-info.html %}
+
+# Assign a new billing owner
+
+To assign a new billing owner, **the person who will take over responsibility for the workspace billing must complete the following process**:
+
+1. Hover over Settings, then click **Workspaces**.
+2. Click the desired workspace name.
+3. Under Workspace Overview, click **Take Over Billing**.
+
+# Add or update payment account
+
+Once you take over billing for a workspace, you must add a payment method to your account.
+
+1. Hover over Settings, then click **Account**.
+2. Click the **Payments** tab.
+3. Scroll down to the Payment Details sections and click **Add Payment Card**.
+4. Enter your credit or debit card information and click **Accept terms, add payment card, and pay $0.00** (the box will only show a balance if one is due).
+
+
diff --git a/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md b/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md
new file mode 100644
index 000000000000..b0b016afbcbb
--- /dev/null
+++ b/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md
@@ -0,0 +1,24 @@
+---
+title: Create a group workspace
+description: Create a workspace for your team's expense reports
+---
+
+
+A workspace is the set of rules, settings, and spending limits for expense reports in your organization. This includes the unique expense categories and tags, budgets, currency and tax settings, etc. that all workspace members will use. A workspace also defines the approval workflow for your employees, as well as the accounting connection if using an accounting software integration.
+
+Here are a couple examples of when you’d want to create different workspaces:
+
+- You have employees with expense reports in different currencies. For example, you may have a workspace for employees who live in the US and submit their reports in USD and a workspace for employees who live in Canada and submit in CAD.
+- You want to limit specific groups of people to their own set of expense coding options (categories/tags) then they can separate their employees by Sales, Marketing, Support, etc.
+
+To create a group workspace,
+
+1. Hover over Settings, then click **Workspaces**.
+2. Click the **Group** tab on the left.
+3. Click **New Workspace**.
+4. Enter the workspace name and select a workspace type.
+ - **Collect**: Ideal for small groups who only need basic features like expense approvals, reimbursement, corporate card management, and integration options.
+ - **Control**: For groups that need a deeper level of control and configurations, like multi-stage approval workflows, corporate card management, integrations, and more. This is the most popular option.
+5. Set up your workspace details including the workspace name, expense rules, categories, and more.
+
+
diff --git a/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md
new file mode 100644
index 000000000000..c8be9a2728d5
--- /dev/null
+++ b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md
@@ -0,0 +1,20 @@
+---
+title: Set up your individual workspace
+description: Capture your personal expenses
+---
+
+
+All Expensify accounts come with an individual workspace where you can track your personal expenses. If you want to connect your personal expenses to an accounting or travel integration, you can create a group workspace—even if you will be the only person in the group.
+
+To set up your individual workspace,
+
+1. Hover over Settings, then click **Workspaces**.
+2. Click the **Individual** tab on the left.
+3. Select the policy type that best fits your needs.
+4. Set up your workspace details including the workspace name, expense rules, categories, and more.
+
+{% include info.html %}
+You can create multiple group workspaces, but you can only create one individual workspace.
+{% include end-info.html %}
+
+
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 1ea2673b92ee..5e2ba1fcd614 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.55.0
+ 1.4.55.3
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index cc6c5cf4c86a..69472200e46d 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.55.0
+ 1.4.55.3
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 7c90acab4958..008ca16909b0 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString
1.4.55
CFBundleVersion
- 1.4.55.0
+ 1.4.55.3
NSExtension
NSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index dd2084b238fb..310003ee8adc 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -39,7 +39,7 @@ PODS:
- React-Core
- Expo (50.0.4):
- ExpoModulesCore
- - ExpoImage (1.10.1):
+ - ExpoImage (1.11.0):
- ExpoModulesCore
- SDWebImage (~> 5.17.0)
- SDWebImageAVIFCoder (~> 0.10.1)
@@ -1790,7 +1790,7 @@ SPEC CHECKSUMS:
EXAV: 09a4d87fa6b113fbb0ada3aade6799f78271cb44
EXImageLoader: 55080616b2fe9da19ef8c7f706afd9814e279b6b
Expo: 1e3bcf9dd99de57a636127057f6b488f0609681a
- ExpoImage: 1cdaa65a6a70bb01067e21ad1347ff2d973885f5
+ ExpoImage: 390c524542b258f8173f475c1cc71f016444a7be
ExpoImageManipulator: c1d7cb865eacd620a35659f3da34c70531f10b59
ExpoModulesCore: 96d1751929ad10622773bb729ab28a8423f0dd0c
FBLazyVector: fbc4957d9aa695250b55d879c1d86f79d7e69ab4
@@ -1921,7 +1921,7 @@ SPEC CHECKSUMS:
SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17
Turf: 13d1a92d969ca0311bbc26e8356cca178ce95da2
VisionCamera: 0a6794d1974aed5d653d0d0cb900493e2583e35a
- Yoga: 13c8ef87792450193e117976337b8527b49e8c03
+ Yoga: e64aa65de36c0832d04e8c7bd614396c77a80047
PODFILE CHECKSUM: a431c146e1501391834a2f299a74093bac53b530
diff --git a/package-lock.json b/package-lock.json
index 61d6a27821cd..4bff5eaf6eb8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.55-0",
+ "version": "1.4.55-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.55-0",
+ "version": "1.4.55-3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -51,10 +51,10 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4e020cfa13ffabde14313c92b341285aeb919f29",
"expo": "^50.0.3",
"expo-av": "~13.10.4",
- "expo-image": "1.10.1",
+ "expo-image": "1.11.0",
"expo-image-manipulator": "11.8.0",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
@@ -70,7 +70,7 @@
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-collapse": "^5.1.0",
- "react-content-loader": "^6.1.0",
+ "react-content-loader": "^7.0.0",
"react-dom": "18.1.0",
"react-error-boundary": "^4.0.11",
"react-map-gl": "^7.1.3",
@@ -23087,9 +23087,9 @@
}
},
"node_modules/classnames": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.4.0.tgz",
- "integrity": "sha512-lWxiIlphgAhTLN657pwU/ofFxsUTOWc2CRIFeoV5st0MGRJHStUnWIUJgDHxjUO/F0mXzGufXIM4Lfu/8h+MpA=="
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.0.tgz",
+ "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA=="
},
"node_modules/clean-css": {
"version": "5.3.2",
@@ -27370,11 +27370,11 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae",
- "integrity": "sha512-k/SmW3EBR+gxFkJP/59LJsmBKjnKR07XS30yk/GkQ0lIfyYkNmFJ0dWm/S/54ezFweezR7MDaQ3zGc45Mb/U5A==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#4e020cfa13ffabde14313c92b341285aeb919f29",
+ "integrity": "sha512-sx3cIYkmiydNaXRe4kJebPyEje8HfssUbsoB6uW8vvMLwFheCZfkmF9fRMBNLo8BQsfWIstT5TApEhwuWPjqZg==",
"license": "MIT",
"dependencies": {
- "classnames": "2.4.0",
+ "classnames": "2.5.0",
"clipboard": "2.0.11",
"html-entities": "^2.4.0",
"jquery": "3.6.0",
@@ -27383,7 +27383,7 @@
"prop-types": "15.8.1",
"react": "16.12.0",
"react-dom": "16.12.0",
- "semver": "^7.5.2",
+ "semver": "^7.6.0",
"simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5",
"ua-parser-js": "^1.0.37",
"underscore": "1.13.6"
@@ -27514,8 +27514,9 @@
}
},
"node_modules/expo-image": {
- "version": "1.10.1",
- "license": "MIT",
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-1.11.0.tgz",
+ "integrity": "sha512-CfkSGWIGidxxqzErke16bCS9aefS04qvgjk+T9Nc34LAb3ysBAqCv5hoCnvylHOvi/7jTCC/ouLm5oLLqkDSRQ==",
"dependencies": {
"@react-native/assets-registry": "~0.73.1"
},
@@ -38827,8 +38828,9 @@
}
},
"node_modules/react-content-loader": {
- "version": "6.2.0",
- "license": "MIT",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/react-content-loader/-/react-content-loader-7.0.0.tgz",
+ "integrity": "sha512-xaBwpO7eiJyEc4ndym+g6wcruV9W2y3DKqbw4U48QFBsv0IeAVZO+aCUb8GptlDLWM8n5zi2HcFSGlj5r+53Tg==",
"engines": {
"node": ">=10"
},
diff --git a/package.json b/package.json
index 92a6b9cde5e1..53eb229d7b85 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.55-0",
+ "version": "1.4.55-3",
"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.",
@@ -102,10 +102,10 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4e020cfa13ffabde14313c92b341285aeb919f29",
"expo": "^50.0.3",
"expo-av": "~13.10.4",
- "expo-image": "1.10.1",
+ "expo-image": "1.11.0",
"expo-image-manipulator": "11.8.0",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
@@ -121,7 +121,7 @@
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-collapse": "^5.1.0",
- "react-content-loader": "^6.1.0",
+ "react-content-loader": "^7.0.0",
"react-dom": "18.1.0",
"react-error-boundary": "^4.0.11",
"react-map-gl": "^7.1.3",
diff --git a/patches/expo-image+1.10.1+001+applyFill.patch b/patches/expo-image+1.10.1+001+applyFill.patch
deleted file mode 100644
index 5f168040d04d..000000000000
--- a/patches/expo-image+1.10.1+001+applyFill.patch
+++ /dev/null
@@ -1,112 +0,0 @@
-diff --git a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-index 619daf2..b58a0df 100644
---- a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-+++ b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-@@ -1,5 +1,9 @@
- package com.caverock.androidsvg
-
-+import com.caverock.androidsvg.SVG.SPECIFIED_COLOR
-+import com.caverock.androidsvg.SVG.SPECIFIED_FILL
-+import com.caverock.androidsvg.SVG.SvgElementBase
-+
- internal fun replaceColor(paint: SVG.SvgPaint?, newColor: Int) {
- if (paint is SVG.Colour && paint !== SVG.Colour.TRANSPARENT) {
- paint.colour = newColor
-@@ -19,15 +23,83 @@ internal fun replaceStyles(style: SVG.Style?, newColor: Int) {
- replaceColor(style.viewportFill, newColor)
- }
-
--internal fun applyTintColor(element: SVG.SvgObject, newColor: Int) {
-- if (element is SVG.SvgElementBase) {
-+internal fun hasStyle(element: SvgElementBase): Boolean {
-+ if (element.style == null && element.baseStyle == null) {
-+ return false
-+ }
-+
-+ val style = element.style
-+ val hasColorInStyle = style != null &&
-+ (
-+ style.color != null || style.fill != null || style.stroke != null ||
-+ style.stroke != null || style.stopColor != null || style.solidColor != null
-+ )
-+
-+ if (hasColorInStyle) {
-+ return true
-+ }
-+
-+ val baseStyle = element.baseStyle ?: return false
-+ return baseStyle.color != null || baseStyle.fill != null || baseStyle.stroke != null ||
-+ baseStyle.viewportFill != null || baseStyle.stopColor != null || baseStyle.solidColor != null
-+}
-+
-+internal fun defineStyles(element: SvgElementBase, newColor: Int, hasStyle: Boolean) {
-+ if (hasStyle) {
-+ return
-+ }
-+
-+ val style = if (element.style != null) {
-+ element.style
-+ } else {
-+ SVG.Style().also {
-+ element.style = it
-+ }
-+ }
-+
-+ val color = SVG.Colour(newColor)
-+ when (element) {
-+ is SVG.Path,
-+ is SVG.Circle,
-+ is SVG.Ellipse,
-+ is SVG.Rect,
-+ is SVG.SolidColor,
-+ is SVG.Line,
-+ is SVG.Polygon,
-+ is SVG.PolyLine -> {
-+ style.apply {
-+ fill = color
-+
-+ specifiedFlags = SPECIFIED_FILL
-+ }
-+ }
-+
-+ is SVG.TextPath -> {
-+ style.apply {
-+ this.color = color
-+
-+ specifiedFlags = SPECIFIED_COLOR
-+ }
-+ }
-+ }
-+}
-+
-+internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefinesStyle: Boolean) {
-+ val definesStyle = if (element is SvgElementBase) {
-+ val hasStyle = parentDefinesStyle || hasStyle(element)
-+
- replaceStyles(element.baseStyle, newColor)
- replaceStyles(element.style, newColor)
-+ defineStyles(element, newColor, hasStyle)
-+
-+ hasStyle
-+ } else {
-+ parentDefinesStyle
- }
-
- if (element is SVG.SvgContainer) {
- for (child in element.children) {
-- applyTintColor(child, newColor)
-+ applyTintColor(child, newColor, definesStyle)
- }
- }
- }
-@@ -36,8 +108,9 @@ fun applyTintColor(svg: SVG, newColor: Int) {
- val root = svg.rootElement
-
- replaceStyles(root.style, newColor)
-+ val hasStyle = hasStyle(root)
-
- for (child in root.children) {
-- applyTintColor(child, newColor)
-+ applyTintColor(child, newColor, hasStyle)
- }
- }
diff --git a/patches/expo-image+1.10.1+002+TintFix.patch b/patches/expo-image+1.10.1+002+TintFix.patch
deleted file mode 100644
index 92b56c039b43..000000000000
--- a/patches/expo-image+1.10.1+002+TintFix.patch
+++ /dev/null
@@ -1,38 +0,0 @@
-diff --git a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-index b58a0df..6b8da3c 100644
---- a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-+++ b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-@@ -107,6 +107,7 @@ internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefines
- fun applyTintColor(svg: SVG, newColor: Int) {
- val root = svg.rootElement
-
-+ replaceStyles(root.baseStyle, newColor)
- replaceStyles(root.style, newColor)
- val hasStyle = hasStyle(root)
-
-diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt
-index 602b570..8becf72 100644
---- a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt
-+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt
-@@ -31,6 +31,7 @@ import expo.modules.image.records.ImageLoadEvent
- import expo.modules.image.records.ImageProgressEvent
- import expo.modules.image.records.ImageTransition
- import expo.modules.image.records.SourceMap
-+import expo.modules.image.svg.SVGPictureDrawable
- import expo.modules.kotlin.AppContext
- import expo.modules.kotlin.tracing.beginAsyncTraceBlock
- import expo.modules.kotlin.tracing.trace
-@@ -127,7 +128,12 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
- internal var tintColor: Int? = null
- set(value) {
- field = value
-- activeView.setTintColor(value)
-+ // To apply the tint color to the SVG, we need to recreate the drawable.
-+ if (activeView.drawable is SVGPictureDrawable) {
-+ shouldRerender = true
-+ } else {
-+ activeView.setTintColor(value)
-+ }
- }
-
- internal var isFocusableProp: Boolean = false
diff --git a/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch b/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch
new file mode 100644
index 000000000000..e6a3822836f4
--- /dev/null
+++ b/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch
@@ -0,0 +1,43 @@
+diff --git a/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js b/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js
+index 0aec2d6..a71aec2 100644
+--- a/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js
++++ b/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js
+@@ -133,7 +133,7 @@ to return true:wantsResponderID| |
+
+ import createResponderEvent from './createResponderEvent';
+ import { isCancelish, isEndish, isMoveish, isScroll, isSelectionChange, isStartish } from './ResponderEventTypes';
+-import { getLowestCommonAncestor, getResponderPaths, hasTargetTouches, hasValidSelection, isPrimaryPointerDown, setResponderId } from './utils';
++import { getLowestCommonAncestor, getResponderPaths, hasTargetTouches, hasValidSelection, isPrimaryOrSecondaryPointerDown, setResponderId } from './utils';
+ import { ResponderTouchHistoryStore } from './ResponderTouchHistoryStore';
+ import canUseDOM from '../canUseDom';
+
+@@ -225,7 +225,7 @@ function eventListener(domEvent) {
+ }
+ return;
+ }
+- var isStartEvent = isStartish(eventType) && isPrimaryPointerDown(domEvent);
++ var isStartEvent = isStartish(eventType) && isPrimaryOrSecondaryPointerDown(domEvent);
+ var isMoveEvent = isMoveish(eventType);
+ var isEndEvent = isEndish(eventType);
+ var isScrollEvent = isScroll(eventType);
+diff --git a/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js b/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js
+index 7382cdd..d88f6c0 100644
+--- a/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js
++++ b/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js
+@@ -148,14 +148,14 @@ export function hasValidSelection(domEvent) {
+ /**
+ * Events are only valid if the primary button was used without specific modifier keys.
+ */
+-export function isPrimaryPointerDown(domEvent) {
++export function isPrimaryOrSecondaryPointerDown(domEvent) {
+ var altKey = domEvent.altKey,
+ button = domEvent.button,
+ buttons = domEvent.buttons,
+ ctrlKey = domEvent.ctrlKey,
+ type = domEvent.type;
+ var isTouch = type === 'touchstart' || type === 'touchmove';
+- var isPrimaryMouseDown = type === 'mousedown' && (button === 0 || buttons === 1);
++ var isPrimaryMouseDown = type === 'mousedown' && (button === 0 || buttons === 1 || buttons === 2);
+ var isPrimaryMouseMove = type === 'mousemove' && buttons === 1;
+ var noModifiers = altKey === false && ctrlKey === false;
+ if (isTouch || isPrimaryMouseDown && noModifiers || isPrimaryMouseMove && noModifiers) {
diff --git a/src/CONST.ts b/src/CONST.ts
index 3c53f083abac..af4864c22a85 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -630,6 +630,7 @@ const CONST = {
EXPORTEDTOQUICKBOOKS: 'EXPORTEDTOQUICKBOOKS', // OldDot Action
FORWARDED: 'FORWARDED', // OldDot Action
HOLD: 'HOLD',
+ HOLDCOMMENT: 'HOLDCOMMENT',
IOU: 'IOU',
INTEGRATIONSMESSAGE: 'INTEGRATIONSMESSAGE', // OldDot Action
MANAGERATTACHRECEIPT: 'MANAGERATTACHRECEIPT', // OldDot Action
diff --git a/src/Expensify.tsx b/src/Expensify.tsx
index 5681be838ca8..026025593aef 100644
--- a/src/Expensify.tsx
+++ b/src/Expensify.tsx
@@ -25,12 +25,10 @@ import Navigation from './libs/Navigation/Navigation';
import NavigationRoot from './libs/Navigation/NavigationRoot';
import NetworkConnection from './libs/NetworkConnection';
import PushNotification from './libs/Notification/PushNotification';
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
import './libs/Notification/PushNotification/subscribePushNotification';
import StartupTimer from './libs/StartupTimer';
// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-import UnreadIndicatorUpdater from './libs/UnreadIndicatorUpdater';
+import './libs/UnreadIndicatorUpdater';
import Visibility from './libs/Visibility';
import ONYXKEYS from './ONYXKEYS';
import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu';
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index d74e691fe10e..d3fab1b9fcde 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -353,6 +353,8 @@ const ONYXKEYS = {
WORKSPACE_TAX_CUSTOM_NAME_DRAFT: 'workspaceTaxCustomNameDraft',
POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm',
POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft',
+ POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm',
+ POLICY_DISTANCE_RATE_EDIT_FORM_DRAFT: 'policyDistanceRateEditFormDraft',
CLOSE_ACCOUNT_FORM: 'closeAccount',
CLOSE_ACCOUNT_FORM_DRAFT: 'closeAccountDraft',
PROFILE_SETTINGS_FORM: 'profileSettingsForm',
@@ -482,6 +484,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.POLICY_TAG_NAME_FORM]: FormTypes.PolicyTagNameForm;
[ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm;
[ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm;
+ [ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_EDIT_FORM]: FormTypes.PolicyDistanceRateEditForm;
[ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm;
[ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm;
};
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 1f802c5036e3..a49df16b570a 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -204,7 +204,7 @@ const ROUTES = {
},
REPORT_ATTACHMENTS: {
route: 'r/:reportID/attachment',
- getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}` as const,
+ getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURIComponent(source)}` as const,
},
REPORT_PARTICIPANTS: {
route: 'r/:reportID/participants',
@@ -637,8 +637,16 @@ const ROUTES = {
getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates/new` as const,
},
WORKSPACE_DISTANCE_RATES_SETTINGS: {
- route: 'settings/workspace/:policyID/distance-rates/settings',
- getRoute: (policyID: string) => `settings/workspace/${policyID}/distance-rates/settings` as const,
+ route: 'settings/workspaces/:policyID/distance-rates/settings',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates/settings` as const,
+ },
+ WORKSPACE_DISTANCE_RATE_DETAILS: {
+ route: 'settings/workspaces/:policyID/distance-rates/:rateID',
+ getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}` as const,
+ },
+ WORKSPACE_DISTANCE_RATE_EDIT: {
+ route: 'settings/workspaces/:policyID/distance-rates/:rateID/edit',
+ getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/edit` as const,
},
// Referral program promotion
REFERRAL_DETAILS_MODAL: {
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 4d4e9ea327c6..9c33c7e63d7d 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -246,6 +246,8 @@ const SCREENS = {
DISTANCE_RATES: 'Distance_Rates',
CREATE_DISTANCE_RATE: 'Create_Distance_Rate',
DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings',
+ DISTANCE_RATE_DETAILS: 'Distance_Rate_Details',
+ DISTANCE_RATE_EDIT: 'Distance_Rate_Edit',
},
EDIT_REQUEST: {
diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
index b934bdfdd738..9524c5203110 100644
--- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
+++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
@@ -15,10 +15,19 @@ import CONST from '@src/CONST';
function extractAttachmentsFromReport(parentReportAction, reportActions) {
const actions = [parentReportAction, ...ReportActionsUtils.getSortedReportActions(_.values(reportActions))];
const attachments = [];
+ // We handle duplicate image sources by considering the first instance as original. Selecting any duplicate
+ // and navigating back (<) shows the image preceding the first instance, not the selected duplicate's position.
+ const uniqueSources = new Set();
const htmlParser = new HtmlParser({
onopentag: (name, attribs) => {
if (name === 'video') {
+ const source = tryResolveUrlFromApiRoot(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]);
+ if (uniqueSources.has(source)) {
+ return;
+ }
+
+ uniqueSources.add(source);
const splittedUrl = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE].split('/');
attachments.unshift({
reportActionID: null,
@@ -35,7 +44,20 @@ function extractAttachmentsFromReport(parentReportAction, reportActions) {
if (name === 'img' && attribs.src) {
const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE];
const source = tryResolveUrlFromApiRoot(expensifySource || attribs.src);
- const fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`);
+ if (uniqueSources.has(source)) {
+ return;
+ }
+
+ uniqueSources.add(source);
+ let fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`);
+
+ // Public image URLs might lack a file extension in the source URL, without an extension our
+ // AttachmentView fails to recognize them as images and renders fallback content instead.
+ // We apply this small hack to add an image extension and ensure AttachmentView renders the image.
+ const fileInfo = FileUtils.splitExtensionFromFileName(fileName);
+ if (!fileInfo.fileExtension) {
+ fileName = `${fileInfo.fileName || 'image'}.jpg`;
+ }
// By iterating actions in chronological order and prepending each attachment
// we ensure correct order of attachments even across actions with multiple attachments.
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index 461548f0d2b1..9fe37734e8ee 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -79,6 +79,7 @@ const defaultProps = {
reportActionID: '',
isHovered: false,
optionalVideoDuration: 0,
+ fallbackSource: Expensicons.Gallery,
};
function AttachmentView({
@@ -201,6 +202,21 @@ function AttachmentView({
// We also check for numeric source since this is how static images (used for preview) are represented in RN.
const isImage = typeof source === 'number' || Str.isImage(source);
if (isImage || (file && Str.isImage(file.name))) {
+ if (imageError) {
+ // AttachmentViewImage can't handle icon fallbacks, so we need to handle it here
+ if (typeof fallbackSource === 'number' || _.isFunction(fallbackSource)) {
+ return (
+
+ );
+ }
+ }
+
return (
-
+
diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx
index 5f426f77b731..e89026137b67 100644
--- a/src/components/ButtonWithDropdownMenu/index.tsx
+++ b/src/components/ButtonWithDropdownMenu/index.tsx
@@ -116,7 +116,7 @@ function ButtonWithDropdownMenu({
success={success}
ref={buttonRef}
pressOnEnter={pressOnEnter}
- isDisabled={isDisabled}
+ isDisabled={isDisabled || !!options[0].disabled}
style={[styles.w100, style]}
isLoading={isLoading}
text={selectedItem.text}
diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts
index 83100788761f..87db9a29d827 100644
--- a/src/components/ButtonWithDropdownMenu/types.ts
+++ b/src/components/ButtonWithDropdownMenu/types.ts
@@ -22,6 +22,7 @@ type DropdownOption = {
iconHeight?: number;
iconDescription?: string;
onSelected?: () => void;
+ disabled?: boolean;
};
type ButtonWithDropdownMenuProps = {
diff --git a/src/components/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx
index 356fbd3726a3..524c8a3903e0 100644
--- a/src/components/CustomStatusBarAndBackground/index.tsx
+++ b/src/components/CustomStatusBarAndBackground/index.tsx
@@ -114,6 +114,11 @@ function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBack
[prevIsRootStatusBarEnabled, isRootStatusBarEnabled, statusBarAnimation, statusBarStyle, theme.PAGE_THEMES, theme.appBG, theme.statusBarStyle],
);
+ useEffect(() => {
+ updateStatusBarAppearance({backgroundColor: theme.appBG});
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this to run on first render
+ }, []);
+
useEffect(() => {
didForceUpdateStatusBarRef.current = false;
}, [isRootStatusBarEnabled]);
diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js
index e138ca4d4194..6bceaf570ccc 100644
--- a/src/components/EmojiPicker/EmojiPicker.js
+++ b/src/components/EmojiPicker/EmojiPicker.js
@@ -7,6 +7,7 @@ import withViewportOffsetTop from '@components/withViewportOffsetTop';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import * as Browser from '@libs/Browser';
import calculateAnchorPosition from '@libs/calculateAnchorPosition';
import CONST from '@src/CONST';
import EmojiPickerMenu from './EmojiPickerMenu';
@@ -169,6 +170,7 @@ const EmojiPicker = forwardRef((props, ref) => {
// emojis. The best alternative is to set it to 1ms so it just "pops" in and out
return (
{emojiCode};
+}
+
+EmojiWithTooltip.displayName = 'EmojiWithTooltip';
+
+export default EmojiWithTooltip;
diff --git a/src/components/EmojiWithTooltip/index.tsx b/src/components/EmojiWithTooltip/index.tsx
new file mode 100644
index 000000000000..32103544b3aa
--- /dev/null
+++ b/src/components/EmojiWithTooltip/index.tsx
@@ -0,0 +1,42 @@
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import Text from '@components/Text';
+import Tooltip from '@components/Tooltip';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as EmojiUtils from '@libs/EmojiUtils';
+import type EmojiWithTooltipProps from './types';
+
+function EmojiWithTooltip({emojiCode, style = {}}: EmojiWithTooltipProps) {
+ const {preferredLocale} = useLocalize();
+ const styles = useThemeStyles();
+ const emoji = EmojiUtils.findEmojiByCode(emojiCode);
+ const emojiName = EmojiUtils.getEmojiName(emoji, preferredLocale);
+
+ const emojiTooltipContent = useCallback(
+ () => (
+
+
+
+ {emojiCode}
+
+
+ {`:${emojiName}:`}
+
+ ),
+ [emojiCode, emojiName, styles.alignItemsCenter, styles.ph2, styles.flexRow, styles.emojiTooltipWrapper, styles.fontColorReactionLabel, styles.onlyEmojisText, styles.textMicro],
+ );
+
+ return (
+
+ {emojiCode}
+
+ );
+}
+
+EmojiWithTooltip.displayName = 'EmojiWithTooltip';
+
+export default EmojiWithTooltip;
diff --git a/src/components/EmojiWithTooltip/types.ts b/src/components/EmojiWithTooltip/types.ts
new file mode 100644
index 000000000000..d13c389c0568
--- /dev/null
+++ b/src/components/EmojiWithTooltip/types.ts
@@ -0,0 +1,8 @@
+import type {StyleProp, TextStyle} from 'react-native';
+
+type EmojiWithTooltipProps = {
+ emojiCode: string;
+ style?: StyleProp;
+};
+
+export default EmojiWithTooltipProps;
diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx
index 5c2488ca144a..902a96b1bcaf 100644
--- a/src/components/Form/FormWrapper.tsx
+++ b/src/components/Form/FormWrapper.tsx
@@ -110,7 +110,7 @@ function FormWrapper({
buttonText={submitButtonText}
isAlertVisible={((!isEmptyObject(errors) || !isEmptyObject(formState?.errorFields)) && !shouldHideFixErrorsAlert) || !!errorMessage}
isLoading={!!formState?.isLoading}
- message={typeof errorMessage === 'string' && isEmptyObject(formState?.errorFields) ? errorMessage : undefined}
+ message={isEmptyObject(formState?.errorFields) ? errorMessage : undefined}
onSubmit={onSubmit}
footerContent={footerContent}
onFixTheErrorsLinkPressed={onFixTheErrorsLinkPressed}
diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
index bd4f72c63ec3..af04c11de41e 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
@@ -70,6 +70,7 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim
mixedUAStyles: {whiteSpace: 'pre'},
contentModel: HTMLContentModel.block,
}),
+ emoji: HTMLElementModel.fromCustomModel({tagName: 'emoji', contentModel: HTMLContentModel.textual}),
}),
[styles.colorMuted, styles.formError, styles.mb0, styles.textLabelSupporting, styles.lh16],
);
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx
new file mode 100644
index 000000000000..6582e99477a8
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html';
+import EmojiWithTooltip from '@components/EmojiWithTooltip';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+function EmojiRenderer({tnode}: CustomRendererProps) {
+ const styles = useThemeStyles();
+ const style = 'islarge' in tnode.attributes ? styles.onlyEmojisText : {};
+ return (
+
+ );
+}
+
+EmojiRenderer.displayName = 'EmojiRenderer';
+
+export default EmojiRenderer;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
index 1914bcf4b5ff..fdd0c89ec5a0 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
@@ -2,6 +2,7 @@ import type {CustomTagRendererRecord} from 'react-native-render-html';
import AnchorRenderer from './AnchorRenderer';
import CodeRenderer from './CodeRenderer';
import EditedRenderer from './EditedRenderer';
+import EmojiRenderer from './EmojiRenderer';
import ImageRenderer from './ImageRenderer';
import MentionHereRenderer from './MentionHereRenderer';
import MentionUserRenderer from './MentionUserRenderer';
@@ -25,6 +26,7 @@ const HTMLEngineProviderComponentList: CustomTagRendererRecord = {
/* eslint-disable @typescript-eslint/naming-convention */
'mention-user': MentionUserRenderer,
'mention-here': MentionHereRenderer,
+ emoji: EmojiRenderer,
'next-step-email': NextStepEmailRenderer,
/* eslint-enable @typescript-eslint/naming-convention */
};
diff --git a/src/components/InlineCodeBlock/getCurrentData.ts b/src/components/InlineCodeBlock/getCurrentData.ts
new file mode 100644
index 000000000000..591ec74c885d
--- /dev/null
+++ b/src/components/InlineCodeBlock/getCurrentData.ts
@@ -0,0 +1,11 @@
+import type {TDefaultRendererProps} from 'react-native-render-html';
+import type {TTextOrTPhrasing} from './types';
+
+// Create a temporary solution to display when there are emojis in the inline code block
+// We can remove this after https://github.com/Expensify/App/issues/14676 is fixed
+export default function getCurrentData(defaultRendererProps: TDefaultRendererProps): string {
+ if ('data' in defaultRendererProps.tnode) {
+ return defaultRendererProps.tnode.data;
+ }
+ return defaultRendererProps.tnode.children.map((child) => ('data' in child ? child.data : '')).join('');
+}
diff --git a/src/components/InlineCodeBlock/index.native.tsx b/src/components/InlineCodeBlock/index.native.tsx
index 85d02b7239ca..1c8a1bea4312 100644
--- a/src/components/InlineCodeBlock/index.native.tsx
+++ b/src/components/InlineCodeBlock/index.native.tsx
@@ -1,11 +1,13 @@
import React from 'react';
import useThemeStyles from '@hooks/useThemeStyles';
+import getCurrentData from './getCurrentData';
import type InlineCodeBlockProps from './types';
import type {TTextOrTPhrasing} from './types';
import WrappedText from './WrappedText';
function InlineCodeBlock({TDefaultRenderer, defaultRendererProps, textStyle, boxModelStyle}: InlineCodeBlockProps) {
const styles = useThemeStyles();
+ const data = getCurrentData(defaultRendererProps);
return (
({TDefaultRenderer,
textStyles={textStyle}
wordStyles={[boxModelStyle, styles.codeWordStyle]}
>
- {'data' in defaultRendererProps.tnode && defaultRendererProps.tnode.data}
+ {data}
);
diff --git a/src/components/InlineCodeBlock/index.tsx b/src/components/InlineCodeBlock/index.tsx
index 593a08aaad5e..26a4e8b7a31f 100644
--- a/src/components/InlineCodeBlock/index.tsx
+++ b/src/components/InlineCodeBlock/index.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import {StyleSheet} from 'react-native';
import Text from '@components/Text';
+import getCurrentData from './getCurrentData';
import type InlineCodeBlockProps from './types';
import type {TTextOrTPhrasing} from './types';
@@ -8,12 +9,14 @@ function InlineCodeBlock({TDefaultRenderer,
const flattenTextStyle = StyleSheet.flatten(textStyle);
const {textDecorationLine, ...textStyles} = flattenTextStyle;
+ const data = getCurrentData(defaultRendererProps);
+
return (
- {'data' in defaultRendererProps.tnode && defaultRendererProps.tnode.data}
+ {data}
);
}
diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx
index fa4c89216d08..07a2cb4b71ee 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.tsx
+++ b/src/components/LHNOptionsList/LHNOptionsList.tsx
@@ -109,7 +109,7 @@ function LHNOptionsList({
],
);
- const extraData = useMemo(() => [reportActions, reports, policy, personalDetails], [reportActions, reports, policy, personalDetails]);
+ const extraData = useMemo(() => [reportActions, reports, policy, personalDetails, data.length], [reportActions, reports, policy, personalDetails, data.length]);
return (
diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx
index 923337ba9ada..5065d1cc7c13 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.tsx
+++ b/src/components/LHNOptionsList/OptionRowLHN.tsx
@@ -54,14 +54,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti
const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
const shouldShowGreenDotIndicator = !hasBrickError && ReportUtils.requiresAttentionFromCurrentUser(optionItem, optionItem.parentReportAction);
-
- const isHidden = optionItem.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
-
- const shouldOverrideHidden = hasBrickError || isFocused || optionItem.isPinned;
- if (isHidden && !shouldOverrideHidden) {
- return null;
- }
-
const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT;
const textStyle = isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText;
const textUnreadStyle = optionItem?.isUnread && optionItem.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE ? [textStyle, styles.sidebarLinkTextBold] : [textStyle];
diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx
index 0db8e581e23e..121390d808b5 100644
--- a/src/components/LHNOptionsList/OptionRowLHNData.tsx
+++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx
@@ -37,7 +37,7 @@ function OptionRowLHNData({
const optionItemRef = useRef();
- const hasViolations = canUseViolations && ReportUtils.doesTransactionThreadHaveViolations(fullReport, transactionViolations, parentReportAction ?? null);
+ const shouldDisplayViolations = canUseViolations && ReportUtils.shouldDisplayTransactionThreadViolations(fullReport, transactionViolations, parentReportAction ?? null);
const optionItem = useMemo(() => {
// Note: ideally we'd have this as a dependent selector in onyx!
@@ -48,7 +48,7 @@ function OptionRowLHNData({
preferredLocale: preferredLocale ?? CONST.LOCALES.DEFAULT,
policy,
parentReportAction,
- hasViolations: !!hasViolations,
+ hasViolations: !!shouldDisplayViolations,
});
if (deepEqual(item, optionItemRef.current)) {
return optionItemRef.current;
diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx
index 56852a8e2ea1..86a52c2baf6c 100644
--- a/src/components/Lightbox/index.tsx
+++ b/src/components/Lightbox/index.tsx
@@ -112,7 +112,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
return;
}
- setContentSize({width: width * PixelRatio.get(), height: height * PixelRatio.get()});
+ setContentSize({width, height});
},
[contentSize, setContentSize],
);
diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx
index 71c0fe47ffca..76f4b251ec83 100644
--- a/src/components/Modal/index.tsx
+++ b/src/components/Modal/index.tsx
@@ -1,4 +1,4 @@
-import React, {useState} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import StatusBar from '@libs/StatusBar';
@@ -6,7 +6,7 @@ import CONST from '@src/CONST';
import BaseModal from './BaseModal';
import type BaseModalProps from './types';
-function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, ...rest}: BaseModalProps) {
+function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, shouldHandleNavigationBack, ...rest}: BaseModalProps) {
const theme = useTheme();
const StyleUtils = useStyleUtils();
const [previousStatusBarColor, setPreviousStatusBarColor] = useState();
@@ -22,8 +22,15 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
const hideModal = () => {
setStatusBarColor(previousStatusBarColor);
onModalHide();
+ if (window.history.state.shouldGoBack) {
+ window.history.back();
+ }
};
+ const handlePopStateRef = useRef(() => {
+ rest.onClose();
+ });
+
const showModal = () => {
const statusBarColor = StatusBar.getBackgroundColor() ?? theme.appBG;
@@ -35,9 +42,20 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
setStatusBarColor(isFullScreenModal ? theme.appBG : StyleUtils.getThemeBackgroundColor(statusBarColor));
}
+ if (shouldHandleNavigationBack) {
+ window.history.pushState({shouldGoBack: true}, '', null);
+ window.addEventListener('popstate', handlePopStateRef.current);
+ }
onModalShow?.();
};
+ useEffect(
+ () => () => {
+ window.removeEventListener('popstate', handlePopStateRef.current);
+ },
+ [],
+ );
+
return (
& {
* */
hideModalContentWhileAnimating?: boolean;
+ /** Whether handle navigation back when modal show. */
+ shouldHandleNavigationBack?: boolean;
+
/** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */
shouldUseCustomBackdrop?: boolean;
};
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index 2bf346ec8de4..9c3d9b8640e7 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -67,9 +67,12 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, chatReport, policy), [moneyRequestReport, chatReport, policy]);
+ const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(moneyRequestReport);
+
const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton;
const shouldShowSubmitButton = isDraft && reimbursableSpend !== 0;
+ const shouldDisableSubmitButton = shouldShowSubmitButton && !ReportUtils.isAllowedToSubmitDraftExpenseReport(moneyRequestReport);
const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE;
const shouldShowNextStep = !ReportUtils.isClosedExpenseReportWithNoExpenses(moneyRequestReport) && isFromPaidPolicy && !!nextStep?.message?.length;
const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep;
@@ -121,6 +124,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
addBankAccountRoute={bankAccountRoute}
shouldHidePaymentOptions={!shouldShowPayButton}
shouldShowApproveButton={shouldShowApproveButton}
+ shouldDisableApproveButton={shouldDisableApproveButton}
style={[styles.pv2]}
formattedAmount={formattedAmount}
isDisabled={!canAllowSettlement}
@@ -135,6 +139,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
text={translate('common.submit')}
style={[styles.mnw120, styles.pv2, styles.pr0]}
onPress={() => IOU.submitReport(moneyRequestReport)}
+ isDisabled={shouldDisableSubmitButton}
/>
)}
@@ -153,6 +158,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
addBankAccountRoute={bankAccountRoute}
shouldHidePaymentOptions={!shouldShowPayButton}
shouldShowApproveButton={shouldShowApproveButton}
+ shouldDisableApproveButton={shouldDisableApproveButton}
formattedAmount={formattedAmount}
isDisabled={!canAllowSettlement}
/>
@@ -166,6 +172,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
text={translate('common.submit')}
style={[styles.w100, styles.pr0]}
onPress={() => IOU.submitReport(moneyRequestReport)}
+ isDisabled={shouldDisableSubmitButton}
/>
)}
diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx
index e1cd18ba4767..19fc86c9f936 100644
--- a/src/components/Popover/index.tsx
+++ b/src/components/Popover/index.tsx
@@ -92,6 +92,7 @@ function Popover(props: PopoverProps) {
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
onClose={onCloseWithPopoverContext}
+ shouldHandleNavigationBack={props.shouldHandleNavigationBack}
type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.POPOVER}
popoverAnchorPosition={isSmallScreenWidth ? undefined : anchorPosition}
fullscreen={isSmallScreenWidth ? true : fullscreen}
diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts
index 314c1ba141c3..4e2f38293f6e 100644
--- a/src/components/Popover/types.ts
+++ b/src/components/Popover/types.ts
@@ -37,6 +37,9 @@ type PopoverProps = BaseModalProps &
/** Whether we want to show the popover on the right side of the screen */
fromSidebarMediumScreen?: boolean;
+
+ /** Whether handle navigation back when modal show. */
+ shouldHandleNavigationBack?: boolean;
};
type PopoverWithWindowDimensionsProps = PopoverProps & WindowDimensionsProps;
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index 44a446b56653..8f54de5182f8 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -24,6 +24,9 @@ type PopoverMenuItem = MenuItemProps & {
/** Sub menu items to be rendered after a menu item is selected */
subMenuItems?: PopoverMenuItem[];
+
+ /** Determines whether the menu item is disabled or not */
+ disabled?: boolean;
};
type PopoverModalProps = Pick;
@@ -205,6 +208,7 @@ function PopoverMenu({
displayInDefaultIconColor={item.displayInDefaultIconColor}
shouldShowRightIcon={item.shouldShowRightIcon}
shouldPutLeftPaddingWhenNoIcon={item.shouldPutLeftPaddingWhenNoIcon}
+ disabled={item.disabled}
/>
))}
diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx
index 792002441ac6..deda6dbd217a 100644
--- a/src/components/PopoverWithMeasuredContent.tsx
+++ b/src/components/PopoverWithMeasuredContent.tsx
@@ -14,6 +14,9 @@ import type {WindowDimensionsProps} from './withWindowDimensions/types';
type PopoverWithMeasuredContentProps = Omit & {
/** The horizontal and vertical anchors points for the popover */
anchorPosition: AnchorPosition;
+
+ /** Whether handle navigation back when modal show. */
+ shouldHandleNavigationBack?: boolean;
};
/**
@@ -42,6 +45,7 @@ function PopoverWithMeasuredContent({
statusBarTranslucent = true,
avoidKeyboard = false,
hideModalContentWhileAnimating = false,
+ shouldHandleNavigationBack = false,
...props
}: PopoverWithMeasuredContentProps) {
const styles = useThemeStyles();
@@ -117,6 +121,7 @@ function PopoverWithMeasuredContent({
};
return isContentMeasured ? (
IOU.canApproveIOU(iouReport, chatReport, policy), [iouReport, chatReport, policy]);
+ const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport);
+
const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton;
const shouldPromptUserToAddBankAccount = ReportUtils.hasMissingPaymentMethod(userWallet, iouReportID);
@@ -307,6 +310,7 @@ function ReportPreview({
addBankAccountRoute={bankAccountRoute}
shouldHidePaymentOptions={!shouldShowPayButton}
shouldShowApproveButton={shouldShowApproveButton}
+ shouldDisableApproveButton={shouldDisableApproveButton}
kycWallAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
@@ -324,6 +328,7 @@ function ReportPreview({
success={isWaitingForSubmissionFromCurrentUser}
text={translate('common.submit')}
onPress={() => iouReport && IOU.submitReport(iouReport)}
+ isDisabled={shouldDisableSubmitButton}
/>
)}
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index fac78ee786a0..e691a5bdb191 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -107,6 +107,9 @@ type ListItem = {
/** Whether to wrap long text up to 2 lines */
isMultilineSupported?: boolean;
+
+ /** The search value from the selection list */
+ searchText?: string | null;
};
type ListItemProps = CommonListItemProps & {
diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx
index 6b6ad3af737a..0ea8ea308d6a 100644
--- a/src/components/SettlementButton.tsx
+++ b/src/components/SettlementButton.tsx
@@ -60,6 +60,9 @@ type SettlementButtonProps = SettlementButtonOnyxProps & {
/** Should we show the payment options? */
shouldShowApproveButton?: boolean;
+ /** Should approve button be disabled? */
+ shouldDisableApproveButton?: boolean;
+
/** The policyID of the report we are paying */
policyID?: string;
@@ -124,6 +127,7 @@ function SettlementButton({
policyID = '',
shouldHidePaymentOptions = false,
shouldShowApproveButton = false,
+ shouldDisableApproveButton = false,
style,
shouldShowPersonalBankAccountOption = false,
enterKeyEventListenerPriority = 0,
@@ -166,6 +170,7 @@ function SettlementButton({
text: translate('iou.approve'),
icon: Expensicons.ThumbsUp,
value: CONST.IOU.REPORT_ACTION_TYPE.APPROVE,
+ disabled: !!shouldDisableApproveButton,
};
const canUseWallet = !isExpenseReport && currency === CONST.CURRENCY.USD;
diff --git a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.js b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
similarity index 68%
rename from src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.js
rename to src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
index c6eb1a179726..f9dd09db59f4 100644
--- a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.js
+++ b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
@@ -1,25 +1,27 @@
-import PropTypes from 'prop-types';
import React, {useEffect, useState} from 'react';
+import type {LayoutChangeEvent, ViewStyle} from 'react-native';
+import type {GestureStateChangeEvent, GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import Animated, {runOnJS, useAnimatedStyle, useSharedValue} from 'react-native-reanimated';
import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext';
import useThemeStyles from '@hooks/useThemeStyles';
-const propTypes = {
- duration: PropTypes.number.isRequired,
+type ProgressBarProps = {
+ /** Total duration of a video. */
+ duration: number;
- position: PropTypes.number.isRequired,
+ /** Position of progress pointer on the bar. */
+ position: number;
- seekPosition: PropTypes.func.isRequired,
+ /** Function to seek to a specific position in the video. */
+ seekPosition: (newPosition: number) => void;
};
-const defaultProps = {};
-
-function getProgress(currentPosition, maxPosition) {
+function getProgress(currentPosition: number, maxPosition: number): number {
return Math.min(Math.max((currentPosition / maxPosition) * 100, 0), 100);
}
-function ProgressBar({duration, position, seekPosition}) {
+function ProgressBar({duration, position, seekPosition}: ProgressBarProps) {
const styles = useThemeStyles();
const {pauseVideo, playVideo, checkVideoPlaying} = usePlaybackContext();
const [sliderWidth, setSliderWidth] = useState(1);
@@ -27,18 +29,18 @@ function ProgressBar({duration, position, seekPosition}) {
const progressWidth = useSharedValue(0);
const wasVideoPlayingOnCheck = useSharedValue(false);
- const onCheckVideoPlaying = (isPlaying) => {
+ const onCheckVideoPlaying = (isPlaying: boolean) => {
wasVideoPlayingOnCheck.value = isPlaying;
};
- const progressBarInteraction = (event) => {
+ const progressBarInteraction = (event: GestureUpdateEvent | GestureStateChangeEvent) => {
const progress = getProgress(event.x, sliderWidth);
progressWidth.value = progress;
runOnJS(seekPosition)((progress * duration) / 100);
};
- const onSliderLayout = (e) => {
- setSliderWidth(e.nativeEvent.layout.width);
+ const onSliderLayout = (event: LayoutChangeEvent) => {
+ setSliderWidth(event.nativeEvent.layout.width);
};
const pan = Gesture.Pan()
@@ -66,7 +68,7 @@ function ProgressBar({duration, position, seekPosition}) {
progressWidth.value = getProgress(position, duration);
}, [duration, isSliderPressed, position, progressWidth]);
- const progressBarStyle = useAnimatedStyle(() => ({width: `${progressWidth.value}%`}));
+ const progressBarStyle: ViewStyle = useAnimatedStyle(() => ({width: `${progressWidth.value}%`}));
return (
@@ -85,8 +87,6 @@ function ProgressBar({duration, position, seekPosition}) {
);
}
-ProgressBar.propTypes = propTypes;
-ProgressBar.defaultProps = defaultProps;
ProgressBar.displayName = 'ProgressBar';
export default ProgressBar;
diff --git a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx
similarity index 81%
rename from src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js
rename to src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx
index 45f47eb87c36..011391ed4c71 100644
--- a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js
+++ b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx
@@ -1,6 +1,7 @@
-import PropTypes from 'prop-types';
import React, {memo, useCallback, useState} from 'react';
+import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
+import type {GestureStateChangeEvent, GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import Animated, {runOnJS, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
import Hoverable from '@components/Hoverable';
@@ -10,18 +11,16 @@ import {useVolumeContext} from '@components/VideoPlayerContexts/VolumeContext';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as NumberUtils from '@libs/NumberUtils';
-import stylePropTypes from '@styles/stylePropTypes';
-const propTypes = {
- style: stylePropTypes.isRequired,
- small: PropTypes.bool,
-};
+type VolumeButtonProps = {
+ /** Style for the volume button. */
+ style?: StyleProp;
-const defaultProps = {
- small: false,
+ /** Is button icon small. */
+ small?: boolean;
};
-const getVolumeIcon = (volume) => {
+const getVolumeIcon = (volume: number) => {
if (volume === 0) {
return Expensicons.Mute;
}
@@ -31,7 +30,7 @@ const getVolumeIcon = (volume) => {
return Expensicons.VolumeHigh;
};
-function VolumeButton({style, small}) {
+function VolumeButton({style, small = false}: VolumeButtonProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {updateVolume, volume} = useVolumeContext();
@@ -39,12 +38,12 @@ function VolumeButton({style, small}) {
const [volumeIcon, setVolumeIcon] = useState({icon: getVolumeIcon(volume.value)});
const [isSliderBeingUsed, setIsSliderBeingUsed] = useState(false);
- const onSliderLayout = useCallback((e) => {
- setSliderHeight(e.nativeEvent.layout.height);
+ const onSliderLayout = useCallback((event: LayoutChangeEvent) => {
+ setSliderHeight(event.nativeEvent.layout.height);
}, []);
const changeVolumeOnPan = useCallback(
- (event) => {
+ (event: GestureStateChangeEvent | GestureUpdateEvent) => {
const val = NumberUtils.roundToTwoDecimalPlaces(1 - event.y / sliderHeight);
volume.value = NumberUtils.clamp(val, 0, 1);
},
@@ -65,7 +64,7 @@ function VolumeButton({style, small}) {
const progressBarStyle = useAnimatedStyle(() => ({height: `${volume.value * 100}%`}));
- const updateIcon = useCallback((vol) => {
+ const updateIcon = useCallback((vol: number) => {
setVolumeIcon({icon: getVolumeIcon(vol)});
}, []);
@@ -98,7 +97,6 @@ function VolumeButton({style, small}) {
tooltipText={volume.value === 0 ? translate('videoPlayer.unmute') : translate('videoPlayer.mute')}
onPress={() => updateVolume(volume.value === 0 ? 1 : 0)}
src={volumeIcon.icon}
- fill={styles.white}
small={small}
shouldForceRenderingTooltipBelow
/>
@@ -108,8 +106,6 @@ function VolumeButton({style, small}) {
);
}
-VolumeButton.propTypes = propTypes;
-VolumeButton.defaultProps = defaultProps;
VolumeButton.displayName = 'VolumeButton';
export default memo(VolumeButton);
diff --git a/src/components/VideoPlayer/VideoPlayerControls/index.js b/src/components/VideoPlayer/VideoPlayerControls/index.tsx
similarity index 75%
rename from src/components/VideoPlayer/VideoPlayerControls/index.js
rename to src/components/VideoPlayer/VideoPlayerControls/index.tsx
index 262613ce0797..7c61721b67b7 100644
--- a/src/components/VideoPlayer/VideoPlayerControls/index.js
+++ b/src/components/VideoPlayer/VideoPlayerControls/index.tsx
@@ -1,55 +1,58 @@
-import PropTypes from 'prop-types';
+import type {Video} from 'expo-av';
+import type {MutableRefObject} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
+import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import Animated from 'react-native-reanimated';
import * as Expensicons from '@components/Icon/Expensicons';
-import refPropTypes from '@components/refPropTypes';
import Text from '@components/Text';
import IconButton from '@components/VideoPlayer/IconButton';
import {convertMillisecondsToTime} from '@components/VideoPlayer/utils';
import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import stylePropTypes from '@styles/stylePropTypes';
import CONST from '@src/CONST';
import ProgressBar from './ProgressBar';
import VolumeButton from './VolumeButton';
-const propTypes = {
- duration: PropTypes.number.isRequired,
+type VideoPlayerControlsProps = {
+ /** Duration of a video. */
+ duration: number;
- position: PropTypes.number.isRequired,
+ /** Position of progress pointer. */
+ position: number;
- url: PropTypes.string.isRequired,
+ /** Url of a video. */
+ url: string;
- videoPlayerRef: refPropTypes.isRequired,
+ /** Ref for video player. */
+ videoPlayerRef: MutableRefObject