-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #9279 from JediWattson/arrow-feature-signed
adding carousel to attachment modal
- Loading branch information
Showing
12 changed files
with
507 additions
and
31 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
64 changes: 64 additions & 0 deletions
64
src/components/AttachmentCarousel/CarouselActions/index.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
79
src/components/AttachmentCarousel/CarouselActions/index.native.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
Oops, something went wrong.