Expensify / App

Welcome to New Expensify: a complete re-imagination of financial collaboration, centered around chat. Help us build the next generation of Expensify by sharing feedback and contributing to the code.
https://new.expensify.com
MIT License
2.97k stars 2.48k forks source link
New Expensify Icon

New Expensify

Table of Contents

Additional Reading


Local development

These instructions should get you set up ready to work on New Expensify πŸ™Œ

Getting Started

  1. Install nvm then node & npm: brew install nvm && nvm install
  2. Install watchman: brew install watchman
  3. Install dependencies: npm install
  4. Install mkcert: brew install mkcert followed by npm run setup-https. If you are not using macOS, follow the instructions here.
  5. Create a host entry in your local hosts file, /etc/hosts for dev.new.expensify.com pointing to localhost:
    127.0.0.1 dev.new.expensify.com

You can use any IDE or code editing tool for developing on any platform. Use your favorite!

Recommended node setup

In order to have more consistent builds, we use a strict node and npm version as defined in the package.json engines field and .nvmrc file. npm install will fail if you do not use the version defined, so it is recommended to install node via nvm for easy node version management. Automatic node version switching can be installed for zsh or bash using nvm.

Configuring HTTPS

The webpack development server now uses https. If you're using a mac, you can simply run npm run setup-https.

If you're using another operating system, you will need to ensure mkcert is installed, and then follow the instructions in the repository to generate certificates valid for dev.new.expensify.com and localhost. The certificate should be named certificate.pem and the key should be named key.pem. They should be placed in config/webpack.

Running the web app πŸ•Έ

Running the iOS app πŸ“±

For an M1 Mac, read this SO for installing cocoapods.

If you want to run the app on an actual physical iOS device, please follow the instructions here.

Running the Android app πŸ€–

Running the MacOS desktop app πŸ–₯

Receiving Notifications

To receive notifications on development build of the app while hitting the Staging or Production API, you need to use the production airship config.

Android

  1. Copy the production config to the development config.
  2. Rebuild the app.

iOS

  1. Replace the development key and secret with the production values.
  2. Rebuild the app.

Troubleshooting

  1. If you are having issues with Getting Started, please reference React Native's Documentation
  2. If you are running into CORS errors like (in the browser dev console)
    Access to fetch at 'https://www.expensify.com/api/BeginSignIn' from origin 'http://localhost:8080' has been blocked by CORS policy

    You probably have a misconfigured .env file - remove it (rm .env) and try again

Note: Expensify engineers that will be testing with the API in your local dev environment please refer to these additional instructions.

Environment variables

Creating an .env file is not necessary. We advise external contributors against it. It can lead to errors when variables referenced here get updated since your local .env file is ignored.


Testing on browsers in simulators and emulators

The development server is reached through the HTTPS protocol, and any client that access the development server needs a certificate.

You create this certificate by following the instructions in Configuring HTTPS of this readme. When accessing the website served from the development server on browsers in iOS simulator or Android emulator, these virtual devices need to have the same certificate installed. Follow the steps below to install them.

Pre-requisite for Android flow

  1. Open any emulator using Android Studio
  2. Use adb push "$(mkcert -CAROOT)/rootCA.pem" /storage/emulated/0/Download/ to push certificate to install in Download folder.
  3. Install the certificate as CA certificate from the settings. On the Android emulator, this option can be found in Settings > Security > Encryption & Credentials > Install a certificate > CA certificate.
  4. Close the emulator.

Note - If you want to run app on https://127.0.0.1:8082, then just install the certificate and use adb reverse tcp:8082 tcp:8082 on every startup.

Android Flow

  1. Run npm run setupNewDotWebForEmulators android
  2. Select the emulator you want to run if prompted. (If single emulator is available, then it will open automatically)
  3. Let the script execute till the message πŸŽ‰ Done!.

Note - If you want to run app on https://dev.new.expensify.com:8082, then just do the Android flow and use npm run startAndroidEmulator to start the Android Emulator every time (It will configure the emulator).

Possible Scenario: The flow may fail to root with error adbd cannot run as root in production builds. In this case, please refer to https://stackoverflow.com/a/45668555. Or use https://127.0.0.1:8082 for less hassle.

iOS Flow

  1. Run npm run setupNewDotWebForEmulators ios
  2. Select the emulator you want to run if prompted. (If single emulator is available, then it will open automatically)
  3. Let the script execute till the message πŸŽ‰ Done!.

All Flow

  1. Run npm run setupNewDotWebForEmulators all or npm run setupNewDotWebForEmulators
  2. Check if the iOS flow runs first and then Android flow runs.
  3. Let the script execute till the message πŸŽ‰ Done!.

Running the tests

Unit tests

Unit tests are valuable when you want to test one component. They should be short, fast, and ideally only test one thing. Often times in order to write a unit test, you may need to mock data, a component, or library. We use the library Jest to help run our Unit tests.

Performance tests

We use Reassure for monitoring performance regression. More detailed information can be found here:


Debugging

iOS

  1. If running on the iOS simulator pressing ⌘D will open the debugging menu.
  2. This will allow you to attach a debugger in your IDE, React Developer Tools, or your browser.
  3. For more information on how to attach a debugger, see React Native Debugging Documentation

Alternatively, you can also set up debugger using Flipper. After installation, press ⌘D and select "Open Debugger". This will open Flipper window. To view data stored by Onyx, go to Plugin Manager and install async-storage plugin.

Android

Our React Native Android app now uses the Hermes JS engine which requires your browser for remote debugging. These instructions are specific to Chrome since that's what the Hermes documentation provided.

  1. Navigate to chrome://inspect
  2. Use the Configure... button to add the Metro server address (typically localhost:8081, check your Metro output)
  3. You should now see a "Hermes React Native" target with an "inspect" link which can be used to bring up a debugger. If you don't see the "inspect" link, make sure the Metro server is running
  4. You can now use the Chrome debug tools. See React Native Debugging Hermes

Web

To make it easier to test things in web, we expose the Onyx object to the window, so you can easily do Onyx.set('bla', 1).


Release Profiler

Often, performance issue debugging occurs in debug builds, which can introduce errors from elements such as JS Garbage Collection, Hermes debug markers, or LLDB pauses.

react-native-release-profiler facilitates profiling within release builds for accurate local problem-solving and broad performance analysis in production to spot regressions or collect extensive device data. Therefore, we will utilize the production build version

Getting Started with Source Maps

To accurately profile your application, generating source maps for Android and iOS is crucial. Here's how to enable them:

  1. Enable source maps on Android Ensure the following is set in your app'sΒ android/app/build.gradleΒ file.

    project.ext.react = [
        enableHermes: true,
        hermesFlagsRelease: ["-O", "-output-source-map"], // <-- here, plus whichever flag was required to set this away from default
    ]
  2. Enable source maps on IOS Within Xcode head to the build phase - Bundle React Native code and images.

    export SOURCEMAP_FILE="$(pwd)/../main.jsbundle.map" // <-- here;
    
    export NODE_BINARY=node
    ../node_modules/react-native/scripts/react-native-xcode.sh
  3. Install the necessary packages and CocoaPods dependencies:

    npm i && npm run pod-install
  4. Depending on the platform you are targeting, run your Android/iOS app in production mode.

  5. Upon completion, the generated source map can be found at: Android: android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map IOS: main.jsbundle.map

Recording a Trace:

  1. Ensure you have generated the source map as outlined above.
  2. Launch the app in production mode.
  3. Navigate to the feature you wish to profile.
  4. Initiate the profiling session by tapping with four fingers to open the menu and selecting Use Profiling.
  5. Close the menu and interact with the app.
  6. After completing your interactions, tap with four fingers again and select to stop profiling.
  7. You will be presented with a Share option to export the trace, which includes a trace file (Profile<app version>.cpuprofile) and build info (AppInfo<app version>.json).

Build info:

{
    appVersion: "1.0.0",
    environment: "production",
    platform: "IOS",
    totalMemory: "3GB",
    usedMemory: "300MB"
}

How to symbolicate trace record:

  1. You have two files: AppInfo<app version>.json and Profile<app version>.cpuprofile
  2. Place the Profile<app version>.cpuprofile file at the root of your project.
  3. If you have already generated a source map from the steps above for this branch, you can skip to the next step. Otherwise, obtain the app version from AppInfo<app version>.json switch to that branch and generate the source map as described.

IMPORTANT: You should generate the source map from the same branch as the trace was recorded.

  1. Use the following commands to symbolicate the trace for Android and iOS, respectively: Android: npm run symbolicate-release:android IOS: npm run symbolicate-release:ios
  2. A new file named Profile_trace_for_<app version>-converted.json will appear in your project's root folder.
  3. Open this file in your tool of choice:

App Structure and Conventions

Onyx

This is a persistent storage solution wrapped in a Pub/Sub library. In general that means:

Actions

Actions are responsible for managing what is on disk. This is usually:

The UI layer

This layer is solely responsible for:

As a convention, the UI layer should never interact with device storage directly or call Onyx.set() or Onyx.merge(). Use an action! For example, check out this action that is signing in the user here.

validateAndSubmitForm() {
    // validate...
    signIn(this.state.password, this.state.twoFactorAuthCode);
}

That action will then call Onyx.merge() to set default data and a loading state, then make an API request, and set the response with another Onyx.merge().

function signIn(password, twoFactorAuthCode) {
    Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true});
    Authentication.Authenticate({
        ...defaultParams,
        password,
        twoFactorAuthCode,
    })
        .then((response) => {
            Onyx.merge(ONYXKEYS.SESSION, {authToken: response.authToken});
        })
        .catch((error) => {
            Onyx.merge(ONYXKEYS.ACCOUNT, {error: error.message});
        })
        .finally(() => {
            Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: false});
        });
}

Keeping our Onyx.merge() out of the view layer and in actions helps organize things as all interactions with device storage and API handling happen in the same place. In addition, actions that are called from inside views should not ever use the .then() method to set loading/error states, navigate or do any additional data processing. All of this stuff should ideally go into Onyx and be fed back to the component via withOnyx(). Design your actions so they clearly describe what they will do and encapsulate all their logic in that action.

// Bad
validateAndSubmitForm() {
    // validate...
    this.setState({isLoading: true});
    signIn()
        .then((response) => {
            if (result.jsonCode === 200) {
                return;
            }

            this.setState({error: response.message});
        })
        .finally(() => {
            this.setState({isLoading: false});
        });
}

// Good
validateAndSubmitForm() {
    // validate...
    signIn();
}

Directory structure

Almost all the code is located in the src folder, inside it there's some organization, we chose to name directories that are created to house a collection of items in plural form and using camelCase (eg: pages, libs, etc), the main ones we have for now are:

Note: There is also a directory called /docs, which houses the Expensify Help site. It's a static site that's built with Jekyll and hosted on GitHub Pages.

File naming/structure

Files should be named after the component/function/constants they export, respecting the casing used for it. ie:

Platform-Specific File Extensions

In most cases, the code written for this repo should be platform-independent. In such cases, each module should have a single file, index.js, which defines the module's exports. There are, however, some cases in which a feature is intrinsically tied to the underlying platform. In such cases, the following file extensions can be used to export platform-specific code from a module:

Note that index.js should be the default and only platform-specific implementations should be done in their respective files. i.e: If you have mobile-specific implementation in index.native.js, then the desktop/web implementation can be contained in a shared index.js.

index.ios.js and index.android.js are used when the app is running natively on respective platforms. These files are not used when users access the app through mobile browsers, but index.website.js is used instead. index.native.js are for both iOS and Android native apps. index.native.js should not be included in the same module as index.ios.js or index.android.js.

API building

When adding new API commands (and preferably when starting using a new one that was not yet used in this codebase) always prefer to return the created/updated data in the command itself, instead of saving and reloading. ie: if we call CreateTransaction, we should prefer making CreateTransaction return the data it just created instead of calling CreateTransaction then Get rvl=transactionList

Storage Eviction

Different platforms come with varying storage capacities and Onyx has a way to gracefully fail when those storage limits are encountered. When Onyx fails to set or modify a key the following steps are taken:

  1. Onyx looks at a list of recently accessed keys (access is defined as subscribed to or modified) and locates the key that was least recently accessed
  2. It then deletes this key and retries the original operation

By default, Onyx will not evict anything from storage and will presume all keys are "unsafe" to remove unless explicitly told otherwise.

To flag a key as safe for removal:

e.g.

Onyx.init({
    safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
});
export default withOnyx({
    reportActions: {
        key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
        canEvict: props => !props.isActiveReport,
    },
})(ReportActionsView);

Things to know or brush up on before jumping into the code

  1. The major difference between React Native and React are the components that are used in the render() method. Everything else is exactly the same. Any React skills you have can be applied to React Native.
  2. The application uses react-navigation for navigating between parts of the app.
  3. Higher Order Components are used to connect React components to persistent storage via react-native-onyx.

Philosophy

This application is built with the following principles.

  1. Data Flow - Ideally, this is how data flows through the app:

    1. Server pushes data to the disk of any client (Server -> Pusher event -> Action listening to pusher event -> Onyx).

      Note: Currently the code only does this with report comments. Until we make more server changes, this step is actually done by the client requesting data from the server via XHR and then storing the response in Onyx.

    2. Disk pushes data to the UI (Onyx -> withOnyx() -> React component).
    3. UI pushes data to people's brains (React component -> device screen).
    4. Brain pushes data into UI inputs (Device input -> React component).
    5. UI inputs push data to the server (React component -> Action -> XHR to server).
    6. Go to 1 New Expensify Data Flow Chart
  2. Offline first

    • Be sure to read OFFLINE_UX.md!
    • All data that is brought into the app and is necessary to display the app when offline should be stored on disk in persistent storage (eg. localStorage on browser platforms). AsyncStorage is a cross-platform abstraction layer that is used to access persistent storage.
    • All data that is displayed, comes from persistent storage.
  3. UI Binds to data on disk

    • Onyx is a Pub/Sub library to connect the application to the data stored on disk.
    • UI components subscribe to Onyx (using withOnyx()) and any change to the Onyx data is published to the component by calling setState() with the changed data.
    • Libraries subscribe to Onyx (with Onyx.connect()) and any change to the Onyx data is published to the callback with the changed data.
    • The UI should never call any Onyx methods except for Onyx.connect(). That is the job of Actions (see next section).
    • The UI always triggers an Action when something needs to happen (eg. a person inputs data, the UI triggers an Action with this data).
    • The UI should be as flexible as possible when it comes to:
      • Incomplete or missing data. Always assume data is incomplete or not there. For example, when a comment is pushed to the client from a pusher event, it's possible that Onyx does not have data for that report yet. That's OK. A partial report object is added to Onyx for the report key report_1234 = {reportID: 1234, isUnread: true}. Then there is code that monitors Onyx for reports with incomplete data, and calls openReport(1234) to get the full data for that report. The UI should be able to gracefully handle the report object not being complete. In this example, the sidebar wouldn't display any report that does not have a report name.
      • The order that actions are done in. All actions should be done in parallel instead of sequence.
        • Parallel actions are asynchronous methods that don't return promises. Any number of these actions can be called at one time and it doesn't matter what order they happen in or when they complete.
        • In-Sequence actions are asynchronous methods that return promises. This is necessary when one asynchronous method depends on the results from a previous asynchronous method. Example: Making an XHR to command=CreateChatReport which returns a reportID which is used to call command=Get&rvl=reportStuff.
  4. Actions manage Onyx Data

    • When data needs to be written to or read from the server, this is done through Actions only.
    • Action methods should only have return values (data or a promise) if they are called by other actions. This is done to encourage that action methods can be called in parallel with no dependency on other methods (see discussion above).
    • Actions should favor using Onyx.merge() over Onyx.set() so that other values in an object aren't completely overwritten.
    • Views should not call Onyx.merge() or Onyx.set() directly and should call an action instead.
    • In general, the operations that happen inside an action should be done in parallel and not in sequence (eg. don't use the promise of one Onyx method to trigger a second Onyx method). Onyx is built so that every operation is done in parallel and it doesn't matter what order they finish in. XHRs on the other hand need to be handled in sequence with promise chains in order to access and act upon the response.
    • If an Action needs to access data stored on disk, use a local variable and Onyx.connect()
    • Data should be optimistically stored on disk whenever possible without waiting for a server response. Example of creating a new optimistic comment:
      1. user adds a comment
      2. comment is shown in the UI (by mocking the expected response from the server)
      3. comment is created in the server
      4. server responds
      5. UI updates with data from the server
  5. Cross Platform 99.9999%

    1. A feature isn't done until it works on all platforms. Accordingly, don't even bother writing a platform-specific code block because you're just going to need to undo it.
    2. If the reason you can't write cross-platform code is because there is a bug in ReactNative that is preventing it from working, the correct action is to fix RN and submit a PR upstream -- not to hack around RN bugs with platform-specific code paths.
    3. If there is a feature that simply doesn't exist on all platforms and thus doesn't exist in RN, rather than doing if (platform=iOS) { }, instead write a "shim" library that is implemented with NOOPs on the other platforms. For example, rather than injecting platform-specific multi-tab code (which can only work on browsers, because it's the only platform with multiple tabs), write a TabManager class that just is NOOP for non-browser platforms. This encapsulates the platform-specific code into a platform library, rather than sprinkling through the business logic.
    4. Put all platform specific code in dedicated files and folders, like /platform, and reject any PR that attempts to put platform-specific code anywhere else. This maintains a strict separation between business logic and platform code.

Security

Updated rules for managing members across all types of chats in New Expensify.

  1. DM

    Member
    Invite ❌
    Remove ❌
    Leave ❌
    Can be removed ❌
    • DM always has two participants. None of the participant can leave or be removed from the DM. Also no additional member can be invited to the chat.
  2. Workspace

    1. Workspace

      Creator Member(Employee/User) Admin Auditor?
      Invite βœ… ❌ βœ… ❌
      Remove βœ… ❌ βœ… ❌
      Leave ❌ βœ… ❌ βœ…
      Can be removed ❌ βœ… βœ… βœ…
      • Creator can't leave or be removed from their own workspace
      • Admins can't leave from the workspace
      • Admins can remove other workspace admins, as well as workspace members, and invited guests
      • Creator can remove other workspace admins, as well as workspace members, and invited guests
      • Members and Auditors cannot invite or remove anyone from the workspace
    2. Workspace #announce room

      Member(Employee/User) Admin Auditor?
      Invite ❌ ❌ ❌
      Remove ❌ ❌ ❌
      Leave ❌ ❌ ❌
      Can be removed ❌ ❌ ❌
      • No one can leave or be removed from the #announce room
    3. Workspace #admin room

      Admin
      Invite ❌
      Remove ❌
      Leave ❌
      Can be removed ❌
      • Admins can't leave or be removed from #admins
    4. Workspace rooms

      Creator Member Guest(outside of the workspace)
      Invite βœ… βœ… βœ…
      Remove βœ… βœ… ❌
      Leave βœ… βœ… βœ…
      Can be removed βœ… βœ… βœ…
      • Everyone can be removed/can leave from the room including creator
      • Guests are not able to remove anyone from the room
    5. Workspace chats

      Admin Member(default) Member(invited)
      Invite βœ… βœ… ❌
      Remove βœ… βœ… ❌
      Leave ❌ ❌ βœ…
      Can be removed ❌ ❌ βœ…
      • Admins are not able to leave/be removed from the workspace chat
      • Default members(automatically invited) are not able to leave/be removed from the workspace chat
      • Invited members(invited by members) are not able to invite or remove from the workspace chat
      • Invited members(invited by members) are able to leave the workspace chat
      • Default members and admins are able to remove invited members
  3. Domain chat

    Member
    Remove ❌
    Leave ❌
    Can be removed ❌
  1. Reports

    Submitter Manager
    Remove ❌ ❌
    Leave ❌ ❌
    Can be removed ❌ ❌

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):

In most cases, you will be needing to localize data used in a component, if that's the case, there's a HOC withLocalize. It will abstract most of the logic you need (mostly subscribe to the NVP_PREFERRED_LOCALE Onyx key) and is the preferred way of localizing things inside components.

Some pointers:


Deploying

QA and deploy cycles

We utilize a CI/CD deployment system built using GitHub 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.

Every time a PR is deployed to staging, it is added to a special tracking issue with the label StagingDeployCash (there will only ever be one open at a time). This tracking issue contains information about the new application version, a list of recently deployed pull requests, and any issues found on staging that are not present on production. Every weekday at 9am PST, our QA team adds the πŸ”LockCashDeploysπŸ” label to that tracking issue, and that signifies that they are starting their daily QA cycle. They will perform both regular regression testing and the QA steps listed for every pull request on the StagingDeployCash checklist.

Once the StagingDeployCash is locked, we won't run any staging deploys until it is either unlocked, or we run a production deploy. If severe issues are found on staging that are not present on production, a new issue (or the PR that caused the issue) will be labeled with DeployBlockerCash, and added to the StagingDeployCash deploy checklist. If we want to resolve a deploy blocker by reverting a pull request or deploying a hotfix directly to the staging environment, we can merge a pull request with the CP Staging label.

Once we have confirmed to the best of our ability that there are no deploy-blocking issues and that all our new features are working as expected on staging, we'll close the StagingDeployCash. That will automatically trigger a production deployment, open a new StagingDeployCash checklist, and deploy to staging any pull requests that were merged while the previous checklist was locked.

Key GitHub workflows

These are some of the most central GitHub Workflows. There is more detailed information in the README here.

preDeploy

The preDeploy workflow executes whenever a pull request is merged to main, and at a high level does the following:

deploy

The deploy workflow is really quite simple. It runs when code is pushed to the staging or production branches, and:

platformDeploy

The platformDeploy workflow is what actually runs the deployment on all four platforms (iOS, Android, Web, macOS Desktop). It runs a staging deploy whenever a new tag is pushed to GitHub, and runs a production deploy whenever a new release is created.

lockDeploys

The lockDeploys workflow executes when the StagingDeployCash is locked, and it waits for any currently running staging deploys to finish, then gives Applause the :green_circle: to begin QA by commenting in the StagingDeployCash checklist.

finishReleaseCycle

The finishReleaseCycle workflow executes when the StagingDeployCash is closed. It updates the production branch from staging (triggering a production deploy), deploys main to staging (with a new PATCH version), and creates a new StagingDeployCash deploy checklist.

Local production builds

Sometimes it might be beneficial to generate a local production version instead of testing on production. Follow the steps below for each client:

Local production build of the web app

In order to generate a production web build, run npm run build, this will generate a production javascript build in the dist/ folder.

Local production build of the MacOS desktop app

In order to compile a production desktop build, run npm run desktop-build, this will generate a production app in the dist/Mac folder named Chat.app.

Local production build the iOS app

In order to compile a production iOS build, run npm run ios-build, this will generate a Chat.ipa in the root directory of this project.

Local production build the Android app

To build an APK to share run (e.g. via Slack), run npm run android-build, this will generate a new APK in the android/app folder.