diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9e0f633ae34e..78040f237689 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,6 +9,12 @@ jobs: runs-on: ubuntu-latest if: github.ref == 'refs/heads/staging' steps: + - name: Checkout staging branch + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + with: + ref: staging + token: ${{ secrets.OS_BOTIFY_TOKEN }} + - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab id: setupGitForOSBotify with: @@ -16,12 +22,6 @@ jobs: OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} - - name: Checkout staging branch - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 - with: - ref: staging - token: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - - name: Tag version run: git tag "$(npm run print-version --silent)" @@ -32,6 +32,12 @@ jobs: runs-on: ubuntu-latest if: github.ref == 'refs/heads/production' steps: + - uses: actions/checkout@v3 + name: Checkout + with: + ref: production + token: ${{ secrets.OS_BOTIFY_TOKEN }} + - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab id: setupGitForOSBotify with: @@ -39,13 +45,6 @@ jobs: OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} - - uses: actions/checkout@v3 - - name: Checkout - uses: actions/checkout@v3 - with: - ref: production - token: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - - name: Get current app version run: echo "PRODUCTION_VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index f8b68786aaab..4fe6249edacc 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -34,13 +34,13 @@ jobs: echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" fi env: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Reopen and comment on issue (not a team member) if: ${{ !fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) }} uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main with: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} COMMENT: | Sorry, only members of @Expensify/Mobile-Deployers can close deploy checklists. @@ -51,14 +51,14 @@ jobs: id: checkDeployBlockers uses: Expensify/App/.github/actions/javascript/checkDeployBlockers@main with: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} - name: Reopen and comment on issue (has blockers) if: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS || 'false') }} uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main with: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} COMMENT: | This issue either has unchecked items or has not yet been marked with the `:shipit:` emoji of approval. diff --git a/android/app/build.gradle b/android/app/build.gradle index 8e5044fb9527..27bceccc6d32 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 1001038110 - versionName "1.3.81-10" + versionCode 1001038301 + versionName "1.3.83-1" } flavorDimensions "default" diff --git a/assets/images/new-expensify-adhoc.svg b/assets/images/new-expensify-adhoc.svg index 26f18c8cc088..d3a926a097ec 100644 --- a/assets/images/new-expensify-adhoc.svg +++ b/assets/images/new-expensify-adhoc.svg @@ -1,6 +1,6 @@ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index cb04e9c1ef90..fd6ad177d56b 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.81 + 1.3.83 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.81.10 + 1.3.83.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index e597c10142d8..af1ff6f92259 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.81 + 1.3.83 CFBundleSignature ???? CFBundleVersion - 1.3.81.10 + 1.3.83.1 diff --git a/package-lock.json b/package-lock.json index e61b21504b7b..13ae50b2d537 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.81-10", + "version": "1.3.83-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.81-10", + "version": "1.3.83-1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 57060257f0e9..0c88994fc29e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.81-10", + "version": "1.3.83-1", "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.", @@ -8,6 +8,8 @@ "private": true, "scripts": { "configure-mapbox": "scripts/setup-mapbox-sdk-walkthrough.sh", + "setupNewDotWebForEmulators": "scripts/setup-newdot-web-emulators.sh", + "startAndroidEmulator": "scripts/start-android.sh", "postinstall": "scripts/postInstall.sh", "clean": "npx react-native clean-project-auto", "android": "scripts/set-pusher-suffix.sh && npx react-native run-android --variant=developmentDebug --appId=com.expensify.chat.dev", diff --git a/scripts/select-device.sh b/scripts/select-device.sh new file mode 100755 index 000000000000..a53a6034d3da --- /dev/null +++ b/scripts/select-device.sh @@ -0,0 +1,133 @@ +#!/bin/bash + +# Utility script for iOS and Android Emulators +# ============================================ +# +# Purpose: +# -------- +# This script helps to start and kill iOS simulators and Android emulators instances. +# +# How this script helps: +# ---------------------- +# This script streamlines the process of starting and killing on both android and ios +# platforms. + +# Use functions and variables from the utils script +source scripts/shellUtils.sh + +select_device_ios() +{ + # shellcheck disable=SC2124 + IFS="$@" arr=$(xcrun xctrace list devices | grep -E "iPhone|iPad") + + # Create arrays to store device names and identifiers + device_names=() + device_identifiers=() + + # Parse the device list and populate the arrays + while IFS= read -r line; do + if [[ $line =~ ^(.*)\ \((.*)\)\ \((.*)\)$ ]]; then + device="${BASH_REMATCH[1]}" + version="${BASH_REMATCH[2]}" + identifier="${BASH_REMATCH[3]}" + device_identifiers+=("$identifier") + device_names+=("$device Version: $version") + else + info "Input does not match the expected pattern." + echo "$line" + fi + done <<< "$arr" + if [ ${#device_names[@]} -eq 0 ]; then + error "No devices detected, please create one." + exit 1 + fi + if [ ${#device_names[@]} -eq 1 ]; then + device_identifier="${device_identifiers[0]}" + success "Single device detected, launching ${device_names[0]}" + open -a Simulator --args -CurrentDeviceUDID "$device_identifier" + return + fi + info "Multiple devices detected, please select one from the list." + PS3='Please enter your choice: ' + select device_name in "${device_names[@]}"; do + if [ -n "$device_name" ]; then + selected_index=$((REPLY - 1)) + device_name_for_display="${device_names[$selected_index]}" + device_identifier="${device_identifiers[$selected_index]}" + break + else + echo "Invalid selection. Please choose a device." + fi + done + success "Launching $device_name_for_display" + open -a Simulator --args -CurrentDeviceUDID "$device_identifier" +} + +kill_all_emulators_ios() { + # kill all the emulators + killall Simulator +} + +restart_adb_server() { + info "Restarting adb ..." + adb kill-server + sleep 2 + adb start-server + sleep 2 + info "Restarting adb done" +} + +ensure_device_available() { + # Must turn off exit on error temporarily + set +e + if adb devices | grep -q offline; then + restart_adb_server + if adb devices | grep -q offline; then + error "Device remains 'offline'. Please investigate!" + exit 1 + fi + fi + set -e +} + +select_device_android() +{ + # shellcheck disable=SC2124 + IFS="$@" arr=$(emulator -list-avds) + + # Create arrays to store device names + device_names=() + + # Parse the device list and populate the arrays + while IFS= read -r line; do + device_names+=("$line") + done <<< "$arr" + if [ ${#device_names[@]} -eq 0 ]; then + error "No devices detected, please create one." + exit 1 + fi + if [ ${#device_names[@]} -eq 1 ]; then + device_identifier="${device_names[0]}" + success "Single device detected, launching $device_identifier" + emulator -avd "$device_identifier" -writable-system > /dev/null 2>&1 & + return + fi + info "Multiple devices detected, please select one from the list." + PS3='Please enter your choice: ' + select device_name in "${device_names[@]}"; do + if [ -n "$device_name" ]; then + selected_index=$((REPLY - 1)) + device_identifier="${device_names[$selected_index]}" + break + else + echo "Invalid selection. Please choose a device." + fi + done + success "Launching $device_identifier" + emulator -avd "$device_identifier" -writable-system > /dev/null 2>&1 & +} + +kill_all_emulators_android() { + # kill all the emulators + adb devices | grep emulator | cut -f1 | while read -r line; do adb -s "$line" emu kill; done +} diff --git a/scripts/setup-mapbox-sdk-walkthrough.sh b/scripts/setup-mapbox-sdk-walkthrough.sh index 20b79641fc42..46b970f2d462 100755 --- a/scripts/setup-mapbox-sdk-walkthrough.sh +++ b/scripts/setup-mapbox-sdk-walkthrough.sh @@ -21,7 +21,7 @@ # To configure Mapbox, invoke this script by running the following command from the project's root directory: # npm run configure-mapbox -# Use functions and varaibles from the utils script +# Use functions and variables from the utils script source scripts/shellUtils.sh # Intro message diff --git a/scripts/setup-mapbox-sdk.sh b/scripts/setup-mapbox-sdk.sh index 06fd75fba299..fa37cc2fbbad 100755 --- a/scripts/setup-mapbox-sdk.sh +++ b/scripts/setup-mapbox-sdk.sh @@ -36,7 +36,7 @@ # To run this script, pass the secret Mapbox access token as a command-line argument: # ./scriptname.sh YOUR_MAPBOX_ACCESS_TOKEN -# Use functions and varaibles from the utils script +# Use functions and variables from the utils script source scripts/shellUtils.sh NETRC_PATH="$HOME/.netrc" diff --git a/scripts/setup-newdot-web-emulators.sh b/scripts/setup-newdot-web-emulators.sh new file mode 100755 index 000000000000..a0ed1422d2a9 --- /dev/null +++ b/scripts/setup-newdot-web-emulators.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +# NewDot Web Configuration Script for iOS and Android Emulators +# ============================================================= +# +# Purpose: +# -------- +# This script configures Configure iOS simulators and Android emulators to connect to +# new.expensify.com.dev over https for local development. +# +# Background: +# ----------- +# We plan to change the URL to serve the App on the development environment from +# localhost:8082 to new.expensify.com.dev. This can be accomplished by adding a new entry +# to the laptop's hosts file along with changes made in the PR. +# However, we're not sure how we can access the App on Safari or Chrome on iOS or Android +# simulators. +# +# How this script helps: +# ---------------------- +# This script streamlines the process of adding the certificates to both android and +# ios platforms. +# +# Usage: +# ------ +# To run this script, pass the platform on which you want to run as a command-line +# argument which can be the following: +# 1. ios +# 2. android +# 3. all (default) +# ./setup-newdot-web-emulators.sh platform + +# Use functions and variables from the utils script +source scripts/shellUtils.sh + +# Use functions to select and open the emulator for iOS and Android +source scripts/select-device.sh + +if [ $# -eq 0 ]; then + platform="all" +else + platform=$1 +fi + +setup_ios() +{ + select_device_ios + sleep 30 + info "Installing certificates on iOS simulator" + xcrun simctl keychain booted add-root-cert "$(mkcert -CAROOT)/rootCA.pem" + kill_all_emulators_ios +} + +setup_android_path_1() +{ + adb root || { + error "Failed to restart adb as root" + info "You may want to check https://stackoverflow.com/a/45668555" + exit 1 + } + sleep 2 + adb remount + adb push /etc/hosts /system/etc/hosts + kill_all_emulators_android +} + +setup_android_path_2() +{ + adb root || { + error "Failed to restart adb as root" + info "You may want to check https://stackoverflow.com/a/45668555" + exit 1 + } + sleep 2 + adb disable-verity + adb reboot + sleep 30 + ensure_device_available + adb root + sleep 2 + adb remount + adb push /etc/hosts /system/etc/hosts + kill_all_emulators_android +} + +setup_android() +{ + select_device_android + sleep 30 + ensure_device_available + info "Installing certificates on Android emulator" + setup_android_path_1 || { + info "Looks like the system partition is read-only" + info "Trying another method to install the certificates" + setup_android_path_2 + } +} + +if [ "$platform" = "ios" ] || [ "$platform" = "all" ]; then + setup_ios || { + error "Failed to setup iOS simulator" + exit 1 + } +fi + +if [ "$platform" = "android" ] || [ "$platform" = "all" ]; then + setup_android || { + error "Failed to setup Android emulator" + exit 1 + } +fi + +success "Done!" diff --git a/scripts/shellUtils.sh b/scripts/shellUtils.sh index 4c9e2febc34d..848e6d238254 100644 --- a/scripts/shellUtils.sh +++ b/scripts/shellUtils.sh @@ -1,10 +1,29 @@ #!/bin/bash -declare -r GREEN=$'\e[1;32m' -declare -r RED=$'\e[1;31m' -declare -r BLUE=$'\e[1;34m' -declare -r TITLE=$'\e[1;4;34m' -declare -r RESET=$'\e[0m' +# Check if GREEN has already been defined +if [ -z "${GREEN+x}" ]; then + declare -r GREEN=$'\e[1;32m' +fi + +# Check if RED has already been defined +if [ -z "${RED+x}" ]; then + declare -r RED=$'\e[1;31m' +fi + +# Check if BLUE has already been defined +if [ -z "${BLUE+x}" ]; then + declare -r BLUE=$'\e[1;34m' +fi + +# Check if TITLE has already been defined +if [ -z "${TITLE+x}" ]; then + declare -r TITLE=$'\e[1;4;34m' +fi + +# Check if RESET has already been defined +if [ -z "${RESET+x}" ]; then + declare -r RESET=$'\e[0m' +fi function success { echo "🎉 $GREEN$1$RESET" diff --git a/scripts/start-android.sh b/scripts/start-android.sh new file mode 100644 index 000000000000..b9a4e08a07a2 --- /dev/null +++ b/scripts/start-android.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Script for starting android emulator correctly + +# Use functions to select and open the emulator for iOS and Android +source scripts/select-device.sh + +select_device_android +sleep 30 +ensure_device_available +adb reverse tcp:8082 tcp:8082 \ No newline at end of file diff --git a/src/components/Hoverable/hoverablePropTypes.js b/src/components/Hoverable/hoverablePropTypes.js index d483a06d6aaf..a3aeaa597d7a 100644 --- a/src/components/Hoverable/hoverablePropTypes.js +++ b/src/components/Hoverable/hoverablePropTypes.js @@ -13,6 +13,12 @@ const propTypes = { /** Function that executes when the mouse leaves the children. */ onHoverOut: PropTypes.func, + /** Direct pass-through of React's onMouseEnter event. */ + onMouseEnter: PropTypes.func, + + /** Direct pass-through of React's onMouseLeave event. */ + onMouseLeave: PropTypes.func, + /** Decides whether to handle the scroll behaviour to show hover once the scroll ends */ shouldHandleScroll: PropTypes.bool, }; diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js index 38ea64952a2c..5cba52db5a7b 100644 --- a/src/components/Hoverable/index.js +++ b/src/components/Hoverable/index.js @@ -155,6 +155,10 @@ class Hoverable extends Component { } }, onMouseEnter: (el) => { + if (_.isFunction(this.props.onMouseEnter)) { + this.props.onMouseEnter(el); + } + this.setIsHovered(true); if (_.isFunction(child.props.onMouseEnter)) { @@ -162,6 +166,10 @@ class Hoverable extends Component { } }, onMouseLeave: (el) => { + if (_.isFunction(this.props.onMouseLeave)) { + this.props.onMouseLeave(el); + } + this.setIsHovered(false); if (_.isFunction(child.props.onMouseLeave)) { diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index 8a2005e64c22..0f2d19d424b6 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -19,6 +19,7 @@ import ConfirmModal from './ConfirmModal'; import useLocalize from '../hooks/useLocalize'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; import * as TransactionUtils from '../libs/TransactionUtils'; +import * as HeaderUtils from '../libs/HeaderUtils'; import reportActionPropTypes from '../pages/home/report/reportActionPropTypes'; import transactionPropTypes from './transactionPropTypes'; import useWindowDimensions from '../hooks/useWindowDimensions'; @@ -80,7 +81,6 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, }, [parentReportAction, setIsDeleteModalVisible]); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); - const canModifyRequest = isActionOwner && !isSettled && !isApproved; useEffect(() => { @@ -90,6 +90,21 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, setIsDeleteModalVisible(false); }, [canModifyRequest]); + const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(report)]; + if (canModifyRequest) { + if (!TransactionUtils.hasReceipt(transaction)) { + threeDotsMenuItems.push({ + icon: Expensicons.Receipt, + text: translate('receipt.addReceipt'), + onSelected: () => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT)), + }); + } + threeDotsMenuItems.push({ + icon: Expensicons.Trashcan, + text: translate('reportActionContextMenu.deleteAction', {action: parentReportAction}), + onSelected: () => setIsDeleteModalVisible(true), + }); + } return ( <> @@ -97,23 +112,8 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT)), - }, - ]), - { - icon: Expensicons.Trashcan, - text: translate('reportActionContextMenu.deleteAction', {action: parentReportAction}), - onSelected: () => setIsDeleteModalVisible(true), - }, - ]} + shouldShowThreeDotsButton + threeDotsMenuItems={threeDotsMenuItems} threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)} report={{ ...report, diff --git a/src/components/Tooltip/BaseTooltip.js b/src/components/Tooltip/BaseTooltip.js index f60982f52dd4..f8d1cf87fc42 100644 --- a/src/components/Tooltip/BaseTooltip.js +++ b/src/components/Tooltip/BaseTooltip.js @@ -2,6 +2,7 @@ import _ from 'underscore'; import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; import {Animated} from 'react-native'; import {BoundsObserver} from '@react-ng/bounds-observer'; +import Str from 'expensify-common/lib/str'; import TooltipRenderedOnPageBody from './TooltipRenderedOnPageBody'; import Hoverable from '../Hoverable'; import * as tooltipPropTypes from './tooltipPropTypes'; @@ -19,9 +20,41 @@ const hasHoverSupport = DeviceCapabilities.hasHoverSupport(); * @param {propTypes} props * @returns {ReactNodeLike} */ -function Tooltip(props) { - const {children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey} = props; +/** + * Choose the correct bounding box for the tooltip to be positioned against. + * This handles the case where the target is wrapped across two lines, and + * so we need to find the correct part (the one that the user is hovering + * over) and show the tooltip there. + * + * @param {Element} target The DOM element being hovered over. + * @param {number} clientX The X position from the MouseEvent. + * @param {number} clientY The Y position from the MouseEvent. + * @return {DOMRect} The chosen bounding box. + */ + +function chooseBoundingBox(target, clientX, clientY) { + const slop = 5; + const bbs = target.getClientRects(); + const clientXMin = clientX - slop; + const clientXMax = clientX + slop; + const clientYMin = clientY - slop; + const clientYMax = clientY + slop; + + for (let i = 0; i < bbs.length; i++) { + const bb = bbs[i]; + if (clientXMin <= bb.right && clientXMax >= bb.left && clientYMin <= bb.bottom && clientYMax >= bb.top) { + return bb; + } + } + + // If no matching bounding box is found, fall back to the first one. + // This could only happen if the user is moving the mouse very quickly + // and they got it outside our slop above. + return bbs[0]; +} + +function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey, shouldHandleScroll, shiftHorizontal, shiftVertical}) { const {preferredLocale} = useLocalize(); const {windowWidth} = useWindowDimensions(); @@ -43,6 +76,14 @@ function Tooltip(props) { const isAnimationCanceled = useRef(false); const prevText = usePrevious(text); + const target = useRef(null); + const initialMousePosition = useRef({x: 0, y: 0}); + + const updateTargetAndMousePosition = useCallback((e) => { + target.current = e.target; + initialMousePosition.current = {x: e.clientX, y: e.clientY}; + }, []); + /** * Display the tooltip in an animation. */ @@ -91,10 +132,15 @@ function Tooltip(props) { if (bounds.width === 0) { setIsRendered(false); } - setWrapperWidth(bounds.width); - setWrapperHeight(bounds.height); - setXOffset(bounds.x); - setYOffset(bounds.y); + // Choose a bounding box for the tooltip to target. + // In the case when the target is a link that has wrapped onto + // multiple lines, we want to show the tooltip over the part + // of the link that the user is hovering over. + const betterBounds = chooseBoundingBox(target.current, initialMousePosition.current.x, initialMousePosition.current.y); + setWrapperWidth(betterBounds.width); + setWrapperHeight(betterBounds.height); + setXOffset(betterBounds.x); + setYOffset(betterBounds.y); }; /** @@ -136,8 +182,8 @@ function Tooltip(props) { yOffset={yOffset} targetWidth={wrapperWidth} targetHeight={wrapperHeight} - shiftHorizontal={_.result(props, 'shiftHorizontal')} - shiftVertical={_.result(props, 'shiftVertical')} + shiftHorizontal={Str.result(shiftHorizontal)} + shiftVertical={Str.result(shiftVertical)} text={text} maxWidth={maxWidth} numberOfLines={numberOfLines} @@ -152,9 +198,10 @@ function Tooltip(props) { onBoundsChange={updateBounds} > {children} @@ -165,4 +212,6 @@ function Tooltip(props) { Tooltip.propTypes = tooltipPropTypes.propTypes; Tooltip.defaultProps = tooltipPropTypes.defaultProps; +Tooltip.displayName = 'Tooltip'; + export default memo(Tooltip); diff --git a/src/components/withViewportOffsetTop.js b/src/components/withViewportOffsetTop.js index 113c72ed1e1a..ccf928b3bd13 100644 --- a/src/components/withViewportOffsetTop.js +++ b/src/components/withViewportOffsetTop.js @@ -1,4 +1,4 @@ -import React, {Component} from 'react'; +import React, {useEffect, forwardRef, useState} from 'react'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; import getComponentDisplayName from '../libs/getComponentDisplayName'; @@ -13,43 +13,33 @@ const viewportOffsetTopPropTypes = { }; export default function (WrappedComponent) { - class WithViewportOffsetTop extends Component { - constructor(props) { - super(props); - - this.updateDimensions = this.updateDimensions.bind(this); - - this.state = { - viewportOffsetTop: 0, + function WithViewportOffsetTop(props) { + const [viewportOffsetTop, setViewportOffsetTop] = useState(0); + + useEffect(() => { + /** + * @param {SyntheticEvent} e + */ + const updateDimensions = (e) => { + const targetOffsetTop = lodashGet(e, 'target.offsetTop', 0); + setViewportOffsetTop(targetOffsetTop); }; - } - - componentDidMount() { - this.removeViewportResizeListener = addViewportResizeListener(this.updateDimensions); - } - componentWillUnmount() { - this.removeViewportResizeListener(); - } + const removeViewportResizeListener = addViewportResizeListener(updateDimensions); - /** - * @param {SyntheticEvent} e - */ - updateDimensions(e) { - const viewportOffsetTop = lodashGet(e, 'target.offsetTop', 0); - this.setState({viewportOffsetTop}); - } - - render() { - return ( - - ); - } + return () => { + removeViewportResizeListener(); + }; + }, []); + + return ( + + ); } WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`; @@ -59,7 +49,7 @@ export default function (WrappedComponent) { WithViewportOffsetTop.defaultProps = { forwardedRef: undefined, }; - return React.forwardRef((props, ref) => ( + return forwardRef((props, ref) => ( Report.togglePinnedState(report.reportID, report.isPinned)), + }; + } + return { + icon: Expensicons.Pin, + iconFill: themeColors.icon, + text: Localize.translateLocal('common.unPin'), + onSelected: Session.checkIfActionIsAllowed(() => Report.togglePinnedState(report.reportID, report.isPinned)), + }; +} + +export { + // eslint-disable-next-line import/prefer-default-export + getPinMenuItem, +}; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index a3c8f289d1c5..43b1c2f39902 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -59,16 +59,13 @@ function buildOptimisticTransaction( commentJSON.originalTransactionID = originalTransactionID; } - // For the SmartScan to run successfully, we need to pass the merchant field empty to the API - const defaultMerchant = !receipt || Object.keys(receipt).length === 0 ? CONST.TRANSACTION.DEFAULT_MERCHANT : ''; - return { transactionID, amount, currency, reportID, comment: commentJSON, - merchant: merchant || defaultMerchant, + merchant: merchant || CONST.TRANSACTION.DEFAULT_MERCHANT, created: created || DateUtils.getDBTime(), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, receipt, diff --git a/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js b/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js index 244eaf805d10..4c829239ef14 100644 --- a/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js +++ b/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js @@ -3,6 +3,7 @@ */ import CONFIG from '../../../CONFIG'; +let unreadTotalCount = 0; /** * Set the page title on web * @@ -10,7 +11,7 @@ import CONFIG from '../../../CONFIG'; */ function updateUnread(totalCount) { const hasUnread = totalCount !== 0; - + unreadTotalCount = totalCount; // This setTimeout is required because due to how react rendering messes with the DOM, the document title can't be modified synchronously, and we must wait until all JS is done // running before setting the title. setTimeout(() => { @@ -22,4 +23,8 @@ function updateUnread(totalCount) { }, 0); } +window.addEventListener('popstate', () => { + updateUnread(unreadTotalCount); +}); + export default updateUnread; diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 27a02b1fc75f..87b907872759 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1401,9 +1401,15 @@ function updateWriteCapabilityAndNavigate(report, newValue) { /** * Navigates to the 1:1 report with Concierge + * + * @param {Boolean} ignoreConciergeReportID - Flag to ignore conciergeChatReportID during navigation. The default behavior is to not ignore. */ -function navigateToConciergeChat() { - if (!conciergeChatReportID) { +function navigateToConciergeChat(ignoreConciergeReportID = false) { + // If conciergeChatReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID. + // Otherwise, we would find the concierge chat and navigate to it. + // Now, when user performs sign-out and a sign-in again, conciergeChatReportID may contain a stale value. + // In order to prevent navigation to a stale value, we use ignoreConciergeReportID to forcefully find and navigate to concierge chat. + if (!conciergeChatReportID || ignoreConciergeReportID) { // In order to avoid creating concierge repeatedly, // we need to ensure that the server data has been successfully pulled Welcome.serverDataIsReadyPromise().then(() => { @@ -1897,7 +1903,7 @@ function openReportFromDeepLink(url, isAuthenticated) { InteractionManager.runAfterInteractions(() => { Session.waitForUserSignIn().then(() => { if (route === ROUTES.CONCIERGE) { - navigateToConciergeChat(); + navigateToConciergeChat(true); return; } Navigation.navigate(route, CONST.NAVIGATION.TYPE.PUSH); diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 4e48a965b095..fc913fb201e0 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -23,11 +23,11 @@ import participantPropTypes from '../../components/participantPropTypes'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; import * as OptionsListUtils from '../../libs/OptionsListUtils'; +import * as HeaderUtils from '../../libs/HeaderUtils'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; import * as ReportUtils from '../../libs/ReportUtils'; import * as Link from '../../libs/actions/Link'; import * as Report from '../../libs/actions/Report'; -import * as Session from '../../libs/actions/Session'; import * as Task from '../../libs/actions/Task'; import compose from '../../libs/compose'; import styles from '../../styles/styles'; @@ -135,21 +135,7 @@ function HeaderView(props) { } } - if (!props.report.isPinned) { - threeDotMenuItems.push({ - icon: Expensicons.Pin, - iconFill: themeColors.icon, - text: props.translate('common.pin'), - onSelected: Session.checkIfActionIsAllowed(() => Report.togglePinnedState(props.report.reportID, props.report.isPinned)), - }); - } else { - threeDotMenuItems.push({ - icon: Expensicons.Pin, - iconFill: themeColors.icon, - text: props.translate('common.unPin'), - onSelected: Session.checkIfActionIsAllowed(() => Report.togglePinnedState(props.report.reportID, props.report.isPinned)), - }); - } + threeDotMenuItems.push(HeaderUtils.getPinMenuItem(props.report)); if (isConcierge && props.guideCalendarLink) { threeDotMenuItems.push({