// Copyright (c) 2015-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 {intlShape} from 'react-intl'; import Button from 'react-native-button'; import {General, RequestStatus} from 'mattermost-redux/constants'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; import {getFormattedFileSize} from 'mattermost-redux/utils/file_utils'; import AttachmentButton from 'app/components/attachment_button'; import Autocomplete from 'app/components/autocomplete'; import FileUploadPreview from 'app/components/file_upload_preview'; import {INITIAL_HEIGHT, INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_CONTENT_HEIGHT, MAX_FILE_COUNT} from 'app/constants/post_textbox'; import {confirmOutOfOfficeDisabled} from 'app/utils/status'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import {t} from 'app/utils/i18n'; import FormattedMarkdownText from 'app/components/formatted_markdown_text'; import FormattedText from 'app/components/formatted_text'; import Typing from './components/typing'; const AUTOCOMPLETE_MARGIN = 20; const AUTOCOMPLETE_MAX_HEIGHT = 200; let PaperPlane = null; export default class PostTextbox extends PureComponent { static propTypes = { actions: PropTypes.shape({ addReactionToLatestPost: PropTypes.func.isRequired, createPost: PropTypes.func.isRequired, executeCommand: PropTypes.func.isRequired, handleCommentDraftChanged: PropTypes.func.isRequired, handlePostDraftChanged: PropTypes.func.isRequired, handleClearFiles: PropTypes.func.isRequired, handleClearFailedFiles: PropTypes.func.isRequired, handleRemoveLastFile: PropTypes.func.isRequired, initUploadFiles: PropTypes.func.isRequired, userTyping: PropTypes.func.isRequired, handleCommentDraftSelectionChanged: PropTypes.func.isRequired, setStatus: PropTypes.func.isRequired, selectPenultimateChannel: PropTypes.func.isRequired, }).isRequired, canUploadFiles: PropTypes.bool.isRequired, channelId: PropTypes.string.isRequired, channelTeamId: PropTypes.string.isRequired, channelIsLoading: PropTypes.bool, channelIsReadOnly: PropTypes.bool.isRequired, currentUserId: PropTypes.string.isRequired, deactivatedChannel: PropTypes.bool.isRequired, files: PropTypes.array, maxFileSize: PropTypes.number.isRequired, maxMessageLength: PropTypes.number.isRequired, navigator: PropTypes.object, rootId: PropTypes.string, theme: PropTypes.object.isRequired, uploadFileRequestStatus: PropTypes.string.isRequired, value: PropTypes.string.isRequired, userIsOutOfOffice: PropTypes.bool.isRequired, channelIsArchived: PropTypes.bool, onCloseChannel: PropTypes.func, }; static defaultProps = { files: [], rootId: '', value: '', }; static contextTypes = { intl: intlShape, }; constructor(props) { super(props); this.state = { contentHeight: INITIAL_HEIGHT, cursorPosition: 0, keyboardType: 'default', fileSizeWarning: null, top: 0, value: props.value, showFileMaxWarning: false, }; } componentDidMount() { const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT; EventEmitter.on(event, this.handleInsertTextToDraft); if (Platform.OS === 'android') { Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard); BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack); } } componentWillReceiveProps(nextProps) { if (nextProps.channelId !== this.props.channelId || nextProps.rootId !== this.props.rootId) { this.setState({value: nextProps.value}); } } componentWillUnmount() { const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT; EventEmitter.off(event, this.handleInsertTextToDraft); if (Platform.OS === 'android') { Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard); BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack); } } blur = () => { if (this.refs.input) { this.refs.input.blur(); } }; canSend = () => { const {files, maxMessageLength, uploadFileRequestStatus} = this.props; const {value} = this.state; const valueLength = value.trim().length; if (files.length) { const filesLoading = []; files.forEach((file) => { if (file.loading) { filesLoading.push(file); } }); const loadingComplete = filesLoading.length === 0; return valueLength <= maxMessageLength && uploadFileRequestStatus !== RequestStatus.STARTED && loadingComplete; } return valueLength > 0 && valueLength <= maxMessageLength; }; changeDraft = (text) => { const { actions, channelId, rootId, } = this.props; if (rootId) { actions.handleCommentDraftChanged(rootId, text); } else { actions.handlePostDraftChanged(channelId, text); } }; checkMessageLength = (value) => { const {intl} = this.context; const {maxMessageLength} = this.props; const valueLength = value.trim().length; if (valueLength > maxMessageLength) { 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: maxMessageLength, count: valueLength, }) ); } }; handleAndroidKeyboard = () => { this.blur(); }; handleAndroidBack = () => { const {channelId, files, rootId} = this.props; if (files.length) { this.props.actions.handleRemoveLastFile(channelId, rootId); return true; } return false; }; handleContentSizeChange = (event) => { if (Platform.OS === 'android') { let contentHeight = event.nativeEvent.contentSize.height; if (contentHeight < INITIAL_HEIGHT) { contentHeight = INITIAL_HEIGHT; } this.setState({ contentHeight, }); } }; handleEndEditing = (e) => { if (e && e.nativeEvent) { this.changeDraft(e.nativeEvent.text || ''); } }; handlePostDraftSelectionChanged = (event) => { const cursorPosition = event.nativeEvent.selection.end; this.setState({ cursorPosition, }); }; handleSendMessage = () => { if (!this.canSend()) { return; } const {actions, channelId, files, rootId} = this.props; const {value} = this.state; const isReactionMatch = value.match(IS_REACTION_REGEX); if (isReactionMatch) { const emoji = isReactionMatch[2]; this.sendReaction(emoji); return; } const hasFailedAttachments = files.some((f) => f.failed); if (hasFailedAttachments) { const {intl} = this.context; 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: () => { // Remove only failed files actions.handleClearFailedFiles(channelId, rootId); this.sendMessage(); }, }], ); } else { this.sendMessage(); } }; handleInsertTextToDraft = (text) => { const {cursorPosition, value} = this.state; let completed; if (value.length === 0) { completed = text; } else { const firstPart = value.substring(0, cursorPosition); const secondPart = value.substring(cursorPosition); completed = `${firstPart}${text}${secondPart}`; } this.setState({ value: completed, }); } handleTextChange = (value) => { const { actions, channelId, rootId, } = this.props; this.checkMessageLength(value); this.setState({value}); if (value) { actions.userTyping(channelId, rootId); } }; handleUploadFiles = (images) => { this.props.actions.initUploadFiles(images, this.props.rootId); }; renderSendButton = () => { const {files, theme} = this.props; const style = getStyleSheet(theme); const canSend = this.canSend(); const imagesLoading = files.filter((f) => f.loading).length > 0; let button = null; if (canSend || imagesLoading) { if (!PaperPlane) { PaperPlane = require('app/components/paper_plane').default; } const icon = ( ); if (imagesLoading) { button = ( {icon} ); } else if (canSend) { button = ( {icon} ); } } return button; }; sendMessage = () => { const {actions, currentUserId, channelId, files, rootId} = this.props; const {intl} = this.context; const {value} = this.state; if (files.length === 0 && !value) { Alert.alert( intl.formatMessage({ id: 'mobile.post_textbox.empty.title', defaultMessage: 'Empty Message', }), intl.formatMessage({ id: 'mobile.post_textbox.empty.message', defaultMessage: 'You are trying to send an empty message.\nPlease make sure you have a message or at least one attached file.', }), [{ text: intl.formatMessage({id: 'mobile.post_textbox.empty.ok', defaultMessage: 'OK'}), }], ); } else { if (value.indexOf('/') === 0) { this.sendCommand(value); } else { 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); if (postFiles.length) { actions.handleClearFiles(channelId, rootId); } } this.handleTextChange(''); this.changeDraft(''); // 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); } }; getStatusFromSlashCommand = (message) => { const tokens = message.split(' '); if (tokens.length > 0) { return tokens[0].substring(1); } return ''; }; isStatusSlashCommand = (command) => { return command === General.ONLINE || command === General.AWAY || command === General.DND || command === General.OFFLINE; }; updateStatus = (status) => { const {actions, currentUserId} = this.props; actions.setStatus({ user_id: currentUserId, status, }); }; sendCommand = async (msg) => { const {intl} = this.context; const {userIsOutOfOffice, actions, channelId, rootId} = this.props; const status = this.getStatusFromSlashCommand(msg); if (userIsOutOfOffice && this.isStatusSlashCommand(status)) { confirmOutOfOfficeDisabled(intl, status, this.updateStatus); return; } const {error} = await actions.executeCommand(msg, channelId, rootId); if (error) { this.handleTextChange(msg); this.changeDraft(msg); Alert.alert( intl.formatMessage({ id: 'mobile.commands.error_title', defaultMessage: 'Error Executing Command', }), error.message ); } }; sendReaction = (emoji) => { const {actions, rootId} = this.props; actions.addReactionToLatestPost(emoji, rootId); this.handleTextChange(''); this.changeDraft(''); }; onShowFileMaxWarning = () => { this.setState({showFileMaxWarning: true}, () => { setTimeout(() => { this.setState({showFileMaxWarning: false}); }, 3000); }); }; onShowFileSizeWarning = (filename) => { const {formatMessage} = this.context.intl; const fileSizeWarning = formatMessage({ id: 'file_upload.fileAbove', defaultMessage: 'File above {max}MB cannot be uploaded: {filename}', }, { max: getFormattedFileSize({size: this.props.maxFileSize}), filename, }); this.setState({fileSizeWarning}, () => { setTimeout(() => { this.setState({fileSizeWarning: null}); }, 3000); }); }; onCloseChannelPress = () => { const {onCloseChannel, channelTeamId} = this.props; this.props.actions.selectPenultimateChannel(channelTeamId); if (onCloseChannel) { onCloseChannel(); } }; archivedView = (theme, style) => { return ( ); }; handleLayout = (e) => { this.setState({ top: e.nativeEvent.layout.y, }); }; render() { const {intl} = this.context; const { canUploadFiles, channelId, channelIsLoading, channelIsReadOnly, deactivatedChannel, files, maxFileSize, navigator, rootId, theme, channelIsArchived, } = this.props; const style = getStyleSheet(theme); if (deactivatedChannel) { return ( {intl.formatMessage({ id: 'create_post.deactivated', defaultMessage: 'You are viewing an archived channel with a deactivated user.', })} ); } const { contentHeight, cursorPosition, fileSizeWarning, showFileMaxWarning, top, value, } = this.state; const textInputHeight = Math.min(contentHeight, MAX_CONTENT_HEIGHT); const textValue = channelIsLoading ? '' : value; let placeholder; if (channelIsReadOnly) { placeholder = {id: t('mobile.create_post.read_only'), defaultMessage: 'This channel is read-only.'}; } else if (rootId) { placeholder = {id: t('create_comment.addComment'), defaultMessage: 'Add a comment...'}; } else { placeholder = {id: t('create_post.write'), defaultMessage: 'Write a message...'}; } let attachmentButton = null; const inputContainerStyle = [style.inputContainer]; if (canUploadFiles) { attachmentButton = ( ); } else { inputContainerStyle.push(style.inputContainerWithoutFileUpload); } return ( {!channelIsArchived && ( {!channelIsReadOnly && attachmentButton} {this.renderSendButton()} )} {channelIsArchived && this.archivedView(theme, style)} ); } } 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: 'stretch', marginRight: 10, }, inputContainerWithoutFileUpload: { marginLeft: 10, }, inputWrapper: { alignItems: 'flex-end', flexDirection: 'row', paddingVertical: 4, backgroundColor: theme.centerChannelBg, borderTopWidth: 1, borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), }, deactivatedMessage: { color: changeOpacity(theme.centerChannelColor, 0.8), fontSize: 15, lineHeight: 22, alignItems: 'flex-end', flexDirection: 'row', paddingVertical: 4, backgroundColor: theme.centerChannelBg, borderTopWidth: 1, borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), marginLeft: 10, marginRight: 10, }, sendButtonContainer: { justifyContent: 'flex-end', paddingHorizontal: 5, paddingVertical: 3, }, sendButton: { backgroundColor: theme.buttonBg, borderRadius: 18, height: 28, width: 28, alignItems: 'center', justifyContent: 'center', }, archivedWrapper: { paddingLeft: 20, paddingRight: 20, paddingTop: 10, paddingBottom: 10, borderTopWidth: 1, borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), }, archivedText: { textAlign: 'center', color: theme.centerChannelColor, }, closeButton: { backgroundColor: theme.buttonBg, alignItems: 'center', paddingTop: 5, paddingBottom: 5, borderRadius: 4, marginTop: 10, height: 40, }, closeButtonText: { marginTop: 7, color: 'white', fontWeight: 'bold', }, }; });