diff --git a/.env.example b/.env.example index bed835645756..944da2aa9296 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,6 @@ USE_WEB_PROXY=false USE_WDYR=false CAPTURE_METRICS=false ONYX_METRICS=false -GOOGLE_GEOLOCATION_API_KEY=AIzaSyBqg6bMvQU7cPWDKhhzpYqJrTEnSorpiLI EXPENSIFY_ACCOUNT_ID_ACCOUNTING=-1 EXPENSIFY_ACCOUNT_ID_ADMIN=-1 diff --git a/.env.production b/.env.production index 4b0a98e77557..5e676134d681 100644 --- a/.env.production +++ b/.env.production @@ -7,4 +7,3 @@ PUSHER_APP_KEY=268df511a204fbb60884 USE_WEB_PROXY=false ENVIRONMENT=production SEND_CRASH_REPORTS=true -GOOGLE_GEOLOCATION_API_KEY=AIzaSyBFKujMpzExz0_z2pAGfPUwkmlaUc-uw1Q diff --git a/.env.staging b/.env.staging index 1b3ec15fc172..17d82ac2d136 100644 --- a/.env.staging +++ b/.env.staging @@ -6,5 +6,4 @@ EXPENSIFY_PARTNER_PASSWORD=e21965746fd75f82bb66 PUSHER_APP_KEY=268df511a204fbb60884 USE_WEB_PROXY=false ENVIRONMENT=staging -SEND_CRASH_REPORTS=true -GOOGLE_GEOLOCATION_API_KEY=AIzaSyD2T1mlByThbUN88O8OPOD8vKuMMwLD4-M \ No newline at end of file +SEND_CRASH_REPORTS=true \ No newline at end of file diff --git a/.eslintignore b/.eslintignore index d3358a02fe4b..5338df11c520 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,11 @@ -**/node_modules/* -**/dist/* -android/**/build/* -.github/actions/**/index.js" +!.storybook +!.github +.github/actions/**/index.js +*.config.js +**/.eslintrc.js +**/node_modules/** +**/dist/** +android/**/build/** docs/vendor/** +docs/assets/** +web/gtm.js diff --git a/.eslintrc.js b/.eslintrc.js index c8a842fa4650..9f839e45ce75 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,9 +4,9 @@ const restrictedImportPaths = [ importNames: ['useWindowDimensions', 'StatusBar', 'TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'Pressable', 'Text', 'ScrollView'], message: [ '', - "For 'useWindowDimensions', please use 'src/hooks/useWindowDimensions' instead.", - "For 'TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'Pressable', please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from 'src/components/Pressable' instead.", - "For 'StatusBar', please use 'src/libs/StatusBar' instead.", + "For 'useWindowDimensions', please use '@src/hooks/useWindowDimensions' instead.", + "For 'TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'Pressable', please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from '@components/Pressable' instead.", + "For 'StatusBar', please use '@src/libs/StatusBar' instead.", "For 'Text', please use '@components/Text' instead.", "For 'ScrollView', please use '@components/ScrollView' instead.", ].join('\n'), @@ -14,7 +14,7 @@ const restrictedImportPaths = [ { name: 'react-native-gesture-handler', importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight'], - message: "Please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from 'src/components/Pressable' instead.", + message: "Please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from '@components/Pressable' instead.", }, { name: 'awesome-phonenumber', @@ -24,7 +24,7 @@ const restrictedImportPaths = [ { name: 'react-native-safe-area-context', importNames: ['useSafeAreaInsets', 'SafeAreaConsumer', 'SafeAreaInsetsContext'], - message: "Please use 'useSafeAreaInsets' from 'src/hooks/useSafeAreaInset' and/or 'SafeAreaConsumer' from 'src/components/SafeAreaConsumer' instead.", + message: "Please use 'useSafeAreaInsets' from '@src/hooks/useSafeAreaInset' and/or 'SafeAreaConsumer' from '@components/SafeAreaConsumer' instead.", }, { name: 'react', @@ -34,18 +34,18 @@ const restrictedImportPaths = [ { name: '@styles/index', importNames: ['default', 'defaultStyles'], - message: 'Do not import styles directly. Please use the `useThemeStyles` hook or `withThemeStyles` HOC instead.', + message: 'Do not import styles directly. Please use the `useThemeStyles` hook instead.', }, { name: '@styles/utils', importNames: ['default', 'DefaultStyleUtils'], - message: 'Do not import StyleUtils directly. Please use the `useStyleUtils` hook or `withStyleUtils` HOC instead.', + message: 'Do not import StyleUtils directly. Please use the `useStyleUtils` hook instead.', }, { name: '@styles/theme', importNames: ['default', 'defaultTheme'], - message: 'Do not import themes directly. Please use the `useTheme` hook or `withTheme` HOC instead.', + message: 'Do not import themes directly. Please use the `useTheme` hook instead.', }, { name: '@styles/theme/illustrations', @@ -55,20 +55,25 @@ const restrictedImportPaths = [ name: 'date-fns/locale', message: "Do not import 'date-fns/locale' directly. Please use the submodule import instead, like 'date-fns/locale/en-GB'.", }, + { + name: 'expensify-common', + importNames: ['Device'], + message: "Do not import Device directly, it's known to make VSCode's IntelliSense crash. Please import the desired module from `expensify-common/dist/Device` instead.", + }, ]; const restrictedImportPatterns = [ { group: ['**/assets/animations/**/*.json'], - message: "Do not import animations directly. Please use the 'src/components/LottieAnimations' import instead.", + message: "Do not import animations directly. Please use the '@components/LottieAnimations' import instead.", }, { group: ['@styles/theme/themes/**'], - message: 'Do not import themes directly. Please use the `useTheme` hook or `withTheme` HOC instead.', + message: 'Do not import themes directly. Please use the `useTheme` hook instead.', }, { group: ['@styles/utils/**', '!@styles/utils/FontUtils', '!@styles/utils/types'], - message: 'Do not import style util functions directly. Please use the `useStyleUtils` hook or `withStyleUtils` HOC instead.', + message: 'Do not import style util functions directly. Please use the `useStyleUtils` hook instead.', }, { group: ['@styles/theme/illustrations/themes/**'], @@ -77,218 +82,188 @@ const restrictedImportPatterns = [ ]; module.exports = { - extends: ['expensify', 'plugin:storybook/recommended', 'plugin:react-native-a11y/basic', 'plugin:@dword-design/import-alias/recommended', 'prettier'], - plugins: ['react-native-a11y', 'testing-library'], - parser: 'babel-eslint', - ignorePatterns: ['!.*', 'src/vendor', '.github/actions/**/index.js', 'desktop/dist/*.js', 'dist/*.js', 'node_modules/.bin/**', 'node_modules/.cache/**', '.git/**'], + extends: [ + 'expensify', + 'airbnb-typescript', + 'plugin:storybook/recommended', + 'plugin:react-native-a11y/basic', + 'plugin:@dword-design/import-alias/recommended', + 'plugin:@typescript-eslint/recommended-type-checked', + 'plugin:@typescript-eslint/stylistic-type-checked', + 'plugin:you-dont-need-lodash-underscore/all', + 'prettier', + ], + plugins: ['@typescript-eslint', 'jsdoc', 'you-dont-need-lodash-underscore', 'react-native-a11y', 'react', 'testing-library'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json', + }, env: { jest: true, }, globals: { __DEV__: 'readonly', }, - overrides: [ - { - files: ['*.js', '*.jsx', '*.ts', '*.tsx'], - plugins: ['react'], - rules: { - 'prefer-regex-literals': 'off', - 'rulesdir/no-multiple-onyx-in-file': 'off', - 'react-native-a11y/has-accessibility-hint': ['off'], - 'react/jsx-no-constructed-context-values': 'error', - 'react-native-a11y/has-valid-accessibility-descriptors': [ - 'error', - { - touchables: ['PressableWithoutFeedback', 'PressableWithFeedback'], - }, - ], - '@dword-design/import-alias/prefer-alias': [ - 'warn', - { - alias: { - '@assets': './assets', - '@components': './src/components', - '@hooks': './src/hooks', - // This is needed up here, if not @libs/actions would take the priority - '@userActions': './src/libs/actions', - '@libs': './src/libs', - '@navigation': './src/libs/Navigation', - '@pages': './src/pages', - '@styles': './src/styles', - // This path is provide alias for files like `ONYXKEYS` and `CONST`. - '@src': './src', - '@desktop': './desktop', - '@github': './.github', - }, - }, - ], - 'rulesdir/avoid-anonymous-functions': 'off', + rules: { + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + + // TypeScript specific rules + '@typescript-eslint/prefer-enum-initializers': 'error', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/switch-exhaustiveness-check': 'error', + '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-import-type-side-effects': 'error', + '@typescript-eslint/array-type': ['error', {default: 'array-simple'}], + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: ['variable', 'property'], + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], }, - }, - // This helps disable the `prefer-alias` rule to be enabled for specific directories - { - files: ['tests/**/*.js', 'tests/**/*.ts', 'tests/**/*.jsx', 'assets/**/*.js', '.storybook/**/*.js'], - rules: {'@dword-design/import-alias/prefer-alias': ['off']}, - }, - { - files: ['tests/**/*.js', 'tests/**/*.ts', 'tests/**/*.jsx', 'tests/**/*.tsx'], - extends: ['plugin:testing-library/react'], - rules: { - 'testing-library/await-async-queries': 'error', - 'testing-library/await-async-utils': 'error', - 'testing-library/no-debugging-utils': 'error', - 'testing-library/no-manual-cleanup': 'error', - 'testing-library/no-unnecessary-act': 'error', - 'testing-library/prefer-find-by': 'error', - 'testing-library/prefer-presence-queries': 'error', - 'testing-library/prefer-screen-queries': 'error', + { + selector: 'function', + format: ['camelCase', 'PascalCase'], }, - }, - { - files: ['*.js', '*.jsx'], - settings: { - 'import/resolver': { - node: { - extensions: ['.js', '.website.js', '.desktop.js', '.native.js', '.ios.js', '.android.js', '.config.js', '.ts', '.tsx'], - }, + { + selector: ['typeLike', 'enumMember'], + format: ['PascalCase'], + }, + { + selector: ['parameter', 'method'], + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + }, + ], + '@typescript-eslint/ban-types': [ + 'error', + { + types: { + object: "Use 'Record' instead.", }, + extendDefaults: true, }, - rules: { - 'import/extensions': [ - 'error', - 'ignorePackages', - { - js: 'never', - jsx: 'never', - ts: 'never', - tsx: 'never', - }, - ], - 'no-restricted-imports': [ - 'error', - { - paths: restrictedImportPaths, - patterns: restrictedImportPatterns, - }, - ], - curly: 'error', - 'react/display-name': 'error', + ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + fixStyle: 'separate-type-imports', }, - }, - { - files: ['*.ts', '*.tsx'], - extends: [ - 'airbnb-typescript', - 'plugin:@typescript-eslint/recommended-type-checked', - 'plugin:@typescript-eslint/stylistic-type-checked', - 'plugin:you-dont-need-lodash-underscore/all', - 'prettier', - ], - plugins: ['@typescript-eslint', 'jsdoc', 'you-dont-need-lodash-underscore'], - parser: '@typescript-eslint/parser', - parserOptions: { - project: './tsconfig.json', + ], + '@typescript-eslint/consistent-type-exports': [ + 'error', + { + fixMixedExportsWithInlineTypeSpecifier: false, }, - rules: { - // TODO: Remove the following rules after TypeScript migration is complete. - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', + ], + '@typescript-eslint/no-use-before-define': ['error', {functions: false}], - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: ['variable', 'property'], - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - }, - { - selector: 'function', - format: ['camelCase', 'PascalCase'], - }, - { - selector: ['typeLike', 'enumMember'], - format: ['PascalCase'], - }, - { - selector: ['parameter', 'method'], - format: ['camelCase', 'PascalCase'], - leadingUnderscore: 'allow', - }, - ], - '@typescript-eslint/ban-types': [ - 'error', - { - types: { - object: "Use 'Record' instead.", - }, - extendDefaults: true, - }, - ], - '@typescript-eslint/array-type': ['error', {default: 'array-simple'}], - '@typescript-eslint/prefer-enum-initializers': 'error', - '@typescript-eslint/no-var-requires': 'off', - '@typescript-eslint/no-non-null-assertion': 'error', - '@typescript-eslint/switch-exhaustiveness-check': 'error', - '@typescript-eslint/consistent-type-definitions': ['error', 'type'], - '@typescript-eslint/no-floating-promises': 'off', - '@typescript-eslint/consistent-type-imports': [ - 'error', - { - prefer: 'type-imports', - fixStyle: 'separate-type-imports', - }, - ], - '@typescript-eslint/no-import-type-side-effects': 'error', - '@typescript-eslint/consistent-type-exports': [ - 'error', - { - fixMixedExportsWithInlineTypeSpecifier: false, - }, - ], - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - 'es/no-nullish-coalescing-operators': 'off', - 'es/no-optional-chaining': 'off', - 'valid-jsdoc': 'off', - 'jsdoc/no-types': 'error', - 'rulesdir/no-default-props': 'error', - 'import/no-extraneous-dependencies': 'off', - 'rulesdir/prefer-underscore-method': 'off', - 'rulesdir/prefer-import-module-contents': 'off', - 'react/require-default-props': 'off', - 'react/prop-types': 'off', - 'no-restricted-syntax': [ - 'error', - { - selector: 'TSEnumDeclaration', - message: "Please don't declare enums, use union types instead.", - }, - ], - 'no-restricted-properties': [ - 'error', - { - object: 'Image', - property: 'getSize', - message: 'Usage of Image.getImage is restricted. Please use the `react-native-image-size`.', - }, - ], - 'no-restricted-imports': [ + // ESLint core rules + 'es/no-nullish-coalescing-operators': 'off', + 'es/no-optional-chaining': 'off', + + // Import specific rules + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], + 'import/no-extraneous-dependencies': 'off', + + // Rulesdir specific rules + 'rulesdir/no-default-props': 'error', + 'rulesdir/prefer-type-fest': 'error', + 'rulesdir/no-multiple-onyx-in-file': 'off', + 'rulesdir/prefer-underscore-method': 'off', + 'rulesdir/prefer-import-module-contents': 'off', + + // React and React Native specific rules + 'react-native-a11y/has-accessibility-hint': ['off'], + 'react/require-default-props': 'off', + 'react/prop-types': 'off', + 'react/jsx-no-constructed-context-values': 'error', + 'react-native-a11y/has-valid-accessibility-descriptors': [ + 'error', + { + touchables: ['PressableWithoutFeedback', 'PressableWithFeedback'], + }, + ], + + // Disallow usage of certain functions and imports + 'no-restricted-syntax': [ + 'error', + { + selector: 'TSEnumDeclaration', + message: "Please don't declare enums, use union types instead.", + }, + ], + 'no-restricted-properties': [ + 'error', + { + object: 'Image', + property: 'getSize', + message: 'Usage of Image.getImage is restricted. Please use the `react-native-image-size`.', + }, + ], + 'no-restricted-imports': [ + 'error', + { + paths: restrictedImportPaths, + patterns: restrictedImportPatterns, + }, + ], + + // Other rules + curly: 'error', + 'you-dont-need-lodash-underscore/throttle': 'off', + 'prefer-regex-literals': 'off', + 'valid-jsdoc': 'off', + 'jsdoc/no-types': 'error', + '@dword-design/import-alias/prefer-alias': [ + 'warn', + { + alias: { + '@assets': './assets', + '@components': './src/components', + '@hooks': './src/hooks', + // This is needed up here, if not @libs/actions would take the priority + '@userActions': './src/libs/actions', + '@libs': './src/libs', + '@navigation': './src/libs/Navigation', + '@pages': './src/pages', + '@styles': './src/styles', + // This path is provide alias for files like `ONYXKEYS` and `CONST`. + '@src': './src', + '@desktop': './desktop', + '@github': './.github', + }, + }, + ], + }, + + overrides: [ + // Enforces every Onyx type and its properties to have a comment explaining its purpose. + { + files: ['src/types/onyx/**/*.ts'], + rules: { + 'jsdoc/require-jsdoc': [ 'error', { - paths: restrictedImportPaths, - patterns: restrictedImportPatterns, + contexts: ['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSPropertySignature'], }, ], - curly: 'error', - 'you-dont-need-lodash-underscore/throttle': 'off', }, }, + + // Remove once no JS files are left { - files: ['workflow_tests/**/*.{js,jsx,ts,tsx}', 'tests/**/*.{js,jsx,ts,tsx}', '.github/**/*.{js,jsx,ts,tsx}'], + files: ['*.js', '*.jsx'], rules: { - '@lwc/lwc/no-async-await': 'off', - 'no-await-in-loop': 'off', - 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], + '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/unbound-method': 'off', + 'jsdoc/no-types': 'off', + 'react/jsx-filename-extension': 'off', + 'rulesdir/no-default-props': 'off', }, }, { diff --git a/.github/.eslintrc.js b/.github/.eslintrc.js index e769944cd1a9..d6d39822b737 100644 --- a/.github/.eslintrc.js +++ b/.github/.eslintrc.js @@ -1,6 +1,10 @@ -// For all these Node.js scripts, we do not want to disable `console` statements module.exports = { rules: { + // For all these Node.js scripts, we do not want to disable `console` statements 'no-console': 'off', + + '@lwc/lwc/no-async-await': 'off', + 'no-await-in-loop': 'off', + 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], }, }; diff --git a/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts b/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts index 26947193cd80..96bb17a14354 100644 --- a/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts +++ b/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts @@ -8,19 +8,10 @@ import {promiseDoWhile} from '@github/libs/promiseWhile'; type CurrentStagingDeploys = Awaited>['data']['workflow_runs']; function run() { - console.info('[awaitStagingDeploys] POLL RATE', CONST.POLL_RATE); - console.info('[awaitStagingDeploys] run()'); - console.info('[awaitStagingDeploys] getStringInput', getStringInput); - console.info('[awaitStagingDeploys] GitHubUtils', GitHubUtils); - console.info('[awaitStagingDeploys] promiseDoWhile', promiseDoWhile); - const tag = getStringInput('TAG', {required: false}); - console.info('[awaitStagingDeploys] run() tag', tag); let currentStagingDeploys: CurrentStagingDeploys = []; - console.info('[awaitStagingDeploys] run() _.throttle', lodashThrottle); - const throttleFunc = () => Promise.all([ // These are active deploys @@ -42,24 +33,20 @@ function run() { }), ]) .then((responses) => { - console.info('[awaitStagingDeploys] listWorkflowRuns responses', responses); const workflowRuns = responses[0].data.workflow_runs; if (!tag && typeof responses[1] === 'object') { workflowRuns.push(...responses[1].data.workflow_runs); } - console.info('[awaitStagingDeploys] workflowRuns', workflowRuns); return workflowRuns; }) .then((workflowRuns) => (currentStagingDeploys = workflowRuns.filter((workflowRun) => workflowRun.status !== 'completed'))) .then(() => { - console.info('[awaitStagingDeploys] currentStagingDeploys', currentStagingDeploys); console.log( !currentStagingDeploys.length ? 'No current staging deploys found' : `Found ${currentStagingDeploys.length} staging deploy${currentStagingDeploys.length > 1 ? 's' : ''} still running...`, ); }); - console.info('[awaitStagingDeploys] run() throttleFunc', throttleFunc); return promiseDoWhile( () => !!currentStagingDeploys.length, diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index c91313520673..0e0168fdb7ae 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -12131,15 +12131,8 @@ const CONST_1 = __importDefault(__nccwpck_require__(9873)); const GithubUtils_1 = __importDefault(__nccwpck_require__(9296)); const promiseWhile_1 = __nccwpck_require__(9438); function run() { - console.info('[awaitStagingDeploys] POLL RATE', CONST_1.default.POLL_RATE); - console.info('[awaitStagingDeploys] run()'); - console.info('[awaitStagingDeploys] getStringInput', ActionUtils_1.getStringInput); - console.info('[awaitStagingDeploys] GitHubUtils', GithubUtils_1.default); - console.info('[awaitStagingDeploys] promiseDoWhile', promiseWhile_1.promiseDoWhile); const tag = (0, ActionUtils_1.getStringInput)('TAG', { required: false }); - console.info('[awaitStagingDeploys] run() tag', tag); let currentStagingDeploys = []; - console.info('[awaitStagingDeploys] run() _.throttle', throttle_1.default); const throttleFunc = () => Promise.all([ // These are active deploys GithubUtils_1.default.octokit.actions.listWorkflowRuns({ @@ -12159,22 +12152,18 @@ function run() { }), ]) .then((responses) => { - console.info('[awaitStagingDeploys] listWorkflowRuns responses', responses); const workflowRuns = responses[0].data.workflow_runs; if (!tag && typeof responses[1] === 'object') { workflowRuns.push(...responses[1].data.workflow_runs); } - console.info('[awaitStagingDeploys] workflowRuns', workflowRuns); return workflowRuns; }) .then((workflowRuns) => (currentStagingDeploys = workflowRuns.filter((workflowRun) => workflowRun.status !== 'completed'))) .then(() => { - console.info('[awaitStagingDeploys] currentStagingDeploys', currentStagingDeploys); console.log(!currentStagingDeploys.length ? 'No current staging deploys found' : `Found ${currentStagingDeploys.length} staging deploy${currentStagingDeploys.length > 1 ? 's' : ''} still running...`); }); - console.info('[awaitStagingDeploys] run() throttleFunc', throttleFunc); return (0, promiseWhile_1.promiseDoWhile)(() => !!currentStagingDeploys.length, (0, throttle_1.default)(throttleFunc, // Poll every 60 seconds instead of every 10 seconds CONST_1.default.POLL_RATE * 6)); @@ -12730,7 +12719,6 @@ exports.promiseDoWhile = exports.promiseWhile = void 0; * Simulates a while loop where the condition is determined by the result of a Promise. */ function promiseWhile(condition, action) { - console.info('[promiseWhile] promiseWhile()'); return new Promise((resolve, reject) => { const loop = function () { if (!condition()) { @@ -12738,7 +12726,6 @@ function promiseWhile(condition, action) { } else { const actionResult = action?.(); - console.info('[promiseWhile] promiseWhile() actionResult', actionResult); if (!actionResult) { resolve(); return; @@ -12759,11 +12746,8 @@ exports.promiseWhile = promiseWhile; * Simulates a do-while loop where the condition is determined by the result of a Promise. */ function promiseDoWhile(condition, action) { - console.info('[promiseWhile] promiseDoWhile()'); return new Promise((resolve, reject) => { - console.info('[promiseWhile] promiseDoWhile() condition', condition); const actionResult = action?.(); - console.info('[promiseWhile] promiseDoWhile() actionResult', actionResult); if (!actionResult) { resolve(); return; diff --git a/.github/actions/javascript/bumpVersion/bumpVersion.ts b/.github/actions/javascript/bumpVersion/bumpVersion.ts index ed4828367cf2..eba79c7c9edb 100644 --- a/.github/actions/javascript/bumpVersion/bumpVersion.ts +++ b/.github/actions/javascript/bumpVersion/bumpVersion.ts @@ -1,6 +1,7 @@ import * as core from '@actions/core'; import {exec as originalExec} from 'child_process'; import fs from 'fs'; +import type {PackageJson} from 'type-fest'; import {promisify} from 'util'; import {generateAndroidVersionCode, updateAndroidVersion, updateiOSVersion} from '@github/libs/nativeVersionUpdater'; import * as versionUpdater from '@github/libs/versionUpdater'; @@ -19,7 +20,7 @@ function updateNativeVersions(version: string) { .then(() => { console.log('Successfully updated Android!'); }) - .catch((err) => { + .catch((err: string | Error) => { console.error('Error updating Android'); core.setFailed(err); }); @@ -47,8 +48,12 @@ if (!semanticVersionLevel || !Object.keys(versionUpdater.SEMANTIC_VERSION_LEVELS console.log(`Invalid input for 'SEMVER_LEVEL': ${semanticVersionLevel}`, `Defaulting to: ${semanticVersionLevel}`); } -const {version: previousVersion} = JSON.parse(fs.readFileSync('./package.json').toString()); -const newVersion = versionUpdater.incrementVersion(previousVersion, semanticVersionLevel); +const {version: previousVersion}: PackageJson = JSON.parse(fs.readFileSync('./package.json').toString()); +if (!previousVersion) { + core.setFailed('Error: Could not read package.json'); +} + +const newVersion = versionUpdater.incrementVersion(previousVersion ?? '', semanticVersionLevel); console.log(`Previous version: ${previousVersion}`, `New version: ${newVersion}`); updateNativeVersions(newVersion); diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js index d4a085fc9ddf..e1a5cf13a8d9 100644 --- a/.github/actions/javascript/bumpVersion/index.js +++ b/.github/actions/javascript/bumpVersion/index.js @@ -3478,7 +3478,10 @@ if (!semanticVersionLevel || !Object.keys(versionUpdater.SEMANTIC_VERSION_LEVELS console.log(`Invalid input for 'SEMVER_LEVEL': ${semanticVersionLevel}`, `Defaulting to: ${semanticVersionLevel}`); } const { version: previousVersion } = JSON.parse(fs_1.default.readFileSync('./package.json').toString()); -const newVersion = versionUpdater.incrementVersion(previousVersion, semanticVersionLevel); +if (!previousVersion) { + core.setFailed('Error: Could not read package.json'); +} +const newVersion = versionUpdater.incrementVersion(previousVersion ?? '', semanticVersionLevel); console.log(`Previous version: ${previousVersion}`, `New version: ${newVersion}`); updateNativeVersions(newVersion); console.log(`Setting npm version to ${newVersion}`); diff --git a/.github/actions/javascript/checkDeployBlockers/checkDeployBlockers.ts b/.github/actions/javascript/checkDeployBlockers/checkDeployBlockers.ts index bf94b136ce43..d51d68796070 100644 --- a/.github/actions/javascript/checkDeployBlockers/checkDeployBlockers.ts +++ b/.github/actions/javascript/checkDeployBlockers/checkDeployBlockers.ts @@ -60,7 +60,7 @@ const run = function (): Promise { core.setOutput('HAS_DEPLOY_BLOCKERS', false); } }) - .catch((error) => { + .catch((error: string | Error) => { console.error('A problem occurred while trying to communicate with the GitHub API', error); core.setFailed(error); }); diff --git a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts index 57a941105f90..5231caa79ed5 100644 --- a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts +++ b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts @@ -1,6 +1,15 @@ import * as core from '@actions/core'; import fs from 'fs'; +type RegressionEntry = { + metadata?: { + creationDate: string; + }; + name: string; + meanDuration: number; + meanCount: number; +}; + const run = () => { // Prefix path to the graphite metric const GRAPHITE_PATH = 'reassure'; @@ -24,7 +33,7 @@ const run = () => { } try { - const current = JSON.parse(entry); + const current: RegressionEntry = JSON.parse(entry); // Extract timestamp, Graphite accepts timestamp in seconds if (current.metadata?.creationDate) { diff --git a/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts b/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts index dc1e99d1e3b8..262b603124fa 100644 --- a/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts +++ b/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts @@ -1,5 +1,6 @@ import * as core from '@actions/core'; import {readFileSync} from 'fs'; +import type {PackageJson} from 'type-fest'; import * as versionUpdater from '@github/libs/versionUpdater'; const semverLevel = core.getInput('SEMVER_LEVEL', {required: true}); @@ -7,6 +8,10 @@ if (!semverLevel || !Object.values(versionUpdater.SEMANTIC_VERSION_LEVEL core.setFailed(`'Error: Invalid input for 'SEMVER_LEVEL': ${semverLevel}`); } -const {version: currentVersion} = JSON.parse(readFileSync('./package.json', 'utf8')); -const previousVersion = versionUpdater.getPreviousVersion(currentVersion, semverLevel); +const {version: currentVersion}: PackageJson = JSON.parse(readFileSync('./package.json', 'utf8')); +if (!currentVersion) { + core.setFailed('Error: Could not read package.json'); +} + +const previousVersion = versionUpdater.getPreviousVersion(currentVersion ?? '', semverLevel); core.setOutput('PREVIOUS_VERSION', previousVersion); diff --git a/.github/actions/javascript/getPreviousVersion/index.js b/.github/actions/javascript/getPreviousVersion/index.js index f372f0fdaf99..8eac2f62f03e 100644 --- a/.github/actions/javascript/getPreviousVersion/index.js +++ b/.github/actions/javascript/getPreviousVersion/index.js @@ -2728,7 +2728,10 @@ if (!semverLevel || !Object.values(versionUpdater.SEMANTIC_VERSION_LEVELS).inclu core.setFailed(`'Error: Invalid input for 'SEMVER_LEVEL': ${semverLevel}`); } const { version: currentVersion } = JSON.parse((0, fs_1.readFileSync)('./package.json', 'utf8')); -const previousVersion = versionUpdater.getPreviousVersion(currentVersion, semverLevel); +if (!currentVersion) { + core.setFailed('Error: Could not read package.json'); +} +const previousVersion = versionUpdater.getPreviousVersion(currentVersion ?? '', semverLevel); core.setOutput('PREVIOUS_VERSION', previousVersion); diff --git a/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts b/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts index aabc6b33086a..f57ef6c36a04 100644 --- a/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts +++ b/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts @@ -90,7 +90,7 @@ function checkIssueForCompletedChecklist(numberOfChecklistItems: number) { getNumberOfItemsFromReviewerChecklist() .then(checkIssueForCompletedChecklist) - .catch((err) => { + .catch((err: string | Error) => { console.error(err); core.setFailed(err); }); diff --git a/.github/libs/GitUtils.ts b/.github/libs/GitUtils.ts index 684e7c76aa86..dc8ae037be28 100644 --- a/.github/libs/GitUtils.ts +++ b/.github/libs/GitUtils.ts @@ -66,11 +66,11 @@ function getCommitHistoryAsJSON(fromTag: string, toTag: string): Promise { + spawnedProcess.stdout.on('data', (chunk: Buffer) => { console.log(chunk.toString()); stdout += chunk.toString(); }); - spawnedProcess.stderr.on('data', (chunk) => { + spawnedProcess.stderr.on('data', (chunk: Buffer) => { console.error(chunk.toString()); stderr += chunk.toString(); }); diff --git a/.github/libs/promiseWhile.ts b/.github/libs/promiseWhile.ts index 401b6ee2e18a..8bedceb894fd 100644 --- a/.github/libs/promiseWhile.ts +++ b/.github/libs/promiseWhile.ts @@ -4,15 +4,12 @@ import type {DebouncedFunc} from 'lodash'; * Simulates a while loop where the condition is determined by the result of a Promise. */ function promiseWhile(condition: () => boolean, action: (() => Promise) | DebouncedFunc<() => Promise> | undefined): Promise { - console.info('[promiseWhile] promiseWhile()'); - return new Promise((resolve, reject) => { const loop = function () { if (!condition()) { resolve(); } else { const actionResult = action?.(); - console.info('[promiseWhile] promiseWhile() actionResult', actionResult); if (!actionResult) { resolve(); @@ -35,12 +32,9 @@ function promiseWhile(condition: () => boolean, action: (() => Promise) | * Simulates a do-while loop where the condition is determined by the result of a Promise. */ function promiseDoWhile(condition: () => boolean, action: (() => Promise) | DebouncedFunc<() => Promise> | undefined): Promise { - console.info('[promiseWhile] promiseDoWhile()'); - return new Promise((resolve, reject) => { - console.info('[promiseWhile] promiseDoWhile() condition', condition); const actionResult = action?.(); - console.info('[promiseWhile] promiseDoWhile() actionResult', actionResult); + if (!actionResult) { resolve(); return; diff --git a/.github/libs/sanitizeStringForJSONParse.ts b/.github/libs/sanitizeStringForJSONParse.ts index 3fbdaa8661f8..ddb7549b0186 100644 --- a/.github/libs/sanitizeStringForJSONParse.ts +++ b/.github/libs/sanitizeStringForJSONParse.ts @@ -16,7 +16,7 @@ const replacer = (str: string): string => * Solution partly taken from SO user Gabriel Rodríguez Flores 🙇 * https://stackoverflow.com/questions/52789718/how-to-remove-special-characters-before-json-parse-while-file-reading */ -const sanitizeStringForJSONParse = (inputString: string): string => { +const sanitizeStringForJSONParse = (inputString: string | number | boolean | null | undefined): string => { if (typeof inputString !== 'string') { throw new TypeError('Input must me of type String'); } diff --git a/.github/scripts/detectRedirectCycle.ts b/.github/scripts/detectRedirectCycle.ts index 5aa0d1daf342..6da0ecba158c 100644 --- a/.github/scripts/detectRedirectCycle.ts +++ b/.github/scripts/detectRedirectCycle.ts @@ -52,7 +52,7 @@ function detectCycle(): boolean { fs.createReadStream(`${process.cwd()}/docs/redirects.csv`) .pipe(parser) - .on('data', (row) => { + .on('data', (row: [string, string]) => { // Create a directed graph of sourceURL -> targetURL addEdge(row[0], row[1]); }) diff --git a/.github/scripts/enforceRedirect.sh b/.github/scripts/enforceRedirect.sh new file mode 100755 index 000000000000..4d7d169b01c5 --- /dev/null +++ b/.github/scripts/enforceRedirect.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# HelpDot - Whenever an article is moved/renamed/deleted we should verify that +# we have added a redirect link for it in redirects.csv. This ensures that we don't have broken links. + +declare -r RED='\033[0;31m' +declare -r GREEN='\033[0;32m' +declare -r NC='\033[0m' + +declare -r ARTICLES_DIRECTORY="docs/articles" +declare -r REDIRECTS_FILE="docs/redirects.csv" + +hasRenamedOrDeletedArticle=false +hasModifiedRedirect=false + +if git log origin/main..HEAD --name-status --pretty=format: $ARTICLES_DIRECTORY | grep -q -E "^(R|D)" +then + echo "Articles have been renamed/moved/deleted" + hasRenamedOrDeletedArticle=true +fi + +if git log origin/main..HEAD --name-status --pretty=format: $REDIRECTS_FILE | grep -q -E "^(M)" +then + echo "Redirects.csv has been modified" + hasModifiedRedirect=true +fi + +if [[ $hasRenamedOrDeletedArticle == true && $hasModifiedRedirect == false ]] +then + echo -e "${RED}Articles have been renamed or deleted. Please add a redirect link for the old article links in redirects.csv${NC}" + exit 1 +fi + +echo -e "${GREEN}Articles aren't moved or deleted, or a redirect has been added. Please verify that a redirect has been added for all the files moved or deleted${NC}" +exit 0 diff --git a/.github/scripts/printPodspec.rb b/.github/scripts/printPodspec.rb new file mode 100755 index 000000000000..80012edbc0aa --- /dev/null +++ b/.github/scripts/printPodspec.rb @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby + +# This file is a lightweight port of the `pod ipc spec` command. +# It was built from scratch to imports some 3rd party functions before reading podspecs + +require 'cocoapods' +require 'json' + +# Require 3rd party functions needed to parse podspecs. This code is copied from ios/Podfile +def node_require(script) + # Resolve script with node to allow for hoisting + require Pod::Executable.execute_command('node', ['-p', + "require.resolve( + '#{script}', + {paths: [process.argv[1]]}, + )", __dir__]).strip +end +node_require('react-native/scripts/react_native_pods.rb') +node_require('react-native-permissions/scripts/setup.rb') + +# Configure pod in silent mode +Pod::Config.instance.silent = true + +# Process command-line arguments +podspec_files = ARGV + +# Validate each podspec file +podspec_files.each do |podspec_file| + begin + spec = Pod::Specification.from_file(podspec_file) + puts(spec.to_pretty_json) + rescue => e + STDERR.puts "Failed to validate #{podspec_file}: #{e.message}" + end +end diff --git a/.github/scripts/verifyPodfile.sh b/.github/scripts/verifyPodfile.sh index cd94a49bb091..0d04d8f1b3ed 100755 --- a/.github/scripts/verifyPodfile.sh +++ b/.github/scripts/verifyPodfile.sh @@ -8,7 +8,12 @@ source scripts/shellUtils.sh title "Verifying that Podfile.lock is synced with the project" -declare EXIT_CODE=0 +# Cleanup and exit +# param - status code +function cleanupAndExit { + cd "$START_DIR" || exit 1 + exit "$1" +} # Check Provisioning Style. If automatic signing is enabled, iOS builds will fail, so ensure we always have the proper profile specified info "Verifying that automatic signing is not enabled" @@ -16,7 +21,7 @@ if grep -q 'PROVISIONING_PROFILE_SPECIFIER = "(NewApp) AppStore"' ios/NewExpensi success "Automatic signing not enabled" else error "Error: Automatic provisioning style is not allowed!" - EXIT_CODE=1 + cleanupAndExit 1 fi PODFILE_SHA=$(openssl sha1 ios/Podfile | awk '{print $2}') @@ -29,7 +34,7 @@ if [[ "$PODFILE_SHA" == "$PODFILE_LOCK_SHA" ]]; then success "Podfile checksum verified!" else error "Podfile.lock checksum mismatch. Did you forget to run \`npx pod-install\`?" - EXIT_CODE=1 + cleanupAndExit 1 fi info "Ensuring correct version of cocoapods is used..." @@ -45,29 +50,36 @@ if [[ "$POD_VERSION_FROM_GEMFILE" == "$POD_VERSION_FROM_PODFILE_LOCK" ]]; then success "Cocoapods version from Podfile.lock matches cocoapods version from Gemfile" else error "Cocoapods version from Podfile.lock does not match cocoapods version from Gemfile. Please use \`npm run pod-install\` or \`bundle exec pod install\` instead of \`pod install\` to install pods." - EXIT_CODE=1 + cleanupAndExit 1 fi info "Comparing Podfile.lock with node packages..." # Retrieve a list of podspec directories as listed in the Podfile.lock -SPEC_DIRS=$(yq '.["EXTERNAL SOURCES"].[].":path" | select( . == "*node_modules*")' < ios/Podfile.lock) +if ! SPEC_DIRS=$(yq '.["EXTERNAL SOURCES"].[].":path" | select( . == "*node_modules*")' < ios/Podfile.lock); then + error "Error: Could not parse podspec directories from Podfile.lock" + cleanupAndExit 1 +fi + +if ! read_lines_into_array PODSPEC_PATHS < <(npx react-native config | jq --raw-output '.dependencies[].platforms.ios.podspecPath | select ( . != null)'); then + error "Error: could not parse podspec paths from react-native config command" + cleanupAndExit 1 +fi # Format a list of Pods based on the output of the config command -FORMATTED_PODS=$( \ - jq --raw-output --slurp 'map((.name + " (" + .version + ")")) | .[]' <<< "$( \ - npx react-native config | \ - jq '.dependencies[].platforms.ios.podspecPath | select( . != null )' | \ - xargs -L 1 pod ipc spec --silent - )" -) +if ! FORMATTED_PODS=$( \ + jq --raw-output --slurp 'map((.name + " (" + .version + ")")) | .[]' <<< "$(./.github/scripts/printPodspec.rb "${PODSPEC_PATHS[@]}")" \ +); then + error "Error: could not parse podspecs at paths parsed from react-native config" + cleanupAndExit 1 +fi # Check for uncommitted package removals # If they are listed in Podfile.lock but the directories don't exist they have been removed while read -r DIR; do if [[ ! -d "${DIR#../}" ]]; then error "Directory \`${DIR#../node_modules/}\` not found in node_modules. Did you forget to run \`npx pod-install\` after removing the package?" - EXIT_CODE=1 + cleanupAndExit 1 fi done <<< "$SPEC_DIRS" @@ -75,15 +87,9 @@ done <<< "$SPEC_DIRS" while read -r POD; do if ! grep -q "$POD" ./ios/Podfile.lock; then error "$POD not found in Podfile.lock. Did you forget to run \`npx pod-install\`?" - EXIT_CODE=1 + cleanupAndExit 1 fi done <<< "$FORMATTED_PODS" -if [[ "$EXIT_CODE" == 0 ]]; then - success "Podfile.lock is up to date." -fi - -# Cleanup -cd "$START_DIR" || exit 1 - -exit $EXIT_CODE +success "Podfile.lock is up to date." +cleanupAndExit 0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 63148f9e4eb5..624c00de6831 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,7 +14,7 @@ jobs: with: ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - + - name: Setup git for OSBotify uses: ./.github/actions/composite/setupGitForOSBotifyApp id: setupGitForOSBotify @@ -65,9 +65,6 @@ jobs: PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} - name: 🚀 Create release to trigger production deploy 🚀 - uses: softprops/action-gh-release@affa18ef97bc9db20076945705aba8c516139abd - with: - tag_name: ${{ env.PRODUCTION_VERSION }} - body: ${{ steps.getReleaseBody.outputs.RELEASE_BODY }} + run: gh release create ${{ env.PRODUCTION_VERSION }} --generate-notes env: GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index cda33d39102e..8a6237832340 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -29,9 +29,12 @@ jobs: env: IS_PR_FROM_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest + continue-on-error: true steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode @@ -42,6 +45,9 @@ jobs: - name: Check for duplicates and cycles in redirects.csv run: ./.github/scripts/verifyRedirect.sh + - name: Enforce that a redirect link has been created + run: ./.github/scripts/enforceRedirect.sh + - name: Build with Jekyll uses: actions/jekyll-build-pages@0143c158f4fa0c5dcd99499a5d00859d79f70b0e with: diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 10723d5efa04..7e7d55ac5d2e 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -15,6 +15,10 @@ on: type: string required: true +concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-e2e + cancel-in-progress: true + jobs: buildBaseline: runs-on: ubuntu-latest-xl diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 50e886942c98..da7757fcbfa8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,7 +8,7 @@ on: paths: ['**.js', '**.ts', '**.tsx', '**.json', '**.mjs', '**.cjs', 'config/.editorconfig', '.watchmanconfig', '.imgbotconfig'] concurrency: - group: "${{ github.ref }}-lint" + group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-lint cancel-in-progress: true jobs: diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 353a898a941f..5ceb760a452b 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -134,7 +134,7 @@ jobs: name: Build and deploy Desktop needs: validateActor if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - runs-on: macos-13-large + runs-on: macos-14-large steps: - name: Checkout uses: actions/checkout@v4 @@ -366,6 +366,17 @@ jobs: with: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + # Build a version of iOS and Android HybridApp if we are deploying to staging + hybridApp: + runs-on: ubuntu-latest + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) && github.event_name == 'push' }} + steps: + - name: 'Deploy HybridApp' + run: gh workflow run --repo Expensify/Mobile-Deploy deploy.yml -f force_build=true + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + postSlackMessageOnSuccess: name: Post a Slack message when all platforms deploy successfully runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 71b4bc3d8fc3..d6b346cb3995 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: paths: ['**.js', '**.ts', '**.tsx', '**.sh', 'package.json', 'package-lock.json'] concurrency: - group: "${{ github.ref }}-jest" + group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-jest cancel-in-progress: true jobs: @@ -41,7 +41,7 @@ jobs: key: ${{ runner.os }}-jest - name: Jest tests - run: npx jest --silent --shard=${{ fromJSON(matrix.chunk) }}/${{ strategy.job-total }} --max-workers ${{ steps.cpu-cores.outputs.count }} + run: NODE_OPTIONS="$NODE_OPTIONS --experimental-vm-modules" npx jest --silent --shard=${{ fromJSON(matrix.chunk) }}/${{ strategy.job-total }} --max-workers ${{ steps.cpu-cores.outputs.count }} storybookTests: if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' || github.event_name == 'workflow_call' }} diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index fe6ea5bfc016..10912aaeb436 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -228,7 +228,7 @@ jobs: if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} env: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} - runs-on: macos-13-large + runs-on: macos-14-large steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/testGithubActionsWorkflows.yml b/.github/workflows/testGithubActionsWorkflows.yml index d052b343cf0c..f65319f14be4 100644 --- a/.github/workflows/testGithubActionsWorkflows.yml +++ b/.github/workflows/testGithubActionsWorkflows.yml @@ -25,6 +25,13 @@ jobs: - name: Setup Homebrew uses: Homebrew/actions/setup-homebrew@master + - name: Login to GitHub Container Regstry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: OSBotify + password: ${{ secrets.GITHUB_TOKEN }} + - name: Install Act run: brew install act diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 88d4d24a5723..476b01f87b07 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -7,6 +7,10 @@ on: branches-ignore: [staging, production] paths: ['**.js', '**.ts', '**.tsx', 'package.json', 'package-lock.json', 'tsconfig.json'] +concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-typecheck + cancel-in-progress: true + jobs: typecheck: if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }} diff --git a/.gitignore b/.gitignore index aeee5f730bfc..dcbec8a96e46 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,11 @@ local.properties android/app/src/main/java/com/expensify/chat/generated/ .cxx/ +# VIM +*.swp +*.swo +*~ + # Vscode .vscode diff --git a/.nvmrc b/.nvmrc index d5a159609d09..48b14e6b2b56 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.10.0 +20.14.0 diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ec8e17dda4cf..7527857eeda7 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -16,7 +16,7 @@ import './fonts.css'; Onyx.init({ keys: ONYXKEYS, initialKeyStates: { - [ONYXKEYS.NETWORK]: {isOffline: false}, + [ONYXKEYS.NETWORK]: {isOffline: false, isBackendReachable: true}, }, }); diff --git a/.storybook/public/favicon.svg b/.storybook/public/favicon.svg index 6bc34f89282e..726791b58cfb 100644 --- a/.storybook/public/favicon.svg +++ b/.storybook/public/favicon.svg @@ -1,11 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/.storybook/webpack.config.ts b/.storybook/webpack.config.ts index 4d638020cd42..09d881846b1e 100644 --- a/.storybook/webpack.config.ts +++ b/.storybook/webpack.config.ts @@ -3,6 +3,7 @@ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/naming-convention */ +import type Environment from 'config/webpack/types'; import dotenv from 'dotenv'; import path from 'path'; import {DefinePlugin} from 'webpack'; @@ -18,6 +19,8 @@ type CustomWebpackConfig = { }; }; +type CustomWebpackFunction = ({file, platform}: Environment) => CustomWebpackConfig; + let envFile: string; switch (process.env.ENV) { case 'production': @@ -31,9 +34,9 @@ switch (process.env.ENV) { } const env = dotenv.config({path: path.resolve(__dirname, `../${envFile}`)}); -const custom: CustomWebpackConfig = require('../config/webpack/webpack.common').default({ - envFile, -}); +const customFunction: CustomWebpackFunction = require('../config/webpack/webpack.common').default; + +const custom: CustomWebpackConfig = customFunction({file: envFile}); const webpackConfig = ({config}: {config: Configuration}) => { if (!config.resolve) { diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index d4b708ccea4d..0b93611527c5 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -57,3 +57,5 @@ compliance with this Code is required to maintain your status as an Expensify co By signing up to participate as an contributor, you are acknowledging your understanding of and consent to (i) what is expected of you under this Code and (ii) notwithstanding anything to the contrary in any agreement you have with Expensify, Expensify’s right, but not obligation, to terminate your participation in the Expensify Contributor Community upon any breach of the Code, as determined in Expensify’s sole Discretion. + +Violations of our two rules may lead to removal from the contributor program. Severe violations can lead to an immediate ban, while lesser ones may result in a formal warning. Multiple warnings will also lead to removal. diff --git a/README.md b/README.md index 29a9e9b8ffdc..6544e0e95486 100644 --- a/README.md +++ b/README.md @@ -663,7 +663,38 @@ Sometimes it might be beneficial to generate a local production version instead 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`. +The commands used to compile a production or staging desktop build are `npm run desktop-build` and `npm run desktop-build-staging`, respectively. These will product an app in the `dist/Mac` folder named NewExpensify.dmg that you can install like a normal app. + +HOWEVER, by default those commands will try to notarize the build (signing it as Expensify) and publish it to the S3 bucket where it's hosted for users. In most cases you won't actually need or want to do that for your local testing. To get around that and disable those behaviors for your local build, apply the following diff: + +```diff +diff --git a/config/electronBuilder.config.js b/config/electronBuilder.config.js +index e4ed685f65..4c7c1b3667 100644 +--- a/config/electronBuilder.config.js ++++ b/config/electronBuilder.config.js +@@ -42,9 +42,6 @@ module.exports = { + entitlements: 'desktop/entitlements.mac.plist', + entitlementsInherit: 'desktop/entitlements.mac.plist', + type: 'distribution', +- notarize: { +- teamId: '368M544MTT', +- }, + }, + dmg: { + title: 'New Expensify', +diff --git a/scripts/build-desktop.sh b/scripts/build-desktop.sh +index 791f59d733..526306eec1 100755 +--- a/scripts/build-desktop.sh ++++ b/scripts/build-desktop.sh +@@ -35,4 +35,4 @@ npx webpack --config config/webpack/webpack.desktop.ts --env file=$ENV_FILE + title "Building Desktop App Archive Using Electron" + info "" + shift 1 +-npx electron-builder --config config/electronBuilder.config.js --publish always "$@" ++npx electron-builder --config config/electronBuilder.config.js --publish never "$@" +``` + +There may be some cases where you need to test a signed and published build, such as when testing the update flows. Instructions on setting that up can be found in [Testing Electron Auto-Update](https://github.com/Expensify/App/blob/main/desktop/README.md#testing-electron-auto-update). Good luck 🙃 #### 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. diff --git a/__mocks__/@react-navigation/native/index.ts b/__mocks__/@react-navigation/native/index.ts index 9a6680ba0b6e..0b7dda4621ad 100644 --- a/__mocks__/@react-navigation/native/index.ts +++ b/__mocks__/@react-navigation/native/index.ts @@ -3,9 +3,7 @@ import {useIsFocused as realUseIsFocused, useTheme as realUseTheme} from '@react // We only want these mocked for storybook, not jest const useIsFocused: typeof realUseIsFocused = process.env.NODE_ENV === 'test' ? realUseIsFocused : () => true; -// @ts-expect-error as we're mocking this function -const useTheme: typeof realUseTheme = process.env.NODE_ENV === 'test' ? realUseTheme : () => ({}); +const useTheme = process.env.NODE_ENV === 'test' ? realUseTheme : () => ({}); export * from '@react-navigation/core'; -export * from '@react-navigation/native'; export {useIsFocused, useTheme}; diff --git a/__mocks__/react-native-permissions.ts b/__mocks__/react-native-permissions.ts index 67b7db830d94..d98b7f32a611 100644 --- a/__mocks__/react-native-permissions.ts +++ b/__mocks__/react-native-permissions.ts @@ -35,30 +35,30 @@ const requestNotifications: jest.Mock = jest.fn((options: Record notificationOptions.includes(option)) - .reduce((acc: NotificationSettings, option: string) => ({...acc, [option]: true}), { - lockScreen: true, - notificationCenter: true, - }), + .reduce( + (acc: NotificationSettings, option: string) => { + acc[option] = true; + return acc; + }, + { + lockScreen: true, + notificationCenter: true, + }, + ), })); const checkMultiple: jest.Mock = jest.fn((permissions: string[]) => - permissions.reduce( - (acc: ResultsCollection, permission: string) => ({ - ...acc, - [permission]: RESULTS.GRANTED, - }), - {}, - ), + permissions.reduce((acc: ResultsCollection, permission: string) => { + acc[permission] = RESULTS.GRANTED; + return acc; + }, {}), ); const requestMultiple: jest.Mock = jest.fn((permissions: string[]) => - permissions.reduce( - (acc: ResultsCollection, permission: string) => ({ - ...acc, - [permission]: RESULTS.GRANTED, - }), - {}, - ), + permissions.reduce((acc: ResultsCollection, permission: string) => { + acc[permission] = RESULTS.GRANTED; + return acc; + }, {}), ); export { diff --git a/android/app/build.gradle b/android/app/build.gradle index 713268c07b1f..05f6916548f0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -2,12 +2,21 @@ apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" apply plugin: "com.google.firebase.firebase-perf" +apply plugin: "fullstory" apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. */ + +/* Fullstory settings */ +fullstory { + org 'o-1WN56P-na1' + enabledVariants 'all' + logcatLevel 'debug' +} + react { /* Folders */ // The root of your project, i.e. where "package.json" lives. Default is '..' @@ -98,8 +107,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001047102 - versionName "1.4.71-2" + versionCode 1001048501 + versionName "1.4.85-1" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" @@ -162,7 +171,7 @@ android { signingConfig null // buildTypes take precedence over productFlavors when it comes to the signing configuration, // thus we need to manually set the signing config, so that the e2e uses the debug config again. - // In other words, the signingConfig setting above will be ignored when we build the flavor in release mode. + // In other words, the signingConfig setting above will be ignored when we build the flavor in release mode. productFlavors.all { flavor -> // All release builds should be signed with the release config ... flavor.signingConfig signingConfigs.release diff --git a/android/build.gradle b/android/build.gradle index 10600480d8bb..9fc585ab9f05 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -20,6 +20,7 @@ buildscript { repositories { google() mavenCentral() + maven {url "https://maven.fullstory.com"} } dependencies { classpath("com.android.tools.build:gradle") @@ -27,6 +28,9 @@ buildscript { classpath("com.google.gms:google-services:4.3.4") classpath("com.google.firebase:firebase-crashlytics-gradle:2.7.1") classpath("com.google.firebase:perf-plugin:1.4.1") + // Fullstory integration + classpath ("com.fullstory:gradle-plugin-local:1.49.0") + // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") @@ -70,7 +74,7 @@ allprojects { // 'mapbox' is the fixed username for Mapbox's Maven repository. username = 'mapbox' - // The value for password is read from the 'MAPBOX_DOWNLOADS_TOKEN' gradle property. + // The value for password is read from the 'MAPBOX_DOWNLOADS_TOKEN' gradle property. // Run "npm run setup-mapbox-sdk" to set this property in «USER_HOME»/.gradle/gradle.properties // Example gradle.properties entry: diff --git a/assets/animations/Abracadabra.lottie b/assets/animations/Abracadabra.lottie new file mode 100644 index 000000000000..8805aed1944e Binary files /dev/null and b/assets/animations/Abracadabra.lottie differ diff --git a/assets/emojis/index.ts b/assets/emojis/index.ts index 02328001674e..d230e8eec2be 100644 --- a/assets/emojis/index.ts +++ b/assets/emojis/index.ts @@ -1,7 +1,5 @@ import type {Locale} from '@src/types/onyx'; import emojis from './common'; -import enEmojis from './en'; -import esEmojis from './es'; import type {Emoji, EmojisList} from './types'; type EmojiTable = Record; @@ -30,10 +28,22 @@ const emojiCodeTableWithSkinTones = emojis.reduce((prev, cur) => { }, {}); const localeEmojis: LocaleEmojis = { - en: enEmojis, - es: esEmojis, + en: undefined, + es: undefined, +}; + +const importEmojiLocale = (locale: Locale) => { + const normalizedLocale = locale.toLowerCase().split('-')[0] as Locale; + if (!localeEmojis[normalizedLocale]) { + const emojiImportPromise = normalizedLocale === 'en' ? import('./en') : import('./es'); + return emojiImportPromise.then((esEmojiModule) => { + // it is needed because in jest test the modules are imported in double nested default object + localeEmojis[normalizedLocale] = esEmojiModule.default.default ? (esEmojiModule.default.default as unknown as EmojisList) : esEmojiModule.default; + }); + } + return Promise.resolve(); }; export default emojis; -export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis}; +export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis, importEmojiLocale}; export {skinTones, categoryFrequentlyUsed} from './common'; diff --git a/assets/images/all.svg b/assets/images/all.svg index d1a833d280ce..f6d9f46fc92e 100644 --- a/assets/images/all.svg +++ b/assets/images/all.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/arrow-down-long.svg b/assets/images/arrow-down-long.svg new file mode 100644 index 000000000000..cbf6e7e5ad2f --- /dev/null +++ b/assets/images/arrow-down-long.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/arrow-right.svg b/assets/images/arrow-right.svg index 8d2ded92e791..649582544847 100644 --- a/assets/images/arrow-right.svg +++ b/assets/images/arrow-right.svg @@ -1,10 +1 @@ - - - - - - + \ No newline at end of file diff --git a/assets/images/arrow-up-long.svg b/assets/images/arrow-up-long.svg new file mode 100644 index 000000000000..13d7a0c2d67e --- /dev/null +++ b/assets/images/arrow-up-long.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/avatars/fallback-avatar.svg b/assets/images/avatars/fallback-avatar.svg index 69293d72aed9..4a7fecf967db 100644 --- a/assets/images/avatars/fallback-avatar.svg +++ b/assets/images/avatars/fallback-avatar.svg @@ -1,10 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_1.svg b/assets/images/avatars/group/default-avatar_1.svg index 5d97c5bf855b..1edcaa33a8aa 100644 --- a/assets/images/avatars/group/default-avatar_1.svg +++ b/assets/images/avatars/group/default-avatar_1.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_10.svg b/assets/images/avatars/group/default-avatar_10.svg index 12c9dd76ae31..62e818cb3e45 100644 --- a/assets/images/avatars/group/default-avatar_10.svg +++ b/assets/images/avatars/group/default-avatar_10.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_11.svg b/assets/images/avatars/group/default-avatar_11.svg index 97f17f30f3a7..2f976b05519d 100644 --- a/assets/images/avatars/group/default-avatar_11.svg +++ b/assets/images/avatars/group/default-avatar_11.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_12.svg b/assets/images/avatars/group/default-avatar_12.svg index f917fb136582..c29992aa1793 100644 --- a/assets/images/avatars/group/default-avatar_12.svg +++ b/assets/images/avatars/group/default-avatar_12.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_13.svg b/assets/images/avatars/group/default-avatar_13.svg index 9e59fb9123a5..5f6b69f01fe3 100644 --- a/assets/images/avatars/group/default-avatar_13.svg +++ b/assets/images/avatars/group/default-avatar_13.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_14.svg b/assets/images/avatars/group/default-avatar_14.svg index ca071e488416..27096ffd77d7 100644 --- a/assets/images/avatars/group/default-avatar_14.svg +++ b/assets/images/avatars/group/default-avatar_14.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_15.svg b/assets/images/avatars/group/default-avatar_15.svg index f227cc0717be..7cae7b1e6562 100644 --- a/assets/images/avatars/group/default-avatar_15.svg +++ b/assets/images/avatars/group/default-avatar_15.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_16.svg b/assets/images/avatars/group/default-avatar_16.svg index efbb85f0b13d..1c02725ba669 100644 --- a/assets/images/avatars/group/default-avatar_16.svg +++ b/assets/images/avatars/group/default-avatar_16.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_17.svg b/assets/images/avatars/group/default-avatar_17.svg index 25c015c595ca..58a5014fae68 100644 --- a/assets/images/avatars/group/default-avatar_17.svg +++ b/assets/images/avatars/group/default-avatar_17.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_18.svg b/assets/images/avatars/group/default-avatar_18.svg index a58ee6e66eff..43eeffb3db8d 100644 --- a/assets/images/avatars/group/default-avatar_18.svg +++ b/assets/images/avatars/group/default-avatar_18.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_2.svg b/assets/images/avatars/group/default-avatar_2.svg index ff1cc3e6dd2d..f67a49d28cd2 100644 --- a/assets/images/avatars/group/default-avatar_2.svg +++ b/assets/images/avatars/group/default-avatar_2.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_3.svg b/assets/images/avatars/group/default-avatar_3.svg index dde31b5d02a0..471d3a348b4a 100644 --- a/assets/images/avatars/group/default-avatar_3.svg +++ b/assets/images/avatars/group/default-avatar_3.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_4.svg b/assets/images/avatars/group/default-avatar_4.svg index f6d02801bc6b..46e22d28b6df 100644 --- a/assets/images/avatars/group/default-avatar_4.svg +++ b/assets/images/avatars/group/default-avatar_4.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_5.svg b/assets/images/avatars/group/default-avatar_5.svg index fdabd36e2058..a81471170e23 100644 --- a/assets/images/avatars/group/default-avatar_5.svg +++ b/assets/images/avatars/group/default-avatar_5.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_6.svg b/assets/images/avatars/group/default-avatar_6.svg index 6f1c6b80eda6..71da5e5631f3 100644 --- a/assets/images/avatars/group/default-avatar_6.svg +++ b/assets/images/avatars/group/default-avatar_6.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_7.svg b/assets/images/avatars/group/default-avatar_7.svg index 62d9a8b76bb8..080426ca0454 100644 --- a/assets/images/avatars/group/default-avatar_7.svg +++ b/assets/images/avatars/group/default-avatar_7.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_8.svg b/assets/images/avatars/group/default-avatar_8.svg index 206b10c2322b..b6b2d98579eb 100644 --- a/assets/images/avatars/group/default-avatar_8.svg +++ b/assets/images/avatars/group/default-avatar_8.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/avatars/group/default-avatar_9.svg b/assets/images/avatars/group/default-avatar_9.svg index ffbe02ce57e8..14885d4c401c 100644 --- a/assets/images/avatars/group/default-avatar_9.svg +++ b/assets/images/avatars/group/default-avatar_9.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/back-left.svg b/assets/images/back-left.svg index 2ddd554e9720..2c709401916f 100644 --- a/assets/images/back-left.svg +++ b/assets/images/back-left.svg @@ -1,10 +1 @@ - - - - - - + \ No newline at end of file diff --git a/assets/images/bed.svg b/assets/images/bed.svg new file mode 100644 index 000000000000..8ee733733ab2 --- /dev/null +++ b/assets/images/bed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/car-with-key.svg b/assets/images/car-with-key.svg new file mode 100644 index 000000000000..e4770fdad970 --- /dev/null +++ b/assets/images/car-with-key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/check-circle.svg b/assets/images/check-circle.svg new file mode 100644 index 000000000000..3f6f1da4f827 --- /dev/null +++ b/assets/images/check-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/checkmark-circle.svg b/assets/images/checkmark-circle.svg new file mode 100644 index 000000000000..102598b55d8a --- /dev/null +++ b/assets/images/checkmark-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/coins.svg b/assets/images/coins.svg index aa3c68e72ea8..164fa84388f5 100644 --- a/assets/images/coins.svg +++ b/assets/images/coins.svg @@ -1,8 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/assets/images/comment-bubbles.svg b/assets/images/comment-bubbles.svg new file mode 100644 index 000000000000..1277b8958c94 --- /dev/null +++ b/assets/images/comment-bubbles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/connection-complete.svg b/assets/images/connection-complete.svg index fbfb2b041358..d864d9a33626 100644 --- a/assets/images/connection-complete.svg +++ b/assets/images/connection-complete.svg @@ -1,330 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/credit-card-exclamation.svg b/assets/images/credit-card-exclamation.svg new file mode 100644 index 000000000000..9cf946a56a5c --- /dev/null +++ b/assets/images/credit-card-exclamation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/credit-card-hourglass.svg b/assets/images/credit-card-hourglass.svg new file mode 100644 index 000000000000..28ffe766b597 --- /dev/null +++ b/assets/images/credit-card-hourglass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/crosshair.svg b/assets/images/crosshair.svg new file mode 100644 index 000000000000..66ee21774d02 --- /dev/null +++ b/assets/images/crosshair.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/document-plus.svg b/assets/images/document-plus.svg index cce2e3027cea..729bc98d4f8a 100644 --- a/assets/images/document-plus.svg +++ b/assets/images/document-plus.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/assets/images/document-slash.svg b/assets/images/document-slash.svg index ebb183142e40..e8a0ff20702e 100644 --- a/assets/images/document-slash.svg +++ b/assets/images/document-slash.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/assets/images/emptystate__routepending.svg b/assets/images/emptystate__routepending.svg index 90f3296d37d6..685696f04abf 100644 --- a/assets/images/emptystate__routepending.svg +++ b/assets/images/emptystate__routepending.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/inbox.svg b/assets/images/inbox.svg new file mode 100644 index 000000000000..29ab7f916616 --- /dev/null +++ b/assets/images/inbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/integrationicons/qbo-icon-square.svg b/assets/images/integrationicons/qbo-icon-square.svg index a8ce3468ffbf..e297b597f980 100644 --- a/assets/images/integrationicons/qbo-icon-square.svg +++ b/assets/images/integrationicons/qbo-icon-square.svg @@ -1,14 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/integrationicons/xero-icon-square.svg b/assets/images/integrationicons/xero-icon-square.svg index 94b79bb3533d..43774919c92c 100644 --- a/assets/images/integrationicons/xero-icon-square.svg +++ b/assets/images/integrationicons/xero-icon-square.svg @@ -1,32 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/invoice-generic.svg b/assets/images/invoice-generic.svg index d0e2662c4084..251918c4cff4 100644 --- a/assets/images/invoice-generic.svg +++ b/assets/images/invoice-generic.svg @@ -1,15 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/money-search.svg b/assets/images/money-search.svg new file mode 100644 index 000000000000..72a77352f861 --- /dev/null +++ b/assets/images/money-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/money-waving.svg b/assets/images/money-waving.svg index 5242e31092a0..e68744d595be 100644 --- a/assets/images/money-waving.svg +++ b/assets/images/money-waving.svg @@ -1,81 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/new-expensify-adhoc.svg b/assets/images/new-expensify-adhoc.svg index b3dd92fbbaae..8da6331c8c94 100644 --- a/assets/images/new-expensify-adhoc.svg +++ b/assets/images/new-expensify-adhoc.svg @@ -1,31 +1 @@ - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/new-expensify-dev.svg b/assets/images/new-expensify-dev.svg index 316da6b5aa4d..fcb371f586b6 100644 --- a/assets/images/new-expensify-dev.svg +++ b/assets/images/new-expensify-dev.svg @@ -1,27 +1 @@ - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/new-expensify-stg.svg b/assets/images/new-expensify-stg.svg index 1a1994c7a9fd..d536257fc880 100644 --- a/assets/images/new-expensify-stg.svg +++ b/assets/images/new-expensify-stg.svg @@ -1,35 +1 @@ - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/plane.svg b/assets/images/plane.svg new file mode 100644 index 000000000000..635bdc4b1ed7 --- /dev/null +++ b/assets/images/plane.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/play.svg b/assets/images/play.svg index 5f7e14969529..98a8c00520fc 100644 --- a/assets/images/play.svg +++ b/assets/images/play.svg @@ -1,6 +1 @@ - - - - - + \ No newline at end of file diff --git a/assets/images/product-illustrations/emptystate__travel.svg b/assets/images/product-illustrations/emptystate__travel.svg new file mode 100644 index 000000000000..287f99996860 --- /dev/null +++ b/assets/images/product-illustrations/emptystate__travel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/qrcode.svg b/assets/images/qrcode.svg index 42c49c958246..47d61d7dd47c 100644 --- a/assets/images/qrcode.svg +++ b/assets/images/qrcode.svg @@ -1,14 +1 @@ - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/receipt-plus.svg b/assets/images/receipt-plus.svg new file mode 100644 index 000000000000..ca4d96b3dfa5 --- /dev/null +++ b/assets/images/receipt-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/receipt-scan.svg b/assets/images/receipt-scan.svg index ecdf3cf2e115..f7c164c948c8 100644 --- a/assets/images/receipt-scan.svg +++ b/assets/images/receipt-scan.svg @@ -1,14 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/assets/images/receipt-slash.svg b/assets/images/receipt-slash.svg new file mode 100644 index 000000000000..f7e7457e3e64 --- /dev/null +++ b/assets/images/receipt-slash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__abacus.svg b/assets/images/simple-illustrations/simple-illustration__abacus.svg index df94ab653982..6dac0e9009b1 100644 --- a/assets/images/simple-illustrations/simple-illustration__abacus.svg +++ b/assets/images/simple-illustrations/simple-illustration__abacus.svg @@ -1,43 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__accounting.svg b/assets/images/simple-illustrations/simple-illustration__accounting.svg index f7634141e966..3213b4f93856 100644 --- a/assets/images/simple-illustrations/simple-illustration__accounting.svg +++ b/assets/images/simple-illustrations/simple-illustration__accounting.svg @@ -1,32 +1 @@ - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__alert.svg b/assets/images/simple-illustrations/simple-illustration__alert.svg index 2e7bca02f5e3..cbf70b7655a7 100644 --- a/assets/images/simple-illustrations/simple-illustration__alert.svg +++ b/assets/images/simple-illustrations/simple-illustration__alert.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__binoculars.svg b/assets/images/simple-illustrations/simple-illustration__binoculars.svg index 381be8988873..5abacd359464 100644 --- a/assets/images/simple-illustrations/simple-illustration__binoculars.svg +++ b/assets/images/simple-illustrations/simple-illustration__binoculars.svg @@ -1,50 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__car-ice.svg b/assets/images/simple-illustrations/simple-illustration__car-ice.svg index ba2b79bca6aa..9da1b844c101 100644 --- a/assets/images/simple-illustrations/simple-illustration__car-ice.svg +++ b/assets/images/simple-illustrations/simple-illustration__car-ice.svg @@ -1,53 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__car.svg b/assets/images/simple-illustrations/simple-illustration__car.svg index 2d420be6c3a9..9da1b844c101 100644 --- a/assets/images/simple-illustrations/simple-illustration__car.svg +++ b/assets/images/simple-illustrations/simple-illustration__car.svg @@ -1,25 +1 @@ - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__checkmarkcircle.svg b/assets/images/simple-illustrations/simple-illustration__checkmarkcircle.svg new file mode 100644 index 000000000000..66d2b1e5b0e2 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__checkmarkcircle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__coins.svg b/assets/images/simple-illustrations/simple-illustration__coins.svg index 5350886402c6..5caa1c0635d5 100644 --- a/assets/images/simple-illustrations/simple-illustration__coins.svg +++ b/assets/images/simple-illustrations/simple-illustration__coins.svg @@ -1,26 +1 @@ - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__company-card.svg b/assets/images/simple-illustrations/simple-illustration__company-card.svg index 4121bbeeb205..1f4e43dbc047 100644 --- a/assets/images/simple-illustrations/simple-illustration__company-card.svg +++ b/assets/images/simple-illustrations/simple-illustration__company-card.svg @@ -1,38 +1 @@ - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg b/assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg new file mode 100644 index 000000000000..26b1ea7f2c31 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__lightbulb.svg b/assets/images/simple-illustrations/simple-illustration__lightbulb.svg index 1dc359764147..62a9cb0c3b76 100644 --- a/assets/images/simple-illustrations/simple-illustration__lightbulb.svg +++ b/assets/images/simple-illustrations/simple-illustration__lightbulb.svg @@ -1,33 +1 @@ - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__pencil.svg b/assets/images/simple-illustrations/simple-illustration__pencil.svg index 8d9f06991612..d3eaf8771021 100644 --- a/assets/images/simple-illustrations/simple-illustration__pencil.svg +++ b/assets/images/simple-illustrations/simple-illustration__pencil.svg @@ -1,20 +1 @@ - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__piggybank.svg b/assets/images/simple-illustrations/simple-illustration__piggybank.svg index be87ff34752a..ab1f73113f18 100644 --- a/assets/images/simple-illustrations/simple-illustration__piggybank.svg +++ b/assets/images/simple-illustrations/simple-illustration__piggybank.svg @@ -1,50 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__receiptupload.svg b/assets/images/simple-illustrations/simple-illustration__receiptupload.svg index b8fe5101715f..efff624f481f 100644 --- a/assets/images/simple-illustrations/simple-illustration__receiptupload.svg +++ b/assets/images/simple-illustrations/simple-illustration__receiptupload.svg @@ -1,22 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__sendmoney.svg b/assets/images/simple-illustrations/simple-illustration__sendmoney.svg new file mode 100644 index 000000000000..1975c15d5d24 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__sendmoney.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__splitbill.svg b/assets/images/simple-illustrations/simple-illustration__splitbill.svg index dfed7535ee90..1390a7cf9205 100644 --- a/assets/images/simple-illustrations/simple-illustration__splitbill.svg +++ b/assets/images/simple-illustrations/simple-illustration__splitbill.svg @@ -1,55 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__subscription-annual.svg b/assets/images/simple-illustrations/simple-illustration__subscription-annual.svg new file mode 100644 index 000000000000..6dcb4a422f0a --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__subscription-annual.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__subscription-ppu.svg b/assets/images/simple-illustrations/simple-illustration__subscription-ppu.svg new file mode 100644 index 000000000000..39b2e4a12542 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__subscription-ppu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__tag.svg b/assets/images/simple-illustrations/simple-illustration__tag.svg index 0cac51679a5e..0a93014d11b3 100644 --- a/assets/images/simple-illustrations/simple-illustration__tag.svg +++ b/assets/images/simple-illustrations/simple-illustration__tag.svg @@ -1,33 +1 @@ - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__teachers-unite.svg b/assets/images/simple-illustrations/simple-illustration__teachers-unite.svg index b4edd9513722..27ce709889dd 100644 --- a/assets/images/simple-illustrations/simple-illustration__teachers-unite.svg +++ b/assets/images/simple-illustrations/simple-illustration__teachers-unite.svg @@ -1,49 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__workflows.svg b/assets/images/simple-illustrations/simple-illustration__workflows.svg index b684c58126f7..c11d2663997f 100644 --- a/assets/images/simple-illustrations/simple-illustration__workflows.svg +++ b/assets/images/simple-illustrations/simple-illustration__workflows.svg @@ -1,153 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/stopwatch.svg b/assets/images/stopwatch.svg index 0f26af219e04..b8ca46fd1fa1 100644 --- a/assets/images/stopwatch.svg +++ b/assets/images/stopwatch.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/subscription-details__approvedlogo--light.svg b/assets/images/subscription-details__approvedlogo--light.svg new file mode 100644 index 000000000000..6582fdf13fcd --- /dev/null +++ b/assets/images/subscription-details__approvedlogo--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/subscription-details__approvedlogo.svg b/assets/images/subscription-details__approvedlogo.svg new file mode 100644 index 000000000000..73615be28528 --- /dev/null +++ b/assets/images/subscription-details__approvedlogo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/suitcase.svg b/assets/images/suitcase.svg index 97036db6b5ac..452c44f73e22 100644 --- a/assets/images/suitcase.svg +++ b/assets/images/suitcase.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/assets/images/tag.svg b/assets/images/tag.svg index f5e13b8135cb..f25bcbe47f71 100644 --- a/assets/images/tag.svg +++ b/assets/images/tag.svg @@ -1,12 +1 @@ - - - - - - + \ No newline at end of file diff --git a/assets/images/thread.svg b/assets/images/thread.svg index 3b8f334fafdd..9f01ce7b2c06 100644 --- a/assets/images/thread.svg +++ b/assets/images/thread.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/assets/images/x-circle.svg b/assets/images/x-circle.svg index c186e41c4244..5fa5f3741567 100644 --- a/assets/images/x-circle.svg +++ b/assets/images/x-circle.svg @@ -1,12 +1 @@ - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/babel.config.js b/babel.config.js index 9f8b7a711d78..8eb0d102db01 100644 --- a/babel.config.js +++ b/babel.config.js @@ -10,6 +10,14 @@ const defaultPlugins = [ '@babel/transform-runtime', '@babel/plugin-proposal-class-properties', + [ + '@fullstory/babel-plugin-annotate-react', + { + 'react-native-web': true, + native: true, + }, + ], + // We use `transform-class-properties` for transforming ReactNative libraries and do not use it for our own // source code transformation as we do not use class property assignment. 'transform-class-properties', @@ -35,6 +43,16 @@ const metro = { ['@babel/plugin-proposal-private-property-in-object', {loose: true}], // The reanimated babel plugin needs to be last, as stated here: https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation 'react-native-reanimated/plugin', + + /* Fullstory */ + '@fullstory/react-native', + [ + '@fullstory/babel-plugin-annotate-react', + { + native: true, + }, + ], + // Import alias for native devices [ 'module-resolver', diff --git a/config/electronBuilder.config.js b/config/electronBuilder.config.js index e4ed685f65fe..5a995fb5de91 100644 --- a/config/electronBuilder.config.js +++ b/config/electronBuilder.config.js @@ -45,6 +45,12 @@ module.exports = { notarize: { teamId: '368M544MTT', }, + target: [ + { + target: 'dmg', + arch: ['universal'], + }, + ], }, dmg: { title: 'New Expensify', diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 7cafafca9973..bedd7e50ef94 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -4,14 +4,27 @@ import dotenv from 'dotenv'; import fs from 'fs'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import path from 'path'; -import type {Configuration} from 'webpack'; +import type {Compiler, Configuration} from 'webpack'; import {DefinePlugin, EnvironmentPlugin, IgnorePlugin, ProvidePlugin} from 'webpack'; import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer'; import CustomVersionFilePlugin from './CustomVersionFilePlugin'; import type Environment from './types'; +// importing anything from @vue/preload-webpack-plugin causes an error +type Options = { + rel: string; + as: string; + fileWhitelist: RegExp[]; + include: string; +}; + +type PreloadWebpackPluginClass = { + new (options?: Options): PreloadWebpackPluginClass; + apply: (compiler: Compiler) => void; +}; + // require is necessary, there are no types for this package and the declaration file can't be seen by the build process which causes an error. -const PreloadWebpackPlugin = require('@vue/preload-webpack-plugin'); +const PreloadWebpackPlugin: PreloadWebpackPluginClass = require('@vue/preload-webpack-plugin'); const includeModules = [ 'react-native-animatable', @@ -98,7 +111,7 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): {from: 'web/apple-touch-icon.png'}, {from: 'assets/images/expensify-app-icon.svg'}, {from: 'web/manifest.json'}, - {from: 'web/gtm.js'}, + {from: 'web/thirdPartyScripts.js'}, {from: 'assets/css', to: 'css'}, {from: 'assets/fonts/web', to: 'fonts'}, {from: 'assets/sounds', to: 'sounds'}, diff --git a/contributingGuides/APPLE_GOOGLE_SIGNIN.md b/contributingGuides/APPLE_GOOGLE_SIGNIN.md index 08a444a6b8e4..cc3e256be399 100644 --- a/contributingGuides/APPLE_GOOGLE_SIGNIN.md +++ b/contributingGuides/APPLE_GOOGLE_SIGNIN.md @@ -96,6 +96,79 @@ These steps are covered in more detail in the "testing" section below. Due to some technical constraints, Apple and Google sign-in may require additional configuration to be able to work in the development environment as expected. This document describes any additional steps for each platform. +## Show Apple / Google SSO buttons development environment + +The Apple/Google Sign In button renders differently in development mode. To prevent confusion +for developers about a possible regression, we decided to not render third party buttons in +development mode. + +To re-enable the SSO buttons in development mode, remove this [condition](https://github.com/Expensify/App/blob/c2a718c9100e704c89ad9564301348bc53a49777/src/pages/signin/LoginForm/BaseLoginForm.tsx#L300) so that we always render the SSO button components: + +```diff +diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx +index 4286a26033..850f8944ca 100644 +--- a/src/pages/signin/LoginForm/BaseLoginForm.tsx ++++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx +@@ -288,7 +288,7 @@ function BaseLoginForm({account, credentials, closeAccount, blurOnSubmit = false + // for developers about possible regressions, we won't render buttons in development mode. + // For more information about these differences and how to test in development mode, + // see`Expensify/App/contributingGuides/APPLE_GOOGLE_SIGNIN.md` +- CONFIG.ENVIRONMENT !== CONST.ENVIRONMENT.DEV && ( ++ ( + + `Swift Default Apps` => `URI Schemes` => `new-expensify` and select `New Expensify.app` +4. Note that a dev build of the desktop app will not work. You'll create and install a local staging build: + 1. Update `build-desktop.sh` replacing `--publish always` with `--publish never`. + 2. Run `npm run desktop-build-staging` and install the locally-generated desktop app to test. +5. (Google only) apply the following diff: + + ```diff + diff --git a/src/components/DeeplinkWrapper/index.website.tsx b/src/components/DeeplinkWrapper/index.website.tsx + index 765fbab038..4318528b4c 100644 + --- a/src/components/DeeplinkWrapper/index.website.tsx + +++ b/src/components/DeeplinkWrapper/index.website.tsx + @@ -63,14 +63,7 @@ function DeeplinkWrapper({children, isAuthenticated, autoAuthState}: DeeplinkWra + const isUnsupportedDeeplinkRoute = routeRegex.test(window.location.pathname); + + // Making a few checks to exit early before checking authentication status + - if ( + - !isMacOSWeb() || + - isUnsupportedDeeplinkRoute || + - hasShownPrompt || + - CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV || + - autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED || + - Session.isAnonymousUser() + - ) { + + if (!isMacOSWeb() || isUnsupportedDeeplinkRoute || hasShownPrompt || autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED || Session.isAnonymousUser()) { + return; + } + // We want to show the prompt immediately if the user is already authenticated. + diff --git a/src/libs/Navigation/linkingConfig/prefixes.ts b/src/libs/Navigation/linkingConfig/prefixes.ts + index ca2da6f56b..2c191598f0 100644 + --- a/src/libs/Navigation/linkingConfig/prefixes.ts + +++ b/src/libs/Navigation/linkingConfig/prefixes.ts + @@ -8,6 +8,7 @@ const prefixes: LinkingOptions['prefixes'] = [ + 'https://www.expensify.cash', + 'https://staging.expensify.cash', + 'https://dev.new.expensify.com', + + 'http://localhost', + CONST.NEW_EXPENSIFY_URL, + CONST.STAGING_NEW_EXPENSIFY_URL, + ]; + ``` + +6. Run `npm run web` + ## Apple #### Port requirements @@ -193,57 +266,11 @@ This is required because the desktop app needs to know the address of the web ap Note that changing this value to a domain that isn't configured for use with Expensify will cause Android to break, as it is still using the real client ID, but now has an incorrect value for `redirectURI`. -#### Set Environment to something other than "Development" - -The `DeepLinkWrapper` component will not handle deep links in the development environment. To be able to test deep linking, you must set the environment to something other than "Development". - -Within the `.env` file, set `envName` to something other than "Development", for example: - -``` -envName=Staging -``` - -Alternatively, within the `DeepLinkWrapper/index.website.js` file, you can set the `CONFIG.ENVIRONMENT` to something other than "Development". +## Google -#### Handle deep links in dev on MacOS +Unlike with Apple, to test Google Sign-In we don't need to set up any http/ssh tunnels. We can just use `localhost`. But we need to set up the web and desktop environments to use `localhost` instead of `dev.new.expensify.com` -If developing on MacOS, the development desktop app can't handle deeplinks correctly. To be able to test deeplinking back to the app, follow these steps: - -1. Create a "real" build of the desktop app, which can handle deep links, open the build folder, and install the dmg there: - -```shell -npm run desktop-build -open desktop-build -# Then double-click "NewExpensify.dmg" in Finder window -``` - -2. Even with this build, the deep link may not be handled by the correct app, as the development Electron config seems to intercept it sometimes. To manage this, install [SwiftDefaultApps](https://github.com/Lord-Kamina/SwiftDefaultApps), which adds a preference pane that can be used to configure which app should handle deep links. - -### Test the Apple / Google SSO buttons in development environment - -The Apple/Google Sign In button renders differently in development mode. To prevent confusion -for developers about a possible regression, we decided to not render third party buttons in -development mode. - -Here's how you can re-enable the SSO buttons in development mode: - -- Remove this [condition](https://github.com/Expensify/App/blob/c2a718c9100e704c89ad9564301348bc53a49777/src/pages/signin/LoginForm/BaseLoginForm.tsx#L300) so that we always render the SSO button components - ```diff - diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx - index 4286a26033..850f8944ca 100644 - --- a/src/pages/signin/LoginForm/BaseLoginForm.tsx - +++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx - @@ -288,7 +288,7 @@ function BaseLoginForm({account, credentials, closeAccount, blurOnSubmit = false - // for developers about possible regressions, we won't render buttons in development mode. - // For more information about these differences and how to test in development mode, - // see`Expensify/App/contributingGuides/APPLE_GOOGLE_SIGNIN.md` - - CONFIG.ENVIRONMENT !== CONST.ENVIRONMENT.DEV && ( - + ( - - { + @@ -246,7 +246,7 @@ const mainWindow = (): Promise => { + let deeplinkUrl: string; + let browserWindow: BrowserWindow; + + - const loadURL = __DEV__ ? (win: BrowserWindow): Promise => win.loadURL(`https://dev.new.expensify.com:${port}`) : serve({directory: `${__dirname}/www`}); + + const loadURL = __DEV__ ? (win: BrowserWindow): Promise => win.loadURL(`http://localhost:${port}`) : serve({directory: `${__dirname}/www`}); + + // Prod and staging set the icon in the electron-builder config, so only update it here for dev + if (__DEV__) { + ``` -The DeepLinkWrapper component will not handle deep links in the development environment. To be able to test deep linking, you must set the environment to something other than "Development". diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 13f7592b65e1..aec527edabe0 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -95,9 +95,10 @@ Additionally, if you want to discuss an idea with the open source community with #### Propose a solution for the job 4. You can propose solutions on any issue at any time, but if you propose solutions to jobs before the `Help Wanted` label is applied, you do so at your own risk. Proposals will not be reviewed until the label is added and there is always a chance that we might not add the label or hire an external contributor for the job. -5. After you reproduce the issue, complete the [proposal template here](./PROPOSAL_TEMPLATE.md) and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). +5. Contributors should **not** submit proposals on issues when they have assigned issues or PRs that are awaiting an action from them. If so, they will be in violation of Rule #1 (Get Shit Done) in our [Code of Conduct](https://github.com/Expensify/App/blob/main/CODE_OF_CONDUCT.md) and will receive a warning. Multiple warnings can lead to removal from the program. +6. After you reproduce the issue, complete the [proposal template here](./PROPOSAL_TEMPLATE.md) and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). - Note: Before submitting a proposal on an issue, be sure to read any other existing proposals. ALL NEW PROPOSALS MUST BE DIFFERENT FROM EXISTING PROPOSALS. The *difference* should be important, meaningful or considerable. -6. Refrain from leaving additional comments until someone from the Contributor-Plus team and / or someone from Expensify provides feedback on your proposal (do not create a pull request yet). +7. Refrain from leaving additional comments until someone from the Contributor-Plus team and / or someone from Expensify provides feedback on your proposal (do not create a pull request yet). - Do not leave more than one proposal. - Do not make extensive changes to your current proposal until after it has been reviewed. - If you want to make an entirely new proposal or update an existing proposal, please go back and edit your original proposal, then post a new comment to the issue in this format to alert everyone that it has been updated: @@ -105,8 +106,8 @@ Additionally, if you want to discuss an idea with the open source community with ## Proposal [Updated](link to proposal) ``` -7. If your proposal is accepted by the Expensify engineer assigned to the issue, Expensify will hire you on Upwork and assign the GitHub issue to you. -8. Once hired, post a comment in the Github issue stating when you expect to have your PR ready for review. +8. If your proposal is accepted by the Expensify engineer assigned to the issue, Expensify will hire you on Upwork and assign the GitHub issue to you. +9. Once hired, post a comment in the Github issue stating when you expect to have your PR ready for review. #### Begin coding your solution in a pull request 9. When you are ready to start, fork the repository and create a new branch. diff --git a/contributingGuides/PROPTYPES_CONVERSION_TABLE.md b/contributingGuides/PROPTYPES_CONVERSION_TABLE.md deleted file mode 100644 index 7e1c1dda4262..000000000000 --- a/contributingGuides/PROPTYPES_CONVERSION_TABLE.md +++ /dev/null @@ -1,144 +0,0 @@ -# Expensify PropTypes Conversion Table - -This is a reference to help you convert PropTypes to TypeScript types. - -## Table of Contents - -- [Important Considerations](#important-considerations) - - [Don't Rely on `isRequired`](#dont-rely-on-isrequired) -- [PropTypes Conversion Table](#proptypes-conversion-table) -- [Conversion Example](#conversion-example) - -## Important Considerations - -### Don't Rely on `isRequired` - -Regardless of `isRequired` is present or not on props in `PropTypes`, read through the component implementation to check if props without `isRequired` can actually be optional. The use of `isRequired` is not consistent in the current codebase. Just because `isRequired` is not present, it does not necessarily mean that the prop is optional. - -One trick is to mark the prop in question with optional modifier `?`. See if the "possibly `undefined`" error is raised by TypeScript. If any error is raised, the implementation assumes the prop not to be optional. - -```ts -// Before -const propTypes = { - isVisible: PropTypes.bool.isRequired, - // `confirmText` prop is not marked as required here, theoretically it is optional. - confirmText: PropTypes.string, -}; - -// After -type ComponentProps = { - isVisible: boolean; - // Consider it as required unless you have proof that it is indeed an optional prop. - confirmText: string; // vs. confirmText?: string; -}; -``` - -## PropTypes Conversion Table - -| PropTypes | TypeScript | Instructions | -| -------------------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PropTypes.any` | `T`, `Record` or `unknown` | Figure out what would be the correct data type and use it.

If you know that it's a object but isn't possible to determine the internal structure, use `Record`. | -| `PropTypes.array` or `PropTypes.arrayOf(T)` | `T[]` or `Array` | Convert to `T[]`, where `T` is the data type of the array.

If `T` isn't a primitive type, create a separate `type` for the object structure of your prop and use it. | -| `PropTypes.bool` | `boolean` | Convert to `boolean`. | -| `PropTypes.func` | `(arg1: Type1, arg2: Type2...) => ReturnType` | Convert to the function signature. | -| `PropTypes.number` | `number` | Convert to `number`. | -| `PropTypes.object`, `PropTypes.shape(T)` or `PropTypes.exact(T)` | `T` | If `T` isn't a primitive type, create a separate `type` for the `T` object structure of your prop and use it.

If you want an object but it isn't possible to determine the internal structure, use `Record`. | -| `PropTypes.objectOf(T)` | `Record` | Convert to a `Record` where `T` is the data type of values stored in the object.

If `T` isn't a primitive type, create a separate `type` for the object structure and use it. | -| `PropTypes.string` | `string` | Convert to `string`. | -| `PropTypes.node` | `React.ReactNode` | Convert to `React.ReactNode`. `ReactNode` includes `ReactElement` as well as other types such as `strings`, `numbers`, `arrays` of the same, `null`, and `undefined` In other words, anything that can be rendered in React is a `ReactNode`. | -| `PropTypes.element` | `React.ReactElement` | Convert to `React.ReactElement`. | -| `PropTypes.symbol` | `symbol` | Convert to `symbol`. | -| `PropTypes.elementType` | `React.ElementType` | Convert to `React.ElementType`. | -| `PropTypes.instanceOf(T)` | `T` | Convert to `T`. | -| `PropTypes.oneOf([T, U, ...])` or `PropTypes.oneOfType([T, U, ...])` | `T \| U \| ...` | Convert to a union type e.g. `T \| U \| ...`. | - -## Conversion Example - -```ts -// Before -const propTypes = { - unknownData: PropTypes.any, - anotherUnknownData: PropTypes.any, - indexes: PropTypes.arrayOf(PropTypes.number), - items: PropTypes.arrayOf( - PropTypes.shape({ - value: PropTypes.string, - label: PropTypes.string, - }) - ), - shouldShowIcon: PropTypes.bool, - onChangeText: PropTypes.func, - count: PropTypes.number, - session: PropTypes.shape({ - authToken: PropTypes.string, - accountID: PropTypes.number, - }), - errors: PropTypes.objectOf(PropTypes.string), - inputs: PropTypes.objectOf( - PropTypes.shape({ - id: PropTypes.string, - label: PropTypes.string, - }) - ), - label: PropTypes.string, - anchor: PropTypes.node, - footer: PropTypes.element, - uniqSymbol: PropTypes.symbol, - icon: PropTypes.elementType, - date: PropTypes.instanceOf(Date), - size: PropTypes.oneOf(["small", "medium", "large"]), - - optionalString: PropTypes.string, - /** - * Note that all props listed above are technically optional because they lack the `isRequired` attribute. - * However, in most cases, props are actually required but the `isRequired` attribute is left out by mistake. - * - * For each prop that appears to be optional, determine whether the component implementation assumes that - * the prop has a value (making it non-optional) or not. Only those props that are truly optional should be - * labeled with a `?` in their type definition. - */ -}; - -// After -type Item = { - value: string; - label: string; -}; - -type Session = { - authToken: string; - accountID: number; -}; - -type Input = { - id: string; - label: string; -}; - -type Size = "small" | "medium" | "large"; - -type ComponentProps = { - unknownData: string[]; - - // It's not possible to infer the data as it can be anything because of reasons X, Y and Z. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - anotherUnknownData: unknown; - - indexes: number[]; - items: Item[]; - shouldShowIcon: boolean; - onChangeText: (value: string) => void; - count: number; - session: Session; - errors: Record; - inputs: Record; - label: string; - anchor: React.ReactNode; - footer: React.ReactElement; - uniqSymbol: symbol; - icon: React.ElementType; - date: Date; - size: Size; - optionalString?: string; -}; -``` diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md index 36815cd0557c..6af3a82c2ff6 100644 --- a/contributingGuides/STYLE.md +++ b/contributingGuides/STYLE.md @@ -1,5 +1,63 @@ -# JavaScript Coding Standards - +# Coding Standards + +## Table of Contents + +- [Introduction](#introduction) +- [TypeScript guidelines](#typescript-guidelines) + - [General rules](#general-rules) + - [`d.ts` extension](#dts-extension) + - [Type Alias vs Interface](#type-alias-vs-interface) + - [Enum vs. Union Type](#enum-vs-union-type) + - [`unknown` vs. `any`](#unknown-vs-any) + - [`T[]` vs. `Array`](#t-vs-arrayt) + - [`@ts-ignore`](#ts-ignore) + - [Type Inference](#type-inference) + - [Utility Types](#utility-types) + - [`object` type](#object-type) + - [Prop Types](#prop-types) + - [File organization](#file-organization) + - [Reusable Types](#reusable-types) + - [`tsx` extension](#tsx-extension) + - [No inline prop types](#no-inline-prop-types) + - [Satisfies Operator](#satisfies-operator) + - [Type imports/exports](#type-importsexports) + - [Refs](#refs) + - [Other Expensify Resources on TypeScript](#other-expensify-resources-on-typescript) + - [Default value for inexistent IDs](#default-value-for-inexistent-IDs) +- [Naming Conventions](#naming-conventions) + - [Type names](#type-names) + - [Prop callbacks](#prop-callbacks) + - [Event Handlers](#event-handlers) + - [Boolean variables and props](#boolean-variables-and-props) + - [Functions](#functions) + - [`var`, `const` and `let`](#var-const-and-let) +- [Object / Array Methods](#object--array-methods) +- [Accessing Object Properties and Default Values](#accessing-object-properties-and-default-values) +- [JSDocs](#jsdocs) +- [Component props](#component-props) +- [Destructuring](#destructuring) +- [Named vs Default Exports in ES6 - When to use what?](#named-vs-default-exports-in-es6---when-to-use-what) +- [Classes and constructors](#classes-and-constructors) + - [Class syntax](#class-syntax) + - [Constructor](#constructor) +- [ESNext: Are we allowed to use [insert new language feature]? Why or why not?](#esnext-are-we-allowed-to-use-insert-new-language-feature-why-or-why-not) +- [React Coding Standards](#react-coding-standards) + - [Code Documentation](#code-documentation) + - [Inline Ternaries](#inline-ternaries) + - [Function component style](#function-component-style) + - [Forwarding refs](#forwarding-refs) + - [Hooks and HOCs](#hooks-and-hocs) + - [Stateless components vs Pure Components vs Class based components vs Render Props](#stateless-components-vs-pure-components-vs-class-based-components-vs-render-props---when-to-use-what) + - [Use Refs Appropriately](#use-refs-appropriately) + - [Are we allowed to use [insert brand new React feature]?](#are-we-allowed-to-use-insert-brand-new-react-feature-why-or-why-not) +- [React Hooks: Frequently Asked Questions](#react-hooks-frequently-asked-questions) +- [Onyx Best Practices](#onyx-best-practices) + - [Collection Keys](#collection-keys) +- [Learning Resources](#learning-resources) + +## Introduction + + For almost all of our code style rules, refer to the [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript). When writing ES6 or React code, please also refer to the [Airbnb React/JSX Style Guide](https://github.com/airbnb/javascript/tree/master/react). @@ -10,13 +68,526 @@ We use Prettier to automatically style our code. There are a few things that we have customized for our tastes which will take precedence over Airbnb's guide. +## TypeScript guidelines + +### General rules + +Strive to type as strictly as possible. + +```ts +type Foo = { + fetchingStatus: "loading" | "success" | "error"; // vs. fetchingStatus: string; + person: { name: string; age: number }; // vs. person: Record; +}; +``` + +### `d.ts` Extension + +Do not use `d.ts` file extension even when a file contains only type declarations. Only exceptions are `src/types/global.d.ts` and `src/types/modules/*.d.ts` files in which third party packages and JavaScript's built-in modules (e.g. `window` object) can be modified using [module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). + +> Why? Type errors in `d.ts` files are not checked by TypeScript. + +### Type Alias vs. Interface + +Do not use `interface`. Use `type`. eslint: [`@typescript-eslint/consistent-type-definitions`](https://typescript-eslint.io/rules/consistent-type-definitions/) + +> Why? In TypeScript, `type` and `interface` can be used interchangeably to declare types. Use `type` for consistency. + +```ts +// BAD +interface Person { + name: string; +} + +// GOOD +type Person = { + name: string; +}; +``` + +### Enum vs. Union Type + +Do not use `enum`. Use union types. eslint: [`no-restricted-syntax`](https://eslint.org/docs/latest/rules/no-restricted-syntax) + +> Why? Enums come with several [pitfalls](https://blog.logrocket.com/why-typescript-enums-suck/). Most enum use cases can be replaced with union types. + +```ts +// Most simple form of union type. +type Color = "red" | "green" | "blue"; +function printColors(color: Color) { + console.log(color); +} + +// When the values need to be iterated upon. +import { TupleToUnion } from "type-fest"; + +const COLORS = ["red", "green", "blue"] as const; +type Color = TupleToUnion; // type: 'red' | 'green' | 'blue' + +for (const color of COLORS) { + printColor(color); +} + +// When the values should be accessed through object keys. (i.e. `COLORS.Red` vs. `"red"`) +import { ValueOf } from "type-fest"; + +const COLORS = { + Red: "red", + Green: "green", + Blue: "blue", +} as const; +type Color = ValueOf; // type: 'red' | 'green' | 'blue' + +printColor(COLORS.Red); +``` + +### `unknown` vs. `any` + +Don't use `any`. Use `unknown` if type is not known beforehand. eslint: [`@typescript-eslint/no-explicit-any`](https://typescript-eslint.io/rules/no-explicit-any/) + +> Why? `any` type bypasses type checking. `unknown` is type safe as `unknown` type needs to be type narrowed before being used. + +```ts +const value: unknown = JSON.parse(someJson); +if (typeof value === 'string') {...} +else if (isPerson(value)) {...} +... +``` + +### `T[]` vs. `Array` + +Use `T[]` or `readonly T[]` for simple types (i.e. types which are just primitive names or type references). Use `Array` or `ReadonlyArray` for all other types (union types, intersection types, object types, function types, etc). eslint: [`@typescript-eslint/array-type`](https://typescript-eslint.io/rules/array-type/) + +```ts +// Array +const a: Array = ["a", "b"]; +const b: Array<{ prop: string }> = [{ prop: "a" }]; +const c: Array<() => void> = [() => {}]; + +// T[] +const d: MyType[] = ["a", "b"]; +const e: string[] = ["a", "b"]; +const f: readonly string[] = ["a", "b"]; +``` + +### `@ts-ignore` + +Do not use `@ts-ignore` or its variant `@ts-nocheck` to suppress warnings and errors. + +### Type Inference + +When possible, allow the compiler to infer type of variables. + +```ts +// BAD +const foo: string = "foo"; +const [counter, setCounter] = useState(0); + +// GOOD +const foo = "foo"; +const [counter, setCounter] = useState(0); +const [username, setUsername] = useState(undefined); // Username is a union type of string and undefined, and its type cannot be inferred from the default value of undefined +``` + +For function return types, default to always typing them unless a function is simple enough to reason about its return type. + +> Why? Explicit return type helps catch errors when implementation of the function changes. It also makes it easy to read code even when TypeScript intellisense is not provided. + +```ts +function simpleFunction(name: string) { + return `hello, ${name}`; +} + +function complicatedFunction(name: string): boolean { +// ... some complex logic here ... + return foo; +} +``` + +### Utility Types + +Use types from [TypeScript utility types](https://www.typescriptlang.org/docs/handbook/utility-types.html) and [`type-fest`](https://github.com/sindresorhus/type-fest) when possible. + +```ts +type Foo = { + bar: string; +}; + +// BAD +type ReadOnlyFoo = { + readonly [Property in keyof Foo]: Foo[Property]; +}; + +// GOOD +type ReadOnlyFoo = Readonly; + +// BAD +type FooValue = Foo[keyof Foo]; + +// GOOD +type FooValue = ValueOf; + +``` + +### `object` type + +Don't use `object` type. eslint: [`@typescript-eslint/ban-types`](https://typescript-eslint.io/rules/ban-types/) + +> Why? `object` refers to "any non-primitive type," not "any object". Typing "any non-primitive value" is not commonly needed. + +```ts +// BAD +const foo: object = [1, 2, 3]; // TypeScript does not error +``` + +If you know that the type of data is an object but don't know what properties or values it has beforehand, use `Record`. + +> Even though `string` is specified as a key, `Record` type can still accept objects whose keys are numbers. This is because numbers are converted to strings when used as an object index. Note that you cannot use [symbols](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol) for `Record`. + +```ts +function logObject(object: Record) { + for (const [key, value] of Object.entries(object)) { + console.log(`${key}: ${value}`); + } +} +``` + +### Prop Types + +Don't use `ComponentProps` to grab a component's prop types. Go to the source file for the component and export prop types from there. Import and use the exported prop types. + +> Why? Importing prop type from the component file is more common and readable. Using `ComponentProps` might cause problems in some cases (see [related GitHub issue](https://github.com/piotrwitek/react-redux-typescript-guide/issues/170)). Each component with props has it's prop type defined in the file anyway, so it's easy to export it when required. + +Don't export prop types from component files by default. Only export it when there is a code that needs to access the prop type directly. + +```tsx +// MyComponent.tsx +export type MyComponentProps = { + foo: string; +}; + +export default function MyComponent({ foo }: MyComponentProps) { + return {foo}; +} + +// BAD +import { ComponentProps } from "React"; +import MyComponent from "./MyComponent"; +type MyComponentProps = ComponentProps; + +// GOOD +import MyComponent, { MyComponentProps } from "./MyComponent"; +``` + +### File organization + +In modules with platform-specific implementations, create `types.ts` to define shared types. Import and use shared types in each platform specific files. Do not use [`satisfies` operator](#satisfies-operator) for platform-specific implementations, always define shared types that complies with all variants. + +> Why? To encourage consistent API across platform-specific implementations. If you're migrating module that doesn't have a default implementation (i.e. `index.ts`, e.g. `getPlatform`), refer to [Migration Guidelines](#migration-guidelines) for further information. + +Utility module example + +```ts +// types.ts +type GreetingModule = { + getHello: () => string; + getGoodbye: () => string; +}; + +// index.native.ts +import { GreetingModule } from "./types"; +function getHello() { + return "hello from mobile code"; +} +function getGoodbye() { + return "goodbye from mobile code"; +} +const Greeting: GreetingModule = { + getHello, + getGoodbye, +}; +export default Greeting; + +// index.ts +import { GreetingModule } from "./types"; +function getHello() { + return "hello from other platform code"; +} +function getGoodbye() { + return "goodbye from other platform code"; +} +const Greeting: GreetingModule = { + getHello, + getGoodbye, +}; +export default Greeting; +``` + +Component module example + +```ts +// types.ts +export type MyComponentProps = { + foo: string; +} + +// index.ios.ts +import { MyComponentProps } from "./types"; + +export MyComponentProps; +export default function MyComponent({ foo }: MyComponentProps) { /* ios specific implementation */ } + +// index.ts +import { MyComponentProps } from "./types"; + +export MyComponentProps; +export default function MyComponent({ foo }: MyComponentProps) { /* Default implementation */ } +``` + +### Reusable Types + +Reusable type definitions, such as models (e.g., Report), must have their own file and be placed under `src/types/`. The type should be exported as a default export. + +```ts +// src/types/Report.ts + +type Report = {...}; + +export default Report; +``` + +### `tsx` extension + +Use `.tsx` extension for files that contain React syntax. + +> Why? It is a widely adopted convention to mark any files that contain React specific syntax with `.jsx` or `.tsx`. + +### No inline prop types + +Do not define prop types inline for components that are exported. + +> Why? Prop types might [need to be exported from component files](#export-prop-types). If the component is only used inside a file or module and not exported, then inline prop types can be used. + +```ts +// BAD +export default function MyComponent({ foo, bar }: { foo: string, bar: number }){ + // component implementation +}; + +// GOOD +type MyComponentProps = { foo: string, bar: number }; +export default MyComponent({ foo, bar }: MyComponentProps){ + // component implementation +} +``` + +### Satisfies Operator + +Use the `satisfies` operator when you need to validate that the structure of an expression matches a specific type, without affecting the resulting type of the expression. + +> Why? TypeScript developers often want to ensure that an expression aligns with a certain type, but they also want to retain the most specific type possible for inference purposes. The `satisfies` operator assists in doing both. + +```ts +// BAD +const sizingStyles = { + w50: { + width: '50%', + }, + mw100: { + maxWidth: '100%', + }, +} as const; + +// GOOD +const sizingStyles = { + w50: { + width: '50%', + }, + mw100: { + maxWidth: '100%', + }, +} as const satisfies Record; +``` + +The example above results in the most narrow type possible, also the values are `readonly`. There are some cases in which that is not desired (e.g. the variable can be modified), if so `as const` should be omitted. + +### Type imports/exports + +Always use the `type` keyword when importing/exporting types + +> Why? In order to improve code clarity and consistency and reduce bundle size after typescript transpilation, we enforce the all type imports/exports to contain the `type` keyword. This way, TypeScript can automatically remove those imports from the transpiled JavaScript bundle + +Imports: +```ts +// BAD +import {SomeType} from './a' +import someVariable from './a' + +import {someVariable, SomeOtherType} from './b' + +// GOOD +import type {SomeType} from './a' +import someVariable from './a' +``` + + Exports: +```ts +// BAD +export {SomeType} +export someVariable +// or +export {someVariable, SomeOtherType} + +// GOOD +export type {SomeType} +export someVariable +``` + +### Refs + +Avoid using HTML elements while declaring refs. Please use React Native components where possible. React Native Web handles the references on its own. It also extends React Native components with [Interaction API](https://necolas.github.io/react-native-web/docs/interactions/) which should be used to handle Pointer and Mouse events. Exception of this rule is when we explicitly need to use functions available only in DOM and not in React Native, e.g. `getBoundingClientRect`. Then please declare ref type as `union` of React Native component and HTML element. When passing it to React Native component assert it as soon as possible using utility methods declared in `src/types/utils`. + +Normal usage: +```tsx +const ref = useRef(); + + {#DO SOMETHING}}> +``` + +Exceptional usage where DOM methods are necessary: +```tsx +import viewRef from '@src/types/utils/viewRef'; + +const ref = useRef(); + +if (ref.current && 'getBoundingClientRect' in ref.current) { + ref.current.getBoundingClientRect(); +} + + {#DO SOMETHING}}> +``` + +### Other Expensify Resources on TypeScript + +- [Expensify TypeScript React Native CheatSheet](./TS_CHEATSHEET.md) + +### Default value for inexistent IDs + + Use `'-1'` or `-1` when there is a possibility that the ID property of an Onyx value could be `null` or `undefined`. + +``` ts +// BAD +const foo = report?.reportID ?? ''; +const bar = report?.reportID ?? '0'; + +report ? report.reportID : '0'; +report ? report.reportID : ''; + +// GOOD +const foo = report?.reportID ?? '-1'; + +report ? report.reportID : '-1'; +``` + ## Naming Conventions +### Type names + + - Use PascalCase for type names. eslint: [`@typescript-eslint/naming-convention`](https://typescript-eslint.io/rules/naming-convention/) + + ```ts + // BAD + type foo = ...; + type BAR = ...; + + // GOOD + type Foo = ...; + type Bar = ...; + ``` + + - Do not postfix type aliases with `Type`. + + ```ts + // BAD + type PersonType = ...; + + // GOOD + type Person = ...; + ``` + + - Use singular name for union types. + + ```ts + // BAD + type Colors = "red" | "blue" | "green"; + + // GOOD + type Color = "red" | "blue" | "green"; + ``` + + - Use `{ComponentName}Props` pattern for prop types. + + ```ts + // BAD + type Props = { + // component's props + }; + + function MyComponent({}: Props) { + // component's code + } + + // GOOD + type MyComponentProps = { + // component's props + }; + + function MyComponent({}: MyComponentProps) { + // component's code + } + ``` + + - For generic type parameters, use `T` if you have only one type parameter. Don't use the `T`, `U`, `V`... sequence. Make type parameter names descriptive, each prefixed with `T`. + + > Prefix each type parameter name to distinguish them from other types. + + ```ts + // BAD + type KeyValuePair = { key: K; value: U }; + + type Keys = Array; + + // GOOD + type KeyValuePair = { key: TKey; value: TValue }; + + type Keys = Array; + type Keys = Array; + ``` + +### Prop callbacks + - Prop callbacks should be named for what has happened, not for what is going to happen. Components should never assume anything about how they will be used (that's the job of whatever is implementing it). + + ```ts + // Bad + type ComponentProps = { + /** A callback to call when we want to save the form */ + onSaveForm: () => void; + }; + + // Good + type ComponentProps = { + /** A callback to call when the form has been submitted */ + onFormSubmitted: () => void; + }; + ``` + + * Do not use underscores when naming private methods. + ### Event Handlers - When you have an event handler, do not prefix it with "on" or "handle". The method should be named for what it does, not what it handles. This promotes code reuse by minimizing assumptions that a method must be called in a certain fashion (eg. only as an event handler). - One exception for allowing the prefix of "on" is when it is used for callback `props` of a React component. Using it in this way helps to distinguish callbacks from public component methods. - ```javascript + ```ts // Bad const onSubmitClick = () => { // Validate form items and submit form @@ -32,7 +603,7 @@ There are a few things that we have customized for our tastes which will take pr - Boolean props or variables must be prefixed with `should` or `is` to make it clear that they are `Boolean`. Use `should` when we are enabling or disabling some features and `is` in most other cases. -```javascript +```tsx // Bad @@ -46,11 +617,11 @@ const valid = props.something && props.somethingElse; const isValid = props.something && props.somethingElse; ``` -## Functions +### Functions Any function declared in a library module should use the `function myFunction` keyword rather than `const myFunction`. -```javascript +```tsx // Bad const myFunction = () => {...}; @@ -68,23 +639,9 @@ export { } ``` -Using named functions is the preferred way to write a callback method. - -```javascript -// Bad -people.map(function (item) {/* Long and complex logic */}); -people.map((item) => {/* Long and complex logic with many inner loops*/}); -useEffect/useMemo/useCallback(() => {/* Long and complex logic */}, []); - -// Good -function mappingPeople(person) {/* Long and complex logic */}; -people.map(mappingPeople); -useEffect/useMemo/useCallback(function handlingConnection() {/* Long and complex logic */}, []); -``` - You can still use arrow function for declarations or simple logics to keep them readable. -```javascript +```tsx // Bad randomList.push({ onSelected: Utils.checkIfAllowed(function checkTask() { return Utils.canTeamUp(people); }), @@ -112,17 +669,6 @@ useEffect(() => { ``` -Empty functions (noop) should be declare as arrow functions with no whitespace inside. Avoid _.noop() - -```javascript -// Bad -const callback = _.noop; -const callback = () => { }; - -// Good -const callback = () => {}; -``` - ## `var`, `const` and `let` - Never use `var` @@ -130,7 +676,7 @@ const callback = () => {}; - Try to write your code in a way where the variable reassignment isn't necessary - Use `let` only if there are no other options -```javascript +```tsx // Bad let array = []; @@ -148,82 +694,97 @@ if (someCondition) { ## Object / Array Methods -We have standardized on using [underscore.js](https://underscorejs.org/) methods for objects and collections instead of the native [Array instance methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#instance_methods). This is mostly to maintain consistency, but there are some type safety features and conveniences that underscore methods provide us e.g. the ability to iterate over an object and the lack of a `TypeError` thrown if a variable is `undefined`. +We have standardized on using the native [Array instance methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#instance_methods) instead of [lodash](https://lodash.com/) methods for objects and collections. As the vast majority of code is written in TypeScript, we can safely use the native methods. -```javascript +```ts // Bad -myArray.forEach(item => doSomething(item)); -// Good _.each(myArray, item => doSomething(item)); +// Good +myArray.forEach(item => doSomething(item)); // Bad -const myArray = Object.keys(someObject).map((key) => doSomething(someObject[key])); -// Good const myArray = _.map(someObject, (value, key) => doSomething(value)); +// Good +const myArray = Object.keys(someObject).map((key) => doSomething(someObject[key])); // Bad -myCollection.includes('item'); -// Good _.contains(myCollection, 'item'); +// Good +myCollection.includes('item'); // Bad -const modifiedArray = someArray.filter(filterFunc).map(mapFunc); -// Good const modifiedArray = _.chain(someArray) .filter(filterFunc) .map(mapFunc) .value(); +// Good +const modifiedArray = someArray.filter(filterFunc).map(mapFunc); ``` ## Accessing Object Properties and Default Values -Use `lodashGet()` to safely access object properties and `||` to short circuit null or undefined values that are not guaranteed to exist in a consistent way throughout the codebase. In the rare case that you want to consider a falsy value as usable and the `||` operator prevents this, then be explicit about this in your code and check for the type. +Use optional chaining (`?.`) to safely access object properties and nullish coalescing (`??`) to short circuit null or undefined values that are not guaranteed to exist in a consistent way throughout the codebase. Don't use the `lodashGet()` function. eslint: [`no-restricted-imports`](https://eslint.org/docs/latest/rules/no-restricted-imports) -```javascript -// Bad -const value = somePossiblyNullThing ?? 'default'; -// Good -const value = somePossiblyNullThing || 'default'; -// Bad -const value = someObject.possiblyUndefinedProperty?.nestedProperty || 'default'; -// Bad -const value = (someObject && someObject.possiblyUndefinedProperty && someObject.possiblyUndefinedProperty.nestedProperty) || 'default'; -// Good -const value = lodashGet(someObject, 'possiblyUndefinedProperty.nestedProperty', 'default'); +```ts +// BAD +import lodashGet from "lodash/get"; +const name = lodashGet(user, "name", "default name"); + +// BAD +const name = user?.name || "default name"; + +// GOOD +const name = user?.name ?? "default name"; ``` ## JSDocs -- Always document parameters and return values. -- Optional parameters should be enclosed by `[]` e.g. `@param {String} [optionalText]`. -- Document object parameters with separate lines e.g. `@param {Object} parameters` followed by `@param {String} parameters.field`. -- If a parameter accepts more than one type, use `*` to denote there is no single type. -- Use uppercase when referring to JS primitive values (e.g. `Boolean` not `bool`, `Number` not `int`, etc). -- When specifying a return value use `@returns` instead of `@return`. If there is no return value, do not include one in the doc. - -- Avoid descriptions that don't add any additional information. Method descriptions should only be added when its behavior is unclear. +- Omit comments that are redundant with TypeScript. Do not declare types in `@param` or `@return` blocks. Do not write `@implements`, `@enum`, `@private`, `@override`. eslint: [`jsdoc/no-types`](https://github.com/gajus/eslint-plugin-jsdoc/blob/main/.README/rules/no-types.md) +- Only document params/return values if their names are not enough to fully understand their purpose. Not all parameters or return values need to be listed in the JSDoc comment. If there is no comment accompanying the parameter or return value, omit it. +- When specifying a return value use `@returns` instead of `@return`. +- Avoid descriptions that don't add any additional information. Method descriptions should only be added when it's behavior is unclear. - Do not use block tags other than `@param` and `@returns` (e.g. `@memberof`, `@constructor`, etc). - Do not document default parameters. They are already documented by adding them to a declared function's arguments. - Do not use record types e.g. `{Object.}`. -- Do not create `@typedef` to use in JSDocs. -- Do not use type unions e.g. `{(number|boolean)}`. -```javascript -// Bad +```ts +// BAD /** - * Populates the shortcut modal - * @param {bool} shouldShowAdvancedShortcuts whether to show advanced shortcuts - * @return {*} + * @param {number} age + * @returns {boolean} Whether the person is a legal drinking age or nots */ -function populateShortcutModal(shouldShowAdvancedShortcuts) { +function canDrink(age: number): boolean { + return age >= 21; } -// Good +// GOOD /** - * @param {Boolean} shouldShowAdvancedShortcuts - * @returns {Boolean} + * @returns Whether the person is a legal drinking age or nots */ -function populateShortcutModal(shouldShowAdvancedShortcuts) { +function canDrink(age: number): boolean { + return age >= 21; +} +``` + +In the above example, because the parameter `age` doesn't have any accompanying comment, it is completely omitted from the JSDoc. + +## Component props + +Do not use **`propTypes` and `defaultProps`**: . Use object destructing and assign a default value to each optional prop unless the default values is `undefined`. + +```tsx +type MyComponentProps = { + requiredProp: string; + optionalPropWithDefaultValue?: number; + optionalProp?: boolean; +}; + +function MyComponent({ + requiredProp, + optionalPropWithDefaultValue = 42, + optionalProp, +}: MyComponentProps) { + // component's code } ``` @@ -234,7 +795,7 @@ We should avoid using object destructuring in situations where it reduces code c - Avoid object destructuring for a single variable that you only use *once*. It's clearer to use dot notation for accessing a single variable. -```javascript +```ts // Bad const {data} = event.data; @@ -242,36 +803,6 @@ const {data} = event.data; const {name, accountID, email} = data; ``` -**React Components** - -Always use destructuring to get prop values. Destructuring is necessary to assign default values to props. - -```javascript -// Bad -function UserInfo(props) { - return ( - - Name: {props.name} - Email: {props.email} - - ); -} - -UserInfo.defaultProps = { - name: 'anonymous'; -} - -// Good -function UserInfo({ name = 'anonymous', email }) { - return ( - - Name: {name} - Email: {email} - - ); -} -``` - ## Named vs Default Exports in ES6 - When to use what? ES6 provides two ways to export a module from a file: `named export` and `default export`. Which variation to use depends on how the module will be used. @@ -283,7 +814,7 @@ ES6 provides two ways to export a module from a file: `named export` and `defaul - All exports (both default and named) should happen at the bottom of the file - Do **not** export individual features inline. -```javascript +```ts // Bad export const something = 'nope'; export const somethingElse = 'stop'; @@ -300,10 +831,10 @@ export { ## Classes and constructors -#### Class syntax +### Class syntax Using the `class` syntax is preferred wherever appropriate. Airbnb has clear [guidelines](https://github.com/airbnb/javascript#classes--constructors) in their JS style guide which promotes using the _class_ syntax. Don't manipulate the `prototype` directly. The `class` syntax is generally considered more concise and easier to understand. -#### Constructor +### Constructor Classes have a default constructor if one is not specified. No need to write a constructor function that is empty or just delegates to a parent class. ```js @@ -341,105 +872,50 @@ So, if a new language feature isn't something we have agreed to support it's off Here are a couple of things we would ask that you *avoid* to help maintain consistency in our codebase: - **Async/Await** - Use the native `Promise` instead -- **Optional Chaining** - Use `lodashGet()` to fetch a nested value instead -- **Null Coalescing Operator** - Use `lodashGet()` or `||` to set a default value for a possibly `undefined` or `null` variable -# React Coding Standards +## React Coding Standards -# React specific styles +### Code Documentation -## Method Naming and Code Documentation -* Prop callbacks should be named for what has happened, not for what is going to happen. Components should never assume anything about how they will be used (that's the job of whatever is implementing it). +* Add descriptions to all component props using a block comment above the definition. No need to document the types, but add some context for each property so that other developers understand the intended use. -```javascript +```tsx // Bad -const propTypes = { - /** A callback to call when we want to save the form */ - onSaveForm: PropTypes.func.isRequired, +type ComponentProps = { + currency: string; + amount: number; + isIgnored: boolean; }; -// Good -const propTypes = { - /** A callback to call when the form has been submitted */ - onFormSubmitted: PropTypes.func.isRequired, -}; -``` - -* Do not use underscores when naming private methods. -* Add descriptions to all `propTypes` using a block comment above the definition. No need to document the types (that's what `propTypes` is doing already), but add some context for each property so that other developers understand the intended use. - -```javascript // Bad -const propTypes = { - currency: PropTypes.string.isRequired, - amount: PropTypes.number.isRequired, - isIgnored: PropTypes.bool.isRequired -}; - -// Bad -const propTypes = { +type ComponentProps = { // The currency that the reward is in - currency: React.PropTypes.string.isRequired, + currency: string; // The amount of reward - amount: React.PropTypes.number.isRequired, + amount: number; // If the reward has been ignored or not - isIgnored: React.PropTypes.bool.isRequired + isIgnored: boolean; } // Good -const propTypes = { +type ComponentProps = { /** The currency that the reward is in */ - currency: React.PropTypes.string.isRequired, + currency: string; /** The amount of the reward */ - amount: React.PropTypes.number.isRequired, + amount: number; /** If the reward has not been ignored yet */ - isIgnored: React.PropTypes.bool.isRequired + isIgnored: boolean; } ``` -All `propTypes` and `defaultProps` *must* be defined at the **top** of the file in variables called `propTypes` and `defaultProps`. -These variables should then be assigned to the component at the bottom of the file. - -```js -MyComponent.propTypes = propTypes; -MyComponent.defaultProps = defaultProps; -export default MyComponent; -``` - -Any nested `propTypes` e.g. that may appear in a `PropTypes.shape({})` should also be documented. - -```javascript -// Bad -const propTypes = { - /** Session data */ - session: PropTypes.shape({ - authToken: PropTypes.string, - login: PropTypes.string, - }), -} - -// Good -const propTypes = { - /** Session data */ - session: PropTypes.shape({ - - /** Token used to authenticate the user */ - authToken: PropTypes.string, - - /** User email or phone number */ - login: PropTypes.string, - }), -} -``` - -## Inline Ternaries +### Inline Ternaries * Use inline ternary statements when rendering optional pieces of templates. Notice the white space and formatting of the ternary. -```javascript +```tsx // Bad { const optionalTitle = props.title ?
{props.title}
: null; @@ -452,7 +928,7 @@ const propTypes = { } ``` -```javascript +```tsx // Good { return ( @@ -466,7 +942,7 @@ const propTypes = { } ``` -```javascript +```tsx // Good { return ( @@ -481,11 +957,11 @@ const propTypes = { } ``` -### Important Note: +#### Important Note: In React Native, one **must not** attempt to falsey-check a string for an inline ternary. Even if it's in curly braces, React Native will try to render it as a `` node and most likely throw an error about trying to render text outside of a `` component. Use `!!` instead. -```javascript +```tsx // Bad! This will cause a breaking an error on native platforms { return ( @@ -511,68 +987,113 @@ In React Native, one **must not** attempt to falsey-check a string for an inline } ``` -## Function component style +### Function component style When writing a function component, you must ALWAYS add a `displayName` property and give it the same value as the name of the component (this is so it appears properly in the React dev tools) -```javascript - function Avatar(props) {...}; +```tsx +function Avatar(props: AvatarProps) {...}; - Avatar.propTypes = propTypes; - Avatar.defaultProps = defaultProps; - Avatar.displayName = 'Avatar'; +Avatar.displayName = 'Avatar'; - export default Avatar; +export default Avatar; ``` -## Forwarding refs +### Forwarding refs When forwarding a ref define named component and pass it directly to the `forwardRef`. By doing this, we remove potential extra layer in React tree in the form of anonymous component. -```javascript - function FancyInput(props, ref) { - ... - return - } +```tsx +import type {ForwarderRef} from 'react'; + +type FancyInputProps = { + ... +}; - export default React.forwardRef(FancyInput) +function FancyInput(props: FancyInputProps, ref: ForwardedRef) { + ... + return +}; + +export default React.forwardRef(FancyInput) ``` -## Stateless components vs Pure Components vs Class based components vs Render Props - When to use what? +If the ref handle is not available (e.g. `useImperativeHandle` is used) you can define a custom handle type above the component. -Class components are DEPRECATED. Use function components and React hooks. +```tsx +import type {ForwarderRef} from 'react'; +import {useImperativeHandle} from 'react'; -[https://react.dev/reference/react/Component#migrating-a-component-with-lifecycle-methods-from-a-class-to-a-function](https://react.dev/reference/react/Component#migrating-a-component-with-lifecycle-methods-from-a-class-to-a-function) +type FancyInputProps = { + ... + onButtonPressed: () => void; +}; -## Composition vs Inheritance +type FancyInputHandle = { + onButtonPressed: () => void; +} -From React's documentation - ->Props and composition give you all the flexibility you need to customize a component’s look and behavior in an explicit and safe way. Remember that components may accept arbitrary props, including primitive values, React elements, or functions. ->If you want to reuse non-UI functionality between components, we suggest extracting it into a separate JavaScript module. The components may import it and use that function, object, or a class, without extending it. +function FancyInput(props: FancyInputProps, ref: ForwardedRef) { + useImperativeHandle(ref, () => ({onButtonPressed})); -Use an HOC a.k.a. *[Higher order component](https://reactjs.org/docs/higher-order-components.html)* if you find a use case where you need inheritance. + ... + return ; +}; -If several HOC need to be combined, there is a `compose()` utility. But we should not use this utility when there is only one HOC. +export default React.forwardRef(FancyInput) +``` -```javascript -// Bad -export default compose( - withLocalize, -)(MyComponent); +### Hooks and HOCs -// Good -export default compose( - withLocalize, - withWindowDimensions, -)(MyComponent); +Use hooks whenever possible, avoid using HOCs. -// Good -export default withLocalize(MyComponent) +> Why? Hooks are easier to use (can be used inside the function component), and don't need nesting or `compose` when exporting the component. It also allows us to remove `compose` completely in some components since it has been bringing up some issues with TypeScript. Read the [`compose` usage](#compose-usage) section for further information about the TypeScript issues with `compose`. + +Onyx now provides a `useOnyx` hook that should be used over `withOnyx` HOC. + +```tsx +// BAD +type ComponentOnyxProps = { + session: OnyxEntry; +}; + +type ComponentProps = ComponentOnyxProps & { + someProp: string; +}; + +function Component({session, someProp}: ComponentProps) { + const {windowWidth, windowHeight} = useWindowDimensions(); + const {translate} = useLocalize(); + // component's code +} + +export default withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, +})(Component); + +// GOOD +type ComponentProps = { + someProp: string; +}; + +function Component({someProp}: ComponentProps) { + const [session] = useOnyx(ONYXKEYS.SESSION) + + const {windowWidth, windowHeight} = useWindowDimensions(); + const {translate} = useLocalize(); + // component's code +} ``` -**Note:** If you find that none of these approaches work for you, please ask an Expensify engineer for guidance via Slack or GitHub. +### Stateless components vs Pure Components vs Class based components vs Render Props - When to use what? + +Class components are DEPRECATED. Use function components and React hooks. + +[https://react.dev/reference/react/Component#migrating-a-component-with-lifecycle-methods-from-a-class-to-a-function](https://react.dev/reference/react/Component#migrating-a-component-with-lifecycle-methods-from-a-class-to-a-function) -## Use Refs Appropriately +### Use Refs Appropriately React's documentation explains refs in [detail](https://reactjs.org/docs/refs-and-the-dom.html). It's important to understand when to use them and how to use them to avoid bugs and hard to maintain code. @@ -580,43 +1101,43 @@ A common mistake with refs is using them to pass data back to a parent component There are several ways to use and declare refs and we prefer the [callback method](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs). -## Are we allowed to use [insert brand new React feature]? Why or why not? +### Are we allowed to use [insert brand new React feature]? Why or why not? We love React and learning about all the new features that are regularly being added to the API. However, we try to keep our organization's usage of React limited to the most stable set of features that React offers. We do this mainly for **consistency** and so our engineers don't have to spend extra time trying to figure out how everything is working. That said, if you aren't sure if we have adopted something, please ask us first. -# React Hooks: Frequently Asked Questions +## React Hooks: Frequently Asked Questions -## Are Hooks a Replacement for HOCs or Render Props? +### Are Hooks a Replacement for HOCs or Render Props? In most cases, a custom hook is a better pattern to use than an HOC or Render Prop. They are easier to create, understand, use and document. However, there might still be a case for a HOC e.g. if you have a component that abstracts some conditional rendering logic. -## Should I wrap all my inline functions with `useCallback()` or move them out of the component if they have no dependencies? +### Should I wrap all my inline functions with `useCallback()` or move them out of the component if they have no dependencies? The answer depends on whether you need a stable reference for the function. If there are no dependencies, you could move the function out of the component. If there are dependencies, you could use `useCallback()` to ensure the reference updates only when the dependencies change. However, it's important to note that using `useCallback()` may have a performance penalty, although the trade-off is still debated. You might choose to do nothing at all if there is no obvious performance downside to declaring a function inline. It's recommended to follow the guidance in the [React documentation](https://react.dev/reference/react/useCallback#should-you-add-usecallback-everywhere) and add the optimization only if necessary. If it's not obvious why such an optimization (i.e. `useCallback()` or `useMemo()`) would be used, leave a code comment explaining the reasoning to aid reviewers and future contributors. -## Why does `useState()` sometimes get initialized with a function? +### Why does `useState()` sometimes get initialized with a function? React saves the initial state once and ignores it on the next renders. However, if you pass the result of a function to `useState()` or call a function directly e.g. `useState(doExpensiveThings())` it will *still run on every render*. This can hurt performance depending on what work the function is doing. As an optimization, we can pass an initializer function instead of a value e.g. `useState(doExpensiveThings)` or `useState(() => doExpensiveThings())`. -## Is there an equivalent to `componentDidUpdate()` when using hooks? +### Is there an equivalent to `componentDidUpdate()` when using hooks? The short answer is no. A longer answer is that sometimes we need to check not only that a dependency has changed, but how it has changed in order to run a side effect. For example, a prop had a value of an empty string on a previous render, but now is non-empty. The generally accepted practice is to store the "previous" value in a `ref` so the comparison can be made in a `useEffect()` call. -## Are `useCallback()` and `useMemo()` basically the same thing? +### Are `useCallback()` and `useMemo()` basically the same thing? No! It is easy to confuse `useCallback()` with a memoization helper like `_.memoize()` or `useMemo()` but they are really not the same at all. [`useCallback()` will return a cached function _definition_](https://react.dev/reference/react/useCallback) and will not save us any computational cost of running that function. So, if you are wrapping something in a `useCallback()` and then calling it in the render, then it is better to use `useMemo()` to cache the actual **result** of calling that function and use it directly in the render. -## What is the `exhaustive-deps` lint rule? Can I ignore it? +### What is the `exhaustive-deps` lint rule? Can I ignore it? A `useEffect()` that does not include referenced props or state in its dependency array is [usually a mistake](https://legacy.reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies) as often we want effects to re-run when those dependencies change. However, there are some cases where we might actually only want to re-run the effect when only some of those dependencies change. We determined the best practice here should be to allow disabling the “next line” with a comment `//eslint-disable-next-line react-hooks/exhaustive-deps` and an additional comment explanation so the next developer can understand why the rule was not used. -## Should I declare my components with arrow functions (`const`) or the `function` keyword? +### Should I declare my components with arrow functions (`const`) or the `function` keyword? There are pros and cons of each, but ultimately we have standardized on using the `function` keyword to align things more with modern React conventions. There are also some minor cognitive overhead benefits in that you don't need to think about adding and removing brackets when encountering an implicit return. The `function` syntax also has the benefit of being able to be hoisted where arrow functions do not. -## How do I auto-focus a TextInput using `useFocusEffect()`? +### How do I auto-focus a TextInput using `useFocusEffect()`? -```javascript +```tsx const focusTimeoutRef = useRef(null); useFocusEffect(useCallback(() => { @@ -636,11 +1157,11 @@ This works better than using `onTransitionEnd` because - Note - This is a solution from [this PR](https://github.com/Expensify/App/pull/26415). You can find detailed discussion in comments. -# Onyx Best Practices +## Onyx Best Practices [Onyx Documentation](https://github.com/expensify/react-native-onyx) -## Collection Keys +### Collection Keys Our potentially larger collections of data (reports, policies, etc) are typically stored under collection keys. Collection keys let us group together individual keys vs. storing arrays with multiple objects. In general, **do not add a new collection key if it can be avoided**. There is most likely a more logical place to put the state. And failing to associate a state property with its logical owner is something we consider to be an anti-pattern (unnecessary data structure adds complexity for no value). @@ -648,4 +1169,20 @@ For example, if you are storing a boolean value that could be associated with a **Exception:** There are some [gotchas](https://github.com/expensify/react-native-onyx#merging-data) when working with complex nested array values in Onyx. So, this could be another valid reason to break a property off of its parent object (e.g. `reportActions` are easier to work with as a separate collection). -If you're not sure whether something should have a collection key, reach out in [`#expensify-open-source`](https://expensify.slack.com/archives/C01GTK53T8Q) for additional feedback. +If you're not sure whether something should have a collection key reach out in [`#expensify-open-source`](https://expensify.slack.com/archives/C01GTK53T8Q) for additional feedback. + +## Learning Resources + +### Quickest way to learn TypeScript + +- Get up to speed quickly + - [TypeScript playground](https://www.typescriptlang.org/play?q=231#example) + - Go though all examples on the playground. Click on "Example" tab on the top +- Handy Reference + - [TypeScript CheatSheet](https://www.typescriptlang.org/cheatsheets) + - [Type](https://www.typescriptlang.org/static/TypeScript%20Types-ae199d69aeecf7d4a2704a528d0fd3f9.png) + - [Control Flow Analysis](https://www.typescriptlang.org/static/TypeScript%20Control%20Flow%20Analysis-8a549253ad8470850b77c4c5c351d457.png) +- TypeScript with React + - [React TypeScript CheatSheet](https://react-typescript-cheatsheet.netlify.app/) + - [List of built-in utility types](https://react-typescript-cheatsheet.netlify.app/docs/basic/troubleshooting/utilities) + - [HOC CheatSheet](https://react-typescript-cheatsheet.netlify.app/docs/hoc/) diff --git a/contributingGuides/TS_CHEATSHEET.md b/contributingGuides/TS_CHEATSHEET.md index 1e330dafb7cf..77d316bb861d 100644 --- a/contributingGuides/TS_CHEATSHEET.md +++ b/contributingGuides/TS_CHEATSHEET.md @@ -21,8 +21,10 @@ - [1.1](#children-prop) **`props.children`** ```tsx - type WrapperComponentProps = { - children?: React.ReactNode; + import type ChildrenProps from '@src/types/utils/ChildrenProps'; + + type WrapperComponentProps = ChildrenProps & { + ... }; function WrapperComponent({ children }: WrapperComponentProps) { diff --git a/contributingGuides/TS_STYLE.md b/contributingGuides/TS_STYLE.md deleted file mode 100644 index d407019cbed6..000000000000 --- a/contributingGuides/TS_STYLE.md +++ /dev/null @@ -1,769 +0,0 @@ -# Expensify TypeScript Style Guide - -## Table of Contents - -- [Other Expensify Resources on TypeScript](#other-expensify-resources-on-typescript) -- [General Rules](#general-rules) -- [Guidelines](#guidelines) - - [1.1 Naming Conventions](#naming-conventions) - - [1.2 `d.ts` Extension](#d-ts-extension) - - [1.3 Type Alias vs. Interface](#type-alias-vs-interface) - - [1.4 Enum vs. Union Type](#enum-vs-union-type) - - [1.5 `unknown` vs. `any`](#unknown-vs-any) - - [1.6 `T[]` vs. `Array`](#array) - - [1.7 @ts-ignore](#ts-ignore) - - [1.8 Optional chaining and nullish coalescing](#ts-nullish-coalescing) - - [1.9 Type Inference](#type-inference) - - [1.10 JSDoc](#jsdoc) - - [1.11 `propTypes` and `defaultProps`](#proptypes-and-defaultprops) - - [1.12 Utility Types](#utility-types) - - [1.13 `object` Type](#object-type) - - [1.14 Export Prop Types](#export-prop-types) - - [1.15 File Organization](#file-organization) - - [1.16 Reusable Types](#reusable-types) - - [1.17 `.tsx`](#tsx) - - [1.18 No inline prop types](#no-inline-prop-types) - - [1.19 Satisfies operator](#satisfies-operator) - - [1.20 Hooks instead of HOCs](#hooks-instead-of-hocs) - - [1.21 `compose` usage](#compose-usage) - - [1.22 Type imports](#type-imports) - - [1.23 Ref types](#ref-types) -- [Exception to Rules](#exception-to-rules) -- [Communication Items](#communication-items) -- [Migration Guidelines](#migration-guidelines) -- [Learning Resources](#learning-resources) - -## Other Expensify Resources on TypeScript - -- [Expensify TypeScript React Native CheatSheet](./TS_CHEATSHEET.md) -- [Expensify TypeScript PropTypes Conversion Table](./PROPTYPES_CONVERSION_TABLE.md) - -## General Rules - -Strive to type as strictly as possible. - -```ts -type Foo = { - fetchingStatus: "loading" | "success" | "error"; // vs. fetchingStatus: string; - person: { name: string; age: number }; // vs. person: Record; -}; -``` - -## Guidelines - - - -- [1.1](#naming-conventions) **Naming Conventions**: Follow naming conventions specified below - - - Use PascalCase for type names. eslint: [`@typescript-eslint/naming-convention`](https://typescript-eslint.io/rules/naming-convention/) - - ```ts - // BAD - type foo = ...; - type BAR = ...; - - // GOOD - type Foo = ...; - type Bar = ...; - ``` - - - Do not postfix type aliases with `Type`. - - ```ts - // BAD - type PersonType = ...; - - // GOOD - type Person = ...; - ``` - - - Use singular name for union types. - - ```ts - // BAD - type Colors = "red" | "blue" | "green"; - - // GOOD - type Color = "red" | "blue" | "green"; - ``` - - - Use `{ComponentName}Props` pattern for prop types. - - ```ts - // BAD - type Props = { - // component's props - }; - - function MyComponent({}: Props) { - // component's code - } - - // GOOD - type MyComponentProps = { - // component's props - }; - - function MyComponent({}: MyComponentProps) { - // component's code - } - ``` - - - For generic type parameters, use `T` if you have only one type parameter. Don't use the `T`, `U`, `V`... sequence. Make type parameter names descriptive, each prefixed with `T`. - - > Prefix each type parameter name to distinguish them from other types. - - ```ts - // BAD - type KeyValuePair = { key: K; value: U }; - - type Keys = Array; - - // GOOD - type KeyValuePair = { key: TKey; value: TValue }; - - type Keys = Array; - type Keys = Array; - ``` - - - -- [1.2](#d-ts-extension) **`d.ts` Extension**: Do not use `d.ts` file extension even when a file contains only type declarations. Only exceptions are `src/types/global.d.ts` and `src/types/modules/*.d.ts` files in which third party packages and JavaScript's built-in modules (e.g. `window` object) can be modified using module augmentation. Refer to the [Communication Items](#communication-items) section to learn more about module augmentation. - - > Why? Type errors in `d.ts` files are not checked by TypeScript [^1]. - -[^1]: This is because `skipLibCheck` TypeScript configuration is set to `true` in this project. - - - -- [1.3](#type-alias-vs-interface) **Type Alias vs. Interface**: Do not use `interface`. Use `type`. eslint: [`@typescript-eslint/consistent-type-definitions`](https://typescript-eslint.io/rules/consistent-type-definitions/) - - > Why? In TypeScript, `type` and `interface` can be used interchangeably to declare types. Use `type` for consistency. - - ```ts - // BAD - interface Person { - name: string; - } - - // GOOD - type Person = { - name: string; - }; - ``` - - - -- [1.4](#enum-vs-union-type) **Enum vs. Union Type**: Do not use `enum`. Use union types. eslint: [`no-restricted-syntax`](https://eslint.org/docs/latest/rules/no-restricted-syntax) - - > Why? Enums come with several [pitfalls](https://blog.logrocket.com/why-typescript-enums-suck/). Most enum use cases can be replaced with union types. - - ```ts - // Most simple form of union type. - type Color = "red" | "green" | "blue"; - function printColors(color: Color) { - console.log(color); - } - - // When the values need to be iterated upon. - import { TupleToUnion } from "type-fest"; - - const COLORS = ["red", "green", "blue"] as const; - type Color = TupleToUnion; // type: 'red' | 'green' | 'blue' - - for (const color of COLORS) { - printColor(color); - } - - // When the values should be accessed through object keys. (i.e. `COLORS.Red` vs. `"red"`) - import { ValueOf } from "type-fest"; - - const COLORS = { - Red: "red", - Green: "green", - Blue: "blue", - } as const; - type Color = ValueOf; // type: 'red' | 'green' | 'blue' - - printColor(COLORS.Red); - ``` - - - -- [1.5](#unknown-vs-any) **`unknown` vs. `any`**: Don't use `any`. Use `unknown` if type is not known beforehand. eslint: [`@typescript-eslint/no-explicit-any`](https://typescript-eslint.io/rules/no-explicit-any/) - - > Why? `any` type bypasses type checking. `unknown` is type safe as `unknown` type needs to be type narrowed before being used. - - ```ts - const value: unknown = JSON.parse(someJson); - if (typeof value === 'string') {...} - else if (isPerson(value)) {...} - ... - ``` - - - -- [1.6](#array) **`T[]` vs. `Array`**: Use `T[]` or `readonly T[]` for simple types (i.e. types which are just primitive names or type references). Use `Array` or `ReadonlyArray` for all other types (union types, intersection types, object types, function types, etc). eslint: [`@typescript-eslint/array-type`](https://typescript-eslint.io/rules/array-type/) - - ```ts - // Array - const a: Array = ["a", "b"]; - const b: Array<{ prop: string }> = [{ prop: "a" }]; - const c: Array<() => void> = [() => {}]; - - // T[] - const d: MyType[] = ["a", "b"]; - const e: string[] = ["a", "b"]; - const f: readonly string[] = ["a", "b"]; - ``` - - - -- [1.7](#ts-ignore) **@ts-ignore**: Do not use `@ts-ignore` or its variant `@ts-nocheck` to suppress warnings and errors. - - > Use `@ts-expect-error` during the migration for type errors that should be handled later. Refer to the [Migration Guidelines](#migration-guidelines) for specific instructions on how to deal with type errors during the migration. eslint: [`@typescript-eslint/ban-ts-comment`](https://typescript-eslint.io/rules/ban-ts-comment/) - - - -- [1.8](#ts-nullish-coalescing) **Optional chaining and nullish coalescing**: Use optional chaining and nullish coalescing instead of the `get` lodash function. eslint: [`no-restricted-imports`](https://eslint.org/docs/latest/rules/no-restricted-imports) - - ```ts - // BAD - import lodashGet from "lodash/get"; - const name = lodashGet(user, "name", "default name"); - - // GOOD - const name = user?.name ?? "default name"; - ``` - - - -- [1.9](#type-inference) **Type Inference**: When possible, allow the compiler to infer type of variables. - - ```ts - // BAD - const foo: string = "foo"; - const [counter, setCounter] = useState(0); - - // GOOD - const foo = "foo"; - const [counter, setCounter] = useState(0); - const [username, setUsername] = useState(undefined); // Username is a union type of string and undefined, and its type cannot be inferred from the default value of undefined - ``` - - For function return types, default to always typing them unless a function is simple enough to reason about its return type. - - > Why? Explicit return type helps catch errors when implementation of the function changes. It also makes it easy to read code even when TypeScript intellisense is not provided. - - ```ts - function simpleFunction(name: string) { - return `hello, ${name}`; - } - - function complicatedFunction(name: string): boolean { - // ... some complex logic here ... - return foo; - } - ``` - - - -- [1.10](#jsdoc) **JSDoc**: Omit comments that are redundant with TypeScript. Do not declare types in `@param` or `@return` blocks. Do not write `@implements`, `@enum`, `@private`, `@override`. eslint: [`jsdoc/no-types`](https://github.com/gajus/eslint-plugin-jsdoc/blob/main/.README/rules/no-types.md) - - > Not all parameters or return values need to be listed in the JSDoc comment. If there is no comment accompanying the parameter or return value, omit it. - - ```ts - // BAD - /** - * @param {number} age - * @returns {boolean} Whether the person is a legal drinking age or nots - */ - function canDrink(age: number): boolean { - return age >= 21; - } - - // GOOD - /** - * @returns Whether the person is a legal drinking age or nots - */ - function canDrink(age: number): boolean { - return age >= 21; - } - ``` - - In the above example, because the parameter `age` doesn't have any accompanying comment, it is completely omitted from the JSDoc. - - - -- [1.11](#proptypes-and-defaultprops) **`propTypes` and `defaultProps`**: Do not use them. Use object destructing to assign default values if necessary. - - > Refer to [the propTypes Migration Table](./PROPTYPES_CONVERSION_TABLE.md) on how to type props based on existing `propTypes`. - - > Assign a default value to each optional prop unless the default values is `undefined`. - - ```tsx - type MyComponentProps = { - requiredProp: string; - optionalPropWithDefaultValue?: number; - optionalProp?: boolean; - }; - - function MyComponent({ - requiredProp, - optionalPropWithDefaultValue = 42, - optionalProp, - }: MyComponentProps) { - // component's code - } - ``` - - - -- [1.12](#utility-types) **Utility Types**: Use types from [TypeScript utility types](https://www.typescriptlang.org/docs/handbook/utility-types.html) and [`type-fest`](https://github.com/sindresorhus/type-fest) when possible. - - ```ts - type Foo = { - bar: string; - }; - - // BAD - type ReadOnlyFoo = { - readonly [Property in keyof Foo]: Foo[Property]; - }; - - // GOOD - type ReadOnlyFoo = Readonly; - ``` - - - -- [1.13](#object-type) **`object`**: Don't use `object` type. eslint: [`@typescript-eslint/ban-types`](https://typescript-eslint.io/rules/ban-types/) - - > Why? `object` refers to "any non-primitive type," not "any object". Typing "any non-primitive value" is not commonly needed. - - ```ts - // BAD - const foo: object = [1, 2, 3]; // TypeScript does not error - ``` - - If you know that the type of data is an object but don't know what properties or values it has beforehand, use `Record`. - - > Even though `string` is specified as a key, `Record` type can still accepts objects whose keys are numbers. This is because numbers are converted to strings when used as an object index. Note that you cannot use [symbols](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol) for `Record`. - - ```ts - function logObject(object: Record) { - for (const [key, value] of Object.entries(object)) { - console.log(`${key}: ${value}`); - } - } - ``` - - - -- [1.14](#export-prop-types) **Prop Types**: Don't use `ComponentProps` to grab a component's prop types. Go to the source file for the component and export prop types from there. Import and use the exported prop types. - - > Don't export prop types from component files by default. Only export it when there is a code that needs to access the prop type directly. - - ```tsx - // MyComponent.tsx - export type MyComponentProps = { - foo: string; - }; - - export default function MyComponent({ foo }: MyComponentProps) { - return {foo}; - } - - // BAD - import { ComponentProps } from "React"; - import MyComponent from "./MyComponent"; - type MyComponentProps = ComponentProps; - - // GOOD - import MyComponent, { MyComponentProps } from "./MyComponent"; - ``` - - - -- [1.15](#file-organization) **File organization**: In modules with platform-specific implementations, create `types.ts` to define shared types. Import and use shared types in each platform specific files. Do not use [`satisfies` operator](#satisfies-operator) for platform-specific implementations, always define shared types that complies with all variants. - - > Why? To encourage consistent API across platform-specific implementations. If you're migrating module that doesn't have a default implement (i.e. `index.ts`, e.g. `getPlatform`), refer to [Migration Guidelines](#migration-guidelines) for further information. - - Utility module example - - ```ts - // types.ts - type GreetingModule = { - getHello: () => string; - getGoodbye: () => string; - }; - - // index.native.ts - import { GreetingModule } from "./types"; - function getHello() { - return "hello from mobile code"; - } - function getGoodbye() { - return "goodbye from mobile code"; - } - const Greeting: GreetingModule = { - getHello, - getGoodbye, - }; - export default Greeting; - - // index.ts - import { GreetingModule } from "./types"; - function getHello() { - return "hello from other platform code"; - } - function getGoodbye() { - return "goodbye from other platform code"; - } - const Greeting: GreetingModule = { - getHello, - getGoodbye, - }; - export default Greeting; - ``` - - Component module example - - ```ts - // types.ts - export type MyComponentProps = { - foo: string; - } - - // index.ios.ts - import { MyComponentProps } from "./types"; - - export MyComponentProps; - export default function MyComponent({ foo }: MyComponentProps) { /* ios specific implementation */ } - - // index.ts - import { MyComponentProps } from "./types"; - - export MyComponentProps; - export default function MyComponent({ foo }: MyComponentProps) { /* Default implementation */ } - ``` - - - -- [1.16](#reusable-types) **Reusable Types**: Reusable type definitions, such as models (e.g., Report), must have their own file and be placed under `src/types/`. The type should be exported as a default export. - - ```ts - // src/types/Report.ts - - type Report = {...}; - - export default Report; - ``` - - - -- [1.17](#tsx) **tsx**: Use `.tsx` extension for files that contain React syntax. - - > Why? It is a widely adopted convention to mark any files that contain React specific syntax with `.jsx` or `.tsx`. - - - -- [1.18](#no-inline-prop-types) **No inline prop types**: Do not define prop types inline for components that are exported. - - > Why? Prop types might [need to be exported from component files](#export-prop-types). If the component is only used inside a file or module and not exported, then inline prop types can be used. - - ```ts - // BAD - export default function MyComponent({ foo, bar }: { foo: string, bar: number }){ - // component implementation - }; - - // GOOD - type MyComponentProps = { foo: string, bar: number }; - export default MyComponent({ foo, bar }: MyComponentProps){ - // component implementation - } - ``` - - - -- [1.19](#satisfies-operator) **Satisfies Operator**: Use the `satisfies` operator when you need to validate that the structure of an expression matches a specific type, without affecting the resulting type of the expression. - - > Why? TypeScript developers often want to ensure that an expression aligns with a certain type, but they also want to retain the most specific type possible for inference purposes. The `satisfies` operator assists in doing both. - - ```ts - // BAD - const sizingStyles = { - w50: { - width: '50%', - }, - mw100: { - maxWidth: '100%', - }, - } as const; - - // GOOD - const sizingStyles = { - w50: { - width: '50%', - }, - mw100: { - maxWidth: '100%', - }, - } satisfies Record; - ``` - - - -- [1.20](#hooks-instead-of-hocs) **Hooks instead of HOCs**: Replace HOCs usage with Hooks whenever possible. - - > Why? Hooks are easier to use (can be used inside the function component), and don't need nesting or `compose` when exporting the component. It also allows us to remove `compose` completely in some components since it has been bringing up some issues with TypeScript. Read the [`compose` usage](#compose-usage) section for further information about the TypeScript issues with `compose`. - - > Note: Because Onyx doesn't provide a hook yet, in a component that accesses Onyx data with `withOnyx` HOC, please make sure that you don't use other HOCs (if applicable) to avoid HOC nesting. - - ```tsx - // BAD - type ComponentOnyxProps = { - session: OnyxEntry; - }; - - type ComponentProps = WindowDimensionsProps & - WithLocalizeProps & - ComponentOnyxProps & { - someProp: string; - }; - - function Component({windowWidth, windowHeight, translate, session, someProp}: ComponentProps) { - // component's code - } - - export default compose( - withWindowDimensions, - withLocalize, - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - }), - )(Component); - - // GOOD - type ComponentOnyxProps = { - session: OnyxEntry; - }; - - type ComponentProps = ComponentOnyxProps & { - someProp: string; - }; - - function Component({session, someProp}: ComponentProps) { - const {windowWidth, windowHeight} = useWindowDimensions(); - const {translate} = useLocalize(); - // component's code - } - - // There is no hook alternative for withOnyx yet. - export default withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - })(Component); - ``` - - - -- [1.21](#compose-usage) **`compose` usage**: Avoid the usage of `compose` function to compose HOCs in TypeScript files. Use nesting instead. - - > Why? `compose` function doesn't work well with TypeScript when dealing with several HOCs being used in a component, many times resulting in wrong types and errors. Instead, nesting can be used to allow a seamless use of multiple HOCs and result in a correct return type of the compoment. Also, you can use [hooks instead of HOCs](#hooks-instead-of-hocs) whenever possible to minimize or even remove the need of HOCs in the component. - - ```ts - // BAD - export default compose( - withCurrentUserPersonalDetails, - withReportOrNotFound(), - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - }), - )(Component); - - // GOOD - export default withCurrentUserPersonalDetails( - withReportOrNotFound()( - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - })(Component), - ), - ); - - // GOOD - alternative to HOC nesting - const ComponentWithOnyx = withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - })(Component); - const ComponentWithReportOrNotFound = withReportOrNotFound()(ComponentWithOnyx); - export default withCurrentUserPersonalDetails(ComponentWithReportOrNotFound); - ``` - - - -- [1.22](#type-imports) **Type imports/exports**: Always use the `type` keyword when importing/exporting types - - > Why? In order to improve code clarity and consistency and reduce bundle size after typescript transpilation, we enforce the all type imports/exports to contain the `type` keyword. This way, TypeScript can automatically remove those imports from the transpiled JavaScript bundle - - Imports: - ```ts - // BAD - import {SomeType} from './a' - import someVariable from './a' - - import {someVariable, SomeOtherType} from './b' - - // GOOD - import type {SomeType} from './a' - import someVariable from './a' - ``` - - Exports: - ```ts - // BAD - export {SomeType} - export someVariable - // or - export {someVariable, SomeOtherType} - - // GOOD - export type {SomeType} - export someVariable - ``` - -- [1.23](#ref-types) **Ref types**: Avoid using HTML elements while declaring refs. Please use React Native components where possible. React Native Web handles the references on its own. It also extends React Native components with [Interaction API](https://necolas.github.io/react-native-web/docs/interactions/) which should be used to handle Pointer and Mouse events. Exception of this rule is when we explicitly need to use functions available only in DOM and not in React Native, e.g. `getBoundingClientRect`. Then please declare ref type as `union` of React Native component and HTML element. When passing it to React Native component assert it as soon as possible using utility methods declared in `src/types/utils`. - -Normal usage: -```tsx -const ref = useRef(); - - {#DO SOMETHING}}> -``` - -Exceptional usage where DOM methods are necessary: -```tsx -import viewRef from '@src/types/utils/viewRef'; - -const ref = useRef(); - -if (ref.current && 'getBoundingClientRect' in ref.current) { - ref.current.getBoundingClientRect(); -} - - {#DO SOMETHING}}> -``` - -## Exception to Rules - -Most of the rules are enforced in ESLint or checked by TypeScript. If you think your particular situation warrants an exception, post the context in the `#expensify-open-source` Slack channel with your message prefixed with `TS EXCEPTION:`. The internal engineer assigned to the PR should be the one that approves each exception, however all discussion regarding granting exceptions should happen in the public channel instead of the GitHub PR page so that the TS migration team can access them easily. - -When an exception is granted, link the relevant Slack conversation in your PR. Suppress ESLint or TypeScript warnings/errors with comments if necessary. - -This rule will apply until the migration is done. After the migration, discussion on granting exception can happen inside the PR page and doesn't need take place in the Slack channel. - -## Communication Items - -> Comment in the `#expensify-open-source` Slack channel if any of the following situations are encountered. Each comment should be prefixed with `TS ATTENTION:`. Internal engineers will access each situation and prescribe solutions to each case. Internal engineers should refer to general solutions to each situation that follows each list item. - -- I think types definitions in a third party library or JavaScript's built-in module are incomplete or incorrect - -When the library indeed contains incorrect or missing type definitions and it cannot be updated, use module augmentation to correct them. All module augmentation code should be contained in `/src/types/modules/*.d.ts`, each library as a separate file. - -```ts -// external-library-name.d.ts - -declare module "external-library-name" { - interface LibraryComponentProps { - // Add or modify typings - additionalProp: string; - } -} -``` - -## Migration Guidelines - -> This section contains instructions that are applicable during the migration. - -- 🚨 Any new files under `src/` directory MUST be created in TypeScript now! New files in other directories (e.g. `tests/`, `desktop/`) can be created in TypeScript, if desired. - -- If you're migrating a module that doesn't have a default implementation (i.e. `index.ts`, e.g. `getPlatform`), convert `index.website.js` to `index.ts`. Without `index.ts`, TypeScript cannot get type information where the module is imported. - -- Deprecate the usage of `underscore`. Use vanilla methods from JS instead. Only use `lodash` when there is no easy vanilla alternative (eg. `lodashMerge`). eslint: [`no-restricted-imports`](https://eslint.org/docs/latest/rules/no-restricted-imports) - -```ts -// BAD -var arr = []; -_.each(arr, () => {}); - -// GOOD -var arr = []; -arr.forEach(function loopArr() {}); - -// BAD -lodashGet(object, ['foo'], 'bar'); - -// GOOD -object?.foo ?? 'bar'; -``` - -- Found type bugs. Now what? - - If TypeScript migration uncovers a bug that has been “invisible,” there are two options an author of a migration PR can take: - - - Fix issues if they are minor. Document each fix in the PR comment. - - Suppress a TypeScript error stemming from the bug with `@ts-expect-error`. Create a separate GH issue. Prefix the issue title with `[TS ERROR #]`. Cross-link the migration PR and the created GH issue. On the same line as `@ts-expect-error`, put down the GH issue number prefixed with `TODO:`. - - > The `@ts-expect-error` annotation tells the TS compiler to ignore any errors in the line that follows it. However, if there's no error in the line, TypeScript will also raise an error. - - ```ts - // @ts-expect-error TODO: #21647 - const x: number = "123"; // No TS error raised - - // @ts-expect-error - const y: number = 123; // TS error: Unused '@ts-expect-error' directive. - ``` - -- The TS issue I'm working on is blocked by another TS issue because of type errors. What should I do? - - In order to proceed with the migration faster, we are now allowing the use of `@ts-expect-error` annotation to temporally suppress those errors and help you unblock your issues. The only requirements is that you MUST add the annotation with a comment explaining that it must be removed when the blocking issue is migrated, e.g.: - - ```tsx - return ( - - ); - ``` - - **You will also need to reference the blocking issue in your PR.** You can find all the TS issues [here](https://github.com/orgs/Expensify/projects/46). - -## Learning Resources - -### Quickest way to learn TypeScript - -- Get up to speed quickly - - [TypeScript playground](https://www.typescriptlang.org/play?q=231#example) - - Go though all examples on the playground. Click on "Example" tab on the top -- Handy Reference - - [TypeScript CheatSheet](https://www.typescriptlang.org/cheatsheets) - - [Type](https://www.typescriptlang.org/static/TypeScript%20Types-ae199d69aeecf7d4a2704a528d0fd3f9.png) - - [Control Flow Analysis](https://www.typescriptlang.org/static/TypeScript%20Control%20Flow%20Analysis-8a549253ad8470850b77c4c5c351d457.png) -- TypeScript with React - - [React TypeScript CheatSheet](https://react-typescript-cheatsheet.netlify.app/) - - [List of built-in utility types](https://react-typescript-cheatsheet.netlify.app/docs/basic/troubleshooting/utilities) - - [HOC CheatSheet](https://react-typescript-cheatsheet.netlify.app/docs/hoc/) diff --git a/desktop/ELECTRON_EVENTS.ts b/desktop/ELECTRON_EVENTS.ts index 607ad7b21580..b06794567c7d 100644 --- a/desktop/ELECTRON_EVENTS.ts +++ b/desktop/ELECTRON_EVENTS.ts @@ -9,6 +9,10 @@ const ELECTRON_EVENTS = { KEYBOARD_SHORTCUTS_PAGE: 'keyboard-shortcuts-page', START_UPDATE: 'start-update', UPDATE_DOWNLOADED: 'update-downloaded', + DOWNLOAD: 'download', + DOWNLOAD_COMPLETED: 'download-completed', + DOWNLOAD_FAILED: 'download-started', + DOWNLOAD_CANCELED: 'download-canceled', SILENT_UPDATE: 'silent-update', } as const; diff --git a/desktop/contextBridge.ts b/desktop/contextBridge.ts index 487e528a7485..74b91c4634a1 100644 --- a/desktop/contextBridge.ts +++ b/desktop/contextBridge.ts @@ -16,10 +16,19 @@ const WHITELIST_CHANNELS_RENDERER_TO_MAIN = [ ELECTRON_EVENTS.REQUEST_VISIBILITY, ELECTRON_EVENTS.START_UPDATE, ELECTRON_EVENTS.LOCALE_UPDATED, + ELECTRON_EVENTS.DOWNLOAD, ELECTRON_EVENTS.SILENT_UPDATE, ] as const; -const WHITELIST_CHANNELS_MAIN_TO_RENDERER = [ELECTRON_EVENTS.KEYBOARD_SHORTCUTS_PAGE, ELECTRON_EVENTS.UPDATE_DOWNLOADED, ELECTRON_EVENTS.FOCUS, ELECTRON_EVENTS.BLUR] as const; +const WHITELIST_CHANNELS_MAIN_TO_RENDERER = [ + ELECTRON_EVENTS.KEYBOARD_SHORTCUTS_PAGE, + ELECTRON_EVENTS.UPDATE_DOWNLOADED, + ELECTRON_EVENTS.FOCUS, + ELECTRON_EVENTS.BLUR, + ELECTRON_EVENTS.DOWNLOAD_COMPLETED, + ELECTRON_EVENTS.DOWNLOAD_FAILED, + ELECTRON_EVENTS.DOWNLOAD_CANCELED, +] as const; const getErrorMessage = (channel: string): string => `Electron context bridge cannot be used with channel '${channel}'`; @@ -67,7 +76,7 @@ contextBridge.exposeInMainWorld('electron', { } // Deliberately strip event as it includes `sender` - ipcRenderer.on(channel, (event, ...args) => func(...args)); + ipcRenderer.on(channel, (event, ...args: unknown[]) => func(...args)); }, /** Remove listeners for a single channel from the main process and sent to the renderer process. */ diff --git a/desktop/createDownloadQueue.ts b/desktop/createDownloadQueue.ts new file mode 100644 index 000000000000..4403f989263c --- /dev/null +++ b/desktop/createDownloadQueue.ts @@ -0,0 +1,122 @@ +import type {BrowserWindow} from 'electron'; +import {app} from 'electron'; +import * as path from 'path'; +import createQueue from '@libs/Queue/Queue'; +import CONST from '@src/CONST'; +import ELECTRON_EVENTS from './ELECTRON_EVENTS'; +import type Options from './electronDownloadManagerType'; + +type DownloadItem = { + // The window where the download will be initiated + win: BrowserWindow; + + // The URL of the file to be downloaded + url: string; + + // The options for the download, such as save path, file name, etc. + options: Options; +}; + +type CreateDownloadQueue = () => { + enqueueDownloadItem: (item: DownloadItem) => void; + dequeueDownloadItem: () => DownloadItem | undefined; +}; + +/** + * Returns the filename with extension based on the given name and MIME type. + * @param name - The name of the file. + * @param mime - The MIME type of the file. + * @returns The filename with extension. + */ +const getFilenameFromMime = (name: string, mime: string): string => { + const extensions = mime.split('/').pop(); + return `${name}.${extensions}`; +}; + +const createDownloadQueue: CreateDownloadQueue = () => { + const downloadItemProcessor = (item: DownloadItem): Promise => + new Promise((resolve, reject) => { + let downloadTimeout: NodeJS.Timeout; + let downloadListener: (event: Electron.Event, electronDownloadItem: Electron.DownloadItem) => void; + + const timeoutFunction = () => { + item.win.webContents.session.removeListener('will-download', downloadListener); + resolve(); + }; + + const listenerFunction = (event: Electron.Event, electronDownloadItem: Electron.DownloadItem) => { + clearTimeout(downloadTimeout); + + const options = item.options; + const cleanup = () => item.win.webContents.session.removeListener('will-download', listenerFunction); + const errorMessage = `The download of ${electronDownloadItem.getFilename()} was interrupted`; + + if (options.directory && !path.isAbsolute(options.directory)) { + throw new Error('The `directory` option must be an absolute path'); + } + + const directory = options.directory ?? app.getPath('downloads'); + + let filePath: string; + if (options.filename) { + filePath = path.join(directory, options.filename); + } else { + const filename = electronDownloadItem.getFilename(); + const name = path.extname(filename) ? filename : getFilenameFromMime(filename, electronDownloadItem.getMimeType()); + + filePath = options.overwrite ? path.join(directory, name) : path.join(directory, name); + } + + if (options.saveAs) { + electronDownloadItem.setSaveDialogOptions({defaultPath: filePath, ...options.dialogOptions}); + } else { + electronDownloadItem.setSavePath(filePath); + } + + electronDownloadItem.on('updated', (_, state) => { + if (state !== 'interrupted') { + return; + } + + item.win.webContents.send(ELECTRON_EVENTS.DOWNLOAD_CANCELED, {url: item.url}); + cleanup(); + reject(new Error(errorMessage)); + electronDownloadItem.cancel(); + }); + + electronDownloadItem.on('done', (_, state) => { + cleanup(); + if (state === 'cancelled') { + item.win.webContents.send(ELECTRON_EVENTS.DOWNLOAD_CANCELED, {url: item.url}); + resolve(); + } else if (state === 'interrupted') { + item.win.webContents.send(ELECTRON_EVENTS.DOWNLOAD_FAILED, {url: item.url}); + reject(new Error(errorMessage)); + } else if (state === 'completed') { + if (process.platform === 'darwin') { + const savePath = electronDownloadItem.getSavePath(); + app.dock.downloadFinished(savePath); + } + item.win.webContents.send(ELECTRON_EVENTS.DOWNLOAD_COMPLETED, {url: item.url}); + resolve(); + } + }); + }; + + downloadTimeout = setTimeout(timeoutFunction, CONST.DOWNLOADS_TIMEOUT); + downloadListener = listenerFunction; + + item.win.webContents.downloadURL(item.url); + item.win.webContents.session.on('will-download', downloadListener); + }); + + const queue = createQueue(downloadItemProcessor); + + const enqueueDownloadItem = (item: DownloadItem): void => { + queue.enqueue(item); + }; + return {enqueueDownloadItem, dequeueDownloadItem: queue.dequeue}; +}; + +export default createDownloadQueue; +export type {DownloadItem, CreateDownloadQueue}; diff --git a/desktop/electronDownloadManagerType.ts b/desktop/electronDownloadManagerType.ts new file mode 100644 index 000000000000..755efe173887 --- /dev/null +++ b/desktop/electronDownloadManagerType.ts @@ -0,0 +1,49 @@ +import type {SaveDialogOptions} from 'electron'; + +type Options = { + /** + Show a `Save As…` dialog instead of downloading immediately. + + Note: Only use this option when strictly necessary. Downloading directly without a prompt is a much better user experience. + + @default false + */ + readonly saveAs?: boolean; + + /** + The directory to save the file in. + + Must be an absolute path. + + Default: [User's downloads directory](https://electronjs.org/docs/api/app/#appgetpathname) + */ + readonly directory?: string; + + /** + Name of the saved file. + This option only makes sense for `electronDownloadManager.download()`. + + Default: [`downloadItem.getFilename()`](https://electronjs.org/docs/api/download-item/#downloaditemgetfilename) + */ + readonly filename?: string; + + /** + Allow downloaded files to overwrite files with the same name in the directory they are saved to. + + The default behavior is to append a number to the filename. + + @default false + */ + readonly overwrite?: boolean; + + /** + Customize the save dialog. + + If `defaultPath` is not explicity defined, a default value is assigned based on the file path. + + @default {} + */ + readonly dialogOptions?: SaveDialogOptions; +}; + +export default Options; diff --git a/desktop/main.ts b/desktop/main.ts index b40557464ec1..57ea647cc3e2 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -13,8 +13,11 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type PlatformSpecificUpdater from '@src/setup/platformSetup/types'; import type {Locale} from '@src/types/onyx'; +import type {CreateDownloadQueue, DownloadItem} from './createDownloadQueue'; import ELECTRON_EVENTS from './ELECTRON_EVENTS'; +const createDownloadQueue: CreateDownloadQueue = require('./createDownloadQueue').default; + const port = process.env.PORT ?? 8082; const {DESKTOP_SHORTCUT_ACCELERATOR, LOCALES} = CONST; @@ -581,7 +584,7 @@ const mainWindow = (): Promise => { app.hide(); } - ipcMain.on(ELECTRON_EVENTS.LOCALE_UPDATED, (event, updatedLocale) => { + ipcMain.on(ELECTRON_EVENTS.LOCALE_UPDATED, (event, updatedLocale: Locale) => { Menu.setApplicationMenu(Menu.buildFromTemplate(localizeMenuItems(initialMenuTemplate, updatedLocale))); disposeContextMenu(); disposeContextMenu = createContextMenu(updatedLocale); @@ -601,7 +604,7 @@ const mainWindow = (): Promise => { // Listen to badge updater event emitted by the render process // and update the app badge count (MacOS only) - ipcMain.on(ELECTRON_EVENTS.REQUEST_UPDATE_BADGE_COUNT, (event, totalCount) => { + ipcMain.on(ELECTRON_EVENTS.REQUEST_UPDATE_BADGE_COUNT, (event, totalCount?: number) => { if (totalCount === -1) { // The electron docs say you should be able to update this and pass no parameters to set the badge // to a single red dot, but in practice it resulted in an error "TypeError: Insufficient number of @@ -613,6 +616,15 @@ const mainWindow = (): Promise => { } }); + const downloadQueue = createDownloadQueue(); + ipcMain.on(ELECTRON_EVENTS.DOWNLOAD, (event, downloadData) => { + const downloadItem: DownloadItem = { + ...downloadData, + win: browserWindow, + }; + downloadQueue.enqueueDownloadItem(downloadItem); + }); + // Automatically check for and install the latest version in the background ipcMain.on(ELECTRON_EVENTS.SILENT_UPDATE, () => { if (isSilentUpdating) { diff --git a/docs/Hidden/Instructions b/docs/Hidden/Instructions new file mode 100644 index 000000000000..940c7ab60d10 --- /dev/null +++ b/docs/Hidden/Instructions @@ -0,0 +1 @@ +This folder is used to house articles that should not be live articles on the helpsite. diff --git a/docs/README.md b/docs/README.md index 224abe0554a5..235334c95732 100644 --- a/docs/README.md +++ b/docs/README.md @@ -186,9 +186,18 @@ Just update the content for each variable accordingly or remove it if the inform Assume that we want to rename the article `The Free Plan` to `Freemium Features` for the hub `billing and plan types` in New Expensify platform. 1. Go to `docs/articles/new-expensify/billing-and-plan-types` 2. Rename `The-Free-Plan.md` to `Freemium-Features.md`. Use dashes for spaces in the file name. +3. Add an entry in redirects.csv for the old article pointing to the new article. Note: It is important that the file has `.md` extension. +# How to hide an article temporarily +Video demo available [here 🧵](https://expensify.slack.com/archives/C02NK2DQWUX/p1717772272605829?thread_ts=1717523271.137469&cid=C02NK2DQWUX). +1. Open github's in built code editor by pressing `.` on your keyboard. ([instructions here](https://docs.github.com/en/codespaces/the-githubdev-web-based-editor#opening-the-githubdev-editor)) +2. Go to the article that you want to hide `docs/articles/...`. +3. Drag and drop the article inside the hidden folder `docs/Hidden/`. +4. Add a redirect for it in `docs/redirects.csv` to ensure that we don't have broken links. You can choose to point it to the home page or the article's hub. +5. Commit the changes and raise a PR. + # How the site is deployed This site is hosted on Cloudflare pages. Whenever code is merged to main, the github action `deployExpensifyHelp` will run. diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index ad738e44ab44..5fd65532c021 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -71,7 +71,7 @@ platforms: - href: integrations title: Integrations - icon: /assets/images/workflow.svg + icon: /assets/images/simple-illustration__monitor-remotesync.svg description: Integrate with accounting or HR software to streamline expense approvals. - href: spending-insights @@ -131,7 +131,7 @@ platforms: - href: connections title: Connections - icon: /assets/images/workflow.svg + icon: /assets/images/simple-illustration__monitor-remotesync.svg description: Connect to accounting software to streamline expense approvals. - href: settings diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index ae19775d75df..eb59388159bf 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -941,8 +941,8 @@ button { } #platform-tabs > .active { - color: var(--color-button-text); - background-color: var(--color-button-success-background); + color: var(--color-text); + background-color: var(--color-button-background); } .hidden { diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md index 814bf8fc559b..05366a91d9fa 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md +++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md @@ -64,8 +64,9 @@ Make sure you're importing your card in the correct spot in Expensify and select Please note there are some things that cannot be bypassed within Expensify, including two-factor authentication being enabled within your bank account. This will prevent the connection from remaining stable and will need to be turned off on the bank side. ## What are the most reliable bank connections in Expensify?* -The most reliable corporate card to use with Expensify is the Expensify Visa® Commercial Card. We offer daily settlement, unapproved expense limits, and real-time compliance for secure and efficient spending, as well as 2% cash back (_Applies to USD purchases only._) Click here to learn more or apply. -Additionally, we've teamed up with major banks worldwide to ensure a smooth import of credit card transactions into your accounts. Corporate cards from the following banks also offer the most dependable connections in Expensify: +All bank connections listed below are extremely reliable, but we recommend transacting with the Expensify Visa® Commercial Card. It also offers daily and monthly settlement, unapproved expense limits, realtime compliance for secure and efficient spending, and cash back on all US purchases. [Click here to learn more about the Expensify Card](https://use.expensify.com/company-credit-card). + +We've also teamed up with major banks worldwide to ensure a smooth import of credit card transactions into your accounts: - American Express - Bank of America - Brex @@ -75,7 +76,7 @@ Additionally, we've teamed up with major banks worldwide to ensure a smooth impo - Stripe - Wells Fargo -Commercial feeds for company cards are the dependable connections in Expensify. If you have a corporate or commercial card account, you might have access to a daily transaction feed where expenses from Visa, MasterCard, and American Express are automatically sent to Expensify. Reach out to your banking relationship manager to check if your card program qualifies for this feature. +Commercial feeds for company cards are the dependable connections in Expensify. If you have a corporate or commercial card account, you might have access to a daily transaction feed where expenses from Visa, Mastercard, and American Express are automatically sent to Expensify. Reach out to your banking relationship manager to check if your card program qualifies for this feature. # Troubleshooting American Express Business diff --git a/docs/articles/expensify-classic/expenses/Referral-Program.md b/docs/articles/expensify-classic/expenses/Referral-Program.md deleted file mode 100644 index 24605dd17d3f..000000000000 --- a/docs/articles/expensify-classic/expenses/Referral-Program.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Earn money with Expensify referrals -description: Get paid with the Expensify referral program! Share your link, earn $250 per successful sign-up, and enjoy unlimited income potential. It’s that easy. -redirect_from: articles/other/Referral-Program/ ---- - - -# Earn money with Expensify referrals - -Picture this: You've found Expensify and it's transformed your approach to expense management and financial organization. You love it so much that you can't help but recommend it to friends, family, and colleagues. Wouldn’t it be nice if you could get rewarded just for spreading the word? - -With Expensify referrals, you can. Every time someone you invite to the platform signs up for a paid annual plan on Expensify, you’ll earn $250. Think of it as a thank-you gift from us to you! - -## How to get paid for Expensify referrals - -Here are a few easy ways to get paid for Expensify friend referrals: - -- Submit an expense report to your boss (even just one receipt!) -- Send an invoice to a client or customer -- Share your referral link with a friend - - To find your referral link, open your Expensify mobile app and go to **Settings > Refer a friend, earn cash! > Share invite link**. - -**If the person you referred commits to an annual subscription with two or more active users and makes two monthly payments, you’ll get $250. Cha-ching!** - -## Who can you refer? - -You can refer anyone who might benefit from Expensify. Seriously. Anybody. - -Know a small business owner? Refer them! An [accountant](https://use.expensify.com/accountants-program)? Refer them! A best friend from childhood who keeps losing paper receipts? Refer them! - -Plus, you can [refer an unlimited amount of new users](https://use.expensify.com/blog/earn-50000-by-referring-your-friends-to-expensify/) with the Expensify referral program, so your earning potential is truly sky-high. - -## Common questions about Expensify benefits - -Still have questions about the Expensify referral program? We’ve got answers. Check out our FAQ below. - -### How will I know if I am the first person to refer someone to Expensify? - -You’ll know if you’re the first person to refer someone to Expensify if we reach out to let you know that they’ve successfully adopted Expensify and have paid for two months of an annual subscription. - -Simply put, we check for the earliest recorded referrer of a member on the workspace, and if that’s you, then we’ll let you know. - -### My referral wasn’t counted! How can I appeal? - -If you think your Expensify friend referral wasn’t counted, please send a message to concierge@expensify.com with the email of the person you referred. Our team will review the referral and get back to you. - -## Share the Expensify love — and get paid in the process - -Who needs a side hustle when you have Expensify? With Expensify benefits, it’s not just about managing your expenses — it's about expanding your income too. Share your Expensify referral link now or send over an invoice to unlock unlimited earning potential. diff --git a/docs/articles/expensify-classic/expensify-card/Change-Expensify-Card-limit.md b/docs/articles/expensify-classic/expensify-card/Change-Expensify-Card-limit.md new file mode 100644 index 000000000000..81ce761f84f4 --- /dev/null +++ b/docs/articles/expensify-classic/expensify-card/Change-Expensify-Card-limit.md @@ -0,0 +1,25 @@ +--- +title: Change Expensify Card limit +description: Increase or decrease the limit for an Expensify Card or for a group +--- +
+ +You can set Expensify Card limits for each group in your organization, or you can set the limit per card. + +# Set a limit per card + +1. Hover over Settings, then click **Domains**. +2. Click the name of the domain. +3. Next to the card, click **Edit Limit**. +4. Ensure the Custom Smart Limit toggle is enabled to be able to set a specific card limit. Otherwise, the card limit will be determined by the limit set for the group that the employee is in. +5. In the Limit Amount field, enter the desired limit. If set to $0, the card will be disabled for use until the limit is increased. +6. Click **Save**. + +# Set a limit per group + +1. Hover over Settings, then click **Domains**. +2. Click the name of the domain. +3. Click the **Groups** tab on the left. +4. Click the Expensify Card Smart Limit field for the card and enter the desired limit. + +
diff --git a/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md b/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md new file mode 100644 index 000000000000..d7fa33221834 --- /dev/null +++ b/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md @@ -0,0 +1,28 @@ +--- +title: Deactivate or cancel an Expensify Card +description: Close an Expensify Card +--- +
+ +A cardholder or a Domain Admin can cancel an Expensify Card. You may want to cancel a card: +- To cancel an old Expensify Card after upgrading to the new Expensify Visa® Commercial Card +- After a fraudulent or suspicious charge +- After an employee leaves the company + +# Domain Admins + +To cancel an employee's Expensify Card as a Domain Admin, + +1. Hover over Settings, then click **Domains**. +2. Click the name of the domain. +3. Next to the card, click **Terminate**. + +# Cardholders + +To cancel an Expensify Card assigned to you, + +1. Hover over Settings, then click **Account**. +2. Click the **Credit Card Import** tab. +3. Click **Cancel** next to the card. + +
diff --git a/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md b/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md index c5578249289a..239da6518be7 100644 --- a/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md +++ b/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md @@ -26,13 +26,13 @@ After adopting the new Expensify Card, domain admins can issue virtual cards to **To assign a virtual card:** -1. Head to **Settings** > **Domains** > [**Company Cards**](https://www.expensify.com/domain_companycards). -2. Click the **Issue Virtual Cards** button. -3. Enter a card name (i.e., "Google Ads"). -4. Select a domain member to assign the card to. -5. Enter a card limit. -6. Select a **Limit Type** of _Fixed_ or _Monthly_. -7. Click **Issue Card**. +Head to **Settings** > **Domains** > [**Company Cards**](https://www.expensify.com/domain_companycards) and click the **Issue Virtual Cards** button. From there: + +1. Enter a card name (i.e., "Google Ads"). +2. Select a domain member to assign the card to. +3. Enter a card limit. +4. Select a **Limit Type** of _Fixed_ or _Monthly_. +5. Click **Issue Card**. ![The Issue Virtual Cards modal is open in the middle of the screen. There are four options to set; Card Name, Assignee, Card Limit, and Limit type. A cancel (left) and save (right) button are at the bottom right of the modal.]({{site.url}}/assets/images/AdminissuedVirtualCards.png){:width="100%"} diff --git a/docs/articles/expensify-classic/expensify-partner-program/Referral-Program.md b/docs/articles/expensify-classic/expensify-partner-program/Referral-Program.md index a1b1043dff47..424c8dc9d107 100644 --- a/docs/articles/expensify-classic/expensify-partner-program/Referral-Program.md +++ b/docs/articles/expensify-classic/expensify-partner-program/Referral-Program.md @@ -16,9 +16,9 @@ The sky's the limit for this referral program! Your referral can be anyone - a f 1. There are a bunch of different ways to refer someone to New Expensify: - Start a chat - - Request money - - Send money - - Split a bill + - Submit an expense to them + - Split an expense with them + - Pay someone (them) - Assign them a task - @ mention them - Invite them to a room diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md index 42d06d45fa87..411cc64eda7f 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md @@ -194,14 +194,11 @@ As mentioned above, we’ll be able to pull in transactions as they post (daily) ### If you don't have a corporate card, use the Expensify Card (US only) Expensify provides a corporate card with the following features: -- Up to 2% cash back (_Applies to USD purchases only._) -- [SmartLimits](https://help.expensify.com/articles/expensify-classic/expensify-card/Cardholder-Settings-and-Features) to control what each individual cardholder can spend -- A stable, unbreakable real-time connection (third-party bank feeds can run into connectivity issues) -- Receipt compliance - informing notifications (e.g. add a receipt!) for users *as soon as the card is swiped* -- Unlimited Virtual Cards - single-purpose cards with a fixed or monthly limit for specific company purchases -- A 50% discount on the price of all Expensify plans -- Multiple discounts and savings on a host of partner tech suppliers -- Good Karma - 10% of all card interchange we earn goes directly to the Expensify.org Social Justice Community funds +- Finish your expenses in a swipe, we'll take care of everything else +- Get cash back on every US purchase and up to 50% off your monthly Expensify bill +- Stay in control with realtime alerts, spend limits, and auto-reconciliation +- Don't worry about credit checks, annual fees, or personal guarantees +- Create unlimited virtual cards with fixed or monthly limits for specific purchases The Expensify Card is recommended as the most efficient way to manage your company's spending. diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md index 30d3b3e7732c..0f6df238db5b 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md @@ -155,11 +155,12 @@ The Expensify Card has many benefits for your company. Two in particular are wor ### If you don't have a corporate card, use the Expensify Card Expensify provides a corporate card with the following features: -- Up to 2% cash back (Applies to USD purchases only) -- [SmartLimits](https://community.expensify.com/discussion/4851/deep-dive-what-are-unapproved-expense-limits#latest) -- Unlimited Virtual Cards - single-purpose cards with a fixed or monthly limit for specific company purchases -- A stable, unbreakable connection (third-party bank feeds can run into connectivity issues) - +- Finish your expenses in a swipe, we'll take care of everything else +- Get cash back on every US purchase and up to 50% off your monthly Expensify bill +- Stay in control with realtime alerts, spend limits, and auto-reconciliation +- Don't worry about credit checks, annual fees, or personal guarantees +- Create unlimited virtual cards with fixed or monthly limits for specific purchases + The Expensify Card is recommended as the most efficient way to manage your company's spending. Here’s how to enable it: diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md b/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md index 3ce0d07cb65d..ee116f65a398 100644 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md @@ -276,9 +276,11 @@ To add a Custom Segment to your workspace, you’ll need to locate three fields **To find the Script/Field ID:** -If configuring Custom Segments as Report Fields, use the Field ID on the Transactions tab (under _Custom Segments > Transactions_). +Note that as of 2019.1, any new custom segments that you create automatically use the unified ID, and the Use as Field ID box is not visible. If you are editing a custom segment definition that was created before 2019.1, the Use as Field ID box is available. +To use a unified ID for the entire custom segment definition, check the Use as Field ID box. When the box is checked, no field ID fields or columns are shown on the Application & Sourcing subtabs because one ID is used for all fields. -If configuring Custom Segments as Tags, use the Field ID on the Transaction Columns tab (under _Custom Segments > Transaction Columns_). +- If configuring Custom Segments as Report Fields, use the Field ID on the Transactions tab (under _Custom Segments > Transactions_), or if no Field ID is shown, use the unified ID (just called "ID" right below the "Label"). +- If configuring Custom Segments as Tags, use the Field ID on the Transaction Columns tab (under _Custom Segments > Transaction Columns_), or if no Field ID is shown, use the unified ID (just called "ID" right below the "Label"). Lastly, head over to Expensify and do the following: 1. Navigate to **Settings > Workspace > Group > _[Workspace Name]_ > Connections > Configure > Coding tab** diff --git a/docs/articles/expensify-classic/reports/Add-custom-fields-to-reports-and-invoices.md b/docs/articles/expensify-classic/reports/Add-custom-fields-to-reports-and-invoices.md new file mode 100644 index 000000000000..ce0f60d3be56 --- /dev/null +++ b/docs/articles/expensify-classic/reports/Add-custom-fields-to-reports-and-invoices.md @@ -0,0 +1,27 @@ +--- +title: Add custom fields to reports and invoices +description: Customize the fields that appear on a report or an invoice +--- +
+ +Workspace Admins can add additional required fields to a report to include selections for project names, locations, trip information, and more. + +{% include info.html %} +You cannot create these report fields directly in Expensify if you are connected to an accounting integration (QuickBooks Online, QuickBooks Desktop, Intacct, Xero, or NetSuite). Please refer to the relevant article for instructions on creating fields within that system. +{% include end-info.html %} + +To create a custom field for a report, + +1. Hover over Settings and select **Workspaces**. +2. Select the desired workspace. +3. Click the **Reports** tab on the left. +4. Scroll down to the Report and Invoice Fields section. +5. Under Add New Field, enter a Field Title. +6. Click the dropdown for the Type field and select the desired selection method: + - **Text**: Provides a text box to type in the requested information. + - **Dropdown**: Provides a dropdown of options to choose from. + - **Date**: Opens a calendar to select a date. +7. Select the report type: **Expense Report** or **Invoice**. +8. Click **Add**. + +
diff --git a/docs/articles/expensify-classic/reports/Set-default-report-title.md b/docs/articles/expensify-classic/reports/Set-default-report-title.md new file mode 100644 index 000000000000..a103ad8d5e5a --- /dev/null +++ b/docs/articles/expensify-classic/reports/Set-default-report-title.md @@ -0,0 +1,17 @@ +--- +title: Set default report title +description: Set an automatic title for all reports +--- +
+ +Workspace Admins can set a default report title for all reports created under a specific workspace. If desired, these titles can also be enforced to prevent employees from changing them. + +1. Hover over Settings and select **Workspaces**. +2. Select the desired workspace. +3. Click the **Reports** tab on the left. +4. Scroll down to the Default Report Title section. +5. Configure the formula. You can use the example provided on the page as a guide or choose from more [report formula options](https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates). + - Some formulas will automatically update the report title as changes are made to the report. For example, any formula related to dates, total amounts, workspace name, would adjust the title before the report is submitted for approval. Changes will not retroactively update report titles for reports which have been Approved or Reimbursed. +6. If desired, enable the Enforce Default Report Title toggle. This will prevent employees from editing the default title. + +
diff --git a/docs/articles/expensify-classic/travel/Approve-travel-expenses.md b/docs/articles/expensify-classic/travel/Approve-travel-expenses.md new file mode 100644 index 000000000000..ae0feb4efaa6 --- /dev/null +++ b/docs/articles/expensify-classic/travel/Approve-travel-expenses.md @@ -0,0 +1,37 @@ +--- +title: Approve travel expenses +description: Determine how travel expenses are approved +--- +
+ +Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel. + +- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider. +- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours. +- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken. + +# Set approval method + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under General, select approval methods for Flights, Hotels, Cars and Rail. + +
+ +
+ +Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel. + +- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider. +- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours. +- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken. + +# Set approval method + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under General, select approval methods for Flights, Hotels, Cars and Rail. + +
diff --git a/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md new file mode 100644 index 000000000000..5d25670ac5ab --- /dev/null +++ b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md @@ -0,0 +1,89 @@ +--- +title: Book with Expensify Travel +description: Book flights, hotels, cars, trains, and more with Expensify Travel +--- +
+ +Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. + +With Expensify Travel, you can: +- Search and book travel arrangements all in one place +- Book travel for yourself or for someone else +- Get real-time support by chat or phone +- Manage all your T&E expenses in Expensify +- Create specific rules for booking travel +- Enable approvals for out-of-policy trips +- Book with any credit card on the market +- Book with the Expensify Card to get cash back and automatically reconcile transactions + +There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. + +# Book travel + +To book travel from the Expensify web app, + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +4. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +5. Select all the details for the arrangement you want to book. +6. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +{% include info.html %} +The travel itinerary is also emailed to the traveler’s [copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot), if applicable. +{% include end-info.html %} + +
+ +
+Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. + +With Expensify Travel, you can: +- Search and book travel arrangements all in one place +- Book travel for yourself or for someone else +- Get real-time support by chat or phone +- Manage all your T&E expenses in Expensify +- Create specific rules for booking travel +- Enable approvals for out-of-policy trips +- Book with any credit card on the market +- Book with the Expensify Card to get cash back and automatically reconcile transactions + +There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. + +# Book travel + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Agree to the terms and conditions and click **Continue**. +4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +6. Select all the details for the arrangement you want to book. +7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap the + icon in the bottom menu and select **Book travel**. +2. Tap **Book or manage travel**. +3. Agree to the terms and conditions and tap **Continue**. +4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +6. Select all the details for the arrangement you want to book. +7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. +{% include end-option.html %} + +{% include end-selector.html %} + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +
diff --git a/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md b/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md new file mode 100644 index 000000000000..2e17af06773c --- /dev/null +++ b/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md @@ -0,0 +1,65 @@ +--- +title: Configure travel policy and preferences +description: Set and update travel policies and preferences for your Expensify Workspace +--- +
+ +As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. + +# Create a travel policy + +When using Expensify Travel for the first time, you will need to create a new Travel Policy. + +To create a travel policy from the Expensify web app, + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. +5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +7. Click the paperclip icon next to each setting to de-couple it from your default policy. +8. Update the desired settings. + +# Update travel policy preferences + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Select the appropriate policy in the left menu. +5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +6. Click the paperclip icon next to each setting to de-couple it from your default policy. +7. Update the desired policies. + +
+ +
+ +As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. + +# Create a travel policy + +When using Expensify Travel for the first time, you will need to create a new Travel Policy. + +To create a travel policy from the Expensify web app, + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. +5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +7. Click the paperclip icon next to each setting to de-couple it from your default policy. +8. Update the desired settings. + +# Update travel policy preferences + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Select the appropriate policy in the left menu. +5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +6. Click the paperclip icon next to each setting to de-couple it from your default policy. +7. Update the desired policies. + +
diff --git a/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md b/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md new file mode 100644 index 000000000000..7dc71c3220ca --- /dev/null +++ b/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md @@ -0,0 +1,28 @@ +--- +title: Edit or cancel travel arrangements +description: Modify travel arrangements booked with Expensify Travel +--- +
+ +Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee. + +
+ +
+ +You can review your travel arrangements any time by opening the Trip chat in your inbox. For example, if you booked a flight to San Francisco, a “Trip to San Francisco” chat will be automatically added to your chat inbox. + +To edit or cancel a travel arrangement, +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select the workspace the travel is booked under. +4. Tap into the booking to see more details. +5. Click **Trip Support**. + +If there is an unexpected change to the itinerary (for example, a flight cancellation), Expensify’s travel partner **Spotnana** will reach out to the traveler to provide updates on those changes. + +{% include info.html %} +You can click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee. +{% include end-info.html %} + +
diff --git a/docs/articles/expensify-classic/workspaces/reports/Currency.md b/docs/articles/expensify-classic/workspaces/reports/Currency.md deleted file mode 100644 index 19fd4e8f3723..000000000000 --- a/docs/articles/expensify-classic/workspaces/reports/Currency.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: Currency -description: How currency works in Expensify and how to change the default currency in your Expensify workspace ---- -# Overview - -In this article, we’ll outline how to change the default currency in your account and how currency works in Expensify. -Expensify supports expenses in almost every currency in the world. Group workspace admins and individual workspace users can specify the desired output currency for employee reports. Expensify handles the currency conversion process. - -# How to change your default currency - -The default currency for all expenses added to your account is set by the primary company workspace. Just head to **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Report Basics** and select your desired Report Currency. - -If you are not using a group workspace, you can change your default currency under **Settings > Workspaces > Individual > *[Workspace Name]* > Reports** and then choose your desired Report Currency. Please note that the currency selected here will be overridden should you begin reporting on a group workspace. - -# How currency works in Expensify - -When totaling expenses across multiple currencies, we convert them to a single currency, which is the "report currency" of the report's expense workspace, or your personal output currency if no workspace is in use. - -**Important notes:** - -- Currency settings on a workspace are all-or-nothing. To reflect a different output currency in reports, create a new workspace for those employees and adjust the currency settings accordingly. -- Currency settings in the workspace take precedence over a user's individual account settings. - -# How the conversion rate is determined -When converting expenses between currencies, we rely on [Open Exchange Rates](https://openexchangerates.org/) to determine the average bid and ask rate on the expense date. This rate becomes available after the market closes for that day, resulting in varying conversion rates depending on when the expense occurred and how the currencies were trading. - -If the markets were closed on the expense date (e.g., weekends), we use the daily average rate from the last open market day prior to the purchase. For future-dated expenses, we use the most recent available data. Consequently, the report's value may fluctuate until the expense date, potentially leading to unexpected results. You cannot submit reports until the markets have closed for all the expense dates on the report. - -To bypass exchange rate calculations, manually enter expenses in your default currency. These entries will only be converted when included in a report with a different default currency. - diff --git a/docs/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles.md b/docs/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles.md deleted file mode 100644 index e79e30ce42c9..000000000000 --- a/docs/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: Report Fields & Titles -description: This article is about managing Report Fields and Report Titles in Expensify ---- -# Overview - -In this article, we'll go over how to use Report Titles and Report Fields. - -## How to use Report Titles - -Default report titles enable group workspace admins or individual workspace users to establish a standardized title format for reports associated with a specific workspace. Additionally, admins have the choice to enforce these report titles, preventing employees from altering them. This ensures uniformity in report naming conventions during the review process, eliminating the need for employees to manually input titles for each report they generate. - -- Group workspace admins can set the Default Report Title from **Settings > Workspaces > Group > *[Workspace Name]* > Reports**. -- Individual users can set the Default Report Title from **Settings > Workspaces > Individual > *[Workspace Name]* > Reports**. - -You can configure the title by using the formulas that we provide to populate the Report Title. Take a look at the help article on Custom Formulas to find all eligible formulas for your Report Titles. - -## Deep Dive on Report Titles - -Some formulas will automatically update the report title as changes are made to the report. For example, any formula related to dates, total amounts, workspace name, would adjust the title before the report is submitted for approval. Changes will not retroactively update report titles for reports which have been Approved or Reimbursed. - -To prevent report title editing by employees, simply enable "Enforce Default Report Title." - -## How to use Report Fields - -Report fields let you specify header-level details, distinct from tags which pertain to expenses on individual line items. These details can encompass specific project names, business trip information, locations, and more. Customize them according to your workspace's requirements. - -To set up Report Fields, follow these steps: -- Workspace Admins can create report fields for group workspaces from **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Report and Invoice Fields**. For individual workspaces, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Report and Invoice Fields**. -- Under "Add New Field," enter the desired field name in the "Field Title" to describe the type of information to be selected. -- Choose the appropriate input method under "Type": - - Text: Provides users with a free-text box to enter the requested information. - - Dropdown: Creates a selection of options for users to choose from. - - Date: Displays a clickable box that opens a calendar for users to select a date. - -## Deep Dive on Report Fields - -You cannot create these report fields directly in Expensify if you are connected to an accounting integration (QuickBooks Online, QuickBooks Desktop, Intacct, Xero, or NetSuite). Please refer to the relevant article for instructions on creating fields within that system. - -When report fields are configured on a workspace, they become mandatory information for associated reports. Leaving a report field empty or unselected will trigger a report violation, potentially blocking report submission or export. - -Report fields are "sticky," which means that any changes made by an employee will persist and be reflected in subsequent reports they create. - diff --git a/docs/articles/expensify-classic/workspaces/reports/Scheduled-Submit.md b/docs/articles/expensify-classic/workspaces/reports/Scheduled-Submit.md deleted file mode 100644 index 18ad693a1c56..000000000000 --- a/docs/articles/expensify-classic/workspaces/reports/Scheduled-Submit.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: Scheduled Submit -description: How to use the Scheduled Submit feature ---- -# Overview - -Scheduled Submit reduces the delay between the time an employee creates an expense to when it is submitted to the admin. This gives admins significantly faster visibility into employee spend. Without Scheduled Submit enabled, expenses can be left Unreported giving workspace admins no visibility into employee spend. - -The biggest delay in expense management is the time it takes for an employee to actually submit the expense after it is incurred. Scheduled Submit allows you to automatically collect employee expenses on a schedule of your choosing without delaying the process while you wait for employees to submit them. - -It works like this: Employee expenses are automatically gathered onto a report. If there is not an existing report, a new one will be created. This report is submitted automatically at the cadence you choose (daily, weekly, monthly, twice month, by trip). - -# How to enable Scheduled Submit - -**For workspace admins**: To enable Scheduled Submit on your group workspace, follow **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu. -For individuals or employees: To enable Scheduled Submit on your individual workspace, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu. - -## Scheduled Submit frequency options - -**Daily**: Each night, expenses without violations will be submitted. Expenses with violations will remain on an open report until the violations are corrected, after which they will be submitted in the evening (PDT). - -**Weekly**: Expenses that are free of violations will be submitted on a weekly basis. However, expenses with violations will be held in a new open report and combined with any new expenses. They will then be submitted at the end of the following weekly cycle, specifically on Sunday evening (PDT). - -**Twice a month**: Expenses that are violation-free will be submitted on both the 15th and the last day of each month, in the evening (PDT). Expenses with violations will not be submitted, but moved on to a new open report so the employee can resolve the violations and then will be submitted at the conclusion of the next cycle. - -**Monthly**: Expenses that are free from violations will be submitted on a monthly basis. Expenses with violations will be held back and moved to a new Open report so the violations can be resolved, and they will be submitted on the evening (PDT) of the specified date. - -**By trip**: Expenses are grouped by trip. This is calculated by grouping all expenses together that occur in a similar time frame. If two full days pass without any new expenses being created, the trip report will be submitted on the evening of the second day. Any expenses generated after this submission will initiate a new trip report. Please note that the "2-day" period refers to a date-based interval, not a 48-hour time frame. - -**Manually**: An open report will be created, and expenses will be added to it automatically. However, it's important to note that the report will not be submitted automatically; manual submission of reports will be required.This is a great option for automatically gathering all an employee’s expenses on a report for the employee’s convenience, but they will still need to review and submit the report. - -**Instantly**: Expenses are automatically added to a report in the Processing state, and all expenses will continue to accumulate on one report until it is Approved or Reimbursed. This removes the need to submit expenses, and Processing reports can either be Reimbursed right away (if Submit and Close is enabled), or Approved and then Reimbursed (if Submit and Approve is enabled) by a workspace admin. - -# Deep Dive - -## Schedule Submit Override -If Scheduled Submit is disabled at the group workspace level or configured the frequency as "Manually," the individual workspace settings of a user will take precedence and be applied. This means an employee can still set up Scheduled Submit for themselves even if the admin has not enabled it. We highly recommend Scheduled Submit as it helps put your expenses on auto-pilot! - -## Personal Card Transactions -Personal card transactions are handled differently compared to other expenses. If a user has added a card through Settings > Account > Credit Card Import, they need to make sure it is set as non-reimbursable and transactions must be automatically merged with a SmartScanned receipt. If transactions are set to come in as reimbursable or they aren’t merged with a SmartScanned receipt, Scheduled Submit settings will not apply. - -## A note on Instantly -Setting Scheduled Submit frequency to Instantly will limit some employee actions on reports, such as the ability to retract or self-close reports, or create multiple reports. When Instantly is selected, expenses are automatically added to a Processing expense report, and new expenses will continue to accumulate on a single report until the report is Closed or Reimbursed by a workspace admin. diff --git a/docs/articles/new-expensify/chat/Expensify-Chat-For-Admins.md b/docs/articles/new-expensify/chat/Expensify-Chat-For-Admins.md deleted file mode 100644 index 5128484adc9d..000000000000 --- a/docs/articles/new-expensify/chat/Expensify-Chat-For-Admins.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: Expensify Chat for Admins -description: Best Practices for Admins settings up Expensify Chat ---- - -# Overview -Expensify Chat is an incredible way to build a community and foster long-term relationships between event producers and attendees, or attendees with each other. Admins are a huge factor in the success of the connections built in Expensify Chat during the events, as they are generally the drivers of the conference schedule, and help ensure safety and respect is upheld by all attendees both on and offline. - -# Getting Started -We’ve rounded up some resources to get you set up on Expensify Chat and ready to start connecting with your session attendees: -- [How to get set up and start using Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-use-chat-in-expensify) -- [How to format text in Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-format-text) -- [How to flag content and/or users for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive) - -# Admin Best Practices -In order to get the most out of Expensify Chat, we created a list of best practices for admins to review in order to use the tool to its fullest capabilities. - -**During the conference:** -- At a minimum, send 3 announcements throughout the day to create awareness of any sessions, activations, contests, or parties you want to promote. -- Communicate with the Expensify Team in the #admins room if you see anything you have questions about or are unsure of to make sure we’re resolving issues together ASAP. -- As an admin, It’s up to you to help keep your conference community safe and respectful. [Flag any content for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive) that does not fit your culture and values to keep chatrooms a positive experience for everyone involved. - -**After the conference:** -- The rooms will all stay open after the conference ends, so encourage speakers to keep engaging as long as the conversation is going in their session room. -- Continue sharing photos and videos from the event or anything fun in #social as part of a wrap up for everyone. -- Use the #announce room to give attendees a sneak preview of your next event. -- \ No newline at end of file diff --git a/docs/articles/new-expensify/connections/Coming-Soon.md b/docs/articles/new-expensify/connections/Coming-Soon.md deleted file mode 100644 index 4d32487a14b5..000000000000 --- a/docs/articles/new-expensify/connections/Coming-Soon.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Coming soon -description: Coming soon ---- - -# Coming soon \ No newline at end of file diff --git a/docs/articles/new-expensify/connections/Set-up-QuickBooks-Online-connection.md b/docs/articles/new-expensify/connections/Set-up-QuickBooks-Online-connection.md new file mode 100644 index 000000000000..6bc3b0896912 --- /dev/null +++ b/docs/articles/new-expensify/connections/Set-up-QuickBooks-Online-connection.md @@ -0,0 +1,126 @@ +--- +title: Set up QuickBooks Online connection +description: Integrate QuickBooks Online with Expensify +--- +
+ +{% include info.html %} +To use the QuickBooks Online connection, you must have a QuickBooks Online account and an Expensify Collect plan. The QuickBooks Self-employed subscription is not supported. +{% include end-info.html %} + +The features available for the Expensify connection with QuickBooks Online vary based on your QuickBooks subscription. The features may still be visible in Expensify even if you don’t have access, but you will receive an error if the feature isn't available with your subscription. + +Here is a list of the features supported by each QuickBooks Online subscription: + +| Feature | Simple Start | Essentials | Essentials Plus | +|----------------------------|--------------|------------|-----------------| +| Expense Reports | ✔ | ✔ | ✔ | +| GL Accounts as Categories | ✔ | ✔ | ✔ | +| Credit Card Transactions | ✔ | ✔ | ✔ | +| Debit Card Transaction | | ✔ | ✔ | +| Classes | | ✔ | ✔ | +| Customers | | ✔ | ✔ | +| Projects | | ✔ | ✔ | +| Vendor Bills | | ✔ | ✔ | +| Journal Entries | | ✔ | ✔ | +| Tax | | ✔ | ✔ | +| Billable | | | ✔ | +| Location | | | ✔ | + +To set up your QuickBooks Online connection, complete the 5 steps below. + +# Step 1: Set up employees in QuickBooks Online + +Log in to QuickBooks Online and ensure all of your employees are setup as either Vendors or Employees using the same email address that they are listed under in Expensify. This process may vary by country, but you can go to **Payroll** and select **Employees** in QuickBooks Online to add new employees or edit existing ones. + +# Step 2: Connect Expensify to QuickBooks Online + +
    +
  1. Click your profile image or icon in the bottom left menu.
  2. +
  3. Scroll down and click Workspaces in the left menu.
  4. +
  5. Select the workspace you want to connect to QuickBooks Online.
  6. +
  7. Click More features in the left menu.
  8. +
  9. Scroll down to the Integrate section and enable the Accounting toggle.
  10. +
  11. Click Accounting in the left menu.
  12. +
  13. Click Set up to the right of QuickBooks Online.
  14. +
  15. Enter your Intuit login details to import your settings from QuickBooks Online to Expensify.
  16. +
+ +# Step 3: Configure import settings + +The following steps help you determine how data will be imported from QuickBooks Online to Expensify. + +
    +
  1. Under the Accounting settings for your workspace, click Import under the QuickBooks Online connection.
  2. +
  3. Review each of the following import settings:
  4. +
      +
    • Chart of accounts: The chart of accounts are automatically imported from QuickBooks Online as categories. This cannot be amended.
    • +
    • Classes: Choose whether to import classes, which will be shown in Expensify as tags for expense-level coding.
    • +
    • Customers/projects: Choose whether to import customers/projects, which will be shown in Expensify as tags for expense-level coding.
    • +
    • Locations: Choose whether to import locations, which will be shown in Expensify as tags for expense-level coding.
    • +{% include info.html %} +As Locations are only configurable as tags, you cannot export expense reports as vendor bills or checks to QuickBooks Online. To unlock these export options, either disable locations import or upgrade to the Control Plan to export locations encoded as a report field. +{% include end-info.html %} +
    • Taxes: Choose whether to import tax rates and defaults.
    • +
    +
+ +# Step 4: Configure export settings + +The following steps help you determine how data will be exported from Expensify to QuickBooks Online. + +
    +
  1. Under the Accounting settings for your workspace, click Export under the QuickBooks Online connection.
  2. +
  3. Review each of the following export settings:
  4. +
      +
    • Preferred Exporter: Choose whether to assign a Workspace Admin as the Preferred Exporter. Once selected, the Preferred Exporter automatically receives reports for export in their account to help automate the exporting process.
    • + +{% include info.html %} +* Other Workspace Admins will still be able to export to QuickBooks Online. +* If you set different export accounts for individual company cards under your domain settings, then your Preferred Exporter must be a Domain Admin. +{% include end-info.html %} + +
    • Date: Choose whether to use the date of last expense, export date, or submitted date.
    • +
    • Export Out-of-Pocket Expenses as: Select whether out-of-pocket expenses will be exported as a check, journal entry, or vendor bill.
    • + +{% include info.html %} +These settings may vary based on whether tax is enabled for your workspace. +* If tax is not enabled on the workspace, you’ll also select the Accounts Payable/AP. +* If tax is enabled on the workspace, journal entry will not be available as an option. If you select the journal entries option first and later enable tax on the workspace, you will see a red dot and an error message under the “Export Out-of-Pocket Expenses as” options. To resolve this error, you must change your export option to vendor bill or check to successfully code and export expense reports. +{% include end-info.html %} + +
    • Invoices: Select the QuickBooks Online invoice account that invoices will be exported to.
    • +
    • Export as: Select whether company cards export to QuickBooks Online as a credit card (the default), debit card, or vendor bill. Then select the account they will export to.
    • +
    • If you select vendor bill, you’ll also select the accounts payable account that vendor bills will be created from, as well as whether to set a default vendor for credit card transactions upon export. If this option is enabled, you will select the vendor that all credit card transactions will be applied to.
    • +
    +
+ +# Step 5: Configure advanced settings + +The following steps help you determine the advanced settings for your connection, like auto-sync and employee invitation settings. + +
    +
  1. Under the Accounting settings for your workspace, click Advanced under the QuickBooks Online connection.
  2. +
  3. Select an option for each of the following settings:
  4. +
      +
    • Auto-sync: Choose whether to enable QuickBooks Online to automatically communicate changes with Expensify to ensure that the data shared between the two systems is up-to-date. New report approvals/reimbursements will be synced during the next auto-sync period.
    • +
    • Invite Employees: Choose whether to enable Expensify to import employee records from QuickBooks Online and invite them to this workspace.
    • +
    • Automatically Create Entities: Choose whether to enable Expensify to automatically create vendors and customers in QuickBooks Online if a matching vendor or customer does not exist.
    • +
    • Sync Reimbursed Reports: Choose whether to enable report syncing for reimbursed expenses. If enabled, all reports that are marked as Paid in QuickBooks Online will also show in Expensify as Paid. If enabled, you must also select the QuickBooks Online account that reimbursements are coming out of, and Expensify will automatically create the payment in QuickBooks Online.
    • +
    • Invoice Collection Account: Select the invoice collection account that you want invoices to appear under once the invoice is marked as paid.
    • +
    +
+ +{% include faq-begin.md %} + +**Why do I see a red dot next to my connection?** +If there is an error with your connection, you’ll see a red dot next to Accounting in the left menu. When you click Accounting, you’ll also see a red dot displayed next to the QuickBooks Online connection card. + +This may occur if you incorrectly enter your QuickBooks Online login information when trying to establish the connection. To resubmit your login details, +1. Click the three-dot menu to the right of the QuickBooks Online connection. +2. Click **Enter credentials**. +3. Enter your Intuit login details (the login information you use for QuickBooks Online) to establish the connection. + +{% include faq-end.md %} + +
diff --git a/docs/articles/new-expensify/connections/Set-up-Xero-connection.md b/docs/articles/new-expensify/connections/Set-up-Xero-connection.md new file mode 100644 index 000000000000..73bff6ad5862 --- /dev/null +++ b/docs/articles/new-expensify/connections/Set-up-Xero-connection.md @@ -0,0 +1,102 @@ +--- +title: Set up Xero connection +description: Integrate Xero with Expensify +--- +
+ +{% include info.html %} +To use the Xero connection, you must have a Xero account and an Expensify Collect plan. +{% include end-info.html %} + +To set up your Xero connection, complete the 4 steps below. + +# Step 1: Connect Expensify to Xero + +
    +
  1. Click your profile image or icon in the bottom left menu.
  2. +
  3. Scroll down and click Workspaces in the left menu.
  4. +
  5. Select the workspace you want to connect to Xero.
  6. +
  7. Click More features in the left menu.
  8. +
  9. Scroll down to the Integrate section and enable the Accounting toggle.
  10. +
  11. Click Accounting in the left menu.
  12. +
  13. Click Set up to the right of Xero.
  14. +
  15. Enter your Xero login details to import your settings from Xero to Expensify.
  16. +
+ +# Step 2: Configure import settings + +The following steps help you determine how data will be imported from Xero to Expensify. + +
    +
  1. Under the Accounting settings for your workspace, click Import under the Xero connection.
  2. +
  3. Select an option for each of the following settings to determine what information will be imported from Xero into Expensify:
  4. +
      +
    • Xero organization: Select which Xero organization your Expensify workspace is connected to. Each organization can only be connected to one workspace at a time.
    • +
    • Chart of Accounts: Your Xero chart of accounts and any accounts marked as “Show In Expense Claims” will be automatically imported into Expensify as Categories. This cannot be amended.
    • +
    • Tracking Categories: Choose whether to import your Xero categories for cost centers and regions as tags in Expensify.
    • +
    • Re-bill Customers: When enabled, Xero customer contacts are imported into Expensify as tags for expense tracking. After exporting to Xero, tagged billable expenses can be included on a sales invoice to your customer.
    • +
    • Taxes: Choose whether to import tax rates and tax defaults from Xero.
    • +
    +
+ +# Step 3: Configure export settings +The following steps help you determine how data will be exported from Expensify to Xero. + +
    +
  1. Under the Accounting settings for your workspace, click Export under the Xero connection.
  2. +
  3. Review each of the following export settings:
  4. +
      +
    • Preferred Exporter: Choose whether to assign a Workspace Admin as the Preferred Exporter. Once selected, the Preferred Exporter automatically receives reports for export in their account to help automate the exporting process.
    • +
    +
+{% include info.html %} +- Other Workspace Admins will still be able to export to Xero. +- If you set different export accounts for individual company cards under your domain settings, then your Preferred Exporter must be a Domain Admin. +{% include end-info.html %} + +
    +
      +
    • Export Out-of-Pocket Expenses as: All out-of-pocket expenses will be exported as purchase bills. This cannot be amended.
    • +
    • Purchase Bill Date: Choose whether to use the date of last expense, export date, or submitted date.
    • +
    • Export invoices as: All invoices exported to Xero will be as a sales invoice. This cannot be amended.
    • +
    • Export company card expenses as: All company card expenses export to Xero as bank transactions. This cannot be amended.
    • +
    • Xero Bank Account: Select which bank account will be used to post bank transactions when non-reimbursable expenses are exported.
    • +
    +
+ +# Step 4: Configure advanced settings + +The following steps help you determine the advanced settings for your connection, like auto-sync. + +
    +
  1. Under the Accounting settings for your workspace, click Advanced under the Xero connection.
  2. +
  3. Select an option for each of the following settings:
  4. +
      +
    • Auto-sync: Choose whether to enable Xero to automatically communicate changes with Expensify to ensure that the data shared between the two systems is up-to-date. New report approvals/reimbursements will be synced during the next auto-sync period. Once you’ve added a business bank account for ACH reimbursement, any reimbursable expenses will be sent to Xero automatically when the report is reimbursed. For non-reimbursable reports, Expensify automatically queues the report to export to Xero after it has completed the approval workflow in Expensify.
    • +
    • Set Purchase Bill Status: Choose the status of your purchase bills:
    • +
        +
      • Draft
      • +
      • Awaiting Approval
      • +
      • Awaiting Payment
      • +
      +
    • Sync Reimbursed Reports: Choose whether to enable report syncing for reimbursed expenses. If enabled, all reports that are marked as Paid in Xero will also show in Expensify as Paid. If enabled, you must also select the Xero account that reimbursements are coming out of, and Expensify will automatically create the payment in Xero.
    • +
    • Xero Bill Payment Account: If you enable Sync Reimbursed Reports, you must select the Xero Bill Payment account your reimbursements will come from.
    • +
    • Xero Invoice Collections Account: If you are exporting invoices from Expensify, select the invoice collection account that you want invoices to appear under once they are marked as paid.
    • +
    +
+ +{% include faq-begin.md %} + +**How do I disconnect Xero from Expensify?** + +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select the workspace you want to disconnect from Xero. +4. Click **Accounting** in the left menu. +5. Click the three dot menu icon to the right of Xero and select **Disconnect**. +6. Click **Disconnect** to confirm. + +You will no longer see the imported options from Xero. +{% include faq-end.md %} + +
diff --git a/docs/articles/new-expensify/expenses/Approve-and-pay-expenses.md b/docs/articles/new-expensify/expenses/Approve-and-pay-expenses.md new file mode 100644 index 000000000000..0cf642c76e4c --- /dev/null +++ b/docs/articles/new-expensify/expenses/Approve-and-pay-expenses.md @@ -0,0 +1,72 @@ +--- +title: Approve and Pay Expenses +description: Approve, hold, or pay expenses submitted to you +--- +
+ +When expenses are sent to you for approval, you have the option to: +- Approve and pay the expenses. +- Hold the expenses if payment needs to be delayed or if the expenses require additional information before they can be approved. + +{% include info.html %} +If your workspace does not require expense approvals, or if the expense is sent to you by a friend, you will not need to approve the expense and instead can immediately pay the expense when you are ready. +{% include end-info.html %} + +# Approve expenses + +When someone sends an expense or a group of expenses to you for approval, you’ll receive the expense in Expensify Chat for the related workspace. Chats with new updates appear with a green dot to the right of the chat message. Concierge also sends you an email notification for the new expense. + +To approve an expense, + +1. Open the Expensify Chat thread for the expense. +2. Click the expense or group of expenses. +3. Review the expense details to ensure they are correct. Look at each receipt, the amount, the description, and any additional details. +4. Determine the next steps. + - **Approve**: When you’re satisfied with the expense, click **Approve**. + - **Handle holds**: If any of the expenses are on hold, you can choose to either approve only the expenses that are not on hold or approve the full amount, including any held expenses. + - **Request changes**: You can add a comment to the expense’s chat thread in your Expensify Chat inbox to request changes to the expense details. + +{% include info.html %} +Admins can modify an expense, if needed. +{% include end-info.html %} + +You’re now ready to pay the expense. + +# Hold an expense + +If you need to delay a payment or if you need more information on the expense before it can be approved, you can hold the expense. + +To hold an expense, + +1. Open the Expensify Chat thread for the expense. +2. Click the expense or group of expenses. +3. Click the three dot menu at the top right of the expense and select **Hold**. +4. Enter a reason for the delay. +5. Review the Hold Overview page and click **Got It**. + +When you’re ready, you can choose to: +- **Remove the hold**: Complete the steps above and select **Unhold**. +- **Approve the expense**: Complete the steps above for “Approve expenses.” +Once the expense has been approved, you can now pay the expense. + +{% include info.html %} +Held expenses will not be available for payment until they have been approved. +{% include end-info.html %} + +# Pay expenses + +Once you’ve approved an expense—or if the expense does not require approval—you’ll be able to pay it. + +{% include info.html %} +To pay expenses within Expensify, you’ll need to set up your Expensify Wallet. +{% include end-info.html %} + +To pay an expense, + +1. Open the Expensify Chat thread for the expense. +2. Click the expense or group of expenses. +3. Select a payment option. + - Click **Pay** to pay the full expense within Expensify. If the expenses contain one that has been held, the pay amount will only include the expenses that have not been held. Then you’ll select your payment method. + - Click **Pay Elsewhere** to indicate that a payment has been sent using a method outside of Expensify, such as cash or a check. This will label the expense as Paid. + +
diff --git a/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md b/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md index 8cf0a18ba529..96653bc69763 100644 --- a/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md @@ -4,69 +4,51 @@ description: How to connect a business bank account to New Expensify ---
-Adding a business bank account unlocks a myriad of features and automation in Expensify, such as: -- Reimburse expenses via direct bank transfer -- Pay bills -- Collect invoice payments -- Issue the Expensify Card - -# To connect a bank account -1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account > Connect bank account** -2. Click **Connect online with Plaid** -3. Click **Continue** -4. When you reach the **Plaid** screen, you'll be shown a list of compatible banks that offer direct online login access -5. Login to the business bank account: -- If the bank is not listed, click the X to go back to the connection type -- Here you’ll see the option to **Connect Manually** -- Enter your account and routing numbers -6. Enter your bank login credentials: -- If your bank requires additional security measures, you will be directed to obtain and enter a security code -- If you have more than one account available to choose from, you will be directed to choose the desired account - -## Enter company information -This is where you’ll add the legal business name as well as several other company details. - -- **Company address**: The company address must be located in the US and a physical location (If you input a maildrop address, PO box, or UPS Store, the address will be flagged for review, and adding the bank account to Expensify will be delayed) -- **Tax Identification Number**: This is the identification number that was assigned to the business by the IRS -- **Company website**: A company website is required to use most of Expensify’s payment features. When adding the website of the business, format it as, https://www.domain.com -- **Industry Classification Code**: You can locate a list of Industry Classification Codes [here]([url](https://www.census.gov/naics/?input=software&year=2022)) - -## Enter personal information -Whoever is connecting the bank account to Expensify, must enter their details under the Requestor Information section: -- The address must be a physical address -- The address must be located in the US -- The SSN must be US-issued - -This does not need to be a signor on the bank account. If someone other than the Expensify account owner enters their personal information in this section, the details will be flagged for review, and adding the bank account to Expensify will be delayed. - -## Upload ID +To connect a bank account in New Expensify, you must first enable the Make or Track Payments Workflow. +# Step 1: Enable Make or track payments +1. Head to **Workspaces** > **More Features** > **Enable Workflows** +2. From there, a Workflows setting will appear in the left-hand menu +3. Click on **Workflows** +4. Enable **Make or track payments** + +# Step 2: Connect bank account +1. Click Connect bank account +2. Select either Connect online with Plaid (preferred) or Connect manually +3. Enter bank details + +# Step 4: Upload ID After entering your personal details, you’ll be prompted to click a link or scan a QR code so that you can do the following: 1. Upload a photo of the front and back of your ID (this cannot be a photo of an existing image) -2. Use your device to take a selfie and record a short video of yourself +2. Use your device to take a selfie and record a short video of yourself **Your ID must be:** - Issued in the US - Current (ie: the expiration date must be in the future) -## Additional Information -Check the appropriate box under **Additional Information**, accept the agreement terms, and verify that all of the information is true and accurate: -- A Beneficial Owner refers to an **individual** who owns 25% or more of the business. -- If you or another **individual** owns 25% or more of the business, please check the appropriate box +# Step 5: Enter company information +This is where you’ll add the legal business name as well as several other company details. +- **Company address:** The company address must be located in the US and a physical location (If you input a maildrop address, PO box, or UPS Store, the address will be flagged for review, and adding the bank account to Expensify will be delayed) +- **Tax Identification Number:** This is the identification number that was assigned to the business by the IRS +- **Company website:** A company website is required to use most of Expensify’s payment features. +- **Industry Classification Code:** You can locate a list of Industry Classification Codes [here](https://www.census.gov/naics/?input=software&year=2022). + +# Step 6: Additional Information +Check the appropriate box under Additional Information, accept the agreement terms, and verify that all of the information is true and accurate: +- A Beneficial Owner refers to an individual who owns 25% or more of the business. +- If you or another individual owns 25% or more of the business, please check the appropriate box - If someone else owns 25% or more of the business, you will be prompted to provide their personal information If no individual owns more than 25% of the company you do not need to list any beneficial owners. In that case, be sure to leave both boxes unchecked under the Beneficial Owner Additional Information section. -The details you submitted may require additional review. If that's the case, you'll receive a message from the Concierge outlining the next steps. Otherwise, your bank account will be connected automatically. +The details you submitted may require additional review. If that's the case, you'll receive a message from the Concierge outlining the next steps. Otherwise, your bank account will be connected automatically. {% include faq-begin.md %} ## What are the general requirements for adding a business bank account? -To add a business bank account to issue reimbursements via ACH (US), to pay invoices (US), or to issue Expensify Cards: -- You must enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We **cannot** accept a PO Box or MailDrop location. -If you are adding the bank account to Expensify, you must do so from your Expensify account settings. -- If you are adding a bank account to Expensify, we are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. For using features related to US ACH, your ID must be issued by the United States. You and any Beneficial Owner (if one exists), must also have a US address -- You must have a valid website for your business to utilize the Expensify Card, or to pay invoices with Expensify. +To add a business bank account to issue reimbursements via ACH (US) or to issue Expensify Cards: +- You must enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We cannot accept a PO Box or MailDrop location. +- If you are adding the bank account to Expensify, we are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. Your ID must be issued by the United States to use features related to US ACH. You and any Beneficial Owner (if one exists) must also have a US address. ## What is a Beneficial Owner? @@ -74,25 +56,27 @@ A Beneficial Owner refers to an **individual** who owns 25% or more of the busin ## What do I do if the Beneficial Owner section only asks for personal details, but my organization is owned by another company? -Please indicate you have a Beneficial Owner only if it is an individual who owns 25% or more of the business. +Please indicate you have a Beneficial Owner only if it is an individual who owns 25% or more of the business. ## Why can’t I input my address or upload my ID? -Are you entering a US address? When adding a verified business bank account in Expensify, the individual adding the account and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account and then share access with you once it is verified. +When adding a verified business bank account in Expensify, the individual adding the account and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account and then share access with you once it is verified. ## Why am I asked for documents when adding my bank account? -When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place. -If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. +When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place. + +If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. If you have any questions regarding the documentation request you received, please contact Concierge and they will be happy to assist. ## I don’t see all three microtransactions I need to validate my bank account. What should I do? -It's a good idea to wait until the end of that second business day. If you still don’t see them, please contact your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify." +Wait until the end of the second business day. If you still don’t see them, please contact your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify." Once that's all set, make sure to contact your account manager or concierge, and our team will be able to re-trigger those three test transactions! + {% include faq-end.md %}
diff --git a/docs/articles/new-expensify/expenses/Resolve-Errors-Adding-a-Bank-Account.md b/docs/articles/new-expensify/expenses/Resolve-Errors-Adding-a-Bank-Account.md new file mode 100644 index 000000000000..ace488f589a1 --- /dev/null +++ b/docs/articles/new-expensify/expenses/Resolve-Errors-Adding-a-Bank-Account.md @@ -0,0 +1,21 @@ +--- +title: Resolve Errors Adding a Bank Account +description: Troubleshooting issues adding a business bank account in Expensify. +--- +
+ +Expensify is required to verify the identity of the individual who is connecting a business bank account. + +**If you get a generic error message while uploading your ID, please go through the following steps:** +1. Ensure you are using either Safari (on iPhone) or Chrome (on Android) as your web browser. +2. Check your browser's permissions to make sure that the camera and microphone settings are set to "Allow" +3. Clear your web cache for Safari (on iPhone) or Chrome (on Android). +4. If using a corporate Wi-Fi network, confirm that your corporate firewall isn't blocking the website. +5. Make sure no other apps are overlapping your screen, such as the Facebook Messenger bubble, while recording the video. +6. On iPhone, if using iOS version 15 or later, disable the Hide IP address feature in Safari. +7. If possible, try these steps on another device +8. If you have another phone available, try to follow these steps on that device + +If the issue persists, please contact your Account Manager or Concierge for further troubleshooting assistance. + +
diff --git a/docs/articles/new-expensify/expenses/Set-up-your-wallet.md b/docs/articles/new-expensify/expenses/Set-up-your-wallet.md new file mode 100644 index 000000000000..de1ee61066b0 --- /dev/null +++ b/docs/articles/new-expensify/expenses/Set-up-your-wallet.md @@ -0,0 +1,52 @@ +--- +title: Set up your wallet +description: Send and receive payments by adding your payment account +--- +
+To send and receive money using Expensify, you’ll first need to set up your Expensify Wallet by adding your payment account. + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Click **Wallet** in the left menu. +3. Click **Enable wallet**. +4. If you haven’t already added your bank account, click **Continue** and follow the prompts to add your bank account details with Plaid. If you have already connected your bank account, you’ll skip to the next step. + +{% include info.html %} +Plaid is an encrypted third-party financial data platform that Expensify uses to securely verify your banking information. +{% include end-info.html %} + +{:start="5"} +5. Enter your personal details (including your name, address, date of birth, phone number, and the last 4 digits of your social security number). +6. Click **Save & continue**. +7. Review the Onfido terms and click **Accept**. +8. Use the prompts to continue the next steps on your mobile device where you will select which option you want to use to verify your device: a QR code, a link, or a text message. +9. Follow the prompts on your mobile device to submit your ID with Onfido. + +When your ID is uploaded successfully, Onfido closes automatically. You can return to your Expensify Wallet to verify that it is now enabled. Once enabled, you are ready to send and receive payments. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon in the bottom menu. +2. Tap **Wallet**. +3. Tap **Enable wallet**. +4. If you haven’t already added your bank account, tap **Continue** and follow the prompts to add your bank account details with Plaid. If you have already connected your bank account, you’ll skip to the next step. + +{% include info.html %} +Plaid is an encrypted third-party financial data platform that Expensify uses to securely verify your banking information. +{% include end-info.html %} + +{:start="5"} +5. Enter your personal details (including your name, address, date of birth, phone number, and the last 4 digits of your social security number). +6. Tap **Save & continue**. +7. Review the Onfido terms and tap **Accept**. +8. Follow the prompts to submit your ID with Onfido. When your ID is uploaded successfully, Onfido closes automatically. +9. Tap **Enable wallet** again to enable payments for the wallet. + +Once enabled, you are ready to send and receive payments. +{% include end-option.html %} + +{% include end-selector.html %} + +
diff --git a/docs/articles/new-expensify/expenses/Track-expenses.md b/docs/articles/new-expensify/expenses/Track-expenses.md new file mode 100644 index 000000000000..f4eeea09ecec --- /dev/null +++ b/docs/articles/new-expensify/expenses/Track-expenses.md @@ -0,0 +1,43 @@ +--- +title: Track Expenses +description: Create, store, or share non-reimbursable expenses +--- +
+ +Create, store, or share non-reimbursable expenses with the Track Expenses feature. + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the + icon in the bottom left menu and select **Track Expense**. +2. Create the expense manually, scan the receipt, or add a distance expense. + +{% include info.html %} +For an in-depth walkthrough on how to create an expense, check out the create an expense article. +{% include end-info.html %} + +3. Choose the next steps for the expense: + - **Submit it to someone**: Select this option to request payment from other members of your Expensify workspace. + - **Categorize it**: Select this option to choose a category and additional details to code the expense for a specific workspace. The expense will then be placed on a report and can be submitted to the workspace for approval. + - **Share it with my accountant**: Select this option to share the expense with your accountant. The expense will then be placed on a report under the workspace for your accountant to review. + - **Nothing for now**: Select this option to store the expense. Expensify will keep the expense until you are ready to take action on it—it won’t expire. When you’re ready, you can then select one of the above options for the expense at a later time. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap the + icon in the bottom menu and select **Track Expense**. +2. Create the expense manually, scan the receipt, or add a distance expense. + +{% include info.html %} +For an in-depth walkthrough on how to create an expense, check out the create an expense article. +{% include end-info.html %} + +3. Choose the next steps for the expense: + - **Submit it to someone**: Select this option to request payment from a contact in your phone’s contact list or from other members of your Expensify workspace. + - **Categorize it**: Select this option to choose a category and additional details to code the expense for a specific workspace. The expense will then be placed on a report and can be submitted to the workspace for approval. + - **Share it with my accountant**: Select this option to share the expense with your accountant. The expense will then be placed on a report under the workspace for your accountant to review. + - **Nothing for now**: Select this option to store the expense. Expensify will keep the expense until you are ready to take action on it—it won’t expire. When you’re ready, you can then select one of the above options for the expense at a later time. +{% include end-option.html %} + +{% include end-selector.html %} + +
diff --git a/docs/articles/new-expensify/expensify-card/Check-Expensify-Card-limit.md b/docs/articles/new-expensify/expensify-card/Check-Expensify-Card-limit.md new file mode 100644 index 000000000000..803211c873ec --- /dev/null +++ b/docs/articles/new-expensify/expensify-card/Check-Expensify-Card-limit.md @@ -0,0 +1,27 @@ +--- +title: Check Expensify Card limit +description: View the available limit for your Expensify Card +--- +
+ +Your Expensify Visa® Commercial Card has a Smart Limit that is automatically updated after you make a purchase with your physical or virtual card. Your base limit is assigned by your admin. + +To check your Smart Limit, + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Click **Wallet** in the left menu. +3. Click your Expensify Card to see the available Smart Limit. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon in the bottom menu. +2. Tap **Wallet**. +3. Tap your Expensify Card to see the available Smart Limit. +{% include end-option.html %} + +{% include end-selector.html %} + +
diff --git a/docs/articles/new-expensify/expensify-card/Coming-Soon.md b/docs/articles/new-expensify/expensify-card/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/expensify-card/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- diff --git a/docs/articles/new-expensify/expensify-card/Dispute-Expensify-Card-transaction.md b/docs/articles/new-expensify/expensify-card/Dispute-Expensify-Card-transaction.md new file mode 100644 index 000000000000..5bd23cd53730 --- /dev/null +++ b/docs/articles/new-expensify/expensify-card/Dispute-Expensify-Card-transaction.md @@ -0,0 +1,73 @@ +--- +title: Dispute Expensify Card transaction +description: Dispute an unrecognized, unauthorized, or fraudulent charge +--- +
+ +When using your Expensify Visa® Commercial Card, you may come across transactions that you want to dispute, including: + +- Unrecognized, unauthorized, or fraudulent charges + - Charges made with your card after it was lost or stolen + - Unauthorized charges while your card is still in your possession + - Continued charges for a canceled recurring subscription +- Service disputes + - Damaged or defective merchandise + - Charges for merchandise that was never received + - Duplicate charges for a single transaction + - Transactions of an incorrect amount + - Refund not received after a return + +# Dispute a transaction + +If you spot a transaction error on your Expensify Card, + +1. Contact the merchant. They can often address the issue promptly. +2. If you are unable to resolve the issue with the merchant, contact us immediately by opening your chat with Expensify Concierge in your Expensify Chat inbox, or by emailing concierge@expensify.com to start the dispute process. Provide the following information: + - Details about the disputed charge, including why you’re disputing it, what occurred, and any steps you’ve taken to address the issue + - Supporting documentation like receipts or cancellation confirmations +3. If you suspect fraud on your Expensify Card, immediately deactivate your card: + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +
    +
  1. Click your profile image or icon in the bottom left menu.
  2. +
  3. Click Wallet in the left menu.
  4. +
  5. Click your Expensify Card.
  6. +
  7. Click Report card fraud.
  8. +
  9. Follow the prompts to deactivate your card and request a new one.
  10. +
+{% include end-option.html %} + +{% include option.html value="mobile" %} +
    +
  1. Tap your profile image or icon in the bottom menu.
  2. +
  3. Tap Wallet.
  4. +
  5. Tap your Expensify Card.
  6. +
  7. Tap Report card fraud.
  8. +
  9. Follow the prompts to deactivate your card and request a new one.
  10. +
+{% include end-option.html %} + +{% include end-selector.html %} + +{:start="4"} +4. [Enable Two-Factor Authentication (2FA)](https://help.expensify.com/articles/new-expensify/settings/Enable-Two-Factor-Authentication) to add an additional layer of security to your account. + +{% include faq-begin.md %} + +**How am I protected from fraud using the Expensify Card?** + +Expensify leverages sophisticated algorithms to detect and/or block unusual card activity. You can also enable real-time notifications to receive alerts each time your card is charged. + +**How long does the dispute process take?** + +The dispute process can take up to 90 days. + +**Can I cancel a dispute?** + +You can cancel a filed dispute by using your Expensify Chat thread with Concierge or by emailing concierge@expensify.com. + +{% include faq-end.md %} + +
diff --git a/docs/articles/new-expensify/expensify-card/Update-your-Expensify-Card-mailing-address.md b/docs/articles/new-expensify/expensify-card/Update-your-Expensify-Card-mailing-address.md new file mode 100644 index 000000000000..6ce53b6a359a --- /dev/null +++ b/docs/articles/new-expensify/expensify-card/Update-your-Expensify-Card-mailing-address.md @@ -0,0 +1,29 @@ +--- +title: Update your Expensify Card mailing address +description: Change your mailing address for your Expensify Card +--- +
+ +1. Hover over Settings, then click **Account**. +2. Click the **Credit Card Import** tab. +3. Click **Request a New Card** on your physical card pending activation. +4. Select **I lost my card**. + +{% include info.html %} +If you’re updating your address to receive your new Expensify Visa® Commercial Card, you’ll still select **I lost my card** even though you have not lost a card. +{% include end-info.html %} + +{:start="5"} +5. Confirm your details and click **Continue**. +6. Update your address and click **Continue**. + +{% include info.html %} +If you’re updating your address to receive your new Expensify Visa® Commercial Card, you can click the X in the right corner to end the process here if the new card has not been shipped out to you yet. However, if the new card has already been shipped out to an incorrect address, proceed to the next step to resend the card to the newly updated address. +{% include end-info.html %} + +{:start="7"} +7. Proceed with the card replacement. + +Your new card will arrive in 2-3 business days. + +
diff --git a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md new file mode 100644 index 000000000000..56e456eb1256 --- /dev/null +++ b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md @@ -0,0 +1,54 @@ +--- +title: Upgrade to the new Expensify Card from Visa +description: Get the new Expensify Visa® Commercial Card +--- +
+ +If your company is already using Expensify Cards, you can upgrade your cards for free to the new Expensify Visa® Commercial Card to get even more tools to manage employee spending, including: +- Unlimited virtual cards +- Controlled spending amounts on virtual cards to manage subscriptions +- Tighter controls for managing spend across employees and merchants +- Fixed or monthly spend limits for each card +- Unique naming for each virtual card for simplified expense categorization + +# Upgrade your company’s Expensify Cards + +{% include info.html %} +This process must be completed by a Domain Admin. Although the process is available for all Domain Admins, only one admin needs to complete these steps. + +Before completing this process, you’ll want to: + +- Have your employees update their address if needed so that they receive their new Expensify Card in the mail before completing the steps below. +- Ensure that existing cardholders have a limit greater than $0 if you want them to receive a new Expensify Card. If their limit is $0, increase the limit. +{% include end-info.html %} + +1. On your Home page, click the task titled “Upgrade to the new and improved Expensify Card.” +2. Review and agree to the Terms of Service. +3. Click **Get the new card**. All existing cardholders with a limit greater than $0 will be automatically mailed a new physical card to the address they have on file. Virtual cards will be automatically issued and available for immediate use. +4. If you have Positive Pay enabled for your settlement account, contact your bank as soon as possible to whitelist the new ACH ID: 2270239450. +5. Remind your employees to update their payment information for recurring charges to their virtual card information. + +New cards will have the same limit as the existing cards. Each cardholder’s current physical and virtual cards will remain active until a Domain Admin or the cardholder deactivates it. + +{% include info.html %} +Cards won’t be issued to any employees who don’t currently have them. In this case, you’ll need to [issue a new card](https://help.expensify.com/articles/expensify-classic/expensify-card/Set-Up-the-Expensify-Visa%C2%AE-Commercial-Card-for-your-Company) +{% include end-info.html %} + +{% include faq-begin.md %} + +**Why don’t I see the task to agree to new terms on my Home page?** + +There are a few reasons why you might not see the task on your Home page: +- You may not be a Domain Admin +- Another domain admin has already accepted the terms +- The task may be hidden. To find hidden tasks, scroll to the bottom of the Home page and click **Show Hidden Tasks** to see all of your available tasks. + +**Will this affect the continuous reconciliation process?** + +No. During the transition period, you may have some employees with old cards and some with new cards, so you’ll have two different debits (settlements) made to your settlement account for each settlement period. Once all spending has transitioned to the new cards, you’ll only see one debit/settlement. + +**Do I have to upgrade to the new Expensify Visa® Commercial Card?** + +Yes. We’ll provide a deadline soon. But don’t worry—you’ll have plenty of time to upgrade. +{% include faq-end.md %} +
diff --git a/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md b/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md new file mode 100644 index 000000000000..6c7457641ce6 --- /dev/null +++ b/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md @@ -0,0 +1,56 @@ +--- +title: Use your Expensify Card +description: Use your physical or virtual Expensify Card +--- +
+ +As soon as you receive your physical Expensify Visa® Commercial Card, you can start using it right away by swiping it like you would with any other card, or you can link your card to your Apple or Google Pay mobile wallet to make in-person, contactless payments. You can also use your virtual Expensify Card for online and in-app purchases. + +A virtual card is a digital card that can be used for online transactions. Virtual cards have the same details as physical cards, but they offer several additional benefits: +- **Flexibility**: Virtual cards can be created or deleted instantly. You can use them for individual transactions with predetermined amounts or recurring payments and subscriptions. +- **Customizable limits**: You can set spending limits for each virtual card. +- **Security**: Admins have the option to issue virtual cards for a single-use (e.g. for one of expenses) or fixed-use (e.g. for recurring expenses). Since you have placed a limit on their usage, it makes them less susceptible to unauthorized transactions. +- **Insights**: You can easily track recurring spend for specific vendors when assigning a virtual card to a team, department, or vendor. + +# View your virtual card details + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Click **Wallet** in the left menu. +3. Click your Expensify Card. +4. Click **Reveal Details** to view your virtual Expensify Card number, expiration date, CVV, and address. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon in the bottom menu. +2. Tap **Wallet**. +3. Tap your Expensify Card. +4. Tap **Reveal Details** to view your virtual Expensify Card number, expiration date, CVV, and address. +{% include end-option.html %} + +{% include end-selector.html %} + +{% include faq-begin.md %} + +**Why did my transaction get declined?** + +Here are some reasons why an Expensify Card transaction might be declined: + +- **Insufficient card limit**: If a transaction exceeds your Expensify Card’s available limit, the transaction will be declined. Submitting expenses and getting them approved will free up your limit for more spending. +- **Inactive card**: Your card isn’t active yet or it was disabled by your Domain Admin +- **Incorrect card details**: Your card information was entered incorrectly with the merchant. Entering incorrect card information, such as the CVC, ZIP, or expiration date, will also lead to declines. There was suspicious activity +- **Fraudulent or risky activity**: If Expensify detects unusual or suspicious activity, we may block transactions as a security measure. This could happen due to irregular spending patterns, attempted purchases from risky vendors, or multiple rapid transactions. Check your Expensify Home page to approve unusual merchants and try again. If the spending looks suspicious, we may complete a manual due diligence check, and our team will do this as quickly as possible - your cards will all be locked while this happens. The merchant is located in a restricted country + +**How do I report my Expensify Card expenses?** + +You can report and submit Expensify Card expenses just like any other expenses, and you’ll want to submit them regularly to ensure you have a sufficient spending amount available on the card. As your expenses are approved, your Smart Limit updates accordingly. + +{% include info.html %} +SmartScanned receipts should automatically attach to the related Expensify Card expense. Expensify also automatically generates an IRS-compliant eReceipt for every transaction as long as the expense isn’t lodging-related. If your organization doesn’t require itemized receipts, you can rely on eReceipts instead. +{% include end-info.html %} + +{% include faq-end.md %} + +
diff --git a/docs/articles/new-expensify/settings/Add-profile-photo.md b/docs/articles/new-expensify/settings/Add-profile-photo.md new file mode 100644 index 000000000000..60e56deaafbc --- /dev/null +++ b/docs/articles/new-expensify/settings/Add-profile-photo.md @@ -0,0 +1,21 @@ +--- +title: Add profile photo +description: Add an image to your profile +--- +
+ +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Click the Edit pencil icon next to your profile image or icon and select **Upload Image** to choose a new image from your saved files. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon at the bottom of the screen. +2. Tap the Edit pencil icon next to your profile image or icon and select **Upload Image** to choose a new image from your saved files. +{% include end-option.html %} + +{% include end-selector.html %} + +
diff --git a/docs/articles/new-expensify/settings/Close-account.md b/docs/articles/new-expensify/settings/Close-account.md new file mode 100644 index 000000000000..e0d8fba2f452 --- /dev/null +++ b/docs/articles/new-expensify/settings/Close-account.md @@ -0,0 +1,44 @@ +--- +title: Close account +description: Close an Expensify account +--- +
+ +Closing your account will delete the data associated with the account. However, transactions shared with other accounts, including approved and reimbursed company expenses, will still be visible under those accounts. We may also be required to retain certain transaction records in compliance with laws in various jurisdictions. + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Click **Security** in the left menu. +3. Click **Close account**. +4. Provide answers to the question prompts, then click **Close Account**. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon in the bottom menu. +2. Tap **Security**. +3. Tap **Close account**. +4. Provide answers to the question prompts, then tap **Close Account**. +{% include end-option.html %} + +{% include end-selector.html %} + +{% include faq-begin.md %} + +**I’m unable to close my account.** + +If your account has an outstanding balance or if you have been assigned a role under a company’s Expensify workspace, you may encounter an error message during the account closure process, or the Close Account button may not be available. Here are the steps to follow for each scenario: + +- **Account Under a Validated Domain**: A Domain Admin must remove your account from the domain. Then you will be able to successfully close your account. +- **Sole Domain Admin**: If you are the only Domain Admin for a company’s domain, you must assign a new Domain Admin before you can close your account. +- **Workspace Billing Owner with an annual subscription**: You must downgrade from the annual subscription before closing the account. Alternatively, you can have another user take over billing for your workspaces. +- **Company Workspace Owner**: You must assign a new workspace owner before you can close your account. +- **Account has an outstanding balance**: You must make a payment to resolve the outstanding balance before you can close your account. +- **Preferred Exporter for a workspace integration**: You must assign a new Preferred Exporter before closing your account. +- **Verified Business Account that is locked**: You must unlock the account. +- **Verified Business Account that has an outstanding balance**: You must make a payment to settle any outstanding balances before the account can be closed. +- **Unverified account**: You must first verify your account before it can be closed. +{% include faq-end.md %} + +
diff --git a/docs/articles/new-expensify/settings/Security.md b/docs/articles/new-expensify/settings/Encryption-and-Data-Security.md similarity index 99% rename from docs/articles/new-expensify/settings/Security.md rename to docs/articles/new-expensify/settings/Encryption-and-Data-Security.md index 5c8eee7ae60e..fff3e6365ff9 100644 --- a/docs/articles/new-expensify/settings/Security.md +++ b/docs/articles/new-expensify/settings/Encryption-and-Data-Security.md @@ -1,5 +1,5 @@ --- -title: Security +title: Encryption and Data Security description: Expensify prioritizes data security and maintains strict compliance standards to safeguard users' sensitive information. --- diff --git a/docs/articles/new-expensify/settings/Profile.md b/docs/articles/new-expensify/settings/Profile.md deleted file mode 100644 index 908cf39c7ac6..000000000000 --- a/docs/articles/new-expensify/settings/Profile.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Profile -description: How to manage your Expensify Profile ---- -# Overview -Your Profile in Expensify allows you to: -- Set your public profile photo -- Set a display name -- Manage your contact methods -- Communicate your current status -- Set your pronouns -- Configure your timezone -- Store your personal details (for travel and payment purposes) - -# How to set your public profile photo - -To set or update your profile photo: -1. Go to **Settings > Profile** -2. Tap on the default or your existing profile photo, -3. You can either either upload (to set a new profile photo), remove or view your profile photo - -Your profile photo is visible to all Expensify users. - -# How to set a display name - -To set or update your display name: -1. Go to **Settings > Profile** -2. Tap on **Display name** -3. Set a first name and a last name, then **Save** - -Your display name is public to all Expensify users. - -# How to add or remove contact methods (email address and phone number) - -Your contact methods allow people to contact you (using your email address or phone number), and allow you to forward receipts to receipts@expensify.com from multiple email addresses. - -To manage your contact methods: -1. Go to **Settings > Profile** -2. Tap on **Contact method** -3. Tap **New contact method** to add a new email or phone number - -Your default contact method (email address or phone number) will be visible to "known" users, with whom you have interacted or are part of your team. - -To change the email address or phone number that's displayed on your Expensify account, add a new contact method, then tap on that email address and tap **Set as default**. - -# How to communicate your current status - -You can use your status emoji to communicate your mood, focus or current activity. You can optionally add a status message too! - -To set your status emoji and status message: -1. Go to **Settings > Profile** -2. Tap on **Status** then **Status** -3. Choose a status emoji, and optionally set a status message -4. Tap on **Save** - -Your status emoji will be visible next to your name in Expensify, and your status emoji and status message will appear in your profile (which is public to all Expensify users). On a computer, your status message will also be visible by hovering your mouse over your name. - -You can also remove your current status: -1. Go to **Settings > Profile** -2. Tap on **Status** -3. Tap on **Clear status** - -# How to set your pronouns - -To set your pronouns: -1. Go to **Settings > Profile** -2. Tap on **Pronouns** -3. Search for your preferred pronouns, then tap on your choice - -Your pronouns will be visible to "known" users, with whom you have interacted or are part of your team. - -# How to configure your timezone - -Your timezone is automatically set using an estimation based on your IP address. - -To set your timezone manually: -1. Go to **Settings > Profile** -2. Tap on **Timezone** -3. Disable **Automatically determine your location** -4. Tap on **Timezone** -5. Search for your preferred timezone, then tap on your choice - -Your timezone will be visible to "known" users, with whom you have interacted or are part of your team. - -# How to store your personal details (for travel and payment purposes) - -Your personal details can be used in Expensify for travel and payment purposes. These will not be shared with any other Expensify user. - -To set your timezone manually: -1. Go to **Settings > Profile** -2. Tap on **Personal details** -3. Tap on **Legal name**, **Date of birth**, and **Address** to set your personal details diff --git a/docs/articles/new-expensify/settings/Switch-to-light-or-dark-mode.md b/docs/articles/new-expensify/settings/Switch-to-light-or-dark-mode.md new file mode 100644 index 000000000000..34f96f9f5f7d --- /dev/null +++ b/docs/articles/new-expensify/settings/Switch-to-light-or-dark-mode.md @@ -0,0 +1,30 @@ +--- +title: Switch to light or dark mode +description: Change the appearance of Expensify +--- +
+ +Expensify has three theme options that determine how the app looks: +- **Dark mode**: The app appears with a dark background +- **Light mode**: The app appears with a light background +- **Use Device settings**: Expensify will automatically use your device’s default theme + +To change your Expensify theme, + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Click **Preferences** in the left menu. +3. Click the **Theme** option and select the desired theme. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon in the bottom menu. +2. Tap **Preferences**. +3. Tap the **Theme** option and select the desired theme. +{% include end-option.html %} + +{% include end-selector.html %} + +
diff --git a/docs/articles/new-expensify/travel/Approve-travel-expenses.md b/docs/articles/new-expensify/travel/Approve-travel-expenses.md new file mode 100644 index 000000000000..ae0feb4efaa6 --- /dev/null +++ b/docs/articles/new-expensify/travel/Approve-travel-expenses.md @@ -0,0 +1,37 @@ +--- +title: Approve travel expenses +description: Determine how travel expenses are approved +--- +
+ +Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel. + +- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider. +- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours. +- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken. + +# Set approval method + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under General, select approval methods for Flights, Hotels, Cars and Rail. + +
+ +
+ +Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel. + +- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider. +- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours. +- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken. + +# Set approval method + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under General, select approval methods for Flights, Hotels, Cars and Rail. + +
diff --git a/docs/articles/new-expensify/travel/Book-with-Expensify-Travel.md b/docs/articles/new-expensify/travel/Book-with-Expensify-Travel.md new file mode 100644 index 000000000000..5d25670ac5ab --- /dev/null +++ b/docs/articles/new-expensify/travel/Book-with-Expensify-Travel.md @@ -0,0 +1,89 @@ +--- +title: Book with Expensify Travel +description: Book flights, hotels, cars, trains, and more with Expensify Travel +--- +
+ +Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. + +With Expensify Travel, you can: +- Search and book travel arrangements all in one place +- Book travel for yourself or for someone else +- Get real-time support by chat or phone +- Manage all your T&E expenses in Expensify +- Create specific rules for booking travel +- Enable approvals for out-of-policy trips +- Book with any credit card on the market +- Book with the Expensify Card to get cash back and automatically reconcile transactions + +There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. + +# Book travel + +To book travel from the Expensify web app, + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +4. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +5. Select all the details for the arrangement you want to book. +6. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +{% include info.html %} +The travel itinerary is also emailed to the traveler’s [copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot), if applicable. +{% include end-info.html %} + +
+ +
+Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. + +With Expensify Travel, you can: +- Search and book travel arrangements all in one place +- Book travel for yourself or for someone else +- Get real-time support by chat or phone +- Manage all your T&E expenses in Expensify +- Create specific rules for booking travel +- Enable approvals for out-of-policy trips +- Book with any credit card on the market +- Book with the Expensify Card to get cash back and automatically reconcile transactions + +There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. + +# Book travel + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Agree to the terms and conditions and click **Continue**. +4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +6. Select all the details for the arrangement you want to book. +7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap the + icon in the bottom menu and select **Book travel**. +2. Tap **Book or manage travel**. +3. Agree to the terms and conditions and tap **Continue**. +4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +6. Select all the details for the arrangement you want to book. +7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. +{% include end-option.html %} + +{% include end-selector.html %} + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +
diff --git a/docs/articles/new-expensify/travel/Coming-Soon.md b/docs/articles/new-expensify/travel/Coming-Soon.md deleted file mode 100644 index 4d32487a14b5..000000000000 --- a/docs/articles/new-expensify/travel/Coming-Soon.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Coming soon -description: Coming soon ---- - -# Coming soon \ No newline at end of file diff --git a/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md b/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md new file mode 100644 index 000000000000..2e17af06773c --- /dev/null +++ b/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md @@ -0,0 +1,65 @@ +--- +title: Configure travel policy and preferences +description: Set and update travel policies and preferences for your Expensify Workspace +--- +
+ +As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. + +# Create a travel policy + +When using Expensify Travel for the first time, you will need to create a new Travel Policy. + +To create a travel policy from the Expensify web app, + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. +5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +7. Click the paperclip icon next to each setting to de-couple it from your default policy. +8. Update the desired settings. + +# Update travel policy preferences + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Select the appropriate policy in the left menu. +5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +6. Click the paperclip icon next to each setting to de-couple it from your default policy. +7. Update the desired policies. + +
+ +
+ +As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. + +# Create a travel policy + +When using Expensify Travel for the first time, you will need to create a new Travel Policy. + +To create a travel policy from the Expensify web app, + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. +5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +7. Click the paperclip icon next to each setting to de-couple it from your default policy. +8. Update the desired settings. + +# Update travel policy preferences + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Select the appropriate policy in the left menu. +5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +6. Click the paperclip icon next to each setting to de-couple it from your default policy. +7. Update the desired policies. + +
diff --git a/docs/articles/new-expensify/travel/Edit-or-cancel-travel-arrangements.md b/docs/articles/new-expensify/travel/Edit-or-cancel-travel-arrangements.md new file mode 100644 index 000000000000..7dc71c3220ca --- /dev/null +++ b/docs/articles/new-expensify/travel/Edit-or-cancel-travel-arrangements.md @@ -0,0 +1,28 @@ +--- +title: Edit or cancel travel arrangements +description: Modify travel arrangements booked with Expensify Travel +--- +
+ +Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee. + +
+ +
+ +You can review your travel arrangements any time by opening the Trip chat in your inbox. For example, if you booked a flight to San Francisco, a “Trip to San Francisco” chat will be automatically added to your chat inbox. + +To edit or cancel a travel arrangement, +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select the workspace the travel is booked under. +4. Tap into the booking to see more details. +5. Click **Trip Support**. + +If there is an unexpected change to the itinerary (for example, a flight cancellation), Expensify’s travel partner **Spotnana** will reach out to the traveler to provide updates on those changes. + +{% include info.html %} +You can click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee. +{% include end-info.html %} + +
diff --git a/docs/articles/new-expensify/workspaces/Create-expense-categories.md b/docs/articles/new-expensify/workspaces/Create-expense-categories.md new file mode 100644 index 000000000000..7b8d29d09d1c --- /dev/null +++ b/docs/articles/new-expensify/workspaces/Create-expense-categories.md @@ -0,0 +1,114 @@ +--- +title: Create expense categories +description: Add categories to use for coding expenses +--- +
+ +In Expensify, categories refer to the **chart of accounts, GL accounts, expense accounts**, and other line-item details that help code expenses for accounting and financial reporting. + +An admin can manually create categories for a workspace, or they will be automatically imported if your workspace is connected to another platform such as QuickBooks Online, QuickBooks Desktop, Intacct, Xero, or NetSuite. These imported categories can be enabled or disabled to use as categories for expenses added to Expensify. Additionally, Expensify will learn how you apply categories to specific merchants over time and apply them automatically. + +# Manually add or delete categories + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +To manually add a category, + +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select the workspace you want to add categories to. +4. Click **Categories** in the left menu. +5. Click **Add Category** in the top right. +6. Enter a name for the category and click **Save**. + +To delete a category, + +1. Click the category on the Categories page. +2. Click the 3-dot menu in the top right. +3. Click **Delete category** to permanently delete the category. +{% include end-option.html %} + +{% include option.html value="mobile" %} +To manually add a category, + +1. Tap your profile image or icon in the bottom menu. +2. Tap **Workspaces**. +3. Select the workspace you want to add categories to. +4. Tap **Categories**. +5. Tap **Add Category**. +6. Enter a name for the category and tap **Save**. + +To delete a category, +1. Tap the category on the Categories page. +2. Tap the 3-dot menu in the top right. +3. Tap **Delete category** to permanently delete the category. +{% include end-option.html %} + +{% include end-selector.html %} + +# Enable or disable categories + +Once you have manually added your categories or automatically imported them from a connected accounting system, you can enable or disable the categories to determine whether they can be added to expenses. + +{% include info.html %} +After connecting an accounting system, Expensify automatically imports charts of accounts, GL accounts, expense accounts, and additional details into your workspace as **disabled** categories. Workspace admins can enable these categories to make them available for workspace members to add to their expenses. +{% include end-info.html %} + +To enable or disable a category, + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select a workspace. +4. Click **Categories** in the left menu. +5. Click a category and use the toggle to enable or disable it. + +{% include info.html %} +You can enable, disable, or delete categories in bulk by selecting the checkbox to the left of the categories. Then click the “selected” dropdown menu at the top right of the page and select the desired option. +{% include end-info.html %} + +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon in the bottom menu. +2. Tap **Workspaces**. +3. Select a workspace. +4. Tap **Categories**. +5. Tap a category and use the toggle to enable or disable it. + +{% include info.html %} +You can enable, disable, or delete categories in bulk by selecting the checkbox to the left of the categories. Then tap the “selected” dropdown menu at the top of the page and select the desired option. +{% include end-info.html %} + +{% include end-option.html %} + +{% include end-selector.html %} + +# Automatic Expensify categories + +Over time, Expensify learns how you categorize specific merchants and automatically applies that category to the merchant in the future. +- If you change a category, Expensify learns that correction over time as well. However, changing a category on one expense does not change it for other expenses that have already been assigned the category. +- Any expense rules for your workspace take priority over Expensify’s automatic categories. +- Expensify won’t automatically add a category to an expense if it is already manually assigned a category. + +{% include faq-begin.md %} +**Can I edit my categories on a submitted expense report?** + +Yes, you can edit categories on an expense you have submitted until the expense is approved or reimbursed. + +Approvers can also edit categories on the submitter’s behalf, even after approval. If you are an approver reviewing a report that wasn’t submitted to you, you’ll see the option to take control of the report and then you can change the category. + +**Can I see an audit trail of category changes on an expense?** + +Yes. When a category is manually edited, Expensify will log the change in the related workspace chat. + +**If I change categories in my accounting system, what happens to categories in the workspace?** + +If a category is disabled in the accounting system, it will be removed from the workspace’s categories list in the workspace. However, the disabled category will remain on approved and drafted expense reports that it has been previously added to. An admin can change the category on an approved or reimbursed expense, and anyone can change the category on an unapproved expense. + +{% include faq-end.md %} + +
diff --git a/docs/articles/new-expensify/workspaces/Create-expense-tags.md b/docs/articles/new-expensify/workspaces/Create-expense-tags.md new file mode 100644 index 000000000000..cf7bdd6fc6a7 --- /dev/null +++ b/docs/articles/new-expensify/workspaces/Create-expense-tags.md @@ -0,0 +1,121 @@ +--- +title: Create expense tags +description: Add tags to use for coding expenses +--- +
+ +In Expensify, tags refer to **classes, projects, cost centers, locations, customers, jobs**, and other line-item details that help code expenses for accounting and financial reporting. + +An admin can manually create tags for a workspace, or they will be automatically imported if your workspace is connected to an accounting system, like QuickBooks Online or Xero. These imported tags can be enabled or disabled to use as tags for expenses added to Expensify. Additionally, Expensify will learn how you apply tags to specific merchants over time and apply them automatically. + +# Manually add or delete tags + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +To manually add a tag, + +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select the workspace you want to add tags to. +4. Click **More features** in the left menu. +5. Scroll down to the Organize section and enable the Tags toggle. +6. Click **Tags** in the left menu. +7. Click **Add Tag** in the top right. +8. Enter a name for the tag and click **Save**. + +To delete a tag, + +1. Click the tag on the Tags page. +2. Click the 3-dot menu in the top right. +3. Click **Delete tag** to permanently delete the tag. +{% include end-option.html %} + +{% include option.html value="mobile" %} +To manually add a tag, + +1. Tap your profile image or icon in the bottom menu. +2. Tap **Workspaces**. +3. Select the workspace you want to add tags to. +4. Tap **More features**. +5. In the Organize section, enable the Tags toggle. +6. Tap **Tags**. +7. Tap **Add Tag**. +8. Enter a name for the tag and tap **Save**. + +To delete a tag, +1. Tap the tag on the Tags page. +2. Tap the 3-dot menu in the top right. +3. Tap **Delete tag** to permanently delete the tag. +{% include end-option.html %} + +{% include end-selector.html %} + +# Enable or disable tags + +Once you have manually added your tags or automatically imported them from a connected accounting system, you can enable or disable the tags to determine whether they can be added to expenses. + +{% include info.html %} +After connecting an accounting system, Expensify automatically imports classes, projects, customers, and additional details into your workspace as disabled tags. Workspace admins can enable these tags to make them available for workspace members to add to their expenses. +{% include end-info.html %} + +To enable or disable a tag, + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select a workspace. +4. Click **Tags** in the left menu. +5. Click a tag and use the toggle to enable or disable it. + +{% include info.html %} +You can enable, disable, or delete tags in bulk by selecting the checkbox to the left of the tags. Then click the “selected” dropdown menu at the top right of the page and select the desired option. +{% include end-info.html %} + +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon in the bottom menu. +2. Tap **Workspaces**. +3. Select a workspace. +4. Tap **Tags**. +5. Tap a tag and use the toggle to enable or disable it. + +{% include info.html %} +You can enable, disable, or delete tags in bulk by selecting the checkbox to the left of the tag. Then tap the “selected” dropdown menu at the top of the page and select the desired option. +{% include end-info.html %} + +{% include end-option.html %} + +{% include end-selector.html %} + +# Automatic Expensify tags + +Over time, Expensify learns how you tag specific merchants and automatically applies that tag to the merchant in the future. +- If you change a tag, Expensify learns that correction over time as well. However, changing a tag on one expense does not change it for other expenses that have already been assigned the tag. +- Any expense rules for your workspace take priority over Expensify’s automatic tags. +- Expensify won’t automatically tag an expense if it is already manually assigned a tag. + +{% include faq-begin.md %} +**Can I edit my tags on a submitted expense report?** + +Yes, you can edit tags on an expense you have submitted until the expense is approved or reimbursed. + +Approvers can also edit tags on the submitter’s behalf, even after approval. If you are an approver reviewing a report that wasn’t submitted to you, you’ll see the option to take control of the report and then you can change the tag. + +**Can I see an audit trail of tag changes on an expense?** + +Yes. When a tag is manually edited, Expensify will log the change in the related workspace chat. + +**If I change tags in my accounting system, what happens to tags in the workspace?** + +If a tag is disabled in the accounting system, it will be removed from the workspace’s tags list in the workspace. However, the disabled tag will remain on approved and drafted expense reports that it has been previously added to. An admin can change the tag on an approved or reimbursed expense, and anyone can change the tag on an unapproved expense. + +**Can I set up multi-level tags in New Expensify?** + +At this time, only single-level tags are available in New Expensify. If you’ve used multi-level tags in Expensify Classic, you will see the first-level tag in New Expensify. Multi-level tags are under development. +{% include faq-end.md %} + +
diff --git a/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md b/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md new file mode 100644 index 000000000000..8f2cf0897ad0 --- /dev/null +++ b/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md @@ -0,0 +1,39 @@ +--- +title: Require tags and categories for expenses +description: Make tags and/or categories required for all expenses +--- +
+ +To require workspace members to add tags and/or categories to their expenses, + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select a workspace. +4. Click **Tags** or **Categories** in the left menu. +5. Click **Settings** at the top right of the page. +6. Enable the “Members must tag/categorize all expenses" toggle. +7. If desired, repeat steps 4-6 for tags or categories (whichever you haven’t done yet). +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon in the bottom menu. +2. Tap **Workspaces**. +3. Select a workspace. +4. Tap **Tags** or **Categories**. +5. Tap **Settings** at the top right of the page. +6. Enable the “Members must tag/categorize all expenses" toggle. +7. If desired, repeat steps 4-6 for tags or categories (whichever you haven’t done yet). +{% include end-option.html %} + +{% include end-selector.html %} + +This will highlight the tag and/or category field as required on all expenses. + +{% include info.html %} +Expenses will still be able to be submitted without a tag and/or category even if they are set as required. The submitter and approver will see an orange dot on the expense details alerting them that the tag/category is missing. +{% include end-info.html %} + +
diff --git a/docs/articles/new-expensify/workspaces/The-Free-Plan.md b/docs/articles/new-expensify/workspaces/The-Free-Plan.md deleted file mode 100644 index b036c5b087d2..000000000000 --- a/docs/articles/new-expensify/workspaces/The-Free-Plan.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -title: The Free Plan -description: Everything you need to know about Expensify's Free Plan! -redirect_from: articles/split-bills/workspaces/The-Free-Plan/ ---- - - - -# What is the Free Plan? -The free plan is ideal for start-ups and small businesses to manage expenses. With the Free Plan, a workspace admin can set their team up with Expensify Cards, reimburse cash expenses, send invoices, and manage bills, all for free! You will have total visibility and control over all spending associated with your workspace in real time. - -# Features Included with the Free Plan -- Expensify Cards for all employees -- Invoicing -- Bill Pay -- Unlimited receipt scanning for everyone in the company -- Free next-day ACH reimbursements for cash expenses -- Up to 4% [cash back](https://community.expensify.com/discussion/8454/4-cash-back-on-the-expensify-card-is-here-to-stay) -- Free corporate travel booking - -# Setting Up the Free Plan -- Navigate to new.expensify.com, enter your company email address, and set a password -- Click the **green “+”** button and select **_New workspace_** - -Once you’ve created your Workspace, you will receive a message from Concierge encouraging you to chat with your Setup Specialist. Click the link in the message, and your designated Setup Specialist will guide you through how to configure your company setup. - -Once you’ve completed your company setup, you should have completed the following tasks: - -- Connected a business bank account (Settings menu > Click **_Bank account_** and follow the prompts). -- Invited members to the workspace -- Assigned Expensify Cards - -# Inviting Members to the Free Plan -- Navigate to the Settings Menu and click **_Members_** to invite your team. You can invite employees one at a time, or you can invite multiple users by listing out their email addresses separated by a comma -- To use the Expensify Card, you must invite them to your workspace via your company email address (i.e., admin@companyemail.com and NOT admin@gmail.com). - -# Managing the Free Plan -## Workspace Settings - -To access your workspace settings, click your profile icon and then on your workspace name. This settings menu allows you to manage your workspace members, issue additional Expensify Cards, and utilize this plan’s various bill pay and payment options. - -## Paying an Expense Report -- Once a user creates an expense it will automatically be shared with you in a Processing report. -- Pay expenses directly through Expensify by choosing ‘Reimburse > via Direct Deposit (ACH)` in a report on www.expensify.com or by choosing ‘Pay with Expensify’ in a payment request on new.expensify.com. -- Notify your user that you’ll pay them manually outside of Expensify by choosing ‘Reimburse > I’ll do it manually’ in a report on www.expensify.com or choosing ‘Pay Elsewhere’ in a payment request on new.expensify.com. -- Reports with only non-reimbursable expenses on them have the option to ‘Mark as Closed’ in the report on www.expensify.com or ‘Mark as Done’ in the payment request on new.expensify.com. - -## Changing Submitted Expenses - -Request an edit an expense or remove an expense before you pay, you can let your user know by making a comment in the Report History section of their Processing report or chatting with them on new.expensify.com. - -# Managing Expenses - -## Creating an Expense -- You can create an expense either by swiping the Expensify card or just smartscan a receipt! -- Once you create an expense it will be automatically added to a report and shared with your admin. -- You can edit or delete any expense that hasn’t been paid or closed by your admin. - -## Getting paid for Expenses -- Automatic submission is already set up, so your admin can pay you back immediately once you create an expense. -- Your admin will get a notification when you send them a new expense, but you can also remind them to pay you by making a comment in the Report History section of your Processing report or chatting with them on new.expensify.com. - -{% include faq-begin.md %} - -## Do I need a business bank account to use the Free Plan? - -You will need a US business checking account if you want to enable the Expensify Card and set up direct ACH reimbursement. -You will need to take a few steps to verify your business bank account and connect it to Expensify. You will also need to set aside some time and have your ID ready. -If you're not in the US, you can still use the Free Plan, but the Expensify Card and direct reimbursement will not be available. - -## Can my workspace have more than one Admin? - -The Expensify Workplace only allows for one admin (the workspace creator). - -## I am on a paid plan, can I switch to a Free Plan? - -You can set up a Free Plan, but you must honor any active subscription you have also. If you're on a paid plan, it is likely you want more functionality than what the Free Plan offers such as a direct connection with an accounting integration and approval workflows. - -## Can I get cashback on Expensify Card purchases if I have a free plan? - -You can get 1% credited back to your settlement account once you spend over $25,000 per month across all cards and 2% when you spend over $250,000! - -## Can I switch the workspace currency on the Free Plan? It looks set to USD - -Yes, you can change the currency of the Free Plan by going to Avatar > [Workspace Name] > General settings > Default currency. - -We do require a US business bank account for reimbursements and Expensify Card settlements though, so if you have a business bank account linked to your account, then the currency of the Free Plan will be USD. - -## Is a free plan available to people outside the USA? - -Yes! You can use the free plan anywhere in the world to track expenses, send invoices etc. Just remember, anything that requires a verified business bank account (such as ACH reimbursement and the Expensify Card) is only available to those with a US checking account! - -## Can I integrate the free plan with my accounting package? - -No. In order to use our accounting sync, you will need to update to a paid plan - -## With the Free Plan, can I add my own categories? - -Categories are standardized on the Free Plan and can’t be edited. Custom categories and tags or accounting system integration are available on a paid plan. - -## With the Free Plan, can I export reports using a custom format? - -The Free Plan offers standard report export formats. You'll need to upgrade to a paid plan to create a custom export format. - -## Can I change the mileage rate or unit used for distance tracking? - -Yes. The workspace admin can access the rate and unit settings by going to Avatar > [Workspace name] > Reimburse expenses > Track distance. - -## Can I add non-Expensify cardholders to my Workspace? - -Yes. You can invite any user to your Workspace. Just click invite and enter their email and they will be invited to download the app and join! - -## Expenses are automatically shared with the admin on a Processing report on the Free Plan, can I change this? - -No. Expense reports submitted on the Free Plan are set to submit automatically and do not allow for approvals. Different Scheduled Submit settings are available on our paid plans. - -## How do I give a user I invited to my Workspace an Expensify Card? - -You can give a user an Expensify Card by going to the Company Cards Page on expensify.com and setting a card limit >$0. Please note that in order to give a user a card, they must be using a private company email address (i.e. name@companyname.com NOT name@gmail.com) - -## Can I use a different bank account to the one I have added for some of the features in the workspace? - -No. The bank account you have added will be used to issue corporate cards, reimburse expenses, collect invoices, and pay bills! - -## Can I upgrade my Free Workspace to a Paid Workspace or do I need to create a new one? - -There is no way to upgrade a workspace, so you would need to create a new Workspace. Paid workspaces have more functionalities than what the Free Plan offers, such as a direct connection with an accounting integration and approval workflows. - -## Can I create more than one Processing report at a time? - -No. To keep things simple, we only allow one Processing report per user at a time. If you need to have more than one report at a time, our paid plans support unlimited reports. - -## A user has added an expense I need to change before I pay it, how do I let them know? - -Users can edit and delete expenses on Processing reports. If you need something changed, let them know by commenting in the Report History section of the report on expensify.com or by chatting with them in new.expensify.com. - -## Can I ‘Reopen’ a report once it’s Reimbursed or Closed? - -No. Once an admin reimburses or closes a report, it cannot be ‘reopened’, but you can always comment on a report to add context. - -## I accidentally reimbursed a report too soon. Can I cancel the reimbursement? - -Depending on how quickly you report it to us, we may be able to help cancel a reimbursement. Chat Concierge to see if we can help cancel a reimbursement. - -## As an admin, can I edit users’ expenses and delete them from reports? - -No. Only users can edit and delete expenses on the Free plan. Admin control of submitted expenses on a workspace is a feature of our paid plans. If you need something changed, let the user know by commenting in the Report History section of the report on www.expensify.com or by chatting with them in new.expensify.com. - -{% include faq-end.md %} diff --git a/docs/assets/images/AdminissuedVirtualCards.png b/docs/assets/images/AdminissuedVirtualCards.png index 88df9b2f3fec..9c44763f5840 100644 Binary files a/docs/assets/images/AdminissuedVirtualCards.png and b/docs/assets/images/AdminissuedVirtualCards.png differ diff --git a/docs/assets/images/ExpensifyHelp_ConnectBankAccount_1_Light.png b/docs/assets/images/ExpensifyHelp_ConnectBankAccount_1_Light.png new file mode 100644 index 000000000000..3ff21c1f34cb Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ConnectBankAccount_1_Light.png differ diff --git a/docs/assets/images/ExpensifyHelp_ConnectBankAccount_2_Light.png b/docs/assets/images/ExpensifyHelp_ConnectBankAccount_2_Light.png new file mode 100644 index 000000000000..dea262434e59 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ConnectBankAccount_2_Light.png differ diff --git a/docs/assets/images/domains.svg b/docs/assets/images/domains.svg index 3a3c95604b79..12c0a0a0792b 100644 --- a/docs/assets/images/domains.svg +++ b/docs/assets/images/domains.svg @@ -1,44 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/docs/assets/images/plane.svg b/docs/assets/images/plane.svg index 0295aa3c66c0..bd7fceba3607 100644 --- a/docs/assets/images/plane.svg +++ b/docs/assets/images/plane.svg @@ -1,34 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/docs/assets/images/simple-illustration__monitor-remotesync.svg b/docs/assets/images/simple-illustration__monitor-remotesync.svg new file mode 100644 index 000000000000..f0f6f363036e --- /dev/null +++ b/docs/assets/images/simple-illustration__monitor-remotesync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 6b3390148ff0..9e4880780e91 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -196,6 +196,35 @@ const tocbotOptions = { scrollContainer: 'content-area', }; +function selectNewExpensify(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent) { + newExpensifyTab.classList.add('active'); + newExpensifyContent.classList.remove('hidden'); + + if (expensifyClassicTab && expensifyClassicContent) { + expensifyClassicTab.classList.remove('active'); + expensifyClassicContent.classList.add('hidden'); + } + window.tocbot.refresh({ + ...tocbotOptions, + contentSelector: '#new-expensify', + }); +} + +function selectExpensifyClassic(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent) { + expensifyClassicTab.classList.add('active'); + expensifyClassicContent.classList.remove('hidden'); + + if (newExpensifyTab && newExpensifyContent) { + newExpensifyTab.classList.remove('active'); + newExpensifyContent.classList.add('hidden'); + } + + window.tocbot.refresh({ + ...tocbotOptions, + contentSelector: '#expensify-classic', + }); +} + window.addEventListener('DOMContentLoaded', () => { injectFooterCopywrite(); @@ -219,8 +248,10 @@ window.addEventListener('DOMContentLoaded', () => { let contentSelector = '.article-toc-content'; if (expensifyClassicContent) { contentSelector = '#expensify-classic'; + selectExpensifyClassic(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent); } else if (newExpensifyContent) { contentSelector = '#new-expensify'; + selectNewExpensify(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent); } if (window.tocbot) { @@ -232,28 +263,12 @@ window.addEventListener('DOMContentLoaded', () => { // eslint-disable-next-line es/no-optional-chaining expensifyClassicTab?.addEventListener('click', () => { - expensifyClassicTab.classList.add('active'); - expensifyClassicContent.classList.remove('hidden'); - - newExpensifyTab.classList.remove('active'); - newExpensifyContent.classList.add('hidden'); - window.tocbot.refresh({ - ...tocbotOptions, - contentSelector: '#expensify-classic', - }); + selectExpensifyClassic(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent); }); // eslint-disable-next-line es/no-optional-chaining newExpensifyTab?.addEventListener('click', () => { - newExpensifyTab.classList.add('active'); - newExpensifyContent.classList.remove('hidden'); - - expensifyClassicTab.classList.remove('active'); - expensifyClassicContent.classList.add('hidden'); - window.tocbot.refresh({ - ...tocbotOptions, - contentSelector: '#new-expensify', - }); + selectNewExpensify(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent); }); document.getElementById('header-button').addEventListener('click', toggleHeaderMenu); diff --git a/docs/redirects.csv b/docs/redirects.csv index f775d2f97094..13463327d06d 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -26,10 +26,31 @@ https://community.expensify.com/discussion/5190/how-to-individually-assign-a-vac https://community.expensify.com/discussion/5274/how-to-set-up-an-adp-indirect-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/integrations/HR-integrations/ADP https://community.expensify.com/discussion/5776/how-to-create-mileage-expenses-in-expensify,https://help.expensify.com/articles/expensify-classic/expenses/Distance-Tracking https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/expensify-classic/hubs/settings/account-settings -https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/expensify-classic/hubs/settings/account-settings https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/expensify-classic/hubs/settings/account-settings https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/expensify-classic/hubs/settings/account-settings https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://community.expensify.com/discussion/3498/how-do-i-invite-users-in-my-company,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/6015/tutorial,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/2596/setting-up-accounts,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/7665/how-do-i-add-another-person-to-my-account-to-keep-track-of-there-expenses,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/7456/how-do-i-submit-an-expense-for-reimbursement,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/1460/schedule-a-demo,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/835/what-is-the-difference-between-a-category-and-a-tag,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/7703/getting-started-video,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/1845/how-to-set-up-account-and-add-users,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/8629/employee-training-e-learning-program,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/1607/on-demand-webinars,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/5444/admin-onboarding-webinar-faqs,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/5417/employee-training-webinar-faqs,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/5885/overview-the-employee-training-webinar,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/5854/overview-the-expensify-admin-onboarding-webinar,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/4699/how-to-download-the-mobile-app,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/4524/how-to-set-up-the-uber-integration,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/5212/how-to-connect-your-policy-to-netsuite-token-based-authentication,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Add-profile-photo +https://community.expensify.com/discussion/5922/deep-dive-day-1-with-expensify-for-submitters,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/5934/day-1-with-expensify-admins-and-accountants,https://help.expensify.com/expensify-classic/hubs/getting-started +https://community.expensify.com/discussion/5694/deep-dive-admin-training-and-setup-resources,https://help.expensify.com/expensify-classic/hubs/getting-started https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks.html,https://use.expensify.com/company-credit-card https://help.expensify.com/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.html,https://use.expensify.com/accountants-program https://help.expensify.com/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners, https://use.expensify.com/blog/maximizing-rewards-expensifyapproved-accounting-partners-now-earn-0-5-revenue-share @@ -170,3 +191,13 @@ https://help.expensify.com/articles/expensify-classic/reports/Report-Audit-Log-a https://help.expensify.com/articles/expensify-classic/reports/The-Reports-Page,https://help.expensify.com/articles/expensify-classic/reports/Report-statuses https://help.expensify.com/articles/new-expensify/getting-started/Free-plan-upgrade-to-collect-plan,https://help.expensify.com/articles/new-expensify/getting-started/Upgrade-to-a-Collect-Plan https://help.expensify.com/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account,https://help.expensify.com/new-expensify/hubs/expenses/Connect-a-Bank-Account +https://help.expensify.com/articles/new-expensify/settings/Profile,https://help.expensify.com/new-expensify/hubs/settings/ +https://help.expensify.com/articles/new-expensify/expenses/Referral-Program.html,https://help.expensify.com/articles/expensify-classic/expensify-partner-program/Referral-Program +https://help.expensify.com/articles/new-expensify/workspaces/The-Free-Plan,https://help.expensify.com/new-expensify/hubs/workspaces/ +https://help.expensify.com/new-expensify/hubs/expenses/Connect-a-Bank-Account,https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account +https://help.expensify.com/articles/new-expensify/settings/Security,https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security +https://help.expensify.com/articles/expensify-classic/workspaces/reports/Currency,https://help.expensify.com/articles/expensify-classic/workspaces/Currency +https://help.expensify.com/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles,https://help.expensify.com/expensify-classic/hubs/workspaces/ +https://help.expensify.com/articles/expensify-classic/workspaces/reports/Scheduled-Submit,https://help.expensify.com/articles/expensify-classic/reports/Automatically-submit-employee-reports +https://help.expensify.com/articles/new-expensify/chat/Expensify-Chat-For-Admins,https://help.expensify.com/new-expensify/hubs/chat/ +https://help.expensify.com/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.html,https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index 440309f63c6e..29d379151525 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg index 2de81ee85018..cf14d27d7d87 100644 Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 94118eb3bfa4..f86952ca7aca 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -648,6 +648,7 @@ "${PODS_ROOT}/Target Support Files/Pods-NewExpensify/Pods-NewExpensify-frameworks.sh", "${BUILT_PRODUCTS_DIR}/MapboxMaps/MapboxMaps.framework", "${BUILT_PRODUCTS_DIR}/Turf/Turf.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FullStory/FullStory.framework/FullStory", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCommon/MapboxCommon.framework/MapboxCommon", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCoreMaps/MapboxCoreMaps.framework/MapboxCoreMaps", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxMobileEvents/MapboxMobileEvents.framework/MapboxMobileEvents", @@ -659,6 +660,7 @@ outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Turf.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FullStory.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCommon.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCoreMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMobileEvents.framework", @@ -680,6 +682,7 @@ "${PODS_ROOT}/Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests-frameworks.sh", "${BUILT_PRODUCTS_DIR}/MapboxMaps/MapboxMaps.framework", "${BUILT_PRODUCTS_DIR}/Turf/Turf.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FullStory/FullStory.framework/FullStory", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCommon/MapboxCommon.framework/MapboxCommon", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCoreMaps/MapboxCoreMaps.framework/MapboxCoreMaps", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxMobileEvents/MapboxMobileEvents.framework/MapboxMobileEvents", @@ -691,6 +694,7 @@ outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Turf.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FullStory.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCommon.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCoreMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMobileEvents.framework", @@ -935,7 +939,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; ENABLE_BITCODE = NO; INFOPLIST_FILE = "$(SRCROOT)/NewExpensify/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -974,7 +978,7 @@ DEVELOPMENT_TEAM = 368M544MTT; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; INFOPLIST_FILE = NewExpensify/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1057,7 +1061,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1142,7 +1146,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1228,7 +1232,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1308,7 +1312,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1386,7 +1390,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1465,7 +1469,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1727,7 +1731,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; ENABLE_BITCODE = NO; INFOPLIST_FILE = "$(SRCROOT)/NewExpensify/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1872,7 +1876,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; ENABLE_BITCODE = NO; INFOPLIST_FILE = "$(SRCROOT)/NewExpensify/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2010,7 +2014,7 @@ DEVELOPMENT_TEAM = 368M544MTT; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; INFOPLIST_FILE = NewExpensify/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2145,7 +2149,7 @@ DEVELOPMENT_TEAM = 368M544MTT; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; INFOPLIST_FILE = NewExpensify/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 0f84b24d783b..70d3fe63ba09 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.71 + 1.4.85 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.71.2 + 1.4.85.1 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index e83366f32bad..887c2b2f10d3 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.71 + 1.4.85 CFBundleSignature ???? CFBundleVersion - 1.4.71.2 + 1.4.85.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 8fb4353bdfe2..272f7bca065f 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.71 + 1.4.85 CFBundleVersion - 1.4.71.2 + 1.4.85.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile b/ios/Podfile index 4f00eb2adfdd..6330bb3d8d52 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -117,3 +117,5 @@ end target 'NotificationServiceExtension' do pod 'AirshipServiceExtension' end + +pod 'FullStory', :http => 'https://ios-releases.fullstory.com/fullstory-1.49.0-xcframework.tar.gz' \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d0155051fc3b..077003ed5285 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -138,6 +138,27 @@ PODS: - GoogleUtilities/Environment (~> 7.7) - "GoogleUtilities/NSData+zlib (~> 7.7)" - fmt (6.2.1) + - FullStory (1.49.0) + - fullstory_react-native (1.4.2): + - FullStory (~> 1.14) + - glog + - hermes-engine + - RCT-Folly (= 2022.05.16.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - glog (0.3.5) - GoogleAppMeasurement (8.8.0): - GoogleAppMeasurement/AdIdSupport (= 8.8.0) @@ -1282,6 +1303,25 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-keyboard-controller (1.12.2): + - glog + - hermes-engine + - RCT-Folly (= 2022.05.16.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-launch-arguments (4.0.2): - React - react-native-netinfo (11.2.1): @@ -1731,7 +1771,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNCPicker (2.6.1): + - RNCPicker (2.7.6): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1831,7 +1871,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.69): + - RNLiveMarkdown (0.1.85): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1849,9 +1889,9 @@ PODS: - React-utils - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/common (= 0.1.69) + - RNLiveMarkdown/common (= 0.1.85) - Yoga - - RNLiveMarkdown/common (0.1.69): + - RNLiveMarkdown/common (0.1.85): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1921,7 +1961,7 @@ PODS: - React-Codegen - React-Core - ReactCommon/turbomodule/core - - RNReanimated (3.7.2): + - RNReanimated (3.8.1): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -2077,6 +2117,8 @@ DEPENDENCIES: - ExpoImageManipulator (from `../node_modules/expo-image-manipulator/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) + - "FullStory (from `{:http=>\"https://ios-releases.fullstory.com/fullstory-1.49.0-xcframework.tar.gz\"}`)" + - "fullstory_react-native (from `../node_modules/@fullstory/react-native`)" - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - libevent (~> 2.1.12) @@ -2114,6 +2156,7 @@ DEPENDENCIES: - "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)" - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-key-command (from `../node_modules/react-native-key-command`) + - react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`) - react-native-launch-arguments (from `../node_modules/react-native-launch-arguments`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-pager-view (from `../node_modules/react-native-pager-view`) @@ -2239,6 +2282,10 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-modules-core" FBLazyVector: :path: "../node_modules/react-native/Libraries/FBLazyVector" + FullStory: + :http: https://ios-releases.fullstory.com/fullstory-1.49.0-xcframework.tar.gz + fullstory_react-native: + :path: "../node_modules/@fullstory/react-native" glog: :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" hermes-engine: @@ -2308,6 +2355,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-image-picker" react-native-key-command: :path: "../node_modules/react-native-key-command" + react-native-keyboard-controller: + :path: "../node_modules/react-native-keyboard-controller" react-native-launch-arguments: :path: "../node_modules/react-native-launch-arguments" react-native-netinfo: @@ -2429,6 +2478,10 @@ EXTERNAL SOURCES: Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" +CHECKOUT OPTIONS: + FullStory: + :http: https://ios-releases.fullstory.com/fullstory-1.49.0-xcframework.tar.gz + SPEC CHECKSUMS: Airship: 5a6d3f8a982398940b0d48423bb9b8736717c123 AirshipFrameworkProxy: 7255f4ed9836dc2920f2f1ea5657ced4cee8a35c @@ -2454,6 +2507,8 @@ SPEC CHECKSUMS: FirebasePerformance: 0c01a7a496657d7cea86d40c0b1725259d164c6c FirebaseRemoteConfig: 2d6e2cfdb49af79535c8af8a80a4a5009038ec2b fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 + FullStory: c95f74445f871bc344cdc4a4e4ece61b5554e55d + fullstory_react-native: 6cba8a2c054374a24a44dc4310407d9435459cae glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 GoogleAppMeasurement: 5ba1164e3c844ba84272555e916d0a6d3d977e91 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a @@ -2508,6 +2563,7 @@ SPEC CHECKSUMS: react-native-geolocation: f9e92eb774cb30ac1e099f34b3a94f03b4db7eb3 react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: 28ccfa09520e7d7e30739480dea4df003493bfe8 + react-native-keyboard-controller: 47c01b0741ae5fc84e53cf282e61cfa5c2edb19b react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: 02d31de0e08ab043d48f2a1a8baade109d7b6ca5 react-native-pager-view: ccd4bbf9fc7effaf8f91f8dae43389844d9ef9fa @@ -2545,7 +2601,7 @@ SPEC CHECKSUMS: ReactCommon: 840a955d37b7f3358554d819446bffcf624b2522 RNAppleAuthentication: 0571c08da8c327ae2afc0261b48b4a515b0286a6 RNCClipboard: 081418ae3b391b1012c3f41d045e5e39f1beed71 - RNCPicker: a37026a67de0cf1a33ffe8722783527e3b18ea9f + RNCPicker: 106d11a1c159ce937009b2bd52db2bdb1577454f RNDeviceInfo: 449272e9faf2afe94a3fe2896d169e92277fffa8 RNDevMenu: 72807568fe4188bd4c40ce32675d82434b43c45d RNFBAnalytics: f76bfa164ac235b00505deb9fc1776634056898c @@ -2556,12 +2612,12 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 74b7b3d06d667ba0bbf41da7718f2607ae0dfe8f RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: bfabd5938e5af5afc1e60e4e34286b17f8308184 + RNLiveMarkdown: fff70dc755ed8199a449f61e76cbadec7cd20440 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: df8fe93dbd251f25022f4023d31bc04160d4d65c RNPermissions: 0b61d30d21acbeafe25baaa47d9bae40a0c65216 RNReactNativeHapticFeedback: 616c35bdec7d20d4c524a7949ca9829c09e35f37 - RNReanimated: 51db0fff543694d931bd3b7cab1a3b36bd86c738 + RNReanimated: 323436b1a5364dca3b5f8b1a13458455e0de9efe RNScreens: 9ec969a95987a6caae170ef09313138abf3331e1 RNShare: 2a4cdfc0626ad56b0ef583d424f2038f772afe58 RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 @@ -2575,6 +2631,6 @@ SPEC CHECKSUMS: VisionCamera: 1394a316c7add37e619c48d7aa40b38b954bf055 Yoga: 1b901a6d6eeba4e8a2e8f308f708691cdb5db312 -PODFILE CHECKSUM: a25a81f2b50270f0c0bd0aff2e2ebe4d0b4ec06d +PODFILE CHECKSUM: d5e281e5370cb0211a104efd90eb5fa7af936e14 COCOAPODS: 1.13.0 diff --git a/jest/setup.ts b/jest/setup.ts index 488e3e36a1d3..f11a8a4ed631 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -2,9 +2,11 @@ import '@shopify/flash-list/jestSetup'; import 'react-native-gesture-handler/jestSetup'; import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import 'setimmediate'; +import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; setupMockImages(); +mockFSLibrary(); // This mock is required as per setup instructions for react-navigation testing // https://reactnavigation.org/docs/testing/#mocking-native-modules @@ -19,8 +21,8 @@ jest.mock('react-native-onyx/dist/storage', () => mockStorage); jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter'); // Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise -jest.spyOn(console, 'debug').mockImplementation((...params) => { - if (params[0].indexOf('Timing:') === 0) { +jest.spyOn(console, 'debug').mockImplementation((...params: string[]) => { + if (params[0].startsWith('Timing:')) { return; } @@ -51,3 +53,6 @@ jest.mock('react-native-sound', () => { jest.mock('react-native-share', () => ({ default: jest.fn(), })); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); diff --git a/jest/setupMockFullstoryLib.ts b/jest/setupMockFullstoryLib.ts new file mode 100644 index 000000000000..9edfccab9441 --- /dev/null +++ b/jest/setupMockFullstoryLib.ts @@ -0,0 +1,24 @@ +type FSPageInterface = { + start: jest.Mock; +}; + +export default function mockFSLibrary() { + jest.mock('@fullstory/react-native', () => { + class Fullstory { + consent = jest.fn(); + + anonymize = jest.fn(); + + identify = jest.fn(); + } + + return { + FSPage(): FSPageInterface { + return { + start: jest.fn(), + }; + }, + default: Fullstory, + }; + }); +} diff --git a/package-lock.json b/package-lock.json index ba5a9e1a3c90..cd3055698f72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,29 +1,33 @@ { "name": "new.expensify", - "version": "1.4.71-2", + "version": "1.4.85-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.71-2", + "version": "1.4.85-1", "hasInstallScript": true, "license": "MIT", "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.69", + "@expensify/react-native-live-markdown": "0.1.85", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", "@formatjs/intl-locale": "^3.3.0", "@formatjs/intl-numberformat": "^8.5.0", "@formatjs/intl-pluralrules": "^5.2.2", + "@fullstory/babel-plugin-annotate-react": "github:fullstorydev/fullstory-babel-plugin-annotate-react#ryanwang/react-native-web-demo", + "@fullstory/babel-plugin-react-native": "^1.2.1", + "@fullstory/browser": "^2.0.3", + "@fullstory/react-native": "^1.4.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.6.0", - "@kie/mock-github": "^1.0.0", + "@kie/mock-github": "2.0.1", "@onfido/react-native-sdk": "10.6.0", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.13.2", @@ -34,7 +38,7 @@ "@react-native-firebase/crashlytics": "^12.3.0", "@react-native-firebase/perf": "^12.3.0", "@react-native-google-signin/google-signin": "^10.0.1", - "@react-native-picker/picker": "2.6.1", + "@react-native-picker/picker": "2.7.6", "@react-navigation/material-top-tabs": "^6.6.3", "@react-navigation/native": "6.1.12", "@react-navigation/stack": "6.3.29", @@ -56,11 +60,12 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#9a68635cdcef4c81593c0f816a007bc9c707d46a", + "expensify-common": "^2.0.12", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.11.0", "expo-image-manipulator": "11.8.0", + "focus-trap-react": "^10.2.3", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "jest-expo": "50.0.1", @@ -70,7 +75,6 @@ "mapbox-gl": "^2.15.0", "onfido-sdk-ui": "14.15.0", "process": "^0.11.10", - "prop-types": "^15.7.2", "pusher-js": "8.3.0", "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", @@ -78,7 +82,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.1.0", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "^1.0.12", + "react-fast-pdf": "1.0.13", "react-map-gl": "^7.1.3", "react-native": "0.73.4", "react-native-android-location-enabler": "^2.0.1", @@ -96,11 +100,12 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", + "react-native-keyboard-controller": "^1.12.2", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.32", + "react-native-onyx": "2.0.49", "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -109,7 +114,7 @@ "react-native-plaid-link-sdk": "11.5.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#abc91857d4b3efb2020ec43abd2a508563b64316", - "react-native-reanimated": "^3.7.2", + "react-native-reanimated": "^3.8.0", "react-native-release-profiler": "^0.1.6", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.8.2", @@ -121,11 +126,11 @@ "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "^4.0.0-beta.13", - "react-native-web": "^0.19.9", + "react-native-web": "^0.19.12", "react-native-web-linear-gradient": "^1.1.2", "react-native-web-sound": "^0.1.3", "react-native-webview": "13.6.4", - "react-pdf": "^7.7.0", + "react-pdf": "^7.7.3", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", @@ -201,11 +206,11 @@ "csv-parse": "^5.5.5", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^29.2.0", + "electron": "^29.4.1", "electron-builder": "24.13.2", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-config-expensify": "^2.0.47", + "eslint-config-expensify": "^2.0.51", "eslint-config-prettier": "^8.8.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^24.1.0", @@ -238,7 +243,7 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "type-fest": "^4.10.2", - "typescript": "^5.3.2", + "typescript": "^5.4.5", "wait-port": "^0.2.9", "webpack": "^5.76.0", "webpack-bundle-analyzer": "^4.5.0", @@ -248,16 +253,8 @@ "yaml": "^2.2.1" }, "engines": { - "node": "20.10.0", - "npm": "10.2.3" - } - }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node": "20.14.0", + "npm": "10.7.0" } }, "node_modules/@actions/core": { @@ -1852,19 +1849,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-object-assign": { - "version": "7.18.6", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-object-rest-spread": { "version": "7.24.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.1.tgz", @@ -3568,10 +3552,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.69", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.69.tgz", - "integrity": "sha512-ZJG6f06lHrNb0s/92JyyvsSDGGZLdU/a/YXir2A5UFCiERVWkgJxcugsYbEMemh2HsWD6GXvhq1Sngj2H620nw==", + "version": "0.1.85", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.85.tgz", + "integrity": "sha512-jeP4JBzN34pGSpjHKM7Zj3d0cqcKbID3//WrqC+SI7SK/1iJT4SdhZptVCxUg+Dcxq5XwzYIhdnhTNimeya0Fg==", + "workspaces": [ + "parser", + "example", + "WebExample" + ], "engines": { "node": ">= 18.0.0" }, @@ -5589,6 +5587,51 @@ "tslib": "^2.4.0" } }, + "node_modules/@fullstory/babel-plugin-annotate-react": { + "version": "2.3.0", + "resolved": "git+ssh://git@github.com/fullstorydev/fullstory-babel-plugin-annotate-react.git#25c26dadb644d5355e381a4ea4ca1cd05af4a8f6" + }, + "node_modules/@fullstory/babel-plugin-react-native": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fullstory/babel-plugin-react-native/-/babel-plugin-react-native-1.2.1.tgz", + "integrity": "sha512-EMAgoPOo+31eppHxQf05oAGhKKTem7rw8GHDdbNJF0c5dQWzBGNVgF72TPjcxES91UI6hbss2eqoVOhUttLEoQ==", + "dependencies": { + "@babel/parser": "^7.0.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@fullstory/browser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@fullstory/browser/-/browser-2.0.3.tgz", + "integrity": "sha512-usjH8FB1O2LiSWoblsuKhFhlYDGpIPuyQVOx4JXtxm9QmQARdKZdNq1vPijxuDvOGjhwtVZa4JmhvByRRuDPnQ==", + "dependencies": { + "@fullstory/snippet": "2.0.3" + } + }, + "node_modules/@fullstory/react-native": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@fullstory/react-native/-/react-native-1.4.2.tgz", + "integrity": "sha512-Ig85ghn5UN+Tc1JWL/y4hY9vleeaVHL3f6qH9W4odDNP4XAv29+G82nIYQhBOQGoVnIQ4oQFQftir/dqAbidSw==", + "dependencies": { + "@fullstory/babel-plugin-annotate-react": "^2.2.0", + "@fullstory/babel-plugin-react-native": "^1.1.0" + }, + "peerDependencies": { + "expo": ">=47.0.0", + "react": "*", + "react-native": ">=0.61.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@fullstory/snippet": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@fullstory/snippet/-/snippet-2.0.3.tgz", + "integrity": "sha512-EaCuTQSLv5FvnjHLbTxErn3sS1+nLqf1p6sA/c4PV49stBtkUakA0eLhJJdaw0WLdXyEzZXf86lRNsjEzrgGPw==" + }, "node_modules/@gar/promisify": { "version": "1.1.3", "license": "MIT" @@ -5641,6 +5684,19 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@humanwhocodes/object-schema": { "version": "1.2.1", "dev": true, @@ -7011,48 +7067,12 @@ "act-js": "bin/act" } }, - "node_modules/@kie/act-js/node_modules/@kie/mock-github": { - "version": "2.0.0", - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@octokit/openapi-types-ghec": "^18.0.0", - "ajv": "^8.11.0", - "express": "^4.18.1", - "fast-glob": "^3.2.12", - "fs-extra": "^10.1.0", - "nock": "^13.2.7", - "simple-git": "^3.8.0", - "totalist": "^3.0.0" - } - }, - "node_modules/@kie/act-js/node_modules/@octokit/openapi-types-ghec": { - "version": "18.1.1", - "license": "MIT" - }, - "node_modules/@kie/act-js/node_modules/fs-extra": { - "version": "10.1.0", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@kie/act-js/node_modules/totalist": { - "version": "3.0.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/@kie/mock-github": { - "version": "1.1.0", - "license": "SEE LICENSE IN LICENSE", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-2.0.1.tgz", + "integrity": "sha512-G1FD/jg1KyW7a6NvKI4uEVJCK3eJnzXkh4Ikxn2is5tiNC980lavi8ak6bn1QEFEgpYcfM4DpZM3yHDfOmyLuQ==", "dependencies": { - "@octokit/openapi-types-ghec": "^14.0.0", + "@octokit/openapi-types-ghec": "^18.0.0", "ajv": "^8.11.0", "express": "^4.18.1", "fast-glob": "^3.2.12", @@ -7502,8 +7522,9 @@ "license": "MIT" }, "node_modules/@octokit/openapi-types-ghec": { - "version": "14.0.0", - "license": "MIT" + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-18.1.1.tgz", + "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw==" }, "node_modules/@octokit/plugin-paginate-rest": { "version": "3.1.0", @@ -8854,12 +8875,12 @@ } }, "node_modules/@react-native-picker/picker": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.6.1.tgz", - "integrity": "sha512-oJftvmLOj6Y6/bF4kPcK6L83yNBALGmqNYugf94BzP0FQGpHBwimVN2ygqkQ2Sn2ZU3pGUZMs0jV6+Gku2GyYg==", + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.7.6.tgz", + "integrity": "sha512-Cs3PxRmE2vu6TofM9vt9TV8ZYFOtEPSupNxwoorH9lpkKM9HGG8QwK2i29KOEoODpUbtudKHUTtqhMZSuX9pgA==", "peerDependencies": { - "react": ">=16", - "react-native": ">=0.57" + "react": "*", + "react-native": "*" } }, "node_modules/@react-native/assets-registry": { @@ -18568,9 +18589,9 @@ } }, "node_modules/electron": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-29.2.0.tgz", - "integrity": "sha512-ALKrCN52RG4g9prx4DriXSPnY5WoiyRUCNp7zEVQuoiNOpHTNqMMpRidQAHzntV4hajF1LMWHVoBkwqIs1jHhg==", + "version": "29.4.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-29.4.1.tgz", + "integrity": "sha512-YQvMAtdmjMF1yGfQFuO/KOmy+04SKot85NalppK/8zxKwOKrrK6dJBp+nJWteqBwRAKiasSrC1lDalF6hZct/w==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -19330,14 +19351,16 @@ } }, "node_modules/eslint-config-expensify": { - "version": "2.0.48", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.48.tgz", - "integrity": "sha512-PFegJ9Wfsiu5tgevhjA1toCxsZ8Etfk6pIjtXAnwpmVj7q4CtB3QDRusJoUDyJ3HrZr8AsFKViz7CU/CBTfwOw==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.51.tgz", + "integrity": "sha512-qEUPCI9vsAi5c5E6zM4QEYal13hIRHFvonf4U/x0JI4ceMdAejOoq/Zvt9r1ZwKT1RmA8eRoGWWIQ/4O/9hJPg==", "dev": true, "dependencies": { "@lwc/eslint-plugin-lwc": "^1.7.2", + "@typescript-eslint/parser": "^7.12.0", + "@typescript-eslint/utils": "^7.12.0", "babel-eslint": "^10.1.0", - "eslint": "^7.32.0", + "eslint": "^8.56.0", "eslint-config-airbnb": "19.0.4", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-es": "^4.1.0", @@ -19350,6 +19373,468 @@ "underscore": "^1.13.6" } }, + "node_modules/eslint-config-expensify/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/parser": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.12.0.tgz", + "integrity": "sha512-dm/J2UDY3oV3TKius2OUZIFHsomQmpHtsV0FTh1WO8EKgHLQ1QCADUqscPgTpU+ih1e21FQSRjXckHn3txn6kQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.12.0", + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/typescript-estree": "7.12.0", + "@typescript-eslint/visitor-keys": "7.12.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/scope-manager": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.12.0.tgz", + "integrity": "sha512-itF1pTnN6F3unPak+kutH9raIkL3lhH1YRPGgt7QQOh43DQKVJXmWkpb+vpc/TiDHs6RSd9CTbDsc/Y+Ygq7kg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/visitor-keys": "7.12.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/types": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.12.0.tgz", + "integrity": "sha512-o+0Te6eWp2ppKY3mLCU+YA9pVJxhUJE15FV7kxuD9jgwIAa+w/ycGJBMrYDTpVGUM/tgpa9SeMOugSabWFq7bg==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.12.0.tgz", + "integrity": "sha512-5bwqLsWBULv1h6pn7cMW5dXX/Y2amRqLaKqsASVwbBHMZSnHqE/HN4vT4fE0aFsiwxYvr98kqOWh1a8ZKXalCQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/visitor-keys": "7.12.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/utils": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.12.0.tgz", + "integrity": "sha512-Y6hhwxwDx41HNpjuYswYp6gDbkiZ8Hin9Bf5aJQn1bpTs3afYY4GX+MPYxma8jtoIV2GRwTM/UJm/2uGCVv+DQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.12.0", + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/typescript-estree": "7.12.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.12.0.tgz", + "integrity": "sha512-uZk7DevrQLL3vSnfFl5bj4sL75qC9D6EdjemIdbtkuUmIheWpuiiylSY01JxJE7+zGrOWDZrp1WxOuDntvKrHQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.12.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint-config-expensify/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint-config-expensify/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint-config-expensify/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-config-expensify/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-config-expensify/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-config-expensify/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint-config-expensify/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-config-expensify/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-config-prettier": { "version": "8.10.0", "dev": true, @@ -19807,8 +20292,9 @@ }, "node_modules/eslint-plugin-you-dont-need-lodash-underscore": { "version": "6.12.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-you-dont-need-lodash-underscore/-/eslint-plugin-you-dont-need-lodash-underscore-6.12.0.tgz", + "integrity": "sha512-WF4mNp+k2532iswT6iUd1BX6qjd3AV4cFy/09VC82GY9SsRtvkxhUIx7JNGSe0/bLyd57oTr4inPFiIaENXhGw==", "dev": true, - "license": "MIT", "dependencies": { "kebab-case": "^1.0.0" }, @@ -19984,22 +20470,6 @@ "dev": true, "license": "MIT" }, - "node_modules/eslint/node_modules/optionator": { - "version": "0.9.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -20300,11 +20770,11 @@ } }, "node_modules/expensify-common": { - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#9a68635cdcef4c81593c0f816a007bc9c707d46a", - "integrity": "sha512-9BHjM3kZs7/dil0oykEQFkEhXjVD5liTttmO7ZYtPZkl4j6g97mubY2p9lYpWwpkWckUfvU7nGuZQjahw9xSFA==", - "license": "MIT", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.12.tgz", + "integrity": "sha512-idIm9mAGDX1qyfA2Ky/1ZJZVMbGydtpIdwl6zl1Yc7FO11IGvAYLh2cH9VsQk98AapRTiJu7QUaRWLLGDaHIcQ==", "dependencies": { + "awesome-phonenumber": "^5.4.0", "classnames": "2.5.0", "clipboard": "2.0.11", "html-entities": "^2.5.2", @@ -21246,6 +21716,28 @@ "node": ">=0.4.0" } }, + "node_modules/focus-trap": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", + "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==", + "dependencies": { + "tabbable": "^6.2.0" + } + }, + "node_modules/focus-trap-react": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.3.tgz", + "integrity": "sha512-YXBpFu/hIeSu6NnmV2xlXzOYxuWkoOtar9jzgp3lOmjWLWY59C/b8DtDHEAV4SPU07Nd/t+nS/SBNGkhUBFmEw==", + "dependencies": { + "focus-trap": "^7.5.4", + "tabbable": "^6.2.0" + }, + "peerDependencies": { + "prop-types": "^15.8.1", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, "node_modules/follow-redirects": { "version": "1.15.5", "funding": [ @@ -29456,6 +29948,23 @@ "opener": "bin/opener-bin.js" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -31059,16 +31568,16 @@ } }, "node_modules/react-fast-pdf": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.12.tgz", - "integrity": "sha512-RSIYTwQVKWFqZKtmtzd4JU/FnsqdGPBtHu/N6xl7TsauAFnEouUJNjmC7Rg/pd010OX1UvyraQKdBIZ5Pf2q0A==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.13.tgz", + "integrity": "sha512-rF7NQZ26rJAI8ysRJaG71dl2c7AIq48ibbn7xCyF3lEZ/yOjA8BeR0utRwDjaHGtswQscgETboilhaaH5UtIYg==", "dependencies": { "react-pdf": "^7.7.0", "react-window": "^1.8.10" }, "engines": { - "node": "20.10.0", - "npm": "10.2.3" + "node": ">=20.10.0", + "npm": ">=10.2.3" }, "peerDependencies": { "lodash": "4.x", @@ -31384,6 +31893,16 @@ "version": "5.0.1", "license": "MIT" }, + "node_modules/react-native-keyboard-controller": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.2.tgz", + "integrity": "sha512-10Sy0+neSHGJxOmOxrUJR8TQznnrQ+jTFQtM1PP6YnblNQeAw1eOa+lO6YLGenRr5WuNSMZbks/3Ay0e2yMKLw==", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-reanimated": ">=2.3.0" + } + }, "node_modules/react-native-launch-arguments": { "version": "4.0.2", "license": "MIT", @@ -31431,17 +31950,17 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.32", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.32.tgz", - "integrity": "sha512-tB9wqMJGTLOYfrfplRP+9aq5JdD8w/hV/OZsMAVH+ewbE1zLY8OymUsAsIFdF1v+cB8HhehP569JVLZmhm6bsg==", + "version": "2.0.49", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.49.tgz", + "integrity": "sha512-cmFc7OZcVRuegb86c0tOCa8GGAXIraOfnLgtSxnNOA7DV/PMrbSetyFry2tzEDnGwORsgVWaunV78Jw1Em5rwA==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", "underscore": "^1.13.6" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=20.14.0", + "npm": ">=10.7.0" }, "peerDependencies": { "idb-keyval": "^6.2.1", @@ -31554,10 +32073,15 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.7.2", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.8.1.tgz", + "integrity": "sha512-EdM0vr3JEaNtqvstqESaPfOBy0gjYBkr1iEolWJ82Ax7io8y9OVUIphgsLKTB36CtR1XtmBw0RZVj7KArc7ZVA==", "dependencies": { - "@babel/plugin-transform-object-assign": "^7.16.7", + "@babel/plugin-transform-arrow-functions": "^7.0.0-0", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", + "@babel/plugin-transform-optional-chaining": "^7.0.0-0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", + "@babel/plugin-transform-template-literals": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", "invariant": "^2.2.4" @@ -31742,11 +32266,12 @@ } }, "node_modules/react-native-web": { - "version": "0.19.9", - "license": "MIT", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.12.tgz", + "integrity": "sha512-o2T0oztoVDQjztt4YksO9S1XRjoH/AqcSvifgWLrPJgGVbMWsfhILgl6lfUdEamVZzZSVV/2gqDVMAk/qq7mZw==", "dependencies": { "@babel/runtime": "^7.18.6", - "@react-native/normalize-color": "^2.1.0", + "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^6.0.1", "memoize-one": "^6.0.0", @@ -31776,6 +32301,11 @@ "react-native-web": "*" } }, + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.81", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.81.tgz", + "integrity": "sha512-g3YvkLO7UsSWiDfYAU+gLhRHtEpUyz732lZB+N8IlLXc5MnfXHC8GKneDGY3Mh52I3gBrs20o37D5viQX9E1CA==" + }, "node_modules/react-native-web/node_modules/memoize-one": { "version": "6.0.0", "license": "MIT" @@ -31959,9 +32489,9 @@ } }, "node_modules/react-pdf": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-7.7.1.tgz", - "integrity": "sha512-cbbf/PuRtGcPPw+HLhMI1f6NSka8OJgg+j/yPWTe95Owf0fK6gmVY7OXpTxMeh92O3T3K3EzfE0ML0eXPGwR5g==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-7.7.3.tgz", + "integrity": "sha512-a2VfDl8hiGjugpqezBTUzJHYLNB7IS7a2t7GD52xMI9xHg8LdVaTMsnM9ZlNmKadnStT/tvX5IfV0yLn+JvYmw==", "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", @@ -35048,6 +35578,11 @@ "version": "3.2.4", "license": "MIT" }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "node_modules/table": { "version": "6.8.1", "dev": true, @@ -35708,11 +36243,12 @@ } }, "node_modules/ts-api-utils": { - "version": "1.0.1", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", "dev": true, - "license": "MIT", "engines": { - "node": ">=16.13.0" + "node": ">=16" }, "peerDependencies": { "typescript": ">=4.2.0" @@ -36029,9 +36565,10 @@ } }, "node_modules/typescript": { - "version": "5.3.3", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "devOptional": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -37688,6 +38225,15 @@ "version": "4.0.15", "license": "MIT" }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index c31eab2cf5d9..0b463d950cfc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.71-2", + "version": "1.4.85-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.", @@ -34,7 +34,7 @@ "android-build": "fastlane android build", "android-build-e2e": "bundle exec fastlane android build_e2e", "android-build-e2edelta": "bundle exec fastlane android build_e2edelta", - "test": "TZ=utc jest", + "test": "TZ=utc NODE_OPTIONS=--experimental-vm-modules jest", "typecheck": "tsc", "lint": "eslint . --max-warnings=0 --cache --cache-location=node_modules/.cache/eslint", "lint-changed": "eslint --fix $(git diff --diff-filter=AM --name-only main -- \"*.js\" \"*.ts\" \"*.tsx\")", @@ -65,17 +65,21 @@ "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.69", + "@expensify/react-native-live-markdown": "0.1.85", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", "@formatjs/intl-locale": "^3.3.0", "@formatjs/intl-numberformat": "^8.5.0", "@formatjs/intl-pluralrules": "^5.2.2", + "@fullstory/babel-plugin-annotate-react": "github:fullstorydev/fullstory-babel-plugin-annotate-react#ryanwang/react-native-web-demo", + "@fullstory/babel-plugin-react-native": "^1.2.1", + "@fullstory/browser": "^2.0.3", + "@fullstory/react-native": "^1.4.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.6.0", - "@kie/mock-github": "^1.0.0", + "@kie/mock-github": "2.0.1", "@onfido/react-native-sdk": "10.6.0", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.13.2", @@ -86,7 +90,7 @@ "@react-native-firebase/crashlytics": "^12.3.0", "@react-native-firebase/perf": "^12.3.0", "@react-native-google-signin/google-signin": "^10.0.1", - "@react-native-picker/picker": "2.6.1", + "@react-native-picker/picker": "2.7.6", "@react-navigation/material-top-tabs": "^6.6.3", "@react-navigation/native": "6.1.12", "@react-navigation/stack": "6.3.29", @@ -108,11 +112,12 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#9a68635cdcef4c81593c0f816a007bc9c707d46a", + "expensify-common": "^2.0.12", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.11.0", "expo-image-manipulator": "11.8.0", + "focus-trap-react": "^10.2.3", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "jest-expo": "50.0.1", @@ -122,7 +127,6 @@ "mapbox-gl": "^2.15.0", "onfido-sdk-ui": "14.15.0", "process": "^0.11.10", - "prop-types": "^15.7.2", "pusher-js": "8.3.0", "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", @@ -130,7 +134,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.1.0", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "^1.0.12", + "react-fast-pdf": "1.0.13", "react-map-gl": "^7.1.3", "react-native": "0.73.4", "react-native-android-location-enabler": "^2.0.1", @@ -148,11 +152,12 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", + "react-native-keyboard-controller": "^1.12.2", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.32", + "react-native-onyx": "2.0.49", "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -161,7 +166,7 @@ "react-native-plaid-link-sdk": "11.5.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#abc91857d4b3efb2020ec43abd2a508563b64316", - "react-native-reanimated": "^3.7.2", + "react-native-reanimated": "^3.8.0", "react-native-release-profiler": "^0.1.6", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.8.2", @@ -173,11 +178,11 @@ "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "^4.0.0-beta.13", - "react-native-web": "^0.19.9", + "react-native-web": "^0.19.12", "react-native-web-linear-gradient": "^1.1.2", "react-native-web-sound": "^0.1.3", "react-native-webview": "13.6.4", - "react-pdf": "^7.7.0", + "react-pdf": "^7.7.3", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", @@ -253,11 +258,11 @@ "csv-parse": "^5.5.5", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^29.2.0", + "electron": "^29.4.1", "electron-builder": "24.13.2", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-config-expensify": "^2.0.47", + "eslint-config-expensify": "^2.0.51", "eslint-config-prettier": "^8.8.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^24.1.0", @@ -290,7 +295,7 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "type-fest": "^4.10.2", - "typescript": "^5.3.2", + "typescript": "^5.4.5", "wait-port": "^0.2.9", "webpack": "^5.76.0", "webpack-bundle-analyzer": "^4.5.0", @@ -327,7 +332,7 @@ ] }, "engines": { - "node": "20.10.0", - "npm": "10.2.3" + "node": "20.14.0", + "npm": "10.7.0" } } diff --git a/patches/@react-navigation+native+6.1.12.patch b/patches/@react-navigation+native+6.1.12.patch index d451d89d687c..0ac57a865dfd 100644 --- a/patches/@react-navigation+native+6.1.12.patch +++ b/patches/@react-navigation+native+6.1.12.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -index 16fdbef..bc2c96a 100644 +index 16fdbef..231a520 100644 --- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js @@ -1,8 +1,23 @@ @@ -63,6 +63,15 @@ index 16fdbef..bc2c96a 100644 replace(_ref3) { var _window$history$state2; let { +@@ -80,7 +101,7 @@ export default function createMemoryHistory() { + + // Need to keep the hash part of the path if there was no previous history entry + // or the previous history entry had the same path +- let pathWithHash = path; ++ let pathWithHash = path.replace(/(\/{2,})/g, '/'); + if (!items.length || items.findIndex(item => item.id === id) < 0) { + // There are two scenarios for creating an array with only one history record: + // - When loaded id not found in the items array, this function by default will replace @@ -108,7 +129,9 @@ export default function createMemoryHistory() { window.history.replaceState({ id diff --git a/patches/@shopify+flash-list+1.6.3.patch b/patches/@shopify+flash-list+1.6.3.patch index ab347fbb4e9c..e3d690055ff8 100644 --- a/patches/@shopify+flash-list+1.6.3.patch +++ b/patches/@shopify+flash-list+1.6.3.patch @@ -867,7 +867,7 @@ index 023b94a..0000000 -{"program":{"fileNames":["../node_modules/typescript/lib/lib.es5.d.ts","../node_modules/typescript/lib/lib.es2015.d.ts","../node_modules/typescript/lib/lib.es2016.d.ts","../node_modules/typescript/lib/lib.es2017.d.ts","../node_modules/typescript/lib/lib.es2018.d.ts","../node_modules/typescript/lib/lib.es2019.d.ts","../node_modules/typescript/lib/lib.es2020.d.ts","../node_modules/typescript/lib/lib.dom.d.ts","../node_modules/typescript/lib/lib.dom.iterable.d.ts","../node_modules/typescript/lib/lib.es2015.core.d.ts","../node_modules/typescript/lib/lib.es2015.collection.d.ts","../node_modules/typescript/lib/lib.es2015.generator.d.ts","../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../node_modules/typescript/lib/lib.es2015.promise.d.ts","../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../node_modules/typescript/lib/lib.es2017.object.d.ts","../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../node_modules/typescript/lib/lib.es2017.string.d.ts","../node_modules/typescript/lib/lib.es2017.intl.d.ts","../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../node_modules/typescript/lib/lib.es2018.intl.d.ts","../node_modules/typescript/lib/lib.es2018.promise.d.ts","../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../node_modules/typescript/lib/lib.es2019.array.d.ts","../node_modules/typescript/lib/lib.es2019.object.d.ts","../node_modules/typescript/lib/lib.es2019.string.d.ts","../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../node_modules/typescript/lib/lib.es2020.date.d.ts","../node_modules/typescript/lib/lib.es2020.promise.d.ts","../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../node_modules/typescript/lib/lib.es2020.string.d.ts","../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../node_modules/typescript/lib/lib.es2020.intl.d.ts","../node_modules/typescript/lib/lib.es2020.number.d.ts","../node_modules/typescript/lib/lib.esnext.intl.d.ts","../node_modules/tslib/tslib.d.ts","../node_modules/@types/react-native/modules/BatchedBridge.d.ts","../node_modules/@types/react-native/modules/Codegen.d.ts","../node_modules/@types/react-native/modules/Devtools.d.ts","../node_modules/@types/react-native/modules/globals.d.ts","../node_modules/@types/react-native/modules/LaunchScreen.d.ts","../node_modules/@types/react/global.d.ts","../node_modules/csstype/index.d.ts","../node_modules/@types/prop-types/index.d.ts","../node_modules/@types/scheduler/tracing.d.ts","../node_modules/@types/react/index.d.ts","../node_modules/@types/react-native/private/Utilities.d.ts","../node_modules/@types/react-native/public/Insets.d.ts","../node_modules/@types/react-native/Libraries/ReactNative/RendererProxy.d.ts","../node_modules/@types/react-native/public/ReactNativeTypes.d.ts","../node_modules/@types/react-native/Libraries/Types/CoreEventTypes.d.ts","../node_modules/@types/react-native/public/ReactNativeRenderer.d.ts","../node_modules/@types/react-native/Libraries/Components/Touchable/Touchable.d.ts","../node_modules/@types/react-native/Libraries/Components/View/ViewAccessibility.d.ts","../node_modules/@types/react-native/Libraries/Components/View/ViewPropTypes.d.ts","../node_modules/@types/react-native/Libraries/Components/RefreshControl/RefreshControl.d.ts","../node_modules/@types/react-native/Libraries/Components/ScrollView/ScrollView.d.ts","../node_modules/@types/react-native/Libraries/Components/View/View.d.ts","../node_modules/@types/react-native/Libraries/Image/ImageResizeMode.d.ts","../node_modules/@types/react-native/Libraries/Image/ImageSource.d.ts","../node_modules/@types/react-native/Libraries/Image/Image.d.ts","../node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.d.ts","../node_modules/@react-native/virtualized-lists/index.d.ts","../node_modules/@types/react-native/Libraries/Lists/FlatList.d.ts","../node_modules/@types/react-native/Libraries/Lists/SectionList.d.ts","../node_modules/@types/react-native/Libraries/Text/Text.d.ts","../node_modules/@types/react-native/Libraries/Animated/Animated.d.ts","../node_modules/@types/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts","../node_modules/@types/react-native/Libraries/StyleSheet/StyleSheet.d.ts","../node_modules/@types/react-native/Libraries/StyleSheet/processColor.d.ts","../node_modules/@types/react-native/Libraries/ActionSheetIOS/ActionSheetIOS.d.ts","../node_modules/@types/react-native/Libraries/Alert/Alert.d.ts","../node_modules/@types/react-native/Libraries/Animated/Easing.d.ts","../node_modules/@types/react-native/Libraries/Animated/useAnimatedValue.d.ts","../node_modules/@types/react-native/Libraries/vendor/emitter/EventEmitter.d.ts","../node_modules/@types/react-native/Libraries/EventEmitter/RCTDeviceEventEmitter.d.ts","../node_modules/@types/react-native/Libraries/EventEmitter/RCTNativeAppEventEmitter.d.ts","../node_modules/@types/react-native/Libraries/AppState/AppState.d.ts","../node_modules/@types/react-native/Libraries/BatchedBridge/NativeModules.d.ts","../node_modules/@types/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts","../node_modules/@types/react-native/Libraries/Components/ActivityIndicator/ActivityIndicator.d.ts","../node_modules/@types/react-native/Libraries/Components/Clipboard/Clipboard.d.ts","../node_modules/@types/react-native/Libraries/Components/DrawerAndroid/DrawerLayoutAndroid.d.ts","../node_modules/@types/react-native/Libraries/EventEmitter/NativeEventEmitter.d.ts","../node_modules/@types/react-native/Libraries/Components/Keyboard/Keyboard.d.ts","../node_modules/@types/react-native/private/TimerMixin.d.ts","../node_modules/@types/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.d.ts","../node_modules/@types/react-native/Libraries/Components/Pressable/Pressable.d.ts","../node_modules/@types/react-native/Libraries/Components/ProgressBarAndroid/ProgressBarAndroid.d.ts","../node_modules/@types/react-native/Libraries/Components/SafeAreaView/SafeAreaView.d.ts","../node_modules/@types/react-native/Libraries/Components/StatusBar/StatusBar.d.ts","../node_modules/@types/react-native/Libraries/Components/Switch/Switch.d.ts","../node_modules/@types/react-native/Libraries/Components/TextInput/InputAccessoryView.d.ts","../node_modules/@types/react-native/Libraries/Components/TextInput/TextInput.d.ts","../node_modules/@types/react-native/Libraries/Components/ToastAndroid/ToastAndroid.d.ts","../node_modules/@types/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.d.ts","../node_modules/@types/react-native/Libraries/Components/Touchable/TouchableHighlight.d.ts","../node_modules/@types/react-native/Libraries/Components/Touchable/TouchableOpacity.d.ts","../node_modules/@types/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.d.ts","../node_modules/@types/react-native/Libraries/Components/Button.d.ts","../node_modules/@types/react-native/Libraries/DevToolsSettings/DevToolsSettingsManager.d.ts","../node_modules/@types/react-native/Libraries/Interaction/InteractionManager.d.ts","../node_modules/@types/react-native/Libraries/Interaction/PanResponder.d.ts","../node_modules/@types/react-native/Libraries/LayoutAnimation/LayoutAnimation.d.ts","../node_modules/@types/react-native/Libraries/Linking/Linking.d.ts","../node_modules/@types/react-native/Libraries/LogBox/LogBox.d.ts","../node_modules/@types/react-native/Libraries/Modal/Modal.d.ts","../node_modules/@types/react-native/Libraries/Performance/Systrace.d.ts","../node_modules/@types/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.d.ts","../node_modules/@types/react-native/Libraries/PushNotificationIOS/PushNotificationIOS.d.ts","../node_modules/@types/react-native/Libraries/Utilities/IPerformanceLogger.d.ts","../node_modules/@types/react-native/Libraries/ReactNative/AppRegistry.d.ts","../node_modules/@types/react-native/Libraries/ReactNative/I18nManager.d.ts","../node_modules/@types/react-native/Libraries/ReactNative/RootTag.d.ts","../node_modules/@types/react-native/Libraries/ReactNative/UIManager.d.ts","../node_modules/@types/react-native/Libraries/ReactNative/requireNativeComponent.d.ts","../node_modules/@types/react-native/Libraries/Settings/Settings.d.ts","../node_modules/@types/react-native/Libraries/Share/Share.d.ts","../node_modules/@types/react-native/Libraries/StyleSheet/PlatformColorValueTypesIOS.d.ts","../node_modules/@types/react-native/Libraries/StyleSheet/PlatformColorValueTypes.d.ts","../node_modules/@types/react-native/Libraries/TurboModule/RCTExport.d.ts","../node_modules/@types/react-native/Libraries/TurboModule/TurboModuleRegistry.d.ts","../node_modules/@types/react-native/Libraries/Utilities/Appearance.d.ts","../node_modules/@types/react-native/Libraries/Utilities/BackHandler.d.ts","../node_modules/@types/react-native/Libraries/Utilities/DevSettings.d.ts","../node_modules/@types/react-native/Libraries/Utilities/Dimensions.d.ts","../node_modules/@types/react-native/Libraries/Utilities/PixelRatio.d.ts","../node_modules/@types/react-native/Libraries/Utilities/Platform.d.ts","../node_modules/@types/react-native/Libraries/Vibration/Vibration.d.ts","../node_modules/@types/react-native/Libraries/YellowBox/YellowBoxDeprecated.d.ts","../node_modules/@types/react-native/Libraries/vendor/core/ErrorUtils.d.ts","../node_modules/@types/react-native/public/DeprecatedPropertiesAlias.d.ts","../node_modules/@types/react-native/index.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/dependencies/ContextProvider.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/dependencies/DataProvider.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/layoutmanager/LayoutManager.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/dependencies/LayoutProvider.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/dependencies/GridLayoutProvider.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/scrollcomponent/BaseScrollView.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/ViewabilityTracker.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/VirtualRenderer.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/ItemAnimator.d.ts","../node_modules/recyclerlistview/dist/reactnative/utils/ComponentCompat.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/RecyclerListView.d.ts","../node_modules/recyclerlistview/dist/reactnative/utils/AutoScroll.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/layoutmanager/GridLayoutManager.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/ProgressiveListView.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/devutils/debughandlers/resize/ResizeDebugHandler.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/devutils/debughandlers/DebugHandlers.d.ts","../node_modules/recyclerlistview/dist/reactnative/index.d.ts","../node_modules/recyclerlistview/dist/reactnative/core/StickyContainer.d.ts","../node_modules/recyclerlistview/sticky/index.d.ts","../src/native/auto-layout/AutoLayoutViewNativeComponentProps.ts","../src/native/auto-layout/AutoLayoutViewNativeComponent.ts","../src/native/auto-layout/AutoLayoutView.tsx","../src/native/cell-container/CellContainer.tsx","../src/PureComponentWrapper.tsx","../src/viewability/ViewToken.ts","../src/FlashListProps.ts","../src/utils/AverageWindow.ts","../src/utils/ContentContainerUtils.ts","../src/GridLayoutProviderWithProps.ts","../src/errors/CustomError.ts","../src/errors/ExceptionList.ts","../src/errors/Warnings.ts","../src/viewability/ViewabilityHelper.ts","../src/viewability/ViewabilityManager.ts","../node_modules/recyclerlistview/dist/reactnative/platform/reactnative/itemanimators/defaultjsanimator/DefaultJSItemAnimator.d.ts","../src/native/config/PlatformHelper.ts","../src/FlashList.tsx","../src/AnimatedFlashList.ts","../src/MasonryFlashList.tsx","../src/benchmark/AutoScrollHelper.ts","../src/benchmark/roundToDecimalPlaces.ts","../src/benchmark/JSFPSMonitor.ts","../src/benchmark/useBlankAreaTracker.ts","../src/benchmark/useBenchmark.ts","../src/benchmark/useDataMultiplier.ts","../src/benchmark/useFlatListBenchmark.ts","../src/index.ts","../src/__tests__/AverageWindow.test.ts","../src/__tests__/ContentContainerUtils.test.ts","../node_modules/@quilted/react-testing/build/typescript/types.d.ts","../node_modules/@quilted/react-testing/build/typescript/matchers/index.d.ts","../node_modules/@quilted/react-testing/build/typescript/environment.d.ts","../node_modules/@quilted/react-testing/build/typescript/implementations/test-renderer.d.ts","../node_modules/@quilted/react-testing/build/typescript/index.d.ts","../src/__tests__/helpers/mountFlashList.tsx","../src/__tests__/FlashList.test.tsx","../src/__tests__/GridLayoutProviderWithProps.test.ts","../src/__tests__/helpers/mountMasonryFlashList.tsx","../src/__tests__/MasonryFlashList.test.ts","../src/native/config/PlatformHelper.web.ts","../src/__tests__/PlatformHelper.web.test.ts","../src/__tests__/ViewabilityHelper.test.ts","../src/__tests__/useBlankAreaTracker.test.tsx","../src/native/auto-layout/AutoLayoutViewNativeComponent.android.ts","../src/native/auto-layout/AutoLayoutViewNativeComponent.ios.ts","../src/native/cell-container/CellContainer.android.ts","../src/native/cell-container/CellContainer.ios.ts","../src/native/cell-container/CellContainer.web.tsx","../src/native/config/PlatformHelper.android.ts","../src/native/config/PlatformHelper.ios.ts","../node_modules/@babel/types/lib/index.d.ts","../node_modules/@types/babel__generator/index.d.ts","../node_modules/@babel/parser/typings/babel-parser.d.ts","../node_modules/@types/babel__template/index.d.ts","../node_modules/@types/babel__traverse/index.d.ts","../node_modules/@types/babel__core/index.d.ts","../node_modules/@types/node/assert.d.ts","../node_modules/@types/node/assert/strict.d.ts","../node_modules/@types/node/globals.d.ts","../node_modules/@types/node/async_hooks.d.ts","../node_modules/@types/node/buffer.d.ts","../node_modules/@types/node/child_process.d.ts","../node_modules/@types/node/cluster.d.ts","../node_modules/@types/node/console.d.ts","../node_modules/@types/node/constants.d.ts","../node_modules/@types/node/crypto.d.ts","../node_modules/@types/node/dgram.d.ts","../node_modules/@types/node/diagnostics_channel.d.ts","../node_modules/@types/node/dns.d.ts","../node_modules/@types/node/dns/promises.d.ts","../node_modules/@types/node/domain.d.ts","../node_modules/@types/node/events.d.ts","../node_modules/@types/node/fs.d.ts","../node_modules/@types/node/fs/promises.d.ts","../node_modules/@types/node/http.d.ts","../node_modules/@types/node/http2.d.ts","../node_modules/@types/node/https.d.ts","../node_modules/@types/node/inspector.d.ts","../node_modules/@types/node/module.d.ts","../node_modules/@types/node/net.d.ts","../node_modules/@types/node/os.d.ts","../node_modules/@types/node/path.d.ts","../node_modules/@types/node/perf_hooks.d.ts","../node_modules/@types/node/process.d.ts","../node_modules/@types/node/punycode.d.ts","../node_modules/@types/node/querystring.d.ts","../node_modules/@types/node/readline.d.ts","../node_modules/@types/node/repl.d.ts","../node_modules/@types/node/stream.d.ts","../node_modules/@types/node/stream/promises.d.ts","../node_modules/@types/node/stream/consumers.d.ts","../node_modules/@types/node/stream/web.d.ts","../node_modules/@types/node/string_decoder.d.ts","../node_modules/@types/node/timers.d.ts","../node_modules/@types/node/timers/promises.d.ts","../node_modules/@types/node/tls.d.ts","../node_modules/@types/node/trace_events.d.ts","../node_modules/@types/node/tty.d.ts","../node_modules/@types/node/url.d.ts","../node_modules/@types/node/util.d.ts","../node_modules/@types/node/v8.d.ts","../node_modules/@types/node/vm.d.ts","../node_modules/@types/node/wasi.d.ts","../node_modules/@types/node/worker_threads.d.ts","../node_modules/@types/node/zlib.d.ts","../node_modules/@types/node/globals.global.d.ts","../node_modules/@types/node/index.d.ts","../node_modules/@types/graceful-fs/index.d.ts","../node_modules/@types/istanbul-lib-coverage/index.d.ts","../node_modules/@types/istanbul-lib-report/index.d.ts","../node_modules/@types/istanbul-reports/index.d.ts","../node_modules/chalk/index.d.ts","../node_modules/@sinclair/typebox/typebox.d.ts","../node_modules/@jest/schemas/build/index.d.ts","../node_modules/pretty-format/build/index.d.ts","../node_modules/jest-diff/build/index.d.ts","../node_modules/jest-matcher-utils/build/index.d.ts","../node_modules/@types/jest/index.d.ts","../node_modules/@types/json-schema/index.d.ts","../node_modules/@types/json5/index.d.ts","../node_modules/@types/parse-json/index.d.ts","../node_modules/@types/prettier/index.d.ts","../node_modules/@types/react-test-renderer/index.d.ts","../node_modules/@types/scheduler/index.d.ts","../node_modules/@types/stack-utils/index.d.ts","../node_modules/@types/websocket/index.d.ts","../node_modules/@types/yargs-parser/index.d.ts","../node_modules/@types/yargs/index.d.ts"],"fileInfos":[{"version":"f5c28122bee592cfaf5c72ed7bcc47f453b79778ffa6e301f45d21a0970719d4","affectsGlobalScope":true},"dc47c4fa66b9b9890cf076304de2a9c5201e94b740cffdf09f87296d877d71f6","7a387c58583dfca701b6c85e0adaf43fb17d590fb16d5b2dc0a2fbd89f35c467","8a12173c586e95f4433e0c6dc446bc88346be73ffe9ca6eec7aa63c8f3dca7f9","5f4e733ced4e129482ae2186aae29fde948ab7182844c3a5a51dd346182c7b06","e6b724280c694a9f588847f754198fb96c43d805f065c3a5b28bbc9594541c84","1fc5ab7a764205c68fa10d381b08417795fc73111d6dd16b5b1ed36badb743d9",{"version":"3f149f903dd20dfeb7c80e228b659f0e436532de772469980dbd00702cc05cc1","affectsGlobalScope":true},{"version":"1272277fe7daa738e555eb6cc45ded42cc2d0f76c07294142283145d49e96186","affectsGlobalScope":true},{"version":"adb996790133eb33b33aadb9c09f15c2c575e71fb57a62de8bf74dbf59ec7dfb","affectsGlobalScope":true},{"version":"43fb1d932e4966a39a41b464a12a81899d9ae5f2c829063f5571b6b87e6d2f9c","affectsGlobalScope":true},{"version":"cdccba9a388c2ee3fd6ad4018c640a471a6c060e96f1232062223063b0a5ac6a","affectsGlobalScope":true},{"version":"c5c05907c02476e4bde6b7e76a79ffcd948aedd14b6a8f56e4674221b0417398","affectsGlobalScope":true},{"version":"0d5f52b3174bee6edb81260ebcd792692c32c81fd55499d69531496f3f2b25e7","affectsGlobalScope":true},{"version":"810627a82ac06fb5166da5ada4159c4ec11978dfbb0805fe804c86406dab8357","affectsGlobalScope":true},{"version":"181f1784c6c10b751631b24ce60c7f78b20665db4550b335be179217bacc0d5f","affectsGlobalScope":true},{"version":"3013574108c36fd3aaca79764002b3717da09725a36a6fc02eac386593110f93","affectsGlobalScope":true},{"version":"75ec0bdd727d887f1b79ed6619412ea72ba3c81d92d0787ccb64bab18d261f14","affectsGlobalScope":true},{"version":"3be5a1453daa63e031d266bf342f3943603873d890ab8b9ada95e22389389006","affectsGlobalScope":true},{"version":"17bb1fc99591b00515502d264fa55dc8370c45c5298f4a5c2083557dccba5a2a","affectsGlobalScope":true},{"version":"7ce9f0bde3307ca1f944119f6365f2d776d281a393b576a18a2f2893a2d75c98","affectsGlobalScope":true},{"version":"6a6b173e739a6a99629a8594bfb294cc7329bfb7b227f12e1f7c11bc163b8577","affectsGlobalScope":true},{"version":"12a310447c5d23c7d0d5ca2af606e3bd08afda69100166730ab92c62999ebb9d","affectsGlobalScope":true},{"version":"b0124885ef82641903d232172577f2ceb5d3e60aed4da1153bab4221e1f6dd4e","affectsGlobalScope":true},{"version":"0eb85d6c590b0d577919a79e0084fa1744c1beba6fd0d4e951432fa1ede5510a","affectsGlobalScope":true},{"version":"da233fc1c8a377ba9e0bed690a73c290d843c2c3d23a7bd7ec5cd3d7d73ba1e0","affectsGlobalScope":true},{"version":"d154ea5bb7f7f9001ed9153e876b2d5b8f5c2bb9ec02b3ae0d239ec769f1f2ae","affectsGlobalScope":true},{"version":"bb2d3fb05a1d2ffbca947cc7cbc95d23e1d053d6595391bd325deb265a18d36c","affectsGlobalScope":true},{"version":"c80df75850fea5caa2afe43b9949338ce4e2de086f91713e9af1a06f973872b8","affectsGlobalScope":true},{"version":"9d57b2b5d15838ed094aa9ff1299eecef40b190722eb619bac4616657a05f951","affectsGlobalScope":true},{"version":"6c51b5dd26a2c31dbf37f00cfc32b2aa6a92e19c995aefb5b97a3a64f1ac99de","affectsGlobalScope":true},{"version":"6e7997ef61de3132e4d4b2250e75343f487903ddf5370e7ce33cf1b9db9a63ed","affectsGlobalScope":true},{"version":"2ad234885a4240522efccd77de6c7d99eecf9b4de0914adb9a35c0c22433f993","affectsGlobalScope":true},{"version":"09aa50414b80c023553090e2f53827f007a301bc34b0495bfb2c3c08ab9ad1eb","affectsGlobalScope":true},{"version":"d7f680a43f8cd12a6b6122c07c54ba40952b0c8aa140dcfcf32eb9e6cb028596","affectsGlobalScope":true},{"version":"3787b83e297de7c315d55d4a7c546ae28e5f6c0a361b7a1dcec1f1f50a54ef11","affectsGlobalScope":true},{"version":"e7e8e1d368290e9295ef18ca23f405cf40d5456fa9f20db6373a61ca45f75f40","affectsGlobalScope":true},{"version":"faf0221ae0465363c842ce6aa8a0cbda5d9296940a8e26c86e04cc4081eea21e","affectsGlobalScope":true},{"version":"06393d13ea207a1bfe08ec8d7be562549c5e2da8983f2ee074e00002629d1871","affectsGlobalScope":true},{"version":"cd483c056da900716879771893a3c9772b66c3c88f8943b4205aec738a94b1d0","affectsGlobalScope":true},{"version":"b248e32ca52e8f5571390a4142558ae4f203ae2f94d5bac38a3084d529ef4e58","affectsGlobalScope":true},{"version":"c37f8a49593a0030eecb51bbfa270e709bec9d79a6cc3bb851ef348d4e6b26f8","affectsGlobalScope":true},"14a84fbe4ec531dcbaf5d2594fd95df107258e60ae6c6a076404f13c3f66f28e",{"version":"1c0e04c54479b57b49fec4e93556974b3d071b65d0b750897e07b3b7d2145fc5","affectsGlobalScope":true},"bc1852215dc1488e6747ca43ae0605041de22ab9a6eeef39542d29837919c414","ae6da60c852e7bacc4a49ff14a42dc1a3fdbb44e11bd9b4acb1bf3d58866ee71",{"version":"0dab023e564abb43c817779fff766e125017e606db344f9633fdba330c970532","affectsGlobalScope":true},"4cbd76eafece5844dc0a32807e68047aecbdd8d863edba651f34c050624f18df",{"version":"ecf78e637f710f340ec08d5d92b3f31b134a46a4fcf2e758690d8c46ce62cba6","affectsGlobalScope":true},"ea0aa24a32c073b8639aa1f3130ba0add0f0f2f76b314d9ba988a5cb91d7e3c4","f7b46d22a307739c145e5fddf537818038fdfffd580d79ed717f4d4d37249380","f5a8b384f182b3851cec3596ccc96cb7464f8d3469f48c74bf2befb782a19de5",{"version":"29b8a3a533884705024eab54e56465614ad167f5dd87fdc2567d8e451f747224","affectsGlobalScope":true},"4f2490e3f420ea6345cade9aee5eada76888848e053726956aaf2af8705477ea","b3ac03d0c853c0ac076a10cfef4dc21d810f54dac5899ade2b1c628c35263533","d17a689ac1bd689f37d6f0d3d9a21afac349e60633844044f7a7b7b9d6f7fd83","019650941b03d4978f62d21ae874788a665c02b54e3268ef2029b02d3b4f7561","ae591c8a4d5c7f7fa44b6965016391457d9c1fd763475f68340599a2a2987a24","fbdef0c642b82cc1713b965f07b4da8005bbbb2c026039bfdc15ca2d20769e38","c2c004e7f1a150541d06bc4a408b96e45ac1f08e0b1b35dfd07fc0f678205f95","1f2081eb2cbeb0828f9baa1dd12cf6d207f8104ae0b085ab9975d11adc7f7e6f","cda9069fc4c312ff484c1373455e4297a02d38ae3bd7d0959aad772a2809623c","c028d20108bcaa3b1fdf3514956a8a90ccf680f18672fa3c92ce5acf81d7ab23","1054f6e8774a75aaf17e7cfea4899344f69590b2db1e06da21048ed1e063c693","9533301b8f75664e1b40a8484a4fd9c77efc04aef526409c2447aab7d12ddc63","b78b5b3fdb4e30976c4263c66c0ad38fb81edcc8075a4160a39d99c6dedd35be","032b51d656feaece529823992f5a39fe9e24d44dfa21b3a149982f7787fc7bdf","5bbfdfb694b019cb2a2022fba361a7a857efc1fc2b77a892c92ebc1349b7e984","46bc25e3501d321a70d0878e82a1d47b16ab77bdf017c8fecc76343f50806a0d","42bacb33cddecbcfe3e043ee1117ba848801749e44f947626765b3e0aec74b1c","49dba0d7a37268e6ae2026e84ad4362eac7e776d816756abf649be7fa177dcd5","5f2b5ab209daae571eb9acc1fd2067ccc94e2a13644579a245875bc4f02b562f","f072acf9547f89b814b9fdb3e72f4ebb1649191591cec99db43d35383906f87f","42450dba65ba1307f27c914a8e45e0b602c6f8f78773c052e42b0b87562f081e","f5870d0ca7b0dfb7e2b9ba9abad3a2e2bffe5c711b53dab2e6e76ca2df58302b","aeb20169389e9f508b1a4eb2a30371b64d64bb7c8543120bc39a3c6b78adfcc9","2a3d3acbab8567057a943f9f56113c0144f5fc561623749fbd6bb5c2b33bf738","9cf21fdcd1beb5142a514887133fa59057e06275bb3070713f3b6d51e830ffa0","0ad4f0b67db47064b404df89c50f99552ce12d6c4bb6154255be61eb6beed094","f8a464b9999126fe1095968c266c0d9c6174612cf256379a1ed1993a87bccdc6","49f981ca657ac160b5de5919ee5602d48bc8f8aac0805107c2ce4fd41dc9a2a1","56e4e08d95a3a7886266a2b4f66b67065c340480d9f1beb73ed7578aa83c639a","eb4360d3818dcd879ee965ae2f4b3fdfdc4149db921b6be338cb7dc7c2bd6710","1c1275f325f13af001aa5873418cb497a26b4b8271f9ad20a45e33f61ea3f9d9","b33e8426136c4f9b349b02c940d23310d350179f790899733aa097ed76457061","05aab001669a230a88820be09a54031c45d9af2488b27d53d4a9c8880ce73e8f","d93a066d4b8b33335dfff910fb25abb8979f8814f8ba45ea902a1360907da1f6","41e97e42d182b4d5f0733ebaad69294faaa507d95e595f317168b8f2325da9ca","debc734fc99b6e1684ed565946bad008913c769d4d2e400d8722c0c23d079c06","5a9f7e087aacb01fa0cdbc36b703a60367239f62beed2507a507199e4c417549","c7c23798fbf564983ed69c1ced3371970d986aaed4801a6e0fb41862550dc034","921f5bce372610ae8948ade7d82decbd2cf56d263de578976189585edd0abac0","ac11f8b13beef593e2f097450a7e214b23dca0d428babd570a2f39582f10e9ab","2499beb5d3e2b4c606977bcc2e08b6ef77b2ecda70e78e0622f5af3bed95c9ba","a11057410396907b84051cbdb8b0cd7f7049d72b58d2b6ac1c14ac2608191a52","bb630c26d487cc45ed107f4f2d3c2a95434716f6367f059de734c40d288c31eb","67cbce0ccdfa96b25de478a93cc493266c152e256c3c96b3d16d1f811e3d881f","19905c928bc4c016d05d915625bb08568447266c4661232faf89f7ddc4417ccc","26204eb4c326e8c975f1b789cbf345c6820205bded6d72e57246a83918d3bc84","618f25b2d41a99216e71817a3bc578991eee86c858c3f0f62a9e70707f4d279d","4cd2947878536ec078e4115b7d53cdcd4dcecd3a8288760caa79098db4f8f61f","2129e984399e94c82b77a32b975f3371ca5ee96341ab9f123474f1a5a1a9921f","798120aaa4952d68cd4b43d6625524c62a135c2f5a3eb705caee98de2355230d","6047365397173788c34bd71fea2bf07a9036d981212efd059b33e52d2c405e97","d7e25d7c03ccf8b10972c2a3a57e29a8d9024e6dbc4ac223baf633a6e8c7145c","6c2e2dead2d80007ee44c429b925d0a7b86f8f3d4c237b2197f7db9f39545dc6","38fbc8f9610fbf4bf619854b26e28c4fbbab16dc1944c4317a4af9bf1ac08d8e","1bd0470a72e6869c330e6e978f15ef32ba2c245249aca097b410448152e8a06b","dd05d7970a92b789f7df3b2252574b2e60f1b9a3758e2839e167b498b8f77159","7092be1889127b2f319efd5d9bdcc0b5cf6fe0740e47247ed039446045518898","0a3d5dbf7c2091017e697ebf9af0a727571f5d99cb4c19e6856212a745c6c355","d05f9c767924db6fb89f6075acb64c042cebdb12779bbd1aaca12c850b772d49","d032678e20ff0f4b8ef6f1e4823b6ae37931b776e8381676dc9141999909b3d7","3e4ab0e8e96e968ac84a2484104892c881ded1757acd81b5e969b6229851f54c","d43a36641f5812794a3b4a941e3dfb5fa070f9fff64cfd6daf5291cb962c8b05","32468df81188116040636844517fbe4f67fc37af4fe565c7592353df8e11d2f3","c12b5f9bf412c891cad443ef00a378ad2d3f1301f140943414308665a7d90af8","cf1b65c20036885ed99ce1c18aa0a0ed66f42acd6d415e99b48a8fa4105c23ed","173aec8be1be982c8244df6f94880d77a9b766c8c1ec3eb0af662c8dc6da7f2e","08188020373062e07955835a996fda1aff97a89e57d469edc6b9210bd9c8926f","cad5c2c0085a3e3b74f58aa199944b25ed8d24f93f51c99ebe2463e4f1694785","3e2d93a797c41ab081fbcd80e959b7c30d5d1c358f091c22a6ebe416ef7c5e19","c440df5735a3305e7db118bf821efb597c8318910861f735372846db9f7b506b","d6d8de719a75e5d2ed9dd9d6a99296d1337259e1c96166579db50797edd72ede","32b4c732e183bf5d123f88d526ac21b71a681089c18d2d761be342df31179d94","212d16020e7dce1b5509f3b9813de73612de57c6a3d74536714eb88787b96dc3","1a63d5212341783aa49cf78d667bf2a6cd03208ea09620b2fc3e647ae07f4e0d","84ea58841272970e6e3247cba4dbb326cf22764c2f4bbcb03f1c634315bbbcb5","86f9fbecdd848d02c90f861cc9839d8f3449c518a77e77ea65362f6a4126c63b","ecdaf317a4a1e7e3540e2f1b6aae38acd78dd99d564b52f98eea7358ac74416d","c30430960f1a0552b3cdaf1ef8164fdd4f289c782a8912df5180d57bc9ddfc03","a348081c01502c9f87d39d9e4b5dd58e1111b34c62686d6e569c595a0417bb35","eff69aee13c76502a16b756cde9c451fb4b5c4234052f3b3bee9dbfe92e1b1d5","9943f44400939f4ff008a882ff71162f70ba0c2f735c9743fd4645ef5c925fc4","b7836eba6c5173a1683aee8aa1771ff339e795cb9c21411590edb910274febe4","6fe447aa7e6fabc4f6c536f2997e3b1116b7f73dbe5bf3fc8d958bad434e4a84","15d3908d453d14be4dae760122ed5d74ad789a19f1fec2edd4034e57217436e9","ef00bc701f382da70870ab7721ed8f6552a38e332e60370b93cf340b6470845c","18891a02fa046e57b43a543dddc7212086fcb04ae6c8e8f28f8605dd3ccf57ed",{"version":"5980a888624dce1b0937a0d21c623f97056501bb61a8da29cbe07f1a0be2c9a8","affectsGlobalScope":true},"590a41ccab332c66a6aa62746612b03ceb2e92cc1da58c140e90fb7ff6e8c851","dc1d2996f23fe7f0da0b2c843e05c0ac170a36b51da11e58de089d344de93c3b","78ff01b50e7e9761f239527ec70b96171bccc28a08d909243e193db03b6f6983","ed18472ee2247563a26d754dd4c8bd66383013df13ce7c2927b03cab1a27b7e8","28ac9ac1fa163e5f2321fafa49b9931908c0076216ed3c82646d79abdf79775e","07dd4bed8ddab685f82a2125bf3aa41b42e36f28c16a5aec7357b727649076fb","fc15a2216f29b825747c0c3a54d6989518dd0f4aa0b580520e5526b4a47bec8f","c656d5baf3d4a8f358fc083db04b0fda8cb8503a613a9ba42327ecbd7909773c","397c2c81eaeae1388f7459699d7606feecfc304b212eb9113407c1315746a578","c2d923e9adc26a3efe5186f3a4a72413d24c80f03b306c68c30fa146690fb101","d34782833b7d5f72486a5fb926d3d96198706ed76aeaf1d435c748ebcf9169fc","b093e56054755189dd891ea832dec40d729d110a0a3f432fff5ea5ab1078cdde","98affe620e6230a3888b445c32376e4edbf6b1b376a71f2bf9c07bee11fcdd65","1e05491bef32ff48393d605d557152735899da3d9b111ba3588a1800f2927f4a","1ff7813974b1b9a0524c1e5a99aa52a05e79fc7df7749ada75ded8c53fe1b7e0","cd8c517f54d4ff3475755b290d741c8799df3265ce73d454d8fafe423f8ff749","bf431147b104ae92d61de6b43e9f25d27e8d3eaeaffd612e0c0d3bb8e2423926","f0f21604ae8f880c0ab529f00303806fdeadc943e32a25ca063fc8fea0fa063c","8dc4f45212fba9381e1674e8bd934a588730efbb8a6681b661cad8cd09b081c5",{"version":"52bf774bd30177ebb3e450c808d8d46f67896848a942e6203ae78b65b33d0106","signature":"688c437017a53e69ff66aac2036a0d7f6263082f676a408c9998cbd87ea2ec73"},{"version":"8b6ee36fd764378c62dca37041c5a12fd5a77b9e853c78908b7ed1c90dc149e4","signature":"03846acca031c757d910dbc017d846c87574faf90bde82316fb9b8537896d5ee"},{"version":"0d089d33f31b56697d142aa7395738c0323cf761b4c79fd6bf65a54ab1ddf02f","signature":"027c87e1cb049497d4f185bc9b922ce91cad59832da8faf3411e6b298b9deb78"},{"version":"ec0982b9e7d6c1b6c80e2829c5909eefb9ecee687e60621e0bb937e8ad5d1d43","signature":"8478b617a5be940f1b4b4d19d2fc6149c21ac69c4a7e00c8a7db2c2c21aa2274"},{"version":"84c5fc9d0d22f4566791b88d5fc2c24f56508b50c9ce894ac549ebaa158b1fca","signature":"677ea66c6fa02f1cebf82df19f416a8302c7a7d10e2de265b162760fcd865eef"},{"version":"8455135ea42310a73404fa2513e212d170af1191584061f583ec1e0f6b75dd91","signature":"83e4298f0b6834e955ee6a76569d3e5b3192065d47f1daf4535bb9edb16e88cb"},{"version":"73529962207605bdc5285d5e745919b8d57b776daa0f22a14b75cd8a92d63af9","signature":"422fcd2a7fd87f05efdfaa6eab382ca607d5d54e1f175ba2efccd4aacd5433ef"},{"version":"ebe927d8a9739c9d32ef4df28c1c36cf82daa9abba7cdf3f79e320c5e99e99d8","signature":"2421f9c6b1ecedd50818719090a77e9d2748c2339c33f3d4817beebf7a39d211"},{"version":"165c56632fea46c85e2a62f1b4eae600b846ea0deacd3c137fde9bacb845c30e","signature":"79bf9e3846b43e706d181c00f3c1c50ae8fc60e587c97a16e521adc150317624"},{"version":"866e1d2cf16a41851b056a2cc0cdc5f0f00df0435376cc2c723a8c609f61fbd0","signature":"5f5bbca60f0bfed6ff714163c4e962a5e260e59db754c89ee2063403accd03e3"},{"version":"ecfa1b63e3829b310ac968b2cc1cc7016ba76ffb8532439aebecbcbc57173b99","signature":"2f1dda63ade2bd085704674523b56ede942bc8c2c37fe8ed9b9b0fdfd69b1262"},{"version":"51d2f746d7e599a5549f5a946565934b4556bb9155be1eed2c474e25f1474872","signature":"c15585fe8935ed5cfedec39b7d41ec49990973f40faaba4b3e14278861643d79"},{"version":"b1d1378906c54a2f4d230ad69d212beedd2552afe3f7ad171b7eacb4cecc26d7","signature":"f9e60e8f79a7f606f19e02e2d39a24995719767dbe587f564f970bb24e3ca29d"},{"version":"f5a156e5b3783ea0399ac0326b7ab31a00e8874c5fa9b5e26fac217da8b5adfd","signature":"cfa7179e0306fc04d93f062c96e7ae8bad58d0cc4a7aa0dd4494ff9d262b101c"},{"version":"3c9fefca9303bcfd5712de11a3cbda20b3d6e85f29019bc75cab24690fb0f90d","signature":"306683152ff5a6038cf05b03ddff85a15b1bc8e18ef268aad26b02fd8e0e8b9d"},"a11c3e55d22d6379fe0949793e2638a6b43aa6e9def4f7452c3e352a296ef8da",{"version":"2770956c9437d7d66650084891c559ff6bb94200b7e2820940fd5d5dd0efa489","signature":"2faaf4f254008bf5be0e145be10dba35dccfac7116e9083f9d697a476a8e7076"},{"version":"ceee917fd557b841b93f7e13103dfdad79d38fe9962408f538f27db03dc9368d","signature":"15003ff6ed10d259dca775c7e5f7a64b272a9c370b6085db2d42a2d4a1d81579"},{"version":"a1691ae6d70af82f3e26d9e2e021dc5063021bd9c335bfdb40dc97d3574d1b3f","signature":"cd1c566b611a70ff987a79d0465da67649a8ed7e7668feddfcdf6dceb01c09a8"},{"version":"a105417dd540f1a400f0665c877e5d7e48e2efe08f01c2e5c7272256e644faa5","signature":"b3a6ee392811d6cddb38378ebaa373d4a39aa7dc4ecac73497c6b33627e6430b"},{"version":"581b44cf6122e3ad267d6bda2428c214fef3d38b6d7249df9fa6bc240a880a78","signature":"0ca09d92d6469d906a3d1c7192a6294c7f65b75f4f7eb8072bbd1b68c7f021e1"},{"version":"2e6426c1a1ff8561aa5f01d9398426bf06e55307f688464939de3196f0d4c143","signature":"5357bd09c9816a9765e617f86a9b49f85133d0bc0f9c5e29e834f2f8e6d52acb"},{"version":"508279c48de5627ae6c30a0aee01f4391bf32450335d7f09d5dd82acbc4d13c5","signature":"11d546a505f70f9c5f8092916027d8045c280a817b709fcaf2c4e63fa026c89c"},{"version":"557f2e0a4e5ac8a59b7c3068b2b30162fb963d72d50152482ab8c414e81caf37","signature":"008eaae28119118f1c589a1e29ea7fd17277f2280d2d3bfddeacd71fd1671bb5"},{"version":"f45c172ca54fb28d64b2dd04e495f17038034e20f37bd286a9f3eeb286cf1388","signature":"75a8761564c8fc5581b062dd339ea698921baf60e52eae055c8177dfa89eba90"},{"version":"ea696a0517ad69afea472e47eb1f904aba1667f54d4557eb98b8c766469d56a2","signature":"7e125d9abc19f62d1480f6c04a45d7bb2c89153316245ae8b8e5a0234b078c4e"},{"version":"902937c505f88d8b5b32829b4c14243eb740013fd0e2f58e6485324bbfe197a6","signature":"dc7de7650e5a64fc010387db18e84d48fe8f562dbd9caac01e54f83681ac976b"},{"version":"842accda78bb1b6f494f264aae307b84d933486d607e91f6e1d3a4d2e4851783","signature":"430d9683c8e5aaab71f0e3b271c4240cd5120a91191f953722985499af51d7e6"},{"version":"45b1a895868587c78a2ddff937967669b4e1968ea72c01e1c2b6dd5993f53b36","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"99cab9373415bac71e9d2c84279782c0a361b59551d0ca8dfaee8d4c08ed3247","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},"ba1fed463e8a21ffddb67a53df3f0d818b351991723736e167d065e2de1c7183",{"version":"22e311fec88bcc49b2b1fb3c9a7c082cd84b3388c9bcc7b9ef08253f6fa74e26","affectsGlobalScope":true},"c186097fd9b86681981cdeba08c0b6bbfcd8b562ab490c25656d85fef8f10c79","0b0c483e991e81c3f26e5f2da53ff26a15994c98c8b89cda1e4156dfc2428111","3340eb7b30bdee5f0349107d4068fd6f2f4712e11a2ba68e203b2f2489350317",{"version":"2000d60bd5195730ffff0d4ce9389003917928502c455ed2a9e296d3bf1a4b42","signature":"56335d3c9b867cc8654c05e633c508dd8de0038157f9958eb8794b7c123bb90e"},{"version":"dfceb5b9355a4a9002a7c291b1c3315511977c73cb23d9c123a72567783a18c0","signature":"b1802850887a3ea11a06df1fc1c65c6579332eefba1e63b3967a73dc937a2574"},{"version":"384fc0e3fa5966f524c96f1782b9d7a005346ba1621c43d0d1d819bf39077fbc","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"7fde517b3f03bb21ec3a46ba5f85c6797f8abf27deacb862183126e2f072788e","signature":"8b310edcfec83da25bc4f3adb20a7583bc5dae56d7d06c5b1431b76d390c1b72"},{"version":"894d93831d2afcd26f7362347e4960dd6d53f4153dad08813f3670e1327e387c","signature":"b1802850887a3ea11a06df1fc1c65c6579332eefba1e63b3967a73dc937a2574"},{"version":"8f9eac2c3ae305c25d4ffeff800b9811c8d3ec6a11b142fe96d08a2bc40f6440","signature":"08d6a2d1b004bbcac4249cd5baf6e9c662adc6139939c266b42e0422ef0c68b3"},{"version":"ac8980bdd810c30c444b59cca584c9b61d5ab274fa9474d778970537f3090240","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"1c024431c672cf9c6dcdb4d30c5b625435d81a5423b9d45e8de0082e969af8a8","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"eee1b57475023853cd09dd79b8d0d6639b6b82c3baee5863c2f2022b710f4102","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"377ba49d29102653a4b0c72b3870f9c599575df7db3a3fae7a21be5327ff84e2","signature":"c47f5db4df0a5031ed84bc6ee192c412b9e2d4d5e94681af77ccdcc25c851839"},{"version":"377ba49d29102653a4b0c72b3870f9c599575df7db3a3fae7a21be5327ff84e2","signature":"c47f5db4df0a5031ed84bc6ee192c412b9e2d4d5e94681af77ccdcc25c851839"},{"version":"39833acf7547216b2f31b2279dcfec3ed1359dec8adc9d1cb87c695ebf9bff94","signature":"7292d4dc9dac6d815dc30245a4a4a4959845d3a2b84ba0166857e4b23f2d033f"},{"version":"39833acf7547216b2f31b2279dcfec3ed1359dec8adc9d1cb87c695ebf9bff94","signature":"7292d4dc9dac6d815dc30245a4a4a4959845d3a2b84ba0166857e4b23f2d033f"},{"version":"529dd364d169ab3dbbb177ccdc4987c4a6f69187f553f3d36460ab65879ad998","signature":"3919e9d5911da2254732c31942e2cdc0057056ebfc2a16d34041c76a9b58d447"},{"version":"ebea587ca6477b9db29baf75d359924c55ab490fecdc38d7c0f16e589f0d27f9","signature":"0688c25f38e78e052338305d23046c7841074b3da5709a8f9e598ed705b9932b"},{"version":"de411013305dbe5c7a1ac13d2ea16dc36e52e6efd255b4e912fe53862058c649","signature":"2faaf4f254008bf5be0e145be10dba35dccfac7116e9083f9d697a476a8e7076"},"e432b56911b58550616fc4d54c1606f65fe98c74875b81d74601f5f965767c60","cc957354aa3c94c9961ebf46282cfde1e81d107fc5785a61f62c67f1dd3ac2eb","a46a2e69d12afe63876ec1e58d70e5dbee6d3e74132f4468f570c3d69f809f1c","93de1c6dab503f053efe8d304cb522bb3a89feab8c98f307a674a4fae04773e9","3b043cf9a81854a72963fdb57d1884fc4da1cf5be69b5e0a4c5b751e58cb6d88","dd5647a9ccccb2b074dca8a02b00948ac293091ebe73fdf2e6e98f718819f669","0cba3a5d7b81356222594442753cf90dd2892e5ccfe1d262aaca6896ba6c1380","a69c09dbea52352f479d3e7ac949fde3d17b195abe90b045d619f747b38d6d1a",{"version":"c2ab70bbc7a24c42a790890739dd8a0ba9d2e15038b40dff8163a97a5d148c00","affectsGlobalScope":true},"422dbb183fdced59425ca072c8bd09efaa77ce4e2ab928ec0d8a1ce062d2a45a",{"version":"712ba0d43b44d144dfd01593f61af6e2e21cfae83e834d297643e7973e55ed61","affectsGlobalScope":true},"1dab5ab6bcf11de47ab9db295df8c4f1d92ffa750e8f095e88c71ce4c3299628","f71f46ccd5a90566f0a37b25b23bc4684381ab2180bdf6733f4e6624474e1894",{"version":"54e65985a3ee3cec182e6a555e20974ea936fc8b8d1738c14e8ed8a42bd921d4","affectsGlobalScope":true},"82408ed3e959ddc60d3e9904481b5a8dc16469928257af22a3f7d1a3bc7fd8c4","98a3ebfa494b46265634a73459050befba5da8fdc6ca0ef9b7269421780f4ff3","34e5de87d983bc6aefef8b17658556e3157003e8d9555d3cb098c6bef0b5fbc8","cc0b61316c4f37393f1f9595e93b673f4184e9d07f4c127165a490ec4a928668","f27371653aded82b2b160f7a7033fb4a5b1534b6f6081ef7be1468f0f15327d3","c762cd6754b13a461c54b59d0ae0ab7aeef3c292c6cf889873f786ee4d8e75c9","f4ea7d5df644785bd9fbf419930cbaec118f0d8b4160037d2339b8e23c059e79",{"version":"bfea28e6162ed21a0aeed181b623dcf250aa79abf49e24a6b7e012655af36d81","affectsGlobalScope":true},"7a5459efa09ea82088234e6533a203d528c594b01787fb90fba148885a36e8b6","ae97e20f2e10dbeec193d6a2f9cd9a367a1e293e7d6b33b68bacea166afd7792","10d4796a130577d57003a77b95d8723530bbec84718e364aa2129fa8ffba0378","ad41bb744149e92adb06eb953da195115620a3f2ad48e7d3ae04d10762dae197","bf73c576885408d4a176f44a9035d798827cc5020d58284cb18d7573430d9022","7ae078ca42a670445ae0c6a97c029cb83d143d62abd1730efb33f68f0b2c0e82",{"version":"e8b18c6385ff784228a6f369694fcf1a6b475355ba89090a88de13587a9391d5","affectsGlobalScope":true},"5d0a9ea09d990b5788f867f1c79d4878f86f7384cb7dab38eecbf22f9efd063d","12eea70b5e11e924bb0543aea5eadc16ced318aa26001b453b0d561c2fd0bd1e","08777cd9318d294646b121838574e1dd7acbb22c21a03df84e1f2c87b1ad47f2","08a90bcdc717df3d50a2ce178d966a8c353fd23e5c392fd3594a6e39d9bb6304",{"version":"4cd4cff679c9b3d9239fd7bf70293ca4594583767526916af8e5d5a47d0219c7","affectsGlobalScope":true},"2a12d2da5ac4c4979401a3f6eaafa874747a37c365e4bc18aa2b171ae134d21b","002b837927b53f3714308ecd96f72ee8a053b8aeb28213d8ec6de23ed1608b66","1dc9c847473bb47279e398b22c740c83ea37a5c88bf66629666e3cf4c5b9f99c","a9e4a5a24bf2c44de4c98274975a1a705a0abbaad04df3557c2d3cd8b1727949","00fa7ce8bc8acc560dc341bbfdf37840a8c59e6a67c9bfa3fa5f36254df35db2","1b952304137851e45bc009785de89ada562d9376177c97e37702e39e60c2f1ff",{"version":"806ef4cac3b3d9fa4a48d849c8e084d7c72fcd7b16d76e06049a9ed742ff79c0","affectsGlobalScope":true},"44b8b584a338b190a59f4f6929d072431950c7bd92ec2694821c11bce180c8a5","5f0ed51db151c2cdc4fa3bb0f44ce6066912ad001b607a34e65a96c52eb76248",{"version":"3345c276cab0e76dda86c0fb79104ff915a4580ba0f3e440870e183b1baec476","affectsGlobalScope":true},"664d8f2d59164f2e08c543981453893bc7e003e4dfd29651ce09db13e9457980","e383ff72aabf294913f8c346f5da1445ae6ad525836d28efd52cbadc01a361a6","f52fbf64c7e480271a9096763c4882d356b05cab05bf56a64e68a95313cd2ce2","59bdb65f28d7ce52ccfc906e9aaf422f8b8534b2d21c32a27d7819be5ad81df7",{"version":"3a2da34079a2567161c1359316a32e712404b56566c45332ac9dcee015ecce9f","affectsGlobalScope":true},"28a2e7383fd898c386ffdcacedf0ec0845e5d1a86b5a43f25b86bc315f556b79","3aff9c8c36192e46a84afe7b926136d520487155154ab9ba982a8b544ea8fc95","a880cf8d85af2e4189c709b0fea613741649c0e40fffb4360ec70762563d5de0","85bbf436a15bbeda4db888be3062d47f99c66fd05d7c50f0f6473a9151b6a070","9f9c49c95ecd25e0cb2587751925976cf64fd184714cb11e213749c80cf0f927","f0c75c08a71f9212c93a719a25fb0320d53f2e50ca89a812640e08f8ad8c408c",{"version":"ab9b9a36e5284fd8d3bf2f7d5fcbc60052f25f27e4d20954782099282c60d23e","affectsGlobalScope":true},"9cafe917bf667f1027b2bb62e2de454ecd2119c80873ad76fc41d941089753b8","3ebae8c00411116a66fca65b08228ea0cf0b72724701f9b854442100aab55aba","8b06ac3faeacb8484d84ddb44571d8f410697f98d7bfa86c0fda60373a9f5215","7eb06594824ada538b1d8b48c3925a83e7db792f47a081a62cf3e5c4e23cf0ee","f5638f7c2f12a9a1a57b5c41b3c1ea7db3876c003bab68e6a57afd6bcc169af0","0d14fa22c41fdc7277e6f71473b20ebc07f40f00e38875142335d5b63cdfc9d2","7980bf9d2972585cdf76b5a72105f7817be0723ccb2256090f6335f45b462abe","301d7466eb591139c7d456958f732153b3400f3243f68d3321956b43a64769e9","22f13de9e2fe5f0f4724797abd3d34a1cdd6e47ef81fc4933fea3b8bf4ad524b","e3ba509d3dce019b3190ceb2f3fc88e2610ab717122dabd91a9efaa37804040d","cda0cb09b995489b7f4c57f168cd31b83dcbaa7aad49612734fb3c9c73f6e4f2",{"version":"2abad7477cf6761b55c18bea4c21b5a5dcf319748c13696df3736b35f8ac149e","affectsGlobalScope":true},"d38e588a10943bbab1d4ce03d94759bf065ff802a9a72fc57aa75a72f1725b71","96d14f21b7652903852eef49379d04dbda28c16ed36468f8c9fa08f7c14c9538","2b8264b2fefd7367e0f20e2c04eed5d3038831fe00f5efbc110ff0131aab899b","6209c901f30cc321f4b86800d11fad3d67e73a3308f19946b1bc642af0280298","60aaac5fb1858fbd4c4eb40e01706eb227eed9eca5c665564bd146971280dbd3","74b0245c42990ed8a849df955db3f4362c81b13f799ebc981b7bec2d5b414a57","b0d10e46cfe3f6c476b69af02eaa38e4ccc7430221ce3109ae84bb9fb8282298","4266ccd2cf1d6a281efd9c7ddf9efd7daecf76575364148bd233e18919cac3ed","70e9a18da08294f75bf23e46c7d69e67634c0765d355887b9b41f0d959e1426e","105b9a2234dcb06ae922f2cd8297201136d416503ff7d16c72bfc8791e9895c1"],"options":{"composite":true,"declaration":true,"declarationMap":true,"downlevelIteration":true,"esModuleInterop":true,"experimentalDecorators":true,"importHelpers":true,"jsx":2,"noEmitOnError":false,"noImplicitAny":true,"noUnusedLocals":true,"outDir":"./","rootDir":"../src","skipLibCheck":true,"sourceMap":true,"strictNullChecks":true,"target":1,"tsBuildInfoFile":"./tsconfig.tsbuildinfo"},"fileIdsList":[[211,260],[260],[260,273],[53,190,260],[192,194,260],[190,192,193,260],[53,260],[53,140,260],[69,260],[211,212,213,214,215,260],[211,213,260],[233,260,267],[260,269],[260,270],[260,275,277],[217,260],[220,260],[221,226,260],[222,232,233,240,249,259,260],[222,223,232,240,260],[224,260],[225,226,233,241,260],[226,249,256,260],[227,229,232,240,260],[228,260],[229,230,260],[231,232,260],[232,260],[232,233,234,249,259,260],[232,233,234,249,260],[235,240,249,259,260],[232,233,235,236,240,249,256,259,260],[235,237,249,256,259,260],[217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266],[232,238,260],[239,259,260],[229,232,240,249,260],[241,260],[242,260],[220,243,260],[244,258,260,264],[245,260],[246,260],[232,247,260],[247,248,260,262],[232,249,250,251,260],[249,251,260],[249,250,260],[252,260],[253,260],[232,254,255,260],[254,255,260],[226,240,249,256,260],[257,260],[240,258,260],[221,235,246,259,260],[226,260],[249,260,261],[260,262],[260,263],[221,226,232,234,243,249,259,260,262,264],[249,260,265],[76,77,260],[53,58,64,65,68,71,72,73,76,260],[74,260],[84,260],[53,57,82,260],[53,54,57,58,62,75,76,260],[53,76,105,106,260],[53,54,57,58,62,76,260],[82,91,260],[53,54,62,75,76,93,260],[53,55,58,61,62,65,75,76,260],[53,54,57,62,76,260],[53,54,57,62,260],[53,54,55,58,60,62,63,75,76,260],[53,76,260],[53,75,76,260],[53,54,57,58,61,62,75,76,82,93,260],[53,55,58,260],[53,54,57,60,75,76,93,103,260],[53,54,60,76,103,105,260],[53,54,57,60,62,93,103,260],[53,54,55,58,60,61,75,76,93,260],[58,260],[53,55,58,59,60,61,75,76,260],[82,260],[83,260],[53,54,55,57,58,61,66,67,75,76,260],[58,59,260],[53,64,65,70,75,76,260],[53,56,64,70,75,76,260],[53,58,62,260],[53,118,260],[53,57,260],[57,260],[76,260],[75,260],[66,74,76,260],[53,54,57,58,61,75,76,260],[128,260],[53,56,57,260],[91,260],[44,45,46,47,48,55,56,57,58,59,60,61,62,63,64,65,66,67,68,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,260],[140,260],[46,260],[49,50,51,52,260],[232,235,237,240,259,260,267],[260,287],[260,275],[260,272,276],[260,274],[151,260],[53,141,142,143,144,146,147,148,149,150,157,260],[53,140,147,150,151,260],[143,144,260],[142,143,144,147,260],[143,260],[155,260],[157,260],[144,260],[53,144,260],[141,142,143,144,145,146,147,149,150,151,152,153,154,156,260],[149,260],[158,260],[43,140,166,177,260],[43,53,140,157,159,162,163,164,166,168,169,170,171,172,174,176,260],[43,53,140,162,165,260],[43,157,166,167,168,260],[43,53,140,165,166,168,170,171,177,260],[43,53,260],[43,167,260],[43,168,260],[43,53,140,157,162,163,166,172,191,195,260],[43,140,157,177,195,260],[43,53,140,157,177,179,191,198,260],[43,200,260],[43,157,170,171,173,260],[43,53,140,166,177,191,194,260],[43,53,140,166,179,191,194,260],[43,53,177,183,194,195,260],[43,260],[43,181,260],[43,53,177,180,181,182,183,260],[43,53,157,162,177,260],[43,53,140,180,182,184,260],[43,162,163,165,166,177,178,179,180,182,183,184,185,186,260],[43,53,140,160,161,260],[43,140,160,260],[43,140,260],[43,53,140,260],[43,157,260],[43,157,175,260],[43,53,140,157,175,260],[43,140,157,166,260],[43,140,157,170,171,260],[43,140,165,173,177,260],[53,140,166],[53,157,166,169],[53,140,162,165],[157,166],[53,140,166,177],[53],[191],[53,166,177,191,194],[53,166,179,191,194],[53,177,182,183,187],[53,162,177],[140,184],[162,163,165,166,177,178,179,180,182,183,184,185,186],[53,140],[140,160],[140],[157],[53,157],[140,157,166],[140,157],[177]],"referencedMap":[[213,1],[211,2],[274,3],[192,4],[193,5],[194,6],[191,4],[190,7],[69,8],[70,9],[273,2],[216,10],[212,1],[214,11],[215,1],[268,12],[269,2],[270,13],[271,14],[278,15],[279,2],[280,2],[217,16],[218,16],[220,17],[221,18],[222,19],[223,20],[224,21],[225,22],[226,23],[227,24],[228,25],[229,26],[230,26],[231,27],[232,28],[233,29],[234,30],[219,2],[266,2],[235,31],[236,32],[237,33],[267,34],[238,35],[239,36],[240,37],[241,38],[242,39],[243,40],[244,41],[245,42],[246,43],[247,44],[248,45],[249,46],[251,47],[250,48],[252,49],[253,50],[254,51],[255,52],[256,53],[257,54],[258,55],[259,56],[260,57],[261,58],[262,59],[263,60],[264,61],[265,62],[281,2],[282,2],[51,2],[78,63],[79,2],[74,64],[80,2],[81,65],[85,66],[86,2],[87,67],[88,68],[107,69],[89,2],[90,70],[92,71],[94,72],[95,73],[96,74],[63,74],[97,75],[64,76],[98,77],[99,68],[100,78],[101,79],[102,2],[60,80],[104,81],[106,82],[105,83],[103,84],[65,75],[61,85],[62,86],[108,2],[91,87],[83,87],[84,88],[68,89],[66,2],[67,2],[109,87],[110,90],[111,2],[112,71],[71,91],[72,92],[113,2],[114,93],[115,2],[116,2],[117,2],[119,94],[120,2],[56,7],[121,7],[122,95],[123,96],[124,2],[125,97],[127,97],[126,97],[76,98],[75,99],[77,97],[73,100],[128,2],[129,101],[58,102],[130,66],[131,66],[132,103],[133,87],[118,2],[134,2],[135,2],[136,2],[137,7],[138,2],[82,2],[140,104],[44,2],[45,105],[46,106],[48,2],[47,2],[93,2],[54,2],[139,105],[55,2],[59,85],[57,7],[283,7],[49,2],[53,107],[284,2],[52,2],[285,2],[286,108],[287,2],[288,109],[272,2],[50,2],[276,110],[277,111],[275,112],[149,2],[154,113],[151,114],[158,115],[147,116],[148,117],[141,2],[142,2],[145,116],[144,118],[156,119],[155,120],[153,116],[143,121],[146,122],[157,123],[175,124],[152,2],[150,7],[159,125],[43,2],[8,2],[9,2],[11,2],[10,2],[2,2],[12,2],[13,2],[14,2],[15,2],[16,2],[17,2],[18,2],[19,2],[3,2],[4,2],[23,2],[20,2],[21,2],[22,2],[24,2],[25,2],[26,2],[5,2],[27,2],[28,2],[29,2],[30,2],[6,2],[31,2],[32,2],[33,2],[34,2],[7,2],[35,2],[40,2],[41,2],[36,2],[37,2],[38,2],[39,2],[1,2],[42,2],[178,126],[177,127],[166,128],[169,129],[179,130],[164,131],[188,132],[189,133],[196,134],[197,135],[199,136],[201,137],[202,138],[195,139],[198,140],[203,141],[180,142],[182,143],[181,142],[184,144],[183,145],[185,142],[186,146],[170,142],[171,142],[172,142],[187,147],[162,148],[204,149],[205,149],[161,149],[160,131],[206,150],[207,150],[163,151],[208,131],[209,152],[210,152],[176,153],[200,154],[167,142],[168,155],[165,142],[173,156],[174,157]],"exportedModulesMap":[[213,1],[211,2],[274,3],[192,4],[193,5],[194,6],[191,4],[190,7],[69,8],[70,9],[273,2],[216,10],[212,1],[214,11],[215,1],[268,12],[269,2],[270,13],[271,14],[278,15],[279,2],[280,2],[217,16],[218,16],[220,17],[221,18],[222,19],[223,20],[224,21],[225,22],[226,23],[227,24],[228,25],[229,26],[230,26],[231,27],[232,28],[233,29],[234,30],[219,2],[266,2],[235,31],[236,32],[237,33],[267,34],[238,35],[239,36],[240,37],[241,38],[242,39],[243,40],[244,41],[245,42],[246,43],[247,44],[248,45],[249,46],[251,47],[250,48],[252,49],[253,50],[254,51],[255,52],[256,53],[257,54],[258,55],[259,56],[260,57],[261,58],[262,59],[263,60],[264,61],[265,62],[281,2],[282,2],[51,2],[78,63],[79,2],[74,64],[80,2],[81,65],[85,66],[86,2],[87,67],[88,68],[107,69],[89,2],[90,70],[92,71],[94,72],[95,73],[96,74],[63,74],[97,75],[64,76],[98,77],[99,68],[100,78],[101,79],[102,2],[60,80],[104,81],[106,82],[105,83],[103,84],[65,75],[61,85],[62,86],[108,2],[91,87],[83,87],[84,88],[68,89],[66,2],[67,2],[109,87],[110,90],[111,2],[112,71],[71,91],[72,92],[113,2],[114,93],[115,2],[116,2],[117,2],[119,94],[120,2],[56,7],[121,7],[122,95],[123,96],[124,2],[125,97],[127,97],[126,97],[76,98],[75,99],[77,97],[73,100],[128,2],[129,101],[58,102],[130,66],[131,66],[132,103],[133,87],[118,2],[134,2],[135,2],[136,2],[137,7],[138,2],[82,2],[140,104],[44,2],[45,105],[46,106],[48,2],[47,2],[93,2],[54,2],[139,105],[55,2],[59,85],[57,7],[283,7],[49,2],[53,107],[284,2],[52,2],[285,2],[286,108],[287,2],[288,109],[272,2],[50,2],[276,110],[277,111],[275,112],[149,2],[154,113],[151,114],[158,115],[147,116],[148,117],[141,2],[142,2],[145,116],[144,118],[156,119],[155,120],[153,116],[143,121],[146,122],[157,123],[175,124],[152,2],[150,7],[159,125],[43,2],[8,2],[9,2],[11,2],[10,2],[2,2],[12,2],[13,2],[14,2],[15,2],[16,2],[17,2],[18,2],[19,2],[3,2],[4,2],[23,2],[20,2],[21,2],[22,2],[24,2],[25,2],[26,2],[5,2],[27,2],[28,2],[29,2],[30,2],[6,2],[31,2],[32,2],[33,2],[34,2],[7,2],[35,2],[40,2],[41,2],[36,2],[37,2],[38,2],[39,2],[1,2],[42,2],[178,158],[177,159],[166,160],[169,161],[179,162],[164,163],[196,164],[199,164],[195,165],[198,166],[184,167],[183,168],[186,169],[187,170],[162,171],[204,172],[205,172],[161,172],[160,163],[206,173],[207,173],[163,171],[208,163],[209,174],[210,174],[176,174],[200,175],[168,176],[173,177],[174,178]],"semanticDiagnosticsPerFile":[213,211,274,192,193,194,191,190,69,70,273,216,212,214,215,268,269,270,271,278,279,280,217,218,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,219,266,235,236,237,267,238,239,240,241,242,243,244,245,246,247,248,249,251,250,252,253,254,255,256,257,258,259,260,261,262,263,264,265,281,282,51,78,79,74,80,81,85,86,87,88,107,89,90,92,94,95,96,63,97,64,98,99,100,101,102,60,104,106,105,103,65,61,62,108,91,83,84,68,66,67,109,110,111,112,71,72,113,114,115,116,117,119,120,56,121,122,123,124,125,127,126,76,75,77,73,128,129,58,130,131,132,133,118,134,135,136,137,138,82,140,44,45,46,48,47,93,54,139,55,59,57,283,49,53,284,52,285,286,287,288,272,50,276,277,275,149,154,151,158,147,148,141,142,145,144,156,155,153,143,146,157,175,152,150,159,43,8,9,11,10,2,12,13,14,15,16,17,18,19,3,4,23,20,21,22,24,25,26,5,27,28,29,30,6,31,32,33,34,7,35,40,41,36,37,38,39,1,42,178,177,166,169,179,164,188,189,196,197,199,201,202,195,198,203,180,182,181,184,183,185,186,170,171,172,187,162,204,205,161,160,206,207,163,208,209,210,176,200,167,168,165,173,174]},"version":"4.7.4"} \ No newline at end of file diff --git a/node_modules/@shopify/flash-list/ios/Sources/AutoLayoutView.swift b/node_modules/@shopify/flash-list/ios/Sources/AutoLayoutView.swift -index f18e92c..71b63dc 100644 +index f18e92c..f166553 100644 --- a/node_modules/@shopify/flash-list/ios/Sources/AutoLayoutView.swift +++ b/node_modules/@shopify/flash-list/ios/Sources/AutoLayoutView.swift @@ -4,31 +4,35 @@ import UIKit @@ -973,7 +973,7 @@ index f18e92c..71b63dc 100644 } else { assertionFailure("CellRendererComponent outer view should always be CellContainer. Learn more here: https://shopify.github.io/flash-list/docs/usage#cellrenderercomponent.") return nil -@@ -106,7 +128,7 @@ import UIKit +@@ -106,12 +128,16 @@ import UIKit /// Checks for overlaps or gaps between adjacent items and then applies a correction. /// Performance: RecyclerListView renders very small number of views and this is not going to trigger multiple layouts on the iOS side. @@ -982,7 +982,16 @@ index f18e92c..71b63dc 100644 var maxBound: CGFloat = 0 var minBound: CGFloat = CGFloat(Int.max) var maxBoundNextCell: CGFloat = 0 -@@ -192,7 +214,7 @@ import UIKit + let correctedScrollOffset = scrollOffset - (horizontal ? frame.minX : frame.minY) + lastMaxBoundOverall = 0 ++ if cellContainers.count == 1 { ++ let firstCellContainer = cellContainers[0] ++ lastMaxBoundOverall = horizontal ? firstCellContainer.frame.maxX : firstCellContainer.frame.maxY ++ } + cellContainers.indices.dropLast().forEach { index in + let cellContainer = cellContainers[index] + let cellTop = cellContainer.frame.minY +@@ -192,7 +218,7 @@ import UIKit lastMinBound = minBound } @@ -991,7 +1000,7 @@ index f18e92c..71b63dc 100644 lastMaxBoundOverall = max(lastMaxBoundOverall, horizontal ? currentCell.frame.maxX : currentCell.frame.maxY, horizontal ? nextCell.frame.maxX : nextCell.frame.maxY) } -@@ -217,7 +239,7 @@ import UIKit +@@ -217,7 +243,7 @@ import UIKit /// It's important to avoid correcting views outside the render window. An item that isn't being recycled might still remain in the view tree. If views outside get considered then gaps between unused items will cause algorithm to fail. func isWithinBounds( @@ -1000,21 +1009,18 @@ index f18e92c..71b63dc 100644 scrollOffset: CGFloat, renderAheadOffset: CGFloat, windowSize: CGFloat, -@@ -260,10 +282,10 @@ import UIKit +@@ -260,17 +286,18 @@ import UIKit } private func footerDiff() -> CGFloat { - if subviews.count == 0 { -+ if viewsToLayout.count == 0 { - lastMaxBoundOverall = 0 +- lastMaxBoundOverall = 0 - } else if subviews.count == 1 { - let firstChild = subviews[0] -+ } else if viewsToLayout.count == 1 { -+ let firstChild = viewsToLayout[0] - lastMaxBoundOverall = horizontal ? firstChild.frame.maxX : firstChild.frame.maxY - } +- lastMaxBoundOverall = horizontal ? firstChild.frame.maxX : firstChild.frame.maxY +- } let autoLayoutEnd = horizontal ? frame.width : frame.height -@@ -271,6 +293,13 @@ import UIKit + return lastMaxBoundOverall - autoLayoutEnd } private func footer() -> UIView? { diff --git a/patches/react-native+0.73.4+015+copyStateOnClone.patch b/patches/react-native+0.73.4+015+copyStateOnClone.patch new file mode 100644 index 000000000000..9c6bac903efb --- /dev/null +++ b/patches/react-native+0.73.4+015+copyStateOnClone.patch @@ -0,0 +1,12 @@ +diff --git a/node_modules/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp b/node_modules/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp +index 641b6d2..6adeb1b 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp +@@ -331,6 +331,7 @@ ShadowNode::Unshared ShadowNode::cloneTree( + childNode = parentNode.clone({ + ShadowNodeFragment::propsPlaceholder(), + std::make_shared(children), ++ parentNode.getState(), + }); + } + diff --git a/patches/react-native+0.73.4+016+iOS-textinput-onscroll-event.patch b/patches/react-native+0.73.4+016+iOS-textinput-onscroll-event.patch new file mode 100644 index 000000000000..1a5b4c40477b --- /dev/null +++ b/patches/react-native+0.73.4+016+iOS-textinput-onscroll-event.patch @@ -0,0 +1,70 @@ +diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputEventEmitter.cpp b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputEventEmitter.cpp +index 88ae3f3..497569a 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputEventEmitter.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputEventEmitter.cpp +@@ -36,6 +36,54 @@ static jsi::Value textInputMetricsPayload( + return payload; + }; + ++static jsi::Value textInputMetricsScrollPayload( ++ jsi::Runtime& runtime, ++ const TextInputMetrics& textInputMetrics) { ++ auto payload = jsi::Object(runtime); ++ ++ { ++ auto contentOffset = jsi::Object(runtime); ++ contentOffset.setProperty(runtime, "x", textInputMetrics.contentOffset.x); ++ contentOffset.setProperty(runtime, "y", textInputMetrics.contentOffset.y); ++ payload.setProperty(runtime, "contentOffset", contentOffset); ++ } ++ ++ { ++ auto contentInset = jsi::Object(runtime); ++ contentInset.setProperty(runtime, "top", textInputMetrics.contentInset.top); ++ contentInset.setProperty( ++ runtime, "left", textInputMetrics.contentInset.left); ++ contentInset.setProperty( ++ runtime, "bottom", textInputMetrics.contentInset.bottom); ++ contentInset.setProperty( ++ runtime, "right", textInputMetrics.contentInset.right); ++ payload.setProperty(runtime, "contentInset", contentInset); ++ } ++ ++ { ++ auto contentSize = jsi::Object(runtime); ++ contentSize.setProperty( ++ runtime, "width", textInputMetrics.contentSize.width); ++ contentSize.setProperty( ++ runtime, "height", textInputMetrics.contentSize.height); ++ payload.setProperty(runtime, "contentSize", contentSize); ++ } ++ ++ { ++ auto layoutMeasurement = jsi::Object(runtime); ++ layoutMeasurement.setProperty( ++ runtime, "width", textInputMetrics.layoutMeasurement.width); ++ layoutMeasurement.setProperty( ++ runtime, "height", textInputMetrics.layoutMeasurement.height); ++ payload.setProperty(runtime, "layoutMeasurement", layoutMeasurement); ++ } ++ ++ payload.setProperty(runtime, "zoomScale", textInputMetrics.zoomScale ?: 1); ++ ++ ++ return payload; ++ }; ++ + static jsi::Value textInputMetricsContentSizePayload( + jsi::Runtime& runtime, + const TextInputMetrics& textInputMetrics) { +@@ -140,7 +188,9 @@ void TextInputEventEmitter::onKeyPressSync( + + void TextInputEventEmitter::onScroll( + const TextInputMetrics& textInputMetrics) const { +- dispatchTextInputEvent("scroll", textInputMetrics); ++ dispatchEvent("scroll", [textInputMetrics](jsi::Runtime& runtime) { ++ return textInputMetricsScrollPayload(runtime, textInputMetrics); ++ }); + } + + void TextInputEventEmitter::dispatchTextInputEvent( diff --git a/patches/react-native-keyboard-controller+1.12.2.patch.patch b/patches/react-native-keyboard-controller+1.12.2.patch.patch new file mode 100644 index 000000000000..3c8034354481 --- /dev/null +++ b/patches/react-native-keyboard-controller+1.12.2.patch.patch @@ -0,0 +1,39 @@ +diff --git a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +index 83884d8..5d9e989 100644 +--- a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt ++++ b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +@@ -99,12 +99,12 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R + } + + private fun goToEdgeToEdge(edgeToEdge: Boolean) { +- reactContext.currentActivity?.let { +- WindowCompat.setDecorFitsSystemWindows( +- it.window, +- !edgeToEdge, +- ) +- } ++ // reactContext.currentActivity?.let { ++ // WindowCompat.setDecorFitsSystemWindows( ++ // it.window, ++ // !edgeToEdge, ++ // ) ++ // } + } + + private fun setupKeyboardCallbacks() { +@@ -158,13 +158,13 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R + // region State managers + private fun enable() { + this.goToEdgeToEdge(true) +- this.setupWindowInsets() ++ // this.setupWindowInsets() + this.setupKeyboardCallbacks() + } + + private fun disable() { + this.goToEdgeToEdge(false) +- this.setupWindowInsets() ++ // this.setupWindowInsets() + this.removeKeyboardCallbacks() + } + // endregion \ No newline at end of file diff --git a/patches/react-native-modal+13.0.1.patch b/patches/react-native-modal+13.0.1.patch index 5bfb2cc5f0b0..cc9c8531e3a3 100644 --- a/patches/react-native-modal+13.0.1.patch +++ b/patches/react-native-modal+13.0.1.patch @@ -11,7 +11,7 @@ index b63bcfc..bd6419e 100644 buildPanResponder: () => void; getAccDistancePerDirection: (gestureState: PanResponderGestureState) => number; diff --git a/node_modules/react-native-modal/dist/modal.js b/node_modules/react-native-modal/dist/modal.js -index 80f4e75..3ba8b8c 100644 +index 80f4e75..5a58eae 100644 --- a/node_modules/react-native-modal/dist/modal.js +++ b/node_modules/react-native-modal/dist/modal.js @@ -75,6 +75,13 @@ export class ReactNativeModal extends React.Component { @@ -48,7 +48,17 @@ index 80f4e75..3ba8b8c 100644 if (this.didUpdateDimensionsEmitter) { this.didUpdateDimensionsEmitter.remove(); } -@@ -525,7 +540,7 @@ export class ReactNativeModal extends React.Component { +@@ -464,6 +479,9 @@ export class ReactNativeModal extends React.Component { + InteractionManager.clearInteractionHandle(this.interactionHandle); + this.interactionHandle = null; + } ++ if (this.state.isVisible) { ++ this.props.onModalHide(); ++ } + } + componentDidUpdate(prevProps) { + // If the animations have been changed then rebuild them to make sure we're +@@ -525,7 +543,7 @@ export class ReactNativeModal extends React.Component { } return (React.createElement(Modal, Object.assign({ transparent: true, animationType: 'none', visible: this.state.isVisible, onRequestClose: onBackButtonPress }, otherProps), this.makeBackdrop(), diff --git a/patches/react-native-web+0.19.9+001+initial.patch b/patches/react-native-web+0.19.12+001+initial.patch similarity index 98% rename from patches/react-native-web+0.19.9+001+initial.patch rename to patches/react-native-web+0.19.12+001+initial.patch index 91ba6bfd59c0..c77cfc7829ed 100644 --- a/patches/react-native-web+0.19.9+001+initial.patch +++ b/patches/react-native-web+0.19.12+001+initial.patch @@ -1,9 +1,9 @@ diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js -index c879838..0c9dfcb 100644 +index e137def..c3e5054 100644 --- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js @@ -285,7 +285,7 @@ class VirtualizedList extends StateSafePureComponent { - // $FlowFixMe[missing-local-annot] + // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. constructor(_props) { - var _this$props$updateCel; @@ -243,7 +243,7 @@ index c879838..0c9dfcb 100644 }); this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1; var innerRet = /*#__PURE__*/React.createElement(VirtualizedListContextProvider, { -@@ -1307,8 +1360,12 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1317,8 +1370,12 @@ class VirtualizedList extends StateSafePureComponent { onStartReached = _this$props8.onStartReached, onStartReachedThreshold = _this$props8.onStartReachedThreshold, onEndReached = _this$props8.onEndReached, @@ -258,7 +258,7 @@ index c879838..0c9dfcb 100644 var _this$_scrollMetrics2 = this._scrollMetrics, contentLength = _this$_scrollMetrics2.contentLength, visibleLength = _this$_scrollMetrics2.visibleLength, -@@ -1348,16 +1405,10 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1358,16 +1415,10 @@ class VirtualizedList extends StateSafePureComponent { // and call onStartReached only once for a given content length, // and only if onEndReached is not being executed else if (onStartReached != null && this.state.cellsAroundViewport.first === 0 && isWithinStartThreshold && this._scrollMetrics.contentLength !== this._sentStartForContentLength) { @@ -279,9 +279,9 @@ index c879838..0c9dfcb 100644 } // If the user scrolls away from the start or end and back again, -@@ -1412,6 +1463,11 @@ class VirtualizedList extends StateSafePureComponent { - } - } +@@ -1433,6 +1484,11 @@ class VirtualizedList extends StateSafePureComponent { + */ + _updateViewableItems(props, cellsAroundViewport) { + // If we have any pending scroll updates it means that the scroll metrics + // are out of date and we should not call any of the visibility callbacks. @@ -292,7 +292,7 @@ index c879838..0c9dfcb 100644 tuple.viewabilityHelper.onUpdate(props, this._scrollMetrics.offset, this._scrollMetrics.visibleLength, this._getFrameMetrics, this._createViewToken, tuple.onViewableItemsChanged, cellsAroundViewport); }); diff --git a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js -index c7d68bb..43f9653 100644 +index c7d68bb..459f017 100644 --- a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js +++ b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js @@ -75,6 +75,10 @@ type ViewabilityHelperCallbackTuple = { diff --git a/patches/react-native-web+0.19.9+004+fixLastSpacer.patch b/patches/react-native-web+0.19.12+002+fixLastSpacer.patch similarity index 100% rename from patches/react-native-web+0.19.9+004+fixLastSpacer.patch rename to patches/react-native-web+0.19.12+002+fixLastSpacer.patch diff --git a/patches/react-native-web+0.19.9+005+image-header-support.patch b/patches/react-native-web+0.19.12+003+image-header-support.patch similarity index 95% rename from patches/react-native-web+0.19.9+005+image-header-support.patch rename to patches/react-native-web+0.19.12+003+image-header-support.patch index 4652e22662f0..6652f0345cc4 100644 --- a/patches/react-native-web+0.19.9+005+image-header-support.patch +++ b/patches/react-native-web+0.19.12+003+image-header-support.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-web/dist/exports/Image/index.js b/node_modules/react-native-web/dist/exports/Image/index.js -index 95355d5..19109fc 100644 +index 9649d27..3281cc8 100644 --- a/node_modules/react-native-web/dist/exports/Image/index.js +++ b/node_modules/react-native-web/dist/exports/Image/index.js @@ -135,7 +135,22 @@ function resolveAssetUri(source) { @@ -23,10 +23,10 @@ index 95355d5..19109fc 100644 + return a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers); +} +var BaseImage = /*#__PURE__*/React.forwardRef((props, ref) => { - var ariaLabel = props['aria-label'], + var _ariaLabel = props['aria-label'], + accessibilityLabel = props.accessibilityLabel, blurRadius = props.blurRadius, - defaultSource = props.defaultSource, -@@ -236,16 +251,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { +@@ -238,16 +253,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { } }, function error() { updateState(ERRORED); @@ -47,7 +47,7 @@ index 95355d5..19109fc 100644 }); } function abortPendingRequest() { -@@ -277,10 +286,78 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { +@@ -279,10 +288,78 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { suppressHydrationWarning: true }), hiddenImage, createTintColorSVG(tintColor, filterRef.current)); }); @@ -129,7 +129,7 @@ index 95355d5..19109fc 100644 ImageLoader.getSize(uri, success, failure); }; diff --git a/node_modules/react-native-web/dist/modules/ImageLoader/index.js b/node_modules/react-native-web/dist/modules/ImageLoader/index.js -index bc06a87..e309394 100644 +index bc06a87..5a22819 100644 --- a/node_modules/react-native-web/dist/modules/ImageLoader/index.js +++ b/node_modules/react-native-web/dist/modules/ImageLoader/index.js @@ -76,7 +76,7 @@ var ImageLoader = { diff --git a/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch b/patches/react-native-web+0.19.12+004+fixPointerEventDown.patch similarity index 100% rename from patches/react-native-web+0.19.9+006+fixPointerEventDown.patch rename to patches/react-native-web+0.19.12+004+fixPointerEventDown.patch diff --git a/patches/react-native-web+0.19.9+007+osr-improvement.patch b/patches/react-native-web+0.19.12+005+osr-improvement.patch similarity index 100% rename from patches/react-native-web+0.19.9+007+osr-improvement.patch rename to patches/react-native-web+0.19.12+005+osr-improvement.patch diff --git a/patches/react-native-web+0.19.12+006+remove focus trap from modal.patch b/patches/react-native-web+0.19.12+006+remove focus trap from modal.patch new file mode 100644 index 000000000000..14dbc88b0b1c --- /dev/null +++ b/patches/react-native-web+0.19.12+006+remove focus trap from modal.patch @@ -0,0 +1,20 @@ +diff --git a/node_modules/react-native-web/dist/exports/Modal/index.js b/node_modules/react-native-web/dist/exports/Modal/index.js +index d5df021..e2c46cf 100644 +--- a/node_modules/react-native-web/dist/exports/Modal/index.js ++++ b/node_modules/react-native-web/dist/exports/Modal/index.js +@@ -86,13 +86,11 @@ var Modal = /*#__PURE__*/React.forwardRef((props, forwardedRef) => { + onDismiss: onDismissCallback, + onShow: onShowCallback, + visible: visible +- }, /*#__PURE__*/React.createElement(ModalFocusTrap, { +- active: isActive + }, /*#__PURE__*/React.createElement(ModalContent, _extends({}, rest, { + active: isActive, + onRequestClose: onRequestClose, + ref: forwardedRef, + transparent: transparent +- }), children)))); ++ }), children))); + }); + export default Modal; +\ No newline at end of file diff --git a/patches/react-native-web+0.19.9+002+measureInWindow.patch b/patches/react-native-web+0.19.9+002+measureInWindow.patch deleted file mode 100644 index f41b4b3b48cb..000000000000 --- a/patches/react-native-web+0.19.9+002+measureInWindow.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/react-native-web/dist/exports/UIManager/index.js b/node_modules/react-native-web/dist/exports/UIManager/index.js -index 15b71d5..46b9e01 100644 ---- a/node_modules/react-native-web/dist/exports/UIManager/index.js -+++ b/node_modules/react-native-web/dist/exports/UIManager/index.js -@@ -77,7 +77,7 @@ var UIManager = { - measureInWindow(node, callback) { - if (node) { - setTimeout(() => { -- var _getRect2 = getRect(node), -+ var _getRect2 = node.getBoundingClientRect(), - height = _getRect2.height, - left = _getRect2.left, - top = _getRect2.top, diff --git a/patches/react-pdf+7.7.1.patch b/patches/react-pdf+7.7.3.patch similarity index 93% rename from patches/react-pdf+7.7.1.patch rename to patches/react-pdf+7.7.3.patch index f6ec8d8c1685..5b1b3ebb6f6e 100644 --- a/patches/react-pdf+7.7.1.patch +++ b/patches/react-pdf+7.7.3.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-pdf/dist/esm/Document.js b/node_modules/react-pdf/dist/esm/Document.js -index 493ff15..8d5e734 100644 +index b1c5a81..569769e 100644 --- a/node_modules/react-pdf/dist/esm/Document.js +++ b/node_modules/react-pdf/dist/esm/Document.js @@ -261,6 +261,7 @@ const Document = forwardRef(function Document(_a, ref) { diff --git a/scripts/shellUtils.sh b/scripts/shellUtils.sh index 848e6d238254..fa44f2ee7d3a 100644 --- a/scripts/shellUtils.sh +++ b/scripts/shellUtils.sh @@ -102,4 +102,18 @@ get_abs_path() { abs_path=${abs_path/#\/\//\/} echo "$abs_path" -} \ No newline at end of file +} + +# Function to read lines from standard input into an array using a temporary file. +# This is a bash 3 polyfill for readarray. +# Arguments: +# $1: Name of the array variable to store the lines +# Usage: +# read_lines_into_array array_name +read_lines_into_array() { + local array_name="$1" + local line + while IFS= read -r line || [ -n "$line" ]; do + eval "$array_name+=(\"$line\")" + done +} diff --git a/src/App.tsx b/src/App.tsx index 6316fa80fba1..1ce17ea095bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,11 +2,12 @@ import {PortalProvider} from '@gorhom/portal'; import React from 'react'; import {LogBox} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; +import {KeyboardProvider} from 'react-native-keyboard-controller'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; -import ActiveWorkspaceContextProvider from './components/ActiveWorkspace/ActiveWorkspaceProvider'; +import ActiveWorkspaceContextProvider from './components/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; import ComposeProviders from './components/ComposeProviders'; import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackground'; @@ -84,6 +85,7 @@ function App({url}: AppProps) { FullScreenContextProvider, VolumeContextProvider, VideoPopoverMenuContextProvider, + KeyboardProvider, ]} > diff --git a/src/CONST.ts b/src/CONST.ts index 19720c05a93c..3d67a951111e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -50,6 +50,7 @@ const KEYBOARD_SHORTCUT_NAVIGATION_TYPE = 'NAVIGATION_SHORTCUT'; const chatTypes = { POLICY_ANNOUNCE: 'policyAnnounce', POLICY_ADMINS: 'policyAdmins', + TRIP_ROOM: 'tripRoom', GROUP: 'group', DOMAIN_ALL: 'domainAll', POLICY_ROOM: 'policyRoom', @@ -73,14 +74,18 @@ const onboardingChoices = { type OnboardingPurposeType = ValueOf; const CONST = { - MERGED_ACCOUNT_PREFIX: 'MERGED_', DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL], // Note: Group and Self-DM excluded as these are not tied to a Workspace WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT], ANDROID_PACKAGE_NAME, - ANIMATED_HIGHLIGHT_DELAY: 500, - ANIMATED_HIGHLIGHT_DURATION: 500, + WORKSPACE_ENABLE_FEATURE_REDIRECT_DELAY: 100, + ANIMATED_HIGHLIGHT_ENTRY_DELAY: 50, + ANIMATED_HIGHLIGHT_ENTRY_DURATION: 300, + ANIMATED_HIGHLIGHT_START_DELAY: 10, + ANIMATED_HIGHLIGHT_START_DURATION: 300, + ANIMATED_HIGHLIGHT_END_DELAY: 800, + ANIMATED_HIGHLIGHT_END_DURATION: 2000, ANIMATED_TRANSITION: 300, ANIMATED_TRANSITION_FROM_VALUE: 100, ANIMATION_IN_TIMING: 100, @@ -91,6 +96,7 @@ const CONST = { // Multiplier for gyroscope animation in order to make it a bit more subtle ANIMATION_GYROSCOPE_VALUE: 0.4, BACKGROUND_IMAGE_TRANSITION_DURATION: 1000, + SCREEN_TRANSITION_END_TIMEOUT: 1000, ARROW_HIDE_DELAY: 3000, API_ATTACHMENT_VALIDATIONS: { @@ -146,6 +152,7 @@ const CONST = { DISPLAY_NAME: { MAX_LENGTH: 50, RESERVED_NAMES: ['Expensify', 'Concierge'], + EXPENSIFY_CONCIERGE: 'Expensify Concierge', }, GPS: { @@ -166,6 +173,9 @@ const CONST = { PULL_REQUEST_NUMBER, + // Regex to get link in href prop inside of component + REGEX_LINK_IN_ANCHOR: /]*?\s+)?href="([^"]*)"/gi, + MERCHANT_NAME_MAX_LENGTH: 255, REQUEST_PREVIEW: { @@ -200,27 +210,12 @@ const CONST = { // Sizes needed for report empty state background image handling EMPTY_STATE_BACKGROUND: { ASPECT_RATIO: 3.72, + OVERLAP: 60, SMALL_SCREEN: { IMAGE_HEIGHT: 300, - CONTAINER_MINHEIGHT: 200, - VIEW_HEIGHT: 240, }, WIDE_SCREEN: { IMAGE_HEIGHT: 450, - CONTAINER_MINHEIGHT: 500, - VIEW_HEIGHT: 390, - }, - MONEY_OR_TASK_REPORT: { - SMALL_SCREEN: { - IMAGE_HEIGHT: 300, - CONTAINER_MINHEIGHT: 280, - VIEW_HEIGHT: 240, - }, - WIDE_SCREEN: { - IMAGE_HEIGHT: 450, - CONTAINER_MINHEIGHT: 280, - VIEW_HEIGHT: 390, - }, }, }, @@ -361,8 +356,8 @@ const CONST = { CHRONOS_IN_CASH: 'chronosInCash', DEFAULT_ROOMS: 'defaultRooms', VIOLATIONS: 'violations', + DUPE_DETECTION: 'dupeDetection', REPORT_FIELDS: 'reportFields', - TRACK_EXPENSE: 'trackExpense', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', WORKFLOWS_DELAYED_SUBMISSION: 'workflowsDelayedSubmission', SPOTNANA_TRAVEL: 'spotnanaTravel', @@ -529,6 +524,16 @@ const CONST = { shortcutKey: 'Tab', modifiers: [], }, + DEBUG: { + descriptionKey: 'openDebug', + shortcutKey: 'D', + modifiers: ['CTRL'], + trigger: { + DEFAULT: {input: 'd', modifierFlags: keyModifierControl}, + [PLATFORM_OS_MACOS]: {input: 'd', modifierFlags: keyModifierCommand}, + [PLATFORM_IOS]: {input: 'd', modifierFlags: keyModifierCommand}, + }, + }, }, KEYBOARD_SHORTCUTS_TYPES: { NAVIGATION_SHORTCUT: KEYBOARD_SHORTCUT_NAVIGATION_TYPE, @@ -556,8 +561,10 @@ const CONST = { EMPTY_ARRAY, EMPTY_OBJECT, USE_EXPENSIFY_URL, + STATUS_EXPENSIFY_URL: 'https://status.expensify.com', GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com', GOOGLE_DOC_IMAGE_LINK_MATCH: 'googleusercontent.com', + GOOGLE_CLOUD_URL: 'https://clients3.google.com/generate_204', IMAGE_BASE64_MATCH: 'base64', DEEPLINK_BASE_URL: 'new-expensify://', PDF_VIEWER_URL: '/pdf/web/viewer.html', @@ -573,12 +580,14 @@ const CONST = { LICENSES_URL: `${USE_EXPENSIFY_URL}/licenses`, ACH_TERMS_URL: `${USE_EXPENSIFY_URL}/achterms`, WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/walletagreement`, + BANCORP_WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/bancorp-bank-wallet-terms-of-service`, HELP_LINK_URL: `${USE_EXPENSIFY_URL}/usa-patriot-act`, ELECTRONIC_DISCLOSURES_URL: `${USE_EXPENSIFY_URL}/esignagreement`, GITHUB_RELEASE_URL: 'https://api.github.com/repos/expensify/app/releases/latest', ADD_SECONDARY_LOGIN_URL: encodeURI('settings?param={"section":"account","openModal":"secondaryLogin"}'), MANAGE_CARDS_URL: 'domain_companycards', FEES_URL: `${USE_EXPENSIFY_URL}/fees`, + SAVE_WITH_EXPENSIFY_URL: `${USE_EXPENSIFY_URL}/savings-calculator`, CFPB_PREPAID_URL: 'https://cfpb.gov/prepaid', STAGING_NEW_EXPENSIFY_URL: 'https://staging.new.expensify.com', NEWHELP_URL: 'https://help.expensify.com', @@ -600,7 +609,6 @@ const CONST = { ADMIN_POLICIES_URL: 'admin_policies', ADMIN_DOMAINS_URL: 'admin_domains', INBOX: 'inbox', - DISMMISSED_REASON: '?dismissedReason=missingFeatures', }, SIGN_IN_FORM_WIDTH: 300, @@ -666,9 +674,9 @@ const CONST = { DELETED_ACCOUNT: 'DELETEDACCOUNT', // OldDot Action DISMISSED_VIOLATION: 'DISMISSEDVIOLATION', DONATION: 'DONATION', // OldDot Action - EXPORTED_TO_CSV: 'EXPORTEDTOCSV', // OldDot Action - EXPORTED_TO_INTEGRATION: 'EXPORTEDTOINTEGRATION', // OldDot Action - EXPORTED_TO_QUICK_BOOKS: 'EXPORTEDTOQUICKBOOKS', // OldDot Action + EXPORTED_TO_CSV: 'EXPORTCSV', // OldDot Action + EXPORTED_TO_INTEGRATION: 'EXPORTINTEGRATION', // OldDot Action + EXPORTED_TO_QUICK_BOOKS: 'EXPORTED', // OldDot Action FORWARDED: 'FORWARDED', // OldDot Action HOLD: 'HOLD', HOLD_COMMENT: 'HOLDCOMMENT', @@ -690,6 +698,7 @@ const CONST = { REIMBURSEMENT_DEQUEUED: 'REIMBURSEMENTDEQUEUED', REIMBURSEMENT_REQUESTED: 'REIMBURSEMENTREQUESTED', // OldDot Action REIMBURSEMENT_SETUP: 'REIMBURSEMENTSETUP', // OldDot Action + REIMBURSEMENT_SETUP_REQUESTED: 'REIMBURSEMENTSETUPREQUESTED', // OldDot Action RENAMED: 'RENAMED', REPORT_PREVIEW: 'REPORTPREVIEW', SELECTED_FOR_RANDOM_AUDIT: 'SELECTEDFORRANDOMAUDIT', // OldDot Action @@ -701,6 +710,7 @@ const CONST = { TASK_COMPLETED: 'TASKCOMPLETED', TASK_EDITED: 'TASKEDITED', TASK_REOPENED: 'TASKREOPENED', + TRIPPREVIEW: 'TRIPPREVIEW', UNAPPROVED: 'UNAPPROVED', // OldDot Action UNHOLD: 'UNHOLD', UNSHARE: 'UNSHARE', // OldDot Action @@ -933,13 +943,18 @@ const CONST = { RESIZE_DEBOUNCE_TIME: 100, }, SEARCH_TABLE_COLUMNS: { + RECEIPT: 'receipt', DATE: 'date', MERCHANT: 'merchant', + DESCRIPTION: 'description', FROM: 'from', TO: 'to', - TOTAL: 'total', + CATEGORY: 'category', + TAG: 'tag', + TOTAL_AMOUNT: 'amount', TYPE: 'type', ACTION: 'action', + TAX_AMOUNT: 'taxAmount', }, PRIORITY_MODE: { GSD: 'gsd', @@ -961,7 +976,7 @@ const CONST = { DARK_CONTENT: 'dark-content', }, TRANSACTION: { - DEFAULT_MERCHANT: 'Request', + DEFAULT_MERCHANT: 'Expense', UNKNOWN_MERCHANT: 'Unknown Merchant', PARTIAL_TRANSACTION_MERCHANT: '(none)', TYPE: { @@ -1045,6 +1060,7 @@ const CONST = { MAX_RETRY_WAIT_TIME_MS: 10 * 1000, PROCESS_REQUEST_DELAY_MS: 1000, MAX_PENDING_TIME_MS: 10 * 1000, + BACKEND_CHECK_INTERVAL_MS: 60 * 1000, MAX_REQUEST_RETRIES: 10, NETWORK_STATUS: { ONLINE: 'online', @@ -1056,7 +1072,7 @@ const CONST = { DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, - DEFAULT_NETWORK_DATA: {isOffline: false}, + DEFAULT_NETWORK_DATA: {isOffline: false, isBackendReachable: true}, FORMS: { LOGIN_FORM: 'LoginForm', VALIDATE_CODE_FORM: 'ValidateCodeForm', @@ -1184,6 +1200,10 @@ const CONST = { WEBP: 'image/webp', JPEG: 'image/jpeg', }, + ATTACHMENT_TYPE: { + REPORT: 'r', + NOTE: 'n', + }, IMAGE_OBJECT_POSITION: { TOP: 'top', @@ -1227,6 +1247,8 @@ const CONST = { MAX_AMOUNT_OF_SUGGESTIONS: 20, MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER: 5, HERE_TEXT: '@here', + SUGGESTION_BOX_MAX_SAFE_DISTANCE: 38, + BIG_SCREEN_SUGGESTION_WIDTH: 300, }, COMPOSER_MAX_HEIGHT: 125, CHAT_FOOTER_SECONDARY_ROW_HEIGHT: 15, @@ -1265,6 +1287,8 @@ const CONST = { EXPENSIFY_EMAIL_DOMAIN: '@expensify.com', }, + CONCIERGE_DISPLAY_NAME: 'Concierge', + INTEGRATION_ENTITY_MAP_TYPES: { DEFAULT: 'DEFAULT', NONE: 'NONE', @@ -1299,8 +1323,27 @@ const CONST = { XERO_CONFIG: { AUTO_SYNC: 'autoSync', SYNC: 'sync', + ENABLE_NEW_CATEGORIES: 'enableNewCategories', + EXPORT: 'export', + TENANT_ID: 'tenantID', IMPORT_CUSTOMERS: 'importCustomers', IMPORT_TAX_RATES: 'importTaxRates', + INVOICE_STATUS: { + DRAFT: 'DRAFT', + AWAITING_APPROVAL: 'AWT_APPROVAL', + AWAITING_PAYMENT: 'AWT_PAYMENT', + }, + IMPORT_TRACKING_CATEGORIES: 'importTrackingCategories', + MAPPINGS: 'mappings', + TRACKING_CATEGORY_PREFIX: 'trackingCategory_', + TRACKING_CATEGORY_FIELDS: { + COST_CENTERS: 'cost centers', + REGION: 'region', + }, + TRACKING_CATEGORY_OPTIONS: { + DEFAULT: 'DEFAULT', + TAG: 'TAG', + }, }, QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE: { @@ -1309,6 +1352,12 @@ const CONST = { JOURNAL_ENTRY: 'journal_entry', }, + XERO_EXPORT_DATE: { + LAST_EXPENSE: 'LAST_EXPENSE', + REPORT_EXPORTED: 'REPORT_EXPORTED', + REPORT_SUBMITTED: 'REPORT_SUBMITTED', + }, + QUICKBOOKS_EXPORT_DATE: { LAST_EXPENSE: 'LAST_EXPENSE', REPORT_EXPORTED: 'REPORT_EXPORTED', @@ -1393,6 +1442,7 @@ const CONST = { }, STEP: { // In the order they appear in the Wallet flow + ADD_BANK_ACCOUNT: 'AddBankAccountStep', ADDITIONAL_DETAILS: 'AdditionalDetailsStep', ADDITIONAL_DETAILS_KBA: 'AdditionalDetailsKBAStep', ONFIDO: 'OnfidoStep', @@ -1429,6 +1479,7 @@ const CONST = { CONCIERGE: 'CONCIERGE_NAVIGATE', }, MTL_WALLET_PROGRAM_ID: '760', + BANCORP_WALLET_PROGRAM_ID: '660', PROGRAM_ISSUERS: { EXPENSIFY_PAYMENTS: 'Expensify Payments LLC', BANCORP_BANK: 'The Bancorp Bank', @@ -1544,7 +1595,7 @@ const CONST = { APPROVE: 'approve', TRACK: 'track', }, - AMOUNT_MAX_LENGTH: 10, + AMOUNT_MAX_LENGTH: 8, RECEIPT_STATE: { SCANREADY: 'SCANREADY', OPEN: 'OPEN', @@ -1567,6 +1618,9 @@ const CONST = { ACCOUNTANT: 'accountant', }, }, + ACCESS_VARIANTS: { + CREATE: 'create', + }, }, GROWL: { @@ -1747,7 +1801,8 @@ const CONST = { XERO: 'xero', }, SYNC_STAGE_NAME: { - STARTING_IMPORT: 'startingImport', + STARTING_IMPORT_QBO: 'startingImportQBO', + STARTING_IMPORT_XERO: 'startingImportXero', QBO_IMPORT_MAIN: 'quickbooksOnlineImportMain', QBO_IMPORT_CUSTOMERS: 'quickbooksOnlineImportCustomers', QBO_IMPORT_EMPLOYEES: 'quickbooksOnlineImportEmployees', @@ -1774,7 +1829,10 @@ const CONST = { XERO_SYNC_IMPORT_CUSTOMERS: 'xeroSyncImportCustomers', XERO_SYNC_IMPORT_BANK_ACCOUNTS: 'xeroSyncImportBankAccounts', XERO_SYNC_IMPORT_TAX_RATES: 'xeroSyncImportTaxRates', + XERO_CHECK_CONNECTION: 'xeroCheckConnection', + XERO_SYNC_TITLE: 'xeroSyncTitle', }, + SYNC_STAGE_TIMEOUT_MINUTES: 20, }, ACCESS_VARIANTS: { PAID: 'paid', @@ -1786,7 +1844,7 @@ const CONST = { NAME_DISTANCE: 'Distance', DISTANCE_UNIT_MILES: 'mi', DISTANCE_UNIT_KILOMETERS: 'km', - MILEAGE_IRS_RATE: 0.655, + MILEAGE_IRS_RATE: 0.67, DEFAULT_RATE: 'Default Rate', RATE_DECIMALS: 3, FAKE_P2P_ID: '_FAKE_P2P_ID_', @@ -1858,6 +1916,12 @@ const CONST = { COMPACT: 'compact', DEFAULT: 'default', }, + SUBSCRIPTION: { + TYPE: { + ANNUAL: 'yearly2018', + PAYPERUSE: 'monthly2018', + }, + }, REGEX: { SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g, DIGITS_AND_PLUS: /^\+?[0-9]*$/, @@ -1891,10 +1955,10 @@ const CONST = { // Extract attachment's source from the data's html string ATTACHMENT_DATA: /(data-expensify-source|data-name)="([^"]+)"/g, - EMOJI_NAME: /:[\w+-]+:/g, - EMOJI_SUGGESTIONS: /:[a-zA-Z0-9_+-]{1,40}$/, + EMOJI_NAME: /:[\p{L}0-9_+-]+:/gu, + EMOJI_SUGGESTIONS: /:[\p{L}0-9_+-]{1,40}$/u, AFTER_FIRST_LINE_BREAK: /\n.*/g, - LINE_BREAK: /\r|\n/g, + LINE_BREAK: /\r\n|\r|\n/g, CODE_2FA: /^\d{6}$/, ATTACHMENT_ID: /chat-attachments\/(\d+)/, HAS_COLON_ONLY_AT_THE_BEGINNING: /^:[^:]+$/, @@ -1943,7 +2007,7 @@ const CONST = { POLICY_ID_FROM_PATH: /\/w\/([a-zA-Z0-9]+)(\/|$)/, - SHORT_MENTION: new RegExp(`@[\\w\\-\\+\\'#@]+(?:\\.[\\w\\-\\'\\+]+)*`, 'gim'), + SHORT_MENTION: new RegExp(`@[\\w\\-\\+\\'#@]+(?:\\.[\\w\\-\\'\\+]+)*(?![^\`]*\`)`, 'gim'), }, PRONOUNS: { @@ -2028,7 +2092,7 @@ const CONST = { LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, TAG_NAME_LIMIT: 256, - REPORT_NAME_LIMIT: 256, + REPORT_NAME_LIMIT: 100, TITLE_CHARACTER_LIMIT: 100, DESCRIPTION_LIMIT: 500, WORKSPACE_NAME_CHARACTER_LIMIT: 80, @@ -3257,6 +3321,12 @@ const CONST = { }, CONCIERGE_TRAVEL_URL: 'https://community.expensify.com/discussion/7066/introducing-concierge-travel', + BOOK_TRAVEL_DEMO_URL: 'https://calendly.com/d/ck2z-xsh-q97/expensify-travel-demo-travel-page', + TRAVEL_DOT_URL: 'https://travel.expensify.com', + STAGING_TRAVEL_DOT_URL: 'https://staging.travel.expensify.com', + TRIP_ID_PATH: (tripID: string) => `trips/${tripID}`, + SPOTNANA_TMC_ID: '8e8e7258-1cf3-48c0-9cd1-fe78a6e31eed', + STAGING_SPOTNANA_TMC_ID: '7a290c6e-5328-4107-aff6-e48765845b81', SCREEN_READER_STATES: { ALL: 'all', ACTIVE: 'active', @@ -3344,6 +3414,11 @@ const CONST = { * @deprecated Please stop using the accessibilityRole prop and use the role prop instead. */ IMAGE: 'image', + + /** + * @deprecated Please stop using the accessibilityRole prop and use the role prop instead. + */ + TEXTBOX: 'textbox', }, /** * Acceptable values for the `role` attribute on react native components. @@ -3406,6 +3481,8 @@ const CONST = { TIMER: 'timer', /** Use for toolbars containing action buttons or components. */ TOOLBAR: 'toolbar', + /** Use for navigation elements */ + NAVIGATION: 'navigation', }, TRANSLATION_KEYS: { ATTACHMENT: 'common.attachment', @@ -3447,6 +3524,9 @@ const CONST = { }, TAB_SEARCH: { ALL: 'all', + SHARED: 'shared', + DRAFTS: 'drafts', + FINISHED: 'finished', }, STATUS_TEXT_MAX_LENGTH: 100, @@ -3459,7 +3539,6 @@ const CONST = { NAVIGATION: { TYPE: { - FORCED_UP: 'FORCED_UP', UP: 'UP', }, ACTION_TYPE: { @@ -3480,10 +3559,12 @@ const CONST = { COLON: ':', MAPBOX: { PADDING: 50, - DEFAULT_ZOOM: 10, + DEFAULT_ZOOM: 15, SINGLE_MARKER_ZOOM: 15, DEFAULT_COORDINATE: [-122.4021, 37.7911], STYLE_URL: 'mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq', + ANIMATION_DURATION_ON_CENTER_ME: 1000, + CENTER_BUTTON_FADE_DURATION: 300, }, ONYX_UPDATE_TYPES: { HTTPS: 'https', @@ -3536,7 +3617,7 @@ const CONST = { TRACK_EXPENSE: 'track-expenses', }, 'track-expenses': { - VIDEO_URL: `${CLOUDFRONT_URL}/videos/guided-setup-track-business.mp4`, + VIDEO_URL: `${CLOUDFRONT_URL}/videos/guided-setup-track-business-v2.mp4`, LEARN_MORE_LINK: `${USE_EXPENSIFY_URL}/track-expenses`, }, }, @@ -3552,10 +3633,10 @@ const CONST = { BACK_BUTTON_NATIVE_ID: 'backButton', /** - * The maximum count of items per page for OptionsSelector. + * The maximum count of items per page for SelectionList. * When paginate, it multiplies by page number. */ - MAX_OPTIONS_SELECTOR_PAGE_LENGTH: 500, + MAX_SELECTION_LIST_PAGE_LENGTH: 500, /** * Bank account names @@ -3641,6 +3722,7 @@ const CONST = { TAX_OUT_OF_POLICY: 'taxOutOfPolicy', TAX_RATE_CHANGED: 'taxRateChanged', TAX_REQUIRED: 'taxRequired', + HOLD: 'hold', }, /** Context menu types */ @@ -3703,8 +3785,9 @@ const CONST = { WELCOME_VIDEO_URL: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + ONBOARDING_INTRODUCTION: 'Let’s get you set up 🔧', ONBOARDING_CHOICES: {...onboardingChoices}, - + ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?', ONBOARDING_CONCIERGE: { [onboardingChoices.EMPLOYER]: '# Expensify is the fastest way to get paid back!\n' + @@ -3749,7 +3832,7 @@ const CONST = { [onboardingChoices.EMPLOYER]: { message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', video: { - url: `${CLOUDFRONT_URL}/videos/guided-setup-get-paid-back.mp4`, + url: `${CLOUDFRONT_URL}/videos/guided-setup-get-paid-back-v2.mp4`, thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-get-paid-back.jpg`, duration: 55, width: 1280, @@ -3792,7 +3875,7 @@ const CONST = { [onboardingChoices.MANAGE_TEAM]: { message: 'Here are some important tasks to help get your team’s expenses under control.', video: { - url: `${CLOUDFRONT_URL}/videos/guided-setup-manage-team.mp4`, + url: `${CLOUDFRONT_URL}/videos/guided-setup-manage-team-v2.mp4`, thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-manage-team.jpg`, duration: 55, width: 1280, @@ -3817,10 +3900,10 @@ const CONST = { type: 'meetGuide', autoCompleted: false, title: 'Meet your setup specialist', - description: ({adminsRoomLink, guideCalendarLink}: {adminsRoomLink: string; guideCalendarLink: string}) => + description: ({adminsRoomLink}: {adminsRoomLink: string}) => `Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` + '\n' + - `Chat with the specialist in your [#admins room](${adminsRoomLink}) or [schedule a call](${guideCalendarLink}) today.`, + `Chat with the specialist in your [#admins room](${adminsRoomLink}).`, }, { type: 'setupCategories', @@ -3878,7 +3961,7 @@ const CONST = { [onboardingChoices.PERSONAL_SPEND]: { message: 'Here’s how to track your spend in a few clicks.', video: { - url: `${CLOUDFRONT_URL}/videos/guided-setup-track-personal.mp4`, + url: `${CLOUDFRONT_URL}/videos/guided-setup-track-personal-v2.mp4`, thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-track-personal.jpg`, duration: 55, width: 1280, @@ -3906,7 +3989,7 @@ const CONST = { [onboardingChoices.CHAT_SPLIT]: { message: 'Splitting bills with friends is as easy as sending a message. Here’s how.', video: { - url: `${CLOUDFRONT_URL}/videos/guided-setup-chat-split-bills.mp4`, + url: `${CLOUDFRONT_URL}/videos/guided-setup-chat-split-bills-v2.mp4`, thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-chat-split-bills.jpg`, duration: 55, width: 1280, @@ -4697,6 +4780,13 @@ const CONST = { SESSION_STORAGE_KEYS: { INITIAL_URL: 'INITIAL_URL', + ACTIVE_WORKSPACE_ID: 'ACTIVE_WORKSPACE_ID', + }, + + RESERVATION_TYPE: { + CAR: 'car', + HOTEL: 'hotel', + FLIGHT: 'flight', }, DOT_SEPARATOR: '•', @@ -4722,6 +4812,7 @@ const CONST = { MAX_TAX_RATE_DECIMAL_PLACES: 4, DOWNLOADS_PATH: '/Downloads', + DOWNLOADS_TIMEOUT: 5000, NEW_EXPENSIFY_PATH: '/New Expensify', ENVIRONMENT_SUFFIX: { @@ -4734,13 +4825,68 @@ const CONST = { CARD: 'card', DISTANCE: 'distance', }, + + SEARCH_RESULTS_PAGE_SIZE: 50, + + SEARCH_DATA_TYPES: { + TRANSACTION: 'transaction', + REPORT: 'report', + }, + + REFERRER: { + NOTIFICATION: 'notification', + }, + + SORT_ORDER: { + ASC: 'asc', + DESC: 'desc', + }, + + SUBSCRIPTION_SIZE_LIMIT: 20000, + + PAYMENT_CARD_CURRENCY: { + USD: 'USD', + AUD: 'AUD', + GBP: 'GBP', + NZD: 'NZD', + }, + + SUBSCRIPTION_PRICE_FACTOR: 2, + SUBSCRIPTION_POSSIBLE_COST_SAVINGS: { + COLLECT_PLAN: 10, + CONTROL_PLAN: 18, + }, + FEEDBACK_SURVEY_OPTIONS: { + TOO_LIMITED: { + ID: 'tooLimited', + TRANSLATION_KEY: 'feedbackSurvey.tooLimited', + }, + TOO_EXPENSIVE: { + ID: 'tooExpensive', + TRANSLATION_KEY: 'feedbackSurvey.tooExpensive', + }, + INADEQUATE_SUPPORT: { + ID: 'inadequateSupport', + TRANSLATION_KEY: 'feedbackSurvey.inadequateSupport', + }, + BUSINESS_CLOSING: { + ID: 'businessClosing', + TRANSLATION_KEY: 'feedbackSurvey.businessClosing', + }, + }, + + EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; type IOUType = ValueOf; type IOUAction = ValueOf; +type IOURequestType = ValueOf; +type FeedbackSurveyOptionID = ValueOf, 'ID'>>; + +type SubscriptionType = ValueOf; -export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType}; +export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID}; export default CONST; diff --git a/src/Expensify.tsx b/src/Expensify.tsx index c67f1400fc4b..ddc4b5f88a69 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -1,3 +1,4 @@ +import {Audio} from 'expo-av'; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {NativeEventSubscription} from 'react-native'; import {AppState, Linking} from 'react-native'; @@ -76,7 +77,9 @@ type ExpensifyOnyxProps = { type ExpensifyProps = ExpensifyOnyxProps; -const SplashScreenHiddenContext = React.createContext({}); +type SplashScreenHiddenContextType = {isSplashHidden?: boolean}; + +const SplashScreenHiddenContext = React.createContext({}); function Expensify({ isCheckingPublicRoom = true, @@ -106,6 +109,9 @@ function Expensify({ const isAuthenticated = useMemo(() => !!(session?.authToken ?? null), [session]); const autoAuthState = useMemo(() => session?.autoAuthState ?? '', [session]); + const isAuthenticatedRef = useRef(false); + isAuthenticatedRef.current = isAuthenticated; + const contextValue = useMemo( () => ({ isSplashHidden, @@ -140,8 +146,10 @@ function Expensify({ // Initialize this client as being an active client ActiveClientManager.init(); - // Used for the offline indicator appearing when someone is offline - NetworkConnection.subscribeToNetInfo(); + // Used for the offline indicator appearing when someone is offline or backend is unreachable + const unsubscribeNetworkStatus = NetworkConnection.subscribeToNetworkStatus(); + + return () => unsubscribeNetworkStatus(); }, []); useEffect(() => { @@ -190,7 +198,8 @@ function Expensify({ // Open chat report from a deep link (only mobile native) Linking.addEventListener('url', (state) => { - Report.openReportFromDeepLink(state.url); + // We need to pass 'isAuthenticated' to avoid loading a non-existing profile page twice + Report.openReportFromDeepLink(state.url, !isAuthenticatedRef.current); }); return () => { @@ -202,6 +211,11 @@ function Expensify({ // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again }, []); + // This is being done since we want to play sound even when iOS device is on silent mode, to align with other platforms. + useEffect(() => { + Audio.setAudioModeAsync({playsInSilentModeIOS: true}); + }, []); + // Display a blank page until the onyx migration completes if (!isOnyxMigrated) { return null; @@ -255,6 +269,8 @@ function Expensify({ ); } +Expensify.displayName = 'Expensify'; + export default withOnyx({ isCheckingPublicRoom: { key: ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 804c8dadd553..0d22d3714fe6 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -121,6 +121,9 @@ const ONYXKEYS = { /** Contains the users's block expiration (if they have one) */ NVP_BLOCKED_FROM_CONCIERGE: 'nvp_private_blockedFromConcierge', + /** Whether the user is blocked from chat */ + NVP_BLOCKED_FROM_CHAT: 'nvp_private_blockedFromChat', + /** A unique identifier that each user has that's used to send notifications */ NVP_PRIVATE_PUSH_NOTIFICATION_ID: 'nvp_private_pushNotificationID', @@ -130,9 +133,6 @@ const ONYXKEYS = { /** This NVP holds to most recent waypoints that a person has used when creating a distance expense */ NVP_RECENT_WAYPOINTS: 'expensify_recentWaypoints', - /** This NVP will be `true` if the user has ever dismissed the engagement modal on either OldDot or NewDot. If it becomes true it should stay true forever. */ - NVP_HAS_DISMISSED_IDLE_PANEL: 'nvp_hasDismissedIdlePanel', - /** This NVP contains the choice that the user made on the engagement modal */ NVP_INTRO_SELECTED: 'nvp_introSelected', @@ -154,6 +154,9 @@ const ONYXKEYS = { /** Whether the user has been shown the hold educational interstitial yet */ NVP_HOLD_USE_EXPLAINED: 'holdUseExplained', + /** Store the state of the subscription */ + NVP_PRIVATE_SUBSCRIPTION: 'nvp_private_subscription', + /** Store preferred skintone for emoji */ PREFERRED_EMOJI_SKIN_TONE: 'nvp_expensify_preferredEmojiSkinTone', @@ -188,6 +191,9 @@ const ONYXKEYS = { /** User's Expensify Wallet */ USER_WALLET: 'userWallet', + /** User's metadata that will be used to segmentation */ + USER_METADATA: 'userMetadata', + /** Object containing Onfido SDK Token + applicantID */ WALLET_ONFIDO: 'walletOnfido', @@ -282,6 +288,9 @@ const ONYXKEYS = { /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected', + /** Onboarding policyID selected by the user during Onboarding flow */ + ONBOARDING_POLICY_ID: 'onboardingPolicyID', + /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_ADMINS_CHAT_REPORT_ID: 'onboardingAdminsChatReportID', @@ -336,6 +345,7 @@ const ONYXKEYS = { WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', REPORT: 'report_', + REPORT_NAME_VALUE_PAIRS: 'reportNameValuePairs_', REPORT_DRAFT: 'reportDraft_', // REPORT_METADATA is a perf optimization used to hold loading states (isLoadingInitialReportActions, isLoadingOlderReportActions, isLoadingNewerReportActions). // A lot of components are connected to the Report entity and do not care about the actions. Setting the loading state @@ -387,6 +397,8 @@ const ONYXKEYS = { POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm', POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft', POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm', + POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM: 'policyDistanceRateTaxReclaimableOnEditForm', + POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM_DRAFT: 'policyDistanceRateTaxReclaimableOnEditFormDraft', POLICY_DISTANCE_RATE_EDIT_FORM_DRAFT: 'policyDistanceRateEditFormDraft', CLOSE_ACCOUNT_FORM: 'closeAccount', CLOSE_ACCOUNT_FORM_DRAFT: 'closeAccountDraft', @@ -472,6 +484,8 @@ const ONYXKEYS = { WORKSPACE_TAX_VALUE_FORM_DRAFT: 'workspaceTaxValueFormDraft', NEW_CHAT_NAME_FORM: 'newChatNameForm', NEW_CHAT_NAME_FORM_DRAFT: 'newChatNameFormDraft', + SUBSCRIPTION_SIZE_FORM: 'subscriptionSizeForm', + SUBSCRIPTION_SIZE_FORM_DRAFT: 'subscriptionSizeFormDraft', }, } as const; @@ -526,9 +540,11 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm; [ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm; [ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_EDIT_FORM]: FormTypes.PolicyDistanceRateEditForm; + [ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM]: FormTypes.PolicyDistanceRateTaxReclaimableOnEditForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm; [ONYXKEYS.FORMS.NEW_CHAT_NAME_FORM]: FormTypes.NewChatNameForm; + [ONYXKEYS.FORMS.SUBSCRIPTION_SIZE_FORM]: FormTypes.SubscriptionSizeForm; }; type OnyxFormDraftValuesMapping = { @@ -548,6 +564,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string; [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; + [ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: OnyxTypes.ReportNameValuePairs; [ONYXKEYS.COLLECTION.REPORT_DRAFT]: OnyxTypes.Report; [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions; @@ -578,7 +595,10 @@ type OnyxValuesMapping = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; [ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string; [ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER]: boolean; + + // NVP_ONBOARDING is an array for old users. [ONYXKEYS.NVP_ONBOARDING]: Onboarding | []; + [ONYXKEYS.ACTIVE_CLIENTS]: string[]; [ONYXKEYS.DEVICE_ID]: string; [ONYXKEYS.IS_SIDEBAR_LOADED]: boolean; @@ -605,17 +625,20 @@ type OnyxValuesMapping = { [ONYXKEYS.USER_LOCATION]: OnyxTypes.UserLocation; [ONYXKEYS.LOGIN_LIST]: OnyxTypes.LoginList; [ONYXKEYS.SESSION]: OnyxTypes.Session; + [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; [ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf; [ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge; + + // The value of this nvp is a string representation of the date when the block expires, or an empty string if the user is not blocked + [ONYXKEYS.NVP_BLOCKED_FROM_CHAT]: string; [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string; [ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean; [ONYXKEYS.NVP_HOLD_USE_EXPLAINED]: boolean; [ONYXKEYS.FOCUS_MODE_NOTIFICATION]: boolean; [ONYXKEYS.NVP_LAST_PAYMENT_METHOD]: OnyxTypes.LastPaymentMethod; [ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[]; - [ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL]: boolean; [ONYXKEYS.NVP_INTRO_SELECTED]: OnyxTypes.IntroSelected; [ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES]: OnyxTypes.LastSelectedDistanceRates; [ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED]: boolean; @@ -628,6 +651,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: string; [ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS]: OnyxTypes.DismissedReferralBanners; [ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING]: boolean; + [ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION]: OnyxTypes.PrivateSubscription; [ONYXKEYS.USER_WALLET]: OnyxTypes.UserWallet; [ONYXKEYS.WALLET_ONFIDO]: OnyxTypes.WalletOnfido; [ONYXKEYS.WALLET_ADDITIONAL_DETAILS]: OnyxTypes.WalletAdditionalDetails; @@ -662,6 +686,7 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string; + [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 9f84b3db0de1..c1fdd68951fa 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2,6 +2,8 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; import type {IOUAction, IOUType} from './CONST'; import type {IOURequestType} from './libs/actions/IOU'; +import type {CentralPaneNavigatorParamList} from './libs/Navigation/types'; +import type {SearchQuery} from './types/onyx/SearchResults'; import type AssertTypesNotEqual from './types/utils/AssertTypesNotEqual'; // This is a file containing constants for all the routes we want to be able to go to @@ -14,10 +16,20 @@ function getUrlWithBackToParam(url: TUrl, backTo?: string): return `${url}${backToParam}` as const; } -const ROUTES = { +const PUBLIC_SCREENS_ROUTES = { // If the user opens this route, we'll redirect them to the path saved in the last visited path or to the home page if the last visited path is empty. ROOT: '', + TRANSITION_BETWEEN_APPS: 'transition', + CONNECTION_COMPLETE: 'connection-complete', + VALIDATE_LOGIN: 'v/:accountID/:validateCode', + UNLINK_LOGIN: 'u/:accountID/:validateCode', + APPLE_SIGN_IN: 'sign-in-with-apple', + GOOGLE_SIGN_IN: 'sign-in-with-google', + SAML_SIGN_IN: 'sign-in-with-saml', +} as const; +const ROUTES = { + ...PUBLIC_SCREENS_ROUTES, // This route renders the list of reports. HOME: 'home', @@ -25,7 +37,15 @@ const ROUTES = { SEARCH: { route: '/search/:query', - getRoute: (query: string) => `search/${query}` as const, + getRoute: (searchQuery: SearchQuery, queryParams?: CentralPaneNavigatorParamList['Search_Central_Pane']) => { + const {sortBy, sortOrder} = queryParams ?? {}; + + if (!sortBy && !sortOrder) { + return `search/${searchQuery}` as const; + } + + return `search/${searchQuery}?sortBy=${sortBy}&sortOrder=${sortOrder}` as const; + }, }, SEARCH_REPORT: { @@ -40,31 +60,24 @@ const ROUTES = { getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}` as const, }, CHAT_FINDER: 'chat-finder', - DETAILS: { - route: 'details', - getRoute: (login: string) => `details?login=${encodeURIComponent(login)}` as const, - }, PROFILE: { route: 'a/:accountID', - getRoute: (accountID: string | number, backTo?: string) => getUrlWithBackToParam(`a/${accountID}`, backTo), + getRoute: (accountID?: string | number, backTo?: string, login?: string) => { + const baseRoute = getUrlWithBackToParam(`a/${accountID}`, backTo); + const loginParam = login ? `?login=${encodeURIComponent(login)}` : ''; + return `${baseRoute}${loginParam}` as const; + }, }, PROFILE_AVATAR: { route: 'a/:accountID/avatar', getRoute: (accountID: string | number) => `a/${accountID}/avatar` as const, }, - TRANSITION_BETWEEN_APPS: 'transition', - VALIDATE_LOGIN: 'v/:accountID/:validateCode', - CONNECTION_COMPLETE: 'connection-complete', GET_ASSISTANCE: { route: 'get-assistance/:taskID', getRoute: (taskID: string, backTo: string) => getUrlWithBackToParam(`get-assistance/${taskID}`, backTo), }, - UNLINK_LOGIN: 'u/:accountID/:validateCode', - APPLE_SIGN_IN: 'sign-in-with-apple', - GOOGLE_SIGN_IN: 'sign-in-with-google', DESKTOP_SIGN_IN_REDIRECT: 'desktop-signin-redirect', - SAML_SIGN_IN: 'sign-in-with-saml', // This is a special validation URL that will take the user to /workspace/new after validation. This is used // when linking users from e.com in order to share a session in this app. @@ -88,6 +101,13 @@ const ROUTES = { SETTINGS_TIMEZONE_SELECT: 'settings/profile/timezone/select', SETTINGS_PRONOUNS: 'settings/profile/pronouns', SETTINGS_PREFERENCES: 'settings/preferences', + SETTINGS_SUBSCRIPTION: 'settings/subscription', + SETTINGS_SUBSCRIPTION_SIZE: { + route: 'settings/subscription/subscription-size', + getRoute: (canChangeSize: 0 | 1) => `settings/subscription/subscription-size?canChangeSize=${canChangeSize}` as const, + }, + SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD: 'settings/subscription/add-payment-card', + SETTINGS_SUBSCRIPTION_DISABLE_AUTO_RENEW_SURVEY: 'settings/subscription/disable-auto-renew-survey', SETTINGS_PRIORITY_MODE: 'settings/preferences/priority-mode', SETTINGS_LANGUAGE: 'settings/preferences/language', SETTINGS_THEME: 'settings/preferences/theme', @@ -123,9 +143,7 @@ const ROUTES = { }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', - SETTINGS_ADD_BANK_ACCOUNT_REFACTOR: 'settings/wallet/add-bank-account-refactor', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', - SETTINGS_ENABLE_PAYMENTS_REFACTOR: 'settings/wallet/enable-payments-refactor', SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS: { route: 'settings/wallet/card/:domain/digital-details/update-address', getRoute: (domain: string) => `settings/wallet/card/${domain}/digital-details/update-address` as const, @@ -179,7 +197,10 @@ const ROUTES = { SETTINGS_STATUS_CLEAR_AFTER_DATE: 'settings/profile/status/clear-after/date', SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time', SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', - SETTINGS_CONSOLE: 'settings/troubleshoot/console', + SETTINGS_CONSOLE: { + route: 'settings/troubleshoot/console', + getRoute: (backTo?: string) => getUrlWithBackToParam(`settings/troubleshoot/console`, backTo), + }, SETTINGS_SHARE_LOG: { route: 'settings/troubleshoot/console/share-log', getRoute: (source: string) => `settings/troubleshoot/console/share-log?source=${encodeURI(source)}` as const, @@ -209,7 +230,11 @@ const ROUTES = { REPORT: 'r', REPORT_WITH_ID: { route: 'r/:reportID?/:reportActionID?', - getRoute: (reportID: string, reportActionID?: string) => (reportActionID ? (`r/${reportID}/${reportActionID}` as const) : (`r/${reportID}` as const)), + getRoute: (reportID: string, reportActionID?: string, referrer?: string) => { + const baseRoute = reportActionID ? (`r/${reportID}/${reportActionID}` as const) : (`r/${reportID}` as const); + const referrerParam = referrer ? `?referrer=${encodeURIComponent(referrer)}` : ''; + return `${baseRoute}${referrerParam}` as const; + }, }, REPORT_AVATAR: { route: 'r/:reportID/avatar', @@ -227,9 +252,10 @@ const ROUTES = { route: 'r/:reportID/details/shareCode', getRoute: (reportID: string) => `r/${reportID}/details/shareCode` as const, }, - REPORT_ATTACHMENTS: { - route: 'r/:reportID/attachment', - getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURIComponent(source)}` as const, + ATTACHMENTS: { + route: 'attachment', + getRoute: (reportID: string, type: ValueOf, url: string, accountID?: number) => + `attachment?source=${encodeURIComponent(url)}&type=${type}${reportID ? `&reportID=${reportID}` : ''}${accountID ? `&accountID=${accountID}` : ''}` as const, }, REPORT_PARTICIPANTS: { route: 'r/:reportID/participants', @@ -255,13 +281,9 @@ const ROUTES = { route: 'r/:reportID/settings', getRoute: (reportID: string) => `r/${reportID}/settings` as const, }, - REPORT_SETTINGS_ROOM_NAME: { - route: 'r/:reportID/settings/room-name', - getRoute: (reportID: string) => `r/${reportID}/settings/room-name` as const, - }, - REPORT_SETTINGS_GROUP_NAME: { - route: 'r/:reportID/settings/group-name', - getRoute: (reportID: string) => `r/${reportID}/settings/group-name` as const, + REPORT_SETTINGS_NAME: { + route: 'r/:reportID/settings/name', + getRoute: (reportID: string) => `r/${reportID}/settings/name` as const, }, REPORT_SETTINGS_NOTIFICATION_PREFERENCES: { route: 'r/:reportID/settings/notification-preferences', @@ -346,6 +368,27 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => getUrlWithBackToParam(`${action as string}/${iouType as string}/category/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, + SETTINGS_CATEGORIES_ROOT: { + route: 'settings/:policyID/categories', + getRoute: (policyID: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/categories`, backTo), + }, + SETTINGS_CATEGORY_SETTINGS: { + route: 'settings/:policyID/categories/:categoryName', + getRoute: (policyID: string, categoryName: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/categories/${encodeURIComponent(categoryName)}`, backTo), + }, + SETTINGS_CATEGORIES_SETTINGS: { + route: 'settings/:policyID/categories/settings', + getRoute: (policyID: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/categories/settings`, backTo), + }, + SETTINGS_CATEGORY_CREATE: { + route: 'settings/:policyID/categories/new', + getRoute: (policyID: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/categories/new`, backTo), + }, + SETTINGS_CATEGORY_EDIT: { + route: 'settings/:policyID/categories/:categoryName/edit', + getRoute: (policyID: string, categoryName: string, backTo = '') => + getUrlWithBackToParam(`settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/edit`, backTo), + }, MONEY_REQUEST_STEP_CURRENCY: { route: ':action/:iouType/currency/:transactionID/:reportID/:pageIndex?', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, pageIndex = '', currency = '', backTo = '') => @@ -508,10 +551,6 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/invoice-account-select', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/invoice-account-select` as const, }, - POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_PAYABLE_SELECT: { - route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/company-card-expense-account/account-payable-select', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/company-card-expense-account/account-payable-select` as const, - }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_PREFERRED_EXPORTER: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/preferred-exporter', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/preferred-exporter` as const, @@ -640,10 +679,6 @@ const ROUTES = { route: 'settings/workspaces/:policyID/categories/settings', getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories/settings` as const, }, - WORKSPACE_MORE_FEATURES: { - route: 'settings/workspaces/:policyID/more-features', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/more-features` as const, - }, WORKSPACE_CATEGORY_CREATE: { route: 'settings/workspaces/:policyID/categories/new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories/new` as const, @@ -652,6 +687,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/categories/:categoryName/edit', getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/edit` as const, }, + WORKSPACE_MORE_FEATURES: { + route: 'settings/workspaces/:policyID/more-features', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/more-features` as const, + }, WORKSPACE_TAGS: { route: 'settings/workspaces/:policyID/tags', getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags` as const, @@ -665,16 +704,20 @@ const ROUTES = { getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags/settings` as const, }, WORKSPACE_EDIT_TAGS: { - route: 'settings/workspaces/:policyID/tags/edit', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags/edit` as const, + route: 'settings/workspaces/:policyID/tags/:orderWeight/edit', + getRoute: (policyID: string, orderWeight: number) => `settings/workspaces/${policyID}/tags/${orderWeight}/edit` as const, }, WORKSPACE_TAG_EDIT: { - route: 'settings/workspace/:policyID/tag/:tagName/edit', - getRoute: (policyID: string, tagName: string) => `settings/workspace/${policyID}/tag/${encodeURIComponent(tagName)}/edit` as const, + route: 'settings/workspaces/:policyID/tag/:orderWeight/:tagName/edit', + getRoute: (policyID: string, orderWeight: number, tagName: string) => `settings/workspaces/${policyID}/tag/${orderWeight}/${encodeURIComponent(tagName)}/edit` as const, }, WORKSPACE_TAG_SETTINGS: { - route: 'settings/workspaces/:policyID/tag/:tagName', - getRoute: (policyID: string, tagName: string) => `settings/workspaces/${policyID}/tag/${encodeURIComponent(tagName)}` as const, + route: 'settings/workspaces/:policyID/tag/:orderWeight/:tagName', + getRoute: (policyID: string, orderWeight: number, tagName: string) => `settings/workspaces/${policyID}/tag/${orderWeight}/${encodeURIComponent(tagName)}` as const, + }, + WORKSPACE_TAG_LIST_VIEW: { + route: 'settings/workspaces/:policyID/tag-list/:orderWeight', + getRoute: (policyID: string, orderWeight: number) => `settings/workspaces/${policyID}/tag-list/${orderWeight}` as const, }, WORKSPACE_TAXES: { route: 'settings/workspaces/:policyID/taxes', @@ -753,6 +796,14 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates/:rateID/edit', getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/edit` as const, }, + WORKSPACE_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT: { + route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-reclaimable/edit', + getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-reclaimable/edit` as const, + }, + WORKSPACE_DISTANCE_RATE_TAX_RATE_EDIT: { + route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit', + getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const, + }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', @@ -776,13 +827,26 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/xero/import', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/import` as const, }, + POLICY_ACCOUNTING_XERO_CHART_OF_ACCOUNTS: { + route: 'settings/workspaces/:policyID/accounting/xero/import/accounts', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/import/accounts` as const, + }, POLICY_ACCOUNTING_XERO_ORGANIZATION: { route: 'settings/workspaces/:policyID/accounting/xero/organization/:currentOrganizationID', getRoute: (policyID: string, currentOrganizationID: string) => `settings/workspaces/${policyID}/accounting/xero/organization/${currentOrganizationID}` as const, }, + POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES: { + route: 'settings/workspaces/:policyID/accounting/xero/import/tracking-categories', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/import/tracking-categories` as const, + }, + POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES_MAP: { + route: 'settings/workspaces/:policyID/accounting/xero/import/tracking-categories/mapping/:categoryId/:categoryName', + getRoute: (policyID: string, categoryId: string, categoryName: string) => + `settings/workspaces/${policyID}/accounting/xero/import/tracking-categories/mapping/${categoryId}/${encodeURIComponent(categoryName)}` as const, + }, POLICY_ACCOUNTING_XERO_CUSTOMER: { - route: '/settings/workspaces/:policyID/accounting/xero/import/customers', - getRoute: (policyID: string) => `/settings/workspaces/${policyID}/accounting/xero/import/customers` as const, + route: 'settings/workspaces/:policyID/accounting/xero/import/customers', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/import/customers` as const, }, POLICY_ACCOUNTING_XERO_TAXES: { route: 'settings/workspaces/:policyID/accounting/xero/import/taxes', @@ -792,14 +856,34 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/xero/export', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/export` as const, }, + POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT: { + route: 'settings/workspaces/:policyID/connections/xero/export/preferred-exporter/select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/xero/export/preferred-exporter/select` as const, + }, + POLICY_ACCOUNTING_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT: { + route: 'settings/workspaces/:policyID/accounting/xero/export/purchase-bill-date-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/export/purchase-bill-date-select` as const, + }, + POLICY_ACCOUNTING_XERO_EXPORT_BANK_ACCOUNT_SELECT: { + route: 'settings/workspaces/:policyID/accounting/xero/export/bank-account-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/export/bank-account-select` as const, + }, POLICY_ACCOUNTING_XERO_ADVANCED: { route: 'settings/workspaces/:policyID/accounting/xero/advanced', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/advanced` as const, }, + POLICY_ACCOUNTING_XERO_BILL_STATUS_SELECTOR: { + route: 'settings/workspaces/:policyID/accounting/xero/export/purchase-bill-status-selector', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/export/purchase-bill-status-selector` as const, + }, POLICY_ACCOUNTING_XERO_INVOICE_SELECTOR: { route: 'settings/workspaces/:policyID/accounting/xero/advanced/invoice-account-selector', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/advanced/invoice-account-selector` as const, }, + POLICY_ACCOUNTING_XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: { + route: 'settings/workspaces/:policyID/accounting/xero/advanced/bill-payment-account-selector', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/advanced/bill-payment-account-selector` as const, + }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/import', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/import` as const, @@ -836,7 +920,7 @@ const HYBRID_APP_ROUTES = { MONEY_REQUEST_SUBMIT_CREATE: '/submit/new/scan', } as const; -export {HYBRID_APP_ROUTES, getUrlWithBackToParam}; +export {HYBRID_APP_ROUTES, getUrlWithBackToParam, PUBLIC_SCREENS_ROUTES}; export default ROUTES; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/SCREENS.ts b/src/SCREENS.ts index d4057c37b3dd..f884cca94ef5 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -7,7 +7,7 @@ import type DeepValueOf from './types/utils/DeepValueOf'; const PROTECTED_SCREENS = { HOME: 'Home', CONCIERGE: 'Concierge', - REPORT_ATTACHMENTS: 'ReportAttachments', + ATTACHMENTS: 'Attachments', } as const; const SCREENS = { @@ -27,7 +27,6 @@ const SCREENS = { MY_TRIPS: 'Travel_MyTrips', TCS: 'Travel_TCS', }, - WORKSPACES_CENTRAL_PANE: 'WorkspacesCentralPane', SEARCH: { CENTRAL_PANE: 'Search_Central_Pane', REPORT_RHP: 'Search_Report_RHP', @@ -43,7 +42,6 @@ const SCREENS = { APP_DOWNLOAD_LINKS: 'Settings_App_Download_Links', ADD_DEBIT_CARD: 'Settings_Add_Debit_Card', ADD_BANK_ACCOUNT: 'Settings_Add_Bank_Account', - ADD_BANK_ACCOUNT_REFACTOR: 'Settings_Add_Bank_Account_Refactor', CLOSE: 'Settings_Close', TWO_FACTOR_AUTH: 'Settings_TwoFactorAuth', REPORT_CARD_LOST_OR_DAMAGED: 'Settings_ReportCardLostOrDamaged', @@ -90,7 +88,6 @@ const SCREENS = { TRANSFER_BALANCE: 'Settings_Wallet_Transfer_Balance', CHOOSE_TRANSFER_ACCOUNT: 'Settings_Wallet_Choose_Transfer_Account', ENABLE_PAYMENTS: 'Settings_Wallet_EnablePayments', - ENABLE_PAYMENTS_REFACTOR: 'Settings_Wallet_EnablePayments_Refactor', CARD_ACTIVATE: 'Settings_Wallet_Card_Activate', REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud', CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS: 'Settings_Wallet_Cards_Digital_Details_Update_Address', @@ -101,6 +98,13 @@ const SCREENS = { RESPONSE: 'Settings_ExitSurvey_Response', CONFIRM: 'Settings_ExitSurvey_Confirm', }, + + SUBSCRIPTION: { + ROOT: 'Settings_Subscription', + SIZE: 'Settings_Subscription_Size', + ADD_PAYMENT_CARD: 'Settings_Subscription_Add_Payment_Card', + DISABLE_AUTO_RENEW_SURVEY: 'Settings_Subscription_DisableAutoRenewSurvey', + }, }, SAVE_THE_WORLD: { ROOT: 'SaveTheWorld_Root', @@ -109,9 +113,6 @@ const SCREENS = { CHAT_FINDER: 'ChatFinder', WORKSPACE_SWITCHER: 'WorkspaceSwitcher', }, - WORKSPACE_SWITCHER: { - ROOT: 'WorkspaceSwitcher_Root', - }, RIGHT_MODAL: { SETTINGS: 'Settings', NEW_CHAT: 'NewChat', @@ -139,6 +140,7 @@ const SCREENS = { PROCESS_MONEY_REQUEST_HOLD: 'ProcessMoneyRequestHold', TRAVEL: 'Travel', SEARCH_REPORT: 'SearchReport', + SETTINGS_CATEGORIES: 'SettingsCategories', }, ONBOARDING_MODAL: { ONBOARDING: 'Onboarding', @@ -183,10 +185,17 @@ const SCREENS = { ENABLE_PAYMENTS: 'IOU_Send_Enable_Payments', }, + SETTINGS_CATEGORIES: { + SETTINGS_CATEGORY_SETTINGS: 'Settings_Category_Settings', + SETTINGS_CATEGORIES_SETTINGS: 'Settings_Categories_Settings', + SETTINGS_CATEGORY_CREATE: 'Settings_Category_Create', + SETTINGS_CATEGORY_EDIT: 'Settings_Category_Edit', + SETTINGS_CATEGORIES_ROOT: 'Settings_Categories', + }, + REPORT_SETTINGS: { ROOT: 'Report_Settings_Root', - ROOM_NAME: 'Report_Settings_Room_Name', - GROUP_NAME: 'Report_Settings_Group_Name', + NAME: 'Report_Settings_Name', NOTIFICATION_PREFERENCES: 'Report_Settings_Notification_Preferences', WRITE_CAPABILITY: 'Report_Settings_Write_Capability', VISIBILITY: 'Report_Settings_Visibility', @@ -230,7 +239,6 @@ const SCREENS = { QUICKBOOKS_ONLINE_EXPORT_INVOICE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Invoice_Account_Select', QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT: 'Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense', QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense_Account_Select', - QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_PAYABLE_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense_Account_Payable_Select', QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Non_Reimbursable_Default_Vendor_Select', QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense_Select', QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER: 'Workspace_Accounting_Quickbooks_Online_Export_Preferred_Exporter', @@ -242,11 +250,19 @@ const SCREENS = { QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Invoice_Account_Selector', XERO_IMPORT: 'Policy_Accounting_Xero_Import', XERO_ORGANIZATION: 'Policy_Accounting_Xero_Customers', + XERO_CHART_OF_ACCOUNTS: 'Policy_Accounting_Xero_Import_Chart_Of_Accounts', XERO_CUSTOMER: 'Policy_Acounting_Xero_Import_Customer', XERO_TAXES: 'Policy_Accounting_Xero_Taxes', + XERO_TRACKING_CATEGORIES: 'Policy_Accounting_Xero_Tracking_Categories', + XERO_MAP_TRACKING_CATEGORY: 'Policy_Accounting_Xero_Map_Tracking_Category', XERO_EXPORT: 'Policy_Accounting_Xero_Export', + XERO_EXPORT_PURCHASE_BILL_DATE_SELECT: 'Policy_Accounting_Xero_Export_Purchase_Bill_Date_Select', XERO_ADVANCED: 'Policy_Accounting_Xero_Advanced', + XERO_BILL_STATUS_SELECTOR: 'Policy_Accounting_Xero_Export_Bill_Status_Selector', XERO_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Invoice_Account_Selector', + XERO_EXPORT_PREFERRED_EXPORTER_SELECT: 'Workspace_Accounting_Xero_Export_Preferred_Exporter_Select', + XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Bill_Payment_Account_Selector', + XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Policy_Accounting_Xero_Export_Bank_Account_Select', }, INITIAL: 'Workspace_Initial', PROFILE: 'Workspace_Profile', @@ -277,6 +293,7 @@ const SCREENS = { TAX_CREATE: 'Workspace_Tax_Create', TAG_CREATE: 'Tag_Create', TAG_SETTINGS: 'Tag_Settings', + TAG_LIST_VIEW: 'Tag_List_View', CURRENCY: 'Workspace_Profile_Currency', ADDRESS: 'Workspace_Profile_Address', WORKFLOWS: 'Workspace_Workflows', @@ -301,6 +318,8 @@ const SCREENS = { DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings', DISTANCE_RATE_DETAILS: 'Distance_Rate_Details', DISTANCE_RATE_EDIT: 'Distance_Rate_Edit', + DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT: 'Distance_Rate_Tax_Reclaimable_On_Edit', + DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit', }, EDIT_REQUEST: { @@ -352,7 +371,6 @@ const SCREENS = { }, ROOM_MEMBERS_ROOT: 'RoomMembers_Root', ROOM_INVITE_ROOT: 'RoomInvite_Root', - CHAT_FINDER_ROOT: 'ChatFinder_Root', FLAG_COMMENT_ROOT: 'FlagComment_Root', REIMBURSEMENT_ACCOUNT: 'ReimbursementAccount', GET_ASSISTANCE: 'GetAssistance', diff --git a/src/TIMEZONES.ts b/src/TIMEZONES.ts index 238563134872..0fb340a2d88d 100644 --- a/src/TIMEZONES.ts +++ b/src/TIMEZONES.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ // All timezones were taken from: https://raw.githubusercontent.com/leon-do/Timezones/main/timezone.json +import type {TupleToUnion} from 'type-fest'; + const TIMEZONES = [ 'Africa/Abidjan', 'Africa/Accra', @@ -425,7 +427,7 @@ const TIMEZONES = [ * The timezones supported in browser and on native devices differ, so we must map each timezone to its supported equivalent. * Data sourced from https://en.wikipedia.org/wiki/List_of_tz_database_time_zones */ -const timezoneBackwardMap: Record = { +const timezoneBackwardMap: Record> = { 'Africa/Asmera': 'Africa/Nairobi', 'Africa/Timbuktu': 'Africa/Abidjan', 'America/Argentina/ComodRivadavia': 'America/Argentina/Catamarca', @@ -549,6 +551,17 @@ const timezoneBackwardMap: Record = { 'US/Pacific': 'America/Los_Angeles', 'US/Samoa': 'Pacific/Pago_Pago', 'W-SU': 'Europe/Moscow', + CET: 'Europe/Paris', + CST6CDT: 'America/Chicago', + EET: 'Europe/Sofia', + EST: 'America/Cancun', + EST5EDT: 'America/New_York', + HST: 'Pacific/Honolulu', + MET: 'Europe/Paris', + MST: 'America/Phoenix', + MST7MDT: 'America/Denver', + PST8PDT: 'America/Los_Angeles', + WET: 'Europe/Lisbon', }; export {timezoneBackwardMap}; diff --git a/src/components/AccountingListSkeletonView.tsx b/src/components/AccountingListSkeletonView.tsx new file mode 100644 index 000000000000..b977903d3adc --- /dev/null +++ b/src/components/AccountingListSkeletonView.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {Circle, Rect} from 'react-native-svg'; +import ItemListSkeletonView from './Skeletons/ItemListSkeletonView'; + +type AccountingListSkeletonViewProps = { + shouldAnimate?: boolean; +}; + +function AccountingListSkeletonView({shouldAnimate = true}: AccountingListSkeletonViewProps) { + return ( + ( + <> + + + + )} + /> + ); +} + +AccountingListSkeletonView.displayName = 'AccountingListSkeletonView'; + +export default AccountingListSkeletonView; diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx b/src/components/ActiveWorkspaceProvider/index.tsx similarity index 80% rename from src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx rename to src/components/ActiveWorkspaceProvider/index.tsx index 884b9a2a2d95..bc7260cdf10b 100644 --- a/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx +++ b/src/components/ActiveWorkspaceProvider/index.tsx @@ -1,6 +1,6 @@ import React, {useMemo, useState} from 'react'; +import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import ActiveWorkspaceContext from './ActiveWorkspaceContext'; function ActiveWorkspaceContextProvider({children}: ChildrenProps) { const [activeWorkspaceID, setActiveWorkspaceID] = useState(undefined); @@ -10,7 +10,7 @@ function ActiveWorkspaceContextProvider({children}: ChildrenProps) { activeWorkspaceID, setActiveWorkspaceID, }), - [activeWorkspaceID], + [activeWorkspaceID, setActiveWorkspaceID], ); return {children}; diff --git a/src/components/ActiveWorkspaceProvider/index.website.tsx b/src/components/ActiveWorkspaceProvider/index.website.tsx new file mode 100644 index 000000000000..82e46d70f896 --- /dev/null +++ b/src/components/ActiveWorkspaceProvider/index.website.tsx @@ -0,0 +1,29 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext'; +import CONST from '@src/CONST'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +function ActiveWorkspaceContextProvider({children}: ChildrenProps) { + const [activeWorkspaceID, updateActiveWorkspaceID] = useState(undefined); + + const setActiveWorkspaceID = useCallback((workspaceID: string | undefined) => { + updateActiveWorkspaceID(workspaceID); + if (workspaceID && sessionStorage) { + sessionStorage?.setItem(CONST.SESSION_STORAGE_KEYS.ACTIVE_WORKSPACE_ID, workspaceID); + } else { + sessionStorage?.removeItem(CONST.SESSION_STORAGE_KEYS.ACTIVE_WORKSPACE_ID); + } + }, []); + + const value = useMemo( + () => ({ + activeWorkspaceID, + setActiveWorkspaceID, + }), + [activeWorkspaceID, setActiveWorkspaceID], + ); + + return {children}; +} + +export default ActiveWorkspaceContextProvider; diff --git a/src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx b/src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx similarity index 82% rename from src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx rename to src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx index fcbbbbd4af3f..60fa838b0577 100644 --- a/src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx +++ b/src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx @@ -6,9 +6,10 @@ import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; -type WorkspaceOwnerPaymentCardCurrencyModalProps = { +type PaymentCardCurrencyModalProps = { /** Whether the modal is visible */ isVisible: boolean; @@ -25,7 +26,8 @@ type WorkspaceOwnerPaymentCardCurrencyModalProps = { onClose?: () => void; }; -function WorkspaceOwnerPaymentCardCurrencyModal({isVisible, currencies, currentCurrency = CONST.CURRENCY.USD, onCurrencyChange, onClose}: WorkspaceOwnerPaymentCardCurrencyModalProps) { +function PaymentCardCurrencyModal({isVisible, currencies, currentCurrency = CONST.CURRENCY.USD, onCurrencyChange, onClose}: PaymentCardCurrencyModalProps) { + const {isSmallScreenWidth} = useWindowDimensions(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {sections} = useMemo( @@ -51,13 +53,14 @@ function WorkspaceOwnerPaymentCardCurrencyModal({isVisible, currencies, currentC onClose={() => onClose?.()} onModalHide={onClose} hideModalContentWhileAnimating + innerContainerStyle={styles.RHPNavigatorContainer(isSmallScreenWidth)} useNativeDriver > , currency?: ValueOf) => void; + submitButtonText: string; + /** Custom content to display in the footer after card form */ + footerContent?: ReactNode; + /** Custom content to display in the header before card form */ + headerContent?: ReactNode; +}; + +function IAcceptTheLabel() { + const {translate} = useLocalize(); + + return ( + + {`${translate('common.iAcceptThe')}`} + {`${translate('common.addCardTermsOfService')}`} {`${translate('common.and')}`} + {` ${translate('common.privacyPolicy')} `} + + ); +} + +const REQUIRED_FIELDS = [ + INPUT_IDS.NAME_ON_CARD, + INPUT_IDS.CARD_NUMBER, + INPUT_IDS.EXPIRATION_DATE, + INPUT_IDS.ADDRESS_STREET, + INPUT_IDS.SECURITY_CODE, + INPUT_IDS.ADDRESS_ZIP_CODE, + INPUT_IDS.ADDRESS_STATE, +]; + +const CARD_TYPES = { + DEBIT_CARD: 'debit', + PAYMENT_CARD: 'payment', +}; + +const CARD_TYPE_SECTIONS = { + DEFAULTS: 'defaults', + ERROR: 'error', +}; +type CartTypesMap = (typeof CARD_TYPES)[keyof typeof CARD_TYPES]; +type CartTypeSectionsMap = (typeof CARD_TYPE_SECTIONS)[keyof typeof CARD_TYPE_SECTIONS]; + +type CardLabels = Record>>; + +const CARD_LABELS: CardLabels = { + [CARD_TYPES.DEBIT_CARD]: { + [CARD_TYPE_SECTIONS.DEFAULTS]: { + cardNumber: 'addDebitCardPage.debitCardNumber', + nameOnCard: 'addDebitCardPage.nameOnCard', + expirationDate: 'addDebitCardPage.expirationDate', + expiration: 'addDebitCardPage.expiration', + securityCode: 'addDebitCardPage.cvv', + billingAddress: 'addDebitCardPage.billingAddress', + }, + [CARD_TYPE_SECTIONS.ERROR]: { + nameOnCard: 'addDebitCardPage.error.invalidName', + cardNumber: 'addDebitCardPage.error.debitCardNumber', + expirationDate: 'addDebitCardPage.error.expirationDate', + securityCode: 'addDebitCardPage.error.securityCode', + addressStreet: 'addDebitCardPage.error.addressStreet', + addressZipCode: 'addDebitCardPage.error.addressZipCode', + }, + }, + [CARD_TYPES.PAYMENT_CARD]: { + defaults: { + cardNumber: 'addPaymentCardPage.paymentCardNumber', + nameOnCard: 'addPaymentCardPage.nameOnCard', + expirationDate: 'addPaymentCardPage.expirationDate', + expiration: 'addPaymentCardPage.expiration', + securityCode: 'addPaymentCardPage.cvv', + billingAddress: 'addPaymentCardPage.billingAddress', + }, + error: { + nameOnCard: 'addPaymentCardPage.error.invalidName', + cardNumber: 'addPaymentCardPage.error.paymentCardNumber', + expirationDate: 'addPaymentCardPage.error.expirationDate', + securityCode: 'addPaymentCardPage.error.securityCode', + addressStreet: 'addPaymentCardPage.error.addressStreet', + addressZipCode: 'addPaymentCardPage.error.addressZipCode', + }, + }, +}; + +function PaymentCardForm({ + shouldShowPaymentCardForm, + addPaymentCard, + showAcceptTerms, + showAddressField, + showCurrencyField, + isDebitCard, + submitButtonText, + showStateSelector, + footerContent, + headerContent, +}: PaymentCardFormProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const route = useRoute(); + const label = CARD_LABELS[isDebitCard ? CARD_TYPES.DEBIT_CARD : CARD_TYPES.PAYMENT_CARD]; + + const cardNumberRef = useRef(null); + + const [isCurrencyModalVisible, setIsCurrencyModalVisible] = useState(false); + const [currency, setCurrency] = useState(CONST.CURRENCY.USD); + + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = ValidationUtils.getFieldRequiredErrors(values, REQUIRED_FIELDS); + + if (values.nameOnCard && !ValidationUtils.isValidLegalName(values.nameOnCard)) { + errors.nameOnCard = translate('addDebitCardPage.error.invalidName'); + } + + if (values.cardNumber && !ValidationUtils.isValidDebitCard(values.cardNumber.replace(/ /g, ''))) { + errors.cardNumber = translate('addDebitCardPage.error.debitCardNumber'); + } + + if (values.expirationDate && !ValidationUtils.isValidExpirationDate(values.expirationDate)) { + errors.expirationDate = translate('addDebitCardPage.error.expirationDate'); + } + + if (values.securityCode && !ValidationUtils.isValidSecurityCode(values.securityCode)) { + errors.securityCode = translate('addDebitCardPage.error.securityCode'); + } + + if (values.addressStreet && !ValidationUtils.isValidAddress(values.addressStreet)) { + errors.addressStreet = translate('addDebitCardPage.error.addressStreet'); + } + + if (values.addressZipCode && !ValidationUtils.isValidZipCode(values.addressZipCode)) { + errors.addressZipCode = translate('addDebitCardPage.error.addressZipCode'); + } + + if (!values.acceptTerms) { + errors.acceptTerms = translate('common.error.acceptTerms'); + } + + return errors; + }; + + const showCurrenciesModal = useCallback(() => { + setIsCurrencyModalVisible(true); + }, []); + + const changeCurrency = useCallback((newCurrency: keyof typeof CONST.CURRENCY) => { + setCurrency(newCurrency); + setIsCurrencyModalVisible(false); + }, []); + + if (!shouldShowPaymentCardForm) { + return null; + } + + return ( + <> + {headerContent} + addPaymentCard(formData, currency)} + submitButtonText={submitButtonText} + scrollContextEnabled + style={[styles.mh5, styles.flexGrow1]} + > + + + + + + + + + + + {!!showAddressField && ( + + + + )} + + {!!showStateSelector && ( + + + + )} + {!!showCurrencyField && ( + + {(isHovered) => ( + + )} + + )} + {!!showAcceptTerms && ( + + + + )} + + } + currentCurrency={currency} + onCurrencyChange={changeCurrency} + onClose={() => setIsCurrencyModalVisible(false)} + /> + {footerContent} + + + ); +} + +PaymentCardForm.displayName = 'PaymentCardForm'; + +export default PaymentCardForm; diff --git a/src/components/AddPaymentMethodMenu.tsx b/src/components/AddPaymentMethodMenu.tsx index ac9657694500..734e8affa9ea 100644 --- a/src/components/AddPaymentMethodMenu.tsx +++ b/src/components/AddPaymentMethodMenu.tsx @@ -67,7 +67,7 @@ function AddPaymentMethodMenu({ // which then starts a bottom up flow and creates a Collect workspace where the payer is an admin and payee is an employee. const isIOUReport = ReportUtils.isIOUReport(iouReport ?? {}); const canUseBusinessBankAccount = - ReportUtils.isExpenseReport(iouReport ?? {}) || (isIOUReport && !ReportActionsUtils.hasRequestFromCurrentAccount(iouReport?.reportID ?? '', session?.accountID ?? 0)); + ReportUtils.isExpenseReport(iouReport ?? {}) || (isIOUReport && !ReportActionsUtils.hasRequestFromCurrentAccount(iouReport?.reportID ?? '-1', session?.accountID ?? -1)); const canUsePersonalBankAccount = shouldShowPersonalBankAccountOption || isIOUReport; diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx index 366f14ec9780..a112b36705c3 100644 --- a/src/components/AddPlaidBankAccount.tsx +++ b/src/components/AddPlaidBankAccount.tsx @@ -18,7 +18,6 @@ import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlocking import FormHelpMessage from './FormHelpMessage'; import Icon from './Icon'; import getBankIcon from './Icon/BankIcons'; -import Picker from './Picker'; import PlaidLink from './PlaidLink'; import RadioButtons from './RadioButtons'; import Text from './Text'; @@ -59,9 +58,6 @@ type AddPlaidBankAccountProps = AddPlaidBankAccountOnyxProps & { /** Are we adding a withdrawal account? */ allowDebit?: boolean; - /** Is displayed in new VBBA */ - isDisplayedInNewVBBA?: boolean; - /** Is displayed in new enable wallet flow */ isDisplayedInWalletFlow?: boolean; @@ -84,7 +80,6 @@ function AddPlaidBankAccount({ bankAccountID = 0, allowDebit = false, isPlaidDisabled, - isDisplayedInNewVBBA = false, errorText = '', onInputChange = () => {}, isDisplayedInWalletFlow = false, @@ -93,7 +88,7 @@ function AddPlaidBankAccount({ const styles = useThemeStyles(); const plaidBankAccounts = plaidData?.bankAccounts ?? []; const defaultSelectedPlaidAccount = plaidBankAccounts.find((account) => account.plaidAccountID === selectedPlaidAccountID); - const defaultSelectedPlaidAccountID = defaultSelectedPlaidAccount?.plaidAccountID ?? ''; + const defaultSelectedPlaidAccountID = defaultSelectedPlaidAccount?.plaidAccountID ?? '-1'; const defaultSelectedPlaidAccountMask = plaidBankAccounts.find((account) => account.plaidAccountID === selectedPlaidAccountID)?.mask ?? ''; const subscribedKeyboardShortcuts = useRef void>>([]); const previousNetworkState = useRef(); @@ -177,6 +172,7 @@ function AddPlaidBankAccount({ })); const {icon, iconSize, iconStyles} = getBankIcon({styles}); const plaidErrors = plaidData?.errors; + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style const plaidDataErrorMessage = !isEmptyObject(plaidErrors) ? (Object.values(plaidErrors)[0] as string) : ''; const bankName = plaidData?.bankName; @@ -259,62 +255,32 @@ function AddPlaidBankAccount({ return {renderPlaidLink()}; } - if (isDisplayedInNewVBBA || isDisplayedInWalletFlow) { - return ( - - {translate(isDisplayedInWalletFlow ? 'walletPage.chooseYourBankAccount' : 'bankAccount.chooseAnAccount')} - {!!text && {text}} - - - - {bankName} - {selectedPlaidAccountMask.length > 0 && ( - {`${translate('bankAccount.accountEnding')} ${selectedPlaidAccountMask}`} - )} - - - {`${translate('bankAccount.chooseAnAccountBelow')}:`} - - - - ); - } - - // Plaid bank accounts view return ( - {!!text && {text}} - + {translate(isDisplayedInWalletFlow ? 'walletPage.chooseYourBankAccount' : 'bankAccount.chooseAnAccount')} + {!!text && {text}} + - {bankName} - - - + + {bankName} + {selectedPlaidAccountMask.length > 0 && ( + {`${translate('bankAccount.accountEnding')} ${selectedPlaidAccountMask}`} + )} + + {`${translate('bankAccount.chooseAnAccountBelow')}:`} + + ); } diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 9ad4643e834a..27822fb390a6 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -3,8 +3,6 @@ import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ErrorUtils from '@libs/ErrorUtils'; -import type {MaybePhraseKey} from '@libs/Localize'; -import Navigation from '@libs/Navigation/Navigation'; import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; @@ -77,7 +75,7 @@ function AddressForm({ const zipSampleFormat = (country && (CONST.COUNTRY_ZIP_REGEX_DATA[country] as CountryZipRegex)?.samples) ?? ''; - const zipFormat: MaybePhraseKey = ['common.zipCodeExampleFormat', {zipSampleFormat}]; + const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat}); const isUSAForm = country === CONST.COUNTRY.US; @@ -88,50 +86,53 @@ function AddressForm({ * @returns - An object containing the errors for each inputID */ - const validator = useCallback((values: FormOnyxValues): Errors => { - const errors: Errors & { - zipPostCode?: string | string[]; - } = {}; - const requiredFields = ['addressLine1', 'city', 'country', 'state'] as const; - - // Check "State" dropdown is a valid state if selected Country is USA - if (values.country === CONST.COUNTRY.US && !values.state) { - errors.state = 'common.error.fieldRequired'; - } - - // Add "Field required" errors if any required field is empty - requiredFields.forEach((fieldKey) => { - const fieldValue = values[fieldKey] ?? ''; - if (ValidationUtils.isRequiredFulfilled(fieldValue)) { - return; + const validator = useCallback( + (values: FormOnyxValues): Errors => { + const errors: Errors & { + zipPostCode?: string | string[]; + } = {}; + const requiredFields = ['addressLine1', 'city', 'country', 'state'] as const; + + // Check "State" dropdown is a valid state if selected Country is USA + if (values.country === CONST.COUNTRY.US && !values.state) { + errors.state = translate('common.error.fieldRequired'); } - errors[fieldKey] = 'common.error.fieldRequired'; - }); + // Add "Field required" errors if any required field is empty + requiredFields.forEach((fieldKey) => { + const fieldValue = values[fieldKey] ?? ''; + if (ValidationUtils.isRequiredFulfilled(fieldValue)) { + return; + } + + errors[fieldKey] = translate('common.error.fieldRequired'); + }); - // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object - const countryRegexDetails = (values.country ? CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex; + // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object + const countryRegexDetails = (values.country ? CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex; - // The postal code system might not exist for a country, so no regex either for them. - const countrySpecificZipRegex = countryRegexDetails?.regex; - const countryZipFormat = countryRegexDetails?.samples ?? ''; + // The postal code system might not exist for a country, so no regex either for them. + const countrySpecificZipRegex = countryRegexDetails?.regex; + const countryZipFormat = countryRegexDetails?.samples ?? ''; - ErrorUtils.addErrorMessage(errors, 'firstName', 'bankAccount.error.firstName'); + ErrorUtils.addErrorMessage(errors, 'firstName', translate('bankAccount.error.firstName')); - if (countrySpecificZipRegex) { - if (!countrySpecificZipRegex.test(values.zipPostCode?.trim().toUpperCase())) { - if (ValidationUtils.isRequiredFulfilled(values.zipPostCode?.trim())) { - errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', countryZipFormat]; - } else { - errors.zipPostCode = 'common.error.fieldRequired'; + if (countrySpecificZipRegex) { + if (!countrySpecificZipRegex.test(values.zipPostCode?.trim().toUpperCase())) { + if (ValidationUtils.isRequiredFulfilled(values.zipPostCode?.trim())) { + errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat', countryZipFormat); + } else { + errors.zipPostCode = translate('common.error.fieldRequired'); + } } + } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values?.zipPostCode?.trim()?.toUpperCase() ?? '')) { + errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat'); } - } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values?.zipPostCode?.trim()?.toUpperCase() ?? '')) { - errors.zipPostCode = 'privatePersonalDetails.error.incorrectZipFormat'; - } - return errors; - }, []); + return errors; + }, + [translate], + ); return ( { onAddressChanged(data, key); - // This enforces the country selector to use the country from address instead of the country from URL - Navigation.setParams({country: undefined}); }} defaultValue={street1} renamedInputKeys={{ diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 31ccfc954513..17a2f6212447 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -263,7 +263,7 @@ function AddressSearch( } setIsFetchingCurrentLocation(false); - setLocationErrorCode(errorData.code); + setLocationErrorCode(errorData?.code ?? null); }, { maximumAge: 0, // No cache, always get fresh location info diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index bc7acf3f7e40..82e4c3c3fc37 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -1,7 +1,6 @@ import type {RefObject} from 'react'; import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, View, ViewStyle} from 'react-native'; import type {Place} from 'react-native-google-places-autocomplete'; -import type {MaybePhraseKey} from '@libs/Localize'; import type Locale from '@src/types/onyx/Locale'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; @@ -35,7 +34,7 @@ type AddressSearchProps = { onBlur?: () => void; /** Error text to display */ - errorText?: MaybePhraseKey; + errorText?: string; /** Hint text to display */ hint?: string; diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index a102b715d526..3319a28c58b9 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -12,6 +12,7 @@ import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST'; import BigNumberPad from './BigNumberPad'; import FormHelpMessage from './FormHelpMessage'; +import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; import type TextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types'; @@ -94,7 +95,7 @@ function AmountForm( if (!textInput.current) { return; } - if (!textInput.current.isFocused()) { + if (!isTextInputFocused(textInput)) { textInput.current.focus(); } }; @@ -143,7 +144,7 @@ function AmountForm( */ const updateAmountNumberPad = useCallback( (key: string) => { - if (shouldUpdateSelection && !textInput.current?.isFocused()) { + if (shouldUpdateSelection && !isTextInputFocused(textInput)) { textInput.current?.focus(); } // Backspace button is pressed @@ -168,7 +169,7 @@ function AmountForm( */ const updateLongPressHandlerState = useCallback((value: boolean) => { setShouldUpdateSelection(!value); - if (!value && !textInput.current?.isFocused()) { + if (!value && !isTextInputFocused(textInput)) { textInput.current?.focus(); } }, []); diff --git a/src/components/AmountPicker/index.tsx b/src/components/AmountPicker/index.tsx index 45e511f24748..b84ec19e2ffd 100644 --- a/src/components/AmountPicker/index.tsx +++ b/src/components/AmountPicker/index.tsx @@ -1,18 +1,13 @@ import React, {forwardRef, useState} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; +import CONST from '@src/CONST'; import callOrReturn from '@src/types/utils/callOrReturn'; import AmountSelectorModal from './AmountSelectorModal'; import type {AmountPickerProps} from './types'; function AmountPicker({value, description, title, errorText = '', onInputChange, furtherDetails, rightLabel, ...rest}: AmountPickerProps, forwardedRef: ForwardedRef) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); const [isPickerVisible, setIsPickerVisible] = useState(false); const showPickerModal = () => { @@ -31,23 +26,19 @@ function AmountPicker({value, description, title, errorText = '', onInputChange, hidePickerModal(); }; - const descStyle = !value || value.length === 0 ? StyleUtils.getFontSizeStyle(variables.fontSizeLabel) : null; - return ( - - - string); /** Form Error description */ - errorText?: MaybePhraseKey; + errorText?: string; /** Callback to call when the input changes */ onInputChange?: (value: string | undefined) => void; diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx index da8e3694a7d2..48604ec364c7 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx @@ -7,6 +7,7 @@ import {ShowContextMenuContext, showContextMenuForReport} from '@components/Show import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import * as Browser from '@libs/Browser'; import fileDownload from '@libs/fileDownload'; import * as ReportUtils from '@libs/ReportUtils'; import * as Download from '@userActions/Download'; @@ -48,12 +49,11 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow return; } Download.setDownload(sourceID, true); - fileDownload(sourceURLWithAuth, displayName).then(() => Download.setDownload(sourceID, false)); + fileDownload(sourceURLWithAuth, displayName, '', Browser.isMobileSafari()).then(() => Download.setDownload(sourceID, false)); }} onPressIn={onPressIn} onPressOut={onPressOut} - // @ts-expect-error TODO: Remove this once ShowContextMenuContext (https://github.com/Expensify/App/issues/24980) is migrated to TypeScript. - onLongPress={(event) => showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} shouldUseHapticsOnLongPress accessibilityLabel={displayName} role={CONST.ROLE.BUTTON} @@ -63,6 +63,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow file={{name: displayName}} shouldShowDownloadIcon={!isOffline} shouldShowLoadingSpinnerIcon={isDownloading} + isUsedAsChatAttachment /> )} diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx index 99a0ee3bf683..4c470858292c 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx @@ -1,4 +1,4 @@ -import Str from 'expensify-common/lib/str'; +import {Str} from 'expensify-common'; import React, {useEffect, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText} from 'react-native'; @@ -6,9 +6,9 @@ import {StyleSheet} from 'react-native'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; @@ -30,7 +30,7 @@ function BaseAnchorForCommentsOnly({onPressIn, onPressOut, href = '', rel = '', [], ); - const {isSmallScreenWidth} = useWindowDimensions(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const linkProps: LinkProps = {}; if (onPress) { @@ -38,7 +38,7 @@ function BaseAnchorForCommentsOnly({onPressIn, onPressOut, href = '', rel = '', } else { linkProps.href = href; } - const defaultTextStyle = DeviceCapabilities.canUseTouchScreen() || isSmallScreenWidth ? {} : {...styles.userSelectText, ...styles.cursorPointer}; + const defaultTextStyle = DeviceCapabilities.canUseTouchScreen() || shouldUseNarrowLayout ? {} : {...styles.userSelectText, ...styles.cursorPointer}; const isEmail = Str.isValidEmail(href.replace(/mailto:/i, '')); return ( diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx index 9713e40136a2..bcc5acf83653 100644 --- a/src/components/ArchivedReportFooter.tsx +++ b/src/components/ArchivedReportFooter.tsx @@ -32,15 +32,15 @@ function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}} const originalMessage = reportClosedAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED ? reportClosedAction.originalMessage : null; const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT; - const actorPersonalDetails = personalDetails?.[reportClosedAction?.actorAccountID ?? 0]; + const actorPersonalDetails = personalDetails?.[reportClosedAction?.actorAccountID ?? -1]; let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(actorPersonalDetails); let oldDisplayName: string | undefined; if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) { const newAccountID = originalMessage?.newAccountID; const oldAccountID = originalMessage?.oldAccountID; - displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[newAccountID ?? 0]); - oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[oldAccountID ?? 0]); + displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[newAccountID ?? -1]); + oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[oldAccountID ?? -1]); } const shouldRenderHTML = archiveReason !== CONST.REPORT.ARCHIVE_REASON.DEFAULT; @@ -64,7 +64,7 @@ function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}} return ( this.props.maxIndex && this.props.shouldResetIndexOnEndReached) { - this.onArrowDownKey(); - } - } - - componentWillUnmount() { - if (this.unsubscribeArrowUpKey) { - this.unsubscribeArrowUpKey(); - } - - if (this.unsubscribeArrowDownKey) { - this.unsubscribeArrowDownKey(); - } - } - - onArrowUpKey() { - if (this.props.maxIndex < 0 || !this.props.isFocused) { - return; - } - - const currentFocusedIndex = this.props.focusedIndex > 0 ? this.props.focusedIndex - 1 : this.props.maxIndex; - let newFocusedIndex = currentFocusedIndex; - - while (this.props.disabledIndexes.includes(newFocusedIndex)) { - newFocusedIndex = newFocusedIndex > 0 ? newFocusedIndex - 1 : this.props.maxIndex; - if (newFocusedIndex === currentFocusedIndex) { - // all indexes are disabled - return; // no-op - } - } - - this.props.onFocusedIndexChanged(newFocusedIndex); - } - - onArrowDownKey() { - if (this.props.maxIndex < 0 || !this.props.isFocused) { - return; - } - - const currentFocusedIndex = this.props.focusedIndex < this.props.maxIndex ? this.props.focusedIndex + 1 : 0; - let newFocusedIndex = currentFocusedIndex; - - while (this.props.disabledIndexes.includes(newFocusedIndex)) { - newFocusedIndex = newFocusedIndex < this.props.maxIndex ? newFocusedIndex + 1 : 0; - if (newFocusedIndex === currentFocusedIndex) { - // all indexes are disabled - return; // no-op - } - } - - this.props.onFocusedIndexChanged(newFocusedIndex); - } - - render() { - return this.props.children; - } -} - -function ArrowKeyFocusManager(props) { - const isFocused = useIsFocused(); - - return ( - - ); -} - -BaseArrowKeyFocusManager.propTypes = propTypes; -BaseArrowKeyFocusManager.defaultProps = defaultProps; -ArrowKeyFocusManager.displayName = 'ArrowKeyFocusManager'; - -export default ArrowKeyFocusManager; diff --git a/src/components/AttachmentContext.ts b/src/components/AttachmentContext.ts new file mode 100644 index 000000000000..4ed6bdc9084f --- /dev/null +++ b/src/components/AttachmentContext.ts @@ -0,0 +1,22 @@ +import {createContext} from 'react'; +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type AttachmentContextProps = { + type?: ValueOf; + reportID?: string; + accountID?: number; +}; + +const AttachmentContext = createContext({ + type: undefined, + reportID: undefined, + accountID: undefined, +}); + +AttachmentContext.displayName = 'AttachmentContext'; + +export { + // eslint-disable-next-line import/prefer-default-export + AttachmentContext, +}; diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index ab322aa1bd91..ae09757b66e6 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -1,12 +1,14 @@ -import Str from 'expensify-common/lib/str'; +import {Str} from 'expensify-common'; import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; import {Animated, Keyboard, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {useSharedValue} from 'react-native-reanimated'; +import type {ValueOf} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -16,7 +18,6 @@ import * as FileUtils from '@libs/fileDownload/FileUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; -import useNativeDriver from '@libs/useNativeDriver'; import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; @@ -51,15 +52,6 @@ import SafeAreaConsumer from './SafeAreaConsumer'; type AttachmentModalOnyxProps = { /** The transaction associated with the receipt attachment, if any */ transaction: OnyxEntry; - - /** The report associated with the receipt attachment, if any */ - parentReport: OnyxEntry; - - /** The policy associated with the receipt attachment, if any */ - policy: OnyxEntry; - - /** The list of report actions associated with the receipt attachment, if any */ - parentReportActions: OnyxEntry; }; type ImagePickerResponse = { @@ -109,6 +101,12 @@ type AttachmentModalProps = AttachmentModalOnyxProps & { /** The report that has this attachment */ report?: OnyxEntry | EmptyObject; + /** The type of the attachment */ + type?: ValueOf; + + /** If the attachment originates from a note, the accountID will represent the author of that note. */ + accountID?: number; + /** Optional callback to fire when we want to do something after modal show. */ onModalShow?: () => void; @@ -157,16 +155,15 @@ function AttachmentModal({ isWorkspaceAvatar = false, maybeIcon = false, transaction, - parentReport, - parentReportActions, headerTitle, - policy, children, fallbackSource, canEditReceipt = false, onModalClose = () => {}, isLoading = false, shouldShowNotFoundPage = false, + type = undefined, + accountID = undefined, }: AttachmentModalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -182,8 +179,9 @@ function AttachmentModal({ const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); const [confirmButtonFadeAnimation] = useState(() => new Animated.Value(1)); const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = React.useState(true); + const {windowWidth} = useWindowDimensions(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const nope = useSharedValue(false); - const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && isAttachmentInvalid); const iouType = useMemo(() => (isTrackExpenseAction ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT), [isTrackExpenseAction]); @@ -283,9 +281,9 @@ function AttachmentModal({ * Detach the receipt and close the modal. */ const deleteAndCloseModal = useCallback(() => { - IOU.detachReceipt(transaction?.transactionID ?? ''); + IOU.detachReceipt(transaction?.transactionID ?? '-1'); setIsDeleteReceiptConfirmModalVisible(false); - Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID ?? '')); + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID ?? '-1')); }, [transaction, report]); const isValidFile = useCallback((fileObject: FileObject) => { @@ -360,28 +358,6 @@ function AttachmentModal({ [isValidFile, getModalType, isDirectoryCheck], ); - /** - * In order to gracefully hide/show the confirm button when the keyboard - * opens/closes, apply an animation to fade the confirm button out/in. And since - * we're only updating the opacity of the confirm button, we must also conditionally - * disable it. - * - * @param shouldFadeOut If true, fade out confirm button. Otherwise fade in. - */ - const updateConfirmButtonVisibility = useCallback( - (shouldFadeOut: boolean) => { - setIsConfirmButtonDisabled(shouldFadeOut); - const toValue = shouldFadeOut ? 0 : 1; - - Animated.timing(confirmButtonFadeAnimation, { - toValue, - duration: 100, - useNativeDriver, - }).start(); - }, - [confirmButtonFadeAnimation], - ); - /** * close the modal */ @@ -413,7 +389,7 @@ function AttachmentModal({ const sourceForAttachmentView = sourceState || source; const threeDotsMenuItems = useMemo(() => { - if (!isReceiptAttachment || !parentReport || !parentReportActions) { + if (!isReceiptAttachment) { return []; } @@ -428,8 +404,8 @@ function AttachmentModal({ ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( CONST.IOU.ACTION.EDIT, iouType, - transaction?.transactionID ?? '', - report?.reportID ?? '', + transaction?.transactionID ?? '-1', + report?.reportID ?? '-1', Navigation.getActiveRouteWithoutParams(), ), ); @@ -454,7 +430,7 @@ function AttachmentModal({ } return menuItems; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isReceiptAttachment, parentReport, parentReportActions, policy, transaction, file, sourceState, iouType]); + }, [isReceiptAttachment, transaction, file, sourceState, iouType]); // There are a few things that shouldn't be set until we absolutely know if the file is a receipt or an attachment. // props.isReceiptAttachment will be null until its certain what the file is, in which case it will then be true|false. @@ -463,12 +439,12 @@ function AttachmentModal({ let shouldShowThreeDotsButton = false; if (!isEmptyObject(report)) { headerTitleNew = translate(isReceiptAttachment ? 'common.receipt' : 'common.attachment'); - shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !isReceiptAttachment && !isOffline; + shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isReceiptAttachment && !isOffline; shouldShowThreeDotsButton = isReceiptAttachment && isModalOpen && threeDotsMenuItems.length !== 0; } const context = useMemo( () => ({ - pagerItems: [], + pagerItems: [{source: sourceForAttachmentView, index: 0, isActive: true}], activePage: 0, pagerRef: undefined, isPagerScrolling: nope, @@ -477,7 +453,7 @@ function AttachmentModal({ onScaleChanged: () => {}, onSwipeDown: closeModal, }), - [closeModal, nope], + [closeModal, nope, sourceForAttachmentView], ); return ( @@ -498,14 +474,14 @@ function AttachmentModal({ propagateSwipe > - {isSmallScreenWidth && } + {shouldUseNarrowLayout && } downloadAttachment()} - shouldShowCloseButton={!isSmallScreenWidth} - shouldShowBackButton={isSmallScreenWidth} + shouldShowCloseButton={!shouldUseNarrowLayout} + shouldShowBackButton={shouldUseNarrowLayout} onBackButtonPress={closeModal} onCloseButtonPress={closeModal} shouldShowThreeDotsButton={shouldShowThreeDotsButton} @@ -527,45 +503,47 @@ function AttachmentModal({ onLinkPress={() => Navigation.dismissModal()} /> )} - {!isEmptyObject(report) && !isReceiptAttachment ? ( - - ) : ( - !!sourceForAttachmentView && - shouldLoadAttachment && - !isLoading && - !shouldShowNotFoundPage && ( - - - - ) - )} + {!shouldShowNotFoundPage && + (!isEmptyObject(report) && !isReceiptAttachment ? ( + + ) : ( + !!sourceForAttachmentView && + shouldLoadAttachment && + !isLoading && ( + + + + ) + ))} {/* If we have an onConfirm method show a confirmation button */} - {!!onConfirm && ( + {!!onConfirm && !isConfirmButtonDisabled && ( {({safeAreaPaddingBottomStyle}) => (