diff --git a/app/components/emoji/emoji.js b/app/components/emoji/emoji.js index 2b0510658..48fa36557 100644 --- a/app/components/emoji/emoji.js +++ b/app/components/emoji/emoji.js @@ -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 + }); + } }); } diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index 2da5651db..c34c8720c 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -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 + } +}); diff --git a/app/components/post_textbox/components/attachment_button.js b/app/components/post_textbox/components/attachment_button.js new file mode 100644 index 000000000..8e219bd72 --- /dev/null +++ b/app/components/post_textbox/components/attachment_button.js @@ -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 ( + + + + ); + } +} + +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' + } +}); diff --git a/app/components/post_textbox/components/typing/index.js b/app/components/post_textbox/components/typing/index.js new file mode 100644 index 000000000..220559627 --- /dev/null +++ b/app/components/post_textbox/components/typing/index.js @@ -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); diff --git a/app/components/post_textbox/components/typing/typing.js b/app/components/post_textbox/components/typing/typing.js new file mode 100644 index 000000000..0586b34fa --- /dev/null +++ b/app/components/post_textbox/components/typing/typing.js @@ -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 ( + + ); + default: { + const last = nextTyping.pop(); + return ( + + ); + } + } + }; + + render() { + const style = getStyleSheet(this.props.theme); + + return ( + + + {this.renderTyping()} + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + typing: { + paddingLeft: 10, + paddingTop: 3, + fontSize: 11, + marginBottom: 5, + color: theme.centerChannelColor, + backgroundColor: 'transparent' + } + }; +}); diff --git a/app/components/post_textbox/index.js b/app/components/post_textbox/index.js index 26f0ee94c..6dd89ff82 100644 --- a/app/components/post_textbox/index.js +++ b/app/components/post_textbox/index.js @@ -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 diff --git a/app/components/post_textbox/post_textbox.js b/app/components/post_textbox/post_textbox.js index ce9a0e0e8..04358f15b 100644 --- a/app/components/post_textbox/post_textbox.js +++ b/app/components/post_textbox/post_textbox.js @@ -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 ( - - ); - default: { - const last = typing.pop(); - return ( - - ); - } - } - }; - 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 = ( - - - + attachmentButton = ( + ); } else { inputContainerStyle.push(style.inputContainerWithoutFileUpload); @@ -498,15 +352,7 @@ class PostTextbox extends PureComponent { > {textValue + ' '} - - - {this.renderTyping()} - - + - {fileUpload} + {attachmentButton} { 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' } }; }); diff --git a/app/screens/channel/channel.js b/app/screens/channel/channel.js index 2ce355ffe..f21850477 100644 --- a/app/screens/channel/channel.js +++ b/app/screens/channel/channel.js @@ -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} /> - diff --git a/app/screens/channel/channel_post_textbox/channel_post_textbox.js b/app/screens/channel/channel_post_textbox/channel_post_textbox.js deleted file mode 100644 index 294447f0b..000000000 --- a/app/screens/channel/channel_post_textbox/channel_post_textbox.js +++ /dev/null @@ -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 ( - - ); - } -} diff --git a/app/screens/channel/channel_post_textbox/index.js b/app/screens/channel/channel_post_textbox/index.js deleted file mode 100644 index 1f2b6af2f..000000000 --- a/app/screens/channel/channel_post_textbox/index.js +++ /dev/null @@ -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); diff --git a/app/screens/thread/index.js b/app/screens/thread/index.js index f91deab2d..72d19fa77 100644 --- a/app/screens/thread/index.js +++ b/app/screens/thread/index.js @@ -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) }; diff --git a/app/screens/thread/thread.js b/app/screens/thread/thread.js index e6b9c20f7..7642038ae 100644 --- a/app/screens/thread/thread.js +++ b/app/screens/thread/thread.js @@ -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 { /> diff --git a/app/selectors/views.js b/app/selectors/views.js new file mode 100644 index 000000000..124049104 --- /dev/null +++ b/app/selectors/views.js @@ -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] +);