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: '@channel',
- 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';