diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index 5c22c74b0..6ff23511a 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -31,7 +31,7 @@ import EventEmitter from '@mm-redux/utils/event_emitter'; import {loadSidebarDirectMessagesProfiles} from '@actions/helpers/channels'; import {getPosts, getPostsBefore, getPostsSince, getPostThread, loadUnreadChannelPosts} from '@actions/views/post'; -import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_textbox'; +import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_draft'; import {getChannelReachable} from '@selectors/channel'; import telemetry from '@telemetry'; import {isDirectChannelVisible, isGroupChannelVisible, getChannelSinceValue} from '@utils/channels'; diff --git a/app/components/autocomplete/at_mention/at_mention.js b/app/components/autocomplete/at_mention/at_mention.js index 30e287bb3..17da43c07 100644 --- a/app/components/autocomplete/at_mention/at_mention.js +++ b/app/components/autocomplete/at_mention/at_mention.js @@ -22,7 +22,7 @@ export default class AtMention extends PureComponent { }).isRequired, currentChannelId: PropTypes.string, currentTeamId: PropTypes.string.isRequired, - cursorPosition: PropTypes.number.isRequired, + cursorPosition: PropTypes.number, defaultChannel: PropTypes.object, inChannel: PropTypes.array, isSearch: PropTypes.bool, diff --git a/app/components/autocomplete/channel_mention/channel_mention.js b/app/components/autocomplete/channel_mention/channel_mention.js index f4249e877..25e21705c 100644 --- a/app/components/autocomplete/channel_mention/channel_mention.js +++ b/app/components/autocomplete/channel_mention/channel_mention.js @@ -22,7 +22,7 @@ export default class ChannelMention extends PureComponent { autocompleteChannelsForSearch: PropTypes.func.isRequired, }).isRequired, currentTeamId: PropTypes.string.isRequired, - cursorPosition: PropTypes.number.isRequired, + cursorPosition: PropTypes.number, isSearch: PropTypes.bool, matchTerm: PropTypes.string, maxListHeight: PropTypes.number, diff --git a/app/components/file_upload_preview/file_upload_item/index.js b/app/components/file_upload_preview/file_upload_item/index.js deleted file mode 100644 index 280fc9cb3..000000000 --- a/app/components/file_upload_preview/file_upload_item/index.js +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {bindActionCreators} from 'redux'; -import {connect} from 'react-redux'; - -import {getTheme} from '@mm-redux/selectors/entities/preferences'; - -import {handleRemoveFile, retryFileUpload, uploadComplete, uploadFailed} from 'app/actions/views/file_upload'; - -import FileUploadItem from './file_upload_item'; - -function mapStateToProps(state) { - return { - theme: getTheme(state), - }; -} - -function mapDispatchToProps(dispatch) { - return { - actions: bindActionCreators({ - handleRemoveFile, - retryFileUpload, - uploadComplete, - uploadFailed, - }, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(FileUploadItem); diff --git a/app/components/post_draft/archived/archived.js b/app/components/post_draft/archived/archived.js new file mode 100644 index 000000000..de2214c75 --- /dev/null +++ b/app/components/post_draft/archived/archived.js @@ -0,0 +1,102 @@ +// 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 {View} from 'react-native'; +import Button from 'react-native-button'; + +import {popToRoot} from '@actions/navigation'; +import FormattedMarkdownText from '@components/formatted_markdown_text'; +import FormattedText from '@components/formatted_text'; +import {t} from '@utils/i18n'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +export default class Archived extends PureComponent { + static propTypes = { + deactivated: PropTypes.bool, + rootId: PropTypes.string, + selectPenultimateChannel: PropTypes.func.isRequired, + teamId: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired, + } + + onCloseChannelPress = () => { + const {rootId, teamId} = this.props; + this.props.selectPenultimateChannel(teamId); + if (rootId) { + popToRoot(); + } + }; + + message = () => { + const {deactivated} = this.props; + if (deactivated) { + // only applies to DM's when the user was deactivated + return { + id: t('create_post.deactivated'), + defaultMessage: 'You are viewing an archived channel with a deactivated user.', + }; + } + + return { + id: t('archivedChannelMessage'), + defaultMessage: 'You are viewing an **archived channel**. New messages cannot be posted.', + }; + }; + + render() { + const {theme} = this.props; + const style = getStyleSheet(theme); + + return ( + + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + archivedWrapper: { + paddingLeft: 20, + paddingRight: 20, + paddingTop: 10, + paddingBottom: 10, + borderTopWidth: 1, + backgroundColor: theme.centerChannelBg, + 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', + }, +})); \ No newline at end of file diff --git a/app/components/post_draft/archived/index.js b/app/components/post_draft/archived/index.js new file mode 100644 index 000000000..42625d374 --- /dev/null +++ b/app/components/post_draft/archived/index.js @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {selectPenultimateChannel} from '@actions/views/channel'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; + +import Archived from './archived'; + +function mapStateToProps(state) { + return { + teamId: getCurrentTeamId(state), + }; +} + +const mapDispatchToProps = { + selectPenultimateChannel, +}; + +export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(Archived); diff --git a/app/components/post_textbox/index.js b/app/components/post_draft/index.js similarity index 70% rename from app/components/post_textbox/index.js rename to app/components/post_draft/index.js index 9e544bd0e..28e66f7ae 100644 --- a/app/components/post_textbox/index.js +++ b/app/components/post_draft/index.js @@ -1,7 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; @@ -10,31 +9,35 @@ import {createPost} from '@mm-redux/actions/posts'; import {setStatus} from '@mm-redux/actions/users'; import {getCurrentChannel, isCurrentChannelReadOnly, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels'; import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles'; -import {canUploadFilesOnMobile, getConfig} from '@mm-redux/selectors/entities/general'; +import {getConfig} from '@mm-redux/selectors/entities/general'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users'; import {getChannelTimezones} from '@mm-redux/actions/channels'; import {executeCommand} from 'app/actions/views/command'; import {addReactionToLatestPost} from 'app/actions/views/emoji'; -import {handlePostDraftChanged, selectPenultimateChannel} from 'app/actions/views/channel'; -import {handleClearFiles, handleClearFailedFiles, handleRemoveLastFile, initUploadFiles} from 'app/actions/views/file_upload'; -import {handleCommentDraftChanged, handleCommentDraftSelectionChanged} from 'app/actions/views/thread'; -import {userTyping} from 'app/actions/views/typing'; +import {handleClearFiles, handleClearFailedFiles, initUploadFiles} from 'app/actions/views/file_upload'; import {getCurrentChannelDraft, getThreadDraft} from 'app/selectors/views'; import {getChannelMembersForDm} from 'app/selectors/channel'; import {getAllowedServerMaxFileSize} from 'app/utils/file'; import {isLandscape} from 'app/selectors/device'; -import PostTextbox from './post_textbox'; +import PostDraft from './post_draft'; const MAX_MESSAGE_LENGTH = 4000; export function mapStateToProps(state, ownProps) { const currentDraft = ownProps.rootId ? getThreadDraft(state, ownProps.rootId) : getCurrentChannelDraft(state); const config = getConfig(state); - const currentChannel = getCurrentChannel(state); + const currentUserId = getCurrentUserId(state); + const status = getStatusForUserId(state, currentUserId); + const userIsOutOfOffice = status === General.OUT_OF_OFFICE; + const enableConfirmNotificationsToChannel = config?.EnableConfirmNotificationsToChannel === 'true'; + const currentChannelStats = getCurrentChannelStats(state); + const membersCount = currentChannelStats?.member_count || 0; // eslint-disable-line camelcase + const isTimezoneEnabled = config?.ExperimentalTimezone === 'true'; + let deactivatedChannel = false; if (currentChannel && currentChannel.type === General.DM_CHANNEL) { const teammate = getChannelMembersForDm(state, currentChannel); @@ -43,14 +46,6 @@ export function mapStateToProps(state, ownProps) { } } - const currentUserId = getCurrentUserId(state); - const status = getStatusForUserId(state, currentUserId); - const userIsOutOfOffice = status === General.OUT_OF_OFFICE; - const enableConfirmNotificationsToChannel = config?.EnableConfirmNotificationsToChannel === 'true'; - const currentChannelStats = getCurrentChannelStats(state); - const currentChannelMembersCount = currentChannelStats?.member_count || 0; // eslint-disable-line camelcase - const isTimezoneEnabled = config?.ExperimentalTimezone === 'true'; - let canPost = true; let useChannelMentions = true; if (isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) { @@ -73,50 +68,36 @@ export function mapStateToProps(state, ownProps) { } return { - currentChannel, - channelId: ownProps.channelId || (currentChannel ? currentChannel.id : ''), - channelTeamId: currentChannel ? currentChannel.team_id : '', - canUploadFiles: canUploadFilesOnMobile(state), - channelDisplayName: state.views.channel.displayName || (currentChannel ? currentChannel.display_name : ''), - channelIsReadOnly: isCurrentChannelReadOnly(state) || false, - channelIsArchived: ownProps.channelIsArchived || (currentChannel ? currentChannel.delete_at !== 0 : false), - currentUserId, - userIsOutOfOffice, - deactivatedChannel, - files: currentDraft.files, - maxFileSize: getAllowedServerMaxFileSize(config), - maxMessageLength: (config && parseInt(config.MaxPostSize || 0, 10)) || MAX_MESSAGE_LENGTH, - theme: getTheme(state), - uploadFileRequestStatus: state.requests.files.uploadFiles.status, - value: currentDraft.draft, - enableConfirmNotificationsToChannel, - currentChannelMembersCount, - isTimezoneEnabled, - isLandscape: isLandscape(state), canPost, + channelDisplayName: state.views.channel.displayName || (currentChannel ? currentChannel.display_name : ''), + channelId: ownProps.channelId || (currentChannel ? currentChannel.id : ''), + channelIsArchived: ownProps.channelIsArchived || (currentChannel ? currentChannel.delete_at !== 0 : false), + channelIsReadOnly: isCurrentChannelReadOnly(state) || false, + currentUserId, + deactivatedChannel, + enableConfirmNotificationsToChannel, + files: currentDraft.files, + isLandscape: isLandscape(state), + isTimezoneEnabled, + maxMessageLength: (config && parseInt(config.MaxPostSize || 0, 10)) || MAX_MESSAGE_LENGTH, + maxFileSize: getAllowedServerMaxFileSize(config), + membersCount, + theme: getTheme(state), useChannelMentions, + userIsOutOfOffice, + value: currentDraft.draft, }; } -function mapDispatchToProps(dispatch) { - return { - actions: bindActionCreators({ - addReactionToLatestPost, - createPost, - executeCommand, - handleClearFiles, - handleClearFailedFiles, - handleCommentDraftChanged, - handlePostDraftChanged, - handleRemoveLastFile, - initUploadFiles, - userTyping, - handleCommentDraftSelectionChanged, - setStatus, - selectPenultimateChannel, - getChannelTimezones, - }, dispatch), - }; -} +const mapDispatchToProps = { + addReactionToLatestPost, + createPost, + executeCommand, + getChannelTimezones, + handleClearFiles, + handleClearFailedFiles, + initUploadFiles, + setStatus, +}; -export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostTextbox); +export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft); diff --git a/app/components/post_textbox/index.test.js b/app/components/post_draft/index.test.js similarity index 98% rename from app/components/post_textbox/index.test.js rename to app/components/post_draft/index.test.js index 74ba5174c..a37ea650d 100644 --- a/app/components/post_textbox/index.test.js +++ b/app/components/post_draft/index.test.js @@ -13,7 +13,7 @@ import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {mapStateToProps} from './index'; -jest.mock('./post_textbox', () => ({ +jest.mock('./post_draft', () => ({ __esModule: true, default: jest.fn(), })); diff --git a/app/components/post_draft/post_draft.js b/app/components/post_draft/post_draft.js new file mode 100644 index 000000000..ae5b0ae0e --- /dev/null +++ b/app/components/post_draft/post_draft.js @@ -0,0 +1,624 @@ +// 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, Platform, ScrollView, View} from 'react-native'; +import {intlShape} from 'react-intl'; +import RNFetchBlob from 'rn-fetch-blob'; + +import Autocomplete from '@components/autocomplete'; +import {paddingHorizontal as padding} from '@components/safe_area_view/iphone_x_spacing'; +import {CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, CHANNEL_POST_TEXTBOX_VALUE_CHANGE, IS_REACTION_REGEX, MAX_FILE_COUNT} from '@constants/post_draft'; +import {NOTIFY_ALL_MEMBERS} from '@constants/view'; +import {General} from '@mm-redux/constants'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {getFormattedFileSize} from '@mm-redux/utils/file_utils'; +import EphemeralStore from '@store/ephemeral_store'; +import {confirmOutOfOfficeDisabled} from '@utils/status'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import Archived from './archived'; +import PostInput from './post_input'; +import QuickActions from './quick_actions'; +import Typing from './typing'; +import Uploads from './uploads'; + +const AUTOCOMPLETE_MARGIN = 20; +const AUTOCOMPLETE_MAX_HEIGHT = 200; + +export default class PostDraft extends PureComponent { + static propTypes = { + addReactionToLatestPost: PropTypes.func.isRequired, + canPost: PropTypes.bool.isRequired, + channelDisplayName: PropTypes.string, + channelId: PropTypes.string.isRequired, + channelIsArchived: PropTypes.bool, + channelIsReadOnly: PropTypes.bool.isRequired, + createPost: PropTypes.func.isRequired, + currentUserId: PropTypes.string.isRequired, + cursorPositionEvent: PropTypes.string, + deactivatedChannel: PropTypes.bool.isRequired, + enableConfirmNotificationsToChannel: PropTypes.bool, + executeCommand: PropTypes.func.isRequired, + files: PropTypes.array, + getChannelTimezones: PropTypes.func.isRequired, + handleClearFiles: PropTypes.func.isRequired, + handleClearFailedFiles: PropTypes.func.isRequired, + initUploadFiles: PropTypes.func.isRequired, + isLandscape: PropTypes.bool.isRequired, + isTimezoneEnabled: PropTypes.bool, + maxMessageLength: PropTypes.number.isRequired, + maxFileSize: PropTypes.number.isRequired, + membersCount: PropTypes.number, + rootId: PropTypes.string, + screenId: PropTypes.string.isRequired, + setStatus: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + useChannelMentions: PropTypes.bool.isRequired, + userIsOutOfOffice: PropTypes.bool.isRequired, + value: PropTypes.string.isRequired, + valueEvent: PropTypes.string, + }; + + static defaultProps = { + canPost: true, + cursorPositionEvent: CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, + files: [], + rootId: '', + value: '', + valueEvent: CHANNEL_POST_TEXTBOX_VALUE_CHANGE, + }; + + static contextTypes = { + intl: intlShape, + }; + + constructor(props) { + super(props); + + this.input = React.createRef(); + + this.state = { + top: 0, + value: props.value, + rootId: props.rootId, + channelId: props.channelId, + channelTimezoneCount: 0, + }; + } + + componentDidMount(prevProps) { + if (this.props.isTimezoneEnabled !== prevProps?.isTimezoneEnabled || prevProps?.channelId !== this.props.channelId) { + this.numberOfTimezones().then((channelTimezoneCount) => this.setState({channelTimezoneCount})); + } + } + + componentDidUpdate(prevProps) { + if (this.input.current) { + const {channelId, rootId, value} = this.props; + const diffChannel = channelId !== prevProps.channelId; + const diffThread = rootId !== prevProps.rootId; + + if (diffChannel || diffThread) { + const trimmed = value.trim(); + this.input.current.setValue(trimmed); + this.updateInitialValue(trimmed); + } + } + } + + blurTextBox = () => { + if (this.input.current) { + this.input.current.blur(); + } + } + + canSend = () => { + const {files, maxMessageLength} = this.props; + const value = this.input.current?.getValue() || ''; + const messageLength = value.trim().length; + + if (messageLength > maxMessageLength) { + return false; + } + + if (files.length) { + const loadingComplete = !this.isFileLoading(); + return loadingComplete; + } + + return messageLength > 0; + }; + + doSubmitMessage = () => { + if (this.input.current) { + const {createPost, currentUserId, channelId, files, handleClearFiles, rootId} = this.props; + const value = this.input.current.getValue() || ''; + const postFiles = files.filter((f) => !f.failed); + const post = { + user_id: currentUserId, + channel_id: channelId, + root_id: rootId, + parent_id: rootId, + message: value.trim(), + }; + + createPost(post, postFiles); + + if (postFiles.length) { + handleClearFiles(channelId, rootId); + } + + if (Platform.OS === 'ios') { + // On iOS, if the PostInput 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 PostInput 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.input.current.setValue(''); + this.setState({sendingMessage: false}); + }, 250); + } else { + this.input.current.setValue(''); + this.setState({sendingMessage: false}); + } + + this.input.current.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 ''; + }; + + handleInputQuickAction = (inputValue) => { + if (this.input.current) { + this.input.current.setValue(inputValue, true); + this.input.current.focus(); + } + }; + + handleLayout = (e) => { + this.setState({ + top: e.nativeEvent.layout.y, + }); + }; + + handlePasteFiles = (error, files) => { + if (this.props.screenId === EphemeralStore.getNavigationTopComponentId()) { + if (error) { + this.showPasteFilesErrorDialog(); + return; + } + + const {maxFileSize} = this.props; + const availableCount = MAX_FILE_COUNT - this.props.files.length; + if (files.length > availableCount) { + this.onShowFileMaxWarning(); + return; + } + + const largeFile = files.find((image) => image.fileSize > maxFileSize); + if (largeFile) { + this.onShowFileSizeWarning(largeFile.fileName); + return; + } + + this.handleUploadFiles(files); + } + }; + + handleSendMessage = () => { + if (!this.isSendButtonEnabled()) { + return; + } + + this.setState({sendingMessage: true}); + + const {channelId, files, handleClearFailedFiles, rootId} = this.props; + const value = this.input.current?.getValue() || ''; + + 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 + handleClearFailedFiles(channelId, rootId); + this.sendMessage(); + }, + }], + ); + } else { + this.sendMessage(); + } + }; + + handleUploadFiles = async (files) => { + const file = files[0]; + if (!file.fileSize | !file.fileName) { + const path = (file.path || file.uri).replace('file://', ''); + const fileInfo = await RNFetchBlob.fs.stat(path); + file.fileSize = fileInfo.size; + file.fileName = fileInfo.filename; + } + + if (file.fileSize > this.props.maxFileSize) { + this.onShowFileSizeWarning(file.fileName); + } else { + this.props.initUploadFiles(files, this.props.rootId); + } + }; + + isFileLoading = () => { + const {files} = this.props; + + return files.some((file) => file.loading); + }; + + isSendButtonEnabled = () => { + return this.canSend() && !this.isFileLoading() && !this.state.sendingMessage; + }; + + isStatusSlashCommand = (command) => { + return command === General.ONLINE || command === General.AWAY || + command === General.DND || command === General.OFFLINE; + }; + + onShowFileMaxWarning = () => { + EventEmitter.emit('fileMaxWarning'); + }; + + onShowFileSizeWarning = (filename) => { + const {formatMessage} = this.context.intl; + const fileSizeWarning = formatMessage({ + id: 'file_upload.fileAbove', + defaultMessage: 'File above {max} cannot be uploaded: {filename}', + }, { + max: getFormattedFileSize({size: this.props.maxFileSize}), + filename, + }); + + EventEmitter.emit('fileSizeWarning', fileSizeWarning); + setTimeout(() => { + EventEmitter.emit('fileSizeWarning', null); + }, 5000); + }; + + numberOfTimezones = async () => { + const {channelId, getChannelTimezones} = this.props; + const {data} = await getChannelTimezones(channelId); + return data?.length || 0; + }; + + sendCommand = async (msg) => { + const {intl} = this.context; + const {channelId, executeCommand, rootId, userIsOutOfOffice} = this.props; + + const status = this.getStatusFromSlashCommand(msg); + if (userIsOutOfOffice && this.isStatusSlashCommand(status)) { + confirmOutOfOfficeDisabled(intl, status, this.updateStatus); + this.setState({sendingMessage: false}); + return; + } + + const {error} = await executeCommand(msg, channelId, rootId); + this.setState({sendingMessage: false}); + + if (error) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.commands.error_title', + defaultMessage: 'Error Executing Command', + }), + error.message, + ); + return; + } + + if (this.input.current) { + this.input.current.setValue(''); + this.input.current.changeDraft(''); + } + }; + + sendMessage = () => { + const value = this.input.current?.getValue() || ''; + const {enableConfirmNotificationsToChannel, membersCount, useChannelMentions} = this.props; + const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions; + const toAllOrChannel = this.textContainsAtAllAtChannel(value); + + if (value.indexOf('/') === 0) { + this.sendCommand(value); + } else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) { + this.showSendToAllOrChannelAlert(membersCount); + } else { + this.doSubmitMessage(); + } + }; + + sendReaction = (emoji) => { + const {addReactionToLatestPost, rootId} = this.props; + addReactionToLatestPost(emoji, rootId); + + if (this.input.current) { + this.input.current.setValue(''); + this.input.current.changeDraft(''); + } + + this.setState({sendingMessage: false}); + }; + + showPasteFilesErrorDialog = () => { + const {formatMessage} = this.context.intl; + Alert.alert( + formatMessage({ + id: 'mobile.files_paste.error_title', + defaultMessage: 'Paste failed', + }), + formatMessage({ + id: 'mobile.files_paste.error_description', + defaultMessage: 'An error occurred while pasting the file(s). Please try again.', + }), + [ + { + text: formatMessage({ + id: 'mobile.files_paste.error_dismiss', + defaultMessage: 'Dismiss', + }), + }, + ], + ); + }; + + showSendToAllOrChannelAlert = (membersCount) => { + const {intl} = this.context; + const {channelTimezoneCount} = this.state; + const {isTimezoneEnabled} = this.props; + + let notifyAllMessage = ''; + if (isTimezoneEnabled && channelTimezoneCount) { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.entire_channel.message.with_timezones', + defaultMessage: 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', + }, + { + totalMembers: membersCount - 1, + timezones: channelTimezoneCount, + }, + ) + ); + } else { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.entire_channel.message', + defaultMessage: 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?', + }, + { + totalMembers: membersCount - 1, + }, + ) + ); + } + + Alert.alert( + intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.title', + defaultMessage: 'Confirm sending notifications to entire channel', + }), + notifyAllMessage, + [ + { + text: intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.cancel', + defaultMessage: 'Cancel', + }), + onPress: () => { + this.setState({sendingMessage: false}); + }, + }, + { + text: intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.confirm', + defaultMessage: 'Confirm', + }), + onPress: () => this.doSubmitMessage(), + }, + ], + ); + }; + + textContainsAtAllAtChannel = (text) => { + const textWithoutCode = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g, ''); + return (/\B@(all|channel)\b/i).test(textWithoutCode); + }; + + updateInitialValue = (value) => { + this.setState({value}); + } + + updateStatus = (status) => { + const {currentUserId, setStatus} = this.props; + setStatus({ + user_id: currentUserId, + status, + }); + }; + + render = () => { + const {channelIsArchived, deactivatedChannel, rootId, theme} = this.props; + if (channelIsArchived || deactivatedChannel) { + return ( + + ); + } + + const { + canPost, + channelDisplayName, + channelId, + channelIsReadOnly, + cursorPositionEvent, + isLandscape, + files, + maxFileSize, + maxMessageLength, + screenId, + valueEvent, + } = this.props; + const style = getStyleSheet(theme); + const readonly = channelIsReadOnly || !canPost; + + return ( + <> + + {Platform.OS === 'android' && + + } + + + + + + + + + ); + }; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + inputContainer: { + flex: 1, + flexDirection: 'column', + }, + inputContentContainer: { + alignItems: 'stretch', + paddingTop: Platform.select({ + ios: 7, + android: 0, + }), + }, + inputWrapper: { + alignItems: 'flex-end', + flexDirection: 'row', + justifyContent: 'center', + paddingBottom: 2, + backgroundColor: theme.centerChannelBg, + borderTopWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), + }, + readonlyContainer: { + marginLeft: 10, + }, + }; +}); diff --git a/app/components/post_draft/post_input/__snapshots__/post_input.test.js.snap b/app/components/post_draft/post_input/__snapshots__/post_input.test.js.snap new file mode 100644 index 000000000..f0889ab53 --- /dev/null +++ b/app/components/post_draft/post_input/__snapshots__/post_input.test.js.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PostInput should match, full snapshot 1`] = ` + +`; diff --git a/app/components/post_draft/post_input/index.js b/app/components/post_draft/post_input/index.js new file mode 100644 index 000000000..ac6efd60d --- /dev/null +++ b/app/components/post_draft/post_input/index.js @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {handlePostDraftChanged} from '@actions/views/channel'; +import {handleCommentDraftChanged} from '@actions/views/thread'; +import {userTyping} from '@actions/views/typing'; + +import PostInput from './post_input'; + +const mapDispatchToProps = { + handleCommentDraftChanged, + handlePostDraftChanged, + userTyping, +}; + +export default connect(null, mapDispatchToProps, null, {forwardRef: true})(PostInput); diff --git a/app/components/post_draft/post_input/post_input.js b/app/components/post_draft/post_input/post_input.js new file mode 100644 index 000000000..2c05bad1b --- /dev/null +++ b/app/components/post_draft/post_input/post_input.js @@ -0,0 +1,323 @@ +// 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, findNodeHandle, Keyboard, NativeModules, Platform} from 'react-native'; +import {intlShape} from 'react-intl'; +import HWKeyboardEvent from 'react-native-hw-keyboard-event'; + +import PasteableTextInput from '@components/pasteable_text_input'; +import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_draft'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {t} from '@utils/i18n'; +import {switchKeyboardForCodeBlocks} from '@utils/markdown'; +import {changeOpacity, makeStyleSheetFromTheme, getKeyboardAppearanceFromTheme} from '@utils/theme'; + +const {RNTextInputReset} = NativeModules; +const INPUT_LINE_HEIGHT = 20; +const HW_SHIFT_ENTER_TEXT = Platform.OS === 'ios' ? '\n' : ''; + +export default class PostInput extends PureComponent { + static contextTypes = { + intl: intlShape, + }; + + static defaultProps = { + rootId: '', + }; + + static propTypes = { + channelDisplayName: PropTypes.string, + channelId: PropTypes.string.isRequired, + cursorPositionEvent: PropTypes.string, + handleCommentDraftChanged: PropTypes.func.isRequired, + handlePostDraftChanged: PropTypes.func.isRequired, + inputEventType: PropTypes.string, + isLandscape: PropTypes.bool, + maxMessageLength: PropTypes.number, + onPasteFiles: PropTypes.func.isRequired, + onSend: PropTypes.func.isRequired, + readonly: PropTypes.bool, + rootId: PropTypes.string, + theme: PropTypes.object.isRequired, + updateInitialValue: PropTypes.func.isRequired, + userTyping: PropTypes.func.isRequired, + } + + constructor(props) { + super(props); + + this.input = React.createRef(); + this.cursorPosition = 0; + this.value = ''; + + this.state = { + keyboardType: 'default', + longMessageAlertShown: false, + }; + } + + componentDidMount() { + const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT; + EventEmitter.on(event, this.handleInsertTextToDraft); + AppState.addEventListener('change', this.handleAppStateChange); + HWKeyboardEvent.onHWKeyPressed(this.handleHardwareEnterPress); + + if (Platform.OS === 'android') { + Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard); + } + } + + componentWillUnmount() { + const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT; + + EventEmitter.off(event, this.handleInsertTextToDraft); + AppState.removeEventListener('change', this.handleAppStateChange); + HWKeyboardEvent.removeOnHWKeyPressed(); + + if (Platform.OS === 'android') { + Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard); + } + } + + blur = () => { + if (this.input.current) { + this.input.current.blur(); + } + }; + + changeDraft = (text) => { + const { + channelId, + handleCommentDraftChanged, + handlePostDraftChanged, + rootId, + } = this.props; + + if (rootId) { + handleCommentDraftChanged(rootId, text); + } else { + handlePostDraftChanged(channelId, text); + } + }; + + checkMessageLength = (value) => { + const {intl} = this.context; + const {maxMessageLength} = this.props; + const valueLength = value.trim().length; + + if (valueLength > maxMessageLength) { + // Check if component is already aware message is too long + if (!this.state.longMessageAlertShown) { + 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: {count}/{max}', + }, { + max: maxMessageLength, + count: valueLength, + }), + ); + this.setState({longMessageAlertShown: true}); + } + } else if (this.state.longMessageAlertShown) { + this.setState({longMessageAlertShown: false}); + } + }; + + focus = () => { + if (this.input.current) { + this.input.current.focus(); + } + } + + getPlaceHolder = () => { + const {readonly, rootId} = this.props; + let placeholder; + + if (readonly) { + 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; + }; + + getValue = () => { + return this.value; + }; + + handleAndroidKeyboard = () => { + this.blur(); + }; + + handleHardwareEnterPress = (keyEvent) => { + switch (keyEvent.pressedKey) { + case 'enter': + this.props.onSend(); + break; + case 'shift-enter': + this.handleInsertTextToDraft(HW_SHIFT_ENTER_TEXT); + break; + } + } + + handleAppStateChange = (nextAppState) => { + if (nextAppState !== 'active') { + this.changeDraft(this.getValue()); + } + }; + + handleEndEditing = (e) => { + if (e && e.nativeEvent) { + this.changeDraft(e.nativeEvent.text || ''); + } + }; + + handlePostDraftSelectionChanged = (event, fromHandleTextChange = false) => { + const cursorPosition = fromHandleTextChange ? this.state.cursorPosition : event.nativeEvent.selection.end; + + const {cursorPositionEvent} = this.props; + + if (cursorPositionEvent) { + EventEmitter.emit(cursorPositionEvent, cursorPosition); + } + + if (Platform.OS === 'ios') { + const keyboardType = switchKeyboardForCodeBlocks(this.state.value, cursorPosition); + this.setState({cursorPosition, keyboardType}); + } else { + this.setState({cursorPosition}); + } + }; + + handleTextChange = (value, autocomplete = false) => { + const { + channelId, + cursorPositionEvent, + inputEventType, + rootId, + updateInitialValue, + userTyping, + } = this.props; + this.value = value; + updateInitialValue(value); + + if (inputEventType) { + EventEmitter.emit(inputEventType, 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); + + // Workaround to avoid iOS emdash autocorrect in Code Blocks + if (Platform.OS === 'ios') { + const callback = () => this.handlePostDraftSelectionChanged(null, true); + this.setState(nextState, callback); + } else { + this.setState(nextState); + } + + if (value) { + userTyping(channelId, rootId); + } + }; + + 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, + }); + }; + + setValue = (value, autocomplete = false) => { + this.value = value; + if (this.input.current) { + this.input.current.setNativeProps({ + text: value, + }); + this.handleTextChange(value, autocomplete); + } + } + + render() { + const {formatMessage} = this.context.intl; + const {channelDisplayName, isLandscape, onPasteFiles, readonly, theme} = this.props; + const style = getStyleSheet(theme); + const placeholder = this.getPlaceHolder(); + let maxHeight = 150; + + if (isLandscape) { + maxHeight = 88; + } + + return ( + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + input: { + color: theme.centerChannelColor, + fontSize: 15, + lineHeight: INPUT_LINE_HEIGHT, + paddingHorizontal: 12, + paddingTop: Platform.select({ + ios: 6, + android: 8, + }), + paddingBottom: Platform.select({ + ios: 6, + android: 2, + }), + minHeight: 30, + }, +})); diff --git a/app/components/post_draft/post_input/post_input.test.js b/app/components/post_draft/post_input/post_input.test.js new file mode 100644 index 000000000..0b1806dea --- /dev/null +++ b/app/components/post_draft/post_input/post_input.test.js @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Alert} from 'react-native'; +import {shallowWithIntl} from 'test/intl-test-helper'; + +import Preferences from '@mm-redux/constants/preferences'; + +import PostInput from './post_input'; + +describe('PostInput', () => { + const baseProps = { + channelDisplayName: 'Test Channel', + channelId: 'channel-id', + cursorPositionEvent: '', + handleCommentDraftChanged: jest.fn(), + handlePostDraftChanged: jest.fn(), + inputEventType: '', + isLandscape: false, + maxMessageLength: 4000, + onPasteFiles: jest.fn(), + onSend: jest.fn(), + readonly: false, + rootId: '', + theme: Preferences.THEMES.default, + updateInitialValue: jest.fn(), + userTyping: jest.fn(), + }; + + test('should match, full snapshot', () => { + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should emit the event but no text is save to draft', () => { + const wrapper = shallowWithIntl( + , + ); + + const instance = wrapper.instance(); + + instance.changeDraft = jest.fn(); + instance.handleAppStateChange('active'); + expect(instance.changeDraft).not.toBeCalled(); + }); + + test('should emit the event and text is save to draft', () => { + const wrapper = shallowWithIntl( + , + ); + + const instance = wrapper.instance(); + const value = 'some text'; + + instance.setValue(value); + instance.handleAppStateChange('background'); + expect(baseProps.handlePostDraftChanged).toHaveBeenCalledWith(baseProps.channelId, value); + expect(baseProps.handlePostDraftChanged).toHaveBeenCalledTimes(1); + }); + + test('should not send multiple alerts when message is too long', () => { + const wrapper = shallowWithIntl( + , + ); + + const instance = wrapper.instance(); + const longString = [...Array(baseProps.maxMessageLength + 2).keys()].map(() => Math.random().toString(36).slice(0, 1)).join(''); + + instance.handleTextChange(longString); + instance.handleTextChange(longString.slice(1)); + + expect(Alert.alert).toBeCalled(); + expect(Alert.alert).toHaveBeenCalledTimes(1); + }); +}); + diff --git a/app/components/post_textbox/components/__snapshots__/camera_button.test.js.snap b/app/components/post_draft/quick_actions/camera_quick_action/__snapshots__/camera_quick_action.test.js.snap similarity index 72% rename from app/components/post_textbox/components/__snapshots__/camera_button.test.js.snap rename to app/components/post_draft/quick_actions/camera_quick_action/__snapshots__/camera_quick_action.test.js.snap index e3f79946c..f3760cade 100644 --- a/app/components/post_textbox/components/__snapshots__/camera_button.test.js.snap +++ b/app/components/post_draft/quick_actions/camera_quick_action/__snapshots__/camera_quick_action.test.js.snap @@ -3,7 +3,13 @@ exports[`CameraButton should match snapshot 1`] = ` ({ @@ -18,16 +18,16 @@ jest.mock('react-native-image-picker', () => ({ describe('CameraButton', () => { const formatMessage = jest.fn(); const baseProps = { + blurTextBox: jest.fn(), fileCount: 0, maxFileCount: 5, onShowFileMaxWarning: jest.fn(), theme: Preferences.THEMES.default, - uploadFiles: jest.fn(), - buttonContainerStyle: {}, + onUploadFiles: jest.fn(), }; test('should match snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.getElement()).toMatchSnapshot(); }); @@ -37,7 +37,7 @@ describe('CameraButton', () => { jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED); const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); @@ -53,7 +53,7 @@ describe('CameraButton', () => { jest.spyOn(Alert, 'alert').mockReturnValue(true); const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); @@ -82,7 +82,7 @@ describe('CameraButton', () => { request.mockReturnValue(Permissions.RESULTS.GRANTED); const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); const instance = wrapper.instance(); @@ -103,7 +103,7 @@ describe('CameraButton', () => { test('should re-enable StatusBar after ImagePicker launchCamera finishes', async () => { const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); diff --git a/app/components/post_textbox/components/camera_button.js b/app/components/post_draft/quick_actions/camera_quick_action/index.js similarity index 81% rename from app/components/post_textbox/components/camera_button.js rename to app/components/post_draft/quick_actions/camera_quick_action/index.js index 14a744bd9..33c4d559f 100644 --- a/app/components/post_textbox/components/camera_button.js +++ b/app/components/post_draft/quick_actions/camera_quick_action/index.js @@ -7,69 +7,36 @@ import { Alert, Platform, StatusBar, + StyleSheet, } from 'react-native'; import DeviceInfo from 'react-native-device-info'; -import {ICON_SIZE} from 'app/constants/post_textbox'; - import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; import ImagePicker from 'react-native-image-picker'; import Permissions from 'react-native-permissions'; -import {changeOpacity} from 'app/utils/theme'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {ICON_SIZE} from '@constants/post_draft'; +import {changeOpacity} from '@utils/theme'; -import TouchableWithFeedback from 'app/components/touchable_with_feedback'; - -export default class AttachmentButton extends PureComponent { +export default class CameraQuickAction extends PureComponent { static propTypes = { + blurTextBox: PropTypes.func.isRequired, + disabled: PropTypes.bool, fileCount: PropTypes.number, - maxFileCount: PropTypes.number.isRequired, + maxFileCount: PropTypes.number, onShowFileMaxWarning: PropTypes.func, + onUploadFiles: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, - uploadFiles: PropTypes.func.isRequired, - buttonContainerStyle: PropTypes.object, - }; - - static defaultProps = { - validMimeTypes: [], - canTakePhoto: true, - canTakeVideo: true, - maxFileCount: 5, }; static contextTypes = { intl: intlShape.isRequired, }; - getPermissionDeniedMessage = () => { - const {formatMessage} = this.context.intl; - const applicationName = DeviceInfo.getApplicationName(); - return { - title: formatMessage({ - id: 'mobile.camera_photo_permission_denied_title', - defaultMessage: '{applicationName} would like to access your camera', - }, {applicationName}), - text: formatMessage({ - id: 'mobile.camera_photo_permission_denied_description', - defaultMessage: 'Take photos and upload them to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your camera.', - }), - }; - } - attachFileFromCamera = async () => { const {formatMessage} = this.context.intl; - const { - fileCount, - maxFileCount, - onShowFileMaxWarning, - } = this.props; - const {title, text} = this.getPermissionDeniedMessage(); - if (fileCount === maxFileCount) { - onShowFileMaxWarning(); - return; - } - const options = { quality: 0.8, videoQuality: 'high', @@ -99,11 +66,43 @@ export default class AttachmentButton extends PureComponent { return; } - this.props.uploadFiles([response]); + this.props.onUploadFiles([response]); }); } }; + getPermissionDeniedMessage = () => { + const {formatMessage} = this.context.intl; + const applicationName = DeviceInfo.getApplicationName(); + return { + title: formatMessage({ + id: 'mobile.camera_photo_permission_denied_title', + defaultMessage: '{applicationName} would like to access your camera', + }, {applicationName}), + text: formatMessage({ + id: 'mobile.camera_photo_permission_denied_description', + defaultMessage: 'Take photos and upload them to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your camera.', + }), + }; + } + + handleButtonPress = () => { + const { + blurTextBox, + fileCount, + maxFileCount, + onShowFileMaxWarning, + } = this.props; + + if (fileCount === maxFileCount) { + onShowFileMaxWarning(); + return; + } + + blurTextBox(); + this.attachFileFromCamera(); + }; + hasCameraPermission = async () => { const {formatMessage} = this.context.intl; const targetSource = Platform.OS === 'ios' ? @@ -150,15 +149,20 @@ export default class AttachmentButton extends PureComponent { }; render() { - const {theme, buttonContainerStyle} = this.props; + const {disabled, theme} = this.props; + const color = disabled ? + changeOpacity(theme.centerChannelColor, 0.16) : + changeOpacity(theme.centerChannelColor, 0.64); + return ( @@ -166,3 +170,11 @@ export default class AttachmentButton extends PureComponent { ); } } + +const style = StyleSheet.create({ + icon: { + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }, +}); \ No newline at end of file diff --git a/app/components/post_textbox/components/__snapshots__/file_upload_button.test.js.snap b/app/components/post_draft/quick_actions/file_quick_action/__snapshots__/file_quick_action.test.js.snap similarity index 60% rename from app/components/post_textbox/components/__snapshots__/file_upload_button.test.js.snap rename to app/components/post_draft/quick_actions/file_quick_action/__snapshots__/file_quick_action.test.js.snap index 2d07d9c8d..9bef1e04f 100644 --- a/app/components/post_textbox/components/__snapshots__/file_upload_button.test.js.snap +++ b/app/components/post_draft/quick_actions/file_quick_action/__snapshots__/file_quick_action.test.js.snap @@ -1,9 +1,15 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FileUploadButton should match snapshot 1`] = ` +exports[`FileQuickAction should match snapshot 1`] = ` ({ launchCamera: jest.fn(), })); -describe('FileUploadButton', () => { +describe('FileQuickAction', () => { const formatMessage = jest.fn(); const baseProps = { blurTextBox: jest.fn(), - browseFileTypes: '*', fileCount: 0, maxFileCount: 5, onShowFileMaxWarning: jest.fn(), theme: Preferences.THEMES.default, - uploadFiles: jest.fn(), - buttonContainerStyle: {}, + onUploadFiles: jest.fn(), }; beforeAll(() => { @@ -37,7 +35,7 @@ describe('FileUploadButton', () => { }); test('should match snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.getElement()).toMatchSnapshot(); }); @@ -47,7 +45,7 @@ describe('FileUploadButton', () => { jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED); const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); @@ -63,7 +61,7 @@ describe('FileUploadButton', () => { jest.spyOn(Alert, 'alert').mockReturnValue(true); const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); @@ -76,7 +74,7 @@ describe('FileUploadButton', () => { test('hasStoragePermission returns true when permission has been granted', async () => { const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); const instance = wrapper.instance(); diff --git a/app/components/post_textbox/components/file_upload_button.js b/app/components/post_draft/quick_actions/file_quick_action/index.js similarity index 81% rename from app/components/post_textbox/components/file_upload_button.js rename to app/components/post_draft/quick_actions/file_quick_action/index.js index 6324e2ec1..536b9ec97 100644 --- a/app/components/post_textbox/components/file_upload_button.js +++ b/app/components/post_draft/quick_actions/file_quick_action/index.js @@ -3,47 +3,33 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {intlShape} from 'react-intl'; -import { - Alert, - NativeModules, - Platform, -} from 'react-native'; +import {Alert, NativeModules, Platform, StyleSheet} from 'react-native'; import DeviceInfo from 'react-native-device-info'; -import {ICON_SIZE} from 'app/constants/post_textbox'; import AndroidOpenSettings from 'react-native-android-open-settings'; - import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; import DocumentPicker from 'react-native-document-picker'; import Permissions from 'react-native-permissions'; -import {changeOpacity} from 'app/utils/theme'; - -import TouchableWithFeedback from 'app/components/touchable_with_feedback'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {ICON_SIZE} from '@constants/post_draft'; +import {changeOpacity} from '@utils/theme'; const ShareExtension = NativeModules.MattermostShare; -export default class FileUploadButton extends PureComponent { +export default class FileQuickAction extends PureComponent { static propTypes = { blurTextBox: PropTypes.func.isRequired, - browseFileTypes: PropTypes.string, + disabled: PropTypes.bool, fileCount: PropTypes.number, - maxFileCount: PropTypes.number.isRequired, + maxFileCount: PropTypes.number, onShowFileMaxWarning: PropTypes.func, + onUploadFiles: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, - uploadFiles: PropTypes.func.isRequired, - buttonContainerStyle: PropTypes.object, }; static defaultProps = { - browseFileTypes: Platform.OS === 'ios' ? 'public.item' : '*/*', - validMimeTypes: [], - canBrowseFiles: true, - canBrowsePhotoLibrary: true, - canBrowseVideoLibrary: true, - canTakePhoto: true, - canTakeVideo: true, + fileCount: 0, maxFileCount: 5, - extraOptions: null, }; static contextTypes = { @@ -66,8 +52,8 @@ export default class FileUploadButton extends PureComponent { } attachFileFromFiles = async () => { - const {browseFileTypes} = this.props; const hasPermission = await this.hasStoragePermission(); + const browseFileTypes = Platform.OS === 'ios' ? 'public.item' : '*/*'; if (hasPermission) { try { @@ -85,7 +71,7 @@ export default class FileUploadButton extends PureComponent { // Decode file uri to get the actual path res.uri = decodeURIComponent(res.uri); - this.props.uploadFiles([res]); + this.props.onUploadFiles([res]); } catch (error) { // Do nothing } @@ -137,6 +123,7 @@ export default class FileUploadButton extends PureComponent { handleButtonPress = () => { const { + blurTextBox, fileCount, maxFileCount, onShowFileMaxWarning, @@ -146,20 +133,26 @@ export default class FileUploadButton extends PureComponent { onShowFileMaxWarning(); return; } - this.props.blurTextBox(); + + blurTextBox(); this.attachFileFromFiles(); }; render() { - const {theme, buttonContainerStyle} = this.props; + const {disabled, theme} = this.props; + const color = disabled ? + changeOpacity(theme.centerChannelColor, 0.16) : + changeOpacity(theme.centerChannelColor, 0.64); + return ( @@ -167,3 +160,11 @@ export default class FileUploadButton extends PureComponent { ); } } + +const style = StyleSheet.create({ + icon: { + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }, +}); diff --git a/app/components/post_textbox/components/__snapshots__/image_upload_button.test.js.snap b/app/components/post_draft/quick_actions/image_quick_action/__snapshots__/image_quick_action.test.js.snap similarity index 59% rename from app/components/post_textbox/components/__snapshots__/image_upload_button.test.js.snap rename to app/components/post_draft/quick_actions/image_quick_action/__snapshots__/image_quick_action.test.js.snap index 80fdc5f7e..949d63313 100644 --- a/app/components/post_textbox/components/__snapshots__/image_upload_button.test.js.snap +++ b/app/components/post_draft/quick_actions/image_quick_action/__snapshots__/image_quick_action.test.js.snap @@ -1,9 +1,15 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ImageUploadButton should match snapshot 1`] = ` +exports[`ImageQuickAction should match snapshot 1`] = ` ({ launchImageLibrary: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})), })); -describe('ImageUploadButton', () => { +describe('ImageQuickAction', () => { const formatMessage = jest.fn(); const baseProps = { blurTextBox: jest.fn(), @@ -23,12 +23,11 @@ describe('ImageUploadButton', () => { maxFileCount: 5, onShowFileMaxWarning: jest.fn(), theme: Preferences.THEMES.default, - uploadFiles: jest.fn(), - buttonContainerStyle: {}, + onUploadFiles: jest.fn(), }; test('should match snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.getElement()).toMatchSnapshot(); }); @@ -38,7 +37,7 @@ describe('ImageUploadButton', () => { jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED); const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); @@ -54,7 +53,7 @@ describe('ImageUploadButton', () => { jest.spyOn(Alert, 'alert').mockReturnValue(true); const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); @@ -83,7 +82,7 @@ describe('ImageUploadButton', () => { request.mockReturnValue(Permissions.RESULTS.GRANTED); const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); const instance = wrapper.instance(); @@ -104,7 +103,7 @@ describe('ImageUploadButton', () => { test('should re-enable StatusBar after ImagePicker launchImageLibrary finishes', async () => { const wrapper = shallow( - , + , {context: {intl: {formatMessage}}}, ); diff --git a/app/components/post_textbox/components/image_upload_button.js b/app/components/post_draft/quick_actions/image_quick_action/index.js similarity index 84% rename from app/components/post_textbox/components/image_upload_button.js rename to app/components/post_draft/quick_actions/image_quick_action/index.js index 35feeec75..809946cc5 100644 --- a/app/components/post_textbox/components/image_upload_button.js +++ b/app/components/post_draft/quick_actions/image_quick_action/index.js @@ -3,77 +3,36 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {intlShape} from 'react-intl'; -import { - Alert, - Platform, - StatusBar, -} from 'react-native'; +import {Alert, Platform, StatusBar, StyleSheet} from 'react-native'; import DeviceInfo from 'react-native-device-info'; -import {ICON_SIZE} from 'app/constants/post_textbox'; - import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; import ImagePicker from 'react-native-image-picker'; import Permissions from 'react-native-permissions'; -import {changeOpacity} from 'app/utils/theme'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {ICON_SIZE} from '@constants/post_draft'; +import {changeOpacity} from '@utils/theme'; -import TouchableWithFeedback from 'app/components/touchable_with_feedback'; - -export default class ImageUploadButton extends PureComponent { +export default class ImageQuickAction extends PureComponent { static propTypes = { blurTextBox: PropTypes.func.isRequired, + disabled: PropTypes.bool, fileCount: PropTypes.number, - maxFileCount: PropTypes.number.isRequired, + maxFileCount: PropTypes.number, onShowFileMaxWarning: PropTypes.func, + onUploadFiles: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, - uploadFiles: PropTypes.func.isRequired, - buttonContainerStyle: PropTypes.object, }; static defaultProps = { - browseFileTypes: Platform.OS === 'ios' ? 'public.item' : '*/*', - validMimeTypes: [], - canBrowseFiles: true, - canBrowsePhotoLibrary: true, - canBrowseVideoLibrary: true, - canTakePhoto: true, - canTakeVideo: true, + fileCount: 0, maxFileCount: 5, - extraOptions: null, }; static contextTypes = { intl: intlShape.isRequired, }; - getPermissionDeniedMessage = () => { - const {formatMessage} = this.context.intl; - const applicationName = DeviceInfo.getApplicationName(); - if (Platform.OS === 'android') { - return { - title: formatMessage({ - id: 'mobile.android.photos_permission_denied_title', - defaultMessage: '{applicationName} would like to access your photos', - }, {applicationName}), - text: formatMessage({ - id: 'mobile.android.photos_permission_denied_description', - defaultMessage: 'Upload photos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your photo library.', - }), - }; - } - - return { - title: formatMessage({ - id: 'mobile.ios.photos_permission_denied_title', - defaultMessage: '{applicationName} would like to access your photos', - }, {applicationName}), - text: formatMessage({ - id: 'mobile.ios.photos_permission_denied_description', - defaultMessage: 'Upload photos and videos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your photo and video library.', - }), - }; - } - attachFileFromLibrary = async () => { const {formatMessage} = this.context.intl; const {title, text} = this.getPermissionDeniedMessage(); @@ -101,11 +60,56 @@ export default class ImageUploadButton extends PureComponent { return; } - this.props.uploadFiles([response]); + this.props.onUploadFiles([response]); }); } }; + getPermissionDeniedMessage = () => { + const {formatMessage} = this.context.intl; + const applicationName = DeviceInfo.getApplicationName(); + if (Platform.OS === 'android') { + return { + title: formatMessage({ + id: 'mobile.android.photos_permission_denied_title', + defaultMessage: '{applicationName} would like to access your photos', + }, {applicationName}), + text: formatMessage({ + id: 'mobile.android.photos_permission_denied_description', + defaultMessage: 'Upload photos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your photo library.', + }), + }; + } + + return { + title: formatMessage({ + id: 'mobile.ios.photos_permission_denied_title', + defaultMessage: '{applicationName} would like to access your photos', + }, {applicationName}), + text: formatMessage({ + id: 'mobile.ios.photos_permission_denied_description', + defaultMessage: 'Upload photos and videos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your photo and video library.', + }), + }; + }; + + handleButtonPress = () => { + const { + blurTextBox, + fileCount, + maxFileCount, + onShowFileMaxWarning, + } = this.props; + + if (fileCount === maxFileCount) { + onShowFileMaxWarning(); + return; + } + + blurTextBox(); + this.attachFileFromLibrary(); + }; + hasPhotoPermission = async () => { const {formatMessage} = this.context.intl; const targetSource = Platform.OS === 'ios' ? @@ -151,32 +155,21 @@ export default class ImageUploadButton extends PureComponent { return true; }; - handleButtonPress = () => { - const { - fileCount, - maxFileCount, - onShowFileMaxWarning, - } = this.props; - - if (fileCount === maxFileCount) { - onShowFileMaxWarning(); - return; - } - - this.props.blurTextBox(); - this.attachFileFromLibrary(); - }; - render() { - const {theme, buttonContainerStyle} = this.props; + const {disabled, theme} = this.props; + const color = disabled ? + changeOpacity(theme.centerChannelColor, 0.16) : + changeOpacity(theme.centerChannelColor, 0.64); + return ( @@ -184,3 +177,11 @@ export default class ImageUploadButton extends PureComponent { ); } } + +const style = StyleSheet.create({ + icon: { + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }, +}); diff --git a/app/components/post_draft/quick_actions/index.js b/app/components/post_draft/quick_actions/index.js new file mode 100644 index 000000000..5572c4385 --- /dev/null +++ b/app/components/post_draft/quick_actions/index.js @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {canUploadFilesOnMobile} from '@mm-redux/selectors/entities/general'; + +import QuickActions from './quick_actions'; + +function mapStateToProps(state) { + return { + canUploadFiles: canUploadFilesOnMobile(state), + }; +} + +export default connect(mapStateToProps)(QuickActions); \ No newline at end of file diff --git a/app/components/post_draft/quick_actions/input_quick_action/index.js b/app/components/post_draft/quick_actions/input_quick_action/index.js new file mode 100644 index 000000000..d75a48bcc --- /dev/null +++ b/app/components/post_draft/quick_actions/input_quick_action/index.js @@ -0,0 +1,102 @@ +// 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 {Image} from 'react-native'; +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; + +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {ICON_SIZE} from '@constants/post_draft'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +export default class InputQuickAction extends PureComponent { + static propTypes = { + disabled: PropTypes.bool, + inputType: PropTypes.oneOf(['at', 'slash']).isRequired, + onTextChange: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + value: PropTypes.string, + }; + + static defaultProps = { + value: '', + }; + + constructor(props) { + super(props); + + this.value = ''; + } + + onPress = () => { + const {inputType, onTextChange, value} = this.props; + + let newValue = '/'; + if (inputType === 'at') { + newValue = `${value}@`; + } + + onTextChange(newValue); + } + + renderInput = (style) => { + const {disabled, inputType, theme} = this.props; + + if (inputType === 'at') { + const color = disabled ? + changeOpacity(theme.centerChannelColor, 0.16) : + changeOpacity(theme.centerChannelColor, 0.64); + + return ( + + ); + } + + return ( + + ); + } + + render() { + const {disabled, theme} = this.props; + const style = getStyleSheet(theme); + + return ( + + {this.renderInput(style)} + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + slash: { + width: ICON_SIZE, + height: ICON_SIZE, + opacity: 1, + tintColor: changeOpacity(theme.centerChannelColor, 0.64), + }, + disabled: { + tintColor: changeOpacity(theme.centerChannelColor, 0.16), + }, + icon: { + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }, + }; +}); \ No newline at end of file diff --git a/app/components/post_draft/quick_actions/quick_actions.js b/app/components/post_draft/quick_actions/quick_actions.js new file mode 100644 index 000000000..3eb525f64 --- /dev/null +++ b/app/components/post_draft/quick_actions/quick_actions.js @@ -0,0 +1,145 @@ +// 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 {Platform, StyleSheet, View} from 'react-native'; + +import {MAX_FILE_COUNT} from '@constants/post_draft'; +import EventEmitter from '@mm-redux/utils/event_emitter'; + +import CameraAction from './camera_quick_action'; +import ImageAction from './image_quick_action'; +import FileAction from './file_quick_action'; +import InputAction from './input_quick_action'; +import SendAction from './send_action'; + +export default class QuickActions extends PureComponent { + static propTypes = { + blurTextBox: PropTypes.func.isRequired, + canUploadFiles: PropTypes.bool, + canSend: PropTypes.bool, + fileCount: PropTypes.number, + initialValue: PropTypes.string, + inputEventType: PropTypes.string.isRequired, + maxFileSize: PropTypes.number.isRequired, + onSend: PropTypes.func.isRequired, + onShowFileMaxWarning: PropTypes.func.isRequired, + onTextChange: PropTypes.func.isRequired, + onUploadFiles: PropTypes.func.isRequired, + readonly: PropTypes.bool, + theme: PropTypes.object.isRequired, + }; + + static defaultProps = { + canUploadFiles: true, + fileCount: 0, + initialValue: '', + }; + + constructor(props) { + super(props); + + this.state = { + inputValue: props.initialValue, + atDisabled: props.readonly, + slashDisabled: props.readonly, + }; + } + + componentDidMount() { + EventEmitter.on(this.props.inputEventType, this.handleInputEvent); + } + + componentDidUpdate(prevProps) { + if (prevProps.readonly !== this.props.readonly || prevProps.initialValue !== this.props.initialValue) { + this.handleInputEvent(this.props.initialValue); + } + } + + componentWillUnmount() { + EventEmitter.off(this.props.inputEventType, this.handleInputEvent); + } + + handleInputEvent = (inputValue) => { + const {readonly} = this.props; + const atDisabled = readonly || inputValue[inputValue.length - 1] === '@'; + const slashDisabled = readonly || inputValue.length > 0; + + this.setState({atDisabled, slashDisabled, inputValue}); + }; + + onShowFileMaxWarning = () => { + EventEmitter.emit('fileMaxWarning'); + }; + + render() { + const { + blurTextBox, + canUploadFiles, + canSend, + fileCount, + onSend, + onTextChange, + onShowFileMaxWarning, + readonly, + theme, + onUploadFiles, + } = this.props; + const uploadProps = { + blurTextBox, + disabled: !canUploadFiles || readonly, + fileCount, + maxFileCount: MAX_FILE_COUNT, + onShowFileMaxWarning, + theme, + onUploadFiles, + }; + + return ( + + + + + + + + + + + ); + } +} + +const style = StyleSheet.create({ + container: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: Platform.select({ + ios: 1, + android: 2, + }), + }, + quickActionsContainer: { + display: 'flex', + flexDirection: 'row', + height: 44, + }, +}); \ No newline at end of file diff --git a/app/components/__snapshots__/send_button.test.js.snap b/app/components/post_draft/quick_actions/send_action/__snapshots__/send.test.js.snap similarity index 90% rename from app/components/__snapshots__/send_button.test.js.snap rename to app/components/post_draft/quick_actions/send_action/__snapshots__/send.test.js.snap index 406264997..4527b9e21 100644 --- a/app/components/__snapshots__/send_button.test.js.snap +++ b/app/components/post_draft/quick_actions/send_action/__snapshots__/send.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = ` +exports[`SendAction should change theme backgroundColor to 0.3 opacity 1`] = ` `; -exports[`SendButton should match snapshot 1`] = ` +exports[`SendAction should match snapshot 1`] = ` `; -exports[`SendButton should render theme backgroundColor 1`] = ` +exports[`SendAction should render theme backgroundColor 1`] = ` { +describe('SendAction', () => { const baseProps = { theme: Preferences.THEMES.default, handleSendMessage: jest.fn(), @@ -18,7 +18,7 @@ describe('SendButton', () => { function getWrapper(props = {}) { return shallow( - , diff --git a/app/components/post_textbox/components/typing/index.js b/app/components/post_draft/typing/index.js similarity index 79% rename from app/components/post_textbox/components/typing/index.js rename to app/components/post_draft/typing/index.js index 9283df8d7..14caefe92 100644 --- a/app/components/post_textbox/components/typing/index.js +++ b/app/components/post_draft/typing/index.js @@ -5,13 +5,10 @@ import {connect} from 'react-redux'; import {getUsersTyping} from '@mm-redux/selectors/entities/typing'; -import {getTheme} from '@mm-redux/selectors/entities/preferences'; - import Typing from './typing'; function mapStateToProps(state) { return { - theme: getTheme(state), typing: getUsersTyping(state), }; } diff --git a/app/components/post_textbox/components/typing/typing.js b/app/components/post_draft/typing/typing.js similarity index 100% rename from app/components/post_textbox/components/typing/typing.js rename to app/components/post_draft/typing/typing.js diff --git a/app/components/post_textbox/components/typing/typing.test.js b/app/components/post_draft/typing/typing.test.js similarity index 100% rename from app/components/post_textbox/components/typing/typing.test.js rename to app/components/post_draft/typing/typing.test.js diff --git a/app/components/file_upload_preview/index.js b/app/components/post_draft/uploads/index.js similarity index 51% rename from app/components/file_upload_preview/index.js rename to app/components/post_draft/uploads/index.js index 1124bce27..31b6294df 100644 --- a/app/components/file_upload_preview/index.js +++ b/app/components/post_draft/uploads/index.js @@ -2,28 +2,28 @@ // See LICENSE.txt for license information. import {connect} from 'react-redux'; + +import {handleRemoveLastFile} from '@actions/views/file_upload'; import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; -import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {getDimensions} from '@selectors/device'; +import {checkForFileUploadingInChannel} from '@selectors/file'; -import {getDimensions} from 'app/selectors/device'; -import {checkForFileUploadingInChannel} from 'app/selectors/file'; -import {getCurrentChannelDraft, getThreadDraft} from 'app/selectors/views'; - -import FileUploadPreview from './file_upload_preview'; +import Uploads from './uploads'; function mapStateToProps(state, ownProps) { const {deviceHeight} = getDimensions(state); - const currentDraft = ownProps.rootId ? getThreadDraft(state, ownProps.rootId) : getCurrentChannelDraft(state); const channelId = getCurrentChannelId(state); return { channelId, channelIsLoading: state.views.channel.loading, deviceHeight, - files: currentDraft.files, filesUploadingForCurrentChannel: checkForFileUploadingInChannel(state, channelId, ownProps.rootId), - theme: getTheme(state), }; } -export default connect(mapStateToProps)(FileUploadPreview); +const mapDispatchToProps = { + handleRemoveLastFile, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(Uploads); diff --git a/app/components/post_draft/uploads/upload_item/index.js b/app/components/post_draft/uploads/upload_item/index.js new file mode 100644 index 000000000..ed027d78f --- /dev/null +++ b/app/components/post_draft/uploads/upload_item/index.js @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {handleRemoveFile, retryFileUpload, uploadComplete, uploadFailed} from 'app/actions/views/file_upload'; + +import UploadItem from './upload_item'; + +const mapDispatchToProps = { + handleRemoveFile, + retryFileUpload, + uploadComplete, + uploadFailed, +}; + +export default connect(null, mapDispatchToProps)(UploadItem); diff --git a/app/components/file_upload_preview/file_upload_item/file_upload_item.js b/app/components/post_draft/uploads/upload_item/upload_item.js similarity index 87% rename from app/components/file_upload_preview/file_upload_item/file_upload_item.js rename to app/components/post_draft/uploads/upload_item/upload_item.js index 2000149f0..1095649f6 100644 --- a/app/components/file_upload_preview/file_upload_item/file_upload_item.js +++ b/app/components/post_draft/uploads/upload_item/upload_item.js @@ -9,28 +9,26 @@ import {AnimatedCircularProgress} from 'react-native-circular-progress'; import {Client4} from '@mm-redux/client'; +import mattermostBucket from 'app/mattermost_bucket'; import FileAttachmentImage from '@components/file_attachment_list/file_attachment_image'; import FileAttachmentIcon from '@components/file_attachment_list/file_attachment_icon'; -import FileUploadRetry from '@components/file_upload_preview/file_upload_retry'; -import FileUploadRemove from '@components/file_upload_preview/file_upload_remove'; import {buildFileUploadData, encodeHeaderURIStringToUTF8} from '@utils/file'; import {emptyFunction} from '@utils/general'; import ImageCacheManager from '@utils/image_cache_manager'; -import mattermostBucket from 'app/mattermost_bucket'; +import UploadRemove from './upload_remove'; +import UploadRetry from './upload_retry'; -export default class FileUploadItem extends PureComponent { +export default class UploadItem extends PureComponent { static propTypes = { - actions: PropTypes.shape({ - handleRemoveFile: PropTypes.func.isRequired, - retryFileUpload: PropTypes.func.isRequired, - uploadComplete: PropTypes.func.isRequired, - uploadFailed: PropTypes.func.isRequired, - }).isRequired, channelId: PropTypes.string.isRequired, file: PropTypes.object.isRequired, + handleRemoveFile: PropTypes.func.isRequired, + retryFileUpload: PropTypes.func.isRequired, rootId: PropTypes.string, theme: PropTypes.object.isRequired, + uploadComplete: PropTypes.func.isRequired, + uploadFailed: PropTypes.func.isRequired, }; state = { @@ -58,11 +56,11 @@ export default class FileUploadItem extends PureComponent { return; } - this.props.actions.retryFileUpload(file, this.props.rootId); + this.props.retryFileUpload(file, this.props.rootId); }; handleRemoveFile = (clientId, channelId, rootId) => { - const {handleRemoveFile} = this.props.actions; + const {handleRemoveFile} = this.props; if (this.uploadPromise) { this.uploadPromise.cancel(() => { this.canceled = true; @@ -74,7 +72,7 @@ export default class FileUploadItem extends PureComponent { }; handleUploadCompleted = (res) => { - const {actions, channelId, file, rootId} = this.props; + const {channelId, file, rootId, uploadComplete, uploadFailed} = this.props; const response = JSON.parse(res.data); if (res.respInfo.status === 200 || res.respInfo.status === 201) { const data = response.file_infos.map((f) => { @@ -83,17 +81,17 @@ export default class FileUploadItem extends PureComponent { clientId: file.clientId, }; }); - actions.uploadComplete(data, channelId, rootId); + uploadComplete(data, channelId, rootId); } else { - actions.uploadFailed([file.clientId], channelId, rootId, response.message); + uploadFailed([file.clientId], channelId, rootId, response.message); } this.uploadPromise = null; }; handleUploadError = (error) => { - const {actions, channelId, file, rootId} = this.props; + const {channelId, file, rootId, uploadFailed} = this.props; if (!this.canceled) { - actions.uploadFailed([file.clientId], channelId, rootId, error); + uploadFailed([file.clientId], channelId, rootId, error); } this.uploadPromise = null; }; @@ -214,7 +212,7 @@ export default class FileUploadItem extends PureComponent { {filePreviewComponent} {file.failed && - @@ -234,7 +232,7 @@ export default class FileUploadItem extends PureComponent { } - { +describe('UploadItem', () => { const props = { - actions: { - handleRemoveFile: jest.fn(), - retryFileUpload: jest.fn(), - uploadComplete: jest.fn(), - uploadFailed: jest.fn(), - }, + handleRemoveFile: jest.fn(), + retryFileUpload: jest.fn(), + uploadComplete: jest.fn(), + uploadFailed: jest.fn(), channelId: 'channel-id', file: { loading: false, @@ -24,7 +22,7 @@ describe('FileUploadItem', () => { describe('downloadAndUploadFile', () => { test('should upload file', async () => { - const component = shallow(); + const component = shallow(); component.instance().uploadFile = jest.fn(); await component.instance().downloadAndUploadFile({ localPath: 'path/to/file', @@ -36,7 +34,7 @@ describe('FileUploadItem', () => { test('should download file if file path is http', async () => { jest.spyOn(ImageCacheManager, 'cache').mockReturnValue('path/to/downloaded/image'); - const component = shallow(); + const component = shallow(); component.instance().uploadFile = jest.fn(); await component.instance().downloadAndUploadFile({ localPath: 'https://path.to/file', @@ -47,7 +45,7 @@ describe('FileUploadItem', () => { }); test('should upload next file when we pass new props', async () => { - const component = shallow(); + const component = shallow(); component.instance().uploadFile = jest.fn(); component.setProps({file: { diff --git a/app/components/file_upload_preview/file_upload_remove.js b/app/components/post_draft/uploads/upload_item/upload_remove.js similarity index 90% rename from app/components/file_upload_preview/file_upload_remove.js rename to app/components/post_draft/uploads/upload_item/upload_remove.js index f39c57a87..5280b60ab 100644 --- a/app/components/file_upload_preview/file_upload_remove.js +++ b/app/components/post_draft/uploads/upload_item/upload_remove.js @@ -5,11 +5,11 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {View, Platform} from 'react-native'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; -import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; -import TouchableWithFeedback from 'app/components/touchable_with_feedback'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; -export default class FileUploadRemove extends PureComponent { +export default class UploadRemove extends PureComponent { static propTypes = { channelId: PropTypes.string, clientId: PropTypes.string, diff --git a/app/components/file_upload_preview/file_upload_retry.js b/app/components/post_draft/uploads/upload_item/upload_retry.js similarity index 89% rename from app/components/file_upload_preview/file_upload_retry.js rename to app/components/post_draft/uploads/upload_item/upload_retry.js index 781ecf052..6f84b26e9 100644 --- a/app/components/file_upload_preview/file_upload_retry.js +++ b/app/components/post_draft/uploads/upload_item/upload_retry.js @@ -6,9 +6,9 @@ import PropTypes from 'prop-types'; import {StyleSheet} from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; -import TouchableWithFeedback from 'app/components/touchable_with_feedback'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; -export default class FileUploadRetry extends PureComponent { +export default class UploadRetry extends PureComponent { static propTypes = { file: PropTypes.object.isRequired, onPress: PropTypes.func.isRequired, diff --git a/app/components/file_upload_preview/file_upload_preview.js b/app/components/post_draft/uploads/uploads.js similarity index 87% rename from app/components/file_upload_preview/file_upload_preview.js rename to app/components/post_draft/uploads/uploads.js index 8d588b511..d2fc589fa 100644 --- a/app/components/file_upload_preview/file_upload_preview.js +++ b/app/components/post_draft/uploads/uploads.js @@ -4,7 +4,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { - InteractionManager, + BackHandler, ScrollView, Text, View, @@ -14,21 +14,22 @@ import * as Animatable from 'react-native-animatable'; import EventEmitter from '@mm-redux/utils/event_emitter'; -import FormattedText from 'app/components/formatted_text'; -import {makeStyleSheetFromTheme} from 'app/utils/theme'; +import FormattedText from '@components/formatted_text'; +import {makeStyleSheetFromTheme} from '@utils/theme'; -import FileUploadItem from './file_upload_item'; +import UploadItem from './upload_item'; const showFiles = {opacity: 1, height: 68}; const hideFiles = {opacity: 0, height: 0}; const hideError = {height: 0}; -export default class FileUploadPreview extends PureComponent { +export default class Uploads extends PureComponent { static propTypes = { channelId: PropTypes.string.isRequired, channelIsLoading: PropTypes.bool, files: PropTypes.array.isRequired, filesUploadingForCurrentChannel: PropTypes.bool.isRequired, + handleRemoveLastFile: PropTypes.func.isRequired, rootId: PropTypes.string, theme: PropTypes.object.isRequired, }; @@ -51,25 +52,33 @@ export default class FileUploadPreview extends PureComponent { EventEmitter.on('fileSizeWarning', this.handleFileSizeWarning); if (this.props.files.length) { - InteractionManager.runAfterInteractions(this.showOrHideContainer); + this.showOrHideContainer(); + } + + if (Platform.OS === 'android') { + BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack); } } componentWillUnmount() { EventEmitter.off('fileMaxWarning', this.handleFileMaxWarning); EventEmitter.off('fileSizeWarning', this.handleFileSizeWarning); + + if (Platform.OS === 'android') { + BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack); + } } componentDidUpdate(prevProps) { if (this.containerRef.current && this.props.files.length !== prevProps.files.length) { - InteractionManager.runAfterInteractions(this.showOrHideContainer); + this.showOrHideContainer(); } } buildFilePreviews = () => { return this.props.files.map((file) => { return ( - { + const {channelId, files, handleRemoveLastFile, rootId} = this.props; + if (files.length) { + handleRemoveLastFile(channelId, rootId); + return true; + } + return false; + }; + handleFileMaxWarning = () => { this.setState({showFileMaxWarning: true}); if (this.errorRef.current) { diff --git a/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap b/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap deleted file mode 100644 index 3324d4660..000000000 --- a/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap +++ /dev/null @@ -1,340 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PostTextBox should match, full snapshot 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/app/components/post_textbox/components/paper_clip_icon.js b/app/components/post_textbox/components/paper_clip_icon.js deleted file mode 100644 index 2df0c2539..000000000 --- a/app/components/post_textbox/components/paper_clip_icon.js +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {Component} from 'react'; -import PropTypes from 'prop-types'; -import Svg, {G, Path} from 'react-native-svg'; - -export default class PaperClipIcon extends Component { - static propTypes = { - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - color: PropTypes.string.isRequired, - }; - - setNativeProps = (nativeProps) => { - if (this.root) { - this.root.setNativeProps(nativeProps); - } - }; - - render() { - return ( - - - - - - ); - } -} diff --git a/app/components/post_textbox/post_textbox.android.js b/app/components/post_textbox/post_textbox.android.js deleted file mode 100644 index 17094bf78..000000000 --- a/app/components/post_textbox/post_textbox.android.js +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import Autocomplete from 'app/components/autocomplete'; - -import Typing from './components/typing'; -import PostTextBoxBase from './post_textbox_base'; - -const AUTOCOMPLETE_MARGIN = 20; -const AUTOCOMPLETE_MAX_HEIGHT = 200; - -export default class PostTextBoxAndroid extends PostTextBoxBase { - render() { - const { - deactivatedChannel, - rootId, - } = this.props; - - if (deactivatedChannel) { - return this.renderDeactivatedChannel(); - } - - const {cursorPosition, top} = this.state; - - return ( - - - - {this.renderTextBox()} - - ); - } -} diff --git a/app/components/post_textbox/post_textbox.ios.js b/app/components/post_textbox/post_textbox.ios.js deleted file mode 100644 index 51a8a9c7d..000000000 --- a/app/components/post_textbox/post_textbox.ios.js +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import Typing from './components/typing'; -import PostTextBoxBase from './post_textbox_base'; - -export default class PostTextBoxIOS extends PostTextBoxBase { - render() { - const {deactivatedChannel} = this.props; - - if (deactivatedChannel) { - return this.renderDeactivatedChannel(); - } - - return ( - <> - - {this.renderTextBox()} - - ); - } -} diff --git a/app/components/post_textbox/post_textbox.test.js b/app/components/post_textbox/post_textbox.test.js deleted file mode 100644 index 0ffec11cc..000000000 --- a/app/components/post_textbox/post_textbox.test.js +++ /dev/null @@ -1,571 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {Alert, Image} from 'react-native'; -import assert from 'assert'; -import {shallowWithIntl} from 'test/intl-test-helper'; - -import Preferences from '@mm-redux/constants/preferences'; -import EventEmitter from '@mm-redux/utils/event_emitter'; - -import SendButton from 'app/components/send_button'; -import PasteableTextInput from 'app/components/pasteable_text_input'; -import EphemeralStore from 'app/store/ephemeral_store'; - -import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; -import FileUploadButton from './components/file_upload_button'; -import ImageUploadButton from './components/image_upload_button'; -import CameraButton from './components/camera_button'; - -import PostTextbox from './post_textbox.ios'; - -jest.runAllTimers(); - -jest.mock('react-native-image-picker', () => ({ - launchCamera: jest.fn(), -})); - -describe('PostTextBox', () => { - const baseProps = { - actions: { - addReactionToLatestPost: jest.fn(), - createPost: jest.fn(), - executeCommand: jest.fn(), - handleCommentDraftChanged: jest.fn(), - handlePostDraftChanged: jest.fn(), - handleClearFiles: jest.fn(), - handleClearFailedFiles: jest.fn(), - handleRemoveLastFile: jest.fn(), - initUploadFiles: jest.fn(), - userTyping: jest.fn(), - handleCommentDraftSelectionChanged: jest.fn(), - setStatus: jest.fn(), - selectPenultimateChannel: jest.fn(), - getChannelTimezones: jest.fn(), - }, - canUploadFiles: true, - channelId: 'channel-id', - channelDisplayName: 'Test Channel', - channelTeamId: 'channel-team-id', - channelIsReadOnly: false, - currentUserId: 'current-user-id', - deactivatedChannel: false, - files: [], - maxFileSize: 1024, - maxMessageLength: 4000, - rootId: '', - theme: Preferences.THEMES.default, - uploadFileRequestStatus: 'NOT_STARTED', - value: '', - userIsOutOfOffice: false, - channelIsArchived: false, - onCloseChannel: jest.fn(), - cursorPositionEvent: '', - valueEvent: '', - isLandscape: false, - screenId: 'NavigationScreen1', - canPost: true, - currentChannelMembersCount: 50, - enableConfirmNotificationsToChannel: true, - useChannelMentions: true, - }; - - test('should match, full snapshot', () => { - const wrapper = shallowWithIntl( - , - ); - - expect(wrapper.getElement()).toMatchSnapshot(); - }); - - test('should emit the event but no text is save to draft', () => { - const wrapper = shallowWithIntl( - , - ); - - wrapper.setState({value: 'some text'}); - - const instance = wrapper.instance(); - - instance.changeDraft = jest.fn(); - instance.handleAppStateChange('active'); - expect(instance.changeDraft).not.toBeCalled(); - }); - - test('should emit the event and text is save to draft', () => { - const wrapper = shallowWithIntl( - , - ); - - const instance = wrapper.instance(); - const value = 'some text'; - - wrapper.setState({value}); - instance.handleAppStateChange('background'); - expect(baseProps.actions.handlePostDraftChanged).toHaveBeenCalledWith(baseProps.channelId, value); - expect(baseProps.actions.handlePostDraftChanged).toHaveBeenCalledTimes(1); - }); - - test('should not send multiple alerts when message is too long', () => { - const wrapper = shallowWithIntl( - , - ); - - const instance = wrapper.instance(); - const longString = [...Array(baseProps.maxMessageLength + 2).keys()].map(() => Math.random().toString(36).slice(0, 1)).join(''); - - instance.handleTextChange(longString); - instance.handleTextChange(longString.slice(1)); - - expect(Alert.alert).toBeCalled(); - expect(Alert.alert).toHaveBeenCalledTimes(1); - }); - - test('should send an alert when sending a message with a channel mention', () => { - const wrapper = shallowWithIntl( - , - ); - const message = '@all'; - const instance = wrapper.instance(); - instance.handleTextChange(message); - - expect(wrapper.state('value')).toBe(message); - - instance.sendMessage(); - expect(Alert.alert).toBeCalled(); - expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything()); - }); - - test('should not send an alert when sending a message with a channel mention when the user does not have channel mentions permission', () => { - const wrapper = shallowWithIntl( - , - ); - const message = '@all'; - const instance = wrapper.instance(); - instance.handleTextChange(message); - - expect(wrapper.state('value')).toBe(message); - - instance.sendMessage(); - expect(Alert.alert).not.toHaveBeenCalled(); - }); - - test('should return correct @all (same for @channel)', () => { - for (const data of [ - { - text: '', - result: false, - }, - { - text: 'all', - result: false, - }, - { - text: '@allison', - result: false, - }, - { - text: '@ALLISON', - result: false, - }, - { - text: '@all123', - result: false, - }, - { - text: '123@all', - result: false, - }, - { - text: 'hey@all', - result: false, - }, - { - text: 'hey@all.com', - result: false, - }, - { - text: '@all', - result: true, - }, - { - text: '@ALL', - result: true, - }, - { - text: '@all hey', - result: true, - }, - { - text: 'hey @all', - result: true, - }, - { - text: 'HEY @ALL', - result: true, - }, - { - text: 'hey @all!', - result: true, - }, - { - text: 'hey @all:+1:', - result: true, - }, - { - text: 'hey @ALL:+1:', - result: true, - }, - { - text: '`@all`', - result: false, - }, - { - text: '@someone `@all`', - result: false, - }, - { - text: '``@all``', - result: false, - }, - { - text: '```@all```', - result: false, - }, - { - text: '```\n@all\n```', - result: false, - }, - { - text: '```````\n@all\n```````', - result: false, - }, - { - text: '```code\n@all\n```', - result: false, - }, - { - text: '~~~@all~~~', - result: true, - }, - { - text: '~~~\n@all\n~~~', - result: false, - }, - { - text: ' /not_cmd @all', - result: true, - }, - { - text: '@channel', - result: true, - }, - { - text: '@channel.', - result: true, - }, - { - text: '@channel/test', - result: true, - }, - { - text: 'test/@channel', - result: true, - }, - { - text: '@all/@channel', - result: true, - }, - { - text: '@cha*nnel*', - result: false, - }, - { - text: '@cha**nnel**', - result: false, - }, - { - text: '*@cha*nnel', - result: false, - }, - { - text: '[@chan](https://google.com)nel', - result: false, - }, - { - text: '@cha![](https://myimage)nnel', - result: false, - }, - ]) { - const wrapper = shallowWithIntl( - , - ); - const containsAtChannel = wrapper.instance().textContainsAtAllAtChannel(data.text); - assert.equal(containsAtChannel, data.result, data.text); - } - }); - - describe('send button', () => { - test('should initially disable and hide the send button', () => { - const wrapper = shallowWithIntl( - , - ); - - expect(wrapper.find(SendButton).prop('disabled')).toBe(true); - }); - - test('should show a disabled send button when uploading a file', () => { - const props = { - ...baseProps, - files: [{loading: true}], - }; - - const wrapper = shallowWithIntl( - , - ); - - expect(wrapper.find(SendButton).prop('disabled')).toBe(true); - }); - - test('should show an enabled send button after uploading a file', () => { - const props = { - ...baseProps, - files: [{loading: false}], - }; - - const wrapper = shallowWithIntl( - , - ); - - expect(wrapper.find(SendButton).prop('disabled')).toBe(false); - }); - - test('should show an enabled send button with a message', () => { - const props = { - ...baseProps, - value: 'test', - }; - - const wrapper = shallowWithIntl( - , - ); - - expect(wrapper.find(SendButton).prop('disabled')).toBe(false); - }); - - test('should show a disabled send button while sending the message', () => { - const props = { - ...baseProps, - value: 'test', - }; - - const wrapper = shallowWithIntl( - , - ); - - wrapper.setState({sendingMessage: true}); - - expect(wrapper.find(SendButton).prop('disabled')).toBe(true); - }); - }); - - test('should preseve post message in the input field if the command failed', async () => { - const props = {...baseProps}; - const errorResult = {error: {message: 'Error message'}}; - props.actions.executeCommand = jest.fn(). - mockResolvedValueOnce(errorResult). - mockResolvedValue({data: 'success'}); - - const wrapper = shallowWithIntl( - , - ); - - const msg = '/fail preserve this text in the post draft'; - const instance = wrapper.instance(); - instance.handleTextChange(msg); - expect(wrapper.state('value')).toBe(msg); - - // On error, should prompt Alert dialog and should remain text value - await instance.sendCommand(msg); - expect(Alert.alert).toHaveBeenCalledTimes(1); - expect(Alert.alert).toHaveBeenCalledWith('Error Executing Command', errorResult.error.message); - expect(wrapper.state('value')).toBe(msg); - - // On success, should not prompt Alert dialog and should change text value to empty - await instance.sendCommand(msg); - expect(Alert.alert).toHaveBeenCalledTimes(1); - expect(wrapper.state('value')).toBe(''); - }); - - describe('Paste images', () => { - test('should show error dialog if error occured', () => { - jest.spyOn(Alert, 'alert').mockReturnValue(null); - const wrapper = shallowWithIntl(); - EphemeralStore.addNavigationComponentId('NavigationScreen1'); - wrapper.find(PasteableTextInput).first().simulate('paste', {error: 'some error'}, []); - expect(Alert.alert).toHaveBeenCalled(); - }); - - test('should show file max warning and not uploading', () => { - jest.spyOn(EventEmitter, 'emit').mockReturnValue(null); - const wrapper = shallowWithIntl(); - EphemeralStore.addNavigationComponentId('NavigationScreen1'); - wrapper.find(PasteableTextInput).first().simulate('paste', null, [ - { - fileSize: 1000, - fileName: 'fileName.png', - type: 'images/png', - url: 'path/to/image', - }, - { - fileSize: 1000, - fileName: 'fileName.png', - type: 'images/png', - url: 'path/to/image', - }, - { - fileSize: 1000, - fileName: 'fileName.png', - type: 'images/png', - url: 'path/to/image', - }, - { - fileSize: 1000, - fileName: 'fileName.png', - type: 'images/png', - url: 'path/to/image', - }, - { - fileSize: 1000, - fileName: 'fileName.png', - type: 'images/png', - url: 'path/to/image', - }, - { - fileSize: 1000, - fileName: 'fileName.png', - type: 'images/png', - url: 'path/to/image', - }, - ]); - expect(EventEmitter.emit).toHaveBeenCalledWith('fileMaxWarning'); - expect(baseProps.actions.initUploadFiles).not.toHaveBeenCalled(); - }); - - test('should show file size warning and not uploading', () => { - jest.spyOn(EventEmitter, 'emit').mockReturnValue(null); - const wrapper = shallowWithIntl( - , - ); - wrapper.find(PasteableTextInput).first().simulate('paste', null, [ - { - fileSize: 51 * 1024 * 1024, - fileName: 'fileName.png', - type: 'images/png', - url: 'path/to/image', - }, - ]); - expect(EventEmitter.emit).toHaveBeenCalledWith('fileSizeWarning', 'File above 50 MB cannot be uploaded: fileName.png'); - expect(baseProps.actions.initUploadFiles).not.toHaveBeenCalled(); - }); - - test('should upload images', () => { - const wrapper = shallowWithIntl(); - EphemeralStore.addNavigationComponentId('NavigationScreen1'); - wrapper.find(PasteableTextInput).first().simulate('paste', null, [ - { - fileSize: 1000, - fileName: 'fileName.png', - type: 'images/png', - url: 'path/to/image', - }, - ]); - expect(baseProps.actions.initUploadFiles).toHaveBeenCalled(); - }); - - test('should NOT upload images when not the top most screen', () => { - const wrapper = shallowWithIntl(); - EphemeralStore.addNavigationComponentId('NavigationScreen2'); - wrapper.find(PasteableTextInput).first().simulate('paste', null, [ - { - fileSize: 1000, - fileName: 'fileName.png', - type: 'images/png', - url: 'path/to/image', - }, - ]); - expect(baseProps.actions.initUploadFiles).not.toHaveBeenCalled(); - }); - - test('should render all quick action icons', () => { - const wrapper = shallowWithIntl(); - - // @ button - expect(wrapper.find(MaterialCommunityIcons).exists()).toBe(true); - - // slash command button - expect(wrapper.find(Image).exists()).toBe(true); - - expect(wrapper.find(FileUploadButton).exists()).toBe(true); - expect(wrapper.find(ImageUploadButton).exists()).toBe(true); - expect(wrapper.find(CameraButton).exists()).toBe(true); - }); - - test('should trigger text change when @ icon is tapped', () => { - const wrapper = shallowWithIntl(); - const instance = wrapper.instance(); - instance.handleTextChange = jest.fn(); - - wrapper.find(MaterialCommunityIcons).parent().props().onPress(); - expect(instance.handleTextChange).toHaveBeenCalledWith(`${instance.state.value}@`, true); - }); - - test('should disable slash icon if textbox value is NOT empty', () => { - const wrapper = shallowWithIntl(); - const instance = wrapper.instance(); - instance.setState({value: 'Test'}); - expect(wrapper.find(Image).parent().props().disabled).toBe(true); - }); - - test('should NOT render file upload icons when server forbids it', () => { - const props = { - ...baseProps, - canUploadFiles: false, - }; - - const wrapper = shallowWithIntl(); - - expect(wrapper.find(MaterialCommunityIcons).exists()).toBe(true); - expect(wrapper.find(Image).exists()).toBe(true); - - expect(wrapper.find(FileUploadButton).exists()).toBe(false); - expect(wrapper.find(ImageUploadButton).exists()).toBe(false); - expect(wrapper.find(CameraButton).exists()).toBe(false); - }); - }); - - test('should change state value on props change', () => { - const wrapper = shallowWithIntl(); - expect(wrapper.state('value')).toEqual(''); - wrapper.setProps({value: 'value', channelId: 'channel-id2'}); - expect(wrapper.state('value')).toEqual('value'); - }); - - test('should be disabled without the create_post permission', () => { - const props = {...baseProps, canPost: false}; - - const wrapper = shallowWithIntl( - , - ); - - expect(wrapper.find(PasteableTextInput).prop('editable')).toBe(false); - }); -}); - diff --git a/app/components/post_textbox/post_textbox_base.js b/app/components/post_textbox/post_textbox_base.js deleted file mode 100644 index eb23dd2e3..000000000 --- a/app/components/post_textbox/post_textbox_base.js +++ /dev/null @@ -1,1105 +0,0 @@ -// 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, - TouchableOpacity, - ScrollView, - View, - Image, -} from 'react-native'; -import {intlShape} from 'react-intl'; -import RNFetchBlob from 'rn-fetch-blob'; -import Button from 'react-native-button'; -import HWKeyboardEvent from 'react-native-hw-keyboard-event'; -import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; -import slashForwardBoxIcon from 'assets/images/icons/slash-forward-box.png'; - -import {General, RequestStatus} from '@mm-redux/constants'; -import EventEmitter from '@mm-redux/utils/event_emitter'; -import {getFormattedFileSize} from '@mm-redux/utils/file_utils'; - -import FileUploadButton from './components/file_upload_button'; -import ImageUploadButton from './components/image_upload_button'; -import CameraButton from './components/camera_button'; -import FormattedMarkdownText from 'app/components/formatted_markdown_text'; -import FormattedText from 'app/components/formatted_text'; -import PasteableTextInput from 'app/components/pasteable_text_input'; -import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing'; -import SendButton from 'app/components/send_button'; -import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_FILE_COUNT, ICON_SIZE} from 'app/constants/post_textbox'; -import {NOTIFY_ALL_MEMBERS} from 'app/constants/view'; -import FileUploadPreview from 'app/components/file_upload_preview'; - -import EphemeralStore from 'app/store/ephemeral_store'; -import {t} from 'app/utils/i18n'; -import {confirmOutOfOfficeDisabled} from 'app/utils/status'; -import {switchKeyboardForCodeBlocks} from 'app/utils/markdown'; -import { - changeOpacity, - makeStyleSheetFromTheme, - getKeyboardAppearanceFromTheme, -} from 'app/utils/theme'; - -const {RNTextInputReset} = NativeModules; -const INPUT_LINE_HEIGHT = 20; -const EXTRA_INPUT_PADDING = 3; -const HW_SHIFT_ENTER_TEXT = Platform.OS === 'ios' ? '\n' : ''; - -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, - getChannelTimezones: PropTypes.func.isRequired, - }).isRequired, - canUploadFiles: PropTypes.bool.isRequired, - channelId: PropTypes.string.isRequired, - channelDisplayName: PropTypes.string, - channelTeamId: PropTypes.string.isRequired, - 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, - currentChannelMembersCount: PropTypes.number, - enableConfirmNotificationsToChannel: PropTypes.bool, - isTimezoneEnabled: PropTypes.bool, - currentChannel: PropTypes.object, - isLandscape: PropTypes.bool.isRequired, - screenId: PropTypes.string.isRequired, - canPost: PropTypes.bool.isRequired, - useChannelMentions: PropTypes.bool.isRequired, - }; - - static defaultProps = { - files: [], - rootId: '', - value: '', - canPost: true, - }; - - static contextTypes = { - intl: intlShape, - }; - - constructor(props) { - super(props); - - this.input = React.createRef(); - - this.state = { - cursorPosition: 0, - keyboardType: 'default', - top: 0, - value: props.value, - rootId: props.rootId, - channelId: props.channelId, - channelTimezoneCount: 0, - longMessageAlertShown: false, - extraInputPadding: 0, - }; - } - - componentDidMount(prevProps) { - const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT; - - EventEmitter.on(event, this.handleInsertTextToDraft); - AppState.addEventListener('change', this.handleAppStateChange); - HWKeyboardEvent.onHWKeyPressed(this.handleHardwareEnterPress); - - if (Platform.OS === 'android') { - Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard); - BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack); - } - - if (this.props.isTimezoneEnabled !== prevProps?.isTimezoneEnabled || prevProps?.channelId !== this.props.channelId) { - this.numberOfTimezones().then((channelTimezoneCount) => this.setState({channelTimezoneCount})); - } - } - - static getDerivedStateFromProps(nextProps, state) { - if (nextProps.channelId !== state.channelId || nextProps.rootId !== state.rootId) { - return { - value: nextProps.value, - channelId: nextProps.channelId, - rootId: nextProps.rootId, - }; - } - return null; - } - - componentWillUnmount() { - const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT; - - EventEmitter.off(event, this.handleInsertTextToDraft); - AppState.removeEventListener('change', this.handleAppStateChange); - HWKeyboardEvent.removeOnHWKeyPressed(); - - if (Platform.OS === 'android') { - Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard); - BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack); - } - } - - blur = () => { - if (this.input.current) { - this.input.current.blur(); - } - }; - - focus = () => { - if (this.input.current) { - this.input.current.focus(); - } - } - - numberOfTimezones = async () => { - const {data} = await this.props.actions.getChannelTimezones(this.props.channelId); - return data?.length || 0; - }; - - 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) { - // Check if component is already aware message is too long - if (!this.state.longMessageAlertShown) { - 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: {count}/{max}', - }, { - max: maxMessageLength, - count: valueLength, - }), - ); - this.setState({longMessageAlertShown: true}); - } - } else if (this.state.longMessageAlertShown) { - this.setState({longMessageAlertShown: false}); - } - }; - - startAtMention = () => { - this.handleTextChange(`${this.state.value}@`, true); - this.focus(); - }; - - startSlashCommand = () => { - this.handleTextChange('/', true); - this.focus(); - }; - - getTextInputButton = (actionType) => { - const {channelIsReadOnly, theme, canPost} = this.props; - const style = getStyleSheet(theme); - - let button = null; - const buttonStyle = []; - let iconColor = changeOpacity(theme.centerChannelColor, 0.64); - let isDisabled = false; - - if (!channelIsReadOnly && canPost) { - switch (actionType) { - case 'at': - isDisabled = this.state.value[this.state.value.length - 1] === '@'; - if (isDisabled) { - iconColor = changeOpacity(theme.centerChannelColor, 0.16); - } - button = ( - - - - ); - break; - case 'slash': - isDisabled = this.state.value.length > 0; - buttonStyle.push(style.slashIcon); - if (isDisabled) { - buttonStyle.push(style.iconDisabled); - } - - button = ( - - - - ); - break; - } - } - - return button; - } - - getMediaButton = (actionType) => { - const {canUploadFiles, channelIsReadOnly, files, maxFileSize, theme, canPost} = this.props; - const style = getStyleSheet(theme); - let button = null; - const props = { - blurTextBox: this.blur, - fileCount: files.length, - maxFileCount: MAX_FILE_COUNT, - onShowFileMaxWarning: this.onShowFileMaxWarning, - onShowFileSizeWarning: this.onShowFileSizeWarning, - uploadFiles: this.handleUploadFiles, - maxFileSize, - theme, - buttonContainerStyle: style.iconWrapper, - }; - - if (canUploadFiles && !channelIsReadOnly && canPost) { - switch (actionType) { - case 'file': - button = ( - - ); - break; - case 'image': - button = ( - - ); - break; - case 'camera': - button = ( - - ); - } - } - - return button; - } - - getInputContainerStyle = () => { - const {channelIsReadOnly, theme, canPost} = this.props; - const style = getStyleSheet(theme); - const inputContainerStyle = [style.inputContainer]; - - if (channelIsReadOnly || !canPost) { - inputContainerStyle.push(style.readonlyContainer); - } - - return inputContainerStyle; - }; - - getPlaceHolder = () => { - const {channelIsReadOnly, rootId, canPost} = this.props; - let placeholder; - - if (channelIsReadOnly || !canPost) { - 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(); - }; - - handleHardwareEnterPress = (keyEvent) => { - switch (keyEvent.pressedKey) { - case 'enter': this.handleSendMessage(); - break; - case 'shift-enter': this.handleInsertTextToDraft(HW_SHIFT_ENTER_TEXT); - } - } - - 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 || ''); - } - }; - - handleOnSelectionChange = (event) => { - this.handlePostDraftSelectionChanged(event, false); - }; - - handlePostDraftSelectionChanged = (event, fromHandleTextChange) => { - const cursorPosition = fromHandleTextChange ? this.state.cursorPosition : event.nativeEvent.selection.end; - - const {cursorPositionEvent} = this.props; - - if (cursorPositionEvent) { - EventEmitter.emit(cursorPositionEvent, cursorPosition); - } - - if (Platform.OS === 'ios') { - const keyboardType = switchKeyboardForCodeBlocks(this.state.value, cursorPosition); - this.setState({cursorPosition, keyboardType}); - } else { - this.setState({cursorPosition}); - } - }; - - handleSendMessage = () => { - if (!this.isSendButtonEnabled()) { - 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); - - // Workaround to avoid iOS emdash autocorrect in Code Blocks - if (Platform.OS === 'ios') { - const callback = () => this.handlePostDraftSelectionChanged(null, true); - this.setState(nextState, callback); - } else { - this.setState(nextState); - } - - if (value) { - actions.userTyping(channelId, rootId); - } - }; - - handleUploadFiles = async (files) => { - const file = files[0]; - if (!file.fileSize | !file.fileName) { - const path = (file.path || file.uri).replace('file://', ''); - const fileInfo = await RNFetchBlob.fs.stat(path); - file.fileSize = fileInfo.size; - file.fileName = fileInfo.filename; - } - - if (file.fileSize > this.props.maxFileSize) { - this.onShowFileSizeWarning(file.fileName); - } else { - this.props.actions.initUploadFiles(files, 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.isFileLoading() && !this.state.sendingMessage; - }; - - sendMessage = () => { - const {value} = this.state; - - const currentMembersCount = this.props.currentChannelMembersCount; - const notificationsToChannel = this.props.enableConfirmNotificationsToChannel && this.props.useChannelMentions; - const toAllOrChannel = this.textContainsAtAllAtChannel(value); - - if (value.indexOf('/') === 0) { - this.sendCommand(value); - } else if (notificationsToChannel && currentMembersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) { - this.showSendToAllOrChannelAlert(currentMembersCount); - } else { - this.doSubmitMessage(); - } - }; - - textContainsAtAllAtChannel = (text) => { - const textWithoutCode = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g, ''); - return (/\B@(all|channel)\b/i).test(textWithoutCode); - }; - - showSendToAllOrChannelAlert = (currentMembersCount) => { - const {intl} = this.context; - const {channelTimezoneCount} = this.state; - const {isTimezoneEnabled} = this.props; - - let notifyAllMessage = ''; - if (isTimezoneEnabled && channelTimezoneCount) { - notifyAllMessage = ( - intl.formatMessage( - { - id: 'mobile.post_textbox.entire_channel.message.with_timezones', - defaultMessage: 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', - }, - { - totalMembers: currentMembersCount - 1, - timezones: channelTimezoneCount, - }, - ) - ); - } else { - notifyAllMessage = ( - intl.formatMessage( - { - id: 'mobile.post_textbox.entire_channel.message', - defaultMessage: 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?', - }, - { - totalMembers: currentMembersCount - 1, - }, - ) - ); - } - - Alert.alert( - intl.formatMessage({ - id: 'mobile.post_textbox.entire_channel.title', - defaultMessage: 'Confirm sending notifications to entire channel', - }), - notifyAllMessage, - [ - { - text: intl.formatMessage({ - id: 'mobile.post_textbox.entire_channel.cancel', - defaultMessage: 'Cancel', - }), - onPress: () => { - this.setState({sendingMessage: false}); - }, - }, - { - text: intl.formatMessage({ - id: 'mobile.post_textbox.entire_channel.confirm', - defaultMessage: 'Confirm', - }), - onPress: () => this.doSubmitMessage(), - }, - ], - ); - }; - - doSubmitMessage = () => { - const {actions, currentUserId, channelId, files, rootId} = this.props; - const {value} = this.state; - 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); - this.setState({sendingMessage: false}); - return; - } - - const {error} = await actions.executeCommand(msg, channelId, rootId); - this.setState({sendingMessage: false}); - - if (error) { - Alert.alert( - intl.formatMessage({ - id: 'mobile.commands.error_title', - defaultMessage: 'Error Executing Command', - }), - error.message, - ); - return; - } - - this.handleTextChange(''); - this.changeDraft(''); - }; - - sendReaction = (emoji) => { - const {actions, rootId} = this.props; - actions.addReactionToLatestPost(emoji, rootId); - - this.handleTextChange(''); - this.changeDraft(''); - - this.setState({sendingMessage: false}); - }; - - onShowFileMaxWarning = () => { - EventEmitter.emit('fileMaxWarning'); - }; - - onShowFileSizeWarning = (filename) => { - const {formatMessage} = this.context.intl; - const fileSizeWarning = formatMessage({ - id: 'file_upload.fileAbove', - defaultMessage: 'File above {max} cannot be uploaded: {filename}', - }, { - max: getFormattedFileSize({size: this.props.maxFileSize}), - filename, - }); - - EventEmitter.emit('fileSizeWarning', fileSizeWarning); - setTimeout(() => { - EventEmitter.emit('fileSizeWarning', null); - }, 5000); - }; - - 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, - }); - }; - - showPasteFilesErrorDialog = () => { - const {formatMessage} = this.context.intl; - Alert.alert( - formatMessage({ - id: 'mobile.files_paste.error_title', - defaultMessage: 'Paste failed', - }), - formatMessage({ - id: 'mobile.files_paste.error_description', - defaultMessage: 'An error occurred while pasting the file(s). Please try again.', - }), - [ - { - text: formatMessage({ - id: 'mobile.files_paste.error_dismiss', - defaultMessage: 'Dismiss', - }), - }, - ], - ); - }; - - handlePasteFiles = (error, files) => { - if (this.props.screenId === EphemeralStore.getNavigationTopComponentId()) { - if (error) { - this.showPasteFilesErrorDialog(); - return; - } - - const {maxFileSize} = this.props; - const availableCount = MAX_FILE_COUNT - this.props.files.length; - if (files.length > availableCount) { - this.onShowFileMaxWarning(); - return; - } - - const largeFile = files.find((image) => image.fileSize > maxFileSize); - if (largeFile) { - this.onShowFileSizeWarning(largeFile.fileName); - return; - } - - this.handleUploadFiles(files); - } - }; - - handleInputSizeChange = ({nativeEvent: {contentSize}}) => { - const {extraInputPadding} = this.state; - const numLines = contentSize.height / INPUT_LINE_HEIGHT; - if (numLines >= 2 && extraInputPadding !== EXTRA_INPUT_PADDING) { - this.setState({extraInputPadding: EXTRA_INPUT_PADDING}); - } else if (numLines < 2 && extraInputPadding !== 0) { - this.setState({extraInputPadding: 0}); - } - } - - 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, channelIsReadOnly, theme, isLandscape, files, rootId, canPost} = this.props; - const style = getStyleSheet(theme); - - if (channelIsArchived) { - return this.archivedView(theme, style); - } - - const {value, extraInputPadding} = this.state; - const placeholder = this.getPlaceHolder(); - - let maxHeight = 150; - - if (isLandscape) { - maxHeight = 88; - } - - const inputStyle = {}; - if (extraInputPadding) { - inputStyle.paddingBottom = style.input.paddingBottom + extraInputPadding; - inputStyle.paddingTop = style.input.paddingTop + extraInputPadding; - } - - return ( - - - - {!channelIsReadOnly && canPost && - - - - - - - {this.getTextInputButton('at')} - - {this.getTextInputButton('slash')} - - {this.getMediaButton('file')} - - {this.getMediaButton('image')} - - {this.getMediaButton('camera')} - - - - - - } - - - ); - }; -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - buttonsContainer: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingBottom: Platform.select({ - ios: 1, - android: 2, - }), - }, - slashIcon: { - width: ICON_SIZE, - height: ICON_SIZE, - opacity: 1, - tintColor: changeOpacity(theme.centerChannelColor, 0.64), - }, - iconDisabled: { - tintColor: changeOpacity(theme.centerChannelColor, 0.16), - }, - iconWrapper: { - alignItems: 'center', - justifyContent: 'center', - padding: 10, - }, - quickActionsContainer: { - display: 'flex', - flexDirection: 'row', - height: 44, - }, - input: { - color: theme.centerChannelColor, - fontSize: 15, - lineHeight: INPUT_LINE_HEIGHT, - paddingHorizontal: 12, - paddingTop: Platform.select({ - ios: 6, - android: 8, - }), - paddingBottom: Platform.select({ - ios: 6, - android: 2, - }), - minHeight: 30, - }, - inputContainer: { - flex: 1, - flexDirection: 'column', - }, - inputContentContainer: { - alignItems: 'stretch', - paddingTop: Platform.select({ - ios: 7, - android: 0, - }), - }, - inputWrapper: { - alignItems: 'flex-end', - flexDirection: 'row', - justifyContent: 'center', - paddingBottom: 2, - 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, - backgroundColor: theme.centerChannelBg, - 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, - }, - }; -}); diff --git a/app/components/sidebars/main/main_sidebar.ios.js b/app/components/sidebars/main/main_sidebar.ios.js index 362fa06a6..e38342476 100644 --- a/app/components/sidebars/main/main_sidebar.ios.js +++ b/app/components/sidebars/main/main_sidebar.ios.js @@ -97,7 +97,7 @@ export default class MainSidebarIOS extends MainSidebarBase { }; open = () => { - EventEmitter.emit(NavigationTypes.BLUR_POST_TEXTBOX); + EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT); if (this.drawerRef?.current) { this.drawerRef.current.openDrawer(); diff --git a/app/components/sidebars/settings/settings_sidebar.ios.js b/app/components/sidebars/settings/settings_sidebar.ios.js index a8892e9e9..834ef132e 100644 --- a/app/components/sidebars/settings/settings_sidebar.ios.js +++ b/app/components/sidebars/settings/settings_sidebar.ios.js @@ -58,7 +58,7 @@ export default class SettingsDrawer extends SettingsSidebarBase { }; open = () => { - EventEmitter.emit(NavigationTypes.BLUR_POST_TEXTBOX); + EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT); if (this.drawerRef && !this.drawerOpened) { this.drawerRef.openDrawer(); diff --git a/app/constants/navigation.js b/app/constants/navigation.js index 04758278c..a7226d2d4 100644 --- a/app/constants/navigation.js +++ b/app/constants/navigation.js @@ -14,7 +14,7 @@ const NavigationTypes = keyMirror({ MAIN_SIDEBAR_DID_CLOSE: null, MAIN_SIDEBAR_DID_OPEN: null, CLOSE_SETTINGS_SIDEBAR: null, - BLUR_POST_TEXTBOX: null, + BLUR_POST_DRAFT: null, }); export default NavigationTypes; diff --git a/app/constants/post_textbox.js b/app/constants/post_draft.js similarity index 53% rename from app/constants/post_textbox.js rename to app/constants/post_draft.js index 082ecf56a..1394d3a83 100644 --- a/app/constants/post_textbox.js +++ b/app/constants/post_draft.js @@ -5,4 +5,7 @@ export const MAX_FILE_COUNT = 5; export const IS_REACTION_REGEX = /(^\+:([^:\s]*):)$/i; export const INSERT_TO_DRAFT = 'insert_to_draft'; export const INSERT_TO_COMMENT = 'insert_to_comment'; -export const ICON_SIZE = 24; \ No newline at end of file +export const ICON_SIZE = 24; +export const ACCESSORIES_CONTAINER_NATIVE_ID = 'channelAccessoriesContainer'; +export const CHANNEL_POST_TEXTBOX_CURSOR_CHANGE = 'onChannelTextBoxCursorChange'; +export const CHANNEL_POST_TEXTBOX_VALUE_CHANGE = 'onChannelTextBoxValueChange'; \ No newline at end of file diff --git a/app/mattermost.js b/app/mattermost.js index 1eee8ab33..c44d42f06 100644 --- a/app/mattermost.js +++ b/app/mattermost.js @@ -108,10 +108,10 @@ Navigation.events().registerAppLaunchedListener(() => { switch (componentId) { case 'MainSidebar': EventEmitter.emit(NavigationTypes.MAIN_SIDEBAR_DID_OPEN, this.handleSidebarDidOpen); - EventEmitter.emit(Navigation.BLUR_POST_TEXTBOX); + EventEmitter.emit(Navigation.BLUR_POST_DRAFT); break; case 'SettingsSidebar': - EventEmitter.emit(NavigationTypes.BLUR_POST_TEXTBOX); + EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT); break; } }); diff --git a/app/screens/channel/channel.android.js b/app/screens/channel/channel.android.js index c6123fff0..6b492eb1c 100644 --- a/app/screens/channel/channel.android.js +++ b/app/screens/channel/channel.android.js @@ -4,15 +4,14 @@ import React from 'react'; import {StyleSheet, View} from 'react-native'; +import {openMainSideMenu, openSettingsSideMenu} from '@actions/navigation'; +import KeyboardLayout from '@components/layout/keyboard_layout'; +import InteractiveDialogController from '@components/interactive_dialog_controller'; +import NetworkIndicator from '@components/network_indicator'; +import PostDraft from '@components/post_draft'; +import {NavigationTypes} from '@constants'; import EventEmitter from '@mm-redux/utils/event_emitter'; -import {openMainSideMenu, openSettingsSideMenu} from 'app/actions/navigation'; -import KeyboardLayout from 'app/components/layout/keyboard_layout'; -import InteractiveDialogController from 'app/components/interactive_dialog_controller'; -import NetworkIndicator from 'app/components/network_indicator'; -import PostTextbox from 'app/components/post_textbox'; -import {NavigationTypes} from 'app/constants'; - import LocalConfig from 'assets/config'; import ChannelNavBar from './channel_nav_bar'; @@ -22,12 +21,12 @@ import ChannelBase, {ClientUpgradeListener} from './channel_base'; export default class ChannelAndroid extends ChannelBase { openMainSidebar = () => { - EventEmitter.emit(NavigationTypes.BLUR_POST_TEXTBOX); + EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT); openMainSideMenu(); }; openSettingsSidebar = () => { - EventEmitter.emit(NavigationTypes.BLUR_POST_TEXTBOX); + EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT); openSettingsSideMenu(); }; @@ -49,8 +48,8 @@ export default class ChannelAndroid extends ChannelBase { - diff --git a/app/screens/channel/channel.ios.js b/app/screens/channel/channel.ios.js index 177c4122b..f0e84747b 100644 --- a/app/screens/channel/channel.ios.js +++ b/app/screens/channel/channel.ios.js @@ -5,15 +5,16 @@ import React from 'react'; import {View} from 'react-native'; import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view'; -import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from 'app/components/autocomplete'; -import InteractiveDialogController from 'app/components/interactive_dialog_controller'; -import MainSidebar from 'app/components/sidebars/main'; -import NetworkIndicator from 'app/components/network_indicator'; -import PostTextbox from 'app/components/post_textbox'; -import SafeAreaView from 'app/components/safe_area_view'; -import SettingsSidebar from 'app/components/sidebars/settings'; -import StatusBar from 'app/components/status_bar'; -import {makeStyleSheetFromTheme} from 'app/utils/theme'; +import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from '@components/autocomplete'; +import InteractiveDialogController from '@components/interactive_dialog_controller'; +import NetworkIndicator from '@components/network_indicator'; +import PostDraft from '@components/post_draft'; +import SafeAreaView from '@components/safe_area_view'; +import MainSidebar from '@components/sidebars/main'; +import SettingsSidebar from '@components/sidebars/settings'; +import StatusBar from '@components/status_bar'; +import {ACCESSORIES_CONTAINER_NATIVE_ID, CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, CHANNEL_POST_TEXTBOX_VALUE_CHANGE} from '@constants/post_draft'; +import {makeStyleSheetFromTheme} from '@utils/theme'; import LocalConfig from 'assets/config'; @@ -21,11 +22,13 @@ import ChannelBase, {ClientUpgradeListener} from './channel_base'; import ChannelNavBar from './channel_nav_bar'; import ChannelPostList from './channel_post_list'; -const ACCESSORIES_CONTAINER_NATIVE_ID = 'channelAccessoriesContainer'; -const CHANNEL_POST_TEXTBOX_CURSOR_CHANGE = 'onChannelTextBoxCursorChange'; -const CHANNEL_POST_TEXTBOX_VALUE_CHANGE = 'onChannelTextBoxValueChange'; - export default class ChannelIOS extends ChannelBase { + handleAutoComplete = (value) => { + if (this.postDraft?.current) { + this.postDraft.current.handleInputQuickAction(value); + } + }; + mainSidebarRef = (ref) => { if (ref) { this.mainSidebar = ref; @@ -87,10 +90,10 @@ export default class ChannelIOS extends ChannelBase { scrollViewNativeID={currentChannelId} accessoriesContainerID={ACCESSORIES_CONTAINER_NATIVE_ID} > - diff --git a/app/screens/channel/channel_base.js b/app/screens/channel/channel_base.js index f8e16bc31..6fef6a586 100644 --- a/app/screens/channel/channel_base.js +++ b/app/screens/channel/channel_base.js @@ -58,7 +58,7 @@ export default class ChannelBase extends PureComponent { constructor(props) { super(props); - this.postTextbox = React.createRef(); + this.postDraft = React.createRef(); this.keyboardTracker = React.createRef(); this.state = { @@ -71,7 +71,7 @@ export default class ChannelBase extends PureComponent { } componentDidMount() { - EventEmitter.on(NavigationTypes.BLUR_POST_TEXTBOX, this.blurPostTextBox); + EventEmitter.on(NavigationTypes.BLUR_POST_DRAFT, this.blurPostDraft); EventEmitter.on('leave_team', this.handleLeaveTeam); if (this.props.currentTeamId) { @@ -134,13 +134,13 @@ export default class ChannelBase extends PureComponent { } componentWillUnmount() { - EventEmitter.off(NavigationTypes.BLUR_POST_TEXTBOX, this.blurPostTextBox); + EventEmitter.off(NavigationTypes.BLUR_POST_DRAFT, this.blurPostDraft); EventEmitter.off('leave_team', this.handleLeaveTeam); } - blurPostTextBox = () => { - if (this.postTextbox?.current) { - this.postTextbox.current.blur(); + blurPostDraft = () => { + if (this.postDraft?.current) { + this.postDraft.current.blurTextBox(); } }; @@ -190,12 +190,6 @@ export default class ChannelBase extends PureComponent { }); }, 1000); - handleAutoComplete = (value) => { - if (this.postTextbox?.current) { - this.postTextbox.current.handleTextChange(value, true); - } - }; - handleLeaveTeam = () => { this.props.actions.selectDefaultTeam(); }; diff --git a/app/screens/thread/__snapshots__/thread.ios.test.js.snap b/app/screens/thread/__snapshots__/thread.ios.test.js.snap index 46420f9f6..d0f70c3f1 100644 --- a/app/screens/thread/__snapshots__/thread.ios.test.js.snap +++ b/app/screens/thread/__snapshots__/thread.ios.test.js.snap @@ -56,11 +56,10 @@ exports[`thread should match snapshot, has root post 1`] = ` accessoriesContainerID="threadAccessoriesContainer" scrollViewNativeID="threadPostList" > - diff --git a/app/screens/thread/thread.android.js b/app/screens/thread/thread.android.js index d6f62d6fc..bb0611af2 100644 --- a/app/screens/thread/thread.android.js +++ b/app/screens/thread/thread.android.js @@ -4,14 +4,14 @@ import React from 'react'; import {View} from 'react-native'; -import {THREAD} from 'app/constants/screen'; -import KeyboardLayout from 'app/components/layout/keyboard_layout'; -import Loading from 'app/components/loading'; -import PostList from 'app/components/post_list'; -import PostTextbox from 'app/components/post_textbox'; -import SafeAreaView from 'app/components/safe_area_view'; -import StatusBar from 'app/components/status_bar'; -import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; +import KeyboardLayout from '@components/layout/keyboard_layout'; +import Loading from '@components/loading'; +import PostList from '@components/post_list'; +import PostDraft from '@components/post_draft'; +import SafeAreaView from '@components/safe_area_view'; +import StatusBar from '@components/status_bar'; +import {THREAD} from '@constants/screen'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import ThreadBase from './thread_base'; @@ -27,29 +27,27 @@ export default class ThreadAndroid extends ThreadBase { } = this.props; let content; - let postTextBox; if (this.hasRootPost()) { content = ( - - ); - - postTextBox = ( - + <> + + + ); } else { content = ( @@ -64,7 +62,6 @@ export default class ThreadAndroid extends ThreadBase { {content} - {postTextBox} ); diff --git a/app/screens/thread/thread.ios.js b/app/screens/thread/thread.ios.js index f326c1e6f..6d7ad2ec1 100644 --- a/app/screens/thread/thread.ios.js +++ b/app/screens/thread/thread.ios.js @@ -5,16 +5,15 @@ import React from 'react'; import {View} from 'react-native'; import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view'; +import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from '@components/autocomplete'; +import Loading from '@components/loading'; +import PostList from '@components/post_list'; +import PostDraft from '@components/post_draft'; +import SafeAreaView from '@components/safe_area_view'; +import StatusBar from '@components/status_bar'; +import {THREAD} from '@constants/screen'; import {getLastPostIndex} from '@mm-redux/utils/post_list'; - -import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from 'app/components/autocomplete'; -import Loading from 'app/components/loading'; -import PostList from 'app/components/post_list'; -import PostTextbox from 'app/components/post_textbox'; -import SafeAreaView from 'app/components/safe_area_view'; -import StatusBar from 'app/components/status_bar'; -import {THREAD} from 'app/constants/screen'; -import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import ThreadBase from './thread_base'; @@ -24,6 +23,12 @@ const THREAD_POST_TEXTBOX_VALUE_CHANGE = 'onThreadTextBoxValueChange'; const SCROLLVIEW_NATIVE_ID = 'threadPostList'; export default class ThreadIOS extends ThreadBase { + handleAutoComplete = (value) => { + if (this.postDraft?.current) { + this.postDraft.current.handleInputQuickAction(value); + } + }; + render() { const { channelId, @@ -35,10 +40,10 @@ export default class ThreadIOS extends ThreadBase { } = this.props; let content; - let postTextBox; + let postDraft; if (this.hasRootPost()) { content = ( - + <> - + ); - postTextBox = ( + postDraft = ( - {content} - {postTextBox} + {postDraft} ); } diff --git a/app/screens/thread/thread.ios.test.js b/app/screens/thread/thread.ios.test.js index 62bb46e1d..d2503ee78 100644 --- a/app/screens/thread/thread.ios.test.js +++ b/app/screens/thread/thread.ios.test.js @@ -8,7 +8,6 @@ import Preferences from '@mm-redux/constants/preferences'; import {General, RequestStatus} from '@mm-redux/constants'; import PostList from 'app/components/post_list'; -import * as NavigationActions from 'app/actions/navigation'; import ThreadIOS from './thread.ios'; @@ -53,23 +52,6 @@ describe('thread', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); - test('should call resetToChannel on onCloseChannel', () => { - const resetToChannel = jest.spyOn(NavigationActions, 'resetToChannel'); - - const passProps = { - disableTermsModal: true, - }; - const wrapper = shallow( - , - {context: {intl: {formatMessage: jest.fn()}}}, - ); - wrapper.instance().onCloseChannel(); - expect(resetToChannel).toHaveBeenCalledTimes(1); - expect(resetToChannel).toBeCalledWith(passProps); - }); - test('should match snapshot, render footer', () => { const wrapper = shallow( , diff --git a/app/screens/thread/thread_base.js b/app/screens/thread/thread_base.js index 9b0526aa3..5d2b67bb9 100644 --- a/app/screens/thread/thread_base.js +++ b/app/screens/thread/thread_base.js @@ -10,7 +10,7 @@ import {General, RequestStatus} from '@mm-redux/constants'; import Loading from 'app/components/loading'; import DeletedPost from 'app/components/deleted_post'; -import {resetToChannel, popTopScreen, mergeNavigationOptions} from 'app/actions/navigation'; +import {popTopScreen, mergeNavigationOptions} from 'app/actions/navigation'; export default class ThreadBase extends PureComponent { static propTypes = { @@ -50,7 +50,7 @@ export default class ThreadBase extends PureComponent { title = formatMessage({id: 'mobile.routes.thread', defaultMessage: '{channelName} Thread'}, {channelName: displayName}); } - this.postTextbox = React.createRef(); + this.postDraft = React.createRef(); const options = { topBar: { @@ -86,12 +86,6 @@ export default class ThreadBase extends PureComponent { popTopScreen(componentId); }; - handleAutoComplete = (value) => { - if (this.postTextbox?.current) { - this.postTextbox.current.handleTextChange(value, true); - } - }; - hasRootPost = () => { return this.props.postIds.includes(this.props.rootId); }; @@ -115,11 +109,4 @@ export default class ThreadBase extends PureComponent { return null; }; - - onCloseChannel = () => { - const passProps = { - disableTermsModal: true, - }; - resetToChannel(passProps); - }; } diff --git a/share_extension/android/extension_post/extension_post.js b/share_extension/android/extension_post/extension_post.js index 8df471b3d..a614bd498 100644 --- a/share_extension/android/extension_post/extension_post.js +++ b/share_extension/android/extension_post/extension_post.js @@ -27,8 +27,8 @@ import {Preferences} from '@mm-redux/constants'; import {getFormattedFileSize, lookupMimeType} from '@mm-redux/utils/file_utils'; import Loading from 'app/components/loading'; -import PaperPlane from 'app/components/paper_plane'; -import {MAX_FILE_COUNT} from 'app/constants/post_textbox'; +import PaperPlane from 'app/components/post_draft/quick_actions/send_action/paper_plane'; +import {MAX_FILE_COUNT} from 'app/constants/post_draft'; import {getCurrentServerUrl, getAppCredentials} from 'app/init/credentials'; import mattermostManaged from 'app/mattermost_managed'; import {getExtensionFromMime} from 'app/utils/file';