RN-342 Refactor post textbox component (#915)

* RN-342 Refactor post textbox component

* Rebase

* Review feedback
This commit is contained in:
Chris Duarte 2017-09-20 13:40:40 -07:00 committed by Harrison Healey
parent 4995a76f2c
commit e4b86518a9
13 changed files with 400 additions and 316 deletions

View file

@ -38,6 +38,7 @@ export default class Emoji extends React.PureComponent {
}
componentWillMount() {
this.mounted = true;
if (this.state.imageUrl && this.state.isCustomEmoji) {
this.updateImageHeight(this.state.imageUrl);
}
@ -59,6 +60,10 @@ export default class Emoji extends React.PureComponent {
}
}
componentWillUnmount() {
this.mounted = false;
}
getImageUrl = (props = this.props) => {
const emojiName = props.emojiName;
@ -83,10 +88,12 @@ export default class Emoji extends React.PureComponent {
updateImageHeight = (imageUrl) => {
Image.getSize(imageUrl, (originalWidth, originalHeight) => {
this.setState({
originalWidth,
originalHeight
});
if (this.mounted) {
this.setState({
originalWidth,
originalHeight
});
}
});
}

View file

@ -4,6 +4,7 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
View
} from 'react-native';
import FlatList from 'app/components/inverted_flat_list';
@ -183,7 +184,14 @@ export default class PostList extends PureComponent {
theme={theme}
getItem={this.getItem}
getItemCount={this.getItemCount}
contentContainerStyle={styles.postListContent}
/>
);
}
}
const styles = StyleSheet.create({
postListContent: {
paddingTop: 5
}
});

View file

@ -0,0 +1,177 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Platform,
StyleSheet,
TouchableOpacity
} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import ImagePicker from 'react-native-image-picker';
import {changeOpacity} from 'app/utils/theme';
export default class AttachmentButton extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
navigator: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
uploadFiles: PropTypes.func.isRequired
};
attachFileFromCamera = () => {
const options = {
quality: 0.7,
noData: true,
storageOptions: {
cameraRoll: true,
waitUntilSaved: true
}
};
ImagePicker.launchCamera(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
};
attachFileFromLibrary = () => {
const options = {
quality: 0.7,
noData: true
};
if (Platform.OS === 'ios') {
options.mediaType = 'mixed';
}
ImagePicker.launchImageLibrary(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
};
attachVideoFromLibraryAndroid = () => {
const options = {
quality: 0.7,
mediaType: 'video',
noData: true
};
ImagePicker.launchImageLibrary(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
}
uploadFiles = (images) => {
this.props.uploadFiles(images);
};
handleFileAttachmentOption = (action) => {
this.props.navigator.dismissModal({
animationType: 'none'
});
// Have to wait to launch the library attachment action.
// If we call the action after dismissModal with no delay then the
// Wix navigator will dismiss the library attachment modal as well.
setTimeout(() => {
if (typeof action === 'function') {
action();
}
}, 100);
}
showFileAttachmentOptions = () => {
this.props.blurTextBox();
const options = {
items: [{
action: () => this.handleFileAttachmentOption(this.attachFileFromCamera),
text: {
id: 'mobile.file_upload.camera',
defaultMessage: 'Take Photo or Video'
},
icon: 'camera'
}, {
action: () => this.handleFileAttachmentOption(this.attachFileFromLibrary),
text: {
id: 'mobile.file_upload.library',
defaultMessage: 'Photo Library'
},
icon: 'photo'
}]
};
if (Platform.OS === 'android') {
options.items.push({
action: () => this.handleFileAttachmentOption(this.attachVideoFromLibraryAndroid),
text: {
id: 'mobile.file_upload.video',
defaultMessage: 'Video Library'
},
icon: 'file-video-o'
});
}
this.props.navigator.showModal({
screen: 'OptionsModal',
title: '',
animationType: 'none',
passProps: {
items: options.items
},
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
screenBackgroundColor: 'transparent',
modalPresentationStyle: 'overCurrentContext'
}
});
};
render() {
const {theme} = this.props;
return (
<TouchableOpacity
onPress={this.showFileAttachmentOptions}
style={style.buttonContainer}
>
<Icon
size={30}
style={style.attachIcon}
color={changeOpacity(theme.centerChannelColor, 0.9)}
name='md-add'
/>
</TouchableOpacity>
);
}
}
const style = StyleSheet.create({
attachIcon: {
marginTop: Platform.select({
ios: 2,
android: 0
})
},
buttonContainer: {
height: Platform.select({
ios: 34,
android: 36
}),
width: 45,
alignItems: 'center',
justifyContent: 'center'
}
});

View file

@ -0,0 +1,19 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getUsersTyping} from 'mattermost-redux/selectors/entities/typing';
import {getTheme} from 'app/selectors/preferences';
import Typing from './typing';
function mapStateToProps(state) {
return {
theme: getTheme(state),
typing: getUsersTyping(state)
};
}
export default connect(mapStateToProps)(Typing);

View file

@ -0,0 +1,102 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Animated,
Text
} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
const {View: AnimatedView} = Animated;
export default class Typing extends PureComponent {
static propTypes = {
theme: PropTypes.object.isRequired,
typing: PropTypes.array.isRequired
};
state = {
typingHeight: new Animated.Value(0)
}
componentWillReceiveProps(nextProps) {
if (nextProps.typing.length && !this.props.typing.length) {
this.animateTyping(true);
} else if (!nextProps.typing.length) {
this.animateTyping();
}
}
animateTyping = (show = false) => {
const height = show ? 20 : 0;
Animated.timing(this.state.typingHeight, {
toValue: height,
duration: 200
}).start();
}
renderTyping = () => {
const {typing} = this.props;
const nextTyping = [...typing];
const numUsers = nextTyping.length;
switch (numUsers) {
case 0:
return null;
case 1:
return (
<FormattedText
id='msg_typing.isTyping'
defaultMessage='{user} is typing...'
values={{
user: nextTyping[0]
}}
/>
);
default: {
const last = nextTyping.pop();
return (
<FormattedText
id='msg_typing.areTyping'
defaultMessage='{users} and {last} are typing...'
values={{
users: (nextTyping.join(', ')),
last
}}
/>
);
}
}
};
render() {
const style = getStyleSheet(this.props.theme);
return (
<AnimatedView style={{height: this.state.typingHeight}}>
<Text
style={style.typing}
ellipsizeMode='tail'
numberOfLines={1}
>
{this.renderTyping()}
</Text>
</AnimatedView>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
typing: {
paddingLeft: 10,
paddingTop: 3,
fontSize: 11,
marginBottom: 5,
color: theme.centerChannelColor,
backgroundColor: 'transparent'
}
};
});

View file

@ -3,27 +3,32 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {createPost} from 'mattermost-redux/actions/posts';
import {userTyping} from 'mattermost-redux/actions/websocket';
import {addReactionToLatestPost} from 'app/actions/views/emoji';
import {handleClearFiles, handleRemoveLastFile, handleUploadFiles} from 'app/actions/views/file_upload';
import {getTheme} from 'app/selectors/preferences';
import {canUploadFilesOnMobile} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getUsersTyping} from 'mattermost-redux/selectors/entities/typing';
import {addReactionToLatestPost} from 'app/actions/views/emoji';
import {handlePostDraftChanged} from 'app/actions/views/channel';
import {handleClearFiles, handleRemoveLastFile, handleUploadFiles} from 'app/actions/views/file_upload';
import {handleCommentDraftChanged} from 'app/actions/views/thread';
import {getTheme} from 'app/selectors/preferences';
import {getCurrentChannelDraft, getThreadDraft} from 'app/selectors/views';
import PostTextbox from './post_textbox';
function mapStateToProps(state, ownProps) {
const currentDraft = ownProps.rootId ? getThreadDraft(state, ownProps.rootId) : getCurrentChannelDraft(state);
return {
...ownProps,
canUploadFiles: canUploadFilesOnMobile(state),
channelIsLoading: state.views.channel.loading,
currentUserId: getCurrentUserId(state),
typing: getUsersTyping(state),
files: currentDraft.files,
theme: getTheme(state),
uploadFileRequestStatus: state.requests.files.uploadFiles.status
uploadFileRequestStatus: state.requests.files.uploadFiles.status,
value: currentDraft.draft
};
}
@ -33,6 +38,8 @@ function mapDispatchToProps(dispatch) {
addReactionToLatestPost,
createPost,
handleClearFiles,
handleCommentDraftChanged,
handlePostDraftChanged,
handleRemoveLastFile,
handleUploadFiles,
userTyping

View file

@ -13,17 +13,17 @@ import {
TouchableOpacity,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import ImagePicker from 'react-native-image-picker';
import {injectIntl, intlShape} from 'react-intl';
import {RequestStatus} from 'mattermost-redux/constants';
import Autocomplete from 'app/components/autocomplete';
import FileUploadPreview from 'app/components/file_upload_preview';
import FormattedText from 'app/components/formatted_text';
import PaperPlane from 'app/components/paper_plane';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import AttachmentButton from './components/attachment_button';
import Typing from './components/typing';
const INITIAL_HEIGHT = Platform.OS === 'ios' ? 34 : 36;
const MAX_CONTENT_HEIGHT = 100;
const MAX_MESSAGE_LENGTH = 4000;
@ -34,6 +34,8 @@ class PostTextbox extends PureComponent {
actions: PropTypes.shape({
addReactionToLatestPost: PropTypes.func.isRequired,
createPost: PropTypes.func.isRequired,
handleCommentDraftChanged: PropTypes.func.isRequired,
handlePostDraftChanged: PropTypes.func.isRequired,
handleClearFiles: PropTypes.func.isRequired,
handleRemoveLastFile: PropTypes.func.isRequired,
handleUploadFiles: PropTypes.func.isRequired,
@ -46,10 +48,8 @@ class PostTextbox extends PureComponent {
files: PropTypes.array,
intl: intlShape.isRequired,
navigator: PropTypes.object,
onChangeText: PropTypes.func.isRequired,
rootId: PropTypes.string,
theme: PropTypes.object.isRequired,
typing: PropTypes.array.isRequired,
uploadFileRequestStatus: PropTypes.string.isRequired,
value: PropTypes.string.isRequired
};
@ -61,14 +61,10 @@ class PostTextbox extends PureComponent {
value: ''
};
constructor(props) {
super(props);
this.state = {
contentHeight: INITIAL_HEIGHT,
inputWidth: null
};
}
state = {
contentHeight: INITIAL_HEIGHT,
inputWidth: null
};
componentDidMount() {
if (Platform.OS === 'android') {
@ -197,6 +193,24 @@ class PostTextbox extends PureComponent {
});
};
handleUploadFiles = (images) => {
this.props.actions.handleUploadFiles(images, this.props.rootId);
};
changeDraft = (text) => {
const {
actions,
channelId,
rootId
} = this.props;
if (rootId) {
actions.handleCommentDraftChanged(rootId, text);
} else {
actions.handlePostDraftChanged(channelId, text);
}
}
sendReaction = (emoji) => {
const {actions, rootId} = this.props;
actions.addReactionToLatestPost(emoji, rootId);
@ -205,13 +219,12 @@ class PostTextbox extends PureComponent {
handleTextChange = (text) => {
const {
onChangeText,
actions,
channelId,
rootId,
actions
rootId
} = this.props;
onChangeText(text);
this.changeDraft(text);
actions.userTyping(channelId, rootId);
};
@ -256,160 +269,6 @@ class PostTextbox extends PureComponent {
this.autocomplete = c;
};
attachFileFromCamera = () => {
const options = {
quality: 0.7,
noData: true,
storageOptions: {
cameraRoll: true,
waitUntilSaved: true
}
};
ImagePicker.launchCamera(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
};
attachFileFromLibrary = () => {
const options = {
quality: 0.7,
noData: true
};
if (Platform.OS === 'ios') {
options.mediaType = 'mixed';
}
ImagePicker.launchImageLibrary(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
};
attachVideoFromLibraryAndroid = () => {
const options = {
quality: 0.7,
mediaType: 'video',
noData: true
};
ImagePicker.launchImageLibrary(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
}
uploadFiles = (images) => {
this.props.actions.handleUploadFiles(images, this.props.rootId);
};
handleFileAttachmentOption = (action) => {
this.props.navigator.dismissModal({
animationType: 'none'
});
// Have to wait to launch the library attachment action.
// If we call the action after dismissModal with no delay then the
// Wix navigator will dismiss the library attachment modal as well.
setTimeout(() => {
if (typeof action === 'function') {
action();
}
}, 100);
}
showFileAttachmentOptions = () => {
this.blur();
const options = {
items: [{
action: () => this.handleFileAttachmentOption(this.attachFileFromCamera),
text: {
id: 'mobile.file_upload.camera',
defaultMessage: 'Take Photo or Video'
},
icon: 'camera'
}, {
action: () => this.handleFileAttachmentOption(this.attachFileFromLibrary),
text: {
id: 'mobile.file_upload.library',
defaultMessage: 'Photo Library'
},
icon: 'photo'
}]
};
if (Platform.OS === 'android') {
options.items.push({
action: () => this.handleFileAttachmentOption(this.attachVideoFromLibraryAndroid),
text: {
id: 'mobile.file_upload.video',
defaultMessage: 'Video Library'
},
icon: 'file-video-o'
});
}
this.props.navigator.showModal({
screen: 'OptionsModal',
title: '',
animationType: 'none',
passProps: {
items: options.items
},
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
screenBackgroundColor: 'transparent',
modalPresentationStyle: 'overCurrentContext'
}
});
};
renderTyping = () => {
const {typing} = this.props;
const numUsers = typing.length;
switch (numUsers) {
case 0:
return null;
case 1:
return (
<FormattedText
id='msg_typing.isTyping'
defaultMessage='{user} is typing...'
values={{
user: typing[0]
}}
/>
);
default: {
const last = typing.pop();
return (
<FormattedText
id='msg_typing.areTyping'
defaultMessage='{users} and {last} are typing...'
values={{
users: (typing.join(', ')),
last
}}
/>
);
}
}
};
renderDisabledSendButton = () => {
const {theme} = this.props;
const style = getStyleSheet(theme);
@ -470,21 +329,16 @@ class PostTextbox extends PureComponent {
placeholder = {id: 'create_post.write', defaultMessage: 'Write a message...'};
}
let fileUpload = null;
let attachmentButton = null;
const inputContainerStyle = [style.inputContainer];
if (canUploadFiles) {
fileUpload = (
<TouchableOpacity
onPress={this.showFileAttachmentOptions}
style={style.buttonContainer}
>
<Icon
size={30}
style={style.attachIcon}
color={changeOpacity(theme.centerChannelColor, 0.9)}
name='md-add'
/>
</TouchableOpacity>
attachmentButton = (
<AttachmentButton
blurTextBox={this.blur}
theme={theme}
navigator={this.props.navigator}
uploadFiles={this.handleUploadFiles}
/>
);
} else {
inputContainerStyle.push(style.inputContainerWithoutFileUpload);
@ -498,15 +352,7 @@ class PostTextbox extends PureComponent {
>
{textValue + ' '}
</Text>
<View>
<Text
style={[style.typing]}
ellipsizeMode='tail'
numberOfLines={1}
>
{this.renderTyping()}
</Text>
</View>
<Typing/>
<FileUploadPreview
channelId={this.props.channelId}
files={this.props.files}
@ -515,11 +361,11 @@ class PostTextbox extends PureComponent {
/>
<Autocomplete
ref={this.attachAutocomplete}
onChangeText={this.props.onChangeText}
onChangeText={this.changeDraft}
rootId={this.props.rootId}
/>
<View style={style.inputWrapper}>
{fileUpload}
{attachmentButton}
<View style={inputContainerStyle}>
<TextInput
ref='input'
@ -546,15 +392,6 @@ class PostTextbox extends PureComponent {
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
buttonContainer: {
height: Platform.select({
ios: 34,
android: 36
}),
width: 45,
alignItems: 'center',
justifyContent: 'center'
},
disableButton: {
backgroundColor: changeOpacity(theme.buttonBg, 0.3)
},
@ -594,12 +431,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.20)
},
attachIcon: {
marginTop: Platform.select({
ios: 2,
android: 0
})
},
sendButton: {
backgroundColor: theme.buttonBg,
borderRadius: 18,
@ -619,13 +450,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
width: 29
}
})
},
typing: {
paddingLeft: 10,
fontSize: 11,
marginBottom: 5,
color: theme.centerChannelColor,
backgroundColor: 'transparent'
}
};
});

View file

@ -21,10 +21,10 @@ import PostListRetry from 'app/components/post_list_retry';
import StatusBar from 'app/components/status_bar';
import {wrapWithPreventDoubleTap} from 'app/utils/tap';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import PostTextbox from 'app/components/post_textbox';
import ChannelDrawerButton from './channel_drawer_button';
import ChannelPostList from './channel_post_list';
import ChannelPostTextbox from './channel_post_textbox';
import ChannelSearchButton from './channel_search_button';
import ChannelTitle from './channel_title';
@ -92,7 +92,7 @@ class Channel extends PureComponent {
};
blurPostTextBox = () => {
this.postTextbox.getWrappedInstance().blur();
this.postTextbox.getWrappedInstance().getWrappedInstance().blur();
};
goToChannelInfo = wrapWithPreventDoubleTap(() => {
@ -219,8 +219,9 @@ class Channel extends PureComponent {
navigator={navigator}
/>
</View>
<ChannelPostTextbox
<PostTextbox
ref={this.attachPostTextbox}
onChangeText={this.handleDraftChanged}
channelId={currentChannelId}
navigator={navigator}
/>

View file

@ -1,39 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import PostTextbox from 'app/components/post_textbox';
export default class ChannelPostTextbox extends React.PureComponent {
static propTypes = {
channelId: PropTypes.string.isRequired,
draft: PropTypes.object.isRequired,
navigator: PropTypes.object.isRequired,
actions: PropTypes.shape({
handlePostDraftChanged: PropTypes.func.isRequired
}).isRequired
};
handleDraftChanged = (value) => {
this.props.actions.handlePostDraftChanged(this.props.channelId, value);
};
blur = () => {
this.refs.postTextbox.getWrappedInstance().getWrappedInstance().blur();
};
render() {
return (
<PostTextbox
ref='postTextbox'
files={this.props.draft.files}
value={this.props.draft.draft}
channelId={this.props.channelId}
onChangeText={this.handleDraftChanged}
navigator={this.props.navigator}
/>
);
}
}

View file

@ -1,28 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {
handlePostDraftChanged
} from 'app/actions/views/channel';
import ChannelPostTextbox from './channel_post_textbox';
function mapStateToProps(state, ownProps) {
return {
draft: state.views.channel.drafts[ownProps.channelId] || {},
...ownProps
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
handlePostDraftChanged
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps, null, {withRef: true})(ChannelPostTextbox);

View file

@ -4,7 +4,6 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {handleCommentDraftChanged} from 'app/actions/views/thread';
import {getStatusBarHeight} from 'app/selectors/device';
import {getTheme} from 'app/selectors/preferences';
@ -21,15 +20,12 @@ function makeMapStateToProps() {
return function mapStateToProps(state, ownProps) {
const posts = getPostsForThread(state, ownProps);
const threadDraft = state.views.thread.drafts[ownProps.rootId];
return {
...ownProps,
channelId: ownProps.channelId,
myMember: getMyCurrentChannelMembership(state),
rootId: ownProps.rootId,
draft: threadDraft.draft,
files: threadDraft.files,
posts,
statusBarHeight: getStatusBarHeight(state),
theme: getTheme(state)
@ -40,7 +36,6 @@ function makeMapStateToProps() {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
handleCommentDraftChanged,
selectPost
}, dispatch)
};

View file

@ -13,15 +13,12 @@ import {makeStyleSheetFromTheme} from 'app/utils/theme';
export default class Thread extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
handleCommentDraftChanged: PropTypes.func.isRequired,
selectPost: PropTypes.func.isRequired
}).isRequired,
channelId: PropTypes.string.isRequired,
navigator: PropTypes.object,
myMember: PropTypes.object.isRequired,
files: PropTypes.array,
rootId: PropTypes.string.isRequired,
draft: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
posts: PropTypes.array.isRequired,
statusBarHeight: PropTypes.number
@ -39,15 +36,9 @@ export default class Thread extends PureComponent {
this.props.actions.selectPost('');
}
handleDraftChanged = (value) => {
this.props.actions.handleCommentDraftChanged(this.props.rootId, value);
};
render() {
const {
channelId,
draft,
files,
myMember,
navigator,
posts,
@ -78,10 +69,7 @@ export default class Thread extends PureComponent {
/>
<PostTextbox
rootId={rootId}
value={draft}
files={files}
channelId={channelId}
onChangeText={this.handleDraftChanged}
navigator={navigator}
/>
</KeyboardLayout>

23
app/selectors/views.js Normal file
View file

@ -0,0 +1,23 @@
import {createSelector} from 'reselect';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
function getChannelDrafts(state) {
return state.views.channel.drafts;
}
function getThreadDrafts(state) {
return state.views.thread.drafts;
}
export const getCurrentChannelDraft = createSelector(
getChannelDrafts,
getCurrentChannel,
(drafts, currentChannel) => drafts[currentChannel.id]
);
export const getThreadDraft = createSelector(
getThreadDrafts,
(state, rootId) => rootId,
(drafts, rootId) => drafts[rootId]
);