diff --git a/.eslintrc.js b/.eslintrc.js index 54d035ab3850..6194ccd39d3f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,26 @@ const restrictedImportPaths = [ importNames: ['CSSProperties'], message: "Please use 'ViewStyle', 'TextStyle', 'ImageStyle' from 'react-native' instead.", }, + { + name: '@styles/index', + importNames: ['default', 'defaultStyles'], + message: 'Do not import styles directly. Please use the `useThemeStyles` hook or `withThemeStyles` HOC instead.', + }, + { + name: '@styles/utils', + importNames: ['default', 'DefaultStyleUtils'], + message: 'Do not import StyleUtils directly. Please use the `useStyleUtils` hook or `withStyleUtils` HOC instead.', + }, + { + name: '@styles/theme', + importNames: ['default', 'defaultTheme'], + + message: 'Do not import themes directly. Please use the `useTheme` hook or `withTheme` HOC instead.', + }, + { + name: '@styles/theme/illustrations', + message: 'Do not import theme illustrations directly. Please use the `useThemeIllustrations` hook instead.', + }, ]; const restrictedImportPatterns = [ @@ -31,6 +51,18 @@ const restrictedImportPatterns = [ group: ['**/assets/animations/**/*.json'], message: "Do not import animations directly. Please use the 'src/components/LottieAnimations' import instead.", }, + { + group: ['@styles/theme/themes/**'], + message: 'Do not import themes directly. Please use the `useTheme` hook or `withTheme` HOC 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.', + }, + { + group: ['@styles/theme/illustrations/themes/**'], + message: 'Do not import theme illustrations directly. Please use the `useThemeIllustrations` hook instead.', + }, ]; module.exports = { diff --git a/.github/actions/javascript/validateReassureOutput/action.yml b/.github/actions/javascript/validateReassureOutput/action.yml index 4fd53e838fb5..b3b18c244a8f 100644 --- a/.github/actions/javascript/validateReassureOutput/action.yml +++ b/.github/actions/javascript/validateReassureOutput/action.yml @@ -7,9 +7,6 @@ inputs: COUNT_DEVIATION: description: Allowable deviation for the mean count in regression test results. required: true - REGRESSION_OUTPUT: - description: Refers to the results obtained from regression tests `.reassure/output.json`. - required: true runs: using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/validateReassureOutput/index.js b/.github/actions/javascript/validateReassureOutput/index.js index 6cc59af1de48..e70c379697cd 100644 --- a/.github/actions/javascript/validateReassureOutput/index.js +++ b/.github/actions/javascript/validateReassureOutput/index.js @@ -8,9 +8,10 @@ /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { const core = __nccwpck_require__(186); +const fs = __nccwpck_require__(147); const run = () => { - const regressionOutput = JSON.parse(core.getInput('REGRESSION_OUTPUT', {required: true})); + const regressionOutput = JSON.parse(fs.readFileSync('.reassure/output.json', 'utf8')); const countDeviation = core.getInput('COUNT_DEVIATION', {required: true}); const durationDeviation = core.getInput('DURATION_DEVIATION_PERCENTAGE', {required: true}); diff --git a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.js b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.js index da81d88c9885..214dc9e8b6d4 100644 --- a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.js +++ b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.js @@ -1,7 +1,8 @@ const core = require('@actions/core'); +const fs = require('fs'); const run = () => { - const regressionOutput = JSON.parse(core.getInput('REGRESSION_OUTPUT', {required: true})); + const regressionOutput = JSON.parse(fs.readFileSync('.reassure/output.json', 'utf8')); const countDeviation = core.getInput('COUNT_DEVIATION', {required: true}); const durationDeviation = core.getInput('DURATION_DEVIATION_PERCENTAGE', {required: true}); diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index c49530c46faa..116f178868c1 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -36,17 +36,10 @@ jobs: npm install --force npx reassure --branch - - name: Read output.json - id: reassure - uses: juliangruber/read-file-action@v1 - with: - path: .reassure/output.json - - name: Validate output.json id: validateReassureOutput uses: ./.github/actions/javascript/validateReassureOutput with: DURATION_DEVIATION_PERCENTAGE: 20 COUNT_DEVIATION: 0 - REGRESSION_OUTPUT: ${{ steps.reassure.outputs.content }} diff --git a/android/app/build.gradle b/android/app/build.gradle index 4c6289456041..63aa4215cd90 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -96,8 +96,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001042104 - versionName "1.4.21-4" + versionCode 1001042203 + versionName "1.4.22-3" } flavorDimensions "default" diff --git a/assets/emojis/index.ts b/assets/emojis/index.ts index 5e5565da1499..02328001674e 100644 --- a/assets/emojis/index.ts +++ b/assets/emojis/index.ts @@ -1,10 +1,13 @@ +import type {Locale} from '@src/types/onyx'; import emojis from './common'; import enEmojis from './en'; import esEmojis from './es'; -import type {Emoji} from './types'; +import type {Emoji, EmojisList} from './types'; type EmojiTable = Record<string, Emoji>; +type LocaleEmojis = Partial<Record<Locale, EmojisList>>; + const emojiNameTable = emojis.reduce<EmojiTable>((prev, cur) => { const newValue = prev; if (!('header' in cur) && cur.name) { @@ -26,10 +29,10 @@ const emojiCodeTableWithSkinTones = emojis.reduce<EmojiTable>((prev, cur) => { return newValue; }, {}); -const localeEmojis = { +const localeEmojis: LocaleEmojis = { en: enEmojis, es: esEmojis, -} as const; +}; export default emojis; export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis}; diff --git a/assets/emojis/types.ts b/assets/emojis/types.ts index e8c222fde948..e659924a7fa4 100644 --- a/assets/emojis/types.ts +++ b/assets/emojis/types.ts @@ -3,7 +3,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; type Emoji = { code: string; name: string; - types?: string[]; + types?: readonly string[]; }; type HeaderEmoji = { @@ -12,8 +12,10 @@ type HeaderEmoji = { code: string; }; -type PickerEmojis = Array<Emoji | HeaderEmoji>; +type PickerEmoji = Emoji | HeaderEmoji; + +type PickerEmojis = PickerEmoji[]; type EmojisList = Record<string, {keywords: string[]; name?: string}>; -export type {Emoji, HeaderEmoji, EmojisList, PickerEmojis}; +export type {Emoji, HeaderEmoji, EmojisList, PickerEmojis, PickerEmoji}; diff --git a/assets/images/simple-illustrations/simple-illustration__commentbubbles.svg b/assets/images/simple-illustrations/simple-illustration__commentbubbles.svg new file mode 100644 index 000000000000..829d3ee2e3fe --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__commentbubbles.svg @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 27.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 68 68" style="enable-background:new 0 0 68 68;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#E96DF2;} + .st1{fill:#F9B5FE;} + .st2{fill:none;stroke:#002140;stroke-linecap:round;stroke-linejoin:round;} +</style> +<path class="st0" d="M12.75,37.082c0,0-5.432-7.6-3.379-12.306c2.048-4.71,4.946-12.07,18.824-12.19 + c13.877-0.12,18.223,12.791,17.501,17.136c-0.722,4.345-6.758,11.585-13.637,12.671s-10.138-0.12-10.138-0.12 + s-6.638,3.74-13.396,1.688C8.525,43.96,12.024,39.615,12.75,37.082z"/> +<path class="st1" d="M45.21,24.411c0,0,1.932,9.777-5.067,14.242c-6.033,3.62-9.172,4.706-17.257,4.105c0,0,0.726,4.947,6.277,8.205 + s12.671,3.984,15.93,2.533c0,0,9.171,3.139,13.517,1.567c0,0-4.225-5.552-4.105-7.239c0,0,4.826-4.586,3.62-10.863 + c-1.207-6.277-7.724-12.19-12.911-12.551H45.21z"/> +<path class="st2" d="M12.682,36.829c-0.457,3.551-4.277,7.223-4.277,7.223s9.584,1.022,13.942-1.607"/> +<path class="st2" d="M22.37,42.441c1.96,6.662,9.139,11.601,17.694,11.601c1.732,0,3.407-0.2,4.995-0.581"/> +<path class="st2" d="M54.723,47.845c2.241-2.537,3.564-5.672,3.564-9.071c0-6.851-5.387-12.647-12.803-14.579"/> +<path class="st2" d="M54.723,47.845C55.18,51.396,59,55.068,59,55.068s-9.584,1.022-13.942-1.607"/> +<path class="st2" d="M12.682,36.829c-2.241-2.537-3.564-5.672-3.564-9.071c0-8.43,8.157-15.264,18.223-15.264 + c10.065,0,18.223,6.834,18.223,15.268c0,8.434-8.157,15.268-18.223,15.268c-1.732,0-3.407-0.2-4.995-0.581"/> +</svg> diff --git a/assets/images/simple-illustrations/simple-illustration__hourglass.svg b/assets/images/simple-illustrations/simple-illustration__hourglass.svg new file mode 100644 index 000000000000..539e1e45b795 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__hourglass.svg @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 27.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 68 68" style="enable-background:new 0 0 68 68;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#0164BF;stroke:#002140;stroke-linecap:round;stroke-linejoin:round;} + .st1{fill:#B0D9FF;} + .st2{fill:#FFFAF0;} + .st3{fill:#FED607;} + .st4{fill:none;stroke:#002140;stroke-linecap:round;stroke-linejoin:round;} +</style> +<path class="st0" d="M42.97,59c0.508,0,1.959-0.373,1.959-1.847s-1.397-1.847-1.959-1.847c-0.261,0-17.466,0-17.709,0 + c-0.508,0-1.959,0.373-1.959,1.847S24.699,59,25.261,59H42.97z"/> +<path class="st0" d="M42.97,12.164c0.508,0,1.959-0.373,1.959-1.847c0-1.474-1.397-1.847-1.959-1.847H25.261 + c-0.508,0-1.959,0.373-1.959,1.847c0,1.474,1.397,1.847,1.959,1.847H42.97z"/> +<path class="st1" d="M45.72,14.024c2.382,1.784,3.154,4.35,2.382,7.284c-1.424,5.406-11.144,8.529-11.211,12.429v0.04 + c0.067,3.9,9.782,7.023,11.211,12.429c0.773,2.934,0,5.5-2.382,7.284c-2.382,1.784-6.412,2.498-11.458,2.498h-0.193 + c-5.046,0-9.077-0.715-11.459-2.498c-2.382-1.784-3.154-4.35-2.377-7.284c1.425-5.406,11.144-8.529,11.207-12.429v-0.04 + c-0.067-3.9-9.782-7.023-11.207-12.429c-0.773-2.934-0.004-5.5,2.377-7.284s6.412-2.498,11.459-2.498h0.193 + C39.308,11.526,43.339,12.24,45.72,14.024z M42.768,52.116c1.186-0.593,1.33-1.361,0.445-1.968c0,0-5.94-4.035-8.947-4.035h-0.099 + c-3.006,0-8.947,4.035-8.947,4.035c-0.885,0.607-0.746,1.375,0.44,1.968c1.258,0.629,2.889,1.191,8.461,1.191h0.189 + c5.572,0,7.203-0.562,8.461-1.191H42.768z M34.787,33.369c0-0.445-0.36-0.809-0.809-0.809c-0.449,0-0.809,0.364-0.809,0.809 + c0,0.445,0.36,0.809,0.809,0.809C34.428,34.178,34.787,33.814,34.787,33.369z M34.787,39.943c0-0.445-0.36-0.809-0.809-0.809 + c-0.449,0-0.809,0.364-0.809,0.809s0.36,0.809,0.809,0.809C34.428,40.752,34.787,40.388,34.787,39.943z"/> +<path class="st2" d="M44.277,43.668c2.377,2.444,3.155,5.127,1.645,5.554c-1.708,0.476-1.64-1.802-4.732-4.193 + c-2.764-2.134-3.191-3.091-2.413-3.954C39.46,40.312,41.343,40.653,44.277,43.668z"/> +<path class="st3" d="M43.208,50.152c0.885,0.607,0.746,1.375-0.445,1.968c-1.258,0.629-2.889,1.191-8.461,1.191h-0.189 + c-5.572,0-7.203-0.562-8.461-1.191c-1.186-0.593-1.326-1.362-0.44-1.968c0,0,5.941-4.035,8.947-4.035h0.099 + c3.006,0,8.947,4.035,8.947,4.035H43.208z"/> +<path class="st3" d="M41.532,22.962c0.706,0,0.998,0.836,0.422,1.218c0,0-5.217,4.354-7.891,4.354s-8.07-4.354-8.07-4.354 + c-0.571-0.382-0.283-1.218,0.422-1.218h15.112H41.532z"/> +<path class="st3" d="M33.978,32.564c0.449,0,0.809,0.364,0.809,0.809s-0.36,0.809-0.809,0.809c-0.449,0-0.809-0.364-0.809-0.809 + S33.529,32.564,33.978,32.564z"/> +<path class="st3" d="M33.978,39.139c0.449,0,0.809,0.364,0.809,0.809c0,0.445-0.36,0.809-0.809,0.809 + c-0.449,0-0.809-0.364-0.809-0.809C33.169,39.503,33.529,39.139,33.978,39.139z"/> +<path class="st2" d="M45.177,15.795c2.098,2.507,1.609,6.956-0.234,6.938c-1.609-0.014-0.126-1.546-1.267-4.04 + c-1.141-2.494-3.73-2.746-2.894-4.201c0.62-1.087,2.894-0.49,4.395,1.308V15.795z"/> +<path class="st4" d="M31.435,33.778c-0.067,3.9-9.782,7.023-11.207,12.429c-0.773,2.934-0.005,5.5,2.377,7.284 + c2.382,1.784,6.412,2.498,11.459,2.498"/> +<path class="st4" d="M36.89,33.778c0.067,3.9,9.782,7.023,11.211,12.429c0.773,2.934,0,5.5-2.382,7.284 + c-2.382,1.784-6.412,2.498-11.459,2.498"/> +<path class="st4" d="M36.89,33.778v-0.04c0.067-3.9,9.782-7.023,11.211-12.429c0.773-2.934,0-5.5-2.382-7.284 + s-6.412-2.498-11.459-2.498"/> +<path class="st4" d="M31.435,33.778v-0.04c-0.067-3.9-9.782-7.023-11.207-12.429c-0.773-2.934-0.005-5.5,2.377-7.284 + s6.412-2.498,11.459-2.498"/> +<path class="st4" d="M26.42,22.962h15.112c0.706,0,0.998,0.836,0.422,1.218c0,0-5.217,4.354-7.891,4.354s-8.07-4.354-8.07-4.354 + c-0.571-0.382-0.283-1.218,0.422-1.218H26.42z"/> +<path class="st4" d="M34.113,53.307c-5.572,0-7.203-0.562-8.461-1.191c-1.186-0.593-1.326-1.362-0.44-1.968 + c0,0,5.941-4.035,8.947-4.035"/> +<path class="st4" d="M34.302,53.307c5.572,0,7.203-0.562,8.461-1.191c1.186-0.593,1.33-1.362,0.445-1.968 + c0,0-5.941-4.035-8.947-4.035"/> +<path class="st4" d="M34.787,33.373c0,0.445-0.36,0.809-0.809,0.809c-0.449,0-0.809-0.364-0.809-0.809s0.36-0.809,0.809-0.809 + C34.428,32.564,34.787,32.928,34.787,33.373z"/> +<path class="st4" d="M34.787,39.947c0,0.445-0.36,0.809-0.809,0.809c-0.449,0-0.809-0.364-0.809-0.809 + c0-0.445,0.36-0.809,0.809-0.809C34.428,39.139,34.787,39.503,34.787,39.947z"/> +</svg> diff --git a/assets/images/simple-illustrations/simple-illustration__trashcan.svg b/assets/images/simple-illustrations/simple-illustration__trashcan.svg new file mode 100644 index 000000000000..4e66efa0a67e --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__trashcan.svg @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 27.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 68 68" style="enable-background:new 0 0 68 68;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#4BA6A6;} + .st1{fill:#002140;} + .st2{fill:#FFED8F;} + .st3{fill:#FFFAF0;} + .st4{fill:#FBCCFF;} + .st5{fill:none;stroke:#002140;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;} +</style> +<path class="st0" d="M16.303,18.365l3.777,36.478c0,0,4.688,3.777,14.591,3.648c9.903-0.13,13.68-3.907,13.68-3.907L52,18.365 + c0,0-5.733,2.996-14.069,2.867C29.595,21.102,19.562,20.972,16.303,18.365L16.303,18.365z"/> +<path class="st1" d="M18.32,18.691l-0.715-2.607c0,0-1.304,0.455-1.174,1.5C16.561,18.624,18.32,18.691,18.32,18.691z"/> +<path class="st1" d="M49.066,15.891l1.174,3.192c0,0,2.281-0.911,1.563-1.826C51.084,16.343,49.066,15.891,49.066,15.891z"/> +<path class="st2" d="M22.687,14.132l-4.429-1.759c0,0-0.887,3.161-0.652,3.715c0.236,0.554,0.848,2.996,0.848,2.996l3.715,0.911 + l0.522-5.862H22.687z"/> +<path class="st3" d="M33.367,13.154c0,0,2.395-3.766,3.255-3.648c0.86,0.118,2.67,1.174,3.974,1.237 + c1.304,0.067,4.3-1.107,4.3-1.107s3.451,5.081,3.581,5.277c0.13,0.196,1.366,4.559,1.366,4.559l-5.603,1.304l-10.421-3.581 + l-0.456-4.041H33.367z"/> +<path class="st4" d="M44.248,20.776l-1.15-2.67l-4.084-1.736l-3.302,1.041l-1.041-3.475l-5.297-2.78l-1.826,2.953l-4.084-0.695 + l-0.868,2.171l-0.436,4.41c0,0,7.209,1.064,13.201,1.15c5.992,0.086,8.882-0.369,8.882-0.369H44.248z"/> +<path class="st5" d="M48.666,15.523c2.077,0.597,3.294,1.327,3.294,2.116c0,2.014-7.971,3.648-17.807,3.648 + s-17.807-1.633-17.807-3.648c0-0.499,0.487-0.97,1.362-1.402"/> +<path class="st5" d="M48.27,54.191c0,2.399-6.318,4.343-14.116,4.343c-7.798,0-14.116-1.944-14.116-4.343"/> +<path class="st5" d="M20.041,54.192L16.35,17.635"/> +<path class="st5" d="M48.27,54.192l3.691-36.557"/> +<path class="st5" d="M19.538,46.374l15.459,12.161"/> +<path class="st5" d="M18.234,34.908L45.418,56.19"/> +<path class="st5" d="M17.104,22.747l31.865,24.522"/> +<path class="st5" d="M32.182,21.408l17.941,13.806"/> +<path class="st5" d="M45.293,20.544l5.427,4.174"/> +<path class="st5" d="M48.635,46.201L33.176,58.361"/> +<path class="st5" d="M50.025,34.994L22.837,56.276"/> +<path class="st5" d="M51.151,22.837L19.291,47.355"/> +<path class="st5" d="M36.077,21.495L18.137,35.301"/> +<path class="st5" d="M22.966,20.63l-5.427,4.174"/> +<path class="st5" d="M26.544,16.08c1.343-1.539,2.27-3.436,2.658-5.442c1.614,1.417,3.451,2.548,5.482,3.251 + c0.114,0.039,0.236,0.082,0.31,0.177c0.078,0.102,0.09,0.24,0.098,0.365c0.118,1.677,0.507,3.334,1.143,4.893 + c-2.109,0.063-4.229-0.432-6.09-1.422"/> +<path class="st5" d="M27.698,14.121c-1.162-0.389-2.34-0.73-3.526-1.029c-0.291-0.071-0.62-0.134-0.868,0.035 + c-0.153,0.106-0.24,0.279-0.314,0.448c-0.84,1.948-0.55,4.209-1.182,6.236"/> +<path class="st5" d="M33.612,12.546c0.691-1.095,1.551-2.085,2.541-2.921c0.157-0.134,0.33-0.267,0.534-0.298 + c0.291-0.043,0.562,0.137,0.805,0.302c1.155,0.774,2.47,1.382,3.86,1.425c1.39,0.043,2.855-0.573,3.55-1.775 + c2.081,3.192,4.205,6.84,4.837,10.111"/> +<path class="st5" d="M35.732,17.328c0.742-0.314,1.488-0.624,2.23-0.938c0.153-0.063,0.306-0.13,0.471-0.137 + c0.161-0.008,0.322,0.039,0.475,0.086c1.19,0.365,2.38,0.726,3.569,1.092c0.126,0.039,0.255,0.079,0.357,0.161 + c0.137,0.106,0.212,0.271,0.283,0.432c0.346,0.774,0.691,1.547,1.033,2.321"/> +<path class="st5" d="M22.593,13.967c-1.512-0.499-2.937-1.252-4.201-2.215c-0.432,1.586-0.62,3.243-0.554,4.889 + c0.035,0.852,0.149,1.736,0.605,2.458"/> +</svg> diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index ac41382486a5..f58687c66c63 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>1.4.21</string> + <string>1.4.22</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleURLTypes</key> @@ -40,7 +40,7 @@ </dict> </array> <key>CFBundleVersion</key> - <string>1.4.21.4</string> + <string>1.4.22.3</string> <key>ITSAppUsesNonExemptEncryption</key> <false/> <key>LSApplicationQueriesSchemes</key> diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index ab58f3c293bd..b7b8c9d3416b 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ <key>CFBundlePackageType</key> <string>BNDL</string> <key>CFBundleShortVersionString</key> - <string>1.4.21</string> + <string>1.4.22</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> - <string>1.4.21.4</string> + <string>1.4.22.3</string> </dict> </plist> diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 77c390c46416..ee54d98895a5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -614,7 +614,7 @@ PODS: - React-Core - react-native-pager-view (6.2.0): - React-Core - - react-native-pdf (6.7.3): + - react-native-pdf (6.7.4): - React-Core - react-native-performance (5.1.0): - React-Core @@ -1265,7 +1265,7 @@ SPEC CHECKSUMS: react-native-key-command: 5af6ee30ff4932f78da6a2109017549042932aa5 react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2 react-native-pager-view: 0ccb8bf60e2ebd38b1f3669fa3650ecce81db2df - react-native-pdf: b4ca3d37a9a86d9165287741c8b2ef4d8940c00e + react-native-pdf: 79aa75e39a80c1d45ffe58aa500f3cf08f267a2e react-native-performance: cef2b618d47b277fb5c3280b81a3aad1e72f2886 react-native-plaid-link-sdk: df1618a85a615d62ff34e34b76abb7a56497fbc1 react-native-quick-sqlite: bcc7a7a250a40222f18913a97cd356bf82d0a6c4 diff --git a/package-lock.json b/package-lock.json index 15964d8c5f3e..55bfafbec2f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.21-4", + "version": "1.4.22-3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.21-4", + "version": "1.4.22-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -96,7 +96,7 @@ "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.118", "react-native-pager-view": "^6.2.0", - "react-native-pdf": "^6.7.3", + "react-native-pdf": "^6.7.4", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#7a407cd4174d9838a944c1c2e1cb4a9737ac69c5", @@ -47635,9 +47635,9 @@ } }, "node_modules/react-native-pdf": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.3.tgz", - "integrity": "sha512-bK1fVkj18kBA5YlRFNJ3/vJ1bEX3FDHyAPY6ArtIdVs+vv0HzcK5WH9LSd2bxUsEMIyY9CSjP4j8BcxNXTiQkQ==", + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.4.tgz", + "integrity": "sha512-sBeNcsrTRnLjmiU9Wx7Uk0K2kPSQtKIIG+FECdrEG16TOdtmQ3iqqEwt0dmy0pJegpg07uES5BXqiKsKkRUIFw==", "dependencies": { "crypto-js": "4.2.0", "deprecated-react-native-prop-types": "^2.3.0" @@ -90531,9 +90531,9 @@ "requires": {} }, "react-native-pdf": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.3.tgz", - "integrity": "sha512-bK1fVkj18kBA5YlRFNJ3/vJ1bEX3FDHyAPY6ArtIdVs+vv0HzcK5WH9LSd2bxUsEMIyY9CSjP4j8BcxNXTiQkQ==", + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.4.tgz", + "integrity": "sha512-sBeNcsrTRnLjmiU9Wx7Uk0K2kPSQtKIIG+FECdrEG16TOdtmQ3iqqEwt0dmy0pJegpg07uES5BXqiKsKkRUIFw==", "requires": { "crypto-js": "4.2.0", "deprecated-react-native-prop-types": "^2.3.0" diff --git a/package.json b/package.json index 29ade80b518d..7264cb5fa25e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.21-4", + "version": "1.4.22-3", "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.", @@ -144,7 +144,7 @@ "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.118", "react-native-pager-view": "^6.2.0", - "react-native-pdf": "^6.7.3", + "react-native-pdf": "^6.7.4", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#7a407cd4174d9838a944c1c2e1cb4a9737ac69c5", diff --git a/src/CONST.ts b/src/CONST.ts index b5563825e016..2fd592f539c2 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -103,6 +103,10 @@ const CONST = { MERCHANT_NAME_MAX_LENGTH: 255, + REQUEST_PREVIEW: { + MAX_LENGTH: 83, + }, + CALENDAR_PICKER: { // Numbers were arbitrarily picked. MIN_YEAR: CURRENT_YEAR - 100, @@ -3054,6 +3058,42 @@ const CONST = { CAROUSEL: 3, }, + VIOLATIONS: { + ALL_TAG_LEVELS_REQUIRED: 'allTagLevelsRequired', + AUTO_REPORTED_REJECTED_EXPENSE: 'autoReportedRejectedExpense', + BILLABLE_EXPENSE: 'billableExpense', + CASH_EXPENSE_WITH_NO_RECEIPT: 'cashExpenseWithNoReceipt', + CATEGORY_OUT_OF_POLICY: 'categoryOutOfPolicy', + CONVERSION_SURCHARGE: 'conversionSurcharge', + CUSTOM_UNIT_OUT_OF_POLICY: 'customUnitOutOfPolicy', + DUPLICATED_TRANSACTION: 'duplicatedTransaction', + FIELD_REQUIRED: 'fieldRequired', + FUTURE_DATE: 'futureDate', + INVOICE_MARKUP: 'invoiceMarkup', + MAX_AGE: 'maxAge', + MISSING_CATEGORY: 'missingCategory', + MISSING_COMMENT: 'missingComment', + MISSING_TAG: 'missingTag', + MODIFIED_AMOUNT: 'modifiedAmount', + MODIFIED_DATE: 'modifiedDate', + NON_EXPENSIWORKS_EXPENSE: 'nonExpensiworksExpense', + OVER_AUTO_APPROVAL_LIMIT: 'overAutoApprovalLimit', + OVER_CATEGORY_LIMIT: 'overCategoryLimit', + OVER_LIMIT: 'overLimit', + OVER_LIMIT_ATTENDEE: 'overLimitAttendee', + PER_DAY_LIMIT: 'perDayLimit', + RECEIPT_NOT_SMART_SCANNED: 'receiptNotSmartScanned', + RECEIPT_REQUIRED: 'receiptRequired', + RTER: 'rter', + SMARTSCAN_FAILED: 'smartscanFailed', + SOME_TAG_LEVELS_REQUIRED: 'someTagLevelsRequired', + TAG_OUT_OF_POLICY: 'tagOutOfPolicy', + TAX_AMOUNT_CHANGED: 'taxAmountChanged', + TAX_OUT_OF_POLICY: 'taxOutOfPolicy', + TAX_RATE_CHANGED: 'taxRateChanged', + TAX_REQUIRED: 'taxRequired', + }, + /** Context menu types */ CONTEXT_MENU_TYPES: { LINK: 'LINK', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 35fa4bbf0837..b6e62814466b 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -358,9 +358,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/distance/${transactionID}/${reportID}/`, backTo), }, MONEY_REQUEST_STEP_MERCHANT: { - route: 'create/:iouType/merchante/:transactionID/:reportID/', + route: 'create/:iouType/merchant/:transactionID/:reportID/', getRoute: (iouType: ValueOf<typeof CONST.IOU.TYPE>, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/merchante/${transactionID}/${reportID}/`, backTo), + getUrlWithBackToParam(`create/${iouType}/merchant/${transactionID}/${reportID}/`, backTo), }, MONEY_REQUEST_STEP_PARTICIPANTS: { route: 'create/:iouType/participants/:transactionID/:reportID/', @@ -479,6 +479,7 @@ const ROUTES = { route: 'referral/:contentType', getRoute: (contentType: string) => `referral/${contentType}` as const, }, + PROCESS_MONEY_REQUEST_HOLD: 'hold-request-educational', } as const; export {getUrlWithBackToParam}; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 91c4153bacd2..703cb309d641 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -108,6 +108,7 @@ const SCREENS = { ROOM_MEMBERS: 'RoomMembers', ROOM_INVITE: 'RoomInvite', REFERRAL: 'Referral', + PROCESS_MONEY_REQUEST_HOLD: 'ProcessMoneyRequestHold', }, SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop', SIGN_IN_WITH_GOOGLE_DESKTOP: 'GoogleSignInDesktop', @@ -231,6 +232,7 @@ const SCREENS = { SIGN_IN_ROOT: 'SignIn_Root', DETAILS_ROOT: 'Details_Root', PROFILE_ROOT: 'Profile_Root', + PROCESS_MONEY_REQUEST_HOLD_ROOT: 'ProcessMoneyRequestHold_Root', REPORT_WELCOME_MESSAGE_ROOT: 'Report_WelcomeMessage_Root', REPORT_PARTICIPANTS_ROOT: 'ReportParticipants_Root', ROOM_MEMBERS_ROOT: 'RoomMembers_Root', diff --git a/src/TIMEZONES.ts b/src/TIMEZONES.ts index 1eb49f291495..238563134872 100644 --- a/src/TIMEZONES.ts +++ b/src/TIMEZONES.ts @@ -1,5 +1,6 @@ +/* eslint-disable @typescript-eslint/naming-convention */ // All timezones were taken from: https://raw.githubusercontent.com/leon-do/Timezones/main/timezone.json -export default [ +const TIMEZONES = [ 'Africa/Abidjan', 'Africa/Accra', 'Africa/Addis_Ababa', @@ -419,3 +420,137 @@ export default [ 'Pacific/Wake', 'Pacific/Wallis', ] as const; + +/** + * 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<string, (typeof TIMEZONES)[number]> = { + 'Africa/Asmera': 'Africa/Nairobi', + 'Africa/Timbuktu': 'Africa/Abidjan', + 'America/Argentina/ComodRivadavia': 'America/Argentina/Catamarca', + 'America/Atka': 'America/Adak', + 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires', + 'America/Catamarca': 'America/Argentina/Catamarca', + 'America/Coral_Harbour': 'America/Panama', + 'America/Cordoba': 'America/Argentina/Cordoba', + 'America/Ensenada': 'America/Tijuana', + 'America/Fort_Wayne': 'America/Indiana/Indianapolis', + 'America/Godthab': 'America/Nuuk', + 'America/Indianapolis': 'America/Indiana/Indianapolis', + 'America/Jujuy': 'America/Argentina/Jujuy', + 'America/Knox_IN': 'America/Indiana/Knox', + 'America/Louisville': 'America/Kentucky/Louisville', + 'America/Mendoza': 'America/Argentina/Mendoza', + 'America/Montreal': 'America/Toronto', + 'America/Nipigon': 'America/Toronto', + 'America/Pangnirtung': 'America/Iqaluit', + 'America/Porto_Acre': 'America/Rio_Branco', + 'America/Rainy_River': 'America/Winnipeg', + 'America/Rosario': 'America/Argentina/Cordoba', + 'America/Santa_Isabel': 'America/Tijuana', + 'America/Shiprock': 'America/Denver', + 'America/Thunder_Bay': 'America/Toronto', + 'America/Virgin': 'America/Puerto_Rico', + 'America/Yellowknife': 'America/Edmonton', + 'Antarctica/South_Pole': 'Pacific/Auckland', + 'Asia/Ashkhabad': 'Asia/Ashgabat', + 'Asia/Calcutta': 'Asia/Kolkata', + 'Asia/Chongqing': 'Asia/Shanghai', + 'Asia/Chungking': 'Asia/Shanghai', + 'Asia/Dacca': 'Asia/Dhaka', + 'Asia/Harbin': 'Asia/Shanghai', + 'Asia/Istanbul': 'Europe/Istanbul', + 'Asia/Kashgar': 'Asia/Urumqi', + 'Asia/Katmandu': 'Asia/Kathmandu', + 'Asia/Macao': 'Asia/Macau', + 'Asia/Rangoon': 'Asia/Yangon', + 'Asia/Saigon': 'Asia/Ho_Chi_Minh', + 'Asia/Tel_Aviv': 'Asia/Jerusalem', + 'Asia/Thimbu': 'Asia/Thimphu', + 'Asia/Ujung_Pandang': 'Asia/Makassar', + 'Asia/Ulan_Bator': 'Asia/Ulaanbaatar', + 'Atlantic/Faeroe': 'Atlantic/Faroe', + 'Atlantic/Jan_Mayen': 'Europe/Berlin', + 'Australia/ACT': 'Australia/Sydney', + 'Australia/Canberra': 'Australia/Sydney', + 'Australia/Currie': 'Australia/Hobart', + 'Australia/LHI': 'Australia/Lord_Howe', + 'Australia/NSW': 'Australia/Sydney', + 'Australia/North': 'Australia/Darwin', + 'Australia/Queensland': 'Australia/Brisbane', + 'Australia/South': 'Australia/Adelaide', + 'Australia/Tasmania': 'Australia/Hobart', + 'Australia/Victoria': 'Australia/Melbourne', + 'Australia/West': 'Australia/Perth', + 'Australia/Yancowinna': 'Australia/Broken_Hill', + 'Brazil/Acre': 'America/Rio_Branco', + 'Brazil/DeNoronha': 'America/Noronha', + 'Brazil/East': 'America/Sao_Paulo', + 'Brazil/West': 'America/Manaus', + 'Canada/Atlantic': 'America/Halifax', + 'Canada/Central': 'America/Winnipeg', + 'Canada/Eastern': 'America/Toronto', + 'Canada/Mountain': 'America/Edmonton', + 'Canada/Newfoundland': 'America/St_Johns', + 'Canada/Pacific': 'America/Vancouver', + 'Canada/Saskatchewan': 'America/Regina', + 'Canada/Yukon': 'America/Whitehorse', + 'Chile/Continental': 'America/Santiago', + 'Chile/EasterIsland': 'Pacific/Easter', + Cuba: 'America/Havana', + Egypt: 'Africa/Cairo', + Eire: 'Europe/Dublin', + 'Europe/Belfast': 'Europe/London', + 'Europe/Kiev': 'Europe/Kyiv', + 'Europe/Nicosia': 'Asia/Nicosia', + 'Europe/Tiraspol': 'Europe/Chisinau', + 'Europe/Uzhgorod': 'Europe/Kyiv', + 'Europe/Zaporozhye': 'Europe/Kyiv', + GB: 'Europe/London', + 'GB-Eire': 'Europe/London', + Hongkong: 'Asia/Hong_Kong', + Iceland: 'Africa/Abidjan', + Iran: 'Asia/Tehran', + Israel: 'Asia/Jerusalem', + Jamaica: 'America/Jamaica', + Japan: 'Asia/Tokyo', + Kwajalein: 'Pacific/Kwajalein', + Libya: 'Africa/Tripoli', + 'Mexico/BajaNorte': 'America/Tijuana', + 'Mexico/BajaSur': 'America/Mazatlan', + 'Mexico/General': 'America/Mexico_City', + NZ: 'Pacific/Auckland', + 'NZ-CHAT': 'Pacific/Chatham', + Navajo: 'America/Denver', + PRC: 'Asia/Shanghai', + 'Pacific/Enderbury': 'Pacific/Kanton', + 'Pacific/Johnston': 'Pacific/Honolulu', + 'Pacific/Ponape': 'Pacific/Guadalcanal', + 'Pacific/Samoa': 'Pacific/Pago_Pago', + 'Pacific/Truk': 'Pacific/Port_Moresby', + 'Pacific/Yap': 'Pacific/Port_Moresby', + Poland: 'Europe/Warsaw', + Portugal: 'Europe/Lisbon', + ROC: 'Asia/Taipei', + ROK: 'Asia/Seoul', + Singapore: 'Asia/Singapore', + Turkey: 'Europe/Istanbul', + 'US/Alaska': 'America/Anchorage', + 'US/Aleutian': 'America/Adak', + 'US/Arizona': 'America/Phoenix', + 'US/Central': 'America/Chicago', + 'US/East-Indiana': 'America/Indiana/Indianapolis', + 'US/Eastern': 'America/New_York', + 'US/Hawaii': 'Pacific/Honolulu', + 'US/Indiana-Starke': 'America/Indiana/Knox', + 'US/Michigan': 'America/Detroit', + 'US/Mountain': 'America/Denver', + 'US/Pacific': 'America/Los_Angeles', + 'US/Samoa': 'Pacific/Pago_Pago', + 'W-SU': 'Europe/Moscow', +}; + +export {timezoneBackwardMap}; + +export default TIMEZONES; diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js index 31b04a3d954f..9f2635633318 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -349,6 +349,7 @@ function AddressSearch({ lat: successData.coords.latitude, lng: successData.coords.longitude, address: CONST.YOUR_LOCATION_TEXT, + name: CONST.YOUR_LOCATION_TEXT, }; onPress(location); }, diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index e1bbb7cdfbdd..0f3416076cc0 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -26,13 +26,16 @@ type AmountTextInputProps = { style?: StyleProp<TextStyle>; /** Style for the container */ - containerStyles?: StyleProp<ViewStyle>; + touchableInputWrapperStyle?: StyleProp<ViewStyle>; /** Function to call to handle key presses in the text input */ onKeyPress?: () => void; }; -function AmountTextInput({formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, containerStyles, onKeyPress}: AmountTextInputProps, ref: BaseTextInputRef) { +function AmountTextInput( + {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress}: AmountTextInputProps, + ref: BaseTextInputRef, +) { const styles = useThemeStyles(); return ( <TextInput @@ -51,7 +54,7 @@ function AmountTextInput({formattedAmount, onChangeAmount, placeholder, selectio onSelectionChange={onSelectionChange} role={CONST.ROLE.PRESENTATION} onKeyPress={onKeyPress} - containerStyles={containerStyles} + touchableInputWrapperStyle={touchableInputWrapperStyle} /> ); } diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index d24d1e18907f..1b4d350f7d4f 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -19,7 +19,6 @@ import fileDownload from '@libs/fileDownload'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import useNativeDriver from '@libs/useNativeDriver'; import reportPropTypes from '@pages/reportPropTypes'; @@ -95,6 +94,9 @@ const propTypes = { /** Whether it is a receipt attachment or not */ isReceiptAttachment: PropTypes.bool, + + /** Whether the receipt can be replaced */ + canEditReceipt: PropTypes.bool, }; const defaultProps = { @@ -113,6 +115,7 @@ const defaultProps = { onCarouselAttachmentChange: () => {}, isWorkspaceAvatar: false, isReceiptAttachment: false, + canEditReceipt: false, }; function AttachmentModal(props) { @@ -126,7 +129,7 @@ function AttachmentModal(props) { const [isAuthTokenRequired, setIsAuthTokenRequired] = useState(props.isAuthTokenRequired); const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(''); const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null); - const [source, setSource] = useState(props.source); + const [source, setSource] = useState(() => props.source); const [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE); const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); const [confirmButtonFadeAnimation] = useState(() => new Animated.Value(1)); @@ -359,7 +362,7 @@ function AttachmentModal(props) { }, []); useEffect(() => { - setSource(props.source); + setSource(() => props.source); }, [props.source]); useEffect(() => { @@ -372,13 +375,9 @@ function AttachmentModal(props) { if (!props.isReceiptAttachment || !props.parentReport || !props.parentReportActions) { return []; } - const menuItems = []; - const parentReportAction = props.parentReportActions[props.report.parentReportActionID]; - const canEdit = - ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, props.parentReport.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT, props.transaction) && - !TransactionUtils.isDistanceRequest(props.transaction); - if (canEdit) { + const menuItems = []; + if (props.canEditReceipt) { menuItems.push({ icon: Expensicons.Camera, text: props.translate('common.replace'), @@ -393,7 +392,7 @@ function AttachmentModal(props) { text: props.translate('common.download'), onSelected: () => downloadAttachment(source), }); - if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction) && canEdit) { + if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction) && props.canEditReceipt) { menuItems.push({ icon: Expensicons.Trashcan, text: props.translate('receipt.deleteReceipt'), diff --git a/src/components/ConfirmPopover.js b/src/components/ConfirmPopover.js deleted file mode 100644 index 83001736b471..000000000000 --- a/src/components/ConfirmPopover.js +++ /dev/null @@ -1,85 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ConfirmContent from './ConfirmContent'; -import Popover from './Popover'; -import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; - -const propTypes = { - /** Title of the modal */ - title: PropTypes.string.isRequired, - - /** A callback to call when the form has been submitted */ - onConfirm: PropTypes.func.isRequired, - - /** A callback to call when the form has been closed */ - onCancel: PropTypes.func, - - /** Modal visibility */ - isVisible: PropTypes.bool.isRequired, - - /** Confirm button text */ - confirmText: PropTypes.string, - - /** Cancel button text */ - cancelText: PropTypes.string, - - /** Is the action destructive */ - danger: PropTypes.bool, - - /** Whether we should show the cancel button */ - shouldShowCancelButton: PropTypes.bool, - - /** Modal content text/element */ - prompt: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), - - /** Where the popover should be positioned */ - anchorPosition: PropTypes.shape({ - top: PropTypes.number, - left: PropTypes.number, - }).isRequired, - - /** Styles for view */ - // eslint-disable-next-line react/forbid-prop-types - contentStyles: PropTypes.arrayOf(PropTypes.object), - - ...windowDimensionsPropTypes, -}; - -const defaultProps = { - confirmText: '', - cancelText: '', - danger: false, - onCancel: () => {}, - shouldShowCancelButton: true, - prompt: '', - contentStyles: [], -}; - -function ConfirmPopover(props) { - return ( - <Popover - onSubmit={props.onConfirm} - onClose={props.onCancel} - isVisible={props.isVisible} - anchorPosition={props.anchorPosition} - > - <ConfirmContent - contentStyles={props.contentStyles} - title={props.title} - prompt={props.prompt} - confirmText={props.confirmText} - cancelText={props.cancelText} - danger={props.danger} - shouldShowCancelButton={props.shouldShowCancelButton} - onConfirm={props.onConfirm} - onCancel={props.onCancel} - onClose={props.onCancel} - /> - </Popover> - ); -} - -ConfirmPopover.propTypes = propTypes; -ConfirmPopover.defaultProps = defaultProps; -ConfirmPopover.displayName = 'ConfirmPopover'; -export default withWindowDimensions(ConfirmPopover); diff --git a/src/components/EmojiSuggestions.tsx b/src/components/EmojiSuggestions.tsx index 5904b1521f98..1c0306741048 100644 --- a/src/components/EmojiSuggestions.tsx +++ b/src/components/EmojiSuggestions.tsx @@ -1,9 +1,9 @@ import type {ReactElement} from 'react'; import React, {useCallback} from 'react'; import {View} from 'react-native'; +import type {Emoji} from '@assets/emojis/types'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {SimpleEmoji} from '@libs/EmojiTrie'; import * as EmojiUtils from '@libs/EmojiUtils'; import getStyledTextArray from '@libs/GetStyledTextArray'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; @@ -16,7 +16,7 @@ type EmojiSuggestionsProps = { highlightedEmojiIndex?: number; /** Array of suggested emoji */ - emojis: SimpleEmoji[]; + emojis: Emoji[]; /** Fired when the user selects an emoji */ onSelect: (index: number) => void; @@ -40,7 +40,7 @@ type EmojiSuggestionsProps = { /** * Create unique keys for each emoji item */ -const keyExtractor = (item: SimpleEmoji, index: number): string => `${item.name}+${index}}`; +const keyExtractor = (item: Emoji, index: number): string => `${item.name}+${index}}`; function EmojiSuggestions({emojis, onSelect, prefix, isEmojiPickerLarge, preferredSkinToneIndex, highlightedEmojiIndex = 0, measureParentContainer = () => {}}: EmojiSuggestionsProps) { const styles = useThemeStyles(); @@ -49,7 +49,7 @@ function EmojiSuggestions({emojis, onSelect, prefix, isEmojiPickerLarge, preferr * Render an emoji suggestion menu item component. */ const renderSuggestionMenuItem = useCallback( - (item: SimpleEmoji): ReactElement => { + (item: Emoji): ReactElement => { const styledTextArray = getStyledTextArray(item.name, prefix); return ( diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js index 0b5cbad29983..46d04ca9404d 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js @@ -4,7 +4,7 @@ import {defaultHTMLElementModels, RenderHTMLConfigProvider, TRenderEngineProvide import _ from 'underscore'; import useThemeStyles from '@hooks/useThemeStyles'; import convertToLTR from '@libs/convertToLTR'; -import singleFontFamily from '@styles/utils/fontFamily/singleFontFamily'; +import FontUtils from '@styles/utils/FontUtils'; import * as HTMLEngineUtils from './htmlEngineUtils'; import htmlRenderers from './HTMLRenderers'; @@ -62,7 +62,7 @@ function BaseHTMLEngineProvider(props) { 'mention-here': defaultHTMLElementModels.span.extend({tagName: 'mention-here'}), 'next-step': defaultHTMLElementModels.span.extend({ tagName: 'next-step', - mixedUAStyles: {...styles.textLabelSupporting}, + mixedUAStyles: {...styles.textLabelSupporting, ...styles.lh16}, }), 'next-step-email': defaultHTMLElementModels.span.extend({tagName: 'next-step-email'}), video: defaultHTMLElementModels.div.extend({ @@ -70,7 +70,7 @@ function BaseHTMLEngineProvider(props) { mixedUAStyles: {whiteSpace: 'pre'}, }), }), - [styles.colorMuted, styles.formError, styles.mb0, styles.textLabelSupporting], + [styles.colorMuted, styles.formError, styles.mb0, styles.textLabelSupporting, styles.lh16], ); // We need to memoize this prop to make it referentially stable. @@ -82,7 +82,7 @@ function BaseHTMLEngineProvider(props) { baseStyle={styles.webViewStyles.baseFontStyle} tagsStyles={styles.webViewStyles.tagStyles} enableCSSInlineProcessing={false} - systemFonts={_.values(singleFontFamily)} + systemFonts={_.values(FontUtils.fontFamily.single)} domVisitors={{ // eslint-disable-next-line no-param-reassign onText: (text) => (text.data = convertToLTR(text.data)), diff --git a/src/components/HeaderPageLayout.js b/src/components/HeaderPageLayout.tsx similarity index 55% rename from src/components/HeaderPageLayout.js rename to src/components/HeaderPageLayout.tsx index 9ef5d4f83a06..304bb2ce49b1 100644 --- a/src/components/HeaderPageLayout.js +++ b/src/components/HeaderPageLayout.tsx @@ -1,56 +1,54 @@ -import PropTypes from 'prop-types'; import React, {useMemo} from 'react'; +import type {ReactNode} from 'react'; import {ScrollView, View} from 'react-native'; -import _ from 'underscore'; +import type {StyleProp, ViewStyle} from 'react-native'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; import FixedFooter from './FixedFooter'; import HeaderWithBackButton from './HeaderWithBackButton'; -import headerWithBackButtonPropTypes from './HeaderWithBackButton/headerWithBackButtonPropTypes'; +import type HeaderWithBackButtonProps from './HeaderWithBackButton/types'; import ScreenWrapper from './ScreenWrapper'; -const propTypes = { - ...headerWithBackButtonPropTypes, +type HeaderPageLayoutProps = ChildrenProps & + HeaderWithBackButtonProps & { + /** The background color to apply in the upper half of the screen. */ + backgroundColor?: string; - /** Children to display in the lower half of the page (below the header section w/ an animation) */ - children: PropTypes.node.isRequired, + /** A fixed footer to display at the bottom of the page. */ + footer?: ReactNode; - /** The background color to apply in the upper half of the screen. */ - backgroundColor: PropTypes.string, + /** The image to display in the upper half of the screen. */ + headerContent?: ReactNode; - /** A fixed footer to display at the bottom of the page. */ - footer: PropTypes.node, + /** Style to apply to the header image container */ + headerContainerStyles?: StyleProp<ViewStyle>; - /** The image to display in the upper half of the screen. */ - header: PropTypes.node, + /** Style to apply to the ScrollView container */ + scrollViewContainerStyles?: StyleProp<ViewStyle>; - /** Style to apply to the header image container */ - // eslint-disable-next-line react/forbid-prop-types - headerContainerStyles: PropTypes.arrayOf(PropTypes.object), + /** Style to apply to the children container */ + childrenContainerStyles?: StyleProp<ViewStyle>; - /** Style to apply to the ScrollView container */ - // eslint-disable-next-line react/forbid-prop-types - scrollViewContainerStyles: PropTypes.arrayOf(PropTypes.object), + /** Style to apply to the whole section container */ + style?: StyleProp<ViewStyle>; + }; - /** Style to apply to the children container */ - // eslint-disable-next-line react/forbid-prop-types - childrenContainerStyles: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - backgroundColor: undefined, - header: null, - headerContainerStyles: [], - scrollViewContainerStyles: [], - childrenContainerStyles: [], - footer: null, -}; - -function HeaderPageLayout({backgroundColor, children, footer, headerContainerStyles, scrollViewContainerStyles, childrenContainerStyles, style, headerContent, ...propsToPassToHeader}) { +function HeaderPageLayout({ + backgroundColor, + children, + footer, + headerContainerStyles, + scrollViewContainerStyles, + childrenContainerStyles, + style, + headerContent, + ...rest +}: HeaderPageLayoutProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -58,7 +56,7 @@ function HeaderPageLayout({backgroundColor, children, footer, headerContainerSty const {isOffline} = useNetwork(); const appBGColor = StyleUtils.getBackgroundColorStyle(theme.appBG); const {titleColor, iconFill} = useMemo(() => { - const isColorfulBackground = (backgroundColor || theme.appBG) !== theme.appBG && (backgroundColor || theme.highlightBG) !== theme.highlightBG; + const isColorfulBackground = (backgroundColor ?? theme.appBG) !== theme.appBG && (backgroundColor ?? theme.highlightBG) !== theme.highlightBG; return { titleColor: isColorfulBackground ? theme.textColorfulBackground : undefined, iconFill: isColorfulBackground ? theme.iconColorfulBackground : undefined, @@ -67,7 +65,7 @@ function HeaderPageLayout({backgroundColor, children, footer, headerContainerSty return ( <ScreenWrapper - style={[StyleUtils.getBackgroundColorStyle(backgroundColor || theme.appBG)]} + style={[StyleUtils.getBackgroundColorStyle(backgroundColor ?? theme.appBG)]} shouldEnablePickerAvoiding={false} includeSafeAreaPaddingBottom={false} offlineIndicatorStyle={[appBGColor]} @@ -77,29 +75,26 @@ function HeaderPageLayout({backgroundColor, children, footer, headerContainerSty <> <HeaderWithBackButton // eslint-disable-next-line react/jsx-props-no-spreading - {...propsToPassToHeader} + {...rest} titleColor={titleColor} iconFill={iconFill} /> - <View style={[styles.flex1, appBGColor, !isOffline && !_.isNull(footer) ? safeAreaPaddingBottomStyle : {}]}> + <View style={[styles.flex1, appBGColor, !isOffline && footer ? safeAreaPaddingBottomStyle : {}]}> {/** Safari on ios/mac has a bug where overscrolling the page scrollview shows green background color. This is a workaround to fix that. https://github.com/Expensify/App/issues/23422 */} {Browser.isSafari() && ( <View style={styles.dualColorOverscrollSpacer}> - <View style={[styles.flex1, StyleUtils.getBackgroundColorStyle(backgroundColor || theme.appBG)]} /> + <View style={[styles.flex1, StyleUtils.getBackgroundColorStyle(backgroundColor ?? theme.appBG)]} /> <View style={[isSmallScreenWidth ? styles.flex1 : styles.flex3, appBGColor]} /> </View> )} - <ScrollView - contentContainerStyle={[safeAreaPaddingBottomStyle, style, scrollViewContainerStyles]} - offlineIndicatorStyle={[appBGColor]} - > - {!Browser.isSafari() && <View style={styles.overscrollSpacer(backgroundColor || theme.appBG, windowHeight)} />} - <View style={[styles.alignItemsCenter, styles.justifyContentEnd, StyleUtils.getBackgroundColorStyle(backgroundColor || theme.appBG), ...headerContainerStyles]}> + <ScrollView contentContainerStyle={[safeAreaPaddingBottomStyle, style, scrollViewContainerStyles]}> + {!Browser.isSafari() && <View style={styles.overscrollSpacer(backgroundColor ?? theme.appBG, windowHeight)} />} + <View style={[styles.alignItemsCenter, styles.justifyContentEnd, StyleUtils.getBackgroundColorStyle(backgroundColor ?? theme.appBG), headerContainerStyles]}> {headerContent} </View> <View style={[styles.pt5, appBGColor, childrenContainerStyles]}>{children}</View> </ScrollView> - {!_.isNull(footer) && <FixedFooter>{footer}</FixedFooter>} + {!!footer && <FixedFooter>{footer}</FixedFooter>} </View> </> )} @@ -107,8 +102,7 @@ function HeaderPageLayout({backgroundColor, children, footer, headerContainerSty ); } -HeaderPageLayout.propTypes = propTypes; -HeaderPageLayout.defaultProps = defaultProps; HeaderPageLayout.displayName = 'HeaderPageLayout'; +export type {HeaderPageLayoutProps}; export default HeaderPageLayout; diff --git a/src/components/HoldMenuSectionList.tsx b/src/components/HoldMenuSectionList.tsx new file mode 100644 index 000000000000..aa5dd75ce159 --- /dev/null +++ b/src/components/HoldMenuSectionList.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {ImageSourcePropType} from 'react-native'; +import type {SvgProps} from 'react-native-svg'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import type {TranslationPaths} from '@src/languages/types'; +import Icon from './Icon'; +import * as Illustrations from './Icon/Illustrations'; +import Text from './Text'; + +type HoldMenuSection = { + /** The icon supplied with the section */ + icon: React.FC<SvgProps> | ImageSourcePropType; + + /** Translation key for the title */ + titleTranslationKey: TranslationPaths; + + /** Translation key for the description */ + descriptionTranslationKey: TranslationPaths; +}; + +function HoldMenuSectionList() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const holdMenuSections: HoldMenuSection[] = [ + { + icon: Illustrations.Hourglass, + titleTranslationKey: 'iou.whatIsHoldTitle', + descriptionTranslationKey: 'iou.whatIsHoldExplain', + }, + { + icon: Illustrations.CommentBubbles, + titleTranslationKey: 'iou.holdIsTemporaryTitle', + descriptionTranslationKey: 'iou.holdIsTemporaryExplain', + }, + { + icon: Illustrations.TrashCan, + titleTranslationKey: 'iou.deleteHoldTitle', + descriptionTranslationKey: 'iou.deleteHoldExplain', + }, + ]; + + return ( + <> + {holdMenuSections.map((section, i) => ( + <View + // eslint-disable-next-line react/no-array-index-key + key={i} + style={[styles.flexRow, styles.alignItemsCenter, styles.mb5]} + > + <Icon + width={variables.holdMenuIconSize} + height={variables.holdMenuIconSize} + src={section.icon} + additionalStyles={[styles.mr3]} + /> + <View style={[styles.flex1, styles.justifyContentCenter]}> + <Text style={[styles.textStrong, styles.mb1]}>{translate(section.titleTranslationKey)}</Text> + <Text + style={[styles.textNormal]} + numberOfLines={3} + > + {translate(section.descriptionTranslationKey)} + </Text> + </View> + </View> + ))} + </> + ); +} + +HoldMenuSectionList.displayName = 'HoldMenuSectionList'; + +export type {HoldMenuSection}; + +export default HoldMenuSectionList; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 1e574504001d..954c8d0392fc 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -32,6 +32,7 @@ import BigRocket from '@assets/images/simple-illustrations/simple-illustration__ import PinkBill from '@assets/images/simple-illustrations/simple-illustration__bill.svg'; import ChatBubbles from '@assets/images/simple-illustrations/simple-illustration__chatbubbles.svg'; import CoffeeMug from '@assets/images/simple-illustrations/simple-illustration__coffeemug.svg'; +import CommentBubbles from '@assets/images/simple-illustrations/simple-illustration__commentbubbles.svg'; import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustration__concierge-bubble.svg'; import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg'; import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; @@ -39,6 +40,7 @@ import EmailAddress from '@assets/images/simple-illustrations/simple-illustratio import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg'; import HandEarth from '@assets/images/simple-illustrations/simple-illustration__handearth.svg'; import HotDogStand from '@assets/images/simple-illustrations/simple-illustration__hotdogstand.svg'; +import Hourglass from '@assets/images/simple-illustrations/simple-illustration__hourglass.svg'; import InvoiceBlue from '@assets/images/simple-illustrations/simple-illustration__invoice.svg'; import LockOpen from '@assets/images/simple-illustrations/simple-illustration__lockopen.svg'; import Luggage from '@assets/images/simple-illustrations/simple-illustration__luggage.svg'; @@ -53,6 +55,7 @@ import ShieldYellow from '@assets/images/simple-illustrations/simple-illustratio import SmallRocket from '@assets/images/simple-illustrations/simple-illustration__smallrocket.svg'; import ThumbsUpStars from '@assets/images/simple-illustrations/simple-illustration__thumbsupstars.svg'; import TrackShoe from '@assets/images/simple-illustrations/simple-illustration__track-shoe.svg'; +import TrashCan from '@assets/images/simple-illustrations/simple-illustration__trashcan.svg'; import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg'; export { @@ -111,5 +114,8 @@ export { Hands, HandEarth, SmartScan, + Hourglass, + CommentBubbles, + TrashCan, TeleScope, }; diff --git a/src/components/IllustratedHeaderPageLayout.js b/src/components/IllustratedHeaderPageLayout.js deleted file mode 100644 index 9980d8a7879a..000000000000 --- a/src/components/IllustratedHeaderPageLayout.js +++ /dev/null @@ -1,67 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import HeaderPageLayout from './HeaderPageLayout'; -import headerWithBackButtonPropTypes from './HeaderWithBackButton/headerWithBackButtonPropTypes'; -import Lottie from './Lottie'; - -const propTypes = { - ...headerWithBackButtonPropTypes, - - /** Children to display in the lower half of the page (below the header section w/ an animation) */ - children: PropTypes.node.isRequired, - - /** The illustration to display in the header. Can be either an SVG component or a JSON object representing a Lottie animation. */ - illustration: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, - - /** The background color to apply in the upper half of the screen. */ - backgroundColor: PropTypes.string, - - /** A fixed footer to display at the bottom of the page. */ - footer: PropTypes.node, - - /** Overlay content to display on top of animation */ - overlayContent: PropTypes.func, -}; - -const defaultProps = { - backgroundColor: undefined, - footer: null, - overlayContent: null, -}; - -function IllustratedHeaderPageLayout({backgroundColor, children, illustration, footer, overlayContent, ...propsToPassToHeader}) { - const theme = useTheme(); - const styles = useThemeStyles(); - return ( - <HeaderPageLayout - backgroundColor={backgroundColor || theme.appBG} - title={propsToPassToHeader.title} - headerContent={ - <> - <Lottie - source={illustration} - style={styles.w100} - webStyle={styles.w100} - autoPlay - loop - /> - {overlayContent && overlayContent()} - </> - } - headerContainerStyles={[styles.justifyContentCenter, styles.w100]} - footer={footer} - // eslint-disable-next-line react/jsx-props-no-spreading - {...propsToPassToHeader} - > - {children} - </HeaderPageLayout> - ); -} - -IllustratedHeaderPageLayout.propTypes = propTypes; -IllustratedHeaderPageLayout.defaultProps = defaultProps; -IllustratedHeaderPageLayout.displayName = 'IllustratedHeaderPageLayout'; - -export default IllustratedHeaderPageLayout; diff --git a/src/components/IllustratedHeaderPageLayout.tsx b/src/components/IllustratedHeaderPageLayout.tsx new file mode 100644 index 000000000000..72ec0adf7672 --- /dev/null +++ b/src/components/IllustratedHeaderPageLayout.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import type {ReactNode} from 'react'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import HeaderPageLayout from './HeaderPageLayout'; +import type {HeaderPageLayoutProps} from './HeaderPageLayout'; +import Lottie from './Lottie'; +import type DotLottieAnimation from './LottieAnimations/types'; + +type IllustratedHeaderPageLayoutProps = HeaderPageLayoutProps & { + /** The illustration to display in the header. Can be a JSON object representing a Lottie animation. */ + illustration: DotLottieAnimation; + + /** The background color to apply in the upper half of the screen. */ + backgroundColor?: string; + + /** Overlay content to display on top of animation */ + overlayContent?: () => ReactNode; +}; + +function IllustratedHeaderPageLayout({backgroundColor, children, illustration, overlayContent, ...rest}: IllustratedHeaderPageLayoutProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + return ( + <HeaderPageLayout + backgroundColor={backgroundColor ?? theme.appBG} + headerContent={ + <> + <Lottie + source={illustration} + style={styles.w100} + webStyle={styles.w100} + autoPlay + loop + /> + {overlayContent?.()} + </> + } + headerContainerStyles={[styles.justifyContentCenter, styles.w100]} + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + > + {children} + </HeaderPageLayout> + ); +} + +IllustratedHeaderPageLayout.displayName = 'IllustratedHeaderPageLayout'; + +export default IllustratedHeaderPageLayout; diff --git a/src/components/InvertedFlatList/index.native.tsx b/src/components/InvertedFlatList/index.native.tsx index 76c4b774d0cc..70cabf5a536a 100644 --- a/src/components/InvertedFlatList/index.native.tsx +++ b/src/components/InvertedFlatList/index.native.tsx @@ -1,18 +1,15 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef} from 'react'; import type {FlatList, FlatListProps} from 'react-native'; -import useThemeStyles from '@hooks/useThemeStyles'; import BaseInvertedFlatList from './BaseInvertedFlatList'; import CellRendererComponent from './CellRendererComponent'; function BaseInvertedFlatListWithRef<T>(props: FlatListProps<T>, ref: ForwardedRef<FlatList>) { - const styles = useThemeStyles(); return ( <BaseInvertedFlatList // eslint-disable-next-line react/jsx-props-no-spreading {...props} ref={ref} - contentContainerStyle={styles.justifyContentEnd} CellRendererComponent={CellRendererComponent} /** * To achieve absolute positioning and handle overflows for list items, the property must be disabled diff --git a/src/components/InvertedFlatList/index.tsx b/src/components/InvertedFlatList/index.tsx index 0fc35957e687..a96058a3046f 100644 --- a/src/components/InvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/index.tsx @@ -7,7 +7,7 @@ import BaseInvertedFlatList from './BaseInvertedFlatList'; // This is adapted from https://codesandbox.io/s/react-native-dsyse // It's a HACK alert since FlatList has inverted scrolling on web -function InvertedFlatList<T>({onScroll: onScrollProp = () => {}, contentContainerStyle, ...props}: FlatListProps<T>, ref: ForwardedRef<FlatList>) { +function InvertedFlatList<T>({onScroll: onScrollProp = () => {}, ...props}: FlatListProps<T>, ref: ForwardedRef<FlatList>) { const lastScrollEvent = useRef<number | null>(null); const scrollEndTimeout = useRef<NodeJS.Timeout | null>(null); const updateInProgress = useRef<boolean>(false); @@ -86,7 +86,6 @@ function InvertedFlatList<T>({onScroll: onScrollProp = () => {}, contentContaine // eslint-disable-next-line react/jsx-props-no-spreading {...props} ref={ref} - contentContainerStyle={contentContainerStyle} onScroll={handleScroll} /> ); diff --git a/src/components/Lightbox.js b/src/components/Lightbox.js index 06f8ee4cfeb6..45326edb4610 100644 --- a/src/components/Lightbox.js +++ b/src/components/Lightbox.js @@ -183,8 +183,8 @@ function Lightbox({isAuthTokenRequired, source, onScaleChanged, onPress, onError onError={onError} onLoadEnd={() => setImageLoaded(true)} onLoad={(e) => { - const width = (e.nativeEvent?.width || 0) / PixelRatio.get(); - const height = (e.nativeEvent?.height || 0) / PixelRatio.get(); + const width = (e.nativeEvent?.width || 0) * PixelRatio.get(); + const height = (e.nativeEvent?.height || 0) * PixelRatio.get(); setImageDimensions({...imageDimensions, lightboxSize: {width, height}}); }} /> @@ -205,8 +205,8 @@ function Lightbox({isAuthTokenRequired, source, onScaleChanged, onPress, onError isAuthTokenRequired={isAuthTokenRequired} onLoadEnd={() => setFallbackLoaded(true)} onLoad={(e) => { - const width = e.nativeEvent?.width || 0; - const height = e.nativeEvent?.height || 0; + const width = (e.nativeEvent?.width || 0) * PixelRatio.get(); + const height = (e.nativeEvent?.height || 0) * PixelRatio.get(); if (imageDimensions?.lightboxSize != null) { return; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 14dfd0e8dbc4..86e77ae4bfc3 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -35,20 +35,6 @@ import RenderHTML from './RenderHTML'; import SelectCircle from './SelectCircle'; import Text from './Text'; -type ResponsiveProps = { - /** Function to fire when component is pressed */ - onPress: (event: GestureResponderEvent | KeyboardEvent) => void; - - interactive?: true; -}; - -type UnresponsiveProps = { - onPress?: undefined; - - /** Whether the menu item should be interactive at all */ - interactive: false; -}; - type IconProps = { /** Flag to choose between avatar image or an icon */ iconType?: typeof CONST.ICON_TYPE_ICON; @@ -69,170 +55,175 @@ type NoIcon = { icon?: undefined; }; -type MenuItemProps = (ResponsiveProps | UnresponsiveProps) & - (IconProps | AvatarProps | NoIcon) & { - /** Text to be shown as badge near the right end. */ - badgeText?: string; +type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { + /** Function to fire when component is pressed */ + onPress?: (event: GestureResponderEvent | KeyboardEvent) => void; + + /** Whether the menu item should be interactive at all */ + interactive?: boolean; - /** Used to apply offline styles to child text components */ - style?: ViewStyle; + /** Text to be shown as badge near the right end. */ + badgeText?: string; - /** Any additional styles to apply */ - wrapperStyle?: StyleProp<ViewStyle>; + /** Used to apply offline styles to child text components */ + style?: ViewStyle; - /** Any additional styles to apply on the outer element */ - containerStyle?: StyleProp<ViewStyle>; + /** Any additional styles to apply */ + wrapperStyle?: StyleProp<ViewStyle>; - /** Used to apply styles specifically to the title */ - titleStyle?: ViewStyle; + /** Any additional styles to apply on the outer element */ + containerStyle?: StyleProp<ViewStyle>; - /** Any adjustments to style when menu item is hovered or pressed */ - hoverAndPressStyle?: StyleProp<AnimatedStyle<ViewStyle>>; + /** Used to apply styles specifically to the title */ + titleStyle?: ViewStyle; - /** Additional styles to style the description text below the title */ - descriptionTextStyle?: StyleProp<TextStyle>; + /** Any adjustments to style when menu item is hovered or pressed */ + hoverAndPressStyle?: StyleProp<AnimatedStyle<ViewStyle>>; - /** The fill color to pass into the icon. */ - iconFill?: string; + /** Additional styles to style the description text below the title */ + descriptionTextStyle?: StyleProp<TextStyle>; - /** Secondary icon to display on the left side of component, right of the icon */ - secondaryIcon?: IconAsset; + /** The fill color to pass into the icon. */ + iconFill?: string; - /** The fill color to pass into the secondary icon. */ - secondaryIconFill?: string; + /** Secondary icon to display on the left side of component, right of the icon */ + secondaryIcon?: IconAsset; - /** Icon Width */ - iconWidth?: number; + /** The fill color to pass into the secondary icon. */ + secondaryIconFill?: string; - /** Icon Height */ - iconHeight?: number; + /** Icon Width */ + iconWidth?: number; - /** Any additional styles to pass to the icon container. */ - iconStyles?: StyleProp<ViewStyle>; + /** Icon Height */ + iconHeight?: number; - /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ - fallbackIcon?: IconAsset; + /** Any additional styles to pass to the icon container. */ + iconStyles?: StyleProp<ViewStyle>; - /** An icon to display under the main item */ - furtherDetailsIcon?: IconAsset; + /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ + fallbackIcon?: IconAsset; - /** Boolean whether to display the title right icon */ - shouldShowTitleIcon?: boolean; + /** An icon to display under the main item */ + furtherDetailsIcon?: IconAsset; - /** Icon to display at right side of title */ - titleIcon?: IconAsset; + /** Boolean whether to display the title right icon */ + shouldShowTitleIcon?: boolean; - /** Boolean whether to display the right icon */ - shouldShowRightIcon?: boolean; + /** Icon to display at right side of title */ + titleIcon?: IconAsset; - /** Overrides the icon for shouldShowRightIcon */ - iconRight?: IconAsset; + /** Boolean whether to display the right icon */ + shouldShowRightIcon?: boolean; - /** Should render component on the right */ - shouldShowRightComponent?: boolean; + /** Overrides the icon for shouldShowRightIcon */ + iconRight?: IconAsset; - /** Component to be displayed on the right */ - rightComponent?: ReactNode; + /** Should render component on the right */ + shouldShowRightComponent?: boolean; - /** A description text to show under the title */ - description?: string; + /** Component to be displayed on the right */ + rightComponent?: ReactNode; - /** Should the description be shown above the title (instead of the other way around) */ - shouldShowDescriptionOnTop?: boolean; + /** A description text to show under the title */ + description?: string; - /** Error to display below the title */ - error?: string; + /** Should the description be shown above the title (instead of the other way around) */ + shouldShowDescriptionOnTop?: boolean; - /** Error to display at the bottom of the component */ - errorText?: string; + /** Error to display below the title */ + error?: string; - /** A boolean flag that gives the icon a green fill if true */ - success?: boolean; + /** Error to display at the bottom of the component */ + errorText?: string; - /** Whether item is focused or active */ - focused?: boolean; + /** A boolean flag that gives the icon a green fill if true */ + success?: boolean; - /** Should we disable this menu item? */ - disabled?: boolean; + /** Whether item is focused or active */ + focused?: boolean; - /** Text that appears above the title */ - label?: string; + /** Should we disable this menu item? */ + disabled?: boolean; - /** Label to be displayed on the right */ - rightLabel?: string; + /** Text that appears above the title */ + label?: string; - /** Text to display for the item */ - title?: string; + /** Label to be displayed on the right */ + rightLabel?: string; - /** A right-aligned subtitle for this menu option */ - subtitle?: string | number; + /** Text to display for the item */ + title?: string; - /** Should the title show with normal font weight (not bold) */ - shouldShowBasicTitle?: boolean; + /** A right-aligned subtitle for this menu option */ + subtitle?: string | number; - /** Should we make this selectable with a checkbox */ - shouldShowSelectedState?: boolean; + /** Should the title show with normal font weight (not bold) */ + shouldShowBasicTitle?: boolean; - /** Whether this item is selected */ - isSelected?: boolean; + /** Should we make this selectable with a checkbox */ + shouldShowSelectedState?: boolean; - /** Prop to identify if we should load avatars vertically instead of diagonally */ - shouldStackHorizontally?: boolean; + /** Whether this item is selected */ + isSelected?: boolean; - /** Prop to represent the size of the avatar images to be shown */ - avatarSize?: (typeof CONST.AVATAR_SIZE)[keyof typeof CONST.AVATAR_SIZE]; + /** Prop to identify if we should load avatars vertically instead of diagonally */ + shouldStackHorizontally?: boolean; - /** Avatars to show on the right of the menu item */ - floatRightAvatars?: IconType[]; + /** Prop to represent the size of the avatar images to be shown */ + avatarSize?: (typeof CONST.AVATAR_SIZE)[keyof typeof CONST.AVATAR_SIZE]; - /** Prop to represent the size of the float right avatar images to be shown */ - floatRightAvatarSize?: ValueOf<typeof CONST.AVATAR_SIZE>; + /** Avatars to show on the right of the menu item */ + floatRightAvatars?: IconType[]; - /** Affects avatar size */ - viewMode?: ValueOf<typeof CONST.OPTION_MODE>; + /** Prop to represent the size of the float right avatar images to be shown */ + floatRightAvatarSize?: ValueOf<typeof CONST.AVATAR_SIZE>; - /** Used to truncate the text with an ellipsis after computing the text layout */ - numberOfLinesTitle?: number; + /** Affects avatar size */ + viewMode?: ValueOf<typeof CONST.OPTION_MODE>; - /** Whether we should use small avatar subscript sizing the for menu item */ - isSmallAvatarSubscriptMenu?: boolean; + /** Used to truncate the text with an ellipsis after computing the text layout */ + numberOfLinesTitle?: number; - /** The type of brick road indicator to show. */ - brickRoadIndicator?: ValueOf<typeof CONST.BRICK_ROAD_INDICATOR_STATUS>; + /** Whether we should use small avatar subscript sizing the for menu item */ + isSmallAvatarSubscriptMenu?: boolean; - /** Should render the content in HTML format */ - shouldRenderAsHTML?: boolean; + /** The type of brick road indicator to show. */ + brickRoadIndicator?: ValueOf<typeof CONST.BRICK_ROAD_INDICATOR_STATUS>; - /** Should we grey out the menu item when it is disabled? */ - shouldGreyOutWhenDisabled?: boolean; + /** Should render the content in HTML format */ + shouldRenderAsHTML?: boolean; - /** The action accept for anonymous user or not */ - isAnonymousAction?: boolean; + /** Should we grey out the menu item when it is disabled? */ + shouldGreyOutWhenDisabled?: boolean; - /** Flag to indicate whether or not text selection should be disabled from long-pressing the menu item. */ - shouldBlockSelection?: boolean; + /** The action accept for anonymous user or not */ + isAnonymousAction?: boolean; - /** Whether should render title as HTML or as Text */ - shouldParseTitle?: false; + /** Flag to indicate whether or not text selection should be disabled from long-pressing the menu item. */ + shouldBlockSelection?: boolean; - /** Should check anonymous user in onPress function */ - shouldCheckActionAllowedOnPress?: boolean; + /** Whether should render title as HTML or as Text */ + shouldParseTitle?: false; - /** Text to display under the main item */ - furtherDetails?: string; + /** Should check anonymous user in onPress function */ + shouldCheckActionAllowedOnPress?: boolean; - /** The function that should be called when this component is LongPressed or right-clicked. */ - onSecondaryInteraction?: () => void; + /** Text to display under the main item */ + furtherDetails?: string; - /** Array of objects that map display names to their corresponding tooltip */ - titleWithTooltips?: DisplayNameWithTooltip[]; + /** The function that should be called when this component is LongPressed or right-clicked. */ + onSecondaryInteraction?: (event: GestureResponderEvent | MouseEvent) => void; - /** Icon should be displayed in its own color */ - displayInDefaultIconColor?: boolean; + /** Array of objects that map display names to their corresponding tooltip */ + titleWithTooltips?: DisplayNameWithTooltip[]; - /** Determines how the icon should be resized to fit its container */ - contentFit?: ImageContentFit; - }; + /** Icon should be displayed in its own color */ + displayInDefaultIconColor?: boolean; + + /** Determines how the icon should be resized to fit its container */ + contentFit?: ImageContentFit; +}; function MenuItem( { @@ -525,17 +516,19 @@ function MenuItem( <Text style={[styles.textLabelError]}>{error}</Text> </View> )} - {furtherDetailsIcon && !!furtherDetails && ( + {!!furtherDetails && ( <View style={[styles.flexRow, styles.mt1, styles.alignItemsCenter]}> - <Icon - src={furtherDetailsIcon} - height={variables.iconSizeNormal} - width={variables.iconSizeNormal} - fill={theme.icon} - inline - /> + {!!furtherDetailsIcon && ( + <Icon + src={furtherDetailsIcon} + height={variables.iconSizeNormal} + width={variables.iconSizeNormal} + fill={theme.icon} + inline + /> + )} <Text - style={[styles.furtherDetailsText, styles.ph2, styles.pt1]} + style={furtherDetailsIcon ? [styles.furtherDetailsText, styles.ph2, styles.pt1] : styles.textLabelSupporting} numberOfLines={2} > {furtherDetails} diff --git a/src/components/MenuItemList.js b/src/components/MenuItemList.js deleted file mode 100644 index c9eee8e888e1..000000000000 --- a/src/components/MenuItemList.js +++ /dev/null @@ -1,62 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import _ from 'underscore'; -import useSingleExecution from '@hooks/useSingleExecution'; -import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import CONST from '@src/CONST'; -import MenuItem from './MenuItem'; -import menuItemPropTypes from './menuItemPropTypes'; - -const propTypes = { - /** An array of props that are pass to individual MenuItem components */ - menuItems: PropTypes.arrayOf(PropTypes.shape(menuItemPropTypes)), - - /** Whether or not to use the single execution hook */ - shouldUseSingleExecution: PropTypes.bool, -}; -const defaultProps = { - menuItems: [], - shouldUseSingleExecution: false, -}; - -function MenuItemList(props) { - let popoverAnchor; - const {isExecuting, singleExecution} = useSingleExecution(); - - /** - * Handle the secondary interaction for a menu item. - * - * @param {*} link the menu item link or function to get the link - * @param {Event} e the interaction event - */ - const secondaryInteraction = (link, e) => { - if (typeof link === 'function') { - link().then((url) => ReportActionContextMenu.showContextMenu(CONST.CONTEXT_MENU_TYPES.LINK, e, url, popoverAnchor)); - } else if (!_.isEmpty(link)) { - ReportActionContextMenu.showContextMenu(CONST.CONTEXT_MENU_TYPES.LINK, e, link, popoverAnchor); - } - }; - - return ( - <> - {_.map(props.menuItems, (menuItemProps) => ( - <MenuItem - key={menuItemProps.title} - onSecondaryInteraction={!_.isUndefined(menuItemProps.link) ? (e) => secondaryInteraction(menuItemProps.link, e) : undefined} - ref={(el) => (popoverAnchor = el)} - shouldBlockSelection={Boolean(menuItemProps.link)} - // eslint-disable-next-line react/jsx-props-no-spreading - {...menuItemProps} - disabled={menuItemProps.disabled || isExecuting} - onPress={props.shouldUseSingleExecution ? singleExecution(menuItemProps.onPress) : menuItemProps.onPress} - /> - ))} - </> - ); -} - -MenuItemList.displayName = 'MenuItemList'; -MenuItemList.propTypes = propTypes; -MenuItemList.defaultProps = defaultProps; - -export default MenuItemList; diff --git a/src/components/MenuItemList.tsx b/src/components/MenuItemList.tsx new file mode 100644 index 000000000000..f83f173a644f --- /dev/null +++ b/src/components/MenuItemList.tsx @@ -0,0 +1,63 @@ +import React, {useRef} from 'react'; +import type {GestureResponderEvent, View} from 'react-native'; +import useSingleExecution from '@hooks/useSingleExecution'; +import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {MenuItemProps} from './MenuItem'; +import MenuItem from './MenuItem'; + +type MenuItemLink = string | (() => Promise<string>); + +type MenuItemWithLink = MenuItemProps & { + /** The link to open when the menu item is clicked */ + link: MenuItemLink; +}; + +type MenuItemListProps = { + /** An array of props that are pass to individual MenuItem components */ + menuItems: MenuItemWithLink[]; + + /** Whether or not to use the single execution hook */ + shouldUseSingleExecution?: boolean; +}; + +function MenuItemList({menuItems = [], shouldUseSingleExecution = false}: MenuItemListProps) { + const popoverAnchor = useRef<View>(null); + const {isExecuting, singleExecution} = useSingleExecution(); + + /** + * Handle the secondary interaction for a menu item. + * + * @param link the menu item link or function to get the link + * @param event the interaction event + */ + const secondaryInteraction = (link: MenuItemLink, event: GestureResponderEvent | MouseEvent) => { + if (typeof link === 'function') { + link().then((url) => ReportActionContextMenu.showContextMenu(CONST.CONTEXT_MENU_TYPES.LINK, event, url, popoverAnchor.current)); + } else if (link) { + ReportActionContextMenu.showContextMenu(CONST.CONTEXT_MENU_TYPES.LINK, event, link, popoverAnchor.current); + } + }; + + return ( + <> + {menuItems.map((menuItemProps) => ( + <MenuItem + key={menuItemProps.title} + onSecondaryInteraction={menuItemProps.link !== undefined ? (e) => secondaryInteraction(menuItemProps.link, e) : undefined} + ref={popoverAnchor} + shouldBlockSelection={!!menuItemProps.link} + // eslint-disable-next-line react/jsx-props-no-spreading + {...menuItemProps} + disabled={!!menuItemProps.disabled || isExecuting} + onPress={shouldUseSingleExecution ? singleExecution(menuItemProps.onPress) : menuItemProps.onPress} + /> + ))} + </> + ); +} + +MenuItemList.displayName = 'MenuItemList'; + +export type {MenuItemWithLink}; +export default MenuItemList; diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js index 8ed6d0746438..5b59fca6cdae 100644 --- a/src/components/MoneyReportHeader.js +++ b/src/components/MoneyReportHeader.js @@ -87,9 +87,9 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); const policyType = lodashGet(policy, 'type'); const isPolicyAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && lodashGet(policy, 'role') === CONST.POLICY.ROLE.ADMIN; - const isGroupPolicy = _.contains([CONST.POLICY.TYPE.CORPORATE, CONST.POLICY.TYPE.TEAM], policyType); + const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicy(moneyRequestReport); const isManager = ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(session, 'accountID', null) === moneyRequestReport.managerID; - const isPayer = isGroupPolicy + const isPayer = isPaidGroupPolicy ? // In a group policy, the admin approver can pay the report directly by skipping the approval step isPolicyAdmin && (isApproved || isManager) : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager); @@ -99,11 +99,11 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt [isPayer, isDraft, isSettled, moneyRequestReport, reimbursableTotal, chatReport], ); const shouldShowApproveButton = useMemo(() => { - if (!isGroupPolicy) { + if (!isPaidGroupPolicy) { return false; } return isManager && !isDraft && !isApproved && !isSettled; - }, [isGroupPolicy, isManager, isDraft, isApproved, isSettled]); + }, [isPaidGroupPolicy, isManager, isDraft, isApproved, isSettled]); const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; const shouldShowSubmitButton = isDraft && reimbursableTotal !== 0; const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE; diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 7ec95aec951f..2fee67a3d632 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -277,7 +277,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const shouldShowTags = isPolicyExpenseChat && OptionsListUtils.hasEnabledOptions(_.values(policyTagList)); // A flag for showing tax rate - const shouldShowTax = isPolicyExpenseChat && policy.isTaxTrackingEnabled; + const shouldShowTax = isPolicyExpenseChat && policy && policy.isTaxTrackingEnabled; // A flag for showing the billable field const shouldShowBillable = !lodashGet(policy, 'disabledFields.defaultBillable', true); diff --git a/src/components/Onfido/BaseOnfidoWeb.js b/src/components/Onfido/BaseOnfidoWeb.js index 09ec96cf5b1e..ee206b15fc24 100644 --- a/src/components/Onfido/BaseOnfidoWeb.js +++ b/src/components/Onfido/BaseOnfidoWeb.js @@ -5,8 +5,7 @@ import _ from 'underscore'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import Log from '@libs/Log'; -import fontFamily from '@styles/utils/fontFamily'; -import fontWeightBold from '@styles/utils/fontWeight/bold'; +import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import './index.css'; @@ -18,11 +17,11 @@ function initializeOnfido({sdkToken, onSuccess, onError, onUserExit, preferredLo containerId: CONST.ONFIDO.CONTAINER_ID, useMemoryHistory: true, customUI: { - fontFamilyTitle: `${fontFamily.EXP_NEUE}, -apple-system, serif`, - fontFamilySubtitle: `${fontFamily.EXP_NEUE}, -apple-system, serif`, - fontFamilyBody: `${fontFamily.EXP_NEUE}, -apple-system, serif`, + fontFamilyTitle: `${FontUtils.fontFamily.platform.EXP_NEUE}, -apple-system, serif`, + fontFamilySubtitle: `${FontUtils.fontFamily.platform.EXP_NEUE}, -apple-system, serif`, + fontFamilyBody: `${FontUtils.fontFamily.platform.EXP_NEUE}, -apple-system, serif`, fontSizeTitle: `${variables.fontSizeLarge}px`, - fontWeightTitle: fontWeightBold, + fontWeightTitle: FontUtils.fontWeight.bold, fontWeightSubtitle: 400, fontSizeSubtitle: `${variables.fontSizeNormal}px`, colorContentTitle: theme.text, diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index b636f90cd7ca..792002441ac6 100644 --- a/src/components/PopoverWithMeasuredContent.tsx +++ b/src/components/PopoverWithMeasuredContent.tsx @@ -36,6 +36,12 @@ function PopoverWithMeasuredContent({ }, children, withoutOverlay = false, + fullscreen = true, + shouldCloseOnOutsideClick = false, + shouldSetModalVisibility = true, + statusBarTranslucent = true, + avoidKeyboard = false, + hideModalContentWhileAnimating = false, ...props }: PopoverWithMeasuredContentProps) { const styles = useThemeStyles(); @@ -115,6 +121,12 @@ function PopoverWithMeasuredContent({ anchorAlignment={anchorAlignment} isVisible={isVisible} withoutOverlay={withoutOverlay} + fullscreen={fullscreen} + shouldCloseOnOutsideClick={shouldCloseOnOutsideClick} + shouldSetModalVisibility={shouldSetModalVisibility} + statusBarTranslucent={statusBarTranslucent} + avoidKeyboard={avoidKeyboard} + hideModalContentWhileAnimating={hideModalContentWhileAnimating} // eslint-disable-next-line react/jsx-props-no-spreading {...props} anchorPosition={shiftedAnchorPosition} diff --git a/src/components/ProcessMoneyRequestHoldMenu.tsx b/src/components/ProcessMoneyRequestHoldMenu.tsx new file mode 100644 index 000000000000..1b711633ed3b --- /dev/null +++ b/src/components/ProcessMoneyRequestHoldMenu.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Button from './Button'; +import HoldMenuSectionList from './HoldMenuSectionList'; +import type {PopoverAnchorPosition} from './Modal/types'; +import Popover from './Popover'; +import type {AnchorAlignment} from './Popover/types'; +import Text from './Text'; +import TextPill from './TextPill'; + +type ProcessMoneyRequestHoldMenuProps = { + /** Whether the content is visible */ + isVisible: boolean; + + /** Method to trigger when pressing outside of the popover menu to close it */ + onClose: () => void; + + /** Method to trigger when pressing confirm button */ + onConfirm: () => void; + + /** The anchor position of the popover menu */ + anchorPosition?: PopoverAnchorPosition; + + /** The anchor alignment of the popover menu */ + anchorAlignment: AnchorAlignment; + + /** The anchor ref of the popover menu */ + anchorRef: React.RefObject<HTMLElement>; +}; + +function ProcessMoneyRequestHoldMenu({isVisible, onClose, onConfirm, anchorPosition, anchorAlignment, anchorRef}: ProcessMoneyRequestHoldMenuProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + return ( + <Popover + isVisible={isVisible} + onClose={onClose} + anchorPosition={anchorPosition} + anchorRef={anchorRef} + anchorAlignment={anchorAlignment} + disableAnimation={false} + withoutOverlay={false} + > + <View style={[styles.mh5, styles.mv5]}> + <View style={[styles.flexRow, styles.alignItemsCenter, styles.mb5]}> + <Text style={[styles.textHeadline, styles.mr2]}>{translate('iou.holdEducationalTitle')}</Text> + <TextPill textStyles={styles.holdRequestInline}>{translate('iou.hold')}</TextPill>; + </View> + <HoldMenuSectionList /> + <Button + success + style={[styles.mt5]} + text={translate('common.buttonConfirm')} + onPress={onConfirm} + /> + </View> + </Popover> + ); +} + +ProcessMoneyRequestHoldMenu.displayName = 'ProcessMoneyRequestHoldMenu'; + +export default ProcessMoneyRequestHoldMenu; diff --git a/src/components/Reactions/AddReactionBubble.js b/src/components/Reactions/AddReactionBubble.tsx similarity index 62% rename from src/components/Reactions/AddReactionBubble.js rename to src/components/Reactions/AddReactionBubble.tsx index a9bfdd367615..52751368a0ae 100644 --- a/src/components/Reactions/AddReactionBubble.js +++ b/src/components/Reactions/AddReactionBubble.tsx @@ -1,81 +1,75 @@ -import PropTypes from 'prop-types'; import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; +import type {Emoji} from '@assets/emojis/types'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import getButtonState from '@libs/getButtonState'; import variables from '@styles/variables'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; +import type {AnchorOrigin} from '@userActions/EmojiPickerAction'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; +import type {CloseContextMenuCallback, OpenPickerCallback, PickerRefElement} from './QuickEmojiReactions/types'; -const propTypes = { +type AddReactionBubbleProps = { /** Whether it is for context menu so we can modify its style */ - isContextMenu: PropTypes.bool, + isContextMenu?: boolean; /** * Called when the user presses on the icon button. * Will have a function as parameter which you can call * to open the picker. */ - onPressOpenPicker: PropTypes.func, + onPressOpenPicker?: (openPicker: OpenPickerCallback) => void; /** * Will get called the moment before the picker opens. */ - onWillShowPicker: PropTypes.func, + onWillShowPicker?: (callback: CloseContextMenuCallback) => void; /** * Called when the user selects an emoji. */ - onSelectEmoji: PropTypes.func.isRequired, + onSelectEmoji: (emoji: Emoji) => void; /** * ReportAction for EmojiPicker. */ - reportAction: PropTypes.shape({ - reportActionID: PropTypes.string.isRequired, - }), - - ...withLocalizePropTypes, -}; - -const defaultProps = { - isContextMenu: false, - onWillShowPicker: () => {}, - onPressOpenPicker: undefined, - reportAction: {}, + reportAction: ReportAction; }; -function AddReactionBubble(props) { +function AddReactionBubble({onSelectEmoji, reportAction, onPressOpenPicker, onWillShowPicker = () => {}, isContextMenu = false}: AddReactionBubbleProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const ref = useRef(); + const ref = useRef<View | HTMLDivElement>(null); + const {translate} = useLocalize(); + useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []); const onPress = () => { - const openPicker = (refParam, anchorOrigin) => { + const openPicker = (refParam?: PickerRefElement, anchorOrigin?: AnchorOrigin) => { EmojiPickerAction.showEmojiPicker( () => {}, (emojiCode, emojiObject) => { - props.onSelectEmoji(emojiObject); + onSelectEmoji(emojiObject); }, - refParam || ref, + refParam ?? ref, anchorOrigin, - props.onWillShowPicker, - props.reportAction.reportActionID, + onWillShowPicker, + reportAction.reportActionID, ); }; - if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { - if (props.onPressOpenPicker) { - props.onPressOpenPicker(openPicker); + if (!EmojiPickerAction.emojiPickerRef.current?.isEmojiPickerVisible) { + if (onPressOpenPicker) { + onPressOpenPicker(openPicker); } else { openPicker(); } @@ -85,21 +79,21 @@ function AddReactionBubble(props) { }; return ( - <Tooltip text={props.translate('emojiReactions.addReactionTooltip')}> + <Tooltip text={translate('emojiReactions.addReactionTooltip')}> <PressableWithFeedback ref={ref} - style={({hovered, pressed}) => [styles.emojiReactionBubble, styles.userSelectNone, StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, false, props.isContextMenu)]} + style={({hovered, pressed}) => [styles.emojiReactionBubble, styles.userSelectNone, StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, false, isContextMenu)]} onPress={Session.checkIfActionIsAllowed(onPress)} - onMouseDown={(e) => { + onMouseDown={(event) => { // Allow text input blur when Add reaction is right clicked - if (!e || e.button === 2) { + if (!event || event.button === 2) { return; } // Prevent text input blur when Add reaction is left clicked - e.preventDefault(); + event.preventDefault(); }} - accessibilityLabel={props.translate('emojiReactions.addReactionTooltip')} + accessibilityLabel={translate('emojiReactions.addReactionTooltip')} role={CONST.ROLE.BUTTON} // disable dimming pressDimmingValue={1} @@ -110,12 +104,12 @@ function AddReactionBubble(props) { {/* This (invisible) text will make the view have the same size as a regular emoji reaction. We make the text invisible and put the icon on top of it. */} - <Text style={[styles.opacity0, StyleUtils.getEmojiReactionBubbleTextStyle(props.isContextMenu)]}>{'\u2800\u2800'}</Text> + <Text style={[styles.opacity0, StyleUtils.getEmojiReactionBubbleTextStyle(isContextMenu)]}>{'\u2800\u2800'}</Text> <View style={styles.pAbsolute}> <Icon src={Expensicons.AddReaction} - width={props.isContextMenu ? variables.iconSizeNormal : variables.iconSizeSmall} - height={props.isContextMenu ? variables.iconSizeNormal : variables.iconSizeSmall} + width={isContextMenu ? variables.iconSizeNormal : variables.iconSizeSmall} + height={isContextMenu ? variables.iconSizeNormal : variables.iconSizeSmall} fill={StyleUtils.getIconFillColor(getButtonState(hovered, pressed))} /> </View> @@ -126,8 +120,6 @@ function AddReactionBubble(props) { ); } -AddReactionBubble.propTypes = propTypes; -AddReactionBubble.defaultProps = defaultProps; AddReactionBubble.displayName = 'AddReactionBubble'; -export default withLocalize(AddReactionBubble); +export default AddReactionBubble; diff --git a/src/components/Reactions/EmojiReactionBubble.js b/src/components/Reactions/EmojiReactionBubble.js deleted file mode 100644 index 3fd22a758f67..000000000000 --- a/src/components/Reactions/EmojiReactionBubble.js +++ /dev/null @@ -1,110 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; -import Text from '@components/Text'; -import {withCurrentUserPersonalDetailsDefaultProps} from '@components/withCurrentUserPersonalDetails'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; - -const propTypes = { - /** - * The emoji codes to display in the bubble. - */ - emojiCodes: PropTypes.arrayOf(PropTypes.string).isRequired, - - /** - * Called when the user presses on the reaction bubble. - */ - onPress: PropTypes.func.isRequired, - - /** - * Called when the user long presses or right clicks - * on the reaction bubble. - */ - onReactionListOpen: PropTypes.func, - - /** - * The number of reactions to display in the bubble. - */ - count: PropTypes.number, - - /** Whether it is for context menu so we can modify its style */ - isContextMenu: PropTypes.bool, - - /** - * Returns true if the current account has reacted to the report action (with the given skin tone). - */ - hasUserReacted: PropTypes.bool, - - /** We disable reacting with emojis on report actions that have errors */ - shouldBlockReactions: PropTypes.bool, - - ...windowDimensionsPropTypes, -}; - -const defaultProps = { - count: 0, - onReactionListOpen: () => {}, - isContextMenu: false, - shouldBlockReactions: false, - - ...withCurrentUserPersonalDetailsDefaultProps, -}; - -function EmojiReactionBubble(props) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - return ( - <PressableWithSecondaryInteraction - style={({hovered, pressed}) => [ - styles.emojiReactionBubble, - StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, props.hasUserReacted, props.isContextMenu), - props.shouldBlockReactions && styles.cursorDisabled, - styles.userSelectNone, - ]} - onPress={() => { - if (props.shouldBlockReactions) { - return; - } - - props.onPress(); - }} - onSecondaryInteraction={props.onReactionListOpen} - ref={props.forwardedRef} - enableLongPressWithHover={props.isSmallScreenWidth} - onMouseDown={(e) => { - // Allow text input blur when emoji reaction is right clicked - if (e && e.button === 2) { - return; - } - - // Prevent text input blur when emoji reaction is left clicked - e.preventDefault(); - }} - role={CONST.ROLE.BUTTON} - accessibilityLabel={props.emojiCodes.join('')} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} - > - <Text style={[styles.emojiReactionBubbleText, StyleUtils.getEmojiReactionBubbleTextStyle(props.isContextMenu)]}>{props.emojiCodes.join('')}</Text> - {props.count > 0 && <Text style={[styles.reactionCounterText, StyleUtils.getEmojiReactionCounterTextStyle(props.hasUserReacted)]}>{props.count}</Text>} - </PressableWithSecondaryInteraction> - ); -} - -EmojiReactionBubble.propTypes = propTypes; -EmojiReactionBubble.defaultProps = defaultProps; -EmojiReactionBubble.displayName = 'EmojiReactionBubble'; - -const EmojiReactionBubbleWithRef = React.forwardRef((props, ref) => ( - <EmojiReactionBubble - // eslint-disable-next-line react/jsx-props-no-spreading - {...props} - forwardedRef={ref} - /> -)); - -EmojiReactionBubbleWithRef.displayName = 'EmojiReactionBubbleWithRef'; - -export default withWindowDimensions(EmojiReactionBubbleWithRef); diff --git a/src/components/Reactions/EmojiReactionBubble.tsx b/src/components/Reactions/EmojiReactionBubble.tsx new file mode 100644 index 000000000000..d83689de2dc1 --- /dev/null +++ b/src/components/Reactions/EmojiReactionBubble.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import type {PressableRef} from '@components/Pressable/GenericPressable/types'; +import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; +import Text from '@components/Text'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import type {ReactionListEvent} from '@pages/home/ReportScreenContext'; +import CONST from '@src/CONST'; + +type EmojiReactionBubbleProps = { + /** + * The emoji codes to display in the bubble. + */ + emojiCodes: string[]; + + /** + * Called when the user presses on the reaction bubble. + */ + onPress: () => void; + + /** + * Called when the user long presses or right clicks + * on the reaction bubble. + */ + onReactionListOpen?: (event: ReactionListEvent) => void; + + /** + * The number of reactions to display in the bubble. + */ + count?: number; + + /** Whether it is for context menu so we can modify its style */ + isContextMenu?: boolean; + + /** + * Returns true if the current account has reacted to the report action (with the given skin tone). + */ + hasUserReacted?: boolean; + + /** We disable reacting with emojis on report actions that have errors */ + shouldBlockReactions?: boolean; +}; + +function EmojiReactionBubble( + {onPress, onReactionListOpen = () => {}, emojiCodes, hasUserReacted = false, count = 0, isContextMenu = false, shouldBlockReactions = false}: EmojiReactionBubbleProps, + ref: PressableRef, +) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {isSmallScreenWidth} = useWindowDimensions(); + + return ( + <PressableWithSecondaryInteraction + style={({hovered, pressed}) => [ + styles.emojiReactionBubble, + StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, hasUserReacted, isContextMenu), + shouldBlockReactions && styles.cursorDisabled, + styles.userSelectNone, + ]} + onPress={() => { + if (shouldBlockReactions) { + return; + } + + onPress(); + }} + onSecondaryInteraction={onReactionListOpen} + ref={ref} + enableLongPressWithHover={isSmallScreenWidth} + onMouseDown={(event) => { + // Allow text input blur when emoji reaction is right clicked + if (event?.button === 2) { + return; + } + + // Prevent text input blur when emoji reaction is left clicked + event.preventDefault(); + }} + role={CONST.ROLE.BUTTON} + accessibilityLabel={emojiCodes.join('')} + accessible + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + > + <Text style={[styles.emojiReactionBubbleText, StyleUtils.getEmojiReactionBubbleTextStyle(isContextMenu)]}>{emojiCodes.join('')}</Text> + {count > 0 && <Text style={[styles.reactionCounterText, StyleUtils.getEmojiReactionCounterTextStyle(hasUserReacted)]}>{count}</Text>} + </PressableWithSecondaryInteraction> + ); +} + +EmojiReactionBubble.displayName = 'EmojiReactionBubble'; + +export default React.forwardRef(EmojiReactionBubble); diff --git a/src/components/Reactions/MiniQuickEmojiReactions.js b/src/components/Reactions/MiniQuickEmojiReactions.tsx similarity index 56% rename from src/components/Reactions/MiniQuickEmojiReactions.js rename to src/components/Reactions/MiniQuickEmojiReactions.tsx index 376afcb9ade5..9f38da6bdb3d 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.js +++ b/src/components/Reactions/MiniQuickEmojiReactions.tsx @@ -1,49 +1,38 @@ -import PropTypes from 'prop-types'; import React, {useRef} from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {Emoji} from '@assets/emojis/types'; import BaseMiniContextMenuItem from '@components/BaseMiniContextMenuItem'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import * as EmojiUtils from '@libs/EmojiUtils'; import getButtonState from '@libs/getButtonState'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {baseQuickEmojiReactionsDefaultProps, baseQuickEmojiReactionsPropTypes} from './QuickEmojiReactions/BaseQuickEmojiReactions'; +import type {ReportActionReactions} from '@src/types/onyx'; +import type {BaseQuickEmojiReactionsProps} from './QuickEmojiReactions/types'; -const propTypes = { - ...baseQuickEmojiReactionsPropTypes, +type MiniQuickEmojiReactionsOnyxProps = { + /** All the emoji reactions for the report action. */ + emojiReactions: OnyxEntry<ReportActionReactions>; + /** The user's preferred skin tone. */ + preferredSkinTone: OnyxEntry<string | number>; +}; + +type MiniQuickEmojiReactionsProps = BaseQuickEmojiReactionsProps & { /** * Will be called when the user closed the emoji picker * without selecting an emoji. */ - onEmojiPickerClosed: PropTypes.func, - - /** - * ReportAction for EmojiPicker. - */ - reportAction: PropTypes.shape({ - reportActionID: PropTypes.string.isRequired, - }), - - ...withLocalizePropTypes, - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), -}; - -const defaultProps = { - ...baseQuickEmojiReactionsDefaultProps, - onEmojiPickerClosed: () => {}, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - reportAction: {}, + onEmojiPickerClosed?: () => void; }; /** @@ -51,56 +40,63 @@ const defaultProps = { * emoji picker icon button. This is used for the mini * context menu which we just show on web, when hovering * a message. - * @param {Props} props - * @returns {JSX.Element} */ -function MiniQuickEmojiReactions(props) { +function MiniQuickEmojiReactions({ + reportAction, + onEmojiSelected, + preferredLocale = CONST.LOCALES.DEFAULT, + preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, + emojiReactions = {}, + onPressOpenPicker = () => {}, + onEmojiPickerClosed = () => {}, +}: MiniQuickEmojiReactionsProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const ref = useRef(); + const ref = useRef<View>(null); + const {translate} = useLocalize(); const openEmojiPicker = () => { - props.onPressOpenPicker(); + onPressOpenPicker(); EmojiPickerAction.showEmojiPicker( - props.onEmojiPickerClosed, + onEmojiPickerClosed, (emojiCode, emojiObject) => { - props.onEmojiSelected(emojiObject, props.emojiReactions); + onEmojiSelected(emojiObject, emojiReactions); }, ref, undefined, () => {}, - props.reportAction.reportActionID, + reportAction.reportActionID, ); }; return ( <View style={styles.flexRow}> - {_.map(CONST.QUICK_REACTIONS, (emoji) => ( + {CONST.QUICK_REACTIONS.map((emoji: Emoji) => ( <BaseMiniContextMenuItem key={emoji.name} isDelayButtonStateComplete={false} - tooltipText={`:${EmojiUtils.getLocalizedEmojiName(emoji.name, props.preferredLocale)}:`} - onPress={Session.checkIfActionIsAllowed(() => props.onEmojiSelected(emoji, props.emojiReactions))} + tooltipText={`:${EmojiUtils.getLocalizedEmojiName(emoji.name, preferredLocale)}:`} + onPress={Session.checkIfActionIsAllowed(() => onEmojiSelected(emoji, emojiReactions))} > <Text style={[styles.miniQuickEmojiReactionText, styles.userSelectNone]} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > - {EmojiUtils.getPreferredEmojiCode(emoji, props.preferredSkinTone)} + {EmojiUtils.getPreferredEmojiCode(emoji, preferredSkinTone)} </Text> </BaseMiniContextMenuItem> ))} <BaseMiniContextMenuItem ref={ref} onPress={Session.checkIfActionIsAllowed(() => { - if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { + if (!EmojiPickerAction.emojiPickerRef.current?.isEmojiPickerVisible) { openEmojiPicker(); } else { - EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); + EmojiPickerAction.emojiPickerRef.current?.hideEmojiPicker(); } })} isDelayButtonStateComplete={false} - tooltipText={props.translate('emojiReactions.addReactionTooltip')} + tooltipText={translate('emojiReactions.addReactionTooltip')} > {({hovered, pressed}) => ( <Icon @@ -115,19 +111,12 @@ function MiniQuickEmojiReactions(props) { } MiniQuickEmojiReactions.displayName = 'MiniQuickEmojiReactions'; -MiniQuickEmojiReactions.propTypes = propTypes; -MiniQuickEmojiReactions.defaultProps = defaultProps; -export default compose( - withLocalize, - // ESLint throws an error because it can't see that emojiReactions is defined in props. It is defined in props, but - // because of a couple spread operators, I think that's why ESLint struggles to see it - // eslint-disable-next-line rulesdir/onyx-props-must-have-default - withOnyx({ - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - }, - emojiReactions: { - key: ({reportActionID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`, - }, - }), -)(MiniQuickEmojiReactions); + +export default withOnyx<MiniQuickEmojiReactionsProps, MiniQuickEmojiReactionsOnyxProps>({ + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + }, + emojiReactions: { + key: ({reportActionID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`, + }, +})(MiniQuickEmojiReactions); diff --git a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js deleted file mode 100644 index c932632f7bff..000000000000 --- a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js +++ /dev/null @@ -1,106 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import AddReactionBubble from '@components/Reactions/AddReactionBubble'; -import EmojiReactionBubble from '@components/Reactions/EmojiReactionBubble'; -import EmojiReactionsPropTypes from '@components/Reactions/EmojiReactionsPropTypes'; -import Tooltip from '@components/Tooltip'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as EmojiUtils from '@libs/EmojiUtils'; -import * as Session from '@userActions/Session'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const baseQuickEmojiReactionsPropTypes = { - emojiReactions: EmojiReactionsPropTypes, - - /** - * Callback to fire when an emoji is selected. - */ - onEmojiSelected: PropTypes.func.isRequired, - - /** - * Will be called when the emoji picker is about to show. - */ - onWillShowPicker: PropTypes.func, - - /** - * Callback to fire when the "open emoji picker" button is pressed. - * The function receives an argument which can be called - * to actually open the emoji picker. - */ - onPressOpenPicker: PropTypes.func, - - /** - * ReportAction for EmojiPicker. - */ - reportAction: PropTypes.object, - - preferredLocale: PropTypes.string, -}; - -const baseQuickEmojiReactionsDefaultProps = { - emojiReactions: {}, - onWillShowPicker: () => {}, - onPressOpenPicker: () => {}, - reportAction: {}, - preferredLocale: CONST.LOCALES.DEFAULT, -}; - -const propTypes = { - ...baseQuickEmojiReactionsPropTypes, - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), -}; - -const defaultProps = { - ...baseQuickEmojiReactionsDefaultProps, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, -}; - -function BaseQuickEmojiReactions(props) { - const styles = useThemeStyles(); - return ( - <View style={styles.quickReactionsContainer}> - {_.map(CONST.QUICK_REACTIONS, (emoji) => ( - <Tooltip - text={`:${EmojiUtils.getLocalizedEmojiName(emoji.name, props.preferredLocale)}:`} - key={emoji.name} - > - <View> - <EmojiReactionBubble - emojiCodes={[EmojiUtils.getPreferredEmojiCode(emoji, props.preferredSkinTone)]} - isContextMenu - onPress={Session.checkIfActionIsAllowed(() => props.onEmojiSelected(emoji, props.emojiReactions))} - /> - </View> - </Tooltip> - ))} - <AddReactionBubble - isContextMenu - onPressOpenPicker={props.onPressOpenPicker} - onWillShowPicker={props.onWillShowPicker} - onSelectEmoji={(emoji) => props.onEmojiSelected(emoji, props.emojiReactions)} - reportAction={props.reportAction} - /> - </View> - ); -} - -BaseQuickEmojiReactions.displayName = 'BaseQuickEmojiReactions'; -BaseQuickEmojiReactions.propTypes = propTypes; -BaseQuickEmojiReactions.defaultProps = defaultProps; -export default withOnyx({ - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - }, - emojiReactions: { - key: ({reportActionID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`, - }, - preferredLocale: { - key: ONYXKEYS.NVP_PREFERRED_LOCALE, - }, -})(BaseQuickEmojiReactions); - -export {baseQuickEmojiReactionsPropTypes, baseQuickEmojiReactionsDefaultProps}; diff --git a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.tsx b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.tsx new file mode 100644 index 000000000000..58973e90b9c4 --- /dev/null +++ b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {Emoji} from '@assets/emojis/types'; +import AddReactionBubble from '@components/Reactions/AddReactionBubble'; +import EmojiReactionBubble from '@components/Reactions/EmojiReactionBubble'; +import Tooltip from '@components/Tooltip'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as EmojiUtils from '@libs/EmojiUtils'; +import * as Session from '@userActions/Session'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {BaseQuickEmojiReactionsOnyxProps, BaseQuickEmojiReactionsProps} from './types'; + +function BaseQuickEmojiReactions({ + reportAction, + onEmojiSelected, + preferredLocale = CONST.LOCALES.DEFAULT, + preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, + emojiReactions = {}, + onPressOpenPicker = () => {}, + onWillShowPicker = () => {}, +}: BaseQuickEmojiReactionsProps) { + const styles = useThemeStyles(); + + return ( + <View style={styles.quickReactionsContainer}> + {CONST.QUICK_REACTIONS.map((emoji: Emoji) => ( + <Tooltip + text={`:${EmojiUtils.getLocalizedEmojiName(emoji.name, preferredLocale)}:`} + key={emoji.name} + > + <View> + <EmojiReactionBubble + emojiCodes={[EmojiUtils.getPreferredEmojiCode(emoji, preferredSkinTone)]} + isContextMenu + onPress={Session.checkIfActionIsAllowed(() => onEmojiSelected(emoji, emojiReactions))} + /> + </View> + </Tooltip> + ))} + <AddReactionBubble + isContextMenu + onPressOpenPicker={onPressOpenPicker} + onWillShowPicker={onWillShowPicker} + onSelectEmoji={(emoji) => onEmojiSelected(emoji, emojiReactions)} + reportAction={reportAction} + /> + </View> + ); +} + +BaseQuickEmojiReactions.displayName = 'BaseQuickEmojiReactions'; + +export default withOnyx<BaseQuickEmojiReactionsProps, BaseQuickEmojiReactionsOnyxProps>({ + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + }, + emojiReactions: { + key: ({reportActionID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`, + }, + preferredLocale: { + key: ONYXKEYS.NVP_PREFERRED_LOCALE, + }, +})(BaseQuickEmojiReactions); diff --git a/src/components/Reactions/QuickEmojiReactions/index.js b/src/components/Reactions/QuickEmojiReactions/index.js deleted file mode 100644 index 0366071f9c81..000000000000 --- a/src/components/Reactions/QuickEmojiReactions/index.js +++ /dev/null @@ -1,38 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {contextMenuRef} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import CONST from '@src/CONST'; -import BaseQuickEmojiReactions, {baseQuickEmojiReactionsPropTypes} from './BaseQuickEmojiReactions'; - -const propTypes = { - ...baseQuickEmojiReactionsPropTypes, - - /** - * Function that can be called to close the - * context menu in which this component is - * rendered. - */ - closeContextMenu: PropTypes.func.isRequired, -}; - -function QuickEmojiReactions(props) { - const onPressOpenPicker = (openPicker) => { - openPicker(contextMenuRef.current.contentRef, { - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, - }); - }; - - return ( - <BaseQuickEmojiReactions - // eslint-disable-next-line react/jsx-props-no-spreading - {...props} - onPressOpenPicker={onPressOpenPicker} - onWillShowPicker={props.closeContextMenu} - /> - ); -} - -QuickEmojiReactions.displayName = 'QuickEmojiReactions'; -QuickEmojiReactions.propTypes = propTypes; -export default QuickEmojiReactions; diff --git a/src/components/Reactions/QuickEmojiReactions/index.native.js b/src/components/Reactions/QuickEmojiReactions/index.native.tsx similarity index 56% rename from src/components/Reactions/QuickEmojiReactions/index.native.js rename to src/components/Reactions/QuickEmojiReactions/index.native.tsx index c29bd2665e4a..b0eb88b31b68 100644 --- a/src/components/Reactions/QuickEmojiReactions/index.native.js +++ b/src/components/Reactions/QuickEmojiReactions/index.native.tsx @@ -1,41 +1,30 @@ -import PropTypes from 'prop-types'; import React from 'react'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; -import BaseQuickEmojiReactions, {baseQuickEmojiReactionsPropTypes} from './BaseQuickEmojiReactions'; +import BaseQuickEmojiReactions from './BaseQuickEmojiReactions'; +import type {OpenPickerCallback, QuickEmojiReactionsProps} from './types'; -const propTypes = { - ...baseQuickEmojiReactionsPropTypes, - - /** - * Function that can be called to close the - * context menu in which this component is - * rendered. - */ - closeContextMenu: PropTypes.func.isRequired, -}; - -function QuickEmojiReactions(props) { - const onPressOpenPicker = (openPicker) => { +function QuickEmojiReactions({closeContextMenu, ...rest}: QuickEmojiReactionsProps) { + const onPressOpenPicker = (openPicker?: OpenPickerCallback) => { // We first need to close the menu as it's a popover. // The picker is a popover as well and on mobile there can only // be one active popover at a time. - props.closeContextMenu(() => { + closeContextMenu(() => { // As the menu which includes the button to open the emoji picker // gets closed, before the picker actually opens, we pass the composer // ref as anchor for the emoji picker popover. - openPicker(ReportActionComposeFocusManager.composerRef); + openPicker?.(ReportActionComposeFocusManager.composerRef); }); }; return ( <BaseQuickEmojiReactions // eslint-disable-next-line react/jsx-props-no-spreading - {...props} + {...rest} onPressOpenPicker={onPressOpenPicker} /> ); } QuickEmojiReactions.displayName = 'QuickEmojiReactions'; -QuickEmojiReactions.propTypes = propTypes; + export default QuickEmojiReactions; diff --git a/src/components/Reactions/QuickEmojiReactions/index.tsx b/src/components/Reactions/QuickEmojiReactions/index.tsx new file mode 100644 index 000000000000..3b44f4fe4826 --- /dev/null +++ b/src/components/Reactions/QuickEmojiReactions/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import {contextMenuRef} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import BaseQuickEmojiReactions from './BaseQuickEmojiReactions'; +import type {OpenPickerCallback, QuickEmojiReactionsProps} from './types'; + +function QuickEmojiReactions({closeContextMenu, ...rest}: QuickEmojiReactionsProps) { + const onPressOpenPicker = (openPicker?: OpenPickerCallback) => { + openPicker?.(contextMenuRef.current?.contentRef, { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }); + }; + + return ( + <BaseQuickEmojiReactions + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + onPressOpenPicker={onPressOpenPicker} + onWillShowPicker={closeContextMenu} + /> + ); +} + +QuickEmojiReactions.displayName = 'QuickEmojiReactions'; + +export default QuickEmojiReactions; diff --git a/src/components/Reactions/QuickEmojiReactions/types.ts b/src/components/Reactions/QuickEmojiReactions/types.ts new file mode 100644 index 000000000000..d782d5ae35c7 --- /dev/null +++ b/src/components/Reactions/QuickEmojiReactions/types.ts @@ -0,0 +1,56 @@ +import type {RefObject} from 'react'; +import type {TextInput, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {Emoji} from '@assets/emojis/types'; +import type {AnchorOrigin} from '@userActions/EmojiPickerAction'; +import type {Locale, ReportAction, ReportActionReactions} from '@src/types/onyx'; + +type PickerRefElement = RefObject<TextInput | View>; + +type OpenPickerCallback = (element?: PickerRefElement, anchorOrigin?: AnchorOrigin) => void; + +type CloseContextMenuCallback = () => void; + +type BaseQuickEmojiReactionsOnyxProps = { + /** All the emoji reactions for the report action. */ + emojiReactions: OnyxEntry<ReportActionReactions>; + + /** The user's preferred locale. */ + preferredLocale: OnyxEntry<Locale>; + + /** The user's preferred skin tone. */ + preferredSkinTone: OnyxEntry<string | number>; +}; + +type BaseQuickEmojiReactionsProps = BaseQuickEmojiReactionsOnyxProps & { + /** Callback to fire when an emoji is selected. */ + onEmojiSelected: (emoji: Emoji, emojiReactions: OnyxEntry<ReportActionReactions>) => void; + + /** + * Will be called when the emoji picker is about to show. + */ + onWillShowPicker?: (callback: CloseContextMenuCallback) => void; + + /** + * Callback to fire when the "open emoji picker" button is pressed. + * The function receives an argument which can be called + * to actually open the emoji picker. + */ + onPressOpenPicker?: (openPicker?: OpenPickerCallback) => void; + + /** ReportAction for EmojiPicker. */ + reportAction: ReportAction; + + /** Id of the ReportAction for EmojiPicker. */ + reportActionID: string; +}; + +type QuickEmojiReactionsProps = BaseQuickEmojiReactionsProps & { + /** + * Function that can be called to close the context menu + * in which this component is rendered. + */ + closeContextMenu: (callback: CloseContextMenuCallback) => void; +}; + +export type {BaseQuickEmojiReactionsProps, BaseQuickEmojiReactionsOnyxProps, QuickEmojiReactionsProps, OpenPickerCallback, CloseContextMenuCallback, PickerRefElement}; diff --git a/src/components/Reactions/ReactionTooltipContent.js b/src/components/Reactions/ReactionTooltipContent.js deleted file mode 100644 index bb6b03c5918b..000000000000 --- a/src/components/Reactions/ReactionTooltipContent.js +++ /dev/null @@ -1,67 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useMemo} from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import Text from '@components/Text'; -import {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; -import withLocalize from '@components/withLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; - -const propTypes = { - /** - * A list of emoji codes to display in the tooltip. - */ - emojiCodes: PropTypes.arrayOf(PropTypes.string).isRequired, - - /** - * The name of the emoji to display in the tooltip. - */ - emojiName: PropTypes.string.isRequired, - - /** - * A list of account IDs to display in the tooltip. - */ - accountIDs: PropTypes.arrayOf(PropTypes.number).isRequired, - - ...withCurrentUserPersonalDetailsPropTypes, -}; - -const defaultProps = { - ...withCurrentUserPersonalDetailsDefaultProps, -}; - -function ReactionTooltipContent(props) { - const styles = useThemeStyles(); - const users = useMemo( - () => PersonalDetailsUtils.getPersonalDetailsByIDs(props.accountIDs, props.currentUserPersonalDetails.accountID, true), - [props.currentUserPersonalDetails.accountID, props.accountIDs], - ); - const namesString = _.filter( - _.map(users, (user) => user && user.displayName), - (n) => n, - ).join(', '); - return ( - <View style={[styles.alignItemsCenter, styles.ph2]}> - <View style={styles.flexRow}> - {_.map(props.emojiCodes, (emojiCode) => ( - <Text - key={emojiCode} - style={styles.reactionEmojiTitle} - > - {emojiCode} - </Text> - ))} - </View> - - <Text style={[styles.mt1, styles.textMicroBold, styles.textReactionSenders, styles.textAlignCenter]}>{namesString}</Text> - - <Text style={[styles.textMicro, styles.fontColorReactionLabel]}>{`${props.translate('emojiReactions.reactedWith')} :${props.emojiName}:`}</Text> - </View> - ); -} - -ReactionTooltipContent.propTypes = propTypes; -ReactionTooltipContent.defaultProps = defaultProps; -ReactionTooltipContent.displayName = 'ReactionTooltipContent'; -export default React.memo(withLocalize(ReactionTooltipContent)); diff --git a/src/components/Reactions/ReactionTooltipContent.tsx b/src/components/Reactions/ReactionTooltipContent.tsx new file mode 100644 index 000000000000..198eba1f969c --- /dev/null +++ b/src/components/Reactions/ReactionTooltipContent.tsx @@ -0,0 +1,58 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import Text from '@components/Text'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; + +type ReactionTooltipContentProps = Pick<WithCurrentUserPersonalDetailsProps, 'currentUserPersonalDetails'> & { + /** + * A list of emoji codes to display in the tooltip. + */ + emojiCodes: string[]; + + /** + * The name of the emoji to display in the tooltip. + */ + emojiName: string; + + /** + * A list of account IDs to display in the tooltip. + */ + accountIDs: number[]; +}; + +function ReactionTooltipContent({accountIDs, currentUserPersonalDetails = {}, emojiCodes, emojiName}: ReactionTooltipContentProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const users = useMemo(() => PersonalDetailsUtils.getPersonalDetailsByIDs(accountIDs, currentUserPersonalDetails.accountID, true), [currentUserPersonalDetails.accountID, accountIDs]); + + const namesString = users + .map((user) => user?.displayName) + .filter((name) => name) + .join(', '); + + return ( + <View style={[styles.alignItemsCenter, styles.ph2]}> + <View style={styles.flexRow}> + {emojiCodes.map((emojiCode) => ( + <Text + key={emojiCode} + style={styles.reactionEmojiTitle} + > + {emojiCode} + </Text> + ))} + </View> + + <Text style={[styles.mt1, styles.textMicroBold, styles.textReactionSenders, styles.textAlignCenter]}>{namesString}</Text> + + <Text style={[styles.textMicro, styles.fontColorReactionLabel]}>{`${translate('emojiReactions.reactedWith')} :${emojiName}:`}</Text> + </View> + ); +} + +ReactionTooltipContent.displayName = 'ReactionTooltipContent'; + +export default React.memo(ReactionTooltipContent); diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.js b/src/components/Reactions/ReportActionItemEmojiReactions.tsx similarity index 52% rename from src/components/Reactions/ReportActionItemEmojiReactions.js rename to src/components/Reactions/ReportActionItemEmojiReactions.tsx index 547f4089857f..d1a2cf56b6a5 100644 --- a/src/components/Reactions/ReportActionItemEmojiReactions.js +++ b/src/components/Reactions/ReportActionItemEmojiReactions.tsx @@ -1,63 +1,98 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; +import sortBy from 'lodash/sortBy'; import React, {useContext, useRef} from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {Emoji} from '@assets/emojis/types'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Tooltip from '@components/Tooltip'; -import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; -import withLocalize from '@components/withLocalize'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import * as EmojiUtils from '@libs/EmojiUtils'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import {ReactionListContext} from '@pages/home/ReportScreenContext'; +import type {ReactionListAnchor, ReactionListEvent} from '@pages/home/ReportScreenContext'; +import CONST from '@src/CONST'; +import type {Locale, ReportAction, ReportActionReactions} from '@src/types/onyx'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import AddReactionBubble from './AddReactionBubble'; import EmojiReactionBubble from './EmojiReactionBubble'; -import EmojiReactionsPropTypes from './EmojiReactionsPropTypes'; import ReactionTooltipContent from './ReactionTooltipContent'; -const propTypes = { - emojiReactions: EmojiReactionsPropTypes, +type ReportActionItemEmojiReactionsProps = WithCurrentUserPersonalDetailsProps & { + /** All the emoji reactions for the report action. */ + emojiReactions: OnyxEntry<ReportActionReactions>; + + /** The user's preferred locale. */ + preferredLocale: OnyxEntry<Locale>; /** The report action that these reactions are for */ - reportAction: PropTypes.shape(reportActionPropTypes).isRequired, + reportAction: ReportAction; /** * Function to call when the user presses on an emoji. * This can also be an emoji the user already reacted with, * hence this function asks to toggle the reaction by emoji. */ - toggleReaction: PropTypes.func.isRequired, + toggleReaction: (emoji: Emoji) => void; /** We disable reacting with emojis on report actions that have errors */ - shouldBlockReactions: PropTypes.bool, - - ...withCurrentUserPersonalDetailsPropTypes, + shouldBlockReactions?: boolean; }; -const defaultProps = { - ...withCurrentUserPersonalDetailsDefaultProps, - emojiReactions: {}, - shouldBlockReactions: false, +type PopoverReactionListAnchors = Record<string, ReactionListAnchor>; + +type FormattedReaction = { + /** The emoji codes to display in the bubble */ + emojiCodes: string[]; + + /** IDs of users used the reaction */ + userAccountIDs: number[]; + + /** Total reaction count */ + reactionCount: number; + + /** Whether the current account has reacted to the report action */ + hasUserReacted: boolean; + + /** Oldest timestamp of when the emoji was added */ + oldestTimestamp: string; + + /** Callback to fire on press */ + onPress: () => void; + + /** Callback to fire on reaction list open */ + onReactionListOpen: (event: ReactionListEvent) => void; + + /** The name of the emoji */ + reactionEmojiName: string; + + /** The type of action that's pending */ + pendingAction: PendingAction; }; -function ReportActionItemEmojiReactions(props) { +function ReportActionItemEmojiReactions({ + reportAction, + currentUserPersonalDetails, + toggleReaction, + emojiReactions = {}, + shouldBlockReactions = false, + preferredLocale = CONST.LOCALES.DEFAULT, +}: ReportActionItemEmojiReactionsProps) { const styles = useThemeStyles(); const reactionListRef = useContext(ReactionListContext); - const popoverReactionListAnchors = useRef({}); + const popoverReactionListAnchors = useRef<PopoverReactionListAnchors>({}); let totalReactionCount = 0; - const reportAction = props.reportAction; const reportActionID = reportAction.reportActionID; - const formattedReactions = _.chain(props.emojiReactions) - .map((emojiReaction, emojiName) => { + // Each emoji is sorted by the oldest timestamp of user reactions so that they will always appear in the same order for everyone + const formattedReactions: Array<FormattedReaction | null> = sortBy( + Object.entries(emojiReactions ?? {}).map(([emojiName, emojiReaction]) => { const {emoji, emojiCodes, reactionCount, hasUserReacted, userAccountIDs, oldestTimestamp} = EmojiUtils.getEmojiReactionDetails( emojiName, emojiReaction, - props.currentUserPersonalDetails.accountID, + currentUserPersonalDetails.accountID, ); if (reactionCount === 0) { @@ -66,11 +101,11 @@ function ReportActionItemEmojiReactions(props) { totalReactionCount += reactionCount; const onPress = () => { - props.toggleReaction(emoji); + toggleReaction(emoji); }; - const onReactionListOpen = (event) => { - reactionListRef.current.showReactionList(event, popoverReactionListAnchors.current[emojiName], emojiName, reportActionID); + const onReactionListOpen = (event: ReactionListEvent) => { + reactionListRef?.current?.showReactionList(event, popoverReactionListAnchors.current[emojiName], emojiName, reportActionID); }; return { @@ -84,15 +119,14 @@ function ReportActionItemEmojiReactions(props) { reactionEmojiName: emojiName, pendingAction: emojiReaction.pendingAction, }; - }) - // Each emoji is sorted by the oldest timestamp of user reactions so that they will always appear in the same order for everyone - .sortBy('oldestTimestamp') - .value(); + }), + ['oldestTimestamp'], + ); return ( totalReactionCount > 0 && ( <View style={[styles.flexRow, styles.flexWrap, styles.gap1, styles.mt2]}> - {_.map(formattedReactions, (reaction) => { + {formattedReactions.map((reaction) => { if (reaction === null) { return; } @@ -100,19 +134,19 @@ function ReportActionItemEmojiReactions(props) { <Tooltip renderTooltipContent={() => ( <ReactionTooltipContent - emojiName={EmojiUtils.getLocalizedEmojiName(reaction.reactionEmojiName, props.preferredLocale)} + emojiName={EmojiUtils.getLocalizedEmojiName(reaction.reactionEmojiName, preferredLocale)} emojiCodes={reaction.emojiCodes} accountIDs={reaction.userAccountIDs} - currentUserPersonalDetails={props.currentUserPersonalDetails} + currentUserPersonalDetails={currentUserPersonalDetails} /> )} - renderTooltipContentKey={[..._.map(reaction.userAccountIDs, String), ...reaction.emojiCodes]} + renderTooltipContentKey={[...reaction.userAccountIDs.map(String), ...reaction.emojiCodes]} key={reaction.reactionEmojiName} > <View> <OfflineWithFeedback pendingAction={reaction.pendingAction} - shouldDisableOpacity={Boolean(lodashGet(reportAction, 'pendingAction'))} + shouldDisableOpacity={!!reportAction.pendingAction} > <EmojiReactionBubble ref={(ref) => (popoverReactionListAnchors.current[reaction.reactionEmojiName] = ref)} @@ -121,17 +155,17 @@ function ReportActionItemEmojiReactions(props) { onPress={reaction.onPress} hasUserReacted={reaction.hasUserReacted} onReactionListOpen={reaction.onReactionListOpen} - shouldBlockReactions={props.shouldBlockReactions} + shouldBlockReactions={shouldBlockReactions} /> </OfflineWithFeedback> </View> </Tooltip> ); })} - {!props.shouldBlockReactions && ( + {!shouldBlockReactions && ( <AddReactionBubble - onSelectEmoji={props.toggleReaction} - reportAction={{reportActionID}} + onSelectEmoji={toggleReaction} + reportAction={reportAction} /> )} </View> @@ -140,6 +174,5 @@ function ReportActionItemEmojiReactions(props) { } ReportActionItemEmojiReactions.displayName = 'ReportActionItemReactions'; -ReportActionItemEmojiReactions.propTypes = propTypes; -ReportActionItemEmojiReactions.defaultProps = defaultProps; -export default compose(withLocalize, withCurrentUserPersonalDetails)(ReportActionItemEmojiReactions); + +export default withCurrentUserPersonalDetails(ReportActionItemEmojiReactions); diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index f0e818ddff4d..c052a885245f 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -1,3 +1,4 @@ +import {truncate} from 'lodash'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React from 'react'; @@ -151,8 +152,9 @@ function MoneyRequestPreview(props) { // Pay button should only be visible to the manager of the report. const isCurrentUserManager = managerID === sessionAccountID; - const {amount: requestAmount, currency: requestCurrency, comment: requestComment, merchant: requestMerchant} = ReportUtils.getTransactionDetails(props.transaction); - const description = requestComment; + const {amount: requestAmount, currency: requestCurrency, comment: requestComment, merchant} = ReportUtils.getTransactionDetails(props.transaction); + const description = truncate(requestComment, {length: CONST.REQUEST_PREVIEW.MAX_LENGTH}); + const requestMerchant = truncate(merchant, {length: CONST.REQUEST_PREVIEW.MAX_LENGTH}); const hasReceipt = TransactionUtils.hasReceipt(props.transaction); const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(props.transaction); const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(props.transaction); @@ -208,7 +210,7 @@ function MoneyRequestPreview(props) { } let message = translate('iou.cash'); - if (ReportUtils.isGroupPolicyExpenseReport(props.iouReport) && ReportUtils.isReportApproved(props.iouReport) && !ReportUtils.isSettled(props.iouReport)) { + if (ReportUtils.isPaidGroupPolicyExpenseReport(props.iouReport) && ReportUtils.isReportApproved(props.iouReport) && !ReportUtils.isSettled(props.iouReport)) { message += ` • ${translate('iou.approved')}`; } else if (props.iouReport.isWaitingOnBankAccount) { message += ` • ${translate('iou.pending')}`; diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index 514dc71ffe2c..37ff163f23c8 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -1,7 +1,7 @@ import lodashGet from 'lodash/get'; import lodashValues from 'lodash/values'; import PropTypes from 'prop-types'; -import React, {useMemo} from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import categoryPropTypes from '@components/categoryPropTypes'; @@ -14,12 +14,14 @@ import Switch from '@components/Switch'; import tagPropTypes from '@components/tagPropTypes'; import Text from '@components/Text'; import transactionPropTypes from '@components/transactionPropTypes'; +import ViolationMessages from '@components/ViolationMessages'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useViolations from '@hooks/useViolations'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CardUtils from '@libs/CardUtils'; import compose from '@libs/compose'; @@ -35,12 +37,39 @@ import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateB import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import iouReportPropTypes from '@pages/iouReportPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; +import {policyDefaultProps, policyPropTypes} from '@pages/workspace/withPolicy'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import ReportActionItemImage from './ReportActionItemImage'; +const violationNames = lodashValues(CONST.VIOLATIONS); + +const transactionViolationPropType = PropTypes.shape({ + type: PropTypes.string.isRequired, + name: PropTypes.oneOf(violationNames).isRequired, + data: PropTypes.shape({ + rejectedBy: PropTypes.string, + rejectReason: PropTypes.string, + amount: PropTypes.string, + surcharge: PropTypes.number, + invoiceMarkup: PropTypes.number, + maxAge: PropTypes.number, + tagName: PropTypes.string, + formattedLimitAmount: PropTypes.string, + categoryLimit: PropTypes.string, + limit: PropTypes.string, + category: PropTypes.string, + brokenBankConnection: PropTypes.bool, + isAdmin: PropTypes.bool, + email: PropTypes.string, + isTransactionOlderThan7Days: PropTypes.bool, + member: PropTypes.string, + taxName: PropTypes.string, + }), +}); + const propTypes = { /** The report currently being looked at */ report: reportPropTypes.isRequired, @@ -55,12 +84,18 @@ const propTypes = { /** The actions from the parent report */ parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + /** The policy the report is tied to */ + ...policyPropTypes, + /** Collection of categories attached to a policy */ policyCategories: PropTypes.objectOf(categoryPropTypes), /** The transaction associated with the transactionThread */ transaction: transactionPropTypes, + /** Violations detected in this transaction */ + transactionViolations: PropTypes.arrayOf(transactionViolationPropType), + /** Collection of tags attached to a policy */ policyTags: tagPropTypes, @@ -70,16 +105,18 @@ const propTypes = { const defaultProps = { parentReport: {}, parentReportActions: {}, - policyCategories: {}, transaction: { amount: 0, currency: CONST.CURRENCY.USD, comment: {comment: ''}, }, + transactionViolations: [], + policyCategories: {}, policyTags: {}, + ...policyDefaultProps, }; -function MoneyRequestView({report, parentReport, parentReportActions, policyCategories, shouldShowHorizontalRule, transaction, policyTags, policy}) { +function MoneyRequestView({report, parentReport, parentReportActions, policyCategories, shouldShowHorizontalRule, transaction, policyTags, policy, transactionViolations}) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -115,12 +152,18 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate // Flags for allowing or disallowing editing a money request const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); const isCancelled = moneyRequestReport && moneyRequestReport.isCancelledIOU; + + // Used for non-restricted fields such as: description, category, tag, billable, etc. const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction); - const canEditAmount = canEdit && !isSettled && !isCardTransaction; - const canEditReceipt = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, moneyRequestReport.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT); + const canEditAmount = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.AMOUNT); + const canEditMerchant = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.MERCHANT); + const canEditDate = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DATE); + const canEditReceipt = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT); + const canEditDistance = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DISTANCE); // A flag for verifying that the current report is a sub-report of a workspace chat - const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]); + // if the policy of the report is either Collect or Control, then this report must be tied to workspace chat + const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report); // Fetches only the first tag, for now const policyTag = PolicyUtils.getTag(policyTags); @@ -131,6 +174,9 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate const shouldShowTag = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledOptions(lodashValues(policyTagsList))); const shouldShowBillable = isPolicyExpenseChat && (transactionBillable || !lodashGet(policy, 'disabledFields.defaultBillable', true)); + const {getViolationsForField} = useViolations(transactionViolations); + const hasViolations = useCallback((field) => canUseViolations && getViolationsForField(field).length > 0, [canUseViolations, getViolationsForField]); + let amountDescription = `${translate('iou.amount')}`; if (isCardTransaction) { @@ -158,18 +204,12 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate } } - // A temporary solution to hide the transaction detail - // This will be removed after we properly add the transaction as a prop - if (ReportActionsUtils.isDeletedAction(parentReportAction)) { - return null; - } - const hasReceipt = TransactionUtils.hasReceipt(transaction); let receiptURIs; let hasErrors = false; if (hasReceipt) { receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction); - hasErrors = canEdit && TransactionUtils.hasMissingSmartscanFields(transaction); + hasErrors = canEditReceipt && TransactionUtils.hasMissingSmartscanFields(transaction); } const pendingAction = lodashGet(transaction, 'pendingAction'); @@ -188,16 +228,18 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate isLocalFile={receiptURIs.isLocalFile} transaction={transaction} enablePreviewModal + canEditReceipt={canEditReceipt} /> </View> </OfflineWithFeedback> )} - {!hasReceipt && canEditReceipt && !isSettled && canUseViolations && ( + {!hasReceipt && canEditReceipt && canUseViolations && ( <ReceiptEmptyState hasError={hasErrors} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT))} /> )} + {canUseViolations && <ViolationMessages violations={getViolationsForField('receipt')} />} <OfflineWithFeedback pendingAction={getPendingFieldAction('pendingFields.amount')}> <MenuItemWithTopDescription title={formattedTransactionAmount ? formattedTransactionAmount.toString() : ''} @@ -208,9 +250,10 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate interactive={canEditAmount} shouldShowRightIcon={canEditAmount} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} - brickRoadIndicator={hasErrors && transactionAmount === 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('amount') || (hasErrors && transactionAmount === 0) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} /> + {canUseViolations && <ViolationMessages violations={getViolationsForField('amount')} />} </OfflineWithFeedback> <OfflineWithFeedback pendingAction={getPendingFieldAction('pendingFields.comment')}> <MenuItemWithTopDescription @@ -222,16 +265,18 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} + brickRoadIndicator={hasViolations('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} numberOfLinesTitle={0} /> + {canUseViolations && <ViolationMessages violations={getViolationsForField('comment')} />} </OfflineWithFeedback> {isDistanceRequest ? ( <OfflineWithFeedback pendingAction={lodashGet(transaction, 'pendingFields.waypoints') || lodashGet(transaction, 'pendingAction')}> <MenuItemWithTopDescription description={translate('common.distance')} title={hasPendingWaypoints ? transactionMerchant.replace(CONST.REGEX.FIRST_SPACE, translate('common.tbd')) : transactionMerchant} - interactive={canEdit && !isSettled} - shouldShowRightIcon={canEdit && !isSettled} + interactive={canEditDistance} + shouldShowRightIcon={canEditDistance} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DISTANCE))} /> @@ -241,26 +286,28 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate <MenuItemWithTopDescription description={translate('common.merchant')} title={isEmptyMerchant ? '' : transactionMerchant} - interactive={canEdit} - shouldShowRightIcon={canEdit} + interactive={canEditMerchant} + shouldShowRightIcon={canEditMerchant} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))} - brickRoadIndicator={hasErrors && isEmptyMerchant ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('merchant') || (hasErrors && isEmptyMerchant) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} error={hasErrors && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} /> + {canUseViolations && <ViolationMessages violations={getViolationsForField('merchant')} />} </OfflineWithFeedback> )} <OfflineWithFeedback pendingAction={getPendingFieldAction('pendingFields.created')}> <MenuItemWithTopDescription description={translate('common.date')} title={transactionDate} - interactive={canEdit && !isSettled} - shouldShowRightIcon={canEdit && !isSettled} + interactive={canEditDate} + shouldShowRightIcon={canEditDate} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} - brickRoadIndicator={hasErrors && transactionDate === '' ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} /> + {canUseViolations && <ViolationMessages violations={getViolationsForField('date')} />} </OfflineWithFeedback> {shouldShowCategory && ( <OfflineWithFeedback pendingAction={lodashGet(transaction, 'pendingFields.category') || lodashGet(transaction, 'pendingAction')}> @@ -271,7 +318,9 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))} + brickRoadIndicator={hasViolations('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} /> + {canUseViolations && <ViolationMessages violations={getViolationsForField('category')} />} </OfflineWithFeedback> )} {shouldShowTag && ( @@ -283,7 +332,9 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.TAG))} + brickRoadIndicator={hasViolations('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} /> + {canUseViolations && <ViolationMessages violations={getViolationsForField('tag')} />} </OfflineWithFeedback> )} {isCardTransaction && ( @@ -295,15 +346,24 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate /> </OfflineWithFeedback> )} + {shouldShowBillable && ( - <View style={[styles.flexRow, styles.optionRow, styles.justifyContentBetween, styles.alignItemsCenter, styles.ml5, styles.mr8]}> - <Text color={!transactionBillable ? theme.textSupporting : undefined}>{translate('common.billable')}</Text> - <Switch - accessibilityLabel={translate('common.billable')} - isOn={transactionBillable} - onToggle={(value) => IOU.editMoneyRequest(transaction, report.reportID, {billable: value})} - /> - </View> + <> + <View style={[styles.flexRow, styles.optionRow, styles.justifyContentBetween, styles.alignItemsCenter, styles.ml5, styles.mr8]}> + <Text color={!transactionBillable ? theme.textSupporting : undefined}>{translate('common.billable')}</Text> + <Switch + accessibilityLabel={translate('common.billable')} + isOn={transactionBillable} + onToggle={(value) => IOU.editMoneyRequest(transaction, report.reportID, {billable: value})} + /> + </View> + {hasViolations('billable') && ( + <ViolationMessages + violations={getViolationsForField('billable')} + isLast + /> + )} + </> )} </View> <SpacerView @@ -349,5 +409,15 @@ export default compose( return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; }, }, + transactionViolation: { + key: ({report}) => { + const parentReportAction = ReportActionsUtils.getParentReportAction(report); + const transactionID = lodashGet(parentReportAction, ['originalMessage', 'IOUTransactionID'], 0); + return `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`; + }, + }, + policyTags: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`, + }, }), )(MoneyRequestView); diff --git a/src/components/ReportActionItem/ReportActionItemImage.js b/src/components/ReportActionItem/ReportActionItemImage.js index 4336a5eddd8a..1495dcbd9111 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.js +++ b/src/components/ReportActionItem/ReportActionItemImage.js @@ -31,6 +31,9 @@ const propTypes = { /** whether thumbnail is refer the local file or not */ isLocalFile: PropTypes.bool, + + /** whether the receipt can be replaced */ + canEditReceipt: PropTypes.bool, }; const defaultProps = { @@ -38,6 +41,7 @@ const defaultProps = { transaction: {}, enablePreviewModal: false, isLocalFile: false, + canEditReceipt: false, }; /** @@ -46,7 +50,7 @@ const defaultProps = { * and optional preview modal as well. */ -function ReportActionItemImage({thumbnail, image, enablePreviewModal, transaction, isLocalFile}) { +function ReportActionItemImage({thumbnail, image, enablePreviewModal, transaction, canEditReceipt, isLocalFile}) { const styles = useThemeStyles(); const {translate} = useLocalize(); const imageSource = tryResolveUrlFromApiRoot(image || ''); @@ -88,6 +92,7 @@ function ReportActionItemImage({thumbnail, image, enablePreviewModal, transactio isAuthTokenRequired={!isLocalFile} report={report} isReceiptAttachment + canEditReceipt={canEditReceipt} allowToDownload originalFileName={transaction.filename} > diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index e88c057a615d..27447a10a32b 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -243,10 +243,10 @@ function ReportPreview(props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const isGroupPolicy = ReportUtils.isGroupPolicyExpenseChat(props.chatReport); + const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicyExpenseChat(props.chatReport); const isPolicyAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && lodashGet(props.policy, 'role') === CONST.POLICY.ROLE.ADMIN; - const isPayer = isGroupPolicy - ? // In a group policy, the admin approver can pay the report directly by skipping the approval step + const isPayer = isPaidGroupPolicy + ? // In a paid group policy, the admin approver can pay the report directly by skipping the approval step isPolicyAdmin && (isApproved || isCurrentUserManager) : isPolicyAdmin || (isMoneyRequestReport && isCurrentUserManager); const shouldShowPayButton = useMemo( @@ -254,11 +254,11 @@ function ReportPreview(props) { [isPayer, isDraftExpenseReport, iouSettled, reimbursableSpend, iouCanceled, props.iouReport], ); const shouldShowApproveButton = useMemo(() => { - if (!isGroupPolicy) { + if (!isPaidGroupPolicy) { return false; } return isCurrentUserManager && !isDraftExpenseReport && !isApproved && !iouSettled; - }, [isGroupPolicy, isCurrentUserManager, isDraftExpenseReport, isApproved, iouSettled]); + }, [isPaidGroupPolicy, isCurrentUserManager, isDraftExpenseReport, isApproved, iouSettled]); const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; return ( <OfflineWithFeedback pendingAction={lodashGet(props, 'iouReport.pendingFields.preview')}> diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js index a7728045f407..414f030d4fc7 100644 --- a/src/components/ReportActionItem/TaskPreview.js +++ b/src/components/ReportActionItem/TaskPreview.js @@ -123,7 +123,10 @@ function TaskPreview(props) { style={[styles.mr2]} containerStyle={[styles.taskCheckbox]} isChecked={isTaskCompleted} - disabled={!Task.canModifyTask(props.taskReport, props.currentUserPersonalDetails.accountID, lodashGet(props.rootParentReportpolicy, 'role', ''))} + disabled={ + _.isEmpty(props.taskReport) || + !Task.canModifyTask(props.taskReport, props.currentUserPersonalDetails.accountID, lodashGet(props.rootParentReportpolicy, 'role', '')) + } onPress={Session.checkIfActionIsAllowed(() => { if (isTaskCompleted) { Task.reopenTask(props.taskReport); diff --git a/src/components/Section/IconSection.js b/src/components/Section/IconSection.js deleted file mode 100644 index 307331aa36d6..000000000000 --- a/src/components/Section/IconSection.js +++ /dev/null @@ -1,41 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import Icon from '@components/Icon'; -import sourcePropTypes from '@components/Image/sourcePropTypes'; -import useThemeStyles from '@hooks/useThemeStyles'; - -const iconSectionPropTypes = { - icon: sourcePropTypes, - IconComponent: PropTypes.IconComponent, - iconContainerStyles: PropTypes.iconContainerStyles, -}; - -const defaultIconSectionPropTypes = { - icon: null, - IconComponent: null, - iconContainerStyles: [], -}; - -function IconSection({icon, IconComponent, iconContainerStyles}) { - const styles = useThemeStyles(); - - return ( - <View style={[styles.flexGrow1, styles.flexRow, styles.justifyContentEnd, ...iconContainerStyles]}> - {Boolean(icon) && ( - <Icon - src={icon} - height={68} - width={68} - /> - )} - {Boolean(IconComponent) && <IconComponent />} - </View> - ); -} - -IconSection.displayName = 'IconSection'; -IconSection.propTypes = iconSectionPropTypes; -IconSection.defaultProps = defaultIconSectionPropTypes; - -export default IconSection; diff --git a/src/components/Section/IconSection.tsx b/src/components/Section/IconSection.tsx new file mode 100644 index 000000000000..cc42c6b7ace5 --- /dev/null +++ b/src/components/Section/IconSection.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type IconSectionProps = { + icon?: IconAsset; + iconContainerStyles?: StyleProp<ViewStyle>; +}; + +function IconSection({icon, iconContainerStyles}: IconSectionProps) { + const styles = useThemeStyles(); + + return ( + <View style={[styles.flexGrow1, styles.flexRow, styles.justifyContentEnd, iconContainerStyles]}> + {!!icon && ( + <Icon + src={icon} + height={68} + width={68} + /> + )} + </View> + ); +} + +IconSection.displayName = 'IconSection'; + +export default IconSection; diff --git a/src/components/Section/index.js b/src/components/Section/index.js deleted file mode 100644 index 50576abef025..000000000000 --- a/src/components/Section/index.js +++ /dev/null @@ -1,122 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import sourcePropTypes from '@components/Image/sourcePropTypes'; -import MenuItemList from '@components/MenuItemList'; -import menuItemPropTypes from '@components/menuItemPropTypes'; -import Text from '@components/Text'; -import useThemeStyles from '@hooks/useThemeStyles'; -import IconSection from './IconSection'; - -const CARD_LAYOUT = { - ICON_ON_TOP: 'iconOnTop', - ICON_ON_RIGHT: 'iconOnRight', -}; - -const propTypes = { - /** An array of props that are pass to individual MenuItem components */ - menuItems: PropTypes.arrayOf(PropTypes.shape(menuItemPropTypes)), - - /** The text to display in the title of the section */ - title: PropTypes.string.isRequired, - - /** The text to display in the subtitle of the section */ - subtitle: PropTypes.string, - - /** The icon to display along with the title */ - icon: sourcePropTypes, - - /** Icon component */ - IconComponent: PropTypes.func, - - /** Card layout that affects icon positioning, margins, sizes. */ - // eslint-disable-next-line rulesdir/prefer-underscore-method - cardLayout: PropTypes.oneOf(Object.values(CARD_LAYOUT)), - - /** Contents to display inside the section */ - children: PropTypes.node, - - /** Customize the Section container */ - // eslint-disable-next-line react/forbid-prop-types - containerStyles: PropTypes.arrayOf(PropTypes.object), - - /** Customize the Section container */ - // eslint-disable-next-line react/forbid-prop-types - titleStyles: PropTypes.arrayOf(PropTypes.object), - - /** Customize the Section container */ - // eslint-disable-next-line react/forbid-prop-types - subtitleStyles: PropTypes.arrayOf(PropTypes.object), - - /** Whether the subtitle should have a muted style */ - subtitleMuted: PropTypes.bool, - - /** Customize the Section container */ - // eslint-disable-next-line react/forbid-prop-types - childrenStyles: PropTypes.arrayOf(PropTypes.object), - - /** Customize the Icon container */ - // eslint-disable-next-line react/forbid-prop-types - iconContainerStyles: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - menuItems: null, - children: null, - icon: null, - IconComponent: null, - cardLayout: CARD_LAYOUT.ICON_ON_RIGHT, - containerStyles: [], - iconContainerStyles: [], - titleStyles: [], - subtitleStyles: [], - subtitleMuted: false, - childrenStyles: [], - subtitle: null, -}; - -function Section({children, childrenStyles, containerStyles, icon, IconComponent, cardLayout, iconContainerStyles, menuItems, subtitle, subtitleStyles, subtitleMuted, title, titleStyles}) { - const styles = useThemeStyles(); - - return ( - <> - <View style={[styles.pageWrapper, styles.cardSection, ...containerStyles]}> - {cardLayout === CARD_LAYOUT.ICON_ON_TOP && ( - <IconSection - icon={icon} - IconComponent={IconComponent} - iconContainerStyles={[...iconContainerStyles, styles.alignSelfStart, styles.mb3]} - /> - )} - <View style={[styles.flexRow, styles.alignItemsCenter, styles.w100, cardLayout === CARD_LAYOUT.ICON_ON_TOP && styles.mh1, ...titleStyles]}> - <View style={[styles.flexShrink1]}> - <Text style={[styles.textHeadline, styles.cardSectionTitle]}>{title}</Text> - </View> - {cardLayout === CARD_LAYOUT.ICON_ON_RIGHT && ( - <IconSection - icon={icon} - IconComponent={IconComponent} - iconContainerStyles={iconContainerStyles} - /> - )} - </View> - - {Boolean(subtitle) && ( - <View style={[styles.flexRow, styles.alignItemsCenter, styles.w100, cardLayout === CARD_LAYOUT.ICON_ON_TOP ? [styles.mt1, styles.mh1] : styles.mt4, ...subtitleStyles]}> - <Text style={[styles.textNormal, subtitleMuted && styles.colorMuted]}>{subtitle}</Text> - </View> - )} - - <View style={[styles.w100, ...childrenStyles]}>{children}</View> - - <View style={[styles.w100]}>{Boolean(menuItems) && <MenuItemList menuItems={menuItems} />}</View> - </View> - </> - ); -} -Section.displayName = 'Section'; -Section.propTypes = propTypes; -Section.defaultProps = defaultProps; - -export {CARD_LAYOUT}; -export default Section; diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx new file mode 100644 index 000000000000..f24316a5f1bb --- /dev/null +++ b/src/components/Section/index.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import type {MenuItemWithLink} from '@components/MenuItemList'; +import MenuItemList from '@components/MenuItemList'; +import Text from '@components/Text'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import type IconAsset from '@src/types/utils/IconAsset'; +import IconSection from './IconSection'; + +const CARD_LAYOUT = { + ICON_ON_TOP: 'iconOnTop', + ICON_ON_RIGHT: 'iconOnRight', +} as const; + +type SectionProps = ChildrenProps & { + /** An array of props that are passed to individual MenuItem components */ + menuItems?: MenuItemWithLink[]; + + /** The text to display in the title of the section */ + title: string; + + /** The text to display in the subtitle of the section */ + subtitle?: string; + + /** The icon to display along with the title */ + icon?: IconAsset; + + /** Card layout that affects icon positioning, margins, sizes */ + cardLayout?: ValueOf<typeof CARD_LAYOUT>; + + /** Whether the subtitle should have a muted style */ + subtitleMuted?: boolean; + + /** Customize the Section container */ + containerStyles?: StyleProp<ViewStyle>; + + /** Customize the Section container */ + titleStyles?: StyleProp<ViewStyle>; + + /** Customize the Section container */ + subtitleStyles?: StyleProp<ViewStyle>; + + /** Customize the Section container */ + childrenStyles?: StyleProp<ViewStyle>; + + /** Customize the Icon container */ + iconContainerStyles?: StyleProp<ViewStyle>; +}; + +function Section({ + children, + childrenStyles, + containerStyles, + icon, + cardLayout = CARD_LAYOUT.ICON_ON_RIGHT, + iconContainerStyles, + menuItems, + subtitle, + subtitleStyles, + subtitleMuted = false, + title, + titleStyles, +}: SectionProps) { + const styles = useThemeStyles(); + + return ( + <> + <View style={[styles.pageWrapper, styles.cardSection, containerStyles]}> + {cardLayout === CARD_LAYOUT.ICON_ON_TOP && ( + <IconSection + icon={icon} + iconContainerStyles={[iconContainerStyles, styles.alignSelfStart, styles.mb3]} + /> + )} + <View style={[styles.flexRow, styles.alignItemsCenter, styles.w100, cardLayout === CARD_LAYOUT.ICON_ON_TOP && styles.mh1, titleStyles]}> + <View style={[styles.flexShrink1]}> + <Text style={[styles.textHeadline, styles.cardSectionTitle]}>{title}</Text> + </View> + {cardLayout === CARD_LAYOUT.ICON_ON_RIGHT && ( + <IconSection + icon={icon} + iconContainerStyles={iconContainerStyles} + /> + )} + </View> + + {!!subtitle && ( + <View style={[styles.flexRow, styles.alignItemsCenter, styles.w100, cardLayout === CARD_LAYOUT.ICON_ON_TOP ? [styles.mt1, styles.mh1] : styles.mt4, subtitleStyles]}> + <Text style={[styles.textNormal, subtitleMuted && styles.colorMuted]}>{subtitle}</Text> + </View> + )} + + <View style={[styles.w100, childrenStyles]}>{children}</View> + + <View style={[styles.w100]}>{!!menuItems && <MenuItemList menuItems={menuItems} />}</View> + </View> + </> + ); +} +Section.displayName = 'Section'; + +export {CARD_LAYOUT}; +export default Section; diff --git a/src/components/SelectionList/BaseListItem.js b/src/components/SelectionList/BaseListItem.js index 443b930d5e7a..64363b4acb2d 100644 --- a/src/components/SelectionList/BaseListItem.js +++ b/src/components/SelectionList/BaseListItem.js @@ -89,8 +89,9 @@ function BaseListItem({ textStyles={[ styles.optionDisplayName, isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, - isUserItem || item.isSelected ? styles.sidebarLinkTextBold : null, + isUserItem || item.isSelected || item.alternateText ? styles.sidebarLinkTextBold : null, styles.pre, + item.alternateText ? styles.mb1 : null, ]} alternateTextStyles={[styles.optionAlternateText, styles.textLabelSupporting, isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, styles.pre]} isDisabled={isDisabled} diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js index 213c55e8d8b9..88c4018f823c 100644 --- a/src/components/SelectionList/BaseSelectionList.js +++ b/src/components/SelectionList/BaseSelectionList.js @@ -76,8 +76,7 @@ function BaseSelectionList({ const activeElement = useActiveElement(); const isFocused = useIsFocused(); const [maxToRenderPerBatch, setMaxToRenderPerBatch] = useState(shouldUseDynamicMaxToRenderPerBatch ? 0 : CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); - const [isInitialRender, setIsInitialRender] = useState(true); - const wrapperStyles = useMemo(() => ({opacity: isInitialRender ? 0 : 1}), [isInitialRender]); + const [isInitialSectionListRender, setIsInitialSectionListRender] = useState(true); /** * Iterates through the sections and items inside each section, and builds 3 arrays along the way: @@ -331,13 +330,13 @@ function BaseSelectionList({ setMaxToRenderPerBatch((Math.ceil(listHeight / itemHeight) || 0) + CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); } - if (!isInitialRender) { + if (!isInitialSectionListRender) { return; } scrollToIndex(focusedIndex, false); - setIsInitialRender(false); + setIsInitialSectionListRender(false); }, - [focusedIndex, isInitialRender, scrollToIndex, shouldUseDynamicMaxToRenderPerBatch], + [focusedIndex, isInitialSectionListRender, scrollToIndex, shouldUseDynamicMaxToRenderPerBatch], ); const updateAndScrollToFocusedIndex = useCallback( @@ -365,7 +364,7 @@ function BaseSelectionList({ useEffect(() => { // do not change focus on the first render, as it should focus on the selected item - if (isInitialRender) { + if (isInitialSectionListRender) { return; } @@ -401,7 +400,7 @@ function BaseSelectionList({ {/* <View style={[styles.flex1, !isKeyboardShown && safeAreaPaddingBottomStyle, wrapperStyle]}> */} <SafeAreaConsumer> {({safeAreaPaddingBottomStyle}) => ( - <View style={[styles.flex1, !isKeyboardShown && safeAreaPaddingBottomStyle, wrapperStyles, StyleUtils.parseStyleAsArray(containerStyle)]}> + <View style={[styles.flex1, !isKeyboardShown && safeAreaPaddingBottomStyle, StyleUtils.parseStyleAsArray(containerStyle)]}> {shouldShowTextInput && ( <View style={[styles.ph5, styles.pb3]}> <TextInput @@ -479,7 +478,7 @@ function BaseSelectionList({ viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}} testID="selection-list" onLayout={scrollToFocusedIndexOnFirstRender} - style={!maxToRenderPerBatch && styles.opacity0} + style={(!maxToRenderPerBatch || isInitialSectionListRender) && styles.opacity0} /> {children} </> diff --git a/src/components/TabSelector/TabLabel.tsx b/src/components/TabSelector/TabLabel.tsx index 40f4dc30bb97..548b4ebccbc8 100644 --- a/src/components/TabSelector/TabLabel.tsx +++ b/src/components/TabSelector/TabLabel.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import {Animated, StyleSheet, Text, View} from 'react-native'; +import {Animated, StyleSheet, View} from 'react-native'; +import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; type TabLabelProps = { diff --git a/src/components/Text.tsx b/src/components/Text.tsx index f8f8a90168fa..f436b9f4495a 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -3,7 +3,8 @@ import React from 'react'; import {Text as RNText, StyleSheet} from 'react-native'; import type {TextProps as RNTextProps, TextStyle} from 'react-native'; import useTheme from '@hooks/useTheme'; -import fontFamily from '@styles/utils/fontFamily'; +import type {FontUtilsType} from '@styles/utils/FontUtils'; +import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -22,7 +23,7 @@ type TextProps = RNTextProps & children: React.ReactNode; /** The family of the font to use */ - family?: keyof typeof fontFamily; + family?: keyof FontUtilsType['fontFamily']['platform']; }; function Text({color, fontSize = variables.fontSizeNormal, textAlign = 'left', children, family = 'EXP_NEUE', style = {}, ...props}: TextProps, ref: ForwardedRef<RNText>) { @@ -32,7 +33,7 @@ function Text({color, fontSize = variables.fontSizeNormal, textAlign = 'left', c color: color ?? theme.text, fontSize, textAlign, - fontFamily: fontFamily[family], + fontFamily: FontUtils.fontFamily.platform[family], ...StyleSheet.flatten(style), }; diff --git a/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js index 78c37e94196a..78f06b4075e0 100644 --- a/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js +++ b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js @@ -26,6 +26,9 @@ const propTypes = { /** Customize the TextInput container */ textInputContainerStyles: PropTypes.arrayOf(PropTypes.object), + /** Customizes the touchable wrapper of the TextInput component */ + touchableInputWrapperStyle: PropTypes.arrayOf(PropTypes.object), + /** Customize the main container */ containerStyles: PropTypes.arrayOf(PropTypes.object), diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index d548041b0cf8..9c3899979aaa 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -37,6 +37,7 @@ function BaseTextInput( errorText = '', icon = null, textInputContainerStyles, + touchableInputWrapperStyle, containerStyles, inputStyle, forceActiveLabel = false, @@ -287,7 +288,7 @@ function BaseTextInput( style={[ autoGrowHeight && styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, typeof maxHeight === 'number' ? maxHeight : 0), !isMultiline && styles.componentHeightLarge, - containerStyles, + touchableInputWrapperStyle, ]} > <View diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 7530e7cbc2c4..21875d4dcc64 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -29,6 +29,9 @@ type CustomBaseTextInputProps = { /** Customize the TextInput container */ textInputContainerStyles?: StyleProp<ViewStyle>; + /** Customizes the touchable wrapper of the TextInput component */ + touchableInputWrapperStyle?: StyleProp<ViewStyle>; + /** Customize the main container */ containerStyles?: StyleProp<ViewStyle>; diff --git a/src/components/TextPill.tsx b/src/components/TextPill.tsx new file mode 100644 index 000000000000..6d473b189534 --- /dev/null +++ b/src/components/TextPill.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import useThemeStyles from '@hooks/useThemeStyles'; +import colors from '@styles/theme/colors'; +import Text from './Text'; + +type TextPillProps = { + /** The color of the text/ */ + color?: string; + + /** Styles to apply to the text */ + textStyles: StyleProp<TextStyle>; + + children: React.ReactNode; +}; + +function TextPill({color, textStyles, children}: TextPillProps) { + const styles = useThemeStyles(); + + return <Text style={[{backgroundColor: color ?? colors.red, borderRadius: 6}, styles.overflowHidden, styles.textStrong, styles.ph2, styles.pv1, textStyles]}>{children}</Text>; +} + +TextPill.displayName = 'TextPill'; + +export default TextPill; diff --git a/src/components/ThemeIllustrationsProvider.tsx b/src/components/ThemeIllustrationsProvider.tsx index 3b83a00960a4..1d54d6782daf 100644 --- a/src/components/ThemeIllustrationsProvider.tsx +++ b/src/components/ThemeIllustrationsProvider.tsx @@ -1,7 +1,8 @@ import React, {useMemo} from 'react'; import useThemePreference from '@hooks/useThemePreference'; import ThemeIllustrationsContext from '@styles/theme/context/ThemeIllustrationsContext'; -import Illustrations from '@styles/theme/illustrations'; +// eslint-disable-next-line no-restricted-imports +import illustrations from '@styles/theme/illustrations'; type ThemeIllustrationsProviderProps = { children: React.ReactNode; @@ -10,9 +11,9 @@ type ThemeIllustrationsProviderProps = { function ThemeIllustrationsProvider({children}: ThemeIllustrationsProviderProps) { const themePreference = useThemePreference(); - const illustrations = useMemo(() => Illustrations[themePreference], [themePreference]); + const themeIllustrations = useMemo(() => illustrations[themePreference], [themePreference]); - return <ThemeIllustrationsContext.Provider value={illustrations}>{children}</ThemeIllustrationsContext.Provider>; + return <ThemeIllustrationsContext.Provider value={themeIllustrations}>{children}</ThemeIllustrationsContext.Provider>; } ThemeIllustrationsProvider.displayName = 'ThemeIllustrationsProvider'; diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index f3d2d9e64ca7..76371bbbc9e1 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, {useMemo} from 'react'; import useThemePreferenceWithStaticOverride from '@hooks/useThemePreferenceWithStaticOverride'; +// eslint-disable-next-line no-restricted-imports import themes from '@styles/theme'; import ThemeContext from '@styles/theme/context/ThemeContext'; import type {ThemePreferenceWithoutSystem} from '@styles/theme/types'; diff --git a/src/components/ThemeStylesProvider.tsx b/src/components/ThemeStylesProvider.tsx index f0d25d9e4dde..3d9431e51446 100644 --- a/src/components/ThemeStylesProvider.tsx +++ b/src/components/ThemeStylesProvider.tsx @@ -1,7 +1,9 @@ import React, {useMemo} from 'react'; import useTheme from '@hooks/useTheme'; -import stylesGenerator from '@styles/index'; +// eslint-disable-next-line no-restricted-imports +import styles from '@styles/index'; import ThemeStylesContext from '@styles/theme/context/ThemeStylesContext'; +// eslint-disable-next-line no-restricted-imports import createStyleUtils from '@styles/utils'; type ThemeStylesProviderProps = React.PropsWithChildren; @@ -9,9 +11,9 @@ type ThemeStylesProviderProps = React.PropsWithChildren; function ThemeStylesProvider({children}: ThemeStylesProviderProps) { const theme = useTheme(); - const styles = useMemo(() => stylesGenerator(theme), [theme]); - const StyleUtils = useMemo(() => createStyleUtils(theme, styles), [theme, styles]); - const contextValue = useMemo(() => ({styles, StyleUtils}), [styles, StyleUtils]); + const themeStyles = useMemo(() => styles(theme), [theme]); + const StyleUtils = useMemo(() => createStyleUtils(theme, themeStyles), [theme, themeStyles]); + const contextValue = useMemo(() => ({styles: themeStyles, StyleUtils}), [themeStyles, StyleUtils]); return <ThemeStylesContext.Provider value={contextValue}>{children}</ThemeStylesContext.Provider>; } diff --git a/src/components/TimePicker/TimePicker.js b/src/components/TimePicker/TimePicker.js index 20f52e2d08ee..a9b4566a390c 100644 --- a/src/components/TimePicker/TimePicker.js +++ b/src/components/TimePicker/TimePicker.js @@ -469,7 +469,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) { setSelectionHour(e.nativeEvent.selection); }} style={styles.timePickerInput} - containerStyles={[styles.timePickerHeight100]} + touchableInputWrapperStyle={styles.timePickerHeight100} selection={selectionHour} showSoftInputOnFocus={false} /> @@ -497,7 +497,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) { setSelectionMinute(e.nativeEvent.selection); }} style={styles.timePickerInput} - containerStyles={[styles.timePickerHeight100]} + touchableInputWrapperStyle={styles.timePickerHeight100} selection={selectionMinute} showSoftInputOnFocus={false} /> diff --git a/src/components/ValuePicker/ValueSelectorModal.js b/src/components/ValuePicker/ValueSelectorModal.js index edc5a48d0bb3..79608edb3ef4 100644 --- a/src/components/ValuePicker/ValueSelectorModal.js +++ b/src/components/ValuePicker/ValueSelectorModal.js @@ -41,7 +41,7 @@ function ValueSelectorModal({items, selectedItem, label, isVisible, onClose, onI const [sectionsData, setSectionsData] = useState([]); useEffect(() => { - const itemsData = _.map(items, (item) => ({value: item.value, keyForList: item.value, text: item.label, isSelected: item === selectedItem})); + const itemsData = _.map(items, (item) => ({value: item.value, alternateText: item.description, keyForList: item.value, text: item.label, isSelected: item === selectedItem})); setSectionsData(itemsData); }, [items, selectedItem]); diff --git a/src/components/ValuePicker/index.js b/src/components/ValuePicker/index.js index b5ddaa7dcb73..a21402b9993f 100644 --- a/src/components/ValuePicker/index.js +++ b/src/components/ValuePicker/index.js @@ -29,6 +29,9 @@ const propTypes = { /** Callback to call when the input changes */ onInputChange: PropTypes.func, + /** Text to display under the main menu item */ + furtherDetails: PropTypes.string, + /** A ref to forward to MenuItemWithTopDescription */ forwardedRef: refPropTypes, }; @@ -40,10 +43,11 @@ const defaultProps = { items: {}, forwardedRef: undefined, errorText: '', + furtherDetails: undefined, onInputChange: () => {}, }; -function ValuePicker({value, label, items, placeholder, errorText, onInputChange, forwardedRef}) { +function ValuePicker({value, label, items, placeholder, errorText, onInputChange, furtherDetails, forwardedRef}) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [isPickerVisible, setIsPickerVisible] = useState(false); @@ -76,6 +80,7 @@ function ValuePicker({value, label, items, placeholder, errorText, onInputChange descriptionTextStyle={descStyle} description={label} onPress={showPickerModal} + furtherDetails={furtherDetails} /> <View style={styles.ml5}> <FormHelpMessage message={errorText} /> diff --git a/src/components/ViolationMessages.tsx b/src/components/ViolationMessages.tsx new file mode 100644 index 000000000000..8eb555184596 --- /dev/null +++ b/src/components/ViolationMessages.tsx @@ -0,0 +1,26 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ViolationsUtils from '@libs/ViolationsUtils'; +import type {TransactionViolation} from '@src/types/onyx'; +import Text from './Text'; + +export default function ViolationMessages({violations, isLast}: {violations: TransactionViolation[]; isLast?: boolean}) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const violationMessages = useMemo(() => violations.map((violation) => [violation.name, ViolationsUtils.getViolationTranslation(violation, translate)]), [translate, violations]); + + return ( + <View style={[styles.mtn2, isLast ? styles.mb2 : styles.mb1]}> + {violationMessages.map(([name, message]) => ( + <Text + key={`violationMessages.${name}`} + style={[styles.ph5, styles.textLabelError]} + > + {message} + </Text> + ))} + </View> + ); +} diff --git a/src/hooks/useSingleExecution/index.ts b/src/hooks/useSingleExecution/index.ts index f1be359f0355..909416dd848b 100644 --- a/src/hooks/useSingleExecution/index.ts +++ b/src/hooks/useSingleExecution/index.ts @@ -9,9 +9,9 @@ type Action<T extends unknown[]> = (...params: T) => void | Promise<void>; */ export default function useSingleExecution() { const singleExecution = useCallback( - <T extends unknown[]>(action: Action<T>) => + <T extends unknown[]>(action?: Action<T>) => (...params: T) => { - action(...params); + action?.(...params); }, [], ); diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index 0f43abdff6e2..76d48158237b 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -2,12 +2,12 @@ import {useCallback, useMemo} from 'react'; import type {TransactionViolation, ViolationName} from '@src/types/onyx'; /** - * Names of Fields where violations can occur + * Names of Fields where violations can occur. */ type ViolationField = 'amount' | 'billable' | 'category' | 'comment' | 'date' | 'merchant' | 'receipt' | 'tag' | 'tax'; /** - * Map from Violation Names to the field where that violation can occur + * Map from Violation Names to the field where that violation can occur. */ const violationFields: Record<ViolationName, ViolationField> = { allTagLevelsRequired: 'tag', @@ -60,13 +60,12 @@ function useViolations(violations: TransactionViolation[]) { return violationGroups ?? new Map(); }, [violations]); - const hasViolations = useCallback((field: ViolationField) => Boolean(violationsByField.get(field)?.length), [violationsByField]); const getViolationsForField = useCallback((field: ViolationField) => violationsByField.get(field) ?? [], [violationsByField]); return { - hasViolations, getViolationsForField, }; } export default useViolations; +export type {ViolationField}; diff --git a/src/languages/en.ts b/src/languages/en.ts index e223dd0a9aaf..0b8983a8361b 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -74,6 +74,20 @@ import type { UpdatedTheDistanceParams, UpdatedTheRequestParams, UserIsAlreadyMemberParams, + ViolationsAutoReportedRejectedExpenseParams, + ViolationsCashExpenseWithNoReceiptParams, + ViolationsConversionSurchargeParams, + ViolationsInvoiceMarkupParams, + ViolationsMaxAgeParams, + ViolationsMissingTagParams, + ViolationsOverAutoApprovalLimitParams, + ViolationsOverCategoryLimitParams, + ViolationsOverLimitParams, + ViolationsPerDayLimitParams, + ViolationsReceiptRequiredParams, + ViolationsRterParams, + ViolationsTagOutOfPolicyParams, + ViolationsTaxOutOfPolicyParams, WaitingOnBankAccountParams, WalletProgramParams, WelcomeEnterMagicCodeParams, @@ -627,6 +641,14 @@ export default { }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`, enableWallet: 'Enable Wallet', + hold: 'Hold', + holdEducationalTitle: 'This request is on', + whatIsHoldTitle: 'What is hold?', + whatIsHoldExplain: 'Hold is our way of streamlining financial collaboration. "Reject" is so harsh!', + holdIsTemporaryTitle: 'Hold is usually temporary', + holdIsTemporaryExplain: "Because hold is used to clear up confusion or clarify an important detail before payment, it's not permanent.", + deleteHoldTitle: "Delete whatever won't be paid", + deleteHoldExplain: "In the rare case where something is put on hold and won't be paid, it's on the person requesting payment to delete it.", set: 'set', changed: 'changed', removed: 'removed', @@ -2035,38 +2057,49 @@ export default { copyReferralLink: 'Copy invite link', }, violations: { - allTagLevelsRequired: 'dummy.violations.allTagLevelsRequired', - autoReportedRejectedExpense: 'dummy.violations.autoReportedRejectedExpense', - billableExpense: 'dummy.violations.billableExpense', - cashExpenseWithNoReceipt: 'dummy.violations.cashExpenseWithNoReceipt', - categoryOutOfPolicy: 'dummy.violations.categoryOutOfPolicy', - conversionSurcharge: 'dummy.violations.conversionSurcharge', - customUnitOutOfPolicy: 'dummy.violations.customUnitOutOfPolicy', - duplicatedTransaction: 'dummy.violations.duplicatedTransaction', - fieldRequired: 'dummy.violations.fieldRequired', - futureDate: 'dummy.violations.futureDate', - invoiceMarkup: 'dummy.violations.invoiceMarkup', - maxAge: 'dummy.violations.maxAge', - missingCategory: 'dummy.violations.missingCategory', - missingComment: 'dummy.violations.missingComment', - missingTag: 'dummy.violations.missingTag', - modifiedAmount: 'dummy.violations.modifiedAmount', - modifiedDate: 'dummy.violations.modifiedDate', - nonExpensiworksExpense: 'dummy.violations.nonExpensiworksExpense', - overAutoApprovalLimit: 'dummy.violations.overAutoApprovalLimit', - overCategoryLimit: 'dummy.violations.overCategoryLimit', - overLimit: 'dummy.violations.overLimit', - overLimitAttendee: 'dummy.violations.overLimitAttendee', - perDayLimit: 'dummy.violations.perDayLimit', - receiptNotSmartScanned: 'dummy.violations.receiptNotSmartScanned', - receiptRequired: 'dummy.violations.receiptRequired', - rter: 'dummy.violations.rter', - smartscanFailed: 'dummy.violations.smartscanFailed', - someTagLevelsRequired: 'dummy.violations.someTagLevelsRequired', - tagOutOfPolicy: 'dummy.violations.tagOutOfPolicy', - taxAmountChanged: 'dummy.violations.taxAmountChanged', - taxOutOfPolicy: 'dummy.violations.taxOutOfPolicy', - taxRateChanged: 'dummy.violations.taxRateChanged', - taxRequired: 'dummy.violations.taxRequired', + allTagLevelsRequired: 'All tags required', + autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, + billableExpense: 'Billable no longer valid', + cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Receipt required over ${amount}`, + categoryOutOfPolicy: 'Category no longer valid', + conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `Applied ${surcharge}% conversion surcharge`, + customUnitOutOfPolicy: 'Unit no longer valid', + duplicatedTransaction: 'Potential duplicate', + fieldRequired: 'Report fields are required', + futureDate: 'Future date not allowed', + invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Marked up by ${invoiceMarkup}%`, + maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date older than ${maxAge} days`, + missingCategory: 'Missing category', + missingComment: 'Description required for selected category', + missingTag: ({tagName}: ViolationsMissingTagParams) => `Missing ${tagName ?? 'tag'}`, + modifiedAmount: 'Amount greater than scanned receipt', + modifiedDate: 'Date differs from scanned receipt', + nonExpensiworksExpense: 'Non-Expensiworks expense', + overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Expense exceeds auto approval limit of ${formattedLimitAmount}`, + overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${categoryLimit}/person category limit`, + overLimit: ({amount}: ViolationsOverLimitParams) => `Amount over ${amount}/person limit`, + overLimitAttendee: ({amount}: ViolationsOverLimitParams) => `Amount over ${amount}/person limit`, + perDayLimit: ({limit}: ViolationsPerDayLimitParams) => `Amount over daily ${limit}/person category limit`, + receiptNotSmartScanned: 'Receipt not verified. Please confirm accuracy.', + receiptRequired: ({amount, category}: ViolationsReceiptRequiredParams) => `Receipt required over ${amount} ${category ? ' category limit' : ''}`, + rter: ({brokenBankConnection, email, isAdmin, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { + if (brokenBankConnection) { + return isAdmin + ? `Can't auto-match receipt due to broken bank connection which ${email} needs to fix` + : "Can't auto-match receipt due to broken bank connection which you need to fix"; + } + if (!isTransactionOlderThan7Days) { + return isAdmin ? `Ask ${member} to mark as a cash or wait 7 days and try again` : 'Awaiting merge with card transaction.'; + } + + return ''; + }, + smartscanFailed: 'Receipt scanning failed. Enter details manually.', + someTagLevelsRequired: 'Missing tag', + tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `${tagName ?? ''} no longer valid`, + taxAmountChanged: 'Tax amount was modified', + taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? ''} no longer valid`, + taxRateChanged: 'Tax rate was modified', + taxRequired: 'Missing tax rate', }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index 42743f43a098..a1afde53482b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -73,6 +73,20 @@ import type { UpdatedTheDistanceParams, UpdatedTheRequestParams, UserIsAlreadyMemberParams, + ViolationsAutoReportedRejectedExpenseParams, + ViolationsCashExpenseWithNoReceiptParams, + ViolationsConversionSurchargeParams, + ViolationsInvoiceMarkupParams, + ViolationsMaxAgeParams, + ViolationsMissingTagParams, + ViolationsOverAutoApprovalLimitParams, + ViolationsOverCategoryLimitParams, + ViolationsOverLimitParams, + ViolationsPerDayLimitParams, + ViolationsReceiptRequiredParams, + ViolationsRterParams, + ViolationsTagOutOfPolicyParams, + ViolationsTaxOutOfPolicyParams, WaitingOnBankAccountParams, WalletProgramParams, WelcomeEnterMagicCodeParams, @@ -622,6 +636,14 @@ export default { }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su Billetera`, enableWallet: 'Habilitar Billetera', + hold: 'Hold', + holdEducationalTitle: 'Esta solicitud está en', + whatIsHoldTitle: '¿Qué es Hold?', + whatIsHoldExplain: 'Hold es nuestra forma de agilizar la colaboración financiera. ¡"Rechazar" es tan duro!', + holdIsTemporaryTitle: 'Hold suele ser temporal', + holdIsTemporaryExplain: 'Debido a que hold se utiliza para aclarar confusión o aclarar un detalle importante antes del pago, no es permanente.', + deleteHoldTitle: 'Eliminar lo que no se pagará', + deleteHoldExplain: 'En el raro caso de que algo se ponga en hold y no se pague, la persona que solicita el pago debe eliminarlo.', set: 'estableció', changed: 'cambió', removed: 'eliminó', @@ -2522,38 +2544,50 @@ export default { copyReferralLink: 'Copiar enlace de invitación', }, violations: { - allTagLevelsRequired: 'dummy.violations.allTagLevelsRequired', - autoReportedRejectedExpense: 'dummy.violations.autoReportedRejectedExpense', - billableExpense: 'dummy.violations.billableExpense', - cashExpenseWithNoReceipt: 'dummy.violations.cashExpenseWithNoReceipt', - categoryOutOfPolicy: 'dummy.violations.categoryOutOfPolicy', - conversionSurcharge: 'dummy.violations.conversionSurcharge', - customUnitOutOfPolicy: 'dummy.violations.customUnitOutOfPolicy', - duplicatedTransaction: 'dummy.violations.duplicatedTransaction', - fieldRequired: 'dummy.violations.fieldRequired', - futureDate: 'dummy.violations.futureDate', - invoiceMarkup: 'dummy.violations.invoiceMarkup', - maxAge: 'dummy.violations.maxAge', - missingCategory: 'dummy.violations.missingCategory', - missingComment: 'dummy.violations.missingComment', - missingTag: 'dummy.violations.missingTag', - modifiedAmount: 'dummy.violations.modifiedAmount', - modifiedDate: 'dummy.violations.modifiedDate', - nonExpensiworksExpense: 'dummy.violations.nonExpensiworksExpense', - overAutoApprovalLimit: 'dummy.violations.overAutoApprovalLimit', - overCategoryLimit: 'dummy.violations.overCategoryLimit', - overLimit: 'dummy.violations.overLimit', - overLimitAttendee: 'dummy.violations.overLimitAttendee', - perDayLimit: 'dummy.violations.perDayLimit', - receiptNotSmartScanned: 'dummy.violations.receiptNotSmartScanned', - receiptRequired: 'dummy.violations.receiptRequired', - rter: 'dummy.violations.rter', - smartscanFailed: 'dummy.violations.smartscanFailed', - someTagLevelsRequired: 'dummy.violations.someTagLevelsRequired', - tagOutOfPolicy: 'dummy.violations.tagOutOfPolicy', - taxAmountChanged: 'dummy.violations.taxAmountChanged', - taxOutOfPolicy: 'dummy.violations.taxOutOfPolicy', - taxRateChanged: 'dummy.violations.taxRateChanged', - taxRequired: 'dummy.violations.taxRequired', + allTagLevelsRequired: 'Todas las etiquetas son obligatorias', + autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, + billableExpense: 'La opción facturable ya no es válida', + cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para montos mayores a ${amount}`, + categoryOutOfPolicy: 'La categorÃa ya no es válida', + conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `${surcharge}% de recargo aplicado`, + customUnitOutOfPolicy: 'Unidad ya no es válida', + duplicatedTransaction: 'Potencial duplicado', + fieldRequired: 'Los campos del informe son obligatorios', + futureDate: 'Fecha futura no permitida', + invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Incrementado un ${invoiceMarkup}%`, + maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Fecha de más de ${maxAge} dÃas`, + missingCategory: 'Falta categorÃa', + missingComment: 'Descripción obligatoria para categorÃa seleccionada', + missingTag: ({tagName}: ViolationsMissingTagParams) => `Falta ${tagName}`, + modifiedAmount: 'Importe superior al del recibo escaneado', + modifiedDate: 'Fecha difiere del recibo escaneado', + nonExpensiworksExpense: 'Gasto no es de Expensiworks', + overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Importe supera el lÃmite de aprobación automática de ${formattedLimitAmount}`, + overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Importe supera el lÃmite para la categorÃa de ${categoryLimit}/persona`, + overLimit: ({amount}: ViolationsOverLimitParams) => `Importe supera el lÃmite de ${amount}/persona`, + overLimitAttendee: ({amount}: ViolationsOverLimitParams) => `Importe supera el lÃmite de ${amount}/persona`, + perDayLimit: ({limit}: ViolationsPerDayLimitParams) => `Importe supera el lÃmite diario de la categorÃa de ${limit}/persona`, + receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma su exactitud', + receiptRequired: ({amount, category}: ViolationsReceiptRequiredParams) => `Recibo obligatorio para importes sobre ${category ? 'el limite de la categorÃa de ' : ''}${amount}`, + rter: ({brokenBankConnection, isAdmin, email, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { + if (brokenBankConnection) { + return isAdmin + ? `No se puede adjuntar recibo debido a una conexión con su banco que ${email} necesita arreglar` + : 'No se puede adjuntar recibo debido a una conexión con su banco que necesitas arreglar'; + } + if (!isTransactionOlderThan7Days) { + return isAdmin + ? `PÃdele a ${member} que marque la transacción como efectivo o espera 7 dÃas e intenta de nuevo` + : 'Esperando adjuntar automáticamente a transacción de tarjeta de crédito'; + } + return ''; + }, + smartscanFailed: 'No se pudo escanear el recibo. Introduce los datos manualmente', + someTagLevelsRequired: 'Falta etiqueta', + tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `Le etiqueta ${tagName} ya no es válida`, + taxAmountChanged: 'El importe del impuesto fue modificado', + taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName} ya no es válido`, + taxRateChanged: 'La tasa de impuesto fue modificada', + taxRequired: 'Falta tasa de impuesto', }, } satisfies EnglishTranslation; diff --git a/src/languages/types.ts b/src/languages/types.ts index dd2d339858b0..5b6e56a38689 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -209,6 +209,40 @@ type TagSelectionParams = {tagName: string}; type WalletProgramParams = {walletProgram: string}; +type ViolationsAutoReportedRejectedExpenseParams = {rejectedBy: string; rejectReason: string}; + +type ViolationsCashExpenseWithNoReceiptParams = {amount: string}; + +type ViolationsConversionSurchargeParams = {surcharge?: number}; + +type ViolationsInvoiceMarkupParams = {invoiceMarkup?: number}; + +type ViolationsMaxAgeParams = {maxAge: number}; + +type ViolationsMissingTagParams = {tagName?: string}; + +type ViolationsOverAutoApprovalLimitParams = {formattedLimitAmount: string}; + +type ViolationsOverCategoryLimitParams = {categoryLimit: string}; + +type ViolationsOverLimitParams = {amount: string}; + +type ViolationsPerDayLimitParams = {limit: string}; + +type ViolationsReceiptRequiredParams = {amount: string; category?: string}; + +type ViolationsRterParams = { + brokenBankConnection: boolean; + isAdmin: boolean; + email?: string; + isTransactionOlderThan7Days: boolean; + member?: string; +}; + +type ViolationsTagOutOfPolicyParams = {tagName?: string}; + +type ViolationsTaxOutOfPolicyParams = {taxName?: string}; + type TaskCreatedActionParams = {title: string}; /* Translation Object types */ @@ -250,87 +284,101 @@ type TranslationFlatObject = { }; export type { - TranslationBase, - TranslationPaths, - EnglishTranslation, - TranslationFlatObject, + ApprovedAmountParams, AddressLineParams, - CharacterLimitParams, - MaxParticipantsReachedParams, - ZipCodeExampleFormatParams, - LoggedInAsParams, - NewFaceEnterMagicCodeParams, - WelcomeEnterMagicCodeParams, AlreadySignedInParams, - GoBackMessageParams, - LocalTimeParams, - EditActionParams, - DeleteActionParams, - DeleteConfirmationParams, - BeginningOfChatHistoryDomainRoomPartOneParams, + AmountEachParams, BeginningOfChatHistoryAdminRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartTwo, - WelcomeToRoomParams, - ReportArchiveReasonsClosedParams, - ReportArchiveReasonsMergedParams, - ReportArchiveReasonsRemovedFromPolicyParams, - ReportArchiveReasonsPolicyDeletedParams, - RequestCountParams, - SettleExpensifyCardParams, - RequestAmountParams, - RequestedAmountMessageParams, - SplitAmountParams, + BeginningOfChatHistoryDomainRoomPartOneParams, + CanceledRequestParams, + CharacterLimitParams, + ConfirmThatParams, + DateShouldBeAfterParams, + DateShouldBeBeforeParams, + DeleteActionParams, + DeleteConfirmationParams, DidSplitAmountMessageParams, - AmountEachParams, + EditActionParams, + EnglishTranslation, + EnterMagicCodeParams, + FormattedMaxLengthParams, + GoBackMessageParams, + GoToRoomParams, + IncorrectZipFormatParams, + InstantSummaryParams, + LocalTimeParams, + LoggedInAsParams, + ManagerApprovedAmountParams, + ManagerApprovedParams, + MaxParticipantsReachedParams, + NewFaceEnterMagicCodeParams, + NoLongerHaveAccessParams, + NotAllowedExtensionParams, + NotYouParams, + OOOEventSummaryFullDayParams, + OOOEventSummaryPartialDayParams, + OurEmailProviderParams, + PaidElsewhereWithAmountParams, + PaidWithExpensifyWithAmountParams, + ParentNavigationSummaryParams, PayerOwesAmountParams, PayerOwesParams, PayerPaidAmountParams, PayerPaidParams, - ApprovedAmountParams, - ManagerApprovedParams, - ManagerApprovedAmountParams, PayerSettledParams, - WaitingOnBankAccountParams, - CanceledRequestParams, - SettledAfterAddedBankAccountParams, - PaidElsewhereWithAmountParams, - PaidWithExpensifyWithAmountParams, - ThreadRequestReportNameParams, - ThreadSentMoneyReportNameParams, - SizeExceededParams, + RemovedTheRequestParams, + RenamedRoomActionParams, + ReportArchiveReasonsClosedParams, + ReportArchiveReasonsMergedParams, + ReportArchiveReasonsPolicyDeletedParams, + ReportArchiveReasonsRemovedFromPolicyParams, + RequestAmountParams, + RequestCountParams, + RequestedAmountMessageParams, ResolutionConstraintsParams, - NotAllowedExtensionParams, - EnterMagicCodeParams, - TransferParams, - InstantSummaryParams, - NotYouParams, - DateShouldBeBeforeParams, - DateShouldBeAfterParams, - IncorrectZipFormatParams, - WeSentYouMagicSignInLinkParams, - ToValidateLoginParams, - NoLongerHaveAccessParams, - OurEmailProviderParams, - ConfirmThatParams, - UntilTimeParams, - StepCounterParams, - UserIsAlreadyMemberParams, - GoToRoomParams, - WelcomeNoteParams, RoomNameReservedErrorParams, - RenamedRoomActionParams, RoomRenamedToParams, - OOOEventSummaryFullDayParams, - OOOEventSummaryPartialDayParams, - ParentNavigationSummaryParams, + SetTheDistanceParams, SetTheRequestParams, - UpdatedTheRequestParams, - RemovedTheRequestParams, - FormattedMaxLengthParams, + SettleExpensifyCardParams, + SettledAfterAddedBankAccountParams, + SizeExceededParams, + SplitAmountParams, + StepCounterParams, TagSelectionParams, - SetTheDistanceParams, + TaskCreatedActionParams, + ThreadRequestReportNameParams, + ThreadSentMoneyReportNameParams, + ToValidateLoginParams, + TransferParams, + TranslationBase, + TranslationFlatObject, + TranslationPaths, + UntilTimeParams, UpdatedTheDistanceParams, + UpdatedTheRequestParams, + UserIsAlreadyMemberParams, + ViolationsAutoReportedRejectedExpenseParams, + ViolationsCashExpenseWithNoReceiptParams, + ViolationsConversionSurchargeParams, + ViolationsInvoiceMarkupParams, + ViolationsMaxAgeParams, + ViolationsMissingTagParams, + ViolationsOverAutoApprovalLimitParams, + ViolationsOverCategoryLimitParams, + ViolationsOverLimitParams, + ViolationsPerDayLimitParams, + ViolationsReceiptRequiredParams, + ViolationsRterParams, + ViolationsTagOutOfPolicyParams, + ViolationsTaxOutOfPolicyParams, + WaitingOnBankAccountParams, WalletProgramParams, - TaskCreatedActionParams, + WeSentYouMagicSignInLinkParams, + WelcomeEnterMagicCodeParams, + WelcomeNoteParams, + WelcomeToRoomParams, + ZipCodeExampleFormatParams, }; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 08f61a50b645..c02d200a7c83 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -30,6 +30,7 @@ import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {timezoneBackwardMap} from '@src/TIMEZONES'; import type {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails'; import * as CurrentDate from './actions/CurrentDate'; import * as Localize from './Localize'; @@ -697,6 +698,22 @@ function formatWithUTCTimeZone(datetime: string, dateFormat: string = CONST.DATE return ''; } +/** + * + * @param timezone + * function format unsupported timezone to supported timezone + * @returns Timezone + */ +function formatToSupportedTimezone(timezoneInput: Timezone): Timezone { + if (!timezoneInput?.selected) { + return timezoneInput; + } + return { + selected: timezoneBackwardMap[timezoneInput.selected] ?? timezoneInput.selected, + automatic: timezoneInput.automatic, + }; +} + const DateUtils = { formatToDayOfWeek, formatToLongDateWithWeekday, @@ -739,6 +756,7 @@ const DateUtils = { getWeekStartsOn, getWeekEndsOn, isTimeAtLeastOneMinuteInFuture, + formatToSupportedTimezone, }; export default DateUtils; diff --git a/src/libs/EmojiTrie.ts b/src/libs/EmojiTrie.ts index 4c441facdd46..d0f0b0dcfab6 100644 --- a/src/libs/EmojiTrie.ts +++ b/src/libs/EmojiTrie.ts @@ -1,38 +1,11 @@ import emojis, {localeEmojis} from '@assets/emojis'; +import type {Emoji, HeaderEmoji, PickerEmoji} from '@assets/emojis/types'; import CONST from '@src/CONST'; -import type IconAsset from '@src/types/utils/IconAsset'; import Timing from './actions/Timing'; import Trie from './Trie'; -type HeaderEmoji = { - code: string; - header: boolean; - icon: IconAsset; -}; - -type SimpleEmoji = { - code: string; - name: string; - types?: string[]; -}; - -type Emoji = HeaderEmoji | SimpleEmoji; - -type LocalizedEmoji = { - name?: string; - keywords: string[]; -}; - -type LocalizedEmojis = Record<string, LocalizedEmoji>; - -type Suggestion = { - code: string; - types?: string[]; - name: string; -}; - type EmojiMetaData = { - suggestions?: Suggestion[]; + suggestions?: Emoji[]; code?: string; types?: string[]; name?: string; @@ -56,7 +29,7 @@ type EmojiTrie = { * @param name The localized name of the emoji. * @param shouldPrependKeyword Prepend the keyword (instead of append) to the suggestions */ -function addKeywordsToTrie(trie: Trie<EmojiMetaData>, keywords: string[], item: SimpleEmoji, name: string, shouldPrependKeyword = false) { +function addKeywordsToTrie(trie: Trie<EmojiMetaData>, keywords: string[], item: Emoji, name: string, shouldPrependKeyword = false) { keywords.forEach((keyword) => { const keywordNode = trie.search(keyword); if (!keywordNode) { @@ -85,13 +58,13 @@ function getNameParts(name: string): string[] { function createTrie(lang: SupportedLanguage = CONST.LOCALES.DEFAULT): Trie<EmojiMetaData> { const trie = new Trie(); - const langEmojis: LocalizedEmojis = localeEmojis[lang]; - const defaultLangEmojis: LocalizedEmojis = localeEmojis[CONST.LOCALES.DEFAULT]; + const langEmojis = localeEmojis[lang]; + const defaultLangEmojis = localeEmojis[CONST.LOCALES.DEFAULT]; const isDefaultLocale = lang === CONST.LOCALES.DEFAULT; emojis - .filter((item: Emoji): item is SimpleEmoji => !(item as HeaderEmoji).header) - .forEach((item: SimpleEmoji) => { + .filter((item: PickerEmoji): item is Emoji => !(item as HeaderEmoji).header) + .forEach((item: Emoji) => { const englishName = item.name; const localeName = langEmojis?.[item.code]?.name ?? englishName; @@ -127,4 +100,4 @@ const emojiTrie: EmojiTrie = supportedLanguages.reduce((prev, cur) => ({...prev, Timing.end(CONST.TIMING.TRIE_INITIALIZATION); export default emojiTrie; -export type {SimpleEmoji, SupportedLanguage}; +export type {SupportedLanguage}; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 06bbd5c871ed..02d1b34c69c1 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -2,11 +2,12 @@ import {getUnixTime} from 'date-fns'; import Str from 'expensify-common/lib/str'; import memoize from 'lodash/memoize'; import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import * as Emojis from '@assets/emojis'; import type {Emoji, HeaderEmoji, PickerEmojis} from '@assets/emojis/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {FrequentlyUsedEmoji} from '@src/types/onyx'; +import type {FrequentlyUsedEmoji, Locale} from '@src/types/onyx'; import type {ReportActionReaction, UsersReactions} from '@src/types/onyx/ReportActionReactions'; import type IconAsset from '@src/types/utils/IconAsset'; import type {SupportedLanguage} from './EmojiTrie'; @@ -48,13 +49,13 @@ const getEmojiName = (emoji: Emoji, lang: 'en' | 'es' = CONST.LOCALES.DEFAULT): /** * Given an English emoji name, get its localized version */ -const getLocalizedEmojiName = (name: string, lang: 'en' | 'es'): string => { +const getLocalizedEmojiName = (name: string, lang: OnyxEntry<Locale>): string => { if (lang === CONST.LOCALES.DEFAULT) { return name; } const emojiCode = Emojis.emojiNameTable[name]?.code ?? ''; - return Emojis.localeEmojis[lang]?.[emojiCode]?.name ?? ''; + return (lang && Emojis.localeEmojis[lang]?.[emojiCode]?.name) ?? ''; }; /** @@ -438,8 +439,8 @@ const getPreferredSkinToneIndex = (value: string | number | null): number => { * Given an emoji object it returns the correct emoji code * based on the users preferred skin tone. */ -const getPreferredEmojiCode = (emoji: Emoji, preferredSkinTone: number): string => { - if (emoji.types) { +const getPreferredEmojiCode = (emoji: Emoji, preferredSkinTone: OnyxEntry<string | number>): string => { + if (emoji.types && typeof preferredSkinTone === 'number') { const emojiCodeWithSkinTone = emoji.types[preferredSkinTone]; // Note: it can happen that preferredSkinTone has a outdated format, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 58825ae6f2b1..7286615e6ba6 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -2,6 +2,7 @@ import React, {memo, useEffect, useRef} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx, {withOnyx} from 'react-native-onyx'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import KeyboardShortcut from '@libs/KeyboardShortcut'; @@ -130,8 +131,9 @@ const modalScreenListeners = { function AuthScreens({lastUpdateIDAppliedToClient, session, lastOpenedPublicRoomID, isUsingMemoryOnlyKeys = false}: AuthScreensProps) { const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); - const screenOptions = getRootNavigatorScreenOptions(isSmallScreenWidth, styles); + const screenOptions = getRootNavigatorScreenOptions(isSmallScreenWidth, styles, StyleUtils); const isInitialRender = useRef(true); if (isInitialRender.current) { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index e177f1c2003d..4be1c988561b 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -283,6 +283,10 @@ const ReferralModalStackNavigator = createModalStackNavigator<ReferralDetailsNav [SCREENS.REFERRAL_DETAILS]: () => require('../../../pages/ReferralDetailsPage').default as React.ComponentType, }); +const ProcessMoneyRequestHoldStackNavigator = createModalStackNavigator({ + [SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: () => require('../../../pages/ProcessMoneyRequestHoldPage').default as React.ComponentType, +}); + export { MoneyRequestModalStackNavigator, SplitDetailsModalStackNavigator, @@ -309,4 +313,5 @@ export { RoomMembersModalStackNavigator, RoomInviteModalStackNavigator, ReferralModalStackNavigator, + ProcessMoneyRequestHoldStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index ca33b32113bb..7721a64adea9 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,6 +1,6 @@ import type {StackScreenProps} from '@react-navigation/stack'; import {createStackNavigator} from '@react-navigation/stack'; -import React, {useMemo} from 'react'; +import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -20,10 +20,21 @@ function RightModalNavigator({navigation}: RightModalNavigatorProps) { const styles = useThemeStyles(); const {isSmallScreenWidth} = useWindowDimensions(); const screenOptions = useMemo(() => ModalNavigatorScreenOptions(styles), [styles]); + const isExecutingRef = useRef<boolean>(false); return ( <NoDropZone> - {!isSmallScreenWidth && <Overlay onPress={navigation.goBack} />} + {!isSmallScreenWidth && ( + <Overlay + onPress={() => { + if (isExecutingRef.current) { + return; + } + isExecutingRef.current = true; + navigation.goBack(); + }} + /> + )} <View style={styles.RHPNavigatorContainer(isSmallScreenWidth)}> <Stack.Navigator screenOptions={screenOptions}> <Stack.Screen @@ -118,6 +129,10 @@ function RightModalNavigator({navigation}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.PRIVATE_NOTES} component={ModalStackNavigators.PrivateNotesModalStackNavigator} /> + <Stack.Screen + name="ProcessMoneyRequestHold" + component={ModalStackNavigators.ProcessMoneyRequestHoldStackNavigator} + /> </Stack.Navigator> </View> </NoDropZone> diff --git a/src/libs/Navigation/AppNavigator/createModalCardStyleInterpolator.ts b/src/libs/Navigation/AppNavigator/createModalCardStyleInterpolator.ts new file mode 100644 index 000000000000..7a88976b3e03 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createModalCardStyleInterpolator.ts @@ -0,0 +1,40 @@ +import type {StackCardInterpolatedStyle, StackCardInterpolationProps} from '@react-navigation/stack'; +import {Animated} from 'react-native'; +import type {StyleUtilsType} from '@styles/utils'; +import variables from '@styles/variables'; + +type ModalCardStyleInterpolator = ( + isSmallScreenWidth: boolean, + isFullScreenModal: boolean, + stackCardInterpolationProps: StackCardInterpolationProps, + outputRangeMultiplier?: number, +) => StackCardInterpolatedStyle; +type CreateModalCardStyleInterpolator = (StyleUtils: StyleUtilsType) => ModalCardStyleInterpolator; + +const createModalCardStyleInterpolator: CreateModalCardStyleInterpolator = + (StyleUtils) => + (isSmallScreenWidth, isFullScreenModal, {current: {progress}, inverted, layouts: {screen}}, outputRangeMultiplier = 1) => { + const translateX = Animated.multiply( + progress.interpolate({ + inputRange: [0, 1], + outputRange: [outputRangeMultiplier * (isSmallScreenWidth ? screen.width : variables.sideBarWidth), 0], + extrapolate: 'clamp', + }), + inverted, + ); + + const cardStyle = StyleUtils.getCardStyles(screen.width); + + if (!isFullScreenModal || isSmallScreenWidth) { + cardStyle.transform = [{translateX}]; + } + + return { + containerStyle: { + overflow: 'hidden', + }, + cardStyle, + }; + }; + +export default createModalCardStyleInterpolator; diff --git a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts index 1c0f06283226..31b8d49e74c0 100644 --- a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts @@ -1,9 +1,9 @@ import type {StackCardInterpolationProps, StackNavigationOptions} from '@react-navigation/stack'; import type {ThemeStyles} from '@styles/index'; -import getNavigationModalCardStyle from '@styles/utils/getNavigationModalCardStyles'; +import type {StyleUtilsType} from '@styles/utils'; import variables from '@styles/variables'; import CONFIG from '@src/CONFIG'; -import modalCardStyleInterpolator from './modalCardStyleInterpolator'; +import createModalCardStyleInterpolator from './createModalCardStyleInterpolator'; type ScreenOptions = Record<string, StackNavigationOptions>; @@ -17,75 +17,83 @@ const commonScreenOptions: StackNavigationOptions = { const SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER = -1; -export default (isSmallScreenWidth: boolean, themeStyles: ThemeStyles): ScreenOptions => ({ - rightModalNavigator: { - ...commonScreenOptions, - cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props), - presentation: 'transparentModal', - - // We want pop in RHP since there are some flows that would work weird otherwise - animationTypeForReplace: 'pop', - cardStyle: { - ...getNavigationModalCardStyle(), - - // This is necessary to cover translated sidebar with overlay. - width: isSmallScreenWidth ? '100%' : '200%', - // Excess space should be on the left so we need to position from right. - right: 0, +type GetRootNavigatorScreenOptions = (isSmallScreenWidth: boolean, styles: ThemeStyles, StyleUtils: StyleUtilsType) => ScreenOptions; + +const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScreenWidth, themeStyles, StyleUtils) => { + const modalCardStyleInterpolator = createModalCardStyleInterpolator(StyleUtils); + + return { + rightModalNavigator: { + ...commonScreenOptions, + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props), + presentation: 'transparentModal', + + // We want pop in RHP since there are some flows that would work weird otherwise + animationTypeForReplace: 'pop', + cardStyle: { + ...StyleUtils.getNavigationModalCardStyle(), + + // This is necessary to cover translated sidebar with overlay. + width: isSmallScreenWidth ? '100%' : '200%', + // Excess space should be on the left so we need to position from right. + right: 0, + }, }, - }, - leftModalNavigator: { - ...commonScreenOptions, - cardStyleInterpolator: (props) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER), - presentation: 'transparentModal', - - // We want pop in LHP since there are some flows that would work weird otherwise - animationTypeForReplace: 'pop', - cardStyle: { - ...getNavigationModalCardStyle(), - - // This is necessary to cover translated sidebar with overlay. - width: isSmallScreenWidth ? '100%' : '200%', - - // LHP should be displayed in place of the sidebar - left: isSmallScreenWidth ? 0 : -variables.sideBarWidth, + leftModalNavigator: { + ...commonScreenOptions, + cardStyleInterpolator: (props) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER), + presentation: 'transparentModal', + + // We want pop in LHP since there are some flows that would work weird otherwise + animationTypeForReplace: 'pop', + cardStyle: { + ...StyleUtils.getNavigationModalCardStyle(), + + // This is necessary to cover translated sidebar with overlay. + width: isSmallScreenWidth ? '100%' : '200%', + + // LHP should be displayed in place of the sidebar + left: isSmallScreenWidth ? 0 : -variables.sideBarWidth, + }, }, - }, - homeScreen: { - title: CONFIG.SITE_TITLE, - ...commonScreenOptions, - cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props), - - cardStyle: { - ...getNavigationModalCardStyle(), - width: isSmallScreenWidth ? '100%' : variables.sideBarWidth, - - // We need to shift the sidebar to not be covered by the StackNavigator so it can be clickable. - marginLeft: isSmallScreenWidth ? 0 : -variables.sideBarWidth, - ...(isSmallScreenWidth ? {} : themeStyles.borderRight), + homeScreen: { + title: CONFIG.SITE_TITLE, + ...commonScreenOptions, + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props), + + cardStyle: { + ...StyleUtils.getNavigationModalCardStyle(), + width: isSmallScreenWidth ? '100%' : variables.sideBarWidth, + + // We need to shift the sidebar to not be covered by the StackNavigator so it can be clickable. + marginLeft: isSmallScreenWidth ? 0 : -variables.sideBarWidth, + ...(isSmallScreenWidth ? {} : themeStyles.borderRight), + }, }, - }, - fullScreen: { - ...commonScreenOptions, - cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props), - cardStyle: { - ...getNavigationModalCardStyle(), + fullScreen: { + ...commonScreenOptions, + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props), + cardStyle: { + ...StyleUtils.getNavigationModalCardStyle(), - // This is necessary to cover whole screen. Including translated sidebar. - marginLeft: isSmallScreenWidth ? 0 : -variables.sideBarWidth, + // This is necessary to cover whole screen. Including translated sidebar. + marginLeft: isSmallScreenWidth ? 0 : -variables.sideBarWidth, + }, }, - }, - centralPaneNavigator: { - title: CONFIG.SITE_TITLE, - ...commonScreenOptions, - animationEnabled: isSmallScreenWidth, - cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props), + centralPaneNavigator: { + title: CONFIG.SITE_TITLE, + ...commonScreenOptions, + animationEnabled: isSmallScreenWidth, + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props), - cardStyle: { - ...getNavigationModalCardStyle(), - paddingRight: isSmallScreenWidth ? 0 : variables.sideBarWidth, + cardStyle: { + ...StyleUtils.getNavigationModalCardStyle(), + paddingRight: isSmallScreenWidth ? 0 : variables.sideBarWidth, + }, }, - }, -}); + }; +}; + +export default getRootNavigatorScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/modalCardStyleInterpolator.ts b/src/libs/Navigation/AppNavigator/modalCardStyleInterpolator.ts deleted file mode 100644 index fd59b02e724d..000000000000 --- a/src/libs/Navigation/AppNavigator/modalCardStyleInterpolator.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type {StackCardInterpolatedStyle, StackCardInterpolationProps} from '@react-navigation/stack'; -import {Animated} from 'react-native'; -import getCardStyles from '@styles/utils/cardStyles'; -import variables from '@styles/variables'; - -export default ( - isSmallScreenWidth: boolean, - isFullScreenModal: boolean, - {current: {progress}, inverted, layouts: {screen}}: StackCardInterpolationProps, - outputRangeMultiplier = 1, -): StackCardInterpolatedStyle => { - const translateX = Animated.multiply( - progress.interpolate({ - inputRange: [0, 1], - outputRange: [outputRangeMultiplier * (isSmallScreenWidth ? screen.width : variables.sideBarWidth), 0], - extrapolate: 'clamp', - }), - inverted, - ); - - const cardStyle = getCardStyles(screen.width); - - if (!isFullScreenModal || isSmallScreenWidth) { - cardStyle.transform = [{translateX}]; - } - - return { - containerStyle: { - overflow: 'hidden', - }, - cardStyle, - }; -}; diff --git a/src/libs/Navigation/linkingConfig.ts b/src/libs/Navigation/linkingConfig.ts index 3b2350afcc43..5f2a607b5f78 100644 --- a/src/libs/Navigation/linkingConfig.ts +++ b/src/libs/Navigation/linkingConfig.ts @@ -492,6 +492,11 @@ const linkingConfig: LinkingOptions<RootStackParamList> = { [SCREENS.REFERRAL_DETAILS]: ROUTES.REFERRAL_DETAILS_MODAL.route, }, }, + ProcessMoneyRequestHold: { + screens: { + ProcessMoneyRequestHold_Root: ROUTES.PROCESS_MONEY_REQUEST_HOLD, + }, + }, }, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 505bc82180f4..90f5361f11f4 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -331,6 +331,10 @@ type ReferralDetailsNavigatorParamList = { [SCREENS.REFERRAL_DETAILS]: undefined; }; +type ProcessMoneyRequestHoldNavigatorParamList = { + [SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: undefined; +}; + type PrivateNotesNavigatorParamList = { [SCREENS.PRIVATE_NOTES.VIEW]: { reportID: string; @@ -372,6 +376,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.FLAG_COMMENT]: NavigatorScreenParams<FlagCommentNavigatorParamList>; [SCREENS.RIGHT_MODAL.EDIT_REQUEST]: NavigatorScreenParams<EditRequestNavigatorParamList>; [SCREENS.RIGHT_MODAL.SIGN_IN]: NavigatorScreenParams<SignInNavigatorParamList>; + [SCREENS.RIGHT_MODAL.PROCESS_MONEY_REQUEST_HOLD]: NavigatorScreenParams<ProcessMoneyRequestHoldNavigatorParamList>; [SCREENS.RIGHT_MODAL.REFERRAL]: NavigatorScreenParams<ReferralDetailsNavigatorParamList>; [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: NavigatorScreenParams<PrivateNotesNavigatorParamList>; }; diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 9efff6f2fdb7..0dc12c720f31 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -322,9 +322,9 @@ function getSearchText(report, reportName, personalDetailList, isChatRoomOrPolic Array.prototype.push.apply(searchTerms, chatRoomSubtitle.split(/[,\s]/)); } else { - const participantAccountIDs = report.participantAccountIDs || []; - for (let i = 0; i < participantAccountIDs.length; i++) { - const accountID = participantAccountIDs[i]; + const visibleChatMemberAccountIDs = report.visibleChatMemberAccountIDs || []; + for (let i = 0; i < visibleChatMemberAccountIDs.length; i++) { + const accountID = visibleChatMemberAccountIDs[i]; if (allPersonalDetails[accountID] && allPersonalDetails[accountID].login) { searchTerms = searchTerms.concat(allPersonalDetails[accountID].login); @@ -506,7 +506,7 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { result.isPinned = report.isPinned; result.iouReportID = report.iouReportID; result.keyForList = String(report.reportID); - result.tooltipText = ReportUtils.getReportParticipantsTitle(report.participantAccountIDs || []); + result.tooltipText = ReportUtils.getReportParticipantsTitle(report.visibleChatMemberAccountIDs || []); result.isWaitingOnBankAccount = report.isWaitingOnBankAccount; result.policyID = report.policyID; @@ -573,7 +573,7 @@ function getPolicyExpenseReportOption(report) { const expenseReport = policyExpenseReports[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]; const option = createOption( - expenseReport.participantAccountIDs, + expenseReport.visibleChatMemberAccountIDs, allPersonalDetails, expenseReport, {}, @@ -1342,7 +1342,7 @@ function getOptions( const isTaskReport = ReportUtils.isTaskReport(report); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); - const accountIDs = report.participantAccountIDs || []; + const accountIDs = report.visibleChatMemberAccountIDs || []; if (isPolicyExpenseChat && report.isOwnPolicyExpenseChat && !includeOwnedWorkspaceChats) { return; diff --git a/src/libs/PopoverWithMeasuredContentUtils.ts b/src/libs/PopoverWithMeasuredContentUtils.ts index b932249211be..5d5af6f7c83d 100644 --- a/src/libs/PopoverWithMeasuredContentUtils.ts +++ b/src/libs/PopoverWithMeasuredContentUtils.ts @@ -1,5 +1,5 @@ -import roundToNearestMultipleOfFour from '@styles/utils/roundToNearestMultipleOfFour'; import variables from '@styles/variables'; +import roundToNearestMultipleOfFour from './roundToNearestMultipleOfFour'; /** * Compute the amount that the Context menu's Anchor needs to be horizontally shifted diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index 734c40271208..bcba68a3a0bd 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -66,7 +66,7 @@ function getThumbnailAndImageURIs(transaction: Transaction, receiptPath: string image = ReceiptSVG; } - const isLocalFile = path.startsWith('blob:') || path.startsWith('file:'); + const isLocalFile = typeof path === 'number' || path.startsWith('blob:') || path.startsWith('file:') || path.startsWith('/'); return {thumbnail: image, image: path, isLocalFile}; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 33a18b8534df..0e159cf69095 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -211,6 +211,7 @@ type OptimisticChatReport = Pick< | 'parentReportActionID' | 'parentReportID' | 'participantAccountIDs' + | 'visibleChatMemberAccountIDs' | 'policyID' | 'reportID' | 'reportName' @@ -265,6 +266,7 @@ type OptimisticTaskReport = Pick< | 'description' | 'ownerAccountID' | 'participantAccountIDs' + | 'visibleChatMemberAccountIDs' | 'managerID' | 'type' | 'parentReportID' @@ -302,6 +304,7 @@ type OptimisticIOUReport = Pick< | 'managerID' | 'ownerAccountID' | 'participantAccountIDs' + | 'visibleChatMemberAccountIDs' | 'reportID' | 'state' | 'stateNum' @@ -543,7 +546,8 @@ function isExpenseReport(report: OnyxEntry<Report> | EmptyObject): boolean { /** * Checks if a report is an IOU report. */ -function isIOUReport(report: OnyxEntry<Report>): boolean { +function isIOUReport(reportOrID: OnyxEntry<Report> | string | EmptyObject): boolean { + const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; return report?.type === CONST.REPORT.TYPE.IOU; } @@ -599,14 +603,15 @@ function isReportManager(report: OnyxEntry<Report>): boolean { /** * Checks if the supplied report has been approved */ -function isReportApproved(report: OnyxEntry<Report> | EmptyObject): boolean { +function isReportApproved(reportOrID: OnyxEntry<Report> | string | EmptyObject): boolean { + const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; return report?.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && report?.statusNum === CONST.REPORT.STATUS.APPROVED; } /** * Checks if the supplied report is an expense report in Open state and status. */ -function isDraftExpenseReport(report: OnyxEntry<Report>): boolean { +function isDraftExpenseReport(report: OnyxEntry<Report> | EmptyObject): boolean { return isExpenseReport(report) && report?.stateNum === CONST.REPORT.STATE_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS.OPEN; } @@ -713,9 +718,17 @@ function isControlPolicyExpenseChat(report: OnyxEntry<Report>): boolean { } /** - * Whether the provided report belongs to a Control or Collect policy + * Whether the provided report belongs to a Free, Collect or Control policy */ function isGroupPolicy(report: OnyxEntry<Report>): boolean { + const policyType = getPolicyType(report, allPolicies); + return policyType === CONST.POLICY.TYPE.CORPORATE || policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.FREE; +} + +/** + * Whether the provided report belongs to a Control or Collect policy + */ +function isPaidGroupPolicy(report: OnyxEntry<Report>): boolean { const policyType = getPolicyType(report, allPolicies); return policyType === CONST.POLICY.TYPE.CORPORATE || policyType === CONST.POLICY.TYPE.TEAM; } @@ -723,8 +736,8 @@ function isGroupPolicy(report: OnyxEntry<Report>): boolean { /** * Whether the provided report belongs to a Control or Collect policy and is an expense chat */ -function isGroupPolicyExpenseChat(report: OnyxEntry<Report>): boolean { - return isPolicyExpenseChat(report) && isGroupPolicy(report); +function isPaidGroupPolicyExpenseChat(report: OnyxEntry<Report>): boolean { + return isPolicyExpenseChat(report) && isPaidGroupPolicy(report); } /** @@ -737,8 +750,8 @@ function isControlPolicyExpenseReport(report: OnyxEntry<Report>): boolean { /** * Whether the provided report belongs to a Control or Collect policy and is an expense report */ -function isGroupPolicyExpenseReport(report: OnyxEntry<Report>): boolean { - return isExpenseReport(report) && isGroupPolicy(report); +function isPaidGroupPolicyExpenseReport(report: OnyxEntry<Report>): boolean { + return isExpenseReport(report) && isPaidGroupPolicy(report); } /** @@ -816,7 +829,7 @@ function isConciergeChatReport(report: OnyxEntry<Report>): boolean { /** * Returns true if report is still being processed */ -function isProcessingReport(report: OnyxEntry<Report>): boolean { +function isProcessingReport(report: OnyxEntry<Report> | EmptyObject): boolean { return report?.stateNum === CONST.REPORT.STATE_NUM.PROCESSING && report?.statusNum === CONST.REPORT.STATUS.SUBMITTED; } @@ -1847,9 +1860,13 @@ function getTransactionDetails(transaction: OnyxEntry<Transaction>, createdDateF * - the current user is the requestor and is not settled yet * - in case of expense report * - the current user is the requestor and is not settled yet - * - or the user is an admin on the policy the expense report is tied to + * - the current user is the manager of the report + * - or the current user is an admin on the policy the expense report is tied to + * + * This is used in conjunction with canEditRestrictedField to control editing of specific fields like amount, currency, created, receipt, and distance. + * On its own, it only controls allowing/disallowing navigating to the editing pages or showing/hiding the 'Edit' icon on report actions */ -function canEditMoneyRequest(reportAction: OnyxEntry<ReportAction>, fieldToEdit = ''): boolean { +function canEditMoneyRequest(reportAction: OnyxEntry<ReportAction>): boolean { const isDeleted = ReportActionsUtils.isDeletedAction(reportAction); if (isDeleted) { @@ -1872,52 +1889,76 @@ function canEditMoneyRequest(reportAction: OnyxEntry<ReportAction>, fieldToEdit } const moneyRequestReport = getReport(String(moneyRequestReportID)); - const isReportSettled = isSettled(moneyRequestReport?.reportID); - const isApproved = isReportApproved(moneyRequestReport); - const isAdmin = isExpenseReport(moneyRequestReport) && (getPolicy(moneyRequestReport?.policyID ?? '')?.role ?? '') === CONST.POLICY.ROLE.ADMIN; const isRequestor = currentUserAccountID === reportAction?.actorAccountID; - if (isAdmin && !isRequestor && fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) { - return false; + if (isIOUReport(moneyRequestReport)) { + return isProcessingReport(moneyRequestReport) && isRequestor; } - if (isAdmin) { + const policy = getPolicy(moneyRequestReport?.policyID ?? ''); + const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; + const isManager = currentUserAccountID === moneyRequestReport?.managerID; + + // Admin & managers can always edit coding fields such as tag, category, billable, etc. As long as the report has a state higher than OPEN. + if ((isAdmin || isManager) && !isDraftExpenseReport(moneyRequestReport)) { return true; } - return !isApproved && !isReportSettled && isRequestor; + return !isReportApproved(moneyRequestReport) && !isSettled(moneyRequestReport?.reportID) && isRequestor; } /** * Checks if the current user can edit the provided property of a money request * */ -function canEditFieldOfMoneyRequest( - reportAction: OnyxEntry<ReportAction>, - reportID: string, - fieldToEdit: ValueOf<typeof CONST.EDIT_REQUEST_FIELD>, - transaction: OnyxEntry<Transaction>, -): boolean { +function canEditFieldOfMoneyRequest(reportAction: OnyxEntry<ReportAction>, fieldToEdit: ValueOf<typeof CONST.EDIT_REQUEST_FIELD>): boolean { // A list of fields that cannot be edited by anyone, once a money request has been settled - const nonEditableFieldsWhenSettled: string[] = [ + const restrictedFields: string[] = [ CONST.EDIT_REQUEST_FIELD.AMOUNT, CONST.EDIT_REQUEST_FIELD.CURRENCY, + CONST.EDIT_REQUEST_FIELD.MERCHANT, CONST.EDIT_REQUEST_FIELD.DATE, CONST.EDIT_REQUEST_FIELD.RECEIPT, CONST.EDIT_REQUEST_FIELD.DISTANCE, ]; - // Checks if this user has permissions to edit this money request - if (!canEditMoneyRequest(reportAction, fieldToEdit)) { - return false; // User doesn't have permission to edit + if (!canEditMoneyRequest(reportAction)) { + return false; + } + + // If we're editing fields such as category, tag, description, etc. the check above should be enough for handling the permission + if (!restrictedFields.includes(fieldToEdit)) { + return true; } - if (!isEmpty(transaction) && fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT && TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction)) { + + const iouMessage = reportAction?.originalMessage as IOUMessage; + const moneyRequestReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouMessage?.IOUReportID}`] ?? ({} as Report); + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${iouMessage?.IOUTransactionID}`] ?? ({} as Transaction); + + if (isSettled(String(moneyRequestReport.reportID)) || isReportApproved(String(moneyRequestReport.reportID))) { return false; } - // Checks if the report is settled - // Checks if the provided property is a restricted one - return !isSettled(reportID) || !nonEditableFieldsWhenSettled.includes(fieldToEdit); + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT || fieldToEdit === CONST.EDIT_REQUEST_FIELD.CURRENCY) { + if (TransactionUtils.isCardTransaction(transaction)) { + return false; + } + + if (TransactionUtils.isDistanceRequest(transaction)) { + const policy = getPolicy(moneyRequestReport?.reportID ?? ''); + const isAdmin = isExpenseReport(moneyRequestReport) && policy.role === CONST.POLICY.ROLE.ADMIN; + const isManager = isExpenseReport(moneyRequestReport) && currentUserAccountID === moneyRequestReport?.managerID; + + return isAdmin || isManager; + } + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) { + const isRequestor = currentUserAccountID === reportAction?.actorAccountID; + return !TransactionUtils.isReceiptBeingScanned(transaction) && !TransactionUtils.isDistanceRequest(transaction) && isRequestor; + } + + return true; } /** @@ -2057,7 +2098,7 @@ function getReportPreviewMessage( const formattedAmount = CurrencyUtils.convertToDisplayString(totalAmount, report.currency); - if (isReportApproved(report) && isGroupPolicy(report)) { + if (isReportApproved(report) && isPaidGroupPolicy(report)) { return Localize.translateLocal('iou.managerApprovedAmount', { manager: payerName ?? '', amount: formattedAmount, @@ -2505,6 +2546,10 @@ function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number const formattedTotal = CurrencyUtils.convertToDisplayString(total, currency); const personalDetails = getPersonalDetailsForAccountID(payerAccountID); const payerEmail = 'login' in personalDetails ? personalDetails.login : ''; + + // When creating a report the participantsAccountIDs and visibleChatMemberAccountIDs are the same + const participantsAccountIDs = [payeeAccountID, payerAccountID]; + return { type: CONST.REPORT.TYPE.IOU, cachedTotal: formattedTotal, @@ -2512,7 +2557,8 @@ function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number currency, managerID: payerAccountID, ownerAccountID: payeeAccountID, - participantAccountIDs: [payeeAccountID, payerAccountID], + participantAccountIDs: participantsAccountIDs, + visibleChatMemberAccountIDs: participantsAccountIDs, reportID: generateReportID(), state: CONST.REPORT.STATE.SUBMITTED, stateNum: isSendingMoney ? CONST.REPORT.STATE_NUM.SUBMITTED : CONST.REPORT.STATE_NUM.PROCESSING, @@ -3048,7 +3094,9 @@ function buildOptimisticChatReport( ownerAccountID: ownerAccountID || CONST.REPORT.OWNER_ACCOUNT_ID_FAKE, parentReportActionID, parentReportID, + // When creating a report the participantsAccountIDs and visibleChatMemberAccountIDs are the same participantAccountIDs: participantList, + visibleChatMemberAccountIDs: participantList, policyID, reportID: generateReportID(), reportName, @@ -3250,12 +3298,16 @@ function buildOptimisticTaskReport( description?: string, policyID: string = CONST.POLICY.OWNER_EMAIL_FAKE, ): OptimisticTaskReport { + // When creating a report the participantsAccountIDs and visibleChatMemberAccountIDs are the same + const participantsAccountIDs = assigneeAccountID && assigneeAccountID !== ownerAccountID ? [assigneeAccountID] : []; + return { reportID: generateReportID(), reportName: title, description, ownerAccountID, - participantAccountIDs: assigneeAccountID && assigneeAccountID !== ownerAccountID ? [assigneeAccountID] : [], + participantAccountIDs: participantsAccountIDs, + visibleChatMemberAccountIDs: participantsAccountIDs, managerID: assigneeAccountID, type: CONST.REPORT.TYPE.TASK, parentReportID, @@ -4082,6 +4134,8 @@ function getTaskAssigneeChatOnyxData( /** * Returns an array of the participants Ids of a report + * + * @deprecated Use getVisibleMemberIDs instead */ function getParticipantsIDs(report: OnyxEntry<Report>): number[] { if (!report) { @@ -4099,6 +4153,25 @@ function getParticipantsIDs(report: OnyxEntry<Report>): number[] { return participants; } +/** + * Returns an array of the visible member accountIDs for a report* + */ +function getVisibleMemberIDs(report: OnyxEntry<Report>): number[] { + if (!report) { + return []; + } + + const visibleChatMemberAccountIDs = report.visibleChatMemberAccountIDs ?? []; + + // Build participants list for IOU/expense reports + if (isMoneyRequestReport(report)) { + const onlyTruthyValues = [report.managerID, report.ownerAccountID, ...visibleChatMemberAccountIDs].filter(Boolean) as number[]; + const onlyUnique = [...new Set([...onlyTruthyValues])]; + return onlyUnique; + } + return visibleChatMemberAccountIDs; +} + /** * Return iou report action display message */ @@ -4309,10 +4382,12 @@ export { formatReportLastMessageText, chatIncludesConcierge, isPolicyExpenseChat, + isGroupPolicy, + isPaidGroupPolicy, isControlPolicyExpenseChat, isControlPolicyExpenseReport, - isGroupPolicyExpenseChat, - isGroupPolicyExpenseReport, + isPaidGroupPolicyExpenseChat, + isPaidGroupPolicyExpenseReport, getIconsForParticipants, getIcons, getRoomWelcomeMessage, @@ -4417,6 +4492,7 @@ export { getTransactionDetails, getTaskAssigneeChatOnyxData, getParticipantsIDs, + getVisibleMemberIDs, canEditMoneyRequest, canEditFieldOfMoneyRequest, buildTransactionThread, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index db16cf5cb552..6e46ec320066 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -127,10 +127,10 @@ function getOrderedReportIDs( [currentReportId, allReports, betas, policies, priorityMode, allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentReportId}`]?.length || 1], (key, value: unknown) => { /** - * Exclude 'participantAccountIDs', 'participants' and 'lastMessageText' not to overwhelm a cached key value with huge data, + * Exclude some properties not to overwhelm a cached key value with huge data, * which we don't need to store in a cacheKey */ - if (key === 'participantAccountIDs' || key === 'participants' || key === 'lastMessageText') { + if (key === 'participantAccountIDs' || key === 'participants' || key === 'lastMessageText' || key === 'visibleChatMemberAccountIDs') { return undefined; } @@ -276,6 +276,7 @@ function getOptionData( isExpenseRequest: false, isWaitingOnBankAccount: false, isAllowedToComment: true, + isDeletedParentAction: false, }; const participantPersonalDetailList: PersonalDetails[] = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(report.participantAccountIDs ?? [], personalDetails)); const personalDetail = participantPersonalDetailList[0] ?? {}; @@ -304,13 +305,14 @@ function getOptionData( result.isPinned = report.isPinned; result.iouReportID = report.iouReportID; result.keyForList = String(report.reportID); - result.tooltipText = ReportUtils.getReportParticipantsTitle(report.participantAccountIDs ?? []); + result.tooltipText = ReportUtils.getReportParticipantsTitle(report.visibleChatMemberAccountIDs ?? []); result.hasOutstandingChildRequest = report.hasOutstandingChildRequest; result.parentReportID = report.parentReportID ?? ''; result.isWaitingOnBankAccount = report.isWaitingOnBankAccount; result.notificationPreference = report.notificationPreference; result.isAllowedToComment = ReportUtils.canUserPerformWriteAction(report); result.chatType = report.chatType; + result.isDeletedParentAction = report.isDeletedParentAction; const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat || ReportUtils.isExpenseReport(report); const subtitle = ReportUtils.getChatRoomSubtitle(report); diff --git a/src/libs/ViolationsUtils.ts b/src/libs/ViolationsUtils.ts index 748f0ed86b7f..2637686e726b 100644 --- a/src/libs/ViolationsUtils.ts +++ b/src/libs/ViolationsUtils.ts @@ -1,7 +1,9 @@ import reject from 'lodash/reject'; import Onyx from 'react-native-onyx'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PolicyCategories, PolicyTags, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {Phrase, PhraseParameters} from './Localize'; const ViolationsUtils = { /** @@ -80,6 +82,104 @@ const ViolationsUtils = { value: newTransactionViolations, }; }, + /** + * Gets the translated message for each violation type. + * + * Necessary because `translate` throws a type error if you attempt to pass it a template strings, when the + * possible values could be either translation keys that resolve to strings or translation keys that resolve to + * functions. + */ + getViolationTranslation( + violation: TransactionViolation, + translate: <TKey extends TranslationPaths>(phraseKey: TKey, ...phraseParameters: PhraseParameters<Phrase<TKey>>) => string, + ): string { + switch (violation.name) { + case 'allTagLevelsRequired': + return translate('violations.allTagLevelsRequired'); + case 'autoReportedRejectedExpense': + return translate('violations.autoReportedRejectedExpense', { + rejectedBy: violation.data?.rejectedBy ?? '', + rejectReason: violation.data?.rejectReason ?? '', + }); + case 'billableExpense': + return translate('violations.billableExpense'); + case 'cashExpenseWithNoReceipt': + return translate('violations.cashExpenseWithNoReceipt', {amount: violation.data?.amount ?? ''}); + case 'categoryOutOfPolicy': + return translate('violations.categoryOutOfPolicy'); + case 'conversionSurcharge': + return translate('violations.conversionSurcharge', {surcharge: violation.data?.surcharge}); + case 'customUnitOutOfPolicy': + return translate('violations.customUnitOutOfPolicy'); + case 'duplicatedTransaction': + return translate('violations.duplicatedTransaction'); + case 'fieldRequired': + return translate('violations.fieldRequired'); + case 'futureDate': + return translate('violations.futureDate'); + case 'invoiceMarkup': + return translate('violations.invoiceMarkup', {invoiceMarkup: violation.data?.invoiceMarkup}); + case 'maxAge': + return translate('violations.maxAge', {maxAge: violation.data?.maxAge ?? 0}); + case 'missingCategory': + return translate('violations.missingCategory'); + case 'missingComment': + return translate('violations.missingComment'); + case 'missingTag': + return translate('violations.missingTag', {tagName: violation.data?.tagName}); + case 'modifiedAmount': + return translate('violations.modifiedAmount'); + case 'modifiedDate': + return translate('violations.modifiedDate'); + case 'nonExpensiworksExpense': + return translate('violations.nonExpensiworksExpense'); + case 'overAutoApprovalLimit': + return translate('violations.overAutoApprovalLimit', {formattedLimitAmount: violation.data?.formattedLimitAmount ?? ''}); + case 'overCategoryLimit': + return translate('violations.overCategoryLimit', {categoryLimit: violation.data?.categoryLimit ?? ''}); + case 'overLimit': + return translate('violations.overLimit', {amount: violation.data?.amount ?? ''}); + case 'overLimitAttendee': + return translate('violations.overLimitAttendee', {amount: violation.data?.amount ?? ''}); + case 'perDayLimit': + return translate('violations.perDayLimit', {limit: violation.data?.limit ?? ''}); + case 'receiptNotSmartScanned': + return translate('violations.receiptNotSmartScanned'); + case 'receiptRequired': + return translate('violations.receiptRequired', { + amount: violation.data?.amount ?? '0', + category: violation.data?.category ?? '', + }); + case 'rter': + return translate('violations.rter', { + brokenBankConnection: violation.data?.brokenBankConnection ?? false, + isAdmin: violation.data?.isAdmin ?? false, + email: violation.data?.email, + isTransactionOlderThan7Days: Boolean(violation.data?.isTransactionOlderThan7Days), + member: violation.data?.member, + }); + case 'smartscanFailed': + return translate('violations.smartscanFailed'); + case 'someTagLevelsRequired': + return translate('violations.someTagLevelsRequired'); + case 'tagOutOfPolicy': + return translate('violations.tagOutOfPolicy', {tagName: violation.data?.tagName}); + case 'taxAmountChanged': + return translate('violations.taxAmountChanged'); + case 'taxOutOfPolicy': + return translate('violations.taxOutOfPolicy', {taxName: violation.data?.taxName}); + case 'taxRateChanged': + return translate('violations.taxRateChanged'); + case 'taxRequired': + return translate('violations.taxRequired'); + default: + // The interpreter should never get here because the switch cases should be exhaustive. + // If typescript is showing an error on the assertion below it means the switch statement is out of + // sync with the `ViolationNames` type, and one or the other needs to be updated. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return violation.name as never; + } + }, }; export default ViolationsUtils; diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index f88bfe09e516..798d94bfb0e0 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -7,6 +7,7 @@ import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import * as API from '@libs/API'; import * as Browser from '@libs/Browser'; +import DateUtils from '@libs/DateUtils'; import Log from '@libs/Log'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation from '@libs/Navigation/Navigation'; @@ -448,6 +449,8 @@ function openProfile(personalDetails: OnyxTypes.PersonalDetails) { }; } + newTimezoneData = DateUtils.formatToSupportedTimezone(newTimezoneData); + type OpenProfileParams = { timezone: string; }; diff --git a/src/libs/actions/EmojiPickerAction.ts b/src/libs/actions/EmojiPickerAction.ts index 064c52f9b7ef..56a5f34c0b8e 100644 --- a/src/libs/actions/EmojiPickerAction.ts +++ b/src/libs/actions/EmojiPickerAction.ts @@ -1,6 +1,9 @@ import React from 'react'; -import type {View} from 'react-native'; +import type {MutableRefObject} from 'react'; +import type {TextInput, View} from 'react-native'; import type {ValueOf} from 'type-fest'; +import type {Emoji} from '@assets/emojis/types'; +import type {CloseContextMenuCallback} from '@components/Reactions/QuickEmojiReactions/types'; import type CONST from '@src/CONST'; type AnchorOrigin = { @@ -8,23 +11,31 @@ type AnchorOrigin = { vertical: ValueOf<typeof CONST.MODAL.ANCHOR_ORIGIN_VERTICAL>; }; +type EmojiPopoverAnchor = MutableRefObject<View | HTMLDivElement | TextInput | null>; + +type OnWillShowPicker = (callback: CloseContextMenuCallback) => void; + +type OnModalHideValue = () => void; + // TODO: Move this type to src/components/EmojiPicker/EmojiPicker.js once it is converted to TS type EmojiPickerRef = { showEmojiPicker: ( - onModalHideValue?: () => void, - onEmojiSelectedValue?: () => void, - emojiPopoverAnchor?: React.MutableRefObject<View | HTMLElement | null>, + onModalHideValue: OnModalHideValue, + onEmojiSelectedValue: OnEmojiSelected, + emojiPopoverAnchor: EmojiPopoverAnchor, anchorOrigin?: AnchorOrigin, - onWillShow?: () => void, + onWillShow?: OnWillShowPicker, id?: string, ) => void; isActive: (id: string) => boolean; clearActive: () => void; - hideEmojiPicker: (isNavigating: boolean) => void; + hideEmojiPicker: (isNavigating?: boolean) => void; isEmojiPickerVisible: boolean; resetEmojiPopoverAnchor: () => void; }; +type OnEmojiSelected = (emojiCode: string, emojiObject: Emoji) => void; + const emojiPickerRef = React.createRef<EmojiPickerRef>(); /** @@ -37,7 +48,14 @@ const emojiPickerRef = React.createRef<EmojiPickerRef>(); * @param onWillShow - Run a callback when Popover will show * @param id - Unique id for EmojiPicker */ -function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor = undefined, anchorOrigin = undefined, onWillShow = () => {}, id = undefined) { +function showEmojiPicker( + onModalHide: OnModalHideValue, + onEmojiSelected: OnEmojiSelected, + emojiPopoverAnchor: EmojiPopoverAnchor, + anchorOrigin?: AnchorOrigin, + onWillShow: OnWillShowPicker = () => {}, + id?: string, +) { if (!emojiPickerRef.current) { return; } @@ -92,3 +110,4 @@ function resetEmojiPopoverAnchor() { } export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActive, clearActive, isEmojiPickerVisible, resetEmojiPopoverAnchor}; +export type {AnchorOrigin}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index b999435cc3e7..fb4e9f02f1b6 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -22,6 +22,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as UserUtils from '@libs/UserUtils'; +import ViolationsUtils from '@libs/ViolationsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -306,6 +307,27 @@ function getReceiptError(receipt, filename, isScanRequest = true) { : ErrorUtils.getMicroSecondOnyxErrorObject({error: CONST.IOU.RECEIPT_ERROR, source: receipt.source, filename}); } +/** + * Builds the Onyx data for a money request. + * + * @param {Object} chatReport + * @param {Object} iouReport + * @param {Object} transaction + * @param {Object} chatCreatedAction + * @param {Object} iouCreatedAction + * @param {Object} iouAction + * @param {Object} optimisticPersonalDetailListAction + * @param {Object} reportPreviewAction + * @param {Array} optimisticPolicyRecentlyUsedCategories + * @param {Array} optimisticPolicyRecentlyUsedTags + * @param {boolean} isNewChatReport + * @param {boolean} isNewIOUReport + * @param {Object} policy - May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) + * @param {Array} policyTags + * @param {Array} policyCategories + * @param {Boolean} hasOutstandingChildRequest + * @returns {Array} - An array containing the optimistic data, success data, and failure data. + */ function buildOnyxDataForMoneyRequest( chatReport, iouReport, @@ -319,6 +341,9 @@ function buildOnyxDataForMoneyRequest( optimisticPolicyRecentlyUsedTags, isNewChatReport, isNewIOUReport, + policy, + policyTags, + policyCategories, hasOutstandingChildRequest = false, ) { const isScanRequest = TransactionUtils.isScanRequest(transaction); @@ -556,6 +581,22 @@ function buildOnyxDataForMoneyRequest( }, ]; + // Policy won't be set for P2P cases for which we don't need to compute violations + if (!policy || !policy.id) { + return [optimisticData, successData, failureData]; + } + + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], policy.requiresTag, policyTags, policy.requiresCategory, policyCategories); + + if (violationsOnyxData) { + optimisticData.push(violationsOnyxData); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`, + value: [], + }); + } + return [optimisticData, successData, failureData]; } @@ -577,6 +618,9 @@ function buildOnyxDataForMoneyRequest( * @param {String} [category] * @param {String} [tag] * @param {Boolean} [billable] + * @param {Object} [policy] + * @param {Object} [policyTags] + * @param {Object} [policyCategories] * @returns {Object} data * @returns {String} data.payerEmail * @returns {Object} data.iouReport @@ -606,6 +650,9 @@ function getMoneyRequestInformation( category = undefined, tag = undefined, billable = undefined, + policy = undefined, + policyTags = undefined, + policyCategories = undefined, ) { const payerEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login); const payerAccountID = Number(participant.accountID); @@ -639,7 +686,6 @@ function getMoneyRequestInformation( let needsToBeManuallySubmitted = false; let isFromPaidPolicy = false; if (isPolicyExpenseChat) { - const policy = ReportUtils.getPolicy(chatReport.policyID); isFromPaidPolicy = PolicyUtils.isPaidGroupPolicy(policy); // If the scheduled submit is turned off on the policy, user needs to manually submit the report which is indicated by GBR in LHN @@ -774,6 +820,9 @@ function getMoneyRequestInformation( optimisticPolicyRecentlyUsedTags, isNewChatReport, isNewIOUReport, + policy, + policyTags, + policyCategories, hasOutstandingChildRequest, ); @@ -808,9 +857,12 @@ function getMoneyRequestInformation( * @param {String} currency * @param {String} merchant * @param {Boolean} [billable] - * @param {Obejct} validWaypoints + * @param {Object} validWaypoints + * @param {Object} policy - May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) + * @param {Array} policyTags + * @param {Array} policyCategories */ -function createDistanceRequest(report, participant, comment, created, category, tag, amount, currency, merchant, billable, validWaypoints) { +function createDistanceRequest(report, participant, comment, created, category, tag, amount, currency, merchant, billable, validWaypoints, policy, policyTags, policyCategories) { // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report.chatReportID) : report; @@ -834,6 +886,9 @@ function createDistanceRequest(report, participant, comment, created, category, category, tag, billable, + policy, + policyTags, + policyCategories, ); API.write( 'CreateDistanceRequest', @@ -926,7 +981,10 @@ function getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, t onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`, value: { - [updatedReportAction.reportActionID]: updatedReportAction, + [updatedReportAction.reportActionID]: { + ...updatedReportAction, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericEditFailureMessage'), + }, }, }); @@ -1051,6 +1109,21 @@ function updateMoneyRequestDate(transactionID, transactionThreadReportID, val) { API.write('UpdateMoneyRequestDate', params, onyxData); } +/** + * Updates the created date of a money request + * + * @param {String} transactionID + * @param {Number} transactionThreadReportID + * @param {String} tag + */ +function updateMoneyRequestTag(transactionID, transactionThreadReportID, tag) { + const transactionChanges = { + tag, + }; + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, true); + API.write('UpdateMoneyRequestTag', params, onyxData); +} + /** * Edits an existing distance request * @@ -1086,6 +1159,9 @@ function updateDistanceRequest(transactionID, transactionThreadReportID, transac * @param {String} [taxCode] * @param {Number} [taxAmount] * @param {Boolean} [billable] + * @param {Object} [policy] + * @param {Object} [policyTags] + * @param {Object} [policyCategories] */ function requestMoney( report, @@ -1103,12 +1179,33 @@ function requestMoney( taxCode = '', taxAmount = 0, billable = undefined, + policy = undefined, + policyTags = undefined, + policyCategories = undefined, ) { // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report.chatReportID) : report; const {payerAccountID, payerEmail, iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = - getMoneyRequestInformation(currentChatReport, participant, comment, amount, currency, created, merchant, payeeAccountID, payeeEmail, receipt, undefined, category, tag, billable); + getMoneyRequestInformation( + currentChatReport, + participant, + comment, + amount, + currency, + created, + merchant, + payeeAccountID, + payeeEmail, + receipt, + undefined, + category, + tag, + billable, + policy, + policyTags, + policyCategories, + ); const activeReportID = isMoneyRequestReport ? report.reportID : chatReport.reportID; API.write( @@ -2340,7 +2437,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView // STEP 2: Decide if we need to: // 1. Delete the transactionThread - delete if there are no visible comments in the thread // 2. Update the moneyRequestPreview to show [Deleted request] - update if the transactionThread exists AND it isn't being deleted - const shouldDeleteTransactionThread = transactionThreadID ? ReportActionsUtils.getLastVisibleMessage(transactionThreadID).lastMessageText.length === 0 : false; + const shouldDeleteTransactionThread = transactionThreadID ? lodashGet(reportAction, 'childVisibleActionCount', 0) === 0 : false; const shouldShowDeletedRequestMessage = transactionThreadID && !shouldDeleteTransactionThread; // STEP 3: Update the IOU reportAction and decide if the iouReport should be deleted. We delete the iouReport if there are no visible comments left in the report. @@ -3490,6 +3587,7 @@ export { setUpDistanceTransaction, navigateToNextPage, updateMoneyRequestDate, + updateMoneyRequestTag, updateMoneyRequestAmountAndCurrency, replaceReceipt, detachReceipt, diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index 2291e6d0af4a..a0772db49585 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -7,6 +7,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxUpdateEvent, OnyxUpdatesFromServer, Request} from '@src/types/onyx'; import type Response from '@src/types/onyx/Response'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as QueuedOnyxUpdates from './QueuedOnyxUpdates'; // This key needs to be separate from ONYXKEYS.ONYX_UPDATES_FROM_SERVER so that it can be updated without triggering the callback when the server IDs are updated. If that @@ -74,7 +75,18 @@ function apply({lastUpdateID, type, request, response, updates}: OnyxUpdatesFrom Log.info(`[OnyxUpdateManager] Applying update type: ${type} with lastUpdateID: ${lastUpdateID}`, false, {command: request?.command}); if (lastUpdateID && lastUpdateIDAppliedToClient && Number(lastUpdateID) <= lastUpdateIDAppliedToClient) { - Log.info('[OnyxUpdateManager] Update received was older or the same than current state, returning without applying the updates', false); + Log.info('[OnyxUpdateManager] Update received was older than or the same as current state, returning without applying the updates other than successData and failureData'); + + // In this case, we're already received the OnyxUpdate included in the response, so we don't need to apply it again. + // However, we do need to apply the successData and failureData from the request + if (type === CONST.ONYX_UPDATE_TYPES.HTTPS && request && response && (!isEmptyObject(request.successData) || !isEmptyObject(request.failureData))) { + Log.info('[OnyxUpdateManager] Applying success or failure data from request without onyxData from response'); + + // We use a spread here instead of delete because we don't want to change the response for other middlewares + const {onyxData, ...responseWithoutOnyxData} = response; + return applyHTTPSOnyxUpdates(request, responseWithoutOnyxData); + } + return Promise.resolve(); } if (lastUpdateID && (lastUpdateIDAppliedToClient === null || Number(lastUpdateID) > lastUpdateIDAppliedToClient)) { diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 20c6d8fef247..0e1662da4d55 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -3,6 +3,7 @@ import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; +import DateUtils from '@libs/DateUtils'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; @@ -12,6 +13,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {DateOfBirthForm, PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; import type {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails'; +import * as Session from './Session'; type FirstAndLastName = { firstName: string; @@ -263,6 +265,10 @@ function updateAddress(street: string, street2: string, city: string, state: str * selected timezone if set to automatically update. */ function updateAutomaticTimezone(timezone: Timezone) { + if (Session.isAnonymousUser()) { + return; + } + if (!currentUserAccountID) { return; } @@ -270,9 +276,9 @@ function updateAutomaticTimezone(timezone: Timezone) { type UpdateAutomaticTimezoneParams = { timezone: string; }; - + const formatedTimezone = DateUtils.formatToSupportedTimezone(timezone); const parameters: UpdateAutomaticTimezoneParams = { - timezone: JSON.stringify(timezone), + timezone: JSON.stringify(formatedTimezone), }; API.write('UpdateAutomaticTimezone', parameters, { @@ -282,7 +288,7 @@ function updateAutomaticTimezone(timezone: Timezone) { key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: { [currentUserAccountID]: { - timezone, + timezone: formatedTimezone, }, }, }, diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index f148f27b3713..212fd3ead898 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -274,11 +274,15 @@ function buildAnnounceRoomMembersOnyxData(policyID, accountIDs) { return announceRoomMembers; } + // Everyone in special policy rooms is visible + const participantAccountIDs = [...announceReport.participantAccountIDs, ...accountIDs]; + announceRoomMembers.onyxOptimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, value: { - participantAccountIDs: [...announceReport.participantAccountIDs, ...accountIDs], + participantAccountIDs, + visibleChatMemberAccountIDs: participantAccountIDs, }, }); @@ -287,6 +291,7 @@ function buildAnnounceRoomMembersOnyxData(policyID, accountIDs) { key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, value: { participantAccountIDs: announceReport.participantAccountIDs, + visibleChatMemberAccountIDs: announceReport.visibleChatMemberAccountIDs, }, }); return announceRoomMembers; @@ -315,6 +320,7 @@ function removeOptimisticAnnounceRoomMembers(policyID, accountIDs) { key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, value: { participantAccountIDs: [...remainUsers], + visibleChatMemberAccountIDs: [...remainUsers], }, }); @@ -323,6 +329,7 @@ function removeOptimisticAnnounceRoomMembers(policyID, accountIDs) { key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, value: { participantAccountIDs: announceReport.participantAccountIDs, + visibleChatMemberAccountIDs: announceReport.visibleChatMemberAccountIDs, }, }); return announceRoomMembers; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 13e4222f942f..cef236a3e1bb 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2120,6 +2120,24 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal }, ]; + if (report.parentReportID && report.parentReportActionID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, + value: {[report.parentReportActionID]: {childReportNotificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}}, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, + value: {[report.parentReportActionID]: {childReportNotificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, + value: {[report.parentReportActionID]: {childReportNotificationPreference: report.notificationPreference}}, + }); + } + type LeaveRoomParameters = { reportID: string; }; @@ -2134,6 +2152,8 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins([CONST.EMAIL.CONCIERGE]); const chat = ReportUtils.getChatByParticipants(participantAccountIDs); if (chat?.reportID) { + // We should call Navigation.goBack to pop the current route first before navigating to Concierge. + Navigation.goBack(ROUTES.HOME); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(chat.reportID)); } } @@ -2152,6 +2172,9 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record<string const participantAccountIDsAfterInvitation = [...new Set([...(report?.participantAccountIDs ?? []), ...inviteeAccountIDs])].filter( (accountID): accountID is number => typeof accountID === 'number', ); + const visibleMemberAccountIDsAfterInvitation = [...new Set([...(report?.visibleChatMemberAccountIDs ?? []), ...inviteeAccountIDs])].filter( + (accountID): accountID is number => typeof accountID === 'number', + ); type PersonalDetailsOnyxData = { optimisticData: OnyxUpdate[]; @@ -2168,6 +2191,7 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record<string key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { participantAccountIDs: participantAccountIDsAfterInvitation, + visibleChatMemberAccountIDs: visibleMemberAccountIDsAfterInvitation, }, }, ...newPersonalDetailsOnyxData.optimisticData, @@ -2181,6 +2205,7 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record<string key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { participantAccountIDs: report.participantAccountIDs, + visibleChatMemberAccountIDs: report.visibleChatMemberAccountIDs, }, }, ...newPersonalDetailsOnyxData.failureData, @@ -2204,6 +2229,7 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) { const report = allReports?.[reportID]; const participantAccountIDsAfterRemoval = report?.participantAccountIDs?.filter((id: number) => !targetAccountIDs.includes(id)); + const visibleChatMemberAccountIDsAfterRemoval = report?.visibleChatMemberAccountIDs?.filter((id: number) => !targetAccountIDs.includes(id)); const optimisticData: OnyxUpdate[] = [ { @@ -2211,6 +2237,7 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) { key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { participantAccountIDs: participantAccountIDsAfterRemoval, + visibleChatMemberAccountIDs: visibleChatMemberAccountIDsAfterRemoval, }, }, ]; @@ -2221,6 +2248,7 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) { key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { participantAccountIDs: report?.participantAccountIDs, + visibleChatMemberAccountIDs: report?.visibleChatMemberAccountIDs, }, }, ]; @@ -2233,6 +2261,7 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) { key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { participantAccountIDs: participantAccountIDsAfterRemoval, + visibleChatMemberAccountIDs: visibleChatMemberAccountIDsAfterRemoval, }, }, ]; @@ -2411,6 +2440,10 @@ const updatePrivateNotes = (reportID: string, accountID: number, note: string) = /** Fetches all the private notes for a given report */ function getReportPrivateNote(reportID: string) { + if (Session.isAnonymousUser()) { + return; + } + if (!reportID) { return; } diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 31cfd96c0bd3..a83a2a42fa68 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -15,6 +15,7 @@ import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {isNotEmptyObject} from '@src/types/utils/EmptyObject'; import * as Report from './Report'; let currentUserEmail; @@ -180,6 +181,12 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail }, ); + // If needed, update optimistic data for parent report action of the parent report. + const optimisticParentReportData = ReportUtils.getOptimisticDataForParentReportAction(parentReportID, currentTime, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + if (isNotEmptyObject(optimisticParentReportData)) { + optimisticData.push(optimisticParentReportData); + } + // FOR PARENT REPORT (SHARE DESTINATION) successData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -392,6 +399,7 @@ function editTask(report, {title, description}) { ...(title && {reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(description && {description: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, + errorFields: null, }, }, ]; @@ -489,8 +497,10 @@ function editTaskAssignee(report, ownerAccountID, assigneeEmail, assigneeAccount // Check if the assignee actually changed if (assigneeAccountID && assigneeAccountID !== report.managerID && assigneeAccountID !== ownerAccountID && assigneeChatReport) { const participants = lodashGet(report, 'participantAccountIDs', []); - if (!participants.includes(assigneeAccountID)) { + const visibleMembers = lodashGet(report, 'visibleChatMemberAccountIDs', []); + if (!visibleMembers.includes(assigneeAccountID)) { optimisticReport.participantAccountIDs = [...participants, assigneeAccountID]; + optimisticReport.visibleChatMemberAccountIDs = [...visibleMembers, assigneeAccountID]; } assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData( @@ -776,6 +786,19 @@ function deleteTask(taskReportID, taskTitle, originalStateNum, originalStatusNum }, ]; + // Update optimistic data for parent report action if the report is a child report and the task report has no visible child + const childVisibleActionCount = lodashGet(parentReportAction, 'childVisibleActionCount', 0); + if (childVisibleActionCount === 0) { + const optimisticParentReportData = ReportUtils.getOptimisticDataForParentReportAction( + parentReport.reportID, + parentReport.lastVisibleActionCreated || '', + CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + if (isNotEmptyObject(optimisticParentReportData)) { + optimisticData.push(optimisticParentReportData); + } + } + const successData = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/styles/utils/roundToNearestMultipleOfFour.ts b/src/libs/roundToNearestMultipleOfFour.ts similarity index 100% rename from src/styles/utils/roundToNearestMultipleOfFour.ts rename to src/libs/roundToNearestMultipleOfFour.ts diff --git a/src/libs/updatePropsPaperWorklet/index.js b/src/libs/updatePropsPaperWorklet/index.js deleted file mode 100644 index 1bca6ea13cdc..000000000000 --- a/src/libs/updatePropsPaperWorklet/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function () { - 'worklet'; -} diff --git a/src/libs/updatePropsPaperWorklet/index.native.js b/src/libs/updatePropsPaperWorklet/index.native.js deleted file mode 100644 index ed79b38ffab5..000000000000 --- a/src/libs/updatePropsPaperWorklet/index.native.js +++ /dev/null @@ -1,13 +0,0 @@ -export default function (viewTag, viewName, updates) { - 'worklet'; - - // _updatePropsPaper is a function that is worklet function from react-native-reanimated which is not available on web - // eslint-disable-next-line no-undef - _updatePropsPaper([ - { - tag: viewTag, - name: viewName, - updates, - }, - ]); -} diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index e41f30779f22..bc05c110ab2f 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -1,7 +1,7 @@ import lodashGet from 'lodash/get'; import lodashValues from 'lodash/values'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useCallback, useEffect} from 'react'; import {withOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import categoryPropTypes from '@components/categoryPropTypes'; @@ -47,9 +47,6 @@ const propTypes = { /** The report object for the thread report */ report: reportPropTypes, - /** The parent report object for the thread report */ - parentReport: reportPropTypes, - /** Collection of categories attached to a policy */ policyCategories: PropTypes.objectOf(categoryPropTypes), @@ -65,14 +62,13 @@ const propTypes = { const defaultProps = { report: {}, - parentReport: {}, policyCategories: {}, policyTags: {}, parentReportActions: {}, transaction: {}, }; -function EditRequestPage({report, route, parentReport, policyCategories, policyTags, parentReportActions, transaction}) { +function EditRequestPage({report, route, policyCategories, policyTags, parentReportActions, transaction}) { const parentReportActionID = lodashGet(report, 'parentReportActionID', '0'); const parentReportAction = lodashGet(parentReportActions, parentReportActionID, {}); const { @@ -93,7 +89,7 @@ function EditRequestPage({report, route, parentReport, policyCategories, policyT const tagListName = PolicyUtils.getTagListName(policyTags); // A flag for verifying that the current report is a sub-report of a workspace chat - const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]); + const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report); // A flag for showing the categories page const shouldShowCategories = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories))); @@ -104,7 +100,7 @@ function EditRequestPage({report, route, parentReport, policyCategories, policyT // Decides whether to allow or disallow editing a money request useEffect(() => { // Do not dismiss the modal, when a current user can edit this property of the money request. - if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, parentReport.reportID, fieldToEdit, transaction)) { + if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, fieldToEdit)) { return; } @@ -112,7 +108,7 @@ function EditRequestPage({report, route, parentReport, policyCategories, policyT Navigation.isNavigationReady().then(() => { Navigation.dismissModal(); }); - }, [parentReportAction, parentReport.reportID, fieldToEdit, transaction]); + }, [parentReportAction, fieldToEdit]); // Update the transaction object and close the modal function editMoneyRequest(transactionChanges) { @@ -149,6 +145,19 @@ function EditRequestPage({report, route, parentReport, policyCategories, policyT [transaction, report], ); + const saveTag = useCallback( + ({tag: newTag}) => { + let updatedTag = newTag; + if (newTag === transactionTag) { + // In case the same tag has been selected, reset the tag. + updatedTag = ''; + } + IOU.updateMoneyRequestTag(transaction.transactionID, report.reportID, updatedTag); + Navigation.dismissModal(); + }, + [transactionTag, transaction.transactionID, report.reportID], + ); + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) { return ( <EditRequestDescriptionPage @@ -238,15 +247,7 @@ function EditRequestPage({report, route, parentReport, policyCategories, policyT defaultTag={transactionTag} tagName={tagListName} policyID={lodashGet(report, 'policyID', '')} - onSubmit={(transactionChanges) => { - let updatedTag = transactionChanges.tag; - - // In case the same tag has been selected, reset the tag. - if (transactionTag === updatedTag) { - updatedTag = ''; - } - editMoneyRequest({tag: updatedTag, tagListName}); - }} + onSubmit={saveTag} /> ); } @@ -298,9 +299,6 @@ export default compose( policyTags: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, }, - parentReport: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`, - }, parentReportActions: { key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, canEvict: false, diff --git a/src/pages/ProcessMoneyRequestHoldPage.js b/src/pages/ProcessMoneyRequestHoldPage.js new file mode 100644 index 000000000000..c9de16f874a2 --- /dev/null +++ b/src/pages/ProcessMoneyRequestHoldPage.js @@ -0,0 +1,51 @@ +import React, {useCallback, useMemo} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import HeaderPageLayout from '@components/HeaderPageLayout'; +import HoldMenuSectionList from '@components/HoldMenuSectionList'; +import Text from '@components/Text'; +import TextPill from '@components/TextPill'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; + +function ProcessMoneyRequestHoldPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const onConfirm = useCallback(() => { + // Currently only goes back, this will be changed after backends for hold will be merged + Navigation.goBack(); + }, []); + + const footerComponent = useMemo( + () => ( + <Button + success + text={translate('common.buttonConfirm')} + onPress={onConfirm} + /> + ), + [onConfirm, translate], + ); + + return ( + <HeaderPageLayout + title={translate('common.back')} + footer={footerComponent} + onBackButtonPress={() => Navigation.goBack()} + > + <View style={[styles.mh5, styles.flex1]}> + <View style={[styles.flexRow, styles.alignItemsCenter, styles.mb5]}> + <Text style={[styles.textHeadline, styles.mr2]}>{translate('iou.holdEducationalTitle')}</Text> + <TextPill textStyles={styles.holdRequestInline}>{translate('iou.hold')}</TextPill>; + </View> + <HoldMenuSectionList /> + </View> + </HeaderPageLayout> + ); +} + +ProcessMoneyRequestHoldPage.displayName = 'ProcessMoneyRequestHoldPage'; + +export default ProcessMoneyRequestHoldPage; diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index dc8b77db4ac8..ff9ed62c6a65 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -73,7 +73,7 @@ function ReportDetailsPage(props) { // eslint-disable-next-line react-hooks/exhaustive-deps -- policy is a dependency because `getChatRoomSubtitle` calls `getPolicyName` which in turn retrieves the value from the `policy` value stored in Onyx const chatRoomSubtitle = useMemo(() => ReportUtils.getChatRoomSubtitle(props.report), [props.report, policy]); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(props.report); - const participants = useMemo(() => ReportUtils.getParticipantsIDs(props.report), [props.report]); + const participants = useMemo(() => ReportUtils.getVisibleMemberIDs(props.report), [props.report]); const isGroupDMChat = useMemo(() => ReportUtils.isDM(props.report) && participants.length > 1, [props.report, participants.length]); diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index fddf5176f815..7dbc1c7036c4 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -56,7 +56,7 @@ const defaultProps = { * @return {Array} */ const getAllParticipants = (report, personalDetails, translate) => - _.chain(ReportUtils.getParticipantsIDs(report)) + _.chain(ReportUtils.getVisibleMemberIDs(report)) .map((accountID, index) => { const userPersonalDetail = lodashGet(personalDetails, accountID, {displayName: personalDetails.displayName || translate('common.hidden'), avatar: ''}); const userLogin = LocalePhoneNumber.formatPhoneNumber(userPersonalDetail.login || '') || translate('common.hidden'); diff --git a/src/pages/RoomInvitePage.js b/src/pages/RoomInvitePage.js index aebdec047895..f8d2d32bfe79 100644 --- a/src/pages/RoomInvitePage.js +++ b/src/pages/RoomInvitePage.js @@ -70,7 +70,10 @@ function RoomInvitePage(props) { const [userToInvite, setUserToInvite] = useState(null); // Any existing participants and Expensify emails should not be eligible for invitation - const excludedUsers = useMemo(() => [...PersonalDetailsUtils.getLoginsByAccountIDs(lodashGet(props.report, 'participantAccountIDs', [])), ...CONST.EXPENSIFY_EMAILS], [props.report]); + const excludedUsers = useMemo( + () => [...PersonalDetailsUtils.getLoginsByAccountIDs(lodashGet(props.report, 'visibleChatMemberAccountIDs', [])), ...CONST.EXPENSIFY_EMAILS], + [props.report], + ); useEffect(() => { const inviteOptions = OptionsListUtils.getMemberInviteOptions(props.personalDetails, props.betas, searchTerm, excludedUsers); diff --git a/src/pages/RoomMembersPage.js b/src/pages/RoomMembersPage.js index 27e1cd1da2e6..67228e574de6 100644 --- a/src/pages/RoomMembersPage.js +++ b/src/pages/RoomMembersPage.js @@ -174,7 +174,7 @@ function RoomMembersPage(props) { const getMemberOptions = () => { let result = []; - _.each(props.report.participantAccountIDs, (accountID) => { + _.each(props.report.visibleChatMemberAccountIDs, (accountID) => { const details = personalDetails[accountID]; if (!details) { diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js index 1f062a42f8bf..344e7f4bc886 100644 --- a/src/pages/ShareCodePage.js +++ b/src/pages/ShareCodePage.js @@ -55,7 +55,7 @@ class ShareCodePage extends React.Component { } if (ReportUtils.isMoneyRequestReport(this.props.report)) { // generate subtitle from participants - return _.map(ReportUtils.getParticipantsIDs(this.props.report), (accountID) => ReportUtils.getDisplayNameForParticipant(accountID)).join(' & '); + return _.map(ReportUtils.getVisibleMemberIDs(this.props.report), (accountID) => ReportUtils.getDisplayNameForParticipant(accountID)).join(' & '); } if (isReport) { diff --git a/src/pages/home/ReportScreenContext.ts b/src/pages/home/ReportScreenContext.ts index 98d593b92d91..3b4e574e01a1 100644 --- a/src/pages/home/ReportScreenContext.ts +++ b/src/pages/home/ReportScreenContext.ts @@ -1,9 +1,13 @@ import type {RefObject} from 'react'; import {createContext} from 'react'; -import type {FlatList, GestureResponderEvent} from 'react-native'; +import type {FlatList, GestureResponderEvent, View} from 'react-native'; + +type ReactionListAnchor = View | HTMLDivElement | null; + +type ReactionListEvent = GestureResponderEvent | MouseEvent; type ReactionListRef = { - showReactionList: (event: GestureResponderEvent | undefined, reactionListAnchor: Element, emojiName: string, reportActionID: string) => void; + showReactionList: (event: ReactionListEvent | undefined, reactionListAnchor: ReactionListAnchor, emojiName: string, reportActionID: string) => void; hideReactionList: () => void; isActiveReportAction: (actionID: number | string) => boolean; }; @@ -21,4 +25,4 @@ const ActionListContext = createContext<ActionListContextType>({flatListRef: nul const ReactionListContext = createContext<ReactionListContextType>(null); export {ActionListContext, ReactionListContext}; -export type {ReactionListRef, ActionListContextType, ReactionListContextType, FlatListRefType}; +export type {ReactionListRef, ActionListContextType, ReactionListContextType, FlatListRefType, ReactionListAnchor, ReactionListEvent}; diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts index 317c3846d160..5b64d90da5da 100644 --- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts @@ -1,5 +1,6 @@ import React from 'react'; -import type {GestureResponderEvent, Text as RNText} from 'react-native'; +import type {RefObject} from 'react'; +import type {GestureResponderEvent, Text as RNText, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; @@ -17,7 +18,7 @@ type ShowContextMenu = ( type: ContextMenuType, event: GestureResponderEvent | MouseEvent, selection: string, - contextMenuAnchor: RNText | null, + contextMenuAnchor: View | RNText | null, reportID?: string, reportActionID?: string, originalReportID?: string, @@ -39,6 +40,7 @@ type ReportActionContextMenu = { instanceID: string; runAndResetOnPopoverHide: () => void; clearActiveReportAction: () => void; + contentRef: RefObject<View>; }; const contextMenuRef = React.createRef<ReportActionContextMenu>(); @@ -94,7 +96,7 @@ function showContextMenu( type: ContextMenuType, event: GestureResponderEvent | MouseEvent, selection: string, - contextMenuAnchor: RNText | null, + contextMenuAnchor: View | RNText | null, reportID = '0', reportActionID = '0', originalReportID = '0', diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index da79db6f2ec8..c072666920ae 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import {runOnJS, useAnimatedRef} from 'react-native-reanimated'; +import {runOnJS, setNativeProps, useAnimatedRef} from 'react-native-reanimated'; import _ from 'underscore'; import AttachmentModal from '@components/AttachmentModal'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; @@ -24,7 +24,6 @@ import getDraftComment from '@libs/ComposerUtils/getDraftComment'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getModalState from '@libs/getModalState'; import * as ReportUtils from '@libs/ReportUtils'; -import updatePropsPaperWorklet from '@libs/updatePropsPaperWorklet'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime'; import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; @@ -348,13 +347,10 @@ function ReportActionCompose({ return; } - const viewTag = animatedRef(); - const viewName = 'RCTMultilineTextInputView'; - const updates = {text: ''}; // We are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state runOnJS(setIsCommentEmpty)(true); runOnJS(resetFullComposerSize)(); - updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread + setNativeProps(animatedRef, {text: ''}); // clears native text input on the UI thread runOnJS(submitForm)(); }, [isSendDisabled, resetFullComposerSize, submitForm, animatedRef, isReportReadyForDisplay]); diff --git a/src/pages/home/report/ReportActionItemMessage.tsx b/src/pages/home/report/ReportActionItemMessage.tsx index d98d1b901578..3a71ee8356b3 100644 --- a/src/pages/home/report/ReportActionItemMessage.tsx +++ b/src/pages/home/report/ReportActionItemMessage.tsx @@ -40,13 +40,15 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid const fragment = ReportActionsUtils.getMemberChangeMessageFragment(action); return ( - <TextCommentFragment - fragment={fragment} - displayAsGroup={displayAsGroup} - style={style} - source="" - styleAsDeleted={false} - /> + <View style={[styles.chatItemMessage, style]}> + <TextCommentFragment + fragment={fragment} + displayAsGroup={displayAsGroup} + style={style} + source="" + styleAsDeleted={false} + /> + </View> ); } diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index dbc77a41817b..580cc7909fd1 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -168,6 +168,7 @@ const chatReportSelector = (report) => // Other important less obivous properties for filtering: parentReportActionID: report.parentReportActionID, parentReportID: report.parentReportID, + isDeletedParentAction: report.isDeletedParentAction, }; /** diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index eab9ab5a7510..24833fc96fdc 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -86,7 +86,7 @@ function IOUCurrencySelection(props) { const parentReportAction = ReportActionsUtils.getReportAction(report.parentReportID, report.parentReportActionID); // Do not dismiss the modal, when a current user can edit this currency of this money request. - if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, report.parentReportID, CONST.EDIT_REQUEST_FIELD.CURRENCY)) { + if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.CURRENCY)) { return; } diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index 893c735aac3b..d41442edd670 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -1,12 +1,15 @@ import lodashGet from 'lodash/get'; +import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import categoryPropTypes from '@components/categoryPropTypes'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestConfirmationList from '@components/MoneyTemporaryForRefactorRequestConfirmationList'; import ScreenWrapper from '@components/ScreenWrapper'; +import tagPropTypes from '@components/tagPropTypes'; import transactionPropTypes from '@components/transactionPropTypes'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; @@ -46,6 +49,12 @@ const propTypes = { /** The policy of the report */ ...policyPropTypes, + /** The tag configuration of the report's policy */ + policyTags: tagPropTypes, + + /** The category configuration of the report's policy */ + policyCategories: PropTypes.objectOf(categoryPropTypes), + /** The full IOU report */ report: reportPropTypes, @@ -55,6 +64,8 @@ const propTypes = { const defaultProps = { personalDetails: {}, policy: {}, + policyCategories: {}, + policyTags: {}, report: {}, transaction: {}, ...withCurrentUserPersonalDetailsDefaultProps, @@ -63,6 +74,8 @@ function IOURequestStepConfirmation({ currentUserPersonalDetails, personalDetails, policy, + policyTags, + policyCategories, report, route: { params: {iouType, reportID, transactionID}, @@ -164,9 +177,12 @@ function IOURequestStepConfirmation({ transactionTaxCode, transactionTaxAmount, transaction.billable, + policy, + policyTags, + policyCategories, ); }, - [report, transaction, transactionTaxCode, transactionTaxAmount, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID], + [report, transaction, transactionTaxCode, transactionTaxAmount, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, policy, policyTags, policyCategories], ); /** @@ -187,9 +203,12 @@ function IOURequestStepConfirmation({ transaction.merchant, transaction.billable, TransactionUtils.getValidWaypoints(transaction.comment.waypoints, true), + policy, + policyTags, + policyCategories, ); }, - [report, transaction], + [policy, policyCategories, policyTags, report, transaction], ); const createTransaction = useCallback( @@ -375,7 +394,13 @@ export default compose( // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${lodashGet(report, 'policyID', '0')}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, + }, + policyCategories: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`, + }, + policyTags: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, }, }), )(IOURequestStepConfirmation); diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.js b/src/pages/iou/request/step/IOURequestStepMerchant.js index 355bb76b89b0..091e7d8023c3 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.js +++ b/src/pages/iou/request/step/IOURequestStepMerchant.js @@ -36,13 +36,15 @@ function IOURequestStepMerchant({ route: { params: {transactionID, backTo}, }, - transaction: {merchant}, + transaction: {merchant, participants}, }) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {inputCallbackRef} = useAutoFocusInput(); const isEmptyMerchant = merchant === '' || merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + const isMerchantRequired = _.some(participants, (participant) => Boolean(participant.isPolicyExpenseChat)); + const navigateBack = () => { Navigation.goBack(backTo || ROUTES.HOME); }; @@ -51,15 +53,18 @@ function IOURequestStepMerchant({ * @param {Object} value * @param {String} value.moneyRequestMerchant */ - const validate = useCallback((value) => { - const errors = {}; + const validate = useCallback( + (value) => { + const errors = {}; - if (_.isEmpty(value.moneyRequestMerchant)) { - errors.moneyRequestMerchant = 'common.error.fieldRequired'; - } + if (isMerchantRequired && _.isEmpty(value.moneyRequestMerchant)) { + errors.moneyRequestMerchant = 'common.error.fieldRequired'; + } - return errors; - }, []); + return errors; + }, + [isMerchantRequired], + ); /** * @param {Object} value diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index 3476b2304875..1738ac78df47 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -4,11 +4,13 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import categoryPropTypes from '@components/categoryPropTypes'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestConfirmationList from '@components/MoneyRequestConfirmationList'; import {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; +import tagPropTypes from '@components/tagPropTypes'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import withLocalize from '@components/withLocalize'; import useInitialValue from '@hooks/useInitialValue'; @@ -23,6 +25,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes'; import reportPropTypes from '@pages/reportPropTypes'; +import {policyDefaultProps, policyPropTypes} from '@pages/workspace/withPolicy'; import * as IOU from '@userActions/IOU'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; @@ -47,12 +50,22 @@ const propTypes = { /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ iou: iouPropTypes, + /** The policy of the current request */ + policy: policyPropTypes, + + policyTags: tagPropTypes, + + policyCategories: PropTypes.objectOf(categoryPropTypes), + ...withCurrentUserPersonalDetailsPropTypes, }; const defaultProps = { report: {}, + policyCategories: {}, + policyTags: {}, iou: iouDefaultProps, + policy: policyDefaultProps, ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -163,6 +176,9 @@ function MoneyRequestConfirmPage(props) { props.iou.category, props.iou.tag, props.iou.billable, + props.policy, + props.policyTags, + props.policyCategories, ); }, [ @@ -176,6 +192,9 @@ function MoneyRequestConfirmPage(props) { props.iou.category, props.iou.tag, props.iou.billable, + props.policy, + props.policyTags, + props.policyCategories, ], ); @@ -197,9 +216,25 @@ function MoneyRequestConfirmPage(props) { props.iou.currency, props.iou.merchant, props.iou.billable, + props.policy, + props.policyTags, + props.policyCategories, ); }, - [props.report, props.iou.created, props.iou.transactionID, props.iou.category, props.iou.tag, props.iou.amount, props.iou.currency, props.iou.merchant, props.iou.billable], + [ + props.report, + props.iou.created, + props.iou.transactionID, + props.iou.category, + props.iou.tag, + props.iou.amount, + props.iou.currency, + props.iou.merchant, + props.iou.billable, + props.policy, + props.policyTags, + props.policyCategories, + ], ); const createTransaction = useCallback( @@ -424,10 +459,15 @@ export default compose( key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, }, }), - // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ policy: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, }, + policyCategories: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`, + }, + policyTags: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, + }, }), )(MoneyRequestConfirmPage); diff --git a/src/pages/reportPropTypes.js b/src/pages/reportPropTypes.js index d2d7bb480ed6..329bf66f7275 100644 --- a/src/pages/reportPropTypes.js +++ b/src/pages/reportPropTypes.js @@ -47,6 +47,9 @@ export default PropTypes.shape({ /** List of accountIDs of participants of the report */ participantAccountIDs: PropTypes.arrayOf(PropTypes.number), + /** List of accountIDs of visible members of the report */ + visibleChatMemberAccountIDs: PropTypes.arrayOf(PropTypes.number), + /** Linked policy's ID */ policyID: PropTypes.string, diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js index f0c3d3ada0c2..8585fdf7ab97 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.js +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -13,7 +13,6 @@ import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import OfflineIndicator from '@components/OfflineIndicator'; import RoomNameInput from '@components/RoomNameInput'; import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; import TextInput from '@components/TextInput'; import ValuePicker from '@components/ValuePicker'; import withNavigationFocus from '@components/withNavigationFocus'; @@ -132,9 +131,7 @@ function WorkspaceNewRoomPage(props) { '', visibility, writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL, - - // The room might contain all policy members so notifying always should be opt-in only. - CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY, + CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, '', '', parsedWelcomeMessage, @@ -329,9 +326,9 @@ function WorkspaceNewRoomPage(props) { items={visibilityOptions} onValueChange={setVisibility} value={visibility} + furtherDetails={visibilityDescription} /> </View> - <Text style={[styles.textLabel, styles.colorMuted]}>{visibilityDescription}</Text> </FormProvider> {isSmallScreenWidth && <OfflineIndicator />} </KeyboardAvoidingView> diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index 90eda3651a04..ec38b61fb0dc 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -54,6 +54,18 @@ const policyPropTypes = { * } */ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + + /** Whether or not the policy requires tags */ + requiresTag: PropTypes.bool, + + /** Whether or not the policy requires categories */ + requiresCategory: PropTypes.bool, + + /** Whether or not the policy has multiple tag lists */ + hasMultipleTagLists: PropTypes.bool, + + /** Whether or not the policy has tax tracking enabled */ + isTaxTrackingEnabled: PropTypes.bool, }), /** The employee list of this policy */ diff --git a/src/stories/CheckboxWithLabel.stories.js b/src/stories/CheckboxWithLabel.stories.js index 2da4713e81b3..f978856aaefb 100644 --- a/src/stories/CheckboxWithLabel.stories.js +++ b/src/stories/CheckboxWithLabel.stories.js @@ -1,6 +1,7 @@ import React from 'react'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; import Text from '@components/Text'; +// eslint-disable-next-line no-restricted-imports import {defaultStyles} from '@styles/index'; /** diff --git a/src/stories/Composer.stories.js b/src/stories/Composer.stories.js index 04fa9be7de86..e4051a4ab72a 100644 --- a/src/stories/Composer.stories.js +++ b/src/stories/Composer.stories.js @@ -6,7 +6,9 @@ import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import withNavigationFallback from '@components/withNavigationFallback'; import useStyleUtils from '@hooks/useStyleUtils'; +// eslint-disable-next-line no-restricted-imports import {defaultStyles} from '@styles/index'; +// eslint-disable-next-line no-restricted-imports import {defaultTheme} from '@styles/theme'; import CONST from '@src/CONST'; diff --git a/src/stories/DragAndDrop.stories.js b/src/stories/DragAndDrop.stories.js index 8e540dcd2a42..57995df1edad 100644 --- a/src/stories/DragAndDrop.stories.js +++ b/src/stories/DragAndDrop.stories.js @@ -4,6 +4,7 @@ import {Image, View} from 'react-native'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; import Text from '@components/Text'; +// eslint-disable-next-line no-restricted-imports import {defaultStyles} from '@styles/index'; /** diff --git a/src/stories/Form.stories.js b/src/stories/Form.stories.js index 7802b59605a5..6a4274c87eda 100644 --- a/src/stories/Form.stories.js +++ b/src/stories/Form.stories.js @@ -11,6 +11,7 @@ import Text from '@components/Text'; import TextInput from '@components/TextInput'; import NetworkConnection from '@libs/NetworkConnection'; import * as ValidationUtils from '@libs/ValidationUtils'; +// eslint-disable-next-line no-restricted-imports import {defaultStyles} from '@styles/index'; import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; diff --git a/src/stories/SelectionList.stories.js b/src/stories/SelectionList.stories.js index c08ef2df783a..835bf67fbfd7 100644 --- a/src/stories/SelectionList.stories.js +++ b/src/stories/SelectionList.stories.js @@ -3,6 +3,7 @@ import {View} from 'react-native'; import _ from 'underscore'; import SelectionList from '@components/SelectionList'; import Text from '@components/Text'; +// eslint-disable-next-line no-restricted-imports import {defaultStyles} from '@styles/index'; import CONST from '@src/CONST'; diff --git a/src/styles/index.ts b/src/styles/index.ts index ce39e69a445a..6d3cbd93c6c8 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -20,8 +20,7 @@ import cursor from './utils/cursor'; import display from './utils/display'; import editedLabelStyles from './utils/editedLabelStyles'; import flex from './utils/flex'; -import fontFamily from './utils/fontFamily'; -import fontWeightBold from './utils/fontWeight/bold'; +import FontUtils from './utils/FontUtils'; import getPopOverVerticalOffset from './utils/getPopOverVerticalOffset'; import objectFit from './utils/objectFit'; import optionAlternateTextPlatformStyles from './utils/optionAlternateTextPlatformStyles'; @@ -91,7 +90,7 @@ const picker = (theme: ThemeColors) => ({ backgroundColor: theme.transparent, color: theme.text, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeNormal, lineHeight: variables.fontSizeNormalHeight, paddingBottom: 8, @@ -107,7 +106,7 @@ const link = (theme: ThemeColors) => ({ color: theme.link, textDecorationColor: theme.link, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, } satisfies ViewStyle & MixedStyleDeclaration); const baseCodeTagStyles = (theme: ThemeColors) => @@ -119,7 +118,7 @@ const baseCodeTagStyles = (theme: ThemeColors) => } satisfies ViewStyle & MixedStyleDeclaration); const headlineFont = { - fontFamily: fontFamily.EXP_NEW_KANSAS_MEDIUM, + fontFamily: FontUtils.fontFamily.platform.EXP_NEW_KANSAS_MEDIUM, fontWeight: '500', } satisfies TextStyle; @@ -139,7 +138,7 @@ const webViewStyles = (theme: ThemeColors) => // component. tagStyles: { em: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontStyle: 'italic', }, @@ -149,7 +148,7 @@ const webViewStyles = (theme: ThemeColors) => }, strong: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontWeight: 'bold', }, @@ -184,7 +183,7 @@ const webViewStyles = (theme: ThemeColors) => paddingBottom: 12, paddingRight: 8, paddingLeft: 8, - fontFamily: fontFamily.MONOSPACE, + fontFamily: FontUtils.fontFamily.platform.MONOSPACE, marginTop: 0, marginBottom: 0, }, @@ -194,7 +193,7 @@ const webViewStyles = (theme: ThemeColors) => ...(codeStyles.codeTextStyle as MixedStyleDeclaration), paddingLeft: 5, paddingRight: 5, - fontFamily: fontFamily.MONOSPACE, + fontFamily: FontUtils.fontFamily.platform.MONOSPACE, // Font size is determined by getCodeFontSize function in `StyleUtils.js` }, @@ -218,7 +217,7 @@ const webViewStyles = (theme: ThemeColors) => baseFontStyle: { color: theme.text, fontSize: variables.fontSizeNormal, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, flex: 1, lineHeight: variables.fontSizeNormalHeight, ...writingDirection.ltr, @@ -293,8 +292,8 @@ const styles = (theme: ThemeColors) => }, mentionSuggestionsDisplayName: { - fontFamily: fontFamily.EXP_NEUE_BOLD, - fontWeight: fontWeightBold, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, }, textSupporting: { @@ -308,7 +307,7 @@ const styles = (theme: ThemeColors) => linkMuted: { color: theme.textSupporting, textDecorationColor: theme.textSupporting, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, }, linkMutedHovered: { @@ -324,9 +323,9 @@ const styles = (theme: ThemeColors) => }, h4: { - fontFamily: fontFamily.EXP_NEUE_BOLD, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, fontSize: variables.fontSizeLabel, - fontWeight: fontWeightBold, + fontWeight: FontUtils.fontWeight.bold, }, textAlignCenter: { @@ -367,29 +366,29 @@ const styles = (theme: ThemeColors) => }, textMicro: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSmall, lineHeight: variables.lineHeightSmall, }, textMicroBold: { color: theme.text, - fontWeight: fontWeightBold, - fontFamily: fontFamily.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, fontSize: variables.fontSizeSmall, lineHeight: variables.lineHeightSmall, }, textMicroSupporting: { color: theme.textSupporting, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSmall, lineHeight: variables.lineHeightSmall, }, textExtraSmallSupporting: { color: theme.textSupporting, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeExtraSmall, }, @@ -411,13 +410,13 @@ const styles = (theme: ThemeColors) => textHero: { fontSize: variables.fontSizeHero, - fontFamily: fontFamily.EXP_NEW_KANSAS_MEDIUM, + fontFamily: FontUtils.fontFamily.platform.EXP_NEW_KANSAS_MEDIUM, lineHeight: variables.lineHeightHero, }, textStrong: { - fontFamily: fontFamily.EXP_NEUE_BOLD, - fontWeight: fontWeightBold, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, }, textHeadline: { @@ -501,9 +500,9 @@ const styles = (theme: ThemeColors) => buttonText: { color: theme.text, - fontFamily: fontFamily.EXP_NEUE_BOLD, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, fontSize: variables.fontSizeNormal, - fontWeight: fontWeightBold, + fontWeight: FontUtils.fontWeight.bold, textAlign: 'center', flexShrink: 1, @@ -540,22 +539,22 @@ const styles = (theme: ThemeColors) => buttonSmallText: { fontSize: variables.fontSizeSmall, - fontFamily: fontFamily.EXP_NEUE_BOLD, - fontWeight: fontWeightBold, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, textAlign: 'center', }, buttonMediumText: { fontSize: variables.fontSizeLabel, - fontFamily: fontFamily.EXP_NEUE_BOLD, - fontWeight: fontWeightBold, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, textAlign: 'center', }, buttonLargeText: { fontSize: variables.fontSizeNormal, - fontFamily: fontFamily.EXP_NEUE_BOLD, - fontWeight: fontWeightBold, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, textAlign: 'center', }, @@ -681,7 +680,7 @@ const styles = (theme: ThemeColors) => pickerSmall: (backgroundColor = theme.highlightBG) => ({ inputIOS: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSmall, paddingLeft: 0, paddingRight: 17, @@ -708,7 +707,7 @@ const styles = (theme: ThemeColors) => backgroundColor: theme.highlightBG, }, inputWeb: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSmall, paddingLeft: 0, paddingRight: 17, @@ -723,7 +722,7 @@ const styles = (theme: ThemeColors) => ...cursor.cursorPointer, }, inputAndroid: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSmall, paddingLeft: 0, paddingRight: 17, @@ -858,16 +857,16 @@ const styles = (theme: ThemeColors) => headerAnonymousFooter: { color: theme.heading, - fontFamily: fontFamily.EXP_NEW_KANSAS_MEDIUM, + fontFamily: FontUtils.fontFamily.platform.EXP_NEW_KANSAS_MEDIUM, fontSize: variables.fontSizeXLarge, lineHeight: variables.lineHeightXXLarge, }, headerText: { color: theme.heading, - fontFamily: fontFamily.EXP_NEUE_BOLD, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, fontSize: variables.fontSizeNormal, - fontWeight: fontWeightBold, + fontWeight: FontUtils.fontWeight.bold, }, headerGap: { @@ -886,7 +885,7 @@ const styles = (theme: ThemeColors) => chatItemComposeSecondaryRowSubText: { color: theme.textSupporting, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSmall, lineHeight: variables.lineHeightSmall, }, @@ -983,7 +982,7 @@ const styles = (theme: ThemeColors) => top: 0, fontSize: variables.fontSizeNormal, color: theme.textSupporting, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, width: '100%', }, @@ -1005,7 +1004,7 @@ const styles = (theme: ThemeColors) => } satisfies TextStyle), baseTextInput: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeNormal, lineHeight: variables.lineHeightXLarge, color: theme.text, @@ -1050,7 +1049,7 @@ const styles = (theme: ThemeColors) => borderColor: theme.border, borderWidth: 1, color: theme.text, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeNormal, paddingLeft: 12, paddingRight: 12, @@ -1073,7 +1072,7 @@ const styles = (theme: ThemeColors) => textInputPrefix: { color: theme.text, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeNormal, verticalAlign: 'middle', }, @@ -1150,20 +1149,20 @@ const styles = (theme: ThemeColors) => noOutline: addOutlineWidth(theme, {}, 0), labelStrong: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontWeight: 'bold', fontSize: variables.fontSizeLabel, lineHeight: variables.lineHeightNormal, }, textLabelSupporting: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeLabel, color: theme.textSupporting, }, textLabelError: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeLabel, color: theme.textError, }, @@ -1176,14 +1175,14 @@ const styles = (theme: ThemeColors) => }, subTextReceiptUpload: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, lineHeight: variables.lineHeightLarge, textAlign: 'center', color: theme.text, }, furtherDetailsText: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSmall, color: theme.textSupporting, }, @@ -1386,7 +1385,7 @@ const styles = (theme: ThemeColors) => color: theme.textSupporting, fontSize: variables.fontSizeSmall, textDecorationLine: 'none', - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, lineHeight: 20, }, @@ -1475,7 +1474,7 @@ const styles = (theme: ThemeColors) => }, createMenuHeaderText: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeLabel, color: theme.heading, }, @@ -1573,8 +1572,8 @@ const styles = (theme: ThemeColors) => }, sidebarLinkTextBold: { - fontFamily: fontFamily.EXP_NEUE_BOLD, - fontWeight: fontWeightBold, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, color: theme.heading, }, @@ -1591,7 +1590,7 @@ const styles = (theme: ThemeColors) => }, optionDisplayName: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, minHeight: variables.alternateTextHeight, lineHeight: variables.lineHeightXLarge, ...whiteSpace.noWrap, @@ -1699,7 +1698,6 @@ const styles = (theme: ThemeColors) => }, chatContentScrollView: { - flexGrow: 1, justifyContent: 'flex-end', paddingBottom: 16, }, @@ -1738,9 +1736,9 @@ const styles = (theme: ThemeColors) => chatItemMessageHeaderSender: { color: theme.heading, - fontFamily: fontFamily.EXP_NEUE_BOLD, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, fontSize: variables.fontSizeNormal, - fontWeight: fontWeightBold, + fontWeight: FontUtils.fontWeight.bold, lineHeight: variables.lineHeightXXLarge, ...wordBreak.breakWord, }, @@ -1755,7 +1753,7 @@ const styles = (theme: ThemeColors) => chatItemMessage: { color: theme.text, fontSize: variables.fontSizeNormal, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, lineHeight: variables.lineHeightXLarge, maxWidth: '100%', ...cursor.cursorAuto, @@ -1766,7 +1764,7 @@ const styles = (theme: ThemeColors) => renderHTMLTitle: { color: theme.text, fontSize: variables.fontSizeNormal, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, lineHeight: variables.lineHeightXLarge, maxWidth: '100%', ...whiteSpace.preWrap, @@ -1837,7 +1835,7 @@ const styles = (theme: ThemeColors) => backgroundColor: theme.componentBG, borderColor: theme.border, color: theme.text, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeNormal, borderWidth: 0, height: 'auto', @@ -1903,8 +1901,8 @@ const styles = (theme: ThemeColors) => emojiSkinToneTitle: { ...spacing.pv1, - fontFamily: fontFamily.EXP_NEUE_BOLD, - fontWeight: fontWeightBold, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, color: theme.heading, fontSize: variables.fontSizeSmall, }, @@ -2365,7 +2363,7 @@ const styles = (theme: ThemeColors) => }, twoFactorAuthCode: { - fontFamily: fontFamily.MONOSPACE, + fontFamily: FontUtils.fontFamily.platform.MONOSPACE, width: 112, textAlign: 'center', }, @@ -2413,7 +2411,7 @@ const styles = (theme: ThemeColors) => height: 20, }, anonymousRoomFooterLogoTaglineText: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeMedium, color: theme.text, }, @@ -2454,7 +2452,7 @@ const styles = (theme: ThemeColors) => avatarInnerTextChat: { color: theme.textLight, fontSize: variables.fontSizeXLarge, - fontFamily: fontFamily.EXP_NEW_KANSAS_MEDIUM, + fontFamily: FontUtils.fontFamily.platform.EXP_NEW_KANSAS_MEDIUM, textAlign: 'center', fontWeight: 'normal', position: 'absolute', @@ -2528,9 +2526,9 @@ const styles = (theme: ThemeColors) => unreadIndicatorText: { color: theme.unreadIndicator, - fontFamily: fontFamily.EXP_NEUE_BOLD, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, fontSize: variables.fontSizeSmall, - fontWeight: fontWeightBold, + fontWeight: FontUtils.fontWeight.bold, textTransform: 'capitalize', }, @@ -2850,7 +2848,7 @@ const styles = (theme: ThemeColors) => growlNotificationText: { fontSize: variables.fontSizeNormal, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, width: '90%', lineHeight: variables.fontSizeNormalHeight, color: theme.textReversed, @@ -3082,7 +3080,7 @@ const styles = (theme: ThemeColors) => color: theme.text, fontSize: variables.fontSizeNormal, lineHeight: variables.fontSizeNormalHeight, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, flex: 1, }, @@ -3190,7 +3188,7 @@ const styles = (theme: ThemeColors) => inlineSystemMessage: { color: theme.textSupporting, fontSize: variables.fontSizeLabel, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, marginLeft: 6, }, @@ -3368,7 +3366,7 @@ const styles = (theme: ThemeColors) => validateCodeDigits: { color: theme.text, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeXXLarge, letterSpacing: 4, }, @@ -3418,7 +3416,7 @@ const styles = (theme: ThemeColors) => }, loginHeroHeader: { - fontFamily: fontFamily.EXP_NEW_KANSAS_MEDIUM, + fontFamily: FontUtils.fontFamily.platform.EXP_NEW_KANSAS_MEDIUM, color: theme.success, fontWeight: '500', textAlign: 'center', @@ -3448,28 +3446,28 @@ const styles = (theme: ThemeColors) => }, eReceiptMerchant: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeXLarge, lineHeight: variables.lineHeightXXLarge, color: theme.textColorfulBackground, }, eReceiptWaypointTitle: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSmall, lineHeight: variables.lineHeightSmall, color: colors.green400, }, eReceiptWaypointAddress: { - fontFamily: fontFamily.MONOSPACE, + fontFamily: FontUtils.fontFamily.platform.MONOSPACE, fontSize: variables.fontSizeNormal, lineHeight: variables.lineHeightNormal, color: theme.textColorfulBackground, }, eReceiptGuaranteed: { - fontFamily: fontFamily.MONOSPACE, + fontFamily: FontUtils.fontFamily.platform.MONOSPACE, fontSize: variables.fontSizeSmall, lineHeight: variables.lineHeightSmall, color: theme.textColorfulBackground, @@ -3509,7 +3507,7 @@ const styles = (theme: ThemeColors) => }, loginHeroBody: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSignInHeroBody, color: theme.textLight, textAlign: 'center', @@ -3561,7 +3559,7 @@ const styles = (theme: ThemeColors) => }, taskTitleDescription: { - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeLabel, color: theme.textSupporting, lineHeight: variables.lineHeightNormal, @@ -3579,8 +3577,8 @@ const styles = (theme: ThemeColors) => }, assigneeTextStyle: { - fontFamily: fontFamily.EXP_NEUE_BOLD, - fontWeight: fontWeightBold, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, minHeight: variables.avatarSizeSubscript, }, @@ -3629,7 +3627,7 @@ const styles = (theme: ThemeColors) => headerEnvBadgeText: { fontSize: 7, - fontWeight: fontWeightBold, + fontWeight: FontUtils.fontWeight.bold, lineHeight: undefined, }, @@ -3707,10 +3705,11 @@ const styles = (theme: ThemeColors) => tabText: (isSelected: boolean) => ({ marginLeft: 8, - fontFamily: isSelected ? fontFamily.EXP_NEUE_BOLD : fontFamily.EXP_NEUE, - fontWeight: isSelected ? fontWeightBold : '400', + fontFamily: isSelected ? FontUtils.fontFamily.platform.EXP_NEUE_BOLD : FontUtils.fontFamily.platform.EXP_NEUE, + fontWeight: isSelected ? FontUtils.fontWeight.bold : '400', color: isSelected ? theme.text : theme.textSupporting, - lineHeight: 14, + lineHeight: variables.lineHeightNormal, + fontSize: variables.fontSizeNormal, } satisfies TextStyle), tabBackground: (hovered: boolean, isFocused: boolean, background: string | Animated.AnimatedInterpolation<string>) => ({ @@ -4070,6 +4069,21 @@ const styles = (theme: ThemeColors) => marginBottom: 16, }, + holdRequestInline: { + ...headlineFont, + ...whiteSpace.preWrap, + color: theme.heading, + fontSize: variables.fontSizeXLarge, + lineHeight: variables.lineHeightXXLarge, + + backgroundColor: colors.red, + borderRadius: variables.componentBorderRadiusMedium, + overflow: 'hidden', + + paddingHorizontal: 8, + paddingVertical: 4, + }, + walletCard: { borderRadius: variables.componentBorderRadiusLarge, position: 'relative', @@ -4078,8 +4092,8 @@ const styles = (theme: ThemeColors) => }, walletCardMenuItem: { - fontFamily: fontFamily.EXP_NEUE_BOLD, - fontWeight: fontWeightBold, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, color: theme.text, fontSize: variables.fontSizeNormal, lineHeight: variables.lineHeightXLarge, @@ -4104,7 +4118,7 @@ const styles = (theme: ThemeColors) => walletRedDotSectionTitle: { color: theme.text, - fontWeight: fontWeightBold, + fontWeight: FontUtils.fontWeight.bold, fontSize: variables.fontSizeNormal, lineHeight: variables.lineHeightXLarge, }, diff --git a/src/styles/theme/context/ThemeIllustrationsContext.ts b/src/styles/theme/context/ThemeIllustrationsContext.ts index f762a35b0800..92e6d9514dcd 100644 --- a/src/styles/theme/context/ThemeIllustrationsContext.ts +++ b/src/styles/theme/context/ThemeIllustrationsContext.ts @@ -1,7 +1,8 @@ import React from 'react'; -import {DefaultIllustrations} from '@styles/theme/illustrations'; -import type {IllustrationsType} from '@styles/theme/illustrations/types'; +// eslint-disable-next-line no-restricted-imports +import {defaultIllustrations} from '@styles/theme/illustrations'; +import type IllustrationsType from '@styles/theme/illustrations/types'; -const ThemeIllustrationsContext = React.createContext<IllustrationsType>(DefaultIllustrations); +const ThemeIllustrationsContext = React.createContext<IllustrationsType>(defaultIllustrations); export default ThemeIllustrationsContext; diff --git a/src/styles/theme/context/ThemeStylesContext.ts b/src/styles/theme/context/ThemeStylesContext.ts index a1c81bbd9c7c..0c7137d206a5 100644 --- a/src/styles/theme/context/ThemeStylesContext.ts +++ b/src/styles/theme/context/ThemeStylesContext.ts @@ -1,6 +1,8 @@ import React from 'react'; +// eslint-disable-next-line no-restricted-imports import {defaultStyles} from '@styles/index'; import type {ThemeStyles} from '@styles/index'; +// eslint-disable-next-line no-restricted-imports import {DefaultStyleUtils} from '@styles/utils'; import type {StyleUtilsType} from '@styles/utils'; diff --git a/src/styles/theme/illustrations/index.ts b/src/styles/theme/illustrations/index.ts index 2686f4205351..abcb20a22d78 100644 --- a/src/styles/theme/illustrations/index.ts +++ b/src/styles/theme/illustrations/index.ts @@ -2,14 +2,14 @@ import type {ThemePreferenceWithoutSystem} from '@styles/theme/types'; import CONST from '@src/CONST'; import darkIllustrations from './themes/dark'; import lightIllustrations from './themes/light'; -import type {IllustrationsType} from './types'; +import type IllustrationsType from './types'; -const Illustrations = { +const illustrations = { [CONST.THEME.LIGHT]: lightIllustrations, [CONST.THEME.DARK]: darkIllustrations, } satisfies Record<ThemePreferenceWithoutSystem, IllustrationsType>; -const DefaultIllustrations = Illustrations[CONST.THEME.FALLBACK]; +const defaultIllustrations = illustrations[CONST.THEME.FALLBACK]; -export default Illustrations; -export {DefaultIllustrations}; +export default illustrations; +export {defaultIllustrations}; diff --git a/src/styles/theme/illustrations/themes/dark.ts b/src/styles/theme/illustrations/themes/dark.ts index 2920081ac137..e24827e8c9eb 100644 --- a/src/styles/theme/illustrations/themes/dark.ts +++ b/src/styles/theme/illustrations/themes/dark.ts @@ -1,7 +1,7 @@ import EmptyStateBackgroundImage from '@assets/images/themeDependent/empty-state_background-fade-dark.png'; import ExampleCheckEN from '@assets/images/themeDependent/example-check-image-dark-en.png'; import ExampleCheckES from '@assets/images/themeDependent/example-check-image-dark-es.png'; -import type {IllustrationsType} from '@styles/theme/illustrations/types'; +import type IllustrationsType from '@styles/theme/illustrations/types'; const illustrations = { EmptyStateBackgroundImage, diff --git a/src/styles/theme/illustrations/themes/light.ts b/src/styles/theme/illustrations/themes/light.ts index 6eb9c11851e4..cc8dc39806e1 100644 --- a/src/styles/theme/illustrations/themes/light.ts +++ b/src/styles/theme/illustrations/themes/light.ts @@ -1,7 +1,7 @@ import EmptyStateBackgroundImage from '@assets/images/themeDependent/empty-state_background-fade-light.png'; import ExampleCheckEN from '@assets/images/themeDependent/example-check-image-light-en.png'; import ExampleCheckES from '@assets/images/themeDependent/example-check-image-light-es.png'; -import type {IllustrationsType} from '@styles/theme/illustrations/types'; +import type IllustrationsType from '@styles/theme/illustrations/types'; const illustrations = { EmptyStateBackgroundImage, diff --git a/src/styles/theme/illustrations/types.ts b/src/styles/theme/illustrations/types.ts index b37ff17965e2..810e3ad2673a 100644 --- a/src/styles/theme/illustrations/types.ts +++ b/src/styles/theme/illustrations/types.ts @@ -6,5 +6,4 @@ type IllustrationsType = { ExampleCheckEN: ImageSourcePropType; }; -// eslint-disable-next-line import/prefer-default-export -export {type IllustrationsType}; +export default IllustrationsType; diff --git a/src/styles/utils/fontFamily/bold/index.android.ts b/src/styles/utils/FontUtils/fontFamily/bold/index.android.ts similarity index 100% rename from src/styles/utils/fontFamily/bold/index.android.ts rename to src/styles/utils/FontUtils/fontFamily/bold/index.android.ts diff --git a/src/styles/utils/fontFamily/bold/index.ios.ts b/src/styles/utils/FontUtils/fontFamily/bold/index.ios.ts similarity index 100% rename from src/styles/utils/fontFamily/bold/index.ios.ts rename to src/styles/utils/FontUtils/fontFamily/bold/index.ios.ts diff --git a/src/styles/utils/fontFamily/bold/index.ts b/src/styles/utils/FontUtils/fontFamily/bold/index.ts similarity index 100% rename from src/styles/utils/fontFamily/bold/index.ts rename to src/styles/utils/FontUtils/fontFamily/bold/index.ts diff --git a/src/styles/utils/fontFamily/bold/types.ts b/src/styles/utils/FontUtils/fontFamily/bold/types.ts similarity index 100% rename from src/styles/utils/fontFamily/bold/types.ts rename to src/styles/utils/FontUtils/fontFamily/bold/types.ts diff --git a/src/styles/utils/fontFamily/index.native.ts b/src/styles/utils/FontUtils/fontFamily/index.native.ts similarity index 100% rename from src/styles/utils/fontFamily/index.native.ts rename to src/styles/utils/FontUtils/fontFamily/index.native.ts diff --git a/src/styles/utils/fontFamily/index.ts b/src/styles/utils/FontUtils/fontFamily/index.ts similarity index 100% rename from src/styles/utils/fontFamily/index.ts rename to src/styles/utils/FontUtils/fontFamily/index.ts diff --git a/src/styles/utils/fontFamily/multiFontFamily.ts b/src/styles/utils/FontUtils/fontFamily/multiFontFamily.ts similarity index 100% rename from src/styles/utils/fontFamily/multiFontFamily.ts rename to src/styles/utils/FontUtils/fontFamily/multiFontFamily.ts diff --git a/src/styles/utils/fontFamily/singleFontFamily.ts b/src/styles/utils/FontUtils/fontFamily/singleFontFamily.ts similarity index 100% rename from src/styles/utils/fontFamily/singleFontFamily.ts rename to src/styles/utils/FontUtils/fontFamily/singleFontFamily.ts diff --git a/src/styles/utils/fontFamily/types.ts b/src/styles/utils/FontUtils/fontFamily/types.ts similarity index 100% rename from src/styles/utils/fontFamily/types.ts rename to src/styles/utils/FontUtils/fontFamily/types.ts diff --git a/src/styles/utils/fontWeight/bold/index.android.ts b/src/styles/utils/FontUtils/fontWeight/bold/index.android.ts similarity index 100% rename from src/styles/utils/fontWeight/bold/index.android.ts rename to src/styles/utils/FontUtils/fontWeight/bold/index.android.ts diff --git a/src/styles/utils/fontWeight/bold/index.ts b/src/styles/utils/FontUtils/fontWeight/bold/index.ts similarity index 100% rename from src/styles/utils/fontWeight/bold/index.ts rename to src/styles/utils/FontUtils/fontWeight/bold/index.ts diff --git a/src/styles/utils/fontWeight/bold/types.ts b/src/styles/utils/FontUtils/fontWeight/bold/types.ts similarity index 100% rename from src/styles/utils/fontWeight/bold/types.ts rename to src/styles/utils/FontUtils/fontWeight/bold/types.ts diff --git a/src/styles/utils/FontUtils/index.ts b/src/styles/utils/FontUtils/index.ts new file mode 100644 index 000000000000..ac07fdbf026e --- /dev/null +++ b/src/styles/utils/FontUtils/index.ts @@ -0,0 +1,20 @@ +import fontFamily from './fontFamily'; +import multiFontFamily from './fontFamily/multiFontFamily'; +import singleFontFamily from './fontFamily/singleFontFamily'; +import fontWeightBold from './fontWeight/bold'; + +const FontUtils = { + fontFamily: { + platform: fontFamily, + single: singleFontFamily, + multi: multiFontFamily, + }, + fontWeight: { + bold: fontWeightBold, + }, +}; + +type FontUtilsType = typeof FontUtils; + +export default FontUtils; +export type {FontUtilsType}; diff --git a/src/styles/utils/cardStyles/index.ts b/src/styles/utils/cardStyles/index.ts index 3a1f778f36f4..af3e465653b8 100644 --- a/src/styles/utils/cardStyles/index.ts +++ b/src/styles/utils/cardStyles/index.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import positioning from '@styles/utils/positioning'; import type GetCardStyles from './types'; diff --git a/src/styles/utils/editedLabelStyles/index.ts b/src/styles/utils/editedLabelStyles/index.ts index 9397966a88a5..0be5e94b3000 100644 --- a/src/styles/utils/editedLabelStyles/index.ts +++ b/src/styles/utils/editedLabelStyles/index.ts @@ -1,4 +1,6 @@ +// eslint-disable-next-line no-restricted-imports import display from '@styles/utils/display'; +// eslint-disable-next-line no-restricted-imports import flex from '@styles/utils/flex'; import type EditedLabelStyles from './types'; diff --git a/src/styles/utils/generators/TooltipStyleUtils.ts b/src/styles/utils/generators/TooltipStyleUtils.ts index 90cef9a6f568..5bd72928df2b 100644 --- a/src/styles/utils/generators/TooltipStyleUtils.ts +++ b/src/styles/utils/generators/TooltipStyleUtils.ts @@ -1,8 +1,10 @@ import type {TextStyle, View, ViewStyle} from 'react-native'; import {Animated} from 'react-native'; -import fontFamily from '@styles/utils/fontFamily'; +import roundToNearestMultipleOfFour from '@libs/roundToNearestMultipleOfFour'; +import FontUtils from '@styles/utils/FontUtils'; +// eslint-disable-next-line no-restricted-imports import positioning from '@styles/utils/positioning'; -import roundToNearestMultipleOfFour from '@styles/utils/roundToNearestMultipleOfFour'; +// eslint-disable-next-line no-restricted-imports import spacing from '@styles/utils/spacing'; import variables from '@styles/variables'; import type StyleUtilGenerator from './types'; @@ -272,7 +274,7 @@ const createTooltipStyleUtils: StyleUtilGenerator<GetTooltipStylesStyleUtil> = ( }, textStyle: { color: theme.textReversed, - fontFamily: fontFamily.EXP_NEUE, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSmall, overflow: 'hidden', lineHeight: variables.lineHeightSmall, diff --git a/src/styles/utils/getNavigationModalCardStyles/index.desktop.ts b/src/styles/utils/getNavigationModalCardStyles/index.desktop.ts index c7a469cbd114..df47e76379c5 100644 --- a/src/styles/utils/getNavigationModalCardStyles/index.desktop.ts +++ b/src/styles/utils/getNavigationModalCardStyles/index.desktop.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import positioning from '@styles/utils/positioning'; import type GetNavigationModalCardStyles from './types'; diff --git a/src/styles/utils/getNavigationModalCardStyles/index.website.ts b/src/styles/utils/getNavigationModalCardStyles/index.website.ts index c7a469cbd114..df47e76379c5 100644 --- a/src/styles/utils/getNavigationModalCardStyles/index.website.ts +++ b/src/styles/utils/getNavigationModalCardStyles/index.website.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import positioning from '@styles/utils/positioning'; import type GetNavigationModalCardStyles from './types'; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index b3b4924ebb19..e2d237c6bbae 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -4,6 +4,7 @@ import type {EdgeInsets} from 'react-native-safe-area-context'; import type {ValueOf} from 'type-fest'; import * as Browser from '@libs/Browser'; import * as UserUtils from '@libs/UserUtils'; +// eslint-disable-next-line no-restricted-imports import {defaultTheme} from '@styles/theme'; import colors from '@styles/theme/colors'; import type {ThemeColors} from '@styles/theme/types'; @@ -12,12 +13,14 @@ import CONST from '@src/CONST'; import type {Transaction} from '@src/types/onyx'; import {defaultStyles} from '..'; import type {ThemeStyles} from '..'; +import getCardStyles from './cardStyles'; import containerComposeStyles from './containerComposeStyles'; -import fontFamily from './fontFamily'; +import FontUtils from './FontUtils'; import createModalStyleUtils from './generators/ModalStyleUtils'; import createReportActionContextMenuStyleUtils from './generators/ReportActionContextMenuStyleUtils'; import createTooltipStyleUtils from './generators/TooltipStyleUtils'; import getContextMenuItemStyles from './getContextMenuItemStyles'; +import getNavigationModalCardStyle from './getNavigationModalCardStyles'; import {compactContentContainerStyles} from './optionRowStyles'; import positioning from './positioning'; import type { @@ -518,11 +521,11 @@ function getModalPaddingStyles({ * Takes fontStyle and fontWeight and returns the correct fontFamily */ function getFontFamilyMonospace({fontStyle, fontWeight}: TextStyle): string { - const italic = fontStyle === 'italic' && fontFamily.MONOSPACE_ITALIC; - const bold = fontWeight === 'bold' && fontFamily.MONOSPACE_BOLD; - const italicBold = italic && bold && fontFamily.MONOSPACE_BOLD_ITALIC; + const italic = fontStyle === 'italic' && FontUtils.fontFamily.platform.MONOSPACE_ITALIC; + const bold = fontWeight === 'bold' && FontUtils.fontFamily.platform.MONOSPACE_BOLD; + const italicBold = italic && bold && FontUtils.fontFamily.platform.MONOSPACE_BOLD_ITALIC; - return italicBold || bold || italic || fontFamily.MONOSPACE; + return italicBold || bold || italic || FontUtils.fontFamily.platform.MONOSPACE; } /** * Returns the font size for the HTML code tag renderer. @@ -1005,6 +1008,7 @@ function getTransparentColor(color: string) { } const staticStyleUtils = { + positioning, combineStyles, displayIfTrue, getAmountFontSizeAndLineHeight, @@ -1064,6 +1068,8 @@ const staticStyleUtils = { parseStyleFromFunction, getEReceiptColorStyles, getEReceiptColorCode, + getNavigationModalCardStyle, + getCardStyles, }; const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 27d70529dd8a..6def4858229f 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -223,6 +223,10 @@ export default { marginTop: 'auto', }, + mtn2: { + marginTop: -8, + }, + mtn6: { marginTop: -24, }, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 4d717389cdb6..08a89526e4c3 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -142,7 +142,7 @@ export default { signInLogoWidthLargeScreen: 144, signInLogoHeightLargeScreen: 108, signInLogoWidthPill: 132, - tabSelectorButtonHeight: 40, + tabSelectorButtonHeight: 42, tabSelectorButtonPadding: 12, lhnLogoWidth: 95.09, lhnLogoHeight: 22.33, @@ -196,4 +196,5 @@ export default { cardPreviewHeight: 148, cardPreviewWidth: 235, cardNameWidth: 156, + holdMenuIconSize: 64, } as const; diff --git a/src/types/onyx/FrequentlyUsedEmoji.ts b/src/types/onyx/FrequentlyUsedEmoji.ts index 333721b25b52..c8f6a5179fc6 100644 --- a/src/types/onyx/FrequentlyUsedEmoji.ts +++ b/src/types/onyx/FrequentlyUsedEmoji.ts @@ -12,7 +12,7 @@ type FrequentlyUsedEmoji = { lastUpdatedAt: number; /** The emoji skin tone type */ - types?: string[]; + types?: readonly string[]; /** The emoji keywords */ keywords?: string[]; diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts index b6dfb7bbab9a..03d5877bc5b5 100644 --- a/src/types/onyx/PolicyCategory.ts +++ b/src/types/onyx/PolicyCategory.ts @@ -20,5 +20,5 @@ type PolicyCategory = { }; type PolicyCategories = Record<string, PolicyCategory>; -export default PolicyCategory; -export type {PolicyCategories}; + +export type {PolicyCategory, PolicyCategories}; diff --git a/src/types/onyx/PolicyTag.ts b/src/types/onyx/PolicyTag.ts index 7807dcc00433..58a21dcf4df5 100644 --- a/src/types/onyx/PolicyTag.ts +++ b/src/types/onyx/PolicyTag.ts @@ -12,5 +12,4 @@ type PolicyTag = { type PolicyTags = Record<string, PolicyTag>; -export default PolicyTag; -export type {PolicyTags}; +export type {PolicyTag, PolicyTags}; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 840bbd1e2e2f..f6af87038d00 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -120,6 +120,7 @@ type Report = { lastActorAccountID?: number; ownerAccountID?: number; participantAccountIDs?: number[]; + visibleChatMemberAccountIDs?: number[]; total?: number; currency?: string; parentReportActionIDs?: number[]; diff --git a/src/types/onyx/ReportActionReactions.ts b/src/types/onyx/ReportActionReactions.ts index be117aafc4c5..0173fcf244f5 100644 --- a/src/types/onyx/ReportActionReactions.ts +++ b/src/types/onyx/ReportActionReactions.ts @@ -24,7 +24,7 @@ type ReportActionReaction = { users: UsersReactions; /** Is this action pending? */ - pendingAction?: OnyxCommon.PendingAction; + pendingAction: OnyxCommon.PendingAction; }; type ReportActionReactions = Record<string, ReportActionReaction>; diff --git a/src/types/onyx/TransactionViolation.ts b/src/types/onyx/TransactionViolation.ts index f7bc5ea1ee8b..dd7a9ef65746 100644 --- a/src/types/onyx/TransactionViolation.ts +++ b/src/types/onyx/TransactionViolation.ts @@ -1,46 +1,34 @@ +import type CONST from '@src/CONST'; + /** - * Names of transaction violations + * Names of violations. + * Derived from `CONST.VIOLATIONS` to maintain a single source of truth. */ -type ViolationName = - | 'allTagLevelsRequired' - | 'autoReportedRejectedExpense' - | 'billableExpense' - | 'cashExpenseWithNoReceipt' - | 'categoryOutOfPolicy' - | 'conversionSurcharge' - | 'customUnitOutOfPolicy' - | 'duplicatedTransaction' - | 'fieldRequired' - | 'futureDate' - | 'invoiceMarkup' - | 'maxAge' - | 'missingCategory' - | 'missingComment' - | 'missingTag' - | 'modifiedAmount' - | 'modifiedDate' - | 'nonExpensiworksExpense' - | 'overAutoApprovalLimit' - | 'overCategoryLimit' - | 'overLimit' - | 'overLimitAttendee' - | 'perDayLimit' - | 'receiptNotSmartScanned' - | 'receiptRequired' - | 'rter' - | 'smartscanFailed' - | 'someTagLevelsRequired' - | 'tagOutOfPolicy' - | 'taxAmountChanged' - | 'taxOutOfPolicy' - | 'taxRateChanged' - | 'taxRequired'; +type ViolationName = (typeof CONST.VIOLATIONS)[keyof typeof CONST.VIOLATIONS]; type TransactionViolation = { type: string; name: ViolationName; userMessage: string; - data?: Record<string, string>; + data?: { + rejectedBy?: string; + rejectReason?: string; + amount?: string; + surcharge?: number; + invoiceMarkup?: number; + maxAge?: number; + tagName?: string; + formattedLimitAmount?: string; + categoryLimit?: string; + limit?: string; + category?: string; + brokenBankConnection?: boolean; + isAdmin?: boolean; + email?: string; + isTransactionOlderThan7Days?: boolean; + member?: string; + taxName?: string; + }; }; export type {TransactionViolation, ViolationName}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index de71202dcc2a..7bd9c321be5e 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -27,13 +27,11 @@ import type {PersonalDetailsList} from './PersonalDetails'; import type PersonalDetails from './PersonalDetails'; import type PlaidData from './PlaidData'; import type Policy from './Policy'; -import type {PolicyCategories} from './PolicyCategory'; -import type PolicyCategory from './PolicyCategory'; +import type {PolicyCategories, PolicyCategory} from './PolicyCategory'; import type {PolicyMembers} from './PolicyMember'; import type PolicyMember from './PolicyMember'; import type PolicyReportField from './PolicyReportField'; -import type {PolicyTags} from './PolicyTag'; -import type PolicyTag from './PolicyTag'; +import type {PolicyTag, PolicyTags} from './PolicyTag'; import type PrivatePersonalDetails from './PrivatePersonalDetails'; import type RecentlyUsedCategories from './RecentlyUsedCategories'; import type RecentlyUsedReportFields from './RecentlyUsedReportFields'; diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 4d9ce42a08ce..0a1ac84d52f9 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -2238,6 +2238,86 @@ describe('actions/IOU', () => { expect(report).toBeFalsy(); }); + it('delete the transaction thread if there are only changelogs (i.e. MODIFIEDEXPENSE actions) in the thread', async () => { + // Given all promises are resolved + await waitForBatchedUpdates(); + jest.advanceTimersByTime(10); + + // Given a transaction thread + thread = ReportUtils.buildTransactionThread(createIOUAction, IOU_REPORT_ID); + + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, + callback: (val) => (reportActions = val), + }); + + await waitForBatchedUpdates(); + + jest.advanceTimersByTime(10); + + // Given User logins from the participant accounts + const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); + + // When Opening a thread report with the given details + Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + await waitForBatchedUpdates(); + + // Then The iou action has the transaction report id as a child report ID + const allReportActions = await new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + Onyx.disconnect(connectionID); + resolve(actions); + }, + }); + }); + const reportActionsForIOUReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.iouReportID}`]; + createIOUAction = _.find(reportActionsForIOUReport, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(createIOUAction.childReportID).toBe(thread.reportID); + + await waitForBatchedUpdates(); + + jest.advanceTimersByTime(10); + IOU.editMoneyRequest(transaction, thread.reportID, {amount: 20000, comment: 'Double the amount!'}); + await waitForBatchedUpdates(); + + // Verify there are two actions (created + changelog) + expect(_.size(reportActions)).toBe(2); + + // Fetch the updated IOU Action from Onyx + await new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + waitForCollectionCallback: true, + callback: (reportActionsForReport) => { + Onyx.disconnect(connectionID); + createIOUAction = _.find(reportActionsForReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + resolve(); + }, + }); + }); + + // When Deleting a money request + IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, false); + await waitForBatchedUpdates(); + + // Then, the report for the given thread ID does not exist + const report = await new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, + waitForCollectionCallback: true, + callback: (reportData) => { + Onyx.disconnect(connectionID); + resolve(reportData); + }, + }); + }); + + expect(report).toBeFalsy(); + }); + it('does not delete the transaction thread if there are visible comments in the thread', async () => { // Given initial environment is set up await waitForBatchedUpdates(); @@ -2369,6 +2449,20 @@ describe('actions/IOU', () => { Report.addComment(thread.reportID, 'Testing a comment'); await waitForBatchedUpdates(); + // Fetch the updated IOU Action from Onyx due to addition of comment to transaction thread. + // This needs to be fetched as `deleteMoneyRequest` depends on `childVisibleActionCount` in `createIOUAction`. + await new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + waitForCollectionCallback: true, + callback: (reportActionsForReport) => { + Onyx.disconnect(connectionID); + createIOUAction = _.find(reportActionsForReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + resolve(); + }, + }); + }); + let resultAction = _.find(reportActions, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT); reportActionID = resultAction.reportActionID; diff --git a/tests/perf-test/ReportScreen.perf-test.js b/tests/perf-test/ReportScreen.perf-test.js index a82903762631..88f3fe99b347 100644 --- a/tests/perf-test/ReportScreen.perf-test.js +++ b/tests/perf-test/ReportScreen.perf-test.js @@ -153,7 +153,7 @@ function ReportScreenWrapper(args) { const runs = CONST.PERFORMANCE_TESTS.RUNS; -test('[ReportScreen] should render ReportScreen with composer interactions', () => { +test.skip('[ReportScreen] should render ReportScreen with composer interactions', () => { const {triggerTransitionEnd, addListener} = createAddListenerMock(); const scenario = async () => { /** @@ -226,7 +226,7 @@ test('[ReportScreen] should render ReportScreen with composer interactions', () ); }); -test('[ReportScreen] should press of the report item', () => { +test.skip('[ReportScreen] should press of the report item', () => { const {triggerTransitionEnd, addListener} = createAddListenerMock(); const scenario = async () => { /** diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js index f4126ff34313..e4d4d877f66b 100644 --- a/tests/ui/UnreadIndicatorsTest.js +++ b/tests/ui/UnreadIndicatorsTest.js @@ -5,6 +5,7 @@ import lodashGet from 'lodash/get'; import React from 'react'; import {AppState, DeviceEventEmitter, Linking} from 'react-native'; import Onyx from 'react-native-onyx'; +import FontUtils from '@styles/utils/FontUtils'; import App from '../../src/App'; import CONFIG from '../../src/CONFIG'; import CONST from '../../src/CONST'; @@ -20,7 +21,6 @@ import * as Pusher from '../../src/libs/Pusher/pusher'; import PusherConnectionManager from '../../src/libs/PusherConnectionManager'; import ONYXKEYS from '../../src/ONYXKEYS'; import appSetup from '../../src/setup'; -import fontWeightBold from '../../src/styles/utils/fontWeight/bold'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -294,7 +294,7 @@ describe('Unread Indicators', () => { // And that the text is bold const displayNameHintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNameText = screen.queryByLabelText(displayNameHintText); - expect(lodashGet(displayNameText, ['props', 'style', 'fontWeight'])).toBe(fontWeightBold); + expect(lodashGet(displayNameText, ['props', 'style', 'fontWeight'])).toBe(FontUtils.fontWeight.bold); return navigateToSidebarOption(0); }) @@ -437,11 +437,11 @@ describe('Unread Indicators', () => { const displayNameTexts = screen.queryAllByLabelText(displayNameHintTexts); expect(displayNameTexts).toHaveLength(2); const firstReportOption = displayNameTexts[0]; - expect(lodashGet(firstReportOption, ['props', 'style', 'fontWeight'])).toBe(fontWeightBold); + expect(lodashGet(firstReportOption, ['props', 'style', 'fontWeight'])).toBe(FontUtils.fontWeight.bold); expect(lodashGet(firstReportOption, ['props', 'children'])).toBe('C User'); const secondReportOption = displayNameTexts[1]; - expect(lodashGet(secondReportOption, ['props', 'style', 'fontWeight'])).toBe(fontWeightBold); + expect(lodashGet(secondReportOption, ['props', 'style', 'fontWeight'])).toBe(FontUtils.fontWeight.bold); expect(lodashGet(secondReportOption, ['props', 'children'])).toBe('B User'); // Tap the new report option and navigate back to the sidebar again via the back button @@ -456,7 +456,7 @@ describe('Unread Indicators', () => { expect(displayNameTexts).toHaveLength(2); expect(lodashGet(displayNameTexts[0], ['props', 'style', 'fontWeight'])).toBe(undefined); expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('C User'); - expect(lodashGet(displayNameTexts[1], ['props', 'style', 'fontWeight'])).toBe(fontWeightBold); + expect(lodashGet(displayNameTexts[1], ['props', 'style', 'fontWeight'])).toBe(FontUtils.fontWeight.bold); expect(lodashGet(displayNameTexts[1], ['props', 'children'])).toBe('B User'); })); @@ -488,7 +488,7 @@ describe('Unread Indicators', () => { const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNameTexts = screen.queryAllByLabelText(hintText); expect(displayNameTexts).toHaveLength(1); - expect(lodashGet(displayNameTexts[0], ['props', 'style', 'fontWeight'])).toBe(fontWeightBold); + expect(lodashGet(displayNameTexts[0], ['props', 'style', 'fontWeight'])).toBe(FontUtils.fontWeight.bold); expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('B User'); // Navigate to the report again and back to the sidebar diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js index 9ac0186d2396..961df1fa3e90 100644 --- a/tests/unit/OptionsListUtilsTest.js +++ b/tests/unit/OptionsListUtilsTest.js @@ -16,6 +16,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 1, participantAccountIDs: [2, 1], + visibleChatMemberAccountIDs: [2, 1], reportName: 'Iron Man, Mister Fantastic', hasDraft: true, type: CONST.REPORT.TYPE.CHAT, @@ -26,6 +27,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 2, participantAccountIDs: [3], + visibleChatMemberAccountIDs: [3], reportName: 'Spider-Man', type: CONST.REPORT.TYPE.CHAT, }, @@ -37,6 +39,7 @@ describe('OptionsListUtils', () => { isPinned: true, reportID: 3, participantAccountIDs: [1], + visibleChatMemberAccountIDs: [1], reportName: 'Mister Fantastic', type: CONST.REPORT.TYPE.CHAT, }, @@ -46,6 +49,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 4, participantAccountIDs: [4], + visibleChatMemberAccountIDs: [4], reportName: 'Black Panther', type: CONST.REPORT.TYPE.CHAT, }, @@ -55,6 +59,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 5, participantAccountIDs: [5], + visibleChatMemberAccountIDs: [5], reportName: 'Invisible Woman', type: CONST.REPORT.TYPE.CHAT, }, @@ -64,6 +69,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 6, participantAccountIDs: [6], + visibleChatMemberAccountIDs: [6], reportName: 'Thor', type: CONST.REPORT.TYPE.CHAT, }, @@ -75,6 +81,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 7, participantAccountIDs: [7], + visibleChatMemberAccountIDs: [7], reportName: 'Captain America', type: CONST.REPORT.TYPE.CHAT, }, @@ -86,6 +93,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 8, participantAccountIDs: [12], + visibleChatMemberAccountIDs: [12], reportName: 'Silver Surfer', type: CONST.REPORT.TYPE.CHAT, }, @@ -97,6 +105,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 9, participantAccountIDs: [8], + visibleChatMemberAccountIDs: [8], reportName: 'Mister Sinister', iouReportID: 100, type: CONST.REPORT.TYPE.CHAT, @@ -109,6 +118,7 @@ describe('OptionsListUtils', () => { reportID: 10, isPinned: false, participantAccountIDs: [2, 7], + visibleChatMemberAccountIDs: [2, 7], reportName: '', oldPolicyName: "SHIELD's workspace", chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, @@ -187,6 +197,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 11, participantAccountIDs: [999], + visibleChatMemberAccountIDs: [999], reportName: 'Concierge', type: CONST.REPORT.TYPE.CHAT, }, @@ -200,6 +211,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 12, participantAccountIDs: [1000], + visibleChatMemberAccountIDs: [1000], reportName: 'Chronos', type: CONST.REPORT.TYPE.CHAT, }, @@ -213,6 +225,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 13, participantAccountIDs: [1001], + visibleChatMemberAccountIDs: [1001], reportName: 'Receipts', type: CONST.REPORT.TYPE.CHAT, }, @@ -226,6 +239,7 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: 14, participantAccountIDs: [1, 10, 3], + visibleChatMemberAccountIDs: [1, 10, 3], reportName: '', oldPolicyName: 'Avengers Room', isArchivedRoom: false,