Skip to content

Commit

Permalink
Merge pull request #9279 from JediWattson/arrow-feature-signed
Browse files Browse the repository at this point in the history
adding carousel to attachment modal
  • Loading branch information
luacmartins authored Mar 8, 2023
2 parents 618ce68 + 483a8b7 commit cb2ba26
Show file tree
Hide file tree
Showing 12 changed files with 507 additions and 31 deletions.
4 changes: 4 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/,
Expand Down
64 changes: 64 additions & 0 deletions src/components/AttachmentCarousel/CarouselActions/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<Pressable style={this.props.styles} onPress={this.props.onPress}>
{this.props.children}
</Pressable>
);
}
}

Carousel.propTypes = propTypes;

export default Carousel;
79 changes: 79 additions & 0 deletions src/components/AttachmentCarousel/CarouselActions/index.native.js
Original file line number Diff line number Diff line change
@@ -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 (
<Animated.View
style={[
styles.w100,
styles.h100,
{transform: [{translateX: this.pan}]},
]}
// eslint-disable-next-line react/jsx-props-no-spreading
{...this.panResponder.panHandlers}
>
{this.props.children}
</Animated.View>
);
}
}

Carousel.propTypes = propTypes;

export default Carousel;
215 changes: 215 additions & 0 deletions src/components/AttachmentCarousel/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<View
style={[styles.attachmentModalArrowsContainer]}
onMouseEnter={() => this.toggleArrowsVisibility(true)}
onMouseLeave={() => this.toggleArrowsVisibility(false)}
>
{(isPageSet && this.state.shouldShowArrow) && (
<>
{!this.state.isBackDisabled && (
<Button
medium
style={[styles.leftAttachmentArrow]}
innerStyles={[styles.arrowIcon]}
icon={Expensicons.BackArrow}
iconFill={themeColors.text}
iconStyles={[styles.mr0]}
onPress={() => this.cycleThroughAttachments(-1)}
/>
)}
{!this.state.isForwardDisabled && (
<Button
medium
style={[styles.rightAttachmentArrow]}
innerStyles={[styles.arrowIcon]}
icon={Expensicons.ArrowRight}
iconFill={themeColors.text}
iconStyles={[styles.mr0]}
onPress={() => this.cycleThroughAttachments(1)}
/>
)}
</>
)}
<CarouselActions
styles={[styles.attachmentModalArrowsContainer]}
canSwipeLeft={!this.state.isBackDisabled}
canSwipeRight={!this.state.isForwardDisabled}
onPress={() => this.canUseTouchScreen && this.toggleArrowsVisibility(!this.state.shouldShowArrow)}
onCycleThroughAttachments={this.cycleThroughAttachments}
>
<AttachmentView
onPress={() => this.toggleArrowsVisibility(!this.state.shouldShowArrow)}
source={authSource}
file={this.state.file}
/>
</CarouselActions>
</View>
);
}
}

AttachmentCarousel.propTypes = propTypes;
AttachmentCarousel.defaultProps = defaultProps;

export default withOnyx({
reportActions: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
canEvict: false,
},
})(AttachmentCarousel);
Loading

0 comments on commit cb2ba26

Please sign in to comment.