diff --git a/.github/ISSUE_TEMPLATE/Standard.md b/.github/ISSUE_TEMPLATE/Standard.md index 123d0143e9af..9ae9bbe077d0 100644 --- a/.github/ISSUE_TEMPLATE/Standard.md +++ b/.github/ISSUE_TEMPLATE/Standard.md @@ -7,15 +7,15 @@ labels: AutoAssignerTriage If you haven’t already, check out our [contributing guidelines](https://github.com/Expensify/ReactNativeChat/blob/main/CONTRIBUTING.md) for onboarding and email contributors@expensify.com to request to join our Slack channel! ___ +## Action Performed: +Break down in numbered steps + ## Expected Result: Describe what you think should've happened ## Actual Result: Describe what actually happened -## Action Performed: -Break down in numbered steps - ## Workaround: Can the user still use Expensify without this being fixed? Have you informed them of the workaround? diff --git a/README.md b/README.md index 545f4055e356..a02c8008f6e9 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ * [Debugging](#debugging) * [Structure of the app](#structure-of-the-app) * [Philosophy](#Philosophy) +* [Internationalization](#Internationalization) * [Deploying](#deploying) #### Additional Reading @@ -236,6 +237,32 @@ This application is built with the following principles. ---- +# Internationalization +This application is built with Internationalization (I18n) / Localization (L10n) support, so it's important to always +localize the following types of data when presented to the user (even accessibility texts that are not rendered): + +- Texts: See [translate method](https://github.com/Expensify/Expensify.cash/blob/655ba416d552d5c88e57977a6e0165fb7eb7ab58/src/libs/translate.js#L15) +- Date/time: see [DateUtils](https://github.com/Expensify/Expensify.cash/blob/f579946fbfbdc62acc5bd281dc75cabb803d9af0/src/libs/DateUtils.js) +- Numbers and amounts: see [numberFormat](https://github.com/Expensify/Expensify.cash/tree/965f92fc2a5a2a0d01e6114bf5aa8755b9d9fd1a/src/libs/numberFormat) +- Phones: see [LocalPhoneNumber](https://github.com/Expensify/Expensify.cash/blob/bdfbafe18ee2d60f766c697744f23fad64b62cad/src/libs/LocalePhoneNumber.js#L51-L52) + +In most cases, you will be needing to localize data used in a component, if that's the case, there's a HOC [withLocalize](https://github.com/Expensify/Expensify.cash/blob/37465dbd07da1feab8347835d82ed3d2302cde4c/src/components/withLocalize.js). +It will abstract most of the logic you need (mostly subscribe to the [PREFERRED_LOCALE](https://github.com/Expensify/Expensify.cash/blob/6cf1a56df670a11bf61aa67eeb64c1f87161dea1/src/ONYXKEYS.js#L88) Onyx key) +and is the preferred way of localizing things inside components. + +Some pointers: + +- All translations are stored in language files in [src/languages](https://github.com/Expensify/Expensify.cash/tree/b114bc86ff38e3feca764e75b3f5bf4f60fcd6fe/src/languages). +- We try to group translations by their pages/components +- A common rule of thumb is to move a common word/phrase to be shared when it's in 3 places +- Always prefer longer and more complex strings in the translation files. For example + if you need to generate the text `User has sent $20.00 to you on Oct 25th at 10:05am`, add just one + key to the translation file and use the arrow function version, like so: + `nameOfTheKey: ({amount, dateTime}) => "User has sent " + amount + " to you on " + dateTime,`. + This is because the order of the phrases might vary from one language to another. + +---- + # Deploying ## QA and deploy cycles We utilize a CI/CD deployment system built using [GitHub Actions](https://github.com/features/actions) to ensure that new code is automatically deployed to our users as fast as possible. As part of this process, all code is first deployed to our staging environments, where it undergoes quality assurance (QA) testing before it is deployed to production. Typically, pull requests are deployed to staging immediately after they are merged. diff --git a/android/app/build.gradle b/android/app/build.gradle index 5f58d28bd8b7..9c5ea243bd9c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -148,8 +148,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001006804 - versionName "1.0.68-4" + versionCode 1001006805 + versionName "1.0.68-5" } splits { abi { diff --git a/assets/images/workspace-default-avatar.svg b/assets/images/workspace-default-avatar.svg new file mode 100644 index 000000000000..6b17c170fdbe --- /dev/null +++ b/assets/images/workspace-default-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ios/ExpensifyCash/Info.plist b/ios/ExpensifyCash/Info.plist index 6b15df09344f..6ff66da3ea69 100644 --- a/ios/ExpensifyCash/Info.plist +++ b/ios/ExpensifyCash/Info.plist @@ -30,7 +30,7 @@ CFBundleVersion - 1.0.68.4 + 1.0.68.5 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/ExpensifyCashTests/Info.plist b/ios/ExpensifyCashTests/Info.plist index f7f50226c806..497c59a88ff5 100644 --- a/ios/ExpensifyCashTests/Info.plist +++ b/ios/ExpensifyCashTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.0.68.4 + 1.0.68.5 diff --git a/package-lock.json b/package-lock.json index 4797f907e560..46a13d03a59e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "expensify.cash", - "version": "1.0.68-4", + "version": "1.0.68-5", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 741cdffa9b48..816ec1e5cd33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "expensify.cash", - "version": "1.0.68-4", + "version": "1.0.68-5", "author": "Expensify, Inc.", "homepage": "https://expensify.cash", "description": "Expensify.cash is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/App.js b/src/App.js index 58845d56e429..c69e6da22cb8 100644 --- a/src/App.js +++ b/src/App.js @@ -1,12 +1,15 @@ import React from 'react'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import CustomStatusBar from './components/CustomStatusBar'; +import ErrorBoundary from './components/ErrorBoundary'; import Expensify from './Expensify'; const App = () => ( - + + + ); diff --git a/src/CONST.js b/src/CONST.js index 67b9010b927c..f786194b6168 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -22,6 +22,7 @@ const CONST = { IOU: 'IOU', PAY_WITH_EXPENSIFY: 'payWithExpensify', FREE_PLAN: 'freePlan', + DEFAULT_ROOMS: 'defaultRooms', }, BUTTON_STATES: { DEFAULT: 'default', @@ -80,6 +81,7 @@ const CONST = { MODAL_TYPE: { CONFIRM: 'confirm', CENTERED: 'centered', + CENTERED_UNSWIPEABLE: 'centered_unswipeable', BOTTOM_DOCKED: 'bottom_docked', POPOVER: 'popover', RIGHT_DOCKED: 'right_docked', diff --git a/src/Expensify.js b/src/Expensify.js index 431db2e8d9a1..0d26f00c96ec 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -1,10 +1,11 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; -import {View, StatusBar, AppState} from 'react-native'; +import {View, AppState} from 'react-native'; import Onyx, {withOnyx} from 'react-native-onyx'; import BootSplash from './libs/BootSplash'; +import StatusBar from './libs/StatusBar'; import listenToStorageEvents from './libs/listenToStorageEvents'; import * as ActiveClientManager from './libs/ActiveClientManager'; import ONYXKEYS from './ONYXKEYS'; @@ -18,6 +19,8 @@ import UpdateAppModal from './components/UpdateAppModal'; import Visibility from './libs/Visibility'; import GrowlNotification from './components/GrowlNotification'; import {growlRef} from './libs/Growl'; +import Navigation from './libs/Navigation/Navigation'; +import ROUTES from './ROUTES'; // Initialize the store when the app loads for the first time Onyx.init({ @@ -54,6 +57,9 @@ const propTypes = { /** Currently logged in user accountID */ accountID: PropTypes.number, + + /** Should app immediately redirect to new workspace route once authenticated */ + redirectToWorkspaceNewAfterSignIn: PropTypes.bool, }), /** Whether a new update is available and ready to install. */ @@ -67,6 +73,7 @@ const defaultProps = { session: { authToken: null, accountID: null, + redirectToWorkspaceNewAfterSignIn: false, }, updateAvailable: false, initialReportDataLoaded: false, @@ -113,6 +120,9 @@ class Expensify extends PureComponent { const previousAuthToken = lodashGet(prevProps, 'session.authToken', null); if (this.getAuthToken() && !previousAuthToken) { BootSplash.show({fade: true}); + if (lodashGet(this.props, 'session.redirectToWorkspaceNewAfterSignIn', false)) { + Navigation.navigate(ROUTES.WORKSPACE_NEW); + } } if (this.getAuthToken() && this.props.initialReportDataLoaded) { diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index d7fcd65074f3..dda30feab524 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -21,6 +21,9 @@ export default { // What the active route is for our navigator. Global route that determines what views to display. CURRENT_URL: 'currentURL', + // Stores current date + CURRENT_DATE: 'currentDate', + // Currently viewed reportID CURRENTLY_VIEWED_REPORTID: 'currentlyViewedReportID', diff --git a/src/ROUTES.js b/src/ROUTES.js index 8fbb80f1853c..b46703870e4a 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -48,6 +48,7 @@ export default { VALIDATE_LOGIN: 'v', VALIDATE_LOGIN_WITH_VALIDATE_CODE: 'v/:accountID/:validateCode', ENABLE_PAYMENTS: 'enable-payments', + WORKSPACE_NEW: 'workspace/new', /** * @param {String} route diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index f42cddd6a57d..599456e55442 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -1,6 +1,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {View} from 'react-native'; +import Str from 'expensify-common/lib/str'; import {withOnyx} from 'react-native-onyx'; import CONST from '../CONST'; import Modal from './Modal'; @@ -97,10 +98,17 @@ class AttachmentModal extends PureComponent { const attachmentViewStyles = this.props.isSmallScreenWidth ? [styles.imageModalImageCenterContainer] : [styles.imageModalImageCenterContainer, styles.p5]; + + // If our attachment is a PDF, make the Modal unswipeable + const modalType = (this.state.sourceURL + && (Str.isPDF(this.state.sourceURL) || (this.state.file + && Str.isPDF(this.state.file.name || this.props.translate('attachmentView.unknownFilename'))))) + ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE + : CONST.MODAL.MODAL_TYPE.CENTERED; return ( <> this.setState({isModalOpen: false})} isVisible={this.state.isModalOpen} diff --git a/src/components/Button.js b/src/components/Button.js index a5854151e748..c170e64eb3a1 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -10,7 +10,7 @@ import OpacityView from './OpacityView'; const propTypes = { /** The text for the button label */ - text: PropTypes.string.isRequired, + text: PropTypes.string, /** Indicates whether the button should be disabled and in the loading state */ isLoading: PropTypes.bool, @@ -44,6 +44,7 @@ const propTypes = { }; const defaultProps = { + text: '', isLoading: false, isDisabled: false, onPress: () => {}, diff --git a/src/components/ErrorBoundary/BaseErrorBoundary.js b/src/components/ErrorBoundary/BaseErrorBoundary.js new file mode 100644 index 000000000000..aee0f94a5665 --- /dev/null +++ b/src/components/ErrorBoundary/BaseErrorBoundary.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const propTypes = { + /* A message posted to `logError` (along with error data) when this component intercepts an error */ + errorMessage: PropTypes.string.isRequired, + + /* A function to perform the actual logging since different platforms support different tools */ + logError: PropTypes.func, + + /* Actual content wrapped by this error boundary */ + children: PropTypes.node.isRequired, +}; + +const defaultProps = { + logError: () => {}, +}; + +/** + * This component captures an error in the child component tree and logs it to the server + * It can be used to wrap the entire app as well as to wrap specific parts for more granularity + * @see {@link https://reactjs.org/docs/error-boundaries.html#where-to-place-error-boundaries} + */ +class BaseErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = {hasError: false}; + } + + static getDerivedStateFromError() { + // Update state so the next render will show the fallback UI. + return {hasError: true}; + } + + componentDidCatch(error, errorInfo) { + this.props.logError(this.props.errorMessage, error, errorInfo); + } + + render() { + if (this.state.hasError) { + // For the moment we've decided not to render any fallback UI + return null; + } + + return this.props.children; + } +} + +BaseErrorBoundary.propTypes = propTypes; +BaseErrorBoundary.defaultProps = defaultProps; + +export default BaseErrorBoundary; diff --git a/src/components/ErrorBoundary/index.js b/src/components/ErrorBoundary/index.js new file mode 100644 index 000000000000..7e8fdfdc2131 --- /dev/null +++ b/src/components/ErrorBoundary/index.js @@ -0,0 +1,9 @@ +import BaseErrorBoundary from './BaseErrorBoundary'; +import Log from '../../libs/Log'; + +BaseErrorBoundary.defaultProps.logError = (errorMessage, error, errorInfo) => { + // Log the error to the server + Log.alert(errorMessage, 0, {error: error.message, errorInfo}, false); +}; + +export default BaseErrorBoundary; diff --git a/src/components/ErrorBoundary/index.native.js b/src/components/ErrorBoundary/index.native.js new file mode 100644 index 000000000000..d320b46984e0 --- /dev/null +++ b/src/components/ErrorBoundary/index.native.js @@ -0,0 +1,16 @@ +import crashlytics from '@react-native-firebase/crashlytics'; + +import BaseErrorBoundary from './BaseErrorBoundary'; +import Log from '../../libs/Log'; + +BaseErrorBoundary.defaultProps.logError = (errorMessage, error, errorInfo) => { + // Log the error to the server + Log.alert(errorMessage, 0, {error: error.message, errorInfo}, false); + + /* On native we also log the error to crashlytics + * Since the error was handled we need to manually tell crashlytics about it */ + crashlytics().log(`errorInfo: ${JSON.stringify(errorInfo)}`); + crashlytics().recordError(error); +}; + +export default BaseErrorBoundary; diff --git a/src/components/FixedFooter.js b/src/components/FixedFooter.js new file mode 100644 index 000000000000..8c4ef0a897fa --- /dev/null +++ b/src/components/FixedFooter.js @@ -0,0 +1,27 @@ +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import styles from '../styles/styles'; + +const propTypes = { + /** Children to wrap in FixedFooter. */ + children: PropTypes.node.isRequired, + + /** Styles to be assigned to Container */ + style: PropTypes.arrayOf(PropTypes.object), +}; + +const defaultProps = { + style: [], +}; + +const FixedFooter = props => ( + + {props.children} + +); + +FixedFooter.propTypes = propTypes; +FixedFooter.defaultProps = defaultProps; +FixedFooter.displayName = 'FixedFooter'; +export default FixedFooter; diff --git a/src/components/IOUConfirmationList.js b/src/components/IOUConfirmationList.js index 0dcecf63a326..3cec85b899a5 100755 --- a/src/components/IOUConfirmationList.js +++ b/src/components/IOUConfirmationList.js @@ -1,7 +1,7 @@ import React, {Component} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; -import {TextInput} from 'react-native-gesture-handler'; +import {ScrollView, TextInput} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; import {withSafeAreaInsets} from 'react-native-safe-area-context'; import styles from '../styles/styles'; @@ -18,6 +18,7 @@ import withLocalize, {withLocalizePropTypes} from './withLocalize'; import SafeAreaInsetPropTypes from '../pages/SafeAreaInsetPropTypes'; import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; import compose from '../libs/compose'; +import FixedFooter from './FixedFooter'; const propTypes = { /** Callback to inform parent modal of success */ @@ -241,8 +242,8 @@ class IOUConfirmationList extends Component { }, ); return ( - - + <> + - - - {this.props.translate('iOUConfirmationList.whatsItFor')} - - - + + {this.props.translate('iOUConfirmationList.whatsItFor')} + + - - + +