// 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, AppState, BackHandler, findNodeHandle, Keyboard, NativeModules, Platform, Text, TextInput, 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 Fade from 'app/components/fade'; import FormattedMarkdownText from 'app/components/formatted_markdown_text'; import FormattedText from 'app/components/formatted_text'; import SendButton from 'app/components/send_button'; import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_CONTENT_HEIGHT, MAX_FILE_COUNT} from 'app/constants/post_textbox'; import {t} from 'app/utils/i18n'; import {confirmOutOfOfficeDisabled} from 'app/utils/status'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; const {RNTextInputReset} = NativeModules; export default class PostTextBoxBase 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, channelDisplayName: PropTypes.string, 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, 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, cursorPositionEvent: PropTypes.string, valueEvent: PropTypes.string, }; static defaultProps = { files: [], rootId: '', value: '', }; static contextTypes = { intl: intlShape, }; constructor(props) { super(props); this.input = React.createRef(); this.state = { cursorPosition: 0, keyboardType: 'default', sendingMessage: false, top: 0, value: props.value, }; } componentDidMount() { const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT; EventEmitter.on(event, this.handleInsertTextToDraft); AppState.addEventListener('change', this.handleAppStateChange); 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); AppState.removeEventListener('change', this.handleAppStateChange); if (Platform.OS === 'android') { Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard); BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack); } } blur = () => { if (this.input.current) { this.input.current.blur(); } }; canSend = () => { const {files, maxMessageLength, uploadFileRequestStatus} = this.props; const {value} = this.state; const messageLength = value.trim().length; if (messageLength > maxMessageLength) { return false; } if (files.length) { const loadingComplete = !this.isFileLoading(); const alreadySendingFiles = uploadFileRequestStatus === RequestStatus.STARTED; return loadingComplete && !alreadySendingFiles; } return messageLength > 0; }; 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, }) ); } }; getAttachmentButton = () => { const {canUploadFiles, channelIsReadOnly, files, maxFileSize, theme} = this.props; let attachmentButton = null; if (canUploadFiles && !channelIsReadOnly) { attachmentButton = ( ); } return attachmentButton; }; getInputContainerStyle = () => { const {canUploadFiles, channelIsReadOnly, theme} = this.props; const style = getStyleSheet(theme); const inputContainerStyle = [style.inputContainer]; if (!canUploadFiles) { inputContainerStyle.push(style.inputContainerWithoutFileUpload); } if (channelIsReadOnly) { inputContainerStyle.push(style.readonlyContainer); } return inputContainerStyle; }; getPlaceHolder = () => { const {channelIsReadOnly, rootId} = this.props; 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 to {channelDisplayName}'}; } return placeholder; }; handleAndroidKeyboard = () => { this.blur(); }; handleAndroidBack = () => { const {channelId, files, rootId} = this.props; if (files.length) { this.props.actions.handleRemoveLastFile(channelId, rootId); return true; } return false; }; handleAppStateChange = (nextAppState) => { if (nextAppState !== 'active') { this.changeDraft(this.state.value); } }; handleEndEditing = (e) => { if (e && e.nativeEvent) { this.changeDraft(e.nativeEvent.text || ''); } }; handlePostDraftSelectionChanged = (event) => { const cursorPosition = event.nativeEvent.selection.end; const {cursorPositionEvent} = this.props; if (cursorPositionEvent) { EventEmitter.emit(cursorPositionEvent, cursorPosition); } this.setState({ cursorPosition, }); }; handleSendMessage = () => { if (!this.canSend() || this.state.sendingMessage) { return; } this.setState({sendingMessage: true}); 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'}), onPress: () => { this.setState({sendingMessage: false}); }, }, { 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, autocomplete = false) => { const { actions, channelId, rootId, cursorPositionEvent, valueEvent, } = this.props; if (valueEvent) { EventEmitter.emit(valueEvent, value); } const nextState = {value}; // Workaround for some Android keyboards that don't play well with cursors (e.g. Samsung keyboards) if (autocomplete && this.input?.current) { if (Platform.OS === 'android') { RNTextInputReset.resetKeyboardInput(findNodeHandle(this.input.current)); } else { nextState.cursorPosition = value.length; if (cursorPositionEvent) { EventEmitter.emit(cursorPositionEvent, nextState.cursorPosition); } } } this.checkMessageLength(value); this.setState(nextState); if (value) { actions.userTyping(channelId, rootId); } }; handleUploadFiles = (images) => { this.props.actions.initUploadFiles(images, this.props.rootId); }; isFileLoading() { const {files} = this.props; return files.some((file) => file.loading); } isSendButtonVisible() { return this.canSend() || this.isFileLoading(); } isSendButtonEnabled() { return this.canSend() && !this.state.sendingMessage; } sendMessage = () => { const {actions, currentUserId, channelId, files, rootId} = this.props; const {value} = this.state; 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); } } if (Platform.OS === 'ios') { // On iOS, if the PostTextbox height increases from its // initial height (due to a multiline post or a post whose // message wraps, for example), then when the text is cleared // the PostTextbox height decrease will be animated. This // animation in conjunction with the PostList animation as it // receives the newly created post is causing issues in the iOS // PostList component as it fails to properly react to its content // size changes. While a proper fix is determined for the PostList // component, a small delay in triggering the height decrease // animation gives the PostList enough time to first handle content // size changes from the new post. setTimeout(() => { this.handleTextChange(''); this.setState({sendingMessage: false}); }, 250); } else { this.handleTextChange(''); this.setState({sendingMessage: false}); } this.changeDraft(''); let callback; if (Platform.OS === 'android') { // Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages // are typed successively without blurring the input const nextState = { keyboardType: 'email-address', }; callback = () => this.setState({keyboardType: 'default'}); this.setState(nextState, callback); } EventEmitter.emit('scroll-to-bottom'); }; 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.setState({sendingMessage: false}); this.changeDraft(''); }; onShowFileMaxWarning = () => { EventEmitter.emit('fileMaxWarning'); }; 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, }); EventEmitter.emit('fileSizeWarning', fileSizeWarning); setTimeout(() => { EventEmitter.emit('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, }); }; renderDeactivatedChannel = () => { const {intl} = this.context; const style = getStyleSheet(this.props.theme); return ( {intl.formatMessage({ id: 'create_post.deactivated', defaultMessage: 'You are viewing an archived channel with a deactivated user.', })} ); } renderTextBox = () => { const {intl} = this.context; const {channelDisplayName, channelIsArchived, channelIsLoading, channelIsReadOnly, theme} = this.props; const style = getStyleSheet(theme); if (channelIsArchived) { return this.archivedView(theme, style); } const {value} = this.state; const textValue = channelIsLoading ? '' : value; const placeholder = this.getPlaceHolder(); return ( {this.getAttachmentButton()} ); }; } const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { input: { color: '#000', flex: 1, fontSize: 14, maxHeight: MAX_CONTENT_HEIGHT, paddingBottom: 8, paddingLeft: 12, paddingRight: 12, paddingTop: 8, }, 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, }, 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', }, readonlyContainer: { marginLeft: 10, }, }; });