diff --git a/src/CONST.js b/src/CONST.js index 7ef61b965e42..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}$/, 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 && ( +