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/android/app/build.gradle b/android/app/build.gradle index 1e34491b04ad..a1bf4bdf2f98 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001037209 - versionName "1.3.72-9" + versionCode 1001037210 + versionName "1.3.72-10" } flavorDimensions "default" diff --git a/docs/_includes/article-card.html b/docs/_includes/article-card.html index a088e5e406db..b6d8998c13ef 100644 --- a/docs/_includes/article-card.html +++ b/docs/_includes/article-card.html @@ -1,4 +1,4 @@ - +

{{ include.title }}

diff --git a/docs/_includes/hub-card.html b/docs/_includes/hub-card.html index 36bf3bc36e6a..b5188bda7670 100644 --- a/docs/_includes/hub-card.html +++ b/docs/_includes/hub-card.html @@ -1,6 +1,6 @@ -{% assign hub = site.data.routes.hubs | where: "href", include.href | first %} - -
+{% assign hub = include.hub %} +{% assign platform = include.platform %} +
{{ hub.href }} diff --git a/docs/_includes/hub.html b/docs/_includes/hub.html index acdc901a38f6..6b0b0e590b19 100644 --- a/docs/_includes/hub.html +++ b/docs/_includes/hub.html @@ -1,5 +1,8 @@ -{% assign activeHub = page.url | remove: "/hubs/" | remove: ".html" %} -{% assign hub = site.data.routes.hubs | where: "href", activeHub | first %} +{% assign activePlatform = page.url | replace: '/', ' ' | truncatewords: 1 | remove:'...' %} +{% assign platform = site.data.routes.platforms | where: "href", activePlatform | first %} + +{% assign activeHub = page.url | remove: activePlatform | remove: "/hubs/" | remove: "/" | remove: ".html" %} +{% assign hub = platform.hubs | where: "href", activeHub | first %}

{{ hub.title }} @@ -9,6 +12,16 @@

{{ hub.description }}

+{% if hub.articles %} +
+
+ {% for article in hub.articles %} + {% include article-card.html hub=hub.href href=article.href title=article.title platform=activePlatform %} + {% endfor %} +
+
+{% endif %} + {% for section in hub.sections %}

@@ -18,18 +31,8 @@

{% for article in section.articles %} {% assign article_href = section.href | append: '/' | append: article.href %} - {% include article-card.html hub=hub.href href=article_href title=article.title %} + {% include article-card.html hub=hub.href href=article_href title=article.title platform=activePlatform %} {% endfor %}

{% endfor %} - -{% if hub.articles %} -
-
- {% for article in hub.articles %} - {% include article-card.html hub=hub.href href=article.href title=article.title %} - {% endfor %} -
-
-{% endif %} diff --git a/docs/_includes/lhn-article-link.html b/docs/_includes/lhn-article-link.html index f9c4f31f0dbe..91c0de4aacce 100644 --- a/docs/_includes/lhn-article-link.html +++ b/docs/_includes/lhn-article-link.html @@ -1,5 +1,5 @@
  • - + {{ include.title }}
  • diff --git a/docs/_includes/lhn-template.html b/docs/_includes/lhn-template.html index 0473e5da9e9c..015c8211e5b2 100644 --- a/docs/_includes/lhn-template.html +++ b/docs/_includes/lhn-template.html @@ -1,4 +1,5 @@ -{% assign activeHub = page.url | remove: "/hubs/" | remove: ".html" %} +{% assign activePlatform = page.url | replace:'/',' ' | truncatewords: 1 | remove:'...' %} +{% assign activeHub = page.url | remove: activePlatform | remove: "/hubs/" | remove: "/" | remove: ".html" %}
      - {% for hub in site.data.routes.hubs %} - {% if hub.href == activeHub %} + {% for platform in site.data.routes.platforms %} + {% if platform.href == activePlatform %}
    • -
        - - {% for section in hub.sections %} + {% for hub in platform.hubs %} +
          + {% if hub.href == activeHub %} + +
            + {% for article in hub.articles %} + {% include lhn-article-link.html platform=activePlatform hub=hub.href href=article.href title=article.title %} + {% endfor %} + + {% for section in hub.sections %} +
          • + {{ section.title }} +
              + {% for article in section.articles %} + {% assign article_href = section.href | append: '/' | append: article.href %} + {% include lhn-article-link.html platform=activePlatform hub=hub.href href=article_href title=article.title %} + {% endfor %} +
            +
          • + {% endfor %} +
          + {% else %}
        • - {{ section.title }} -
            - {% for article in section.articles %} - {% assign article_href = section.href | append: '/' | append: article.href %} - {% include lhn-article-link.html hub=hub.href href=article_href title=article.title %} - {% endfor %} -
          + + + {{ hub.title }} +
        • - {% endfor %} - - - {% for article in hub.articles %} - {% include lhn-article-link.html hub=hub.href href=article.href title=article.title %} - {% endfor %} + {% endif %}
        + {% endfor %} + {% else %}
      • - + - {{ hub.title }} + {{ platform.title }}
      • {% endif %} diff --git a/docs/_includes/platform-card.html b/docs/_includes/platform-card.html new file mode 100644 index 000000000000..d56a234a5c14 --- /dev/null +++ b/docs/_includes/platform-card.html @@ -0,0 +1,13 @@ +{% assign platform = site.data.routes.platforms | where: "href", include.href | first %} + + +
        +
        + {{ platform.href }} +
        +
        +

        {{ platform.title }}

        +

        {{ platform.description }}

        +
        +
        +
        diff --git a/docs/_includes/platform.html b/docs/_includes/platform.html new file mode 100644 index 000000000000..f3867ee4f5b7 --- /dev/null +++ b/docs/_includes/platform.html @@ -0,0 +1,18 @@ +{% assign selectedPlatform = page.url | remove: "/hubs/" | remove: "/" | remove: ".html" %} +{% assign platform = site.data.routes.platforms | where: "href", selectedPlatform | first %} +
        +

        {{ platform.hub-title }}

        + +

        {{ site.data.routes.home.description }}

        + +
        + {% for hub in platform.hubs %} + {% include hub-card.html hub=hub platform=selectedPlatform %} + {% endfor %} +
        + +
        + + {% include floating-concierge-button.html id="floating-concierge-button-global" %} +
        +
        diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 209d14de0f48..de3fbc203243 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -96,12 +96,5 @@

        Didn't find what you were looking for?

        {% include footer.html %}

    - - - diff --git a/docs/assets/images/accounting.svg b/docs/assets/images/accounting.svg new file mode 100644 index 000000000000..4398e9573747 --- /dev/null +++ b/docs/assets/images/accounting.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/bank-card.svg b/docs/assets/images/bank-card.svg new file mode 100644 index 000000000000..48da9af0d986 --- /dev/null +++ b/docs/assets/images/bank-card.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/envelope-receipt.svg b/docs/assets/images/envelope-receipt.svg new file mode 100644 index 000000000000..40f57cc4ebda --- /dev/null +++ b/docs/assets/images/envelope-receipt.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/gears.svg b/docs/assets/images/gears.svg new file mode 100644 index 000000000000..23621afc8008 --- /dev/null +++ b/docs/assets/images/gears.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/hand-card.svg b/docs/assets/images/hand-card.svg new file mode 100644 index 000000000000..779e6ff4184c --- /dev/null +++ b/docs/assets/images/hand-card.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/money-into-wallet.svg b/docs/assets/images/money-into-wallet.svg new file mode 100644 index 000000000000..d6d5b0e7d6e7 --- /dev/null +++ b/docs/assets/images/money-into-wallet.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/money-receipt.svg b/docs/assets/images/money-receipt.svg new file mode 100644 index 000000000000..379d56727e42 --- /dev/null +++ b/docs/assets/images/money-receipt.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/money-wings.svg b/docs/assets/images/money-wings.svg new file mode 100644 index 000000000000..c2155080f721 --- /dev/null +++ b/docs/assets/images/money-wings.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/monitor.svg b/docs/assets/images/monitor.svg new file mode 100644 index 000000000000..6e2580b4c9e8 --- /dev/null +++ b/docs/assets/images/monitor.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/docs/assets/images/workflow.svg b/docs/assets/images/workflow.svg new file mode 100644 index 000000000000..e5eac423cd1d --- /dev/null +++ b/docs/assets/images/workflow.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index f0f335536c20..aebd0f5d4864 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -58,10 +58,10 @@ function navigateBack() { return; } - const hubs = JSON.parse(document.getElementById('hubs-data').value); - const hubToNavigate = hubs.find((hub) => window.location.pathname.includes(hub)); // eslint-disable-line rulesdir/prefer-underscore-method - if (hubToNavigate) { - window.location.href = `/hubs/${hubToNavigate}`; + // Path name is of the form /articles/[platform]/[hub]/[resource] + const path = window.location.pathname.split('/'); + if (path[2] && path[3]) { + window.location.href = `/${path[2]}/hubs/${path[3]}`; } else { window.location.href = '/'; } diff --git a/docs/index.html b/docs/index.html index 74296c200971..70bd5f31545a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,15 +6,11 @@

    {{ site.data.routes.home.title }}

    {{ site.data.routes.home.description }}

    -

    - Which best describes how you use Expensify? -

    -
    - {% include hub-card.html href="split-bills" %} - {% include hub-card.html href="request-money" %} - {% include hub-card.html href="playbooks" %} - {% include hub-card.html href="other" %} + {% for platform in site.data.routes.platforms %} + {% assign platform_href = platform.href %} + {% include platform-card.html href=platform.platform_href %} + {% endfor %}
    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/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 441bd2feab92..7e2dab8bd288 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.72.9 + 1.3.72.10 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 27273c7f3866..c98e5cd85d0d 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.3.72.9 + 1.3.72.10 diff --git a/package-lock.json b/package-lock.json index c2e322bf8ba2..6fcf6ea9b31e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.72-9", + "version": "1.3.72-10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.72-9", + "version": "1.3.72-10", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a181de2bce16..2bbab25426f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.72-9", + "version": "1.3.72-10", "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.", @@ -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" }, 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 && } {({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/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/NewDatePicker/CalendarPicker/YearPickerModal.js b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js index 1149f9dc56ce..9825109fbb63 100644 --- a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js +++ b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js @@ -64,6 +64,7 @@ function YearPickerModal(props) { style={[styles.pb0]} includePaddingTop={false} includeSafeAreaPaddingBottom={false} + testID={YearPickerModal.displayName} > {}, 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/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} > { - if (!props.disableKeyboard) { - return; - } - - const appStateSubscription = AppState.addEventListener('change', (nextAppState) => { - if (!nextAppState.match(/inactive|background/)) { - return; - } - - Keyboard.dismiss(); - }); - - return () => { - appStateSubscription.remove(); - }; - }, [props.disableKeyboard]); - // AutoFocus which only works on mount: useEffect(() => { // We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514 diff --git a/src/components/TextInput/index.native.js b/src/components/TextInput/index.native.js index eb9970f2261f..059550855c0a 100644 --- a/src/components/TextInput/index.native.js +++ b/src/components/TextInput/index.native.js @@ -1,19 +1,40 @@ -import React, {forwardRef} from 'react'; +import React, {forwardRef, useEffect} from 'react'; +import {AppState, Keyboard} from 'react-native'; import styles from '../../styles/styles'; import BaseTextInput from './BaseTextInput'; import * as baseTextInputPropTypes from './baseTextInputPropTypes'; -const TextInput = forwardRef((props, ref) => ( - -)); +const TextInput = forwardRef((props, ref) => { + useEffect(() => { + if (!props.disableKeyboard) { + return; + } + + const appStateSubscription = AppState.addEventListener('change', (nextAppState) => { + if (!nextAppState.match(/inactive|background/)) { + return; + } + + Keyboard.dismiss(); + }); + + return () => { + appStateSubscription.remove(); + }; + }, [props.disableKeyboard]); + + return ( + + ); +}); TextInput.propTypes = baseTextInputPropTypes.propTypes; TextInput.defaultProps = baseTextInputPropTypes.defaultProps; diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js index f0cee6fdea2f..5e22a74fa37e 100644 --- a/src/components/ThreeDotsMenu/index.js +++ b/src/components/ThreeDotsMenu/index.js @@ -1,5 +1,5 @@ import React, {useState, useRef} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import Icon from '../Icon'; @@ -46,6 +46,9 @@ const propTypes = { vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), }), + /** Function to call on modal hide */ + onModalHide: PropTypes.func, + /** Whether the popover menu should overlay the current view */ shouldOverlay: PropTypes.bool, }; @@ -60,10 +63,11 @@ const defaultProps = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP }, + onModalHide: () => {}, 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/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/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 a5af66f08460..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; + } + + // 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 (third element in the array) and return those + // 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], }; diff --git a/src/libs/UpdateMultilineInputRange/index.ios.js b/src/libs/UpdateMultilineInputRange/index.ios.js new file mode 100644 index 000000000000..85ed529a33bc --- /dev/null +++ b/src/libs/UpdateMultilineInputRange/index.ios.js @@ -0,0 +1,23 @@ +/** + * Place the cursor at the end of the value (if there is a value in the input). + * + * When a multiline input contains a text value that goes beyond the scroll height, the cursor will be placed + * at the end of the text value, and automatically scroll the input field to this position after the field gains + * focus. This provides a better user experience in cases where the text in the field has to be edited. The auto- + * scroll behaviour works on all platforms except iOS native. + * See https://github.com/Expensify/App/issues/20836 for more details. + * + * @param {Object} input the input element + */ +export default function updateMultilineInputRange(input) { + if (!input) { + return; + } + + /* + * Adding this iOS specific patch because of the scroll issue in native iOS + * Issue: does not scroll multiline input when text exceeds the maximum number of lines + * For more details: https://github.com/Expensify/App/pull/27702#issuecomment-1728651132 + */ + input.focus(); +} diff --git a/src/libs/focusAndUpdateMultilineInputRange.js b/src/libs/UpdateMultilineInputRange/index.js similarity index 80% rename from src/libs/focusAndUpdateMultilineInputRange.js rename to src/libs/UpdateMultilineInputRange/index.js index b5e438899d3d..179d30dc611d 100644 --- a/src/libs/focusAndUpdateMultilineInputRange.js +++ b/src/libs/UpdateMultilineInputRange/index.js @@ -1,5 +1,5 @@ /** - * Focus a multiline text input and place the cursor at the end of the value (if there is a value in the input). + * Place the cursor at the end of the value (if there is a value in the input). * * When a multiline input contains a text value that goes beyond the scroll height, the cursor will be placed * at the end of the text value, and automatically scroll the input field to this position after the field gains @@ -9,12 +9,11 @@ * * @param {Object} input the input element */ -export default function focusAndUpdateMultilineInputRange(input) { +export default function updateMultilineInputRange(input) { if (!input) { return; } - input.focus(); if (input.value && input.setSelectionRange) { const length = input.value.length; input.setSelectionRange(length, length); diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 36d512c8d843..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 = [ @@ -1227,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/migrateOnyx.js b/src/libs/migrateOnyx.js index c4383f37a273..4a115536e654 100644 --- a/src/libs/migrateOnyx.js +++ b/src/libs/migrateOnyx.js @@ -1,7 +1,6 @@ import _ from 'underscore'; import Log from './Log'; 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'; @@ -13,7 +12,7 @@ export default function () { return new Promise((resolve) => { // Add all migrations to an array so they are executed in order - const migrationPromises = [MoveToIndexedDB, RenamePriorityModeKey, RenameExpensifyNewsStatus, AddLastVisibleActionCreated, PersonalDetailsByAccountID, RenameReceiptFilename]; + const migrationPromises = [RenamePriorityModeKey, 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/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} > +
    { + focusTimeoutRef.current = setTimeout(() => { + if (descriptionInputRef.current) { + descriptionInputRef.current.focus(); + } + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, CONST.ANIMATED_TRANSITION); + }, []), + ); + return ( descriptionInputRef.current && descriptionInputRef.current.focus()} + testID={EditRequestDescriptionPage.displayName} > (descriptionInputRef.current = e)} + ref={(el) => { + if (!el) { + return; + } + descriptionInputRef.current = el; + updateMultilineInputRange(descriptionInputRef.current); + }} autoGrowHeight containerStyles={[styles.autoGrowHeightMultilineInput]} textAlignVertical="top" diff --git a/src/pages/EditRequestMerchantPage.js b/src/pages/EditRequestMerchantPage.js index 7e7791fa3212..7e15a88a3ab4 100644 --- a/src/pages/EditRequestMerchantPage.js +++ b/src/pages/EditRequestMerchantPage.js @@ -38,6 +38,7 @@ function EditRequestMerchantPage({defaultMerchant, onSubmit}) { includeSafeAreaPaddingBottom={false} shouldEnableMaxHeight onEntryTransitionEnd={() => merchantInputRef.current && merchantInputRef.current.focus()} + testID={EditRequestMerchantPage.displayName} > + {() => { if (this.props.userWallet.errorCode === CONST.WALLET.ERROR.KYC) { return ( diff --git a/src/pages/ErrorPage/NotFoundPage.js b/src/pages/ErrorPage/NotFoundPage.js index f0121cd8f3ef..445e81296566 100644 --- a/src/pages/ErrorPage/NotFoundPage.js +++ b/src/pages/ErrorPage/NotFoundPage.js @@ -5,7 +5,7 @@ import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoun // eslint-disable-next-line rulesdir/no-negated-variables function NotFoundPage() { return ( - + ); diff --git a/src/pages/FlagCommentPage.js b/src/pages/FlagCommentPage.js index 1a9a2d8c8767..53da810007ea 100644 --- a/src/pages/FlagCommentPage.js +++ b/src/pages/FlagCommentPage.js @@ -154,7 +154,10 @@ function FlagCommentPage(props) { )); return ( - + {({safeAreaPaddingBottomStyle}) => ( diff --git a/src/pages/GetAssistancePage.js b/src/pages/GetAssistancePage.js index 34f996936654..97b498d758b7 100644 --- a/src/pages/GetAssistancePage.js +++ b/src/pages/GetAssistancePage.js @@ -78,7 +78,7 @@ function GetAssistancePage(props) { } return ( - + Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index cb54aa8e5a7b..c3b66910face 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -173,6 +173,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate}) includeSafeAreaPaddingBottom={false} includePaddingTop={false} shouldEnableMaxHeight + testID={NewChatPage.displayName} > {({safeAreaPaddingBottomStyle, insets}) => ( { + focusTimeoutRef.current = setTimeout(() => { + if (privateNotesInput.current) { + privateNotesInput.current.focus(); + } + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, CONST.ANIMATED_TRANSITION); + }, []), + ); const savePrivateNote = () => { const editedNote = parser.replace(privateNote); @@ -79,7 +97,7 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { return ( focusAndUpdateMultilineInputRange(privateNotesInput.current)} + testID={PrivateNotesEditPage.displayName} > setPrivateNote(text)} - ref={(el) => (privateNotesInput.current = el)} + ref={(el) => { + if (!el) { + return; + } + privateNotesInput.current = el; + updateMultilineInputRange(privateNotesInput.current); + }} /> diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.js b/src/pages/PrivateNotes/PrivateNotesListPage.js index 098bfd2a245b..fdfaa4c60e33 100644 --- a/src/pages/PrivateNotes/PrivateNotesListPage.js +++ b/src/pages/PrivateNotes/PrivateNotesListPage.js @@ -117,7 +117,10 @@ function PrivateNotesListPage({report, personalDetailsList, network, session}) { }, [report, personalDetailsList, session, translate]); return ( - + Navigation.dismissModal()} /> {report.isLoadingPrivateNotes && _.isEmpty(lodashGet(report, 'privateNotes', {})) ? ( - + ) : ( _.map(privateNotes, (item, index) => getMenuItem(item, index)) )} diff --git a/src/pages/PrivateNotes/PrivateNotesViewPage.js b/src/pages/PrivateNotes/PrivateNotesViewPage.js index 48f053f10f90..4c6d960d5d9a 100644 --- a/src/pages/PrivateNotes/PrivateNotesViewPage.js +++ b/src/pages/PrivateNotes/PrivateNotesViewPage.js @@ -56,7 +56,10 @@ function PrivateNotesViewPage({route, personalDetailsList, session, report}) { const privateNote = lodashGet(report, ['privateNotes', route.params.accountID, 'note'], ''); return ( - + { function ProfilePage(props) { const accountID = Number(lodashGet(props.route.params, 'accountID', 0)); - // eslint-disable-next-line rulesdir/prefer-early-return - useEffect(() => { - if (ValidationUtils.isValidAccountRoute(accountID)) { - PersonalDetails.openPublicProfilePage(accountID); - } - }, [accountID]); - const details = lodashGet(props.personalDetails, accountID, ValidationUtils.isValidAccountRoute(accountID) ? {} : {isloading: false}); const displayName = details.displayName ? details.displayName : props.translate('common.hidden'); @@ -143,8 +136,15 @@ function ProfilePage(props) { const chatReportWithCurrentUser = !isCurrentUser && !Session.isAnonymousUser() ? ReportUtils.getChatByParticipants([accountID]) : 0; + // eslint-disable-next-line rulesdir/prefer-early-return + useEffect(() => { + if (ValidationUtils.isValidAccountRoute(accountID) && !hasMinimumDetails) { + PersonalDetails.openPublicProfilePage(accountID); + } + }, [accountID, hasMinimumDetails]); + return ( - + Navigation.goBack(navigateBackTo)} diff --git a/src/pages/ReimbursementAccount/ACHContractStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js index 1ef43843571c..761be71d864a 100644 --- a/src/pages/ReimbursementAccount/ACHContractStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -145,7 +145,10 @@ function ACHContractStep(props) { }; return ( - + + + + + + Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} @@ -385,7 +385,7 @@ class ReimbursementAccountPage extends React.Component { if (errorText) { return ( - + + + diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index 6ccb7a0c2e87..c10401fb90db 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -99,7 +99,10 @@ function ReportParticipantsPage(props) { })); return ( - + {({safeAreaPaddingBottomStyle}) => ( { setWelcomeMessage(value); @@ -54,55 +56,58 @@ function ReportWelcomeMessagePage(props) { Report.updateWelcomeMessage(props.report.reportID, props.report.welcomeMessage, welcomeMessage.trim()); }, [props.report.reportID, props.report.welcomeMessage, welcomeMessage]); - return ( - { - if (!welcomeMessageInputRef.current) { - return; + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => { + if (welcomeMessageInputRef.current) { + welcomeMessageInputRef.current.focus(); } - focusAndUpdateMultilineInputRange(welcomeMessageInputRef.current); - }} - > - {({didScreenTransitionEnd}) => ( - - -
    - {props.translate('welcomeMessagePage.explainerText')} - - { - // Before updating the DOM, React sets the affected ref.current values to null. After updating the DOM, React immediately sets them to the corresponding DOM nodes - // to avoid focus multiple time, we should early return if el is null. - if (!el) { - return; - } - if (!welcomeMessageInputRef.current && didScreenTransitionEnd) { - focusAndUpdateMultilineInputRange(el); - } - welcomeMessageInputRef.current = el; - }} - value={welcomeMessage} - onChangeText={handleWelcomeMessageChange} - autoCapitalize="none" - textAlignVertical="top" - containerStyles={[styles.autoGrowHeightMultilineInput]} - /> - -
    -
    - )} + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, CONST.ANIMATED_TRANSITION); + }, []), + ); + + return ( + + + +
    + {props.translate('welcomeMessagePage.explainerText')} + + { + if (!el) { + return; + } + welcomeMessageInputRef.current = el; + updateMultilineInputRange(welcomeMessageInputRef.current); + }} + value={welcomeMessage} + onChangeText={handleWelcomeMessageChange} + autoCapitalize="none" + textAlignVertical="top" + containerStyles={[styles.autoGrowHeightMultilineInput]} + /> + +
    +
    ); } diff --git a/src/pages/SearchPage.js b/src/pages/SearchPage.js index 2ee29380ff80..141f4e841853 100755 --- a/src/pages/SearchPage.js +++ b/src/pages/SearchPage.js @@ -169,7 +169,10 @@ class SearchPage extends Component { ); return ( - + {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( <> diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js index a36149a5f4fa..f19c79db5459 100644 --- a/src/pages/ShareCodePage.js +++ b/src/pages/ShareCodePage.js @@ -54,7 +54,7 @@ class ShareCodePage extends React.Component { const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; return ( - + Navigation.goBack(isReport ? ROUTES.getReportDetailsRoute(this.props.report.reportID) : ROUTES.SETTINGS)} diff --git a/src/pages/TeachersUnite/ImTeacherPage.js b/src/pages/TeachersUnite/ImTeacherPage.js index dbeba700d208..a938abf81b79 100644 --- a/src/pages/TeachersUnite/ImTeacherPage.js +++ b/src/pages/TeachersUnite/ImTeacherPage.js @@ -1,54 +1,36 @@ import React from 'react'; -import ScreenWrapper from '../../components/ScreenWrapper'; -import HeaderWithBackButton from '../../components/HeaderWithBackButton'; -import ROUTES from '../../ROUTES'; -import Navigation from '../../libs/Navigation/Navigation'; -import FixedFooter from '../../components/FixedFooter'; -import styles from '../../styles/styles'; -import Button from '../../components/Button'; -import * as Illustrations from '../../components/Icon/Illustrations'; -import variables from '../../styles/variables'; -import useLocalize from '../../hooks/useLocalize'; -import BlockingView from '../../components/BlockingViews/BlockingView'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as LoginUtils from '../../libs/LoginUtils'; +import ImTeacherUpdateEmailPage from './ImTeacherUpdateEmailPage'; +import IntroSchoolPrincipalPage from './IntroSchoolPrincipalPage'; -const propTypes = {}; +const propTypes = { + /** Current user session */ + session: PropTypes.shape({ + /** Current user primary login */ + email: PropTypes.string.isRequired, + }), +}; -const defaultProps = {}; +const defaultProps = { + session: { + email: null, + }, +}; -function ImTeacherPage() { - const {translate} = useLocalize(); - - return ( - - Navigation.goBack(ROUTES.TEACHERS_UNITE)} - /> - Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS)} - iconWidth={variables.signInLogoWidthLargeScreen} - iconHeight={variables.lhnLogoWidth} - /> - -