diff --git a/.eslintrc.js b/.eslintrc.js
index 3c144064eb62..b5b4add538f6 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -24,7 +24,7 @@ const restrictedImportPatterns = [
];
module.exports = {
- extends: ['expensify', 'plugin:storybook/recommended', 'plugin:react-hooks/recommended', 'prettier', 'plugin:react-native-a11y/basic'],
+ extends: ['expensify', 'plugin:storybook/recommended', 'plugin:react-hooks/recommended', 'plugin:react-native-a11y/basic', 'prettier'],
plugins: ['react-hooks', 'react-native-a11y'],
parser: 'babel-eslint',
ignorePatterns: ['!.*', 'src/vendor', '.github/actions/**/index.js', 'desktop/dist/*.js', 'dist/*.js', 'node_modules/.bin/**', 'node_modules/.cache/**', '.git/**'],
@@ -46,7 +46,6 @@ module.exports = {
touchables: ['PressableWithoutFeedback', 'PressableWithFeedback'],
},
],
- curly: 'error',
},
},
{
@@ -76,6 +75,7 @@ module.exports = {
patterns: restrictedImportPatterns,
},
],
+ curly: 'error',
},
},
{
@@ -162,6 +162,7 @@ module.exports = {
patterns: restrictedImportPatterns,
},
],
+ curly: 'error',
},
},
{
diff --git a/.github/scripts/createDocsRoutes.js b/.github/scripts/createDocsRoutes.js
index 39cd98383de1..6604a9d207fa 100644
--- a/.github/scripts/createDocsRoutes.js
+++ b/.github/scripts/createDocsRoutes.js
@@ -16,7 +16,7 @@ const platformNames = {
* @returns {String}
*/
function toTitleCase(str) {
- return str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase());
+ return str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1));
}
/**
diff --git a/.github/scripts/findUnusedKeys.sh b/.github/scripts/findUnusedKeys.sh
new file mode 100755
index 000000000000..77c3ea25326b
--- /dev/null
+++ b/.github/scripts/findUnusedKeys.sh
@@ -0,0 +1,375 @@
+#!/bin/bash
+
+# Configurations
+declare LIB_PATH
+LIB_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd ../../ && pwd)"
+
+readonly SRC_DIR="${LIB_PATH}/src"
+readonly STYLES_DIR="${LIB_PATH}/src/styles"
+readonly STYLES_FILE="${LIB_PATH}/src/styles/styles.js"
+readonly UTILITIES_STYLES_FILE="${LIB_PATH}/src/styles/utilities"
+readonly STYLES_KEYS_FILE="${LIB_PATH}/scripts/style_keys_list_temp.txt"
+readonly UTILITY_STYLES_KEYS_FILE="${LIB_PATH}/scripts/utility_keys_list_temp.txt"
+readonly REMOVAL_KEYS_FILE="${LIB_PATH}/scripts/removal_keys_list_temp.txt"
+readonly AMOUNT_LINES_TO_SHOW=3
+
+readonly FILE_EXTENSIONS=('-name' '*.js' '-o' '-name' '*.jsx' '-o' '-name' '*.ts' '-o' '-name' '*.tsx')
+
+source scripts/shellUtils.sh
+
+# trap ctrl-c and call ctrl_c()
+trap ctrl_c INT
+
+delete_temp_files() {
+ find "${LIB_PATH}/scripts" -name "*keys_list_temp*" -type f -exec rm -f {} \;
+}
+
+# shellcheck disable=SC2317 # Don't warn about unreachable commands in this function
+ctrl_c() {
+ delete_temp_files
+ exit 1
+}
+
+count_lines() {
+ local file=$1
+ if [[ -e "$file" ]]; then
+ wc -l < "$file"
+ else
+ echo "File not found: $file"
+ fi
+}
+
+# Read the style file with unused keys
+show_unused_style_keywords() {
+ while IFS=: read -r key file line_number; do
+ title "File: $file:$line_number"
+
+ # Get lines before and after the error line
+ local lines_before=$((line_number - AMOUNT_LINES_TO_SHOW))
+ local lines_after=$((line_number + AMOUNT_LINES_TO_SHOW))
+
+ # Read the lines into an array
+ local lines=()
+ while IFS= read -r line; do
+ lines+=("$line")
+ done < "$file"
+
+ # Loop through the lines
+ for ((i = lines_before; i <= lines_after; i++)); do
+ local line="${lines[i]}"
+ # Print context of the error line
+ echo "$line"
+ done
+ error "Unused key: $key"
+ echo "--------------------------------"
+ done < "$STYLES_KEYS_FILE"
+}
+
+# Function to remove a keyword from the temp file
+remove_keyword() {
+ local keyword="$1"
+ if grep -q "$keyword" "$STYLES_KEYS_FILE"; then
+ grep -v "$keyword" "$STYLES_KEYS_FILE" > "$REMOVAL_KEYS_FILE"
+ mv "$REMOVAL_KEYS_FILE" "$STYLES_KEYS_FILE"
+
+ return 0 # Keyword was removed
+ else
+ return 1 # Keyword was not found
+ fi
+}
+
+lookfor_unused_keywords() {
+ # Loop through all files in the src folder
+ while read -r file; do
+
+ # Search for keywords starting with "styles"
+ while IFS= read -r keyword; do
+
+ # Remove any [ ] characters from the keyword
+ local clean_keyword="${keyword//[\[\]]/}"
+ # skip styles. keyword that might be used in comments
+ if [[ "$clean_keyword" == "styles." ]]; then
+ continue
+ fi
+
+ if ! remove_keyword "$clean_keyword" ; then
+ # In case of a leaf of the styles object is being used, it means the parent objects is being used
+ # we need to mark it as used.
+ if [[ "$clean_keyword" =~ ^styles\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$ ]]; then
+ # Keyword has more than two words, remove words after the second word
+ local keyword_prefix="${clean_keyword%.*}"
+ remove_keyword "$keyword_prefix"
+ fi
+ fi
+ done < <(grep -E -o '\bstyles\.[a-zA-Z0-9_.]*' "$file" | grep -v '\/\/' | grep -vE '\/\*.*\*\/')
+ done < <(find "${SRC_DIR}" -type f \( "${FILE_EXTENSIONS[@]}" \))
+}
+
+
+# Function to find and store keys from a file
+find_styles_object_and_store_keys() {
+ local file="$1"
+ local base_name="${2:-styles}" # Set styles as default
+ local line_number=0
+ local inside_arrow_function=false
+
+ while IFS= read -r line; do
+ ((line_number++))
+
+ # Check if we are inside an arrow function and we find a closing curly brace
+ if [[ "$inside_arrow_function" == true ]]; then
+ if [[ "$line" =~ ^[[:space:]]*\}\) ]]; then
+ inside_arrow_function=false
+ fi
+ continue
+ fi
+
+ # Check if we are inside an arrow function
+ if [[ "$line" =~ ^[[:space:]]*([a-zA-Z0-9_-])+:[[:space:]]*\(.*\)[[:space:]]*'=>'[[:space:]]*\(\{ || "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\(.*\)[[:space:]]*'=>' ]]; then
+ inside_arrow_function=true
+ continue
+ fi
+
+ # Skip lines that are not key-related
+ if [[ ! "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\{|^[[:space:]]*([a-zA-Z0-9_-]+\.)?[a-zA-Z0-9_-]+:[[:space:]]*\{|^[[:space:]]*\} ]]; then
+ continue
+ fi
+
+ if [[ "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\{ ]]; then
+ key="${BASH_REMATCH[2]%%:*{*)}"
+ echo "styles.${key}|...${key}|${base_name}.${key}:${file}:${line_number}" >> "$STYLES_KEYS_FILE"
+ fi
+ done < "$file"
+}
+
+find_styles_functions_and_store_keys() {
+ local file="$1"
+ local line_number=0
+ local inside_object=false
+ local inside_arrow_function=false
+ local key=""
+
+ while IFS= read -r line; do
+ ((line_number++))
+
+ # Skip lines that are not key-related
+ if [[ "${line}" == *styles* ]]; then
+ continue
+ fi
+
+ # Check if we are inside an arrow function and we find a closing curly brace
+ if [[ "$inside_arrow_function" == true ]]; then
+ if [[ "$line" =~ ^[[:space:]]*\}\) ]]; then
+ inside_arrow_function=false
+ fi
+ continue
+ fi
+
+ # Check if we are inside an arrow function
+ if [[ "${line}" =~ ^[[:space:]]*([a-zA-Z0-9_-])+:[[:space:]]*\(.*\)[[:space:]]*'=>'[[:space:]]*\( ]]; then
+ inside_arrow_function=true
+ key="${line%%:*}"
+ key="${key// /}" # Trim spaces
+ echo "styles.${key}|...${key}:${file}:${line_number}" >> "$STYLES_KEYS_FILE"
+ continue
+ fi
+
+ if [[ "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\(.*\)[[:space:]]*'=>' ]]; then
+ inside_arrow_function=true
+ key="${BASH_REMATCH[2]}"
+ key="${key// /}" # Trim spaces
+ echo "styles.${key}|...${key}:${file}:${line_number}" >> "$STYLES_KEYS_FILE"
+ continue
+ fi
+
+ done < "$file"
+}
+
+find_theme_style_and_store_keys() {
+ local file="$1"
+ local start_line_number="$2"
+ local base_name="${3:-styles}" # Set styles as default
+ local parent_keys=()
+ local root_key=""
+ local line_number=0
+ local inside_arrow_function=false
+
+ while IFS= read -r line; do
+ ((line_number++))
+
+ if [ ! "$line_number" -ge "$start_line_number" ]; then
+ continue
+ fi
+
+ # Check if we are inside an arrow function and we find a closing curly brace
+ if [[ "$inside_arrow_function" == true ]]; then
+ if [[ "$line" =~ ^[[:space:]]*\}\) ]]; then
+ inside_arrow_function=false
+ fi
+ continue
+ fi
+
+ # Check if we are inside an arrow function
+ if [[ "$line" =~ ^[[:space:]]*([a-zA-Z0-9_-])+:[[:space:]]*\(.*\)[[:space:]]*'=>'[[:space:]]*\(\{ || "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\(.*\)[[:space:]]*'=>' ]]; then
+ inside_arrow_function=true
+ continue
+ fi
+
+ # Skip lines that are not key-related
+ if [[ ! "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\{|^[[:space:]]*([a-zA-Z0-9_-]+\.)?[a-zA-Z0-9_-]+:[[:space:]]*\{|^[[:space:]]*\} ]]; then
+
+ continue
+ fi
+
+ if [[ "$line" =~ ^[[:space:]]*([a-zA-Z0-9_-]+\.)?[a-zA-Z0-9_-]+:[[:space:]]*\{|^[[:space:]]*([a-zA-Z0-9_-])+:[[:space:]]*\(.*\)[[:space:]]*'=>'[[:space:]]*\(\{ ]]; then
+ # Removing all the extra lines after the ":"
+ local key="${line%%:*}"
+ key="${key// /}" # Trim spaces
+
+ if [[ ${#parent_keys[@]} -gt 0 ]]; then
+ local parent_key_trimmed="${parent_keys[${#parent_keys[@]}-1]// /}" # Trim spaces
+ key="$parent_key_trimmed.$key"
+ elif [[ -n "$root_key" ]]; then
+ local parent_key_trimmed="${root_key// /}" # Trim spaces
+ key="$parent_key_trimmed.$key"
+ fi
+
+ echo "styles.${key}|...${key}|${base_name}.${key}:${file}:${line_number}" >> "$STYLES_KEYS_FILE"
+ parent_keys+=("$key")
+ elif [[ "$line" =~ ^[[:space:]]*\} ]]; then
+ parent_keys=("${parent_keys[@]:0:${#parent_keys[@]}-1}")
+ fi
+ done < "$file"
+}
+
+# Given that all the styles are inside of a function, we need to find the function and then look for the styles
+collect_theme_keys_from_styles() {
+ local file="$1"
+ local line_number=0
+ local inside_styles=false
+
+ while IFS= read -r line; do
+ ((line_number++))
+
+ if [[ "$inside_styles" == false ]]; then
+ if [[ "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\(.*\)[[:space:]]*'=>' ]]; then
+ key="${BASH_REMATCH[2]}"
+ key="${key// /}" # Trim spaces
+ if [[ "$key" == "styles"* ]]; then
+ inside_styles=true
+ # Need to start within the style function
+ ((line_number++))
+ find_theme_style_and_store_keys "$STYLES_FILE" "$line_number"
+ fi
+ continue
+ fi
+ fi
+ done < "$file"
+}
+
+lookfor_unused_spread_keywords() {
+ local inside_object=false
+ local key=""
+
+ while IFS= read -r line; do
+ # Detect the start of an object
+ if [[ "$line" =~ ^[[:space:]]*([a-zA-Z0-9_-]+\.)?[a-zA-Z0-9_-]+:[[:space:]]*\{ ]]; then
+ inside_object=true
+ fi
+
+ # Detect the end of an object
+ if [[ "$line" =~ ^[[:space:]]*\},?$ ]]; then
+ inside_object=false
+ fi
+
+ # If we're inside an object and the line starts with '...', capture the key
+ if [[ "$inside_object" == true && "$line" =~ ^[[:space:]]*\.\.\.([a-zA-Z0-9_]+)(\(.+\))?,?$ ]]; then
+ key="${BASH_REMATCH[1]}"
+ remove_keyword "...${key}"
+ fi
+ done < "$STYLES_FILE"
+}
+
+find_utility_styles_store_prefix() {
+ # Loop through all files in the src folder
+ while read -r file; do
+ # Search for keywords starting with "styles"
+ while IFS= read -r keyword; do
+ local variable="${keyword##*/}"
+ local variable_trimmed="${variable// /}" # Trim spaces
+
+ echo "$variable_trimmed" >> "$UTILITY_STYLES_KEYS_FILE"
+ done < <(grep -E -o './utilities/[a-zA-Z0-9_-]+' "$file" | grep -v '\/\/' | grep -vE '\/\*.*\*\/')
+ done < <(find "${STYLES_DIR}" -type f \( "${FILE_EXTENSIONS[@]}" \))
+
+ # Sort and remove duplicates from the temporary file
+ sort -u -o "${UTILITY_STYLES_KEYS_FILE}" "${UTILITY_STYLES_KEYS_FILE}"
+}
+
+find_utility_usage_as_styles() {
+ while read -r file; do
+ local root_key
+ local parent_dir
+
+ # Get the folder name, given this utility files are index.js
+ parent_dir=$(dirname "$file")
+ root_key=$(basename "${parent_dir}")
+
+ if [[ "${root_key}" == "utilities" ]]; then
+ continue
+ fi
+
+ find_theme_style_and_store_keys "${file}" 0 "${root_key}"
+ done < <(find "${UTILITIES_STYLES_FILE}" -type f \( "${FILE_EXTENSIONS[@]}" \))
+}
+
+lookfor_unused_utilities() {
+ # Read each utility keyword from the file
+ while read -r keyword; do
+ # Creating a copy so later the replacement can reference it
+ local original_keyword="${keyword}"
+
+ # Iterate through all files in "src/styles"
+ while read -r file; do
+ # Find all words that match "$keyword.[a-zA-Z0-9_-]+"
+ while IFS= read -r match; do
+ # Replace the utility prefix with "styles"
+ local variable="${match/#$original_keyword/styles}"
+ # Call the remove_keyword function with the variable
+ remove_keyword "${variable}"
+ remove_keyword "${match}"
+ done < <(grep -E -o "$original_keyword\.[a-zA-Z0-9_-]+" "$file" | grep -v '\/\/' | grep -vE '\/\*.*\*\/')
+ done < <(find "${STYLES_DIR}" -type f \( "${FILE_EXTENSIONS[@]}" \))
+ done < "$UTILITY_STYLES_KEYS_FILE"
+}
+
+echo "🔍 Looking for styles."
+# Find and store the name of the utility files as keys
+find_utility_styles_store_prefix
+find_utility_usage_as_styles
+
+# Find and store keys from styles.js
+find_styles_object_and_store_keys "$STYLES_FILE"
+find_styles_functions_and_store_keys "$STYLES_FILE"
+collect_theme_keys_from_styles "$STYLES_FILE"
+
+echo "🗄️ Now going through the codebase and looking for unused keys."
+
+# Look for usages of utilities into src/styles
+lookfor_unused_utilities
+lookfor_unused_spread_keywords
+lookfor_unused_keywords
+
+final_styles_line_count=$(count_lines "$STYLES_KEYS_FILE")
+
+if [[ $final_styles_line_count -eq 0 ]]; then
+ # Exit successfully (status code 0)
+ delete_temp_files
+ success "Styles are in a good shape"
+ exit 0
+else
+ show_unused_style_keywords
+ delete_temp_files
+ error "Unused keys: $final_styles_line_count"
+ exit 1
+fi
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 5953a4aa89e2..b403a1eb737c 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -33,3 +33,7 @@ jobs:
echo 'Error: Prettier diff detected! Please run `npm run prettier` and commit the changes.'
exit 1
fi
+
+ - name: Run unused style searcher
+ shell: bash
+ run: ./.github/scripts/findUnusedKeys.sh
diff --git a/.gitignore b/.gitignore
index aae9baad529f..d3b4daac04d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -107,3 +107,7 @@ tsconfig.tsbuildinfo
# Workflow test logs
/workflow_tests/logs/
+
+# Yalc
+.yalc
+yalc.lock
diff --git a/docs/new-expensify/hubs/exports.html b/docs/new-expensify/hubs/exports.html
index e69de29bb2d1..16c96cb51d01 100644
--- a/docs/new-expensify/hubs/exports.html
+++ b/docs/new-expensify/hubs/exports.html
@@ -0,0 +1,6 @@
+---
+layout: default
+title: Exports
+---
+
+{% include hub.html %}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index ff0500eb385b..c2e322bf8ba2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -190,7 +190,7 @@
"electron-builder": "24.6.4",
"eslint": "^7.6.0",
"eslint-config-airbnb-typescript": "^17.1.0",
- "eslint-config-expensify": "^2.0.38",
+ "eslint-config-expensify": "^2.0.39",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jest": "^24.1.0",
"eslint-plugin-jsdoc": "^46.2.6",
@@ -26492,9 +26492,9 @@
}
},
"node_modules/eslint-config-expensify": {
- "version": "2.0.38",
- "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.38.tgz",
- "integrity": "sha512-jAlrYSjkDV8YESUUPcaTjUM8Fgru+37FS+Hq6zzcRR0FbA5bLiOPguhJHo77XpYh5N+PEf4wrpgsS04sjdgDPg==",
+ "version": "2.0.39",
+ "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.39.tgz",
+ "integrity": "sha512-DIxR3k99ZIDPE2NK+WLLRWpoDq06gTXdY8XZg9Etd1UqZ7fXm/Yz3/QkTxu7CH7UaXbCH3P4PTo023ULQGKOSw==",
"dev": true,
"dependencies": {
"@lwc/eslint-plugin-lwc": "^0.11.0",
@@ -67265,9 +67265,9 @@
}
},
"eslint-config-expensify": {
- "version": "2.0.38",
- "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.38.tgz",
- "integrity": "sha512-jAlrYSjkDV8YESUUPcaTjUM8Fgru+37FS+Hq6zzcRR0FbA5bLiOPguhJHo77XpYh5N+PEf4wrpgsS04sjdgDPg==",
+ "version": "2.0.39",
+ "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.39.tgz",
+ "integrity": "sha512-DIxR3k99ZIDPE2NK+WLLRWpoDq06gTXdY8XZg9Etd1UqZ7fXm/Yz3/QkTxu7CH7UaXbCH3P4PTo023ULQGKOSw==",
"dev": true,
"requires": {
"@lwc/eslint-plugin-lwc": "^0.11.0",
diff --git a/package.json b/package.json
index 44b936c8c588..c568ff7b0ab4 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map",
"symbolicate:ios": "npx metro-symbolicate main.jsbundle.map",
"test:e2e": "node tests/e2e/testRunner.js --development",
+ "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh",
"workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh",
"workflow-test:generate": "node workflow_tests/utils/preGenerateTest.js"
},
@@ -232,7 +233,7 @@
"electron-builder": "24.6.4",
"eslint": "^7.6.0",
"eslint-config-airbnb-typescript": "^17.1.0",
- "eslint-config-expensify": "^2.0.38",
+ "eslint-config-expensify": "^2.0.39",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jest": "^24.1.0",
"eslint-plugin-jsdoc": "^46.2.6",
diff --git a/src/CONST.ts b/src/CONST.ts
index dcd5ac1a8db7..eed1b98ae551 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1358,6 +1358,7 @@ const CONST = {
DATE: 'date',
DESCRIPTION: 'description',
MERCHANT: 'merchant',
+ CATEGORY: 'category',
RECEIPT: 'receipt',
},
FOOTER: {
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 05256f2b806c..8f95dff079fc 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -249,6 +249,7 @@ const ONYXKEYS = {
REPORT_DRAFT_COMMENT_NUMBER_OF_LINES: 'reportDraftCommentNumberOfLines_',
REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_',
REPORT_USER_IS_TYPING: 'reportUserIsTyping_',
+ REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_',
SECURITY_GROUP: 'securityGroup_',
TRANSACTION: 'transactions_',
@@ -386,6 +387,7 @@ type OnyxValues = {
[ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES]: number;
[ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE]: boolean;
[ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING]: boolean;
+ [ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM]: boolean;
[ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup;
[ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction;
[ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 2c37116db395..feead4890114 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -139,8 +139,8 @@ export default {
SEARCH: 'search',
TEACHERS_UNITE: 'teachersunite',
I_KNOW_A_TEACHER: 'teachersunite/i-know-a-teacher',
- INTRO_SCHOOL_PRINCIPAL: 'teachersunite/intro-school-principal',
I_AM_A_TEACHER: 'teachersunite/i-am-a-teacher',
+ INTRO_SCHOOL_PRINCIPAL: 'teachersunite/intro-school-principal',
DETAILS: 'details',
getDetailsRoute: (login: string) => `details?login=${encodeURIComponent(login)}`,
PROFILE: 'a/:accountID',
diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.js
index baa958106f84..10e8a76f756d 100644
--- a/src/components/AvatarCropModal/AvatarCropModal.js
+++ b/src/components/AvatarCropModal/AvatarCropModal.js
@@ -366,6 +366,7 @@ function AvatarCropModal(props) {
style={[styles.pb0]}
includePaddingTop={false}
includeSafeAreaPaddingBottom={false}
+ testID={AvatarCropModal.displayName}
>
{props.isSmallScreenWidth && }
{
- if (!iou.category) {
+ if (!selectedCategory) {
return [];
}
return [
{
- name: iou.category,
+ name: selectedCategory,
enabled: true,
accountID: null,
},
];
- }, [iou.category]);
+ }, [selectedCategory]);
const initialFocusedIndex = useMemo(() => {
if (isCategoriesCountBelowThreshold && selectedOptions.length > 0) {
@@ -53,20 +50,6 @@ function CategoryPicker({policyCategories, reportID, iouType, iou, policyRecentl
const headerMessage = OptionsListUtils.getHeaderMessage(lodashGet(sections, '[0].data.length', 0) > 0, false, searchValue);
const shouldShowTextInput = !isCategoriesCountBelowThreshold;
- const navigateBack = () => {
- Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID));
- };
-
- const updateCategory = (category) => {
- if (category.searchText === iou.category) {
- IOU.resetMoneyRequestCategory();
- } else {
- IOU.setMoneyRequestCategory(category.searchText);
- }
-
- navigateBack();
- };
-
return (
);
}
@@ -97,7 +80,4 @@ export default withOnyx({
policyRecentlyUsedCategories: {
key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`,
},
- iou: {
- key: ONYXKEYS.IOU,
- },
})(CategoryPicker);
diff --git a/src/components/CountryPicker/CountrySelectorModal.js b/src/components/CountryPicker/CountrySelectorModal.js
index ac543d9921d2..6c6cd19af0c7 100644
--- a/src/components/CountryPicker/CountrySelectorModal.js
+++ b/src/components/CountryPicker/CountrySelectorModal.js
@@ -78,6 +78,7 @@ function CountrySelectorModal({currentCountry, isVisible, onClose, onCountrySele
style={[styles.pb0]}
includePaddingTop={false}
includeSafeAreaPaddingBottom={false}
+ testID={CountrySelectorModal.displayName}
>
{({safeAreaPaddingBottomStyle}) => (
diff --git a/src/components/HeaderPageLayout.js b/src/components/HeaderPageLayout.js
index bec1e52b1cad..17c2255593e9 100644
--- a/src/components/HeaderPageLayout.js
+++ b/src/components/HeaderPageLayout.js
@@ -58,6 +58,7 @@ function HeaderPageLayout({backgroundColor, children, footer, headerContainerSty
shouldEnablePickerAvoiding={false}
includeSafeAreaPaddingBottom={false}
offlineIndicatorStyle={[appBGColor]}
+ testID={HeaderPageLayout.displayName}
>
{({safeAreaPaddingBottomStyle}) => (
<>
diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js
index aab54612e206..7bcd57385d5f 100755
--- a/src/components/HeaderWithBackButton/index.js
+++ b/src/components/HeaderWithBackButton/index.js
@@ -47,6 +47,7 @@ function HeaderWithBackButton({
},
threeDotsMenuItems = [],
children = null,
+ onModalHide = () => {},
shouldOverlay = false,
}) {
const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState();
@@ -138,6 +139,7 @@ function HeaderWithBackButton({
menuItems={threeDotsMenuItems}
onIconPress={onThreeDotsButtonPress}
anchorPosition={threeDotsAnchorPosition}
+ onModalHide={onModalHide}
shouldOverlay={shouldOverlay}
/>
)}
diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.js b/src/components/InvertedFlatList/BaseInvertedFlatList.js
index d3fcda0ea5fd..0bfffb733052 100644
--- a/src/components/InvertedFlatList/BaseInvertedFlatList.js
+++ b/src/components/InvertedFlatList/BaseInvertedFlatList.js
@@ -136,8 +136,6 @@ class BaseInvertedFlatList extends Component {
// Native platforms do not need to measure items and work fine without this.
// Web requires that items be measured or else crazy things happen when scrolling.
getItemLayout={this.props.shouldMeasureItems ? this.getItemLayout : undefined}
- // We keep this property very low so that chat switching remains fast
- maxToRenderPerBatch={1}
windowSize={15}
// Commenting the line below as it breaks the unread indicator test
diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js
index 74409e9a0fe0..d46cd5801605 100644
--- a/src/components/InvertedFlatList/index.js
+++ b/src/components/InvertedFlatList/index.js
@@ -119,6 +119,9 @@ function InvertedFlatList(props) {
shouldMeasureItems
contentContainerStyle={StyleSheet.compose(contentContainerStyle, styles.justifyContentEnd)}
onScroll={handleScroll}
+ // We need to keep batch size to one to workaround a bug in react-native-web.
+ // This can be removed once https://github.com/Expensify/App/pull/24482 is merged.
+ maxToRenderPerBatch={1}
/>
);
}
diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js
index 2c51d6332946..4eebdd387fab 100644
--- a/src/components/LHNOptionsList/OptionRowLHNData.js
+++ b/src/components/LHNOptionsList/OptionRowLHNData.js
@@ -195,6 +195,7 @@ export default React.memo(
key: ONYXKEYS.NVP_PREFERRED_LOCALE,
},
}),
+ // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
withOnyx({
parentReportActions: {
key: ({fullReport}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fullReport.parentReportID}`,
@@ -209,6 +210,7 @@ export default React.memo(
// However, performance overhead of this is minimized by using memos inside the component.
receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION},
}),
+ // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
withOnyx({
transaction: {
key: ({fullReport, parentReportActions}) =>
diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx
index 7a2248ffafb9..d9f51e111a43 100644
--- a/src/components/MapView/MapView.tsx
+++ b/src/components/MapView/MapView.tsx
@@ -54,7 +54,9 @@ const MapView = forwardRef(({accessToken, style, ma
}, [accessToken]);
const setMapIdle = (e: MapState) => {
- if (e.gestures.isGestureActive) return;
+ if (e.gestures.isGestureActive) {
+ return;
+ }
setIsIdle(true);
if (onMapReady) {
onMapReady();
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index bb4eeb7a18ac..268351699567 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -24,11 +24,7 @@ import variables from '../styles/variables';
import * as Session from '../libs/actions/Session';
import Hoverable from './Hoverable';
import useWindowDimensions from '../hooks/useWindowDimensions';
-import RenderHTML from './RenderHTML';
-import getPlatform from '../libs/getPlatform';
-
-const platform = getPlatform();
-const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID;
+import MenuItemRenderHTMLTitle from './MenuItemRenderHTMLTitle';
const propTypes = menuItemPropTypes;
@@ -251,16 +247,10 @@ const MenuItem = React.forwardRef((props, ref) => {
)}
- {Boolean(props.title) &&
- (Boolean(props.shouldRenderAsHTML) || (Boolean(props.shouldParseTitle) && Boolean(html.length))) &&
- (isNative ? (
-
- ) : (
-
-
-
- ))}
- {!props.shouldRenderAsHTML && !html.length && Boolean(props.title) && (
+ {Boolean(props.title) && (Boolean(props.shouldRenderAsHTML) || (Boolean(props.shouldParseTitle) && Boolean(html.length))) && (
+
+ )}
+ {!props.shouldRenderAsHTML && !props.shouldParseTitle && Boolean(props.title) && (
+
+
+ );
+}
+
+MenuItemRenderHTMLTitle.propTypes = propTypes;
+MenuItemRenderHTMLTitle.defaultProps = defaultProps;
+MenuItemRenderHTMLTitle.displayName = 'MenuItemRenderHTMLTitle';
+
+export default MenuItemRenderHTMLTitle;
diff --git a/src/components/MenuItemRenderHTMLTitle/index.native.js b/src/components/MenuItemRenderHTMLTitle/index.native.js
new file mode 100644
index 000000000000..b3dff8d77eff
--- /dev/null
+++ b/src/components/MenuItemRenderHTMLTitle/index.native.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import RenderHTML from '../RenderHTML';
+import menuItemRenderHTMLTitlePropTypes from './propTypes';
+
+const propTypes = menuItemRenderHTMLTitlePropTypes;
+
+const defaultProps = {};
+
+function MenuItemRenderHTMLTitle(props) {
+ return ;
+}
+
+MenuItemRenderHTMLTitle.propTypes = propTypes;
+MenuItemRenderHTMLTitle.defaultProps = defaultProps;
+MenuItemRenderHTMLTitle.displayName = 'MenuItemRenderHTMLTitle';
+
+export default MenuItemRenderHTMLTitle;
diff --git a/src/components/MenuItemRenderHTMLTitle/propTypes.js b/src/components/MenuItemRenderHTMLTitle/propTypes.js
new file mode 100644
index 000000000000..68e279eb28c3
--- /dev/null
+++ b/src/components/MenuItemRenderHTMLTitle/propTypes.js
@@ -0,0 +1,8 @@
+import PropTypes from 'prop-types';
+
+const propTypes = {
+ /** Processed title to display for the MenuItem */
+ title: PropTypes.string.isRequired,
+};
+
+export default propTypes;
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 4703ca099c7c..13471407914f 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -194,14 +194,23 @@ function MoneyRequestConfirmationList(props) {
const {unit, rate, currency} = props.mileageRate;
const distance = lodashGet(transaction, 'routes.route0.distance', 0);
const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0;
- const shouldCategoryBeEditable = !_.isEmpty(props.policyCategories) && Permissions.canUseCategories(props.betas);
+
+ // A flag for verifying that the current report is a sub-report of a workspace chat
+ const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(ReportUtils.getReport(props.reportID))), [props.reportID]);
+
+ // A flag for showing the categories field
+ const shouldShowCategories = isPolicyExpenseChat && Permissions.canUseCategories(props.betas) && OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories));
// Fetches the first tag list of the policy
const tagListKey = _.first(_.keys(props.policyTags));
const tagList = lodashGet(props.policyTags, [tagListKey, 'tags'], []);
const tagListName = lodashGet(props.policyTags, [tagListKey, 'name'], '');
const canUseTags = Permissions.canUseTags(props.betas);
- const shouldShowTags = canUseTags && _.any(tagList, (tag) => tag.enabled);
+ // A flag for showing the tags field
+ const shouldShowTags = isPolicyExpenseChat && canUseTags && _.any(tagList, (tag) => tag.enabled);
+
+ // A flag for showing the billable field
+ const shouldShowBillable = canUseTags && !lodashGet(props.policy, 'disabledFields.defaultBillable', true);
const hasRoute = TransactionUtils.hasRoute(transaction);
const isDistanceRequestWithoutRoute = props.isDistanceRequest && !hasRoute;
@@ -518,7 +527,7 @@ function MoneyRequestConfirmationList(props) {
disabled={didConfirm || props.isReadOnly || !isTypeRequest}
/>
)}
- {shouldCategoryBeEditable && (
+ {shouldShowCategories && (
)}
- {canUseTags && !lodashGet(props.policy, 'disabledFields.defaultBillable', true) && (
+ {shouldShowBillable && (
{translate('common.billable')}
{},
withoutOverlay: false,
+ onModalHide: () => {},
};
function PopoverMenu(props) {
@@ -78,6 +82,7 @@ function PopoverMenu(props) {
isVisible={props.isVisible}
onModalHide={() => {
setFocusedIndex(-1);
+ props.onModalHide();
if (selectedItemIndex.current !== null) {
props.menuItems[selectedItemIndex.current].onSelected();
selectedItemIndex.current = null;
diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js
index 61a602be0bda..21aac35f4005 100644
--- a/src/components/ReimbursementAccountLoadingIndicator.js
+++ b/src/components/ReimbursementAccountLoadingIndicator.js
@@ -4,14 +4,12 @@ import PropTypes from 'prop-types';
import Lottie from 'lottie-react-native';
import * as LottieAnimations from './LottieAnimations';
import styles from '../styles/styles';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
+import useLocalize from '../hooks/useLocalize';
import Text from './Text';
import HeaderWithBackButton from './HeaderWithBackButton';
import ScreenWrapper from './ScreenWrapper';
import FullScreenLoadingIndicator from './FullscreenLoadingIndicator';
import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView';
-import compose from '../libs/compose';
-import {withNetwork} from './OnyxProvider';
const propTypes = {
/** Whether the user is submitting verifications data */
@@ -19,17 +17,18 @@ const propTypes = {
/** Method to trigger when pressing back button of the header */
onBackButtonPress: PropTypes.func.isRequired,
- ...withLocalizePropTypes,
};
function ReimbursementAccountLoadingIndicator(props) {
+ const {translate} = useLocalize();
return (
@@ -42,7 +41,7 @@ function ReimbursementAccountLoadingIndicator(props) {
style={styles.loadingVBAAnimation}
/>
- {props.translate('reimbursementAccountLoadingAnimation.explanationLine')}
+ {translate('reimbursementAccountLoadingAnimation.explanationLine')}
) : (
@@ -56,4 +55,4 @@ function ReimbursementAccountLoadingIndicator(props) {
ReimbursementAccountLoadingIndicator.propTypes = propTypes;
ReimbursementAccountLoadingIndicator.displayName = 'ReimbursementAccountLoadingIndicator';
-export default compose(withLocalize, withNetwork())(ReimbursementAccountLoadingIndicator);
+export default ReimbursementAccountLoadingIndicator;
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index d8b15653d8af..178cab75a0c2 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -1,7 +1,8 @@
-import React from 'react';
+import React, {useMemo} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
+import lodashValues from 'lodash/values';
import PropTypes from 'prop-types';
import reportPropTypes from '../../pages/reportPropTypes';
import ONYXKEYS from '../../ONYXKEYS';
@@ -9,9 +10,11 @@ import ROUTES from '../../ROUTES';
import Navigation from '../../libs/Navigation/Navigation';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails';
import compose from '../../libs/compose';
+import Permissions from '../../libs/Permissions';
import MenuItemWithTopDescription from '../MenuItemWithTopDescription';
import styles from '../../styles/styles';
import * as ReportUtils from '../../libs/ReportUtils';
+import * as OptionsListUtils from '../../libs/OptionsListUtils';
import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
import * as StyleUtils from '../../styles/StyleUtils';
import CONST from '../../CONST';
@@ -27,26 +30,36 @@ import Image from '../Image';
import ReportActionItemImage from './ReportActionItemImage';
import * as TransactionUtils from '../../libs/TransactionUtils';
import OfflineWithFeedback from '../OfflineWithFeedback';
+import categoryPropTypes from '../categoryPropTypes';
import SpacerView from '../SpacerView';
const propTypes = {
/** The report currently being looked at */
report: reportPropTypes.isRequired,
+ /** Whether we should display the horizontal rule below the component */
+ shouldShowHorizontalRule: PropTypes.bool.isRequired,
+
+ /* Onyx Props */
+ /** List of betas available to current user */
+ betas: PropTypes.arrayOf(PropTypes.string),
+
/** The expense report or iou report (only will have a value if this is a transaction thread) */
parentReport: iouReportPropTypes,
+ /** Collection of categories attached to a policy */
+ policyCategories: PropTypes.objectOf(categoryPropTypes),
+
/** The transaction associated with the transactionThread */
transaction: transactionPropTypes,
- /** Whether we should display the horizontal rule below the component */
- shouldShowHorizontalRule: PropTypes.bool.isRequired,
-
...withCurrentUserPersonalDetailsPropTypes,
};
const defaultProps = {
+ betas: [],
parentReport: {},
+ policyCategories: {},
transaction: {
amount: 0,
currency: CONST.CURRENCY.USD,
@@ -54,7 +67,7 @@ const defaultProps = {
},
};
-function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, transaction}) {
+function MoneyRequestView({betas, report, parentReport, policyCategories, shouldShowHorizontalRule, transaction}) {
const {isSmallScreenWidth} = useWindowDimensions();
const {translate} = useLocalize();
@@ -66,6 +79,7 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, trans
currency: transactionCurrency,
comment: transactionDescription,
merchant: transactionMerchant,
+ category: transactionCategory,
} = ReportUtils.getTransactionDetails(transaction);
const isEmptyMerchant =
transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
@@ -73,6 +87,10 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, trans
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction);
+ // A flag for verifying that the current report is a sub-report of a workspace chat
+ const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]);
+ // A flag for showing categories
+ const shouldShowCategory = isPolicyExpenseChat && Permissions.canUseCategories(betas) && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories)));
let description = `${translate('iou.amount')} • ${translate('iou.cash')}`;
if (isSettled) {
@@ -170,6 +188,18 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, trans
subtitleTextStyle={styles.textLabelError}
/>
+ {shouldShowCategory && (
+
+ Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))}
+ />
+
+ )}
`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`,
},
policy: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`,
},
+ policyCategories: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report.policyID}`,
+ },
session: {
key: ONYXKEYS.SESSION,
},
diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js
index f760e5d5aeb4..1cad1e96b26d 100644
--- a/src/components/ScreenWrapper/index.js
+++ b/src/components/ScreenWrapper/index.js
@@ -109,6 +109,7 @@ class ScreenWrapper extends React.Component {
style={styles.flex1}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(this.props.environment === CONST.ENVIRONMENT.DEV ? this.panResponder.panHandlers : {})}
+ testID={this.props.testID}
>
{},
shouldOverlay: false,
};
-function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment, shouldOverlay}) {
+function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment, onModalHide, shouldOverlay}) {
const [isPopupMenuVisible, setPopupMenuVisible] = useState(false);
const buttonRef = useRef(null);
const {translate} = useLocalize();
@@ -73,6 +77,9 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me
};
const hidePopoverMenu = () => {
+ InteractionManager.runAfterInteractions(() => {
+ onModalHide();
+ });
setPopupMenuVisible(false);
};
@@ -105,6 +112,7 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me
{
Log.info('[BootSplash] hiding splash screen', false);
return BootSplash.hide();
}
diff --git a/src/libs/BootSplash/index.js b/src/libs/BootSplash/index.ts
similarity index 62%
rename from src/libs/BootSplash/index.js
rename to src/libs/BootSplash/index.ts
index c169f380a8eb..24842fe631f4 100644
--- a/src/libs/BootSplash/index.js
+++ b/src/libs/BootSplash/index.ts
@@ -1,20 +1,21 @@
import Log from '../Log';
+import {VisibilityStatus} from './types';
-function resolveAfter(delay) {
- return new Promise((resolve) => setTimeout(resolve, delay));
+function resolveAfter(delay: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, delay));
}
-function hide() {
+function hide(): Promise {
Log.info('[BootSplash] hiding splash screen', false);
return document.fonts.ready.then(() => {
const splash = document.getElementById('splash');
if (splash) {
- splash.style.opacity = 0;
+ splash.style.opacity = '0';
}
return resolveAfter(250).then(() => {
- if (!splash || !splash.parentNode) {
+ if (!splash?.parentNode) {
return;
}
splash.parentNode.removeChild(splash);
@@ -22,7 +23,7 @@ function hide() {
});
}
-function getVisibilityStatus() {
+function getVisibilityStatus(): Promise {
return Promise.resolve(document.getElementById('splash') ? 'visible' : 'hidden');
}
diff --git a/src/libs/BootSplash/types.ts b/src/libs/BootSplash/types.ts
new file mode 100644
index 000000000000..2329d5315817
--- /dev/null
+++ b/src/libs/BootSplash/types.ts
@@ -0,0 +1,9 @@
+type VisibilityStatus = 'visible' | 'hidden';
+
+type BootSplashModule = {
+ navigationBarHeight: number;
+ hide: () => Promise;
+ getVisibilityStatus: () => Promise;
+};
+
+export type {BootSplashModule, VisibilityStatus};
diff --git a/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.js b/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.js
deleted file mode 100644
index 4306b0cff3f6..000000000000
--- a/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.js
+++ /dev/null
@@ -1,5 +0,0 @@
-function canUseTouchScreen() {
- return true;
-}
-
-export default canUseTouchScreen;
diff --git a/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.ts b/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.ts
new file mode 100644
index 000000000000..60980801e73c
--- /dev/null
+++ b/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.ts
@@ -0,0 +1,5 @@
+import CanUseTouchScreen from './types';
+
+const canUseTouchScreen: CanUseTouchScreen = () => true;
+
+export default canUseTouchScreen;
diff --git a/src/libs/DeviceCapabilities/canUseTouchScreen/index.js b/src/libs/DeviceCapabilities/canUseTouchScreen/index.ts
similarity index 51%
rename from src/libs/DeviceCapabilities/canUseTouchScreen/index.js
rename to src/libs/DeviceCapabilities/canUseTouchScreen/index.ts
index 17dcc9dffd73..9e21f5a42b5d 100644
--- a/src/libs/DeviceCapabilities/canUseTouchScreen/index.js
+++ b/src/libs/DeviceCapabilities/canUseTouchScreen/index.ts
@@ -1,29 +1,38 @@
+import {Merge} from 'type-fest';
+import CanUseTouchScreen from './types';
+
+type ExtendedNavigator = Merge;
+
/**
* Allows us to identify whether the platform has a touchscreen.
*
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
- *
- * @returns {Boolean}
*/
-function canUseTouchScreen() {
+const canUseTouchScreen: CanUseTouchScreen = () => {
let hasTouchScreen = false;
+
+ // TypeScript removed support for msMaxTouchPoints, this doesn't mean however that
+ // this property doesn't exist - hence the use of ExtendedNavigator to ensure
+ // that the functionality doesn't change
+ // https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1029
if ('maxTouchPoints' in navigator) {
hasTouchScreen = navigator.maxTouchPoints > 0;
} else if ('msMaxTouchPoints' in navigator) {
- hasTouchScreen = navigator.msMaxTouchPoints > 0;
+ hasTouchScreen = (navigator as ExtendedNavigator).msMaxTouchPoints > 0;
} else {
- const mQ = window.matchMedia && matchMedia('(pointer:coarse)');
+ // Same case as for Navigator - TypeScript thinks that matchMedia is obligatory property of window although it may not be
+ const mQ = window.matchMedia?.('(pointer:coarse)');
if (mQ && mQ.media === '(pointer:coarse)') {
hasTouchScreen = !!mQ.matches;
} else if ('orientation' in window) {
hasTouchScreen = true; // deprecated, but good fallback
} else {
// Only as a last resort, fall back to user agent sniffing
- const UA = navigator.userAgent;
+ const UA = (navigator as ExtendedNavigator).userAgent;
hasTouchScreen = /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) || /\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA);
}
}
return hasTouchScreen;
-}
+};
export default canUseTouchScreen;
diff --git a/src/libs/DeviceCapabilities/canUseTouchScreen/types.ts b/src/libs/DeviceCapabilities/canUseTouchScreen/types.ts
new file mode 100644
index 000000000000..6b71ecffeb05
--- /dev/null
+++ b/src/libs/DeviceCapabilities/canUseTouchScreen/types.ts
@@ -0,0 +1,3 @@
+type CanUseTouchScreen = () => boolean;
+
+export default CanUseTouchScreen;
diff --git a/src/libs/DeviceCapabilities/hasHoverSupport/index.js b/src/libs/DeviceCapabilities/hasHoverSupport/index.js
deleted file mode 100644
index 84a3fbbc5ed1..000000000000
--- a/src/libs/DeviceCapabilities/hasHoverSupport/index.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * Allows us to identify whether the platform is hoverable.
- *
- * @returns {Boolean}
- */
-function hasHoverSupport() {
- return window.matchMedia('(hover: hover) and (pointer: fine)').matches;
-}
-
-export default hasHoverSupport;
diff --git a/src/libs/DeviceCapabilities/hasHoverSupport/index.native.js b/src/libs/DeviceCapabilities/hasHoverSupport/index.native.ts
similarity index 52%
rename from src/libs/DeviceCapabilities/hasHoverSupport/index.native.js
rename to src/libs/DeviceCapabilities/hasHoverSupport/index.native.ts
index d77fcc17448a..097b3b0cbba1 100644
--- a/src/libs/DeviceCapabilities/hasHoverSupport/index.native.js
+++ b/src/libs/DeviceCapabilities/hasHoverSupport/index.native.ts
@@ -1,9 +1,8 @@
+import HasHoverSupport from './types';
+
/**
* Allows us to identify whether the platform is hoverable.
- *
- * @returns {Boolean}
*/
-
-const hasHoverSupport = () => false;
+const hasHoverSupport: HasHoverSupport = () => false;
export default hasHoverSupport;
diff --git a/src/libs/DeviceCapabilities/hasHoverSupport/index.ts b/src/libs/DeviceCapabilities/hasHoverSupport/index.ts
new file mode 100644
index 000000000000..1ff0f461db69
--- /dev/null
+++ b/src/libs/DeviceCapabilities/hasHoverSupport/index.ts
@@ -0,0 +1,8 @@
+import HasHoverSupport from './types';
+
+/**
+ * Allows us to identify whether the platform is hoverable.
+ */
+const hasHoverSupport: HasHoverSupport = () => window.matchMedia?.('(hover: hover) and (pointer: fine)').matches;
+
+export default hasHoverSupport;
diff --git a/src/libs/DeviceCapabilities/hasHoverSupport/types.ts b/src/libs/DeviceCapabilities/hasHoverSupport/types.ts
new file mode 100644
index 000000000000..b8fe944cf88e
--- /dev/null
+++ b/src/libs/DeviceCapabilities/hasHoverSupport/types.ts
@@ -0,0 +1,3 @@
+type HasHoverSupport = () => boolean;
+
+export default HasHoverSupport;
diff --git a/src/libs/DeviceCapabilities/index.js b/src/libs/DeviceCapabilities/index.ts
similarity index 100%
rename from src/libs/DeviceCapabilities/index.js
rename to src/libs/DeviceCapabilities/index.ts
diff --git a/src/libs/Growl.ts b/src/libs/Growl.ts
index 99c728f0a210..33d7311973cb 100644
--- a/src/libs/Growl.ts
+++ b/src/libs/Growl.ts
@@ -12,7 +12,9 @@ const isReadyPromise = new Promise((resolve) => {
});
function setIsReady() {
- if (!resolveIsReadyPromise) return;
+ if (!resolveIsReadyPromise) {
+ return;
+ }
resolveIsReadyPromise();
}
@@ -21,7 +23,9 @@ function setIsReady() {
*/
function show(bodyText: string, type: string, duration: number = CONST.GROWL.DURATION) {
isReadyPromise.then(() => {
- if (!growlRef?.current?.show) return;
+ if (!growlRef?.current?.show) {
+ return;
+ }
growlRef.current.show(bodyText, type, duration);
});
}
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index 5c110264e034..f3939eabe6f7 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -345,7 +345,7 @@ const NewTeachersUniteNavigator = createModalStackNavigator([
},
{
getComponent: () => {
- const IntroSchoolPrincipalPage = require('../../../pages/TeachersUnite/IntroSchoolPrincipalPage').default;
+ const IntroSchoolPrincipalPage = require('../../../pages/TeachersUnite/ImTeacherPage').default;
return IntroSchoolPrincipalPage;
},
name: 'Intro_School_Principal',
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 3c6e879bd423..3bdf77745432 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -593,6 +593,30 @@ function isCurrentUser(userDetails) {
return _.some(_.keys(loginList), (login) => login.toLowerCase() === userDetailsLogin.toLowerCase());
}
+/**
+ * Calculates count of all enabled options
+ *
+ * @param {Object[]} options - an initial strings array
+ * @param {Boolean} options[].enabled - a flag to enable/disable option in a list
+ * @param {String} options[].name - a name of an option
+ * @returns {Number}
+ */
+function getEnabledCategoriesCount(options) {
+ return _.filter(options, (option) => option.enabled).length;
+}
+
+/**
+ * Verifies that there is at least one enabled option
+ *
+ * @param {Object[]} options - an initial strings array
+ * @param {Boolean} options[].enabled - a flag to enable/disable option in a list
+ * @param {String} options[].name - a name of an option
+ * @returns {Boolean}
+ */
+function hasEnabledOptions(options) {
+ return _.some(options, (option) => option.enabled);
+}
+
/**
* Build the options for the category tree hierarchy via indents
*
@@ -606,6 +630,10 @@ function getCategoryOptionTree(options, isOneLine = false) {
const optionCollection = {};
_.each(options, (option) => {
+ if (!option.enabled) {
+ return;
+ }
+
if (isOneLine) {
if (_.has(optionCollection, option.name)) {
return;
@@ -656,10 +684,26 @@ function getCategoryOptionTree(options, isOneLine = false) {
*/
function getCategoryListSections(categories, recentlyUsedCategories, selectedOptions, searchInputValue, maxRecentReportsToShow) {
const categorySections = [];
- const categoriesValues = _.values(categories);
+ const categoriesValues = _.chain(categories)
+ .values()
+ .filter((category) => category.enabled)
+ .value();
+
const numberOfCategories = _.size(categoriesValues);
let indexOffset = 0;
+ if (numberOfCategories === 0 && selectedOptions.length > 0) {
+ categorySections.push({
+ // "Selected" section
+ title: '',
+ shouldShow: false,
+ indexOffset,
+ data: getCategoryOptionTree(selectedOptions, true),
+ });
+
+ return categorySections;
+ }
+
if (!_.isEmpty(searchInputValue)) {
const searchCategories = _.filter(categoriesValues, (category) => category.name.toLowerCase().includes(searchInputValue.toLowerCase()));
@@ -1474,6 +1518,8 @@ export {
isSearchStringMatch,
shouldOptionShowTooltip,
getLastMessageTextForReport,
+ getEnabledCategoriesCount,
+ hasEnabledOptions,
getCategoryOptionTree,
formatMemberForList,
};
diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js
index 164f284a4ef5..d2de5b1c0d7e 100644
--- a/src/libs/PolicyUtils.js
+++ b/src/libs/PolicyUtils.js
@@ -6,11 +6,12 @@ import ONYXKEYS from '../ONYXKEYS';
/**
* Filter out the active policies, which will exclude policies with pending deletion
+ * These are policies that we can use to create reports with in NewDot.
* @param {Object} policies
* @returns {Array}
*/
function getActivePolicies(policies) {
- return _.filter(policies, (policy) => policy && policy.isPolicyExpenseChatEnabled && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+ return _.filter(policies, (policy) => policy && (policy.isPolicyExpenseChatEnabled || policy.areChatRoomsEnabled) && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
}
/**
@@ -166,6 +167,14 @@ function getIneligibleInvitees(policyMembers, personalDetails) {
return memberEmailsToExclude;
}
+/**
+ * @param {Object} policy
+ * @returns {Boolean}
+ */
+function isPendingDeletePolicy(policy) {
+ return policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
+}
+
export {
getActivePolicies,
hasPolicyMemberError,
@@ -179,4 +188,5 @@ export {
isPolicyAdmin,
getMemberAccountIDsForWorkspace,
getIneligibleInvitees,
+ isPendingDeletePolicy,
};
diff --git a/src/libs/Pusher/EventType.js b/src/libs/Pusher/EventType.js
index 639e10020fc7..85ccc5e17242 100644
--- a/src/libs/Pusher/EventType.js
+++ b/src/libs/Pusher/EventType.js
@@ -5,6 +5,7 @@
export default {
REPORT_COMMENT: 'reportComment',
ONYX_API_UPDATE: 'onyxApiUpdate',
+ USER_IS_LEAVING_ROOM: 'client-userIsLeavingRoom',
USER_IS_TYPING: 'client-userIsTyping',
MULTIPLE_EVENTS: 'multipleEvents',
MULTIPLE_EVENT_TYPE: {
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index edf646d0266b..eee9d6549f6c 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -947,23 +947,31 @@ function getIconsForParticipants(participants, personalDetails) {
for (let i = 0; i < participantsList.length; i++) {
const accountID = participantsList[i];
const avatarSource = UserUtils.getAvatar(lodashGet(personalDetails, [accountID, 'avatar'], ''), accountID);
- participantDetails.push([
- accountID,
- lodashGet(personalDetails, [accountID, 'displayName']) || lodashGet(personalDetails, [accountID, 'login'], ''),
- lodashGet(personalDetails, [accountID, 'firstName'], ''),
- avatarSource,
- ]);
+ const displayNameLogin = lodashGet(personalDetails, [accountID, 'displayName']) || lodashGet(personalDetails, [accountID, 'login'], '');
+ participantDetails.push([accountID, displayNameLogin, avatarSource]);
}
- // Sort all logins by first name (which is the second element in the array)
- const sortedParticipantDetails = participantDetails.sort((a, b) => a[2] - b[2]);
+ const sortedParticipantDetails = _.chain(participantDetails)
+ .sort((first, second) => {
+ // First sort by displayName/login
+ const displayNameLoginOrder = first[1].localeCompare(second[1]);
+ if (displayNameLoginOrder !== 0) {
+ return displayNameLoginOrder;
+ }
- // Now that things are sorted, gather only the avatars (third element in the array) and return those
+ // Then fallback on accountID as the final sorting criteria.
+ // This will ensure that the order of avatars with same login/displayName
+ // stay consistent across all users and devices
+ return first[0] > second[0];
+ })
+ .value();
+
+ // Now that things are sorted, gather only the avatars (second element in the array) and return those
const avatars = [];
for (let i = 0; i < sortedParticipantDetails.length; i++) {
const userIcon = {
id: sortedParticipantDetails[i][0],
- source: sortedParticipantDetails[i][3],
+ source: sortedParticipantDetails[i][2],
type: CONST.ICON_TYPE_AVATAR,
name: sortedParticipantDetails[i][1],
};
@@ -1641,6 +1649,29 @@ function getParentReport(report) {
return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, {});
}
+/**
+ * Returns the root parentReport if the given report is nested.
+ * Uses recursion to iterate any depth of nested reports.
+ *
+ * @param {Object} report
+ * @returns {Object}
+ */
+function getRootParentReport(report) {
+ if (!report) {
+ return {};
+ }
+
+ // Returns the current report as the root report, because it does not have a parentReportID
+ if (!report.parentReportID) {
+ return report;
+ }
+
+ const parentReport = getReport(report.parentReportID);
+
+ // Runs recursion to iterate a parent report
+ return getRootParentReport(parentReport);
+}
+
/**
* Get the title for a report.
*
@@ -3676,6 +3707,7 @@ export {
isAllowedToComment,
getBankAccountRoute,
getParentReport,
+ getRootParentReport,
getTaskParentReportActionIDInAssigneeReport,
getReportPreviewMessage,
getModifiedExpenseMessage,
diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js
index cea530f6f47a..5dcfbc467c20 100644
--- a/src/libs/TransactionUtils.js
+++ b/src/libs/TransactionUtils.js
@@ -147,6 +147,10 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep
shouldStopSmartscan = true;
}
+ if (_.has(transactionChanges, 'category')) {
+ updatedTransaction.category = transactionChanges.category;
+ }
+
if (shouldStopSmartscan && _.has(transaction, 'receipt') && !_.isEmpty(transaction.receipt) && lodashGet(transaction, 'receipt.state') !== CONST.IOU.RECEIPT_STATE.OPEN) {
updatedTransaction.receipt.state = CONST.IOU.RECEIPT_STATE.OPEN;
}
@@ -157,6 +161,7 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep
...(_.has(transactionChanges, 'amount') && {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
...(_.has(transactionChanges, 'currency') && {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
...(_.has(transactionChanges, 'merchant') && {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(_.has(transactionChanges, 'category') && {category: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
};
return updatedTransaction;
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index 08b032e383b8..86cd791f7fce 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -1148,6 +1148,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
}
// STEP 4: Compose the optimistic data
+ const currentTime = DateUtils.getDBTime();
const optimisticData = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -1171,6 +1172,14 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.chatReportID}`,
value: updatedChatReport,
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`,
+ value: {
+ lastReadTime: currentTime,
+ lastVisibleActionCreated: currentTime,
+ },
+ },
];
const successData = [
@@ -1191,6 +1200,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
created: null,
currency: null,
merchant: null,
+ category: null,
},
},
},
@@ -1226,6 +1236,14 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.chatReportID}`,
value: chatReport,
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`,
+ value: {
+ lastReadTime: transactionThread.lastReadTime,
+ lastVisibleActionCreated: transactionThread.lastVisibleActionCreated,
+ },
+ },
];
// STEP 6: Call the API endpoint
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index aa0d4b432da4..9fa1e5fe0567 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -103,24 +103,24 @@ function getReportChannelName(reportID) {
}
/**
- * There are 2 possibilities that we can receive via pusher for a user's typing status:
+ * There are 2 possibilities that we can receive via pusher for a user's typing/leaving status:
* 1. The "new" way from New Expensify is passed as {[login]: Boolean} (e.g. {yuwen@expensify.com: true}), where the value
- * is whether the user with that login is typing on the report or not.
+ * is whether the user with that login is typing/leaving on the report or not.
* 2. The "old" way from e.com which is passed as {userLogin: login} (e.g. {userLogin: bstites@expensify.com})
*
* This method makes sure that no matter which we get, we return the "new" format
*
- * @param {Object} typingStatus
+ * @param {Object} status
* @returns {Object}
*/
-function getNormalizedTypingStatus(typingStatus) {
- let normalizedTypingStatus = typingStatus;
+function getNormalizedStatus(status) {
+ let normalizedStatus = status;
- if (_.first(_.keys(typingStatus)) === 'userLogin') {
- normalizedTypingStatus = {[typingStatus.userLogin]: true};
+ if (_.first(_.keys(status)) === 'userLogin') {
+ normalizedStatus = {[status.userLogin]: true};
}
- return normalizedTypingStatus;
+ return normalizedStatus;
}
/**
@@ -141,7 +141,7 @@ function subscribeToReportTypingEvents(reportID) {
// If the pusher message comes from OldDot, we expect the typing status to be keyed by user
// login OR by 'Concierge'. If the pusher message comes from NewDot, it is keyed by accountID
// since personal details are keyed by accountID.
- const normalizedTypingStatus = getNormalizedTypingStatus(typingStatus);
+ const normalizedTypingStatus = getNormalizedStatus(typingStatus);
const accountIDOrLogin = _.first(_.keys(normalizedTypingStatus));
if (!accountIDOrLogin) {
@@ -170,6 +170,41 @@ function subscribeToReportTypingEvents(reportID) {
});
}
+/**
+ * Initialize our pusher subscriptions to listen for someone leaving a room.
+ *
+ * @param {String} reportID
+ */
+function subscribeToReportLeavingEvents(reportID) {
+ if (!reportID) {
+ return;
+ }
+
+ // Make sure we have a clean Leaving indicator before subscribing to leaving events
+ Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportID}`, false);
+
+ const pusherChannelName = getReportChannelName(reportID);
+ Pusher.subscribe(pusherChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, (leavingStatus) => {
+ // If the pusher message comes from OldDot, we expect the leaving status to be keyed by user
+ // login OR by 'Concierge'. If the pusher message comes from NewDot, it is keyed by accountID
+ // since personal details are keyed by accountID.
+ const normalizedLeavingStatus = getNormalizedStatus(leavingStatus);
+ const accountIDOrLogin = _.first(_.keys(normalizedLeavingStatus));
+
+ if (!accountIDOrLogin) {
+ return;
+ }
+
+ if (Number(accountIDOrLogin) !== currentUserAccountID) {
+ return;
+ }
+
+ Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportID}`, true);
+ }).catch((error) => {
+ Log.hmmm('[Report] Failed to initially subscribe to Pusher channel', false, {errorType: error.type, pusherChannelName});
+ });
+}
+
/**
* Remove our pusher subscriptions to listen for someone typing in a report.
*
@@ -185,6 +220,21 @@ function unsubscribeFromReportChannel(reportID) {
Pusher.unsubscribe(pusherChannelName, Pusher.TYPE.USER_IS_TYPING);
}
+/**
+ * Remove our pusher subscriptions to listen for someone leaving a report.
+ *
+ * @param {String} reportID
+ */
+function unsubscribeFromLeavingRoomReportChannel(reportID) {
+ if (!reportID) {
+ return;
+ }
+
+ const pusherChannelName = getReportChannelName(reportID);
+ Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportID}`, false);
+ Pusher.unsubscribe(pusherChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM);
+}
+
// New action subscriber array for report pages
let newActionSubscribers = [];
@@ -865,6 +915,17 @@ function broadcastUserIsTyping(reportID) {
typingStatus[currentUserAccountID] = true;
Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_TYPING, typingStatus);
}
+/**
+ * Broadcasts to the report's private pusher channel whether a user is leaving a report
+ *
+ * @param {String} reportID
+ */
+function broadcastUserIsLeavingRoom(reportID) {
+ const privateReportChannelName = getReportChannelName(reportID);
+ const leavingStatus = {};
+ leavingStatus[currentUserAccountID] = true;
+ Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, leavingStatus);
+}
/**
* When a report changes in Onyx, this fetches the report from the API if the report doesn't have a name
@@ -1781,6 +1842,12 @@ function getCurrentUserAccountID() {
function leaveRoom(reportID) {
const report = lodashGet(allReports, [reportID], {});
const reportKeys = _.keys(report);
+
+ // Pusher's leavingStatus should be sent earlier.
+ // Place the broadcast before calling the LeaveRoom API to prevent a race condition
+ // between Onyx report being null and Pusher's leavingStatus becoming true.
+ broadcastUserIsLeavingRoom(reportID);
+
API.write(
'LeaveRoom',
{
@@ -2071,10 +2138,13 @@ export {
updateWriteCapabilityAndNavigate,
updateNotificationPreferenceAndNavigate,
subscribeToReportTypingEvents,
+ subscribeToReportLeavingEvents,
unsubscribeFromReportChannel,
+ unsubscribeFromLeavingRoomReportChannel,
saveReportComment,
saveReportCommentNumberOfLines,
broadcastUserIsTyping,
+ broadcastUserIsLeavingRoom,
togglePinnedState,
editReportComment,
handleUserDeletedLinksInHtml,
diff --git a/src/libs/isInputAutoFilled.ts b/src/libs/isInputAutoFilled.ts
index 0abe634001e4..e1b9942b0e78 100644
--- a/src/libs/isInputAutoFilled.ts
+++ b/src/libs/isInputAutoFilled.ts
@@ -4,7 +4,9 @@ import isSelectorSupported from './isSelectorSupported';
* Check the input is auto filled or not
*/
export default function isInputAutoFilled(input: Element): boolean {
- if (!input?.matches) return false;
+ if (!input?.matches) {
+ return false;
+ }
if (isSelectorSupported(':autofill')) {
return input.matches(':-webkit-autofill') || input.matches(':autofill');
}
diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js
index e401cbd9db69..588e1a5642aa 100644
--- a/src/libs/migrateOnyx.js
+++ b/src/libs/migrateOnyx.js
@@ -2,7 +2,6 @@ import _ from 'underscore';
import Log from './Log';
import AddEncryptedAuthToken from './migrations/AddEncryptedAuthToken';
import RenamePriorityModeKey from './migrations/RenamePriorityModeKey';
-import MoveToIndexedDB from './migrations/MoveToIndexedDB';
import RenameExpensifyNewsStatus from './migrations/RenameExpensifyNewsStatus';
import AddLastVisibleActionCreated from './migrations/AddLastVisibleActionCreated';
import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID';
@@ -14,15 +13,7 @@ export default function () {
return new Promise((resolve) => {
// Add all migrations to an array so they are executed in order
- const migrationPromises = [
- MoveToIndexedDB,
- RenamePriorityModeKey,
- AddEncryptedAuthToken,
- RenameExpensifyNewsStatus,
- AddLastVisibleActionCreated,
- PersonalDetailsByAccountID,
- RenameReceiptFilename,
- ];
+ const migrationPromises = [RenamePriorityModeKey, AddEncryptedAuthToken, RenameExpensifyNewsStatus, AddLastVisibleActionCreated, PersonalDetailsByAccountID, RenameReceiptFilename];
// Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the
// previous promise to finish before moving onto the next one.
diff --git a/src/libs/migrations/MoveToIndexedDB.js b/src/libs/migrations/MoveToIndexedDB.js
deleted file mode 100644
index 1f62985ed7bf..000000000000
--- a/src/libs/migrations/MoveToIndexedDB.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import _ from 'underscore';
-import Onyx from 'react-native-onyx';
-
-import Log from '../Log';
-import ONYXKEYS from '../../ONYXKEYS';
-import getPlatform from '../getPlatform';
-import CONST from '../../CONST';
-
-/**
- * Test whether the current platform is eligible for migration
- * This migration is only applicable for desktop/web
- * We're also skipping logged-out users as there would be nothing to migrate
- *
- * @returns {Boolean}
- */
-function shouldMigrate() {
- const isTargetPlatform = _.contains([CONST.PLATFORM.WEB, CONST.PLATFORM.DESKTOP], getPlatform());
- if (!isTargetPlatform) {
- Log.info('[Migrate Onyx] Skipped migration MoveToIndexedDB (Not applicable to current platform)');
- return false;
- }
-
- const session = window.localStorage.getItem(ONYXKEYS.SESSION);
- if (!session || !session.includes('authToken')) {
- Log.info('[Migrate Onyx] Skipped migration MoveToIndexedDB (Not applicable to logged out users)');
- return false;
- }
-
- return true;
-}
-
-/**
- * Find Onyx data and move it from local storage to IndexedDB
- *
- * @returns {Promise}
- */
-function applyMigration() {
- const onyxKeys = new Set(_.values(ONYXKEYS));
- const onyxCollections = _.values(ONYXKEYS.COLLECTION);
-
- // Prepare a key-value dictionary of keys eligible for migration
- // Targeting existing Onyx keys in local storage or any key prefixed by a collection name
- const dataToMigrate = _.chain(window.localStorage)
- .keys()
- .filter((key) => onyxKeys.has(key) || _.some(onyxCollections, (collectionKey) => key.startsWith(collectionKey)))
- .map((key) => [key, JSON.parse(window.localStorage.getItem(key))])
- .object()
- .value();
-
- // Move the data in Onyx and only then delete it from local storage
- return Onyx.multiSet(dataToMigrate).then(() => _.each(dataToMigrate, (value, key) => window.localStorage.removeItem(key)));
-}
-
-/**
- * Migrate Web/Desktop storage to IndexedDB
- *
- * @returns {Promise}
- */
-export default function () {
- if (!shouldMigrate()) {
- return Promise.resolve();
- }
-
- return new Promise((resolve, reject) => {
- applyMigration()
- .then(() => {
- Log.info('[Migrate Onyx] Ran migration MoveToIndexedDB');
- resolve();
- })
- .catch((e) => {
- Log.alert('[Migrate Onyx] MoveToIndexedDB failed', {error: e.message, stack: e.stack}, false);
- reject(e);
- });
- });
-}
diff --git a/src/libs/requireParameters.ts b/src/libs/requireParameters.ts
index 098a6d114430..ebeb55e254e0 100644
--- a/src/libs/requireParameters.ts
+++ b/src/libs/requireParameters.ts
@@ -14,7 +14,9 @@ export default function requireParameters(parameterNames: string[], parameters:
const propertiesToRedact = ['authToken', 'password', 'partnerUserSecret', 'twoFactorAuthCode'];
const parametersCopy = {...parameters};
Object.keys(parametersCopy).forEach((key) => {
- if (!propertiesToRedact.includes(key.toString())) return;
+ if (!propertiesToRedact.includes(key.toString())) {
+ return;
+ }
parametersCopy[key] = '';
});
diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js
index 98bc09a7a217..7c04970c3980 100644
--- a/src/pages/AddPersonalBankAccountPage.js
+++ b/src/pages/AddPersonalBankAccountPage.js
@@ -103,6 +103,7 @@ class AddPersonalBankAccountPage extends React.Component {
includeSafeAreaPaddingBottom={shouldShowSuccess}
shouldEnablePickerAvoiding={false}
shouldShowOfflineIndicator={false}
+ testID={AddPersonalBankAccountPage.displayName}
>
+
{
+ onSubmit({
+ category: category.searchText,
+ });
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+EditRequestCategoryPage.propTypes = propTypes;
+EditRequestCategoryPage.displayName = 'EditRequestCategoryPage';
+
+export default EditRequestCategoryPage;
diff --git a/src/pages/EditRequestCreatedPage.js b/src/pages/EditRequestCreatedPage.js
index a29936ba9ba4..9edce7350400 100644
--- a/src/pages/EditRequestCreatedPage.js
+++ b/src/pages/EditRequestCreatedPage.js
@@ -23,6 +23,7 @@ function EditRequestCreatedPage({defaultCreated, onSubmit}) {