// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { Alert, BackHandler, Keyboard, Platform, Text, TextInput, TouchableOpacity, View } from 'react-native'; 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 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; const IS_REACTION_REGEX = /(^\+:([^:\s]*):)$/i; class PostTextbox extends PureComponent { static propTypes = { 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, userTyping: PropTypes.func.isRequired, handlePostDraftSelectionChanged: PropTypes.func.isRequired, handleCommentDraftSelectionChanged: PropTypes.func.isRequired }).isRequired, canUploadFiles: PropTypes.bool.isRequired, channelId: PropTypes.string.isRequired, channelIsLoading: PropTypes.bool.isRequired, currentUserId: PropTypes.string.isRequired, files: PropTypes.array, intl: intlShape.isRequired, navigator: PropTypes.object, rootId: PropTypes.string, theme: PropTypes.object.isRequired, uploadFileRequestStatus: PropTypes.string.isRequired, value: PropTypes.string.isRequired }; static defaultProps = { files: [], rootId: '', value: '' }; state = { contentHeight: INITIAL_HEIGHT, inputWidth: null, keyboardType: 'default' }; componentDidMount() { if (Platform.OS === 'android') { Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard); BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack); } } componentWillReceiveProps(nextProps) { const {intl} = this.props; const {value} = nextProps; const valueLength = value.trim().length; if (valueLength > MAX_MESSAGE_LENGTH) { Alert.alert( intl.formatMessage({ id: 'mobile.message_length.title', defaultMessage: 'Message Length' }), intl.formatMessage({ id: 'mobile.message_length.message', defaultMessage: 'Your current message is too long. Current character count: {max}/{count}' }, { max: MAX_MESSAGE_LENGTH, count: valueLength }) ); } } componentWillUnmount() { if (Platform.OS === 'android') { Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard); BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack); } } blur = () => { this.refs.input.blur(); }; canSend = () => { const {files, uploadFileRequestStatus, value} = this.props; const valueLength = value.trim().length; if (files.length) { return valueLength <= MAX_MESSAGE_LENGTH && uploadFileRequestStatus !== RequestStatus.STARTED && files.filter((f) => !f.failed).length > 0; } return valueLength > 0 && valueLength <= MAX_MESSAGE_LENGTH; }; handleAndroidKeyboard = () => { this.blur(); }; handleAndroidBack = () => { const {channelId, files, rootId} = this.props; if (files.length) { this.props.actions.handleRemoveLastFile(channelId, rootId); return true; } return false; }; handleSendMessage = () => { if (!this.canSend()) { return; } const {files, value} = this.props; const isReactionMatch = value.match(IS_REACTION_REGEX); if (isReactionMatch) { const emoji = isReactionMatch[2]; this.sendReaction(emoji); return; } const hasFailedImages = files.some((f) => f.failed); if (hasFailedImages) { const {intl} = this.props; Alert.alert( intl.formatMessage({ id: 'mobile.post_textbox.uploadFailedTitle', defaultMessage: 'Attachment failure' }), intl.formatMessage({ id: 'mobile.post_textbox.uploadFailedDesc', defaultMessage: 'Some attachments failed to upload to the server, Are you sure you want to post the message?' }), [{ text: intl.formatMessage({id: 'mobile.channel_info.alertNo', defaultMessage: 'No'}) }, { text: intl.formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}), onPress: this.sendMessage }], ); } else { this.sendMessage(); } }; sendMessage = () => { const {actions, currentUserId, channelId, files, rootId, value} = this.props; const postFiles = files.filter((f) => !f.failed); const post = { user_id: currentUserId, channel_id: channelId, root_id: rootId, parent_id: rootId, message: value }; actions.createPost(post, postFiles); this.handleTextChange(''); if (postFiles.length) { actions.handleClearFiles(channelId, rootId); } // Shrink the input textbox since the layout events lag slightly const nextState = { contentHeight: INITIAL_HEIGHT }; // Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages // are typed successively without blurring the input let callback; if (Platform.OS === 'android') { nextState.keyboardType = 'email-address'; callback = () => this.setState({keyboardType: 'default'}); } this.setState(nextState, callback); }; 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); this.handleTextChange(''); } handleTextChange = (text) => { const { actions, channelId, rootId } = this.props; this.changeDraft(text); actions.userTyping(channelId, rootId); }; handleContentSizeChange = (event) => { let contentHeight = event.nativeEvent.layout.height; if (contentHeight < INITIAL_HEIGHT) { contentHeight = INITIAL_HEIGHT; } this.setState({ contentHeight }); }; handleInputSizeChange = (event) => { this.setState({ inputWidth: event.nativeEvent.layout.width }); }; handleSubmit = () => { // Workaround for android as the multiline is not working if (Platform.OS === 'android') { if (this.timeout) { clearTimeout(this.timeout); } this.timeout = setTimeout(() => { let {value: msg} = this.props; msg += '\n'; this.handleTextChange(msg); }, 10); } }; attachAutocomplete = (c) => { this.autocomplete = c; }; renderDisabledSendButton = () => { const {theme} = this.props; const style = getStyleSheet(theme); return ( ); } renderSendButton = () => { const {theme, uploadFileRequestStatus} = this.props; const style = getStyleSheet(theme); if (uploadFileRequestStatus === RequestStatus.STARTED) { return this.renderDisabledSendButton(); } else if (this.canSend()) { return ( ); } return null; } handlePostDraftSelectionChanged = (event) => { const cursorPosition = event.nativeEvent.selection.end; if (this.props.rootId) { this.props.actions.handleCommentDraftSelectionChanged(this.props.rootId, cursorPosition); } else { this.props.actions.handlePostDraftSelectionChanged(this.props.channelId, cursorPosition); } this.autocomplete.handleSelectionChange(event); } render() { const { canUploadFiles, channelIsLoading, intl, theme, value } = this.props; const style = getStyleSheet(theme); const textInputHeight = Math.min(this.state.contentHeight, MAX_CONTENT_HEIGHT); const textValue = channelIsLoading ? '' : value; let placeholder; if (this.props.rootId) { placeholder = {id: 'create_comment.addComment', defaultMessage: 'Add a comment...'}; } else { placeholder = {id: 'create_post.write', defaultMessage: 'Write a message...'}; } let attachmentButton = null; const inputContainerStyle = [style.inputContainer]; if (canUploadFiles) { attachmentButton = ( ); } else { inputContainerStyle.push(style.inputContainerWithoutFileUpload); } return ( {textValue + ' '} {attachmentButton} {this.renderSendButton()} ); } } const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { disableButton: { backgroundColor: changeOpacity(theme.buttonBg, 0.3) }, input: { color: '#000', flex: 1, fontSize: 14, paddingBottom: 8, paddingLeft: 12, paddingRight: 12, paddingTop: 8, textAlignVertical: 'top' }, hidden: { position: 'absolute', top: 10000, // way off screen left: 10000, // way off screen backgroundColor: 'transparent', borderColor: 'transparent', color: 'transparent' }, inputContainer: { flex: 1, flexDirection: 'row', backgroundColor: '#fff', alignItems: 'flex-end', marginRight: 10 }, inputContainerWithoutFileUpload: { marginLeft: 10 }, inputWrapper: { alignItems: 'flex-end', flexDirection: 'row', paddingVertical: 4, backgroundColor: theme.centerChannelBg, borderTopWidth: 1, borderTopColor: changeOpacity(theme.centerChannelColor, 0.20) }, sendButton: { backgroundColor: theme.buttonBg, borderRadius: 18, marginRight: 5, height: 28, width: 28, alignItems: 'center', justifyContent: 'center', paddingLeft: 2, ...Platform.select({ ios: { marginBottom: 3 }, android: { height: 29, marginBottom: 4, width: 29 } }) } }; }); export default injectIntl(PostTextbox, {withRef: true});