diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 02f4af653ca6..24f3b6aa9aeb 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -264,11 +264,11 @@ jobs: - name: Deploy production to S3 if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist s3://expensify-cash/ && aws s3 cp --content-type 'application/json' --metadata-directive REPLACE s3://expensify-cash/.well-known/apple-app-site-association s3://expensify-cash/.well-known/apple-app-site-association + run: aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist s3://expensify-cash/ && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://expensify-cash/.well-known/apple-app-site-association s3://expensify-cash/.well-known/apple-app-site-association - name: Deploy staging to S3 if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist s3://staging-expensify-cash/ && aws s3 cp --content-type 'application/json' --metadata-directive REPLACE s3://staging-expensify-cash/.well-known/apple-app-site-association s3://staging-expensify-cash/.well-known/apple-app-site-association + run: aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist s3://staging-expensify-cash/ && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://staging-expensify-cash/.well-known/apple-app-site-association s3://staging-expensify-cash/.well-known/apple-app-site-association - name: Purge production Cloudflare cache if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index ef37d1266930..6b068c9f6f8e 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -290,6 +290,6 @@ jobs: IOS: ${{ needs.iOS.result }} WEB: ${{ needs.web.result }} ANDROID_LINK: ${{fromJson(steps.get_android_path.outputs.android_paths).html_path}} - DESKTOP_LINK: "https://ad-hoc-expensify-cash.s3.amazonaws.com/desktop/$PULL_REQUEST_NUMBER/NewExpensify.dmg" + DESKTOP_LINK: https://ad-hoc-expensify-cash.s3.amazonaws.com/desktop/${{ env.PULL_REQUEST_NUMBER }}/NewExpensify.dmg IOS_LINK: ${{ fromJson(steps.get_ios_path.outputs.ios_paths).html_path }} - WEB_LINK: "https://$PULL_REQUEST_NUMBER.pr-testing.expensify.com" + WEB_LINK: https://${{ env.PULL_REQUEST_NUMBER }}.pr-testing.expensify.com diff --git a/android/app/build.gradle b/android/app/build.gradle index a0f9f25292d2..fc33dbe46373 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -156,8 +156,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001028002 - versionName "1.2.80-2" + versionCode 1001028100 + versionName "1.2.81-0" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() if (isNewArchitectureEnabled()) { diff --git a/assets/images/add-reaction.svg b/assets/images/add-reaction.svg new file mode 100644 index 000000000000..a576e2c84622 --- /dev/null +++ b/assets/images/add-reaction.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 9f319b4fcb79..46cd23113d25 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2.80 + 1.2.81 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.2.80.2 + 1.2.81.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 419a9a37a8b4..fc7e0ae14189 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.2.80 + 1.2.81 CFBundleSignature ???? CFBundleVersion - 1.2.80.2 + 1.2.81.0 diff --git a/package-lock.json b/package-lock.json index 0b4897abca4f..e1b55c1a17e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.2.80-2", + "version": "1.2.81-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.2.80-2", + "version": "1.2.81-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4b46717518d2..5dcbbb04bc81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.2.80-2", + "version": "1.2.81-0", "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.", diff --git a/src/CONST.js b/src/CONST.js index b8c26f163376..80f8ed3ccd59 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -823,6 +823,10 @@ const CONST = { EMOJIS: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, TAX_ID: /^\d{9}$/, NON_NUMERIC: /\D/g, + + // Extract attachment's source from the data's html string + ATTACHMENT_DATA: /(data-expensify-source|data-name)="([^"]+)"/g, + NON_NUMERIC_WITH_PLUS: /[^0-9+]/g, EMOJI_NAME: /:[\w+-]+:/g, EMOJI_SUGGESTIONS: /:[a-zA-Z0-9_+-]{1,40}$/, @@ -973,261 +977,293 @@ const CONST = { MAKE_REQUEST_WITH_SIDE_EFFECTS: 'makeRequestWithSideEffects', }, + QUICK_REACTIONS: [ + { + name: '+1', + code: '👍', + types: [ + '👍🏿', + '👍🏾', + '👍🏽', + '👍🏼', + '👍🏻', + ], + }, + { + name: 'heart', + code: '❤️', + }, + { + name: 'joy', + code: '😂', + }, + { + name: 'fire', + code: '🔥', + }, + ], + TFA_CODE_LENGTH: 6, CHAT_ATTACHMENT_TOKEN_KEY: 'X-Chat-Attachment-Token', USA_COUNTRY_NAME, - ALL_COUNTRIES: [ - 'Afghanistan', - 'Aland Islands', - 'Albania', - 'Algeria', - 'American Samoa', - 'Andorra', - 'Angola', - 'Anguilla', - 'Antarctica', - 'Antigua and Barbuda', - 'Argentina', - 'Armenia', - 'Aruba', - 'Australia', - 'Austria', - 'Azerbaijan', - 'Bahamas', - 'Bahrain', - 'Bangladesh', - 'Barbados', - 'Belarus', - 'Belgium', - 'Belize', - 'Benin', - 'Bermuda', - 'Bhutan', - 'Bolivia', - 'Bonaire, Saint Eustatius and Saba ', - 'Bosnia and Herzegovina', - 'Botswana', - 'Bouvet Island', - 'Brazil', - 'British Indian Ocean Territory', - 'British Virgin Islands', - 'Brunei', - 'Bulgaria', - 'Burkina Faso', - 'Burundi', - 'Cambodia', - 'Cameroon', - 'Canada', - 'Cape Verde', - 'Cayman Islands', - 'Central African Republic', - 'Chad', - 'Chile', - 'China', - 'Christmas Island', - 'Cocos Islands', - 'Colombia', - 'Comoros', - 'Cook Islands', - 'Costa Rica', - 'Croatia', - 'Cuba', - 'Curacao', - 'Cyprus', - 'Czech Republic', - 'Democratic Republic of the Congo', - 'Denmark', - 'Djibouti', - 'Dominica', - 'Dominican Republic', - 'East Timor', - 'Ecuador', - 'Egypt', - 'El Salvador', - 'Equatorial Guinea', - 'Eritrea', - 'Estonia', - 'Ethiopia', - 'Falkland Islands', - 'Faroe Islands', - 'Fiji', - 'Finland', - 'France', - 'French Guiana', - 'French Polynesia', - 'French Southern Territories', - 'Gabon', - 'Gambia', - 'Georgia', - 'Germany', - 'Ghana', - 'Gibraltar', - 'Greece', - 'Greenland', - 'Grenada', - 'Guadeloupe', - 'Guam', - 'Guatemala', - 'Guernsey', - 'Guinea', - 'Guinea-Bissau', - 'Guyana', - 'Haiti', - 'Heard Island and McDonald Islands', - 'Honduras', - 'Hong Kong', - 'Hungary', - 'Iceland', - 'India', - 'Indonesia', - 'Iran', - 'Iraq', - 'Ireland', - 'Isle of Man', - 'Israel', - 'Italy', - 'Ivory Coast', - 'Jamaica', - 'Japan', - 'Jersey', - 'Jordan', - 'Kazakhstan', - 'Kenya', - 'Kiribati', - 'Kosovo', - 'Kuwait', - 'Kyrgyzstan', - 'Laos', - 'Latvia', - 'Lebanon', - 'Lesotho', - 'Liberia', - 'Libya', - 'Liechtenstein', - 'Lithuania', - 'Luxembourg', - 'Macao', - 'Macedonia', - 'Madagascar', - 'Malawi', - 'Malaysia', - 'Maldives', - 'Mali', - 'Malta', - 'Marshall Islands', - 'Martinique', - 'Mauritania', - 'Mauritius', - 'Mayotte', - 'Mexico', - 'Micronesia', - 'Moldova', - 'Monaco', - 'Mongolia', - 'Montenegro', - 'Montserrat', - 'Morocco', - 'Mozambique', - 'Myanmar', - 'Namibia', - 'Nauru', - 'Nepal', - 'Netherlands', - 'New Caledonia', - 'New Zealand', - 'Nicaragua', - 'Niger', - 'Nigeria', - 'Niue', - 'Norfolk Island', - 'North Korea', - 'Northern Mariana Islands', - 'Norway', - 'Oman', - 'Pakistan', - 'Palau', - 'Palestinian Territory', - 'Panama', - 'Papua New Guinea', - 'Paraguay', - 'Peru', - 'Philippines', - 'Pitcairn', - 'Poland', - 'Portugal', - 'Puerto Rico', - 'Qatar', - 'Republic of the Congo', - 'Reunion', - 'Romania', - 'Russia', - 'Rwanda', - 'Saint Barthelemy', - 'Saint Helena', - 'Saint Kitts and Nevis', - 'Saint Lucia', - 'Saint Martin', - 'Saint Pierre and Miquelon', - 'Saint Vincent and the Grenadines', - 'Samoa', - 'San Marino', - 'Sao Tome and Principe', - 'Saudi Arabia', - 'Senegal', - 'Serbia', - 'Seychelles', - 'Sierra Leone', - 'Singapore', - 'Sint Maarten', - 'Slovakia', - 'Slovenia', - 'Solomon Islands', - 'Somalia', - 'South Africa', - 'South Georgia and the South Sandwich Islands', - 'South Korea', - 'South Sudan', - 'Spain', - 'Sri Lanka', - 'Sudan', - 'Suriname', - 'Svalbard and Jan Mayen', - 'Swaziland', - 'Sweden', - 'Switzerland', - 'Syria', - 'Taiwan', - 'Tajikistan', - 'Tanzania', - 'Thailand', - 'Togo', - 'Tokelau', - 'Tonga', - 'Trinidad and Tobago', - 'Tunisia', - 'Turkey', - 'Turkmenistan', - 'Turks and Caicos Islands', - 'Tuvalu', - 'U.S. Virgin Islands', - 'Uganda', - 'Ukraine', - 'United Arab Emirates', - 'United Kingdom', - USA_COUNTRY_NAME, - 'Uruguay', - 'Uzbekistan', - 'Vanuatu', - 'Vatican', - 'Venezuela', - 'Vietnam', - 'Wallis and Futuna', - 'Western Sahara', - 'Yemen', - 'Zambia', - 'Zimbabwe', - ], + ALL_COUNTRIES: { + AC: 'Ascension Island', + AD: 'Andorra', + AE: 'United Arab Emirates', + AF: 'Afghanistan', + AG: 'Antigua & Barbuda', + AI: 'Anguilla', + AL: 'Albania', + AM: 'Armenia', + AO: 'Angola', + AQ: 'Antarctica', + AR: 'Argentina', + AS: 'American Samoa', + AT: 'Austria', + AU: 'Australia', + AW: 'Aruba', + AX: 'Åland Islands', + AZ: 'Azerbaijan', + BA: 'Bosnia & Herzegovina', + BB: 'Barbados', + BD: 'Bangladesh', + BE: 'Belgium', + BF: 'Burkina Faso', + BG: 'Bulgaria', + BH: 'Bahrain', + BI: 'Burundi', + BJ: 'Benin', + BL: 'St. Barthélemy', + BM: 'Bermuda', + BN: 'Brunei', + BO: 'Bolivia', + BQ: 'Caribbean Netherlands', + BR: 'Brazil', + BS: 'Bahamas', + BT: 'Bhutan', + BW: 'Botswana', + BY: 'Belarus', + BZ: 'Belize', + CA: 'Canada', + CC: 'Cocos (Keeling) Islands', + CD: 'Congo - Kinshasa', + CF: 'Central African Republic', + CG: 'Congo - Brazzaville', + CH: 'Switzerland', + CI: 'Côte d’Ivoire', + CK: 'Cook Islands', + CL: 'Chile', + CM: 'Cameroon', + CN: 'China', + CO: 'Colombia', + CR: 'Costa Rica', + CU: 'Cuba', + CV: 'Cape Verde', + CW: 'Curaçao', + CX: 'Christmas Island', + CY: 'Cyprus', + CZ: 'Czechia', + DE: 'Germany', + DG: 'Diego Garcia', + DJ: 'Djibouti', + DK: 'Denmark', + DM: 'Dominica', + DO: 'Dominican Republic', + DZ: 'Algeria', + EA: 'Ceuta & Melilla', + EC: 'Ecuador', + EE: 'Estonia', + EG: 'Egypt', + EH: 'Western Sahara', + ER: 'Eritrea', + ES: 'Spain', + ET: 'Ethiopia', + EZ: 'Eurozone', + FI: 'Finland', + FJ: 'Fiji', + FK: 'Falkland Islands', + FM: 'Micronesia', + FO: 'Faroe Islands', + FR: 'France', + GA: 'Gabon', + GB: 'United Kingdom', + GD: 'Grenada', + GE: 'Georgia', + GF: 'French Guiana', + GG: 'Guernsey', + GH: 'Ghana', + GI: 'Gibraltar', + GL: 'Greenland', + GM: 'Gambia', + GN: 'Guinea', + GP: 'Guadeloupe', + GQ: 'Equatorial Guinea', + GR: 'Greece', + GS: 'South Georgia & South Sandwich Islands', + GT: 'Guatemala', + GU: 'Guam', + GW: 'Guinea-Bissau', + GY: 'Guyana', + HK: 'Hong Kong', + HN: 'Honduras', + HR: 'Croatia', + HT: 'Haiti', + HU: 'Hungary', + IC: 'Canary Islands', + ID: 'Indonesia', + IE: 'Ireland', + IL: 'Israel', + IM: 'Isle of Man', + IN: 'India', + IO: 'British Indian Ocean Territory', + IQ: 'Iraq', + IR: 'Iran', + IS: 'Iceland', + IT: 'Italy', + JE: 'Jersey', + JM: 'Jamaica', + JO: 'Jordan', + JP: 'Japan', + KE: 'Kenya', + KG: 'Kyrgyzstan', + KH: 'Cambodia', + KI: 'Kiribati', + KM: 'Comoros', + KN: 'St. Kitts & Nevis', + KP: 'North Korea', + KR: 'South Korea', + KW: 'Kuwait', + KY: 'Cayman Islands', + KZ: 'Kazakhstan', + LA: 'Laos', + LB: 'Lebanon', + LC: 'St. Lucia', + LI: 'Liechtenstein', + LK: 'Sri Lanka', + LR: 'Liberia', + LS: 'Lesotho', + LT: 'Lithuania', + LU: 'Luxembourg', + LV: 'Latvia', + LY: 'Libya', + MA: 'Morocco', + MC: 'Monaco', + MD: 'Moldova', + ME: 'Montenegro', + MF: 'St. Martin', + MG: 'Madagascar', + MH: 'Marshall Islands', + MK: 'Macedonia', + ML: 'Mali', + MM: 'Myanmar (Burma)', + MN: 'Mongolia', + MO: 'Macau', + MP: 'Northern Mariana Islands', + MQ: 'Martinique', + MR: 'Mauritania', + MS: 'Montserrat', + MT: 'Malta', + MU: 'Mauritius', + MV: 'Maldives', + MW: 'Malawi', + MX: 'Mexico', + MY: 'Malaysia', + MZ: 'Mozambique', + NA: 'Namibia', + NC: 'New Caledonia', + NE: 'Niger', + NF: 'Norfolk Island', + NG: 'Nigeria', + NI: 'Nicaragua', + NL: 'Netherlands', + NO: 'Norway', + NP: 'Nepal', + NR: 'Nauru', + NU: 'Niue', + NZ: 'New Zealand', + OM: 'Oman', + PA: 'Panama', + PE: 'Peru', + PF: 'French Polynesia', + PG: 'Papua New Guinea', + PH: 'Philippines', + PK: 'Pakistan', + PL: 'Poland', + PM: 'St. Pierre & Miquelon', + PN: 'Pitcairn Islands', + PR: 'Puerto Rico', + PS: 'Palestinian Territories', + PT: 'Portugal', + PW: 'Palau', + PY: 'Paraguay', + QA: 'Qatar', + RE: 'Réunion', + RO: 'Romania', + RS: 'Serbia', + RU: 'Russia', + RW: 'Rwanda', + SA: 'Saudi Arabia', + SB: 'Solomon Islands', + SC: 'Seychelles', + SD: 'Sudan', + SE: 'Sweden', + SG: 'Singapore', + SH: 'St. Helena', + SI: 'Slovenia', + SJ: 'Svalbard & Jan Mayen', + SK: 'Slovakia', + SL: 'Sierra Leone', + SM: 'San Marino', + SN: 'Senegal', + SO: 'Somalia', + SR: 'Suriname', + SS: 'South Sudan', + ST: 'São Tomé & Príncipe', + SV: 'El Salvador', + SX: 'Sint Maarten', + SY: 'Syria', + SZ: 'Swaziland', + TA: 'Tristan da Cunha', + TC: 'Turks & Caicos Islands', + TD: 'Chad', + TF: 'French Southern Territories', + TG: 'Togo', + TH: 'Thailand', + TJ: 'Tajikistan', + TK: 'Tokelau', + TL: 'Timor-Leste', + TM: 'Turkmenistan', + TN: 'Tunisia', + TO: 'Tonga', + TR: 'Turkey', + TT: 'Trinidad & Tobago', + TV: 'Tuvalu', + TW: 'Taiwan', + TZ: 'Tanzania', + UA: 'Ukraine', + UG: 'Uganda', + UM: 'U.S. Outlying Islands', + UN: 'United Nations', + US: 'United States', + UY: 'Uruguay', + UZ: 'Uzbekistan', + VA: 'Vatican City', + VC: 'St. Vincent & Grenadines', + VE: 'Venezuela', + VG: 'British Virgin Islands', + VI: 'U.S. Virgin Islands', + VN: 'Vietnam', + VU: 'Vanuatu', + WF: 'Wallis & Futuna', + WS: 'Samoa', + XK: 'Kosovo', + YE: 'Yemen', + YT: 'Mayotte', + ZA: 'South Africa', + ZM: 'Zambia', + ZW: 'Zimbabwe', + }, // Values for checking if polyfill is required on a platform POLYFILL_TEST: { diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 9cb0d62731d1..42a4a2d651be 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -161,9 +161,6 @@ export default { // Set when we are loading payment methods IS_LOADING_PAYMENT_METHODS: 'isLoadingPaymentMethods', - // The number of minutes a user has to wait for a call. - INBOX_CALL_USER_WAIT_TIME: 'inboxCallUserWaitTime', - // Is report data loading? IS_LOADING_REPORT_DATA: 'isLoadingReportData', @@ -179,7 +176,6 @@ export default { // List of Form ids FORMS: { ADD_DEBIT_CARD_FORM: 'addDebitCardForm', - REQUEST_CALL_FORM: 'requestCallForm', REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount', WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', CLOSE_ACCOUNT_FORM: 'closeAccount', diff --git a/src/ROUTES.js b/src/ROUTES.js index 74f3ba233bdf..0b70b92c1d6f 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -129,8 +129,6 @@ export default { getWorkspaceInvoicesRoute: policyID => `workspace/${policyID}/invoices`, getWorkspaceTravelRoute: policyID => `workspace/${policyID}/travel`, getWorkspaceMembersRoute: policyID => `workspace/${policyID}/members`, - getRequestCallRoute: taskID => `request-call/${taskID}`, - REQUEST_CALL: 'request-call/:taskID', /** * @param {String} route diff --git a/src/components/AddressSearch.js b/src/components/AddressSearch.js index 3ba622591546..dd88073e228e 100644 --- a/src/components/AddressSearch.js +++ b/src/components/AddressSearch.js @@ -117,7 +117,15 @@ const AddressSearch = (props) => { sublocality: 'long_name', postal_code: 'long_name', administrative_area_level_1: 'short_name', - country: 'long_name', + country: 'short_name', + }); + + // The state's iso code (short_name) is needed for the StatePicker component but we also + // need the state's full name (long_name) when we render the state in a TextInput. + const { + administrative_area_level_1: longStateName, + } = GooglePlacesUtils.getAddressComponents(addressComponents, { + administrative_area_level_1: 'long_name', }); const values = { @@ -128,6 +136,12 @@ const AddressSearch = (props) => { country: '', }; + // If the address is not in the US, use the full length state name since we're displaying the address's + // state / province in a TextInput instead of in a picker. + if (country !== CONST.COUNTRY.US) { + values.state = longStateName; + } + const street = `${streetNumber} ${streetName}`.trim(); if (street && street.length >= values.street.length) { // We are only passing the street number and name if the combined length is longer than the value @@ -137,7 +151,8 @@ const AddressSearch = (props) => { values.street = street; } - if (_.includes(CONST.ALL_COUNTRIES, country)) { + const isValidCountryCode = lodashGet(CONST.ALL_COUNTRIES, country); + if (isValidCountryCode) { values.country = country; } diff --git a/src/components/AttachmentCarousel/CarouselActions/index.js b/src/components/AttachmentCarousel/CarouselActions/index.js new file mode 100644 index 000000000000..26af8917a04a --- /dev/null +++ b/src/components/AttachmentCarousel/CarouselActions/index.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {Pressable} from 'react-native'; + +const propTypes = { + /** Handles onPress events with a callback */ + onPress: PropTypes.func.isRequired, + + /** Callback to cycle through attachments */ + onCycleThroughAttachments: PropTypes.func.isRequired, + + /** Styles to be assigned to Carousel */ + styles: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + + /** Children to render */ + children: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.node, + ]).isRequired, +}; + +class Carousel extends React.Component { + constructor(props) { + super(props); + + this.handleKeyPress = this.handleKeyPress.bind(this); + } + + componentDidMount() { + document.addEventListener('keydown', this.handleKeyPress); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.handleKeyPress); + } + + /** + * Listens for keyboard shortcuts and applies the action + * + * @param {Object} e + */ + handleKeyPress(e) { + // prevents focus from highlighting around the modal + e.target.blur(); + if (e.key === 'ArrowLeft') { + this.props.onCycleThroughAttachments(-1); + } + if (e.key === 'ArrowRight') { + this.props.onCycleThroughAttachments(1); + } + } + + render() { + return ( + + {this.props.children} + + ); + } +} + +Carousel.propTypes = propTypes; + +export default Carousel; diff --git a/src/components/AttachmentCarousel/CarouselActions/index.native.js b/src/components/AttachmentCarousel/CarouselActions/index.native.js new file mode 100644 index 000000000000..ebc7b7768077 --- /dev/null +++ b/src/components/AttachmentCarousel/CarouselActions/index.native.js @@ -0,0 +1,79 @@ +import React, {Component} from 'react'; +import {PanResponder, Dimensions, Animated} from 'react-native'; +import PropTypes from 'prop-types'; +import styles from '../../../styles/styles'; + +const propTypes = { + /** Attachment that's rendered */ + children: PropTypes.element.isRequired, + + /** Callback to fire when swiping left or right */ + onCycleThroughAttachments: PropTypes.func.isRequired, + + /** Callback to handle a press event */ + onPress: PropTypes.func.isRequired, + + /** Boolean to prevent a left swipe action */ + canSwipeLeft: PropTypes.bool.isRequired, + + /** Boolean to prevent a right swipe action */ + canSwipeRight: PropTypes.bool.isRequired, +}; + +class Carousel extends Component { + constructor(props) { + super(props); + this.pan = new Animated.Value(0); + + this.panResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => true, + + onPanResponderMove: (event, gestureState) => Animated.event([null, { + dx: this.pan, + }], {useNativeDriver: false})(event, gestureState), + + onPanResponderRelease: (event, gestureState) => { + if (gestureState.dx === 0 && gestureState.dy === 0) { + return this.props.onPress(); + } + + const deltaSlide = gestureState.dx > 0 ? 1 : -1; + if (Math.abs(gestureState.vx) < 1 || (deltaSlide === 1 && !this.props.canSwipeLeft) || (deltaSlide === -1 && !this.props.canSwipeRight)) { + return Animated.spring(this.pan, {useNativeDriver: false, toValue: 0}).start(); + } + + const width = Dimensions.get('window').width; + const slideLength = deltaSlide * (width * 1.1); + Animated.timing(this.pan, {useNativeDriver: false, duration: 100, toValue: slideLength}).start(({finished}) => { + if (!finished) { + return; + } + + this.props.onCycleThroughAttachments(-deltaSlide); + this.pan.setValue(-slideLength); + Animated.timing(this.pan, {useNativeDriver: false, duration: 100, toValue: 0}).start(); + }); + }, + }); + } + + render() { + return ( + + {this.props.children} + + ); + } +} + +Carousel.propTypes = propTypes; + +export default Carousel; diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js new file mode 100644 index 000000000000..144d0aaa0874 --- /dev/null +++ b/src/components/AttachmentCarousel/index.js @@ -0,0 +1,215 @@ +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import * as Expensicons from '../Icon/Expensicons'; +import styles from '../../styles/styles'; +import themeColors from '../../styles/themes/default'; +import CarouselActions from './CarouselActions'; +import Button from '../Button'; +import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; +import AttachmentView from '../AttachmentView'; +import addEncryptedAuthTokenToURL from '../../libs/addEncryptedAuthTokenToURL'; +import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; +import CONST from '../../CONST'; +import ONYXKEYS from '../../ONYXKEYS'; +import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes'; +import tryResolveUrlFromApiRoot from '../../libs/tryResolveUrlFromApiRoot'; + +const propTypes = { + /** source is used to determine the starting index in the array of attachments */ + source: PropTypes.string, + + /** Callback to update the parent modal's state with a source and name from the attachments array */ + onNavigate: PropTypes.func, + + /** Object of report actions for this report */ + reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), +}; + +const defaultProps = { + source: '', + reportActions: {}, + onNavigate: () => {}, +}; + +class AttachmentCarousel extends React.Component { + constructor(props) { + super(props); + + this.canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); + this.cycleThroughAttachments = this.cycleThroughAttachments.bind(this); + + this.state = { + source: this.props.source, + shouldShowArrow: this.canUseTouchScreen, + isForwardDisabled: true, + isBackDisabled: true, + }; + } + + componentDidMount() { + this.makeStateWithReports(); + } + + componentDidUpdate(prevProps) { + const previousReportActionsCount = _.size(prevProps.reportActions); + const currentReportActionsCount = _.size(this.props.reportActions); + if (previousReportActionsCount === currentReportActionsCount) { + return; + } + this.makeStateWithReports(); + } + + /** + * Helps to navigate between next/previous attachments + * @param {Object} attachmentItem + * @returns {Object} + */ + getAttachment(attachmentItem) { + const source = _.get(attachmentItem, 'source', ''); + const file = _.get(attachmentItem, 'file', {name: ''}); + this.props.onNavigate({source: addEncryptedAuthTokenToURL(source), file}); + + return { + source, + file, + }; + } + + /** + * Toggles the visibility of the arrows + * @param {Boolean} shouldShowArrow + */ + toggleArrowsVisibility(shouldShowArrow) { + this.setState({shouldShowArrow}); + } + + /** + * This is called when there are new reports to set the state + */ + makeStateWithReports() { + let page; + const actions = ReportActionsUtils.getSortedReportActions(_.values(this.props.reportActions), true); + + /** + * Looping to filter out attachments and retrieve the src URL and name of attachments. + */ + const attachments = []; + _.forEach(actions, ({originalMessage, message}) => { + // Check for attachment which hasn't been deleted + if (!originalMessage || !originalMessage.html || _.some(message, m => m.isEdited)) { + return; + } + const matches = [...originalMessage.html.matchAll(CONST.REGEX.ATTACHMENT_DATA)]; + + // matchAll captured both source url and name of the attachment + if (matches.length === 2) { + const [originalSource, name] = _.map(matches, m => m[2]); + + // Update the image URL so the images can be accessed depending on the config environment. + // Eg: while using Ngrok the image path is from an Ngrok URL and not an Expensify URL. + const source = tryResolveUrlFromApiRoot(originalSource); + if (source === this.state.source) { + page = attachments.length; + } + + attachments.push({source, file: {name}}); + } + }); + + const {file} = this.getAttachment(attachments[page]); + this.setState({ + page, + attachments, + file, + isForwardDisabled: page === 0, + isBackDisabled: page === attachments.length - 1, + }); + } + + /** + * Increments or decrements the index to get another selected item + * @param {Number} deltaSlide + */ + cycleThroughAttachments(deltaSlide) { + if ((deltaSlide > 0 && this.state.isForwardDisabled) || (deltaSlide < 0 && this.state.isBackDisabled)) { + return; + } + + this.setState(({attachments, page}) => { + const nextIndex = page - deltaSlide; + const {source, file} = this.getAttachment(attachments[nextIndex]); + return { + page: nextIndex, + source, + file, + isBackDisabled: nextIndex === attachments.length - 1, + isForwardDisabled: nextIndex === 0, + }; + }); + } + + render() { + const isPageSet = Number.isInteger(this.state.page); + const authSource = addEncryptedAuthTokenToURL(this.state.source); + return ( + this.toggleArrowsVisibility(true)} + onMouseLeave={() => this.toggleArrowsVisibility(false)} + > + {(isPageSet && this.state.shouldShowArrow) && ( + <> + {!this.state.isBackDisabled && ( +