Skip to content

Commit cb2ba26

Browse files
authored
Merge pull request #9279 from JediWattson/arrow-feature-signed
adding carousel to attachment modal
2 parents 618ce68 + 483a8b7 commit cb2ba26

12 files changed

Lines changed: 507 additions & 31 deletions

File tree

src/CONST.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,10 @@ const CONST = {
823823
EMOJIS: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
824824
TAX_ID: /^\d{9}$/,
825825
NON_NUMERIC: /\D/g,
826+
827+
// Extract attachment's source from the data's html string
828+
ATTACHMENT_DATA: /(data-expensify-source|data-name)="([^"]+)"/g,
829+
826830
NON_NUMERIC_WITH_PLUS: /[^0-9+]/g,
827831
EMOJI_NAME: /:[\w+-]+:/g,
828832
EMOJI_SUGGESTIONS: /:[a-zA-Z0-9_+-]{1,40}$/,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import {Pressable} from 'react-native';
4+
5+
const propTypes = {
6+
/** Handles onPress events with a callback */
7+
onPress: PropTypes.func.isRequired,
8+
9+
/** Callback to cycle through attachments */
10+
onCycleThroughAttachments: PropTypes.func.isRequired,
11+
12+
/** Styles to be assigned to Carousel */
13+
styles: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
14+
15+
/** Children to render */
16+
children: PropTypes.oneOfType([
17+
PropTypes.func,
18+
PropTypes.node,
19+
]).isRequired,
20+
};
21+
22+
class Carousel extends React.Component {
23+
constructor(props) {
24+
super(props);
25+
26+
this.handleKeyPress = this.handleKeyPress.bind(this);
27+
}
28+
29+
componentDidMount() {
30+
document.addEventListener('keydown', this.handleKeyPress);
31+
}
32+
33+
componentWillUnmount() {
34+
document.removeEventListener('keydown', this.handleKeyPress);
35+
}
36+
37+
/**
38+
* Listens for keyboard shortcuts and applies the action
39+
*
40+
* @param {Object} e
41+
*/
42+
handleKeyPress(e) {
43+
// prevents focus from highlighting around the modal
44+
e.target.blur();
45+
if (e.key === 'ArrowLeft') {
46+
this.props.onCycleThroughAttachments(-1);
47+
}
48+
if (e.key === 'ArrowRight') {
49+
this.props.onCycleThroughAttachments(1);
50+
}
51+
}
52+
53+
render() {
54+
return (
55+
<Pressable style={this.props.styles} onPress={this.props.onPress}>
56+
{this.props.children}
57+
</Pressable>
58+
);
59+
}
60+
}
61+
62+
Carousel.propTypes = propTypes;
63+
64+
export default Carousel;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React, {Component} from 'react';
2+
import {PanResponder, Dimensions, Animated} from 'react-native';
3+
import PropTypes from 'prop-types';
4+
import styles from '../../../styles/styles';
5+
6+
const propTypes = {
7+
/** Attachment that's rendered */
8+
children: PropTypes.element.isRequired,
9+
10+
/** Callback to fire when swiping left or right */
11+
onCycleThroughAttachments: PropTypes.func.isRequired,
12+
13+
/** Callback to handle a press event */
14+
onPress: PropTypes.func.isRequired,
15+
16+
/** Boolean to prevent a left swipe action */
17+
canSwipeLeft: PropTypes.bool.isRequired,
18+
19+
/** Boolean to prevent a right swipe action */
20+
canSwipeRight: PropTypes.bool.isRequired,
21+
};
22+
23+
class Carousel extends Component {
24+
constructor(props) {
25+
super(props);
26+
this.pan = new Animated.Value(0);
27+
28+
this.panResponder = PanResponder.create({
29+
onStartShouldSetPanResponder: () => true,
30+
31+
onPanResponderMove: (event, gestureState) => Animated.event([null, {
32+
dx: this.pan,
33+
}], {useNativeDriver: false})(event, gestureState),
34+
35+
onPanResponderRelease: (event, gestureState) => {
36+
if (gestureState.dx === 0 && gestureState.dy === 0) {
37+
return this.props.onPress();
38+
}
39+
40+
const deltaSlide = gestureState.dx > 0 ? 1 : -1;
41+
if (Math.abs(gestureState.vx) < 1 || (deltaSlide === 1 && !this.props.canSwipeLeft) || (deltaSlide === -1 && !this.props.canSwipeRight)) {
42+
return Animated.spring(this.pan, {useNativeDriver: false, toValue: 0}).start();
43+
}
44+
45+
const width = Dimensions.get('window').width;
46+
const slideLength = deltaSlide * (width * 1.1);
47+
Animated.timing(this.pan, {useNativeDriver: false, duration: 100, toValue: slideLength}).start(({finished}) => {
48+
if (!finished) {
49+
return;
50+
}
51+
52+
this.props.onCycleThroughAttachments(-deltaSlide);
53+
this.pan.setValue(-slideLength);
54+
Animated.timing(this.pan, {useNativeDriver: false, duration: 100, toValue: 0}).start();
55+
});
56+
},
57+
});
58+
}
59+
60+
render() {
61+
return (
62+
<Animated.View
63+
style={[
64+
styles.w100,
65+
styles.h100,
66+
{transform: [{translateX: this.pan}]},
67+
]}
68+
// eslint-disable-next-line react/jsx-props-no-spreading
69+
{...this.panResponder.panHandlers}
70+
>
71+
{this.props.children}
72+
</Animated.View>
73+
);
74+
}
75+
}
76+
77+
Carousel.propTypes = propTypes;
78+
79+
export default Carousel;
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import React from 'react';
2+
import {View} from 'react-native';
3+
import PropTypes from 'prop-types';
4+
import {withOnyx} from 'react-native-onyx';
5+
import _ from 'underscore';
6+
import * as Expensicons from '../Icon/Expensicons';
7+
import styles from '../../styles/styles';
8+
import themeColors from '../../styles/themes/default';
9+
import CarouselActions from './CarouselActions';
10+
import Button from '../Button';
11+
import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
12+
import AttachmentView from '../AttachmentView';
13+
import addEncryptedAuthTokenToURL from '../../libs/addEncryptedAuthTokenToURL';
14+
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
15+
import CONST from '../../CONST';
16+
import ONYXKEYS from '../../ONYXKEYS';
17+
import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes';
18+
import tryResolveUrlFromApiRoot from '../../libs/tryResolveUrlFromApiRoot';
19+
20+
const propTypes = {
21+
/** source is used to determine the starting index in the array of attachments */
22+
source: PropTypes.string,
23+
24+
/** Callback to update the parent modal's state with a source and name from the attachments array */
25+
onNavigate: PropTypes.func,
26+
27+
/** Object of report actions for this report */
28+
reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
29+
};
30+
31+
const defaultProps = {
32+
source: '',
33+
reportActions: {},
34+
onNavigate: () => {},
35+
};
36+
37+
class AttachmentCarousel extends React.Component {
38+
constructor(props) {
39+
super(props);
40+
41+
this.canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
42+
this.cycleThroughAttachments = this.cycleThroughAttachments.bind(this);
43+
44+
this.state = {
45+
source: this.props.source,
46+
shouldShowArrow: this.canUseTouchScreen,
47+
isForwardDisabled: true,
48+
isBackDisabled: true,
49+
};
50+
}
51+
52+
componentDidMount() {
53+
this.makeStateWithReports();
54+
}
55+
56+
componentDidUpdate(prevProps) {
57+
const previousReportActionsCount = _.size(prevProps.reportActions);
58+
const currentReportActionsCount = _.size(this.props.reportActions);
59+
if (previousReportActionsCount === currentReportActionsCount) {
60+
return;
61+
}
62+
this.makeStateWithReports();
63+
}
64+
65+
/**
66+
* Helps to navigate between next/previous attachments
67+
* @param {Object} attachmentItem
68+
* @returns {Object}
69+
*/
70+
getAttachment(attachmentItem) {
71+
const source = _.get(attachmentItem, 'source', '');
72+
const file = _.get(attachmentItem, 'file', {name: ''});
73+
this.props.onNavigate({source: addEncryptedAuthTokenToURL(source), file});
74+
75+
return {
76+
source,
77+
file,
78+
};
79+
}
80+
81+
/**
82+
* Toggles the visibility of the arrows
83+
* @param {Boolean} shouldShowArrow
84+
*/
85+
toggleArrowsVisibility(shouldShowArrow) {
86+
this.setState({shouldShowArrow});
87+
}
88+
89+
/**
90+
* This is called when there are new reports to set the state
91+
*/
92+
makeStateWithReports() {
93+
let page;
94+
const actions = ReportActionsUtils.getSortedReportActions(_.values(this.props.reportActions), true);
95+
96+
/**
97+
* Looping to filter out attachments and retrieve the src URL and name of attachments.
98+
*/
99+
const attachments = [];
100+
_.forEach(actions, ({originalMessage, message}) => {
101+
// Check for attachment which hasn't been deleted
102+
if (!originalMessage || !originalMessage.html || _.some(message, m => m.isEdited)) {
103+
return;
104+
}
105+
const matches = [...originalMessage.html.matchAll(CONST.REGEX.ATTACHMENT_DATA)];
106+
107+
// matchAll captured both source url and name of the attachment
108+
if (matches.length === 2) {
109+
const [originalSource, name] = _.map(matches, m => m[2]);
110+
111+
// Update the image URL so the images can be accessed depending on the config environment.
112+
// Eg: while using Ngrok the image path is from an Ngrok URL and not an Expensify URL.
113+
const source = tryResolveUrlFromApiRoot(originalSource);
114+
if (source === this.state.source) {
115+
page = attachments.length;
116+
}
117+
118+
attachments.push({source, file: {name}});
119+
}
120+
});
121+
122+
const {file} = this.getAttachment(attachments[page]);
123+
this.setState({
124+
page,
125+
attachments,
126+
file,
127+
isForwardDisabled: page === 0,
128+
isBackDisabled: page === attachments.length - 1,
129+
});
130+
}
131+
132+
/**
133+
* Increments or decrements the index to get another selected item
134+
* @param {Number} deltaSlide
135+
*/
136+
cycleThroughAttachments(deltaSlide) {
137+
if ((deltaSlide > 0 && this.state.isForwardDisabled) || (deltaSlide < 0 && this.state.isBackDisabled)) {
138+
return;
139+
}
140+
141+
this.setState(({attachments, page}) => {
142+
const nextIndex = page - deltaSlide;
143+
const {source, file} = this.getAttachment(attachments[nextIndex]);
144+
return {
145+
page: nextIndex,
146+
source,
147+
file,
148+
isBackDisabled: nextIndex === attachments.length - 1,
149+
isForwardDisabled: nextIndex === 0,
150+
};
151+
});
152+
}
153+
154+
render() {
155+
const isPageSet = Number.isInteger(this.state.page);
156+
const authSource = addEncryptedAuthTokenToURL(this.state.source);
157+
return (
158+
<View
159+
style={[styles.attachmentModalArrowsContainer]}
160+
onMouseEnter={() => this.toggleArrowsVisibility(true)}
161+
onMouseLeave={() => this.toggleArrowsVisibility(false)}
162+
>
163+
{(isPageSet && this.state.shouldShowArrow) && (
164+
<>
165+
{!this.state.isBackDisabled && (
166+
<Button
167+
medium
168+
style={[styles.leftAttachmentArrow]}
169+
innerStyles={[styles.arrowIcon]}
170+
icon={Expensicons.BackArrow}
171+
iconFill={themeColors.text}
172+
iconStyles={[styles.mr0]}
173+
onPress={() => this.cycleThroughAttachments(-1)}
174+
/>
175+
)}
176+
{!this.state.isForwardDisabled && (
177+
<Button
178+
medium
179+
style={[styles.rightAttachmentArrow]}
180+
innerStyles={[styles.arrowIcon]}
181+
icon={Expensicons.ArrowRight}
182+
iconFill={themeColors.text}
183+
iconStyles={[styles.mr0]}
184+
onPress={() => this.cycleThroughAttachments(1)}
185+
/>
186+
)}
187+
</>
188+
)}
189+
<CarouselActions
190+
styles={[styles.attachmentModalArrowsContainer]}
191+
canSwipeLeft={!this.state.isBackDisabled}
192+
canSwipeRight={!this.state.isForwardDisabled}
193+
onPress={() => this.canUseTouchScreen && this.toggleArrowsVisibility(!this.state.shouldShowArrow)}
194+
onCycleThroughAttachments={this.cycleThroughAttachments}
195+
>
196+
<AttachmentView
197+
onPress={() => this.toggleArrowsVisibility(!this.state.shouldShowArrow)}
198+
source={authSource}
199+
file={this.state.file}
200+
/>
201+
</CarouselActions>
202+
</View>
203+
);
204+
}
205+
}
206+
207+
AttachmentCarousel.propTypes = propTypes;
208+
AttachmentCarousel.defaultProps = defaultProps;
209+
210+
export default withOnyx({
211+
reportActions: {
212+
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
213+
canEvict: false,
214+
},
215+
})(AttachmentCarousel);

0 commit comments

Comments
 (0)