MM-22401 Refactor Post Draft (#4122)

* Babel TS and Eslint config

* Channel is archived component

* Move Typing component

* Move Uploads component

* Add PostInput component

* QuickActions components

* Add PostDraft component

* Remove old PostTextbox component

* Rename post_textbox constant

* Fix Autocomplete

* Rename blur post draft event

* Fix references

* Feedback review

* Fix autocomplete for iOS
This commit is contained in:
Elias Nahum 2020-05-06 06:30:51 -04:00 committed by GitHub
parent 597e03a6bc
commit 1a605891fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 1901 additions and 2585 deletions

View file

@ -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';

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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 (
<View style={style.archivedWrapper}>
<FormattedMarkdownText
{...this.message()}
theme={theme}
style={style.archivedText}
/>
<Button
containerStyle={style.closeButton}
onPress={this.onCloseChannelPress}
>
<FormattedText
id='center_panel.archived.closeChannel'
defaultMessage='Close Channel'
style={style.closeButtonText}
/>
</Button>
</View>
);
}
}
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',
},
}));

View file

@ -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);

View file

@ -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);

View file

@ -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(),
}));

View file

@ -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 (
<Archived
defactivated={deactivatedChannel}
rootId={rootId}
theme={theme}
/>
);
}
const {
canPost,
channelDisplayName,
channelId,
channelIsReadOnly,
cursorPositionEvent,
isLandscape,
files,
maxFileSize,
maxMessageLength,
screenId,
valueEvent,
} = this.props;
const style = getStyleSheet(theme);
const readonly = channelIsReadOnly || !canPost;
return (
<>
<Typing theme={theme}/>
{Platform.OS === 'android' &&
<Autocomplete
cursorPositionEvent={cursorPositionEvent}
maxHeight={Math.min(this.state.top - AUTOCOMPLETE_MARGIN, AUTOCOMPLETE_MAX_HEIGHT)}
onChangeText={this.handleInputQuickAction}
valueEvent={valueEvent}
/>
}
<View
style={[style.inputWrapper, padding(isLandscape)]}
onLayout={this.handleLayout}
>
<ScrollView
style={[style.inputContainer, readonly ? style.readonlyContainer : null]}
contentContainerStyle={style.inputContentContainer}
keyboardShouldPersistTaps={'always'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
pinchGestureEnabled={false}
overScrollMode={'never'}
disableScrollViewPanResponder={true}
>
<PostInput
channelDisplayName={channelDisplayName}
channelId={channelId}
cursorPositionEvent={cursorPositionEvent}
inputEventType={valueEvent}
isLandscape={isLandscape}
maxMessageLength={maxMessageLength}
onPasteFiles={this.handlePasteFiles}
onSend={this.handleSendMessage}
readonly={readonly}
ref={this.input}
rootId={rootId}
screenId={screenId}
theme={theme}
updateInitialValue={this.updateInitialValue}
/>
<Uploads
files={files}
rootId={rootId}
theme={theme}
/>
<QuickActions
blurTextBox={this.blurTextBox}
canSend={this.isSendButtonEnabled()}
fileCount={files.length}
initialValue={this.state.value}
inputEventType={valueEvent}
maxFileSize={maxFileSize}
onSend={this.handleSendMessage}
onShowFileMaxWarning={this.onShowFileMaxWarning}
onTextChange={this.handleInputQuickAction}
onUploadFiles={this.handleUploadFiles}
readonly={readonly}
theme={theme}
/>
</ScrollView>
</View>
</>
);
};
}
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,
},
};
});

View file

@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostInput should match, full snapshot 1`] = `
<ForwardRef(WrappedPasteableTextInput)
blurOnSubmit={false}
disableFullscreenUI={true}
editable={true}
keyboardAppearance="light"
keyboardType="default"
multiline={true}
onChangeText={[Function]}
onEndEditing={[Function]}
onPaste={[MockFunction]}
onSelectionChange={[Function]}
placeholder="Write to Test Channel"
placeholderTextColor="rgba(61,60,64,0.5)"
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
"lineHeight": 20,
"maxHeight": 150,
"minHeight": 30,
"paddingBottom": 6,
"paddingHorizontal": 12,
"paddingTop": 6,
}
}
underlineColorAndroid="transparent"
/>
`;

View file

@ -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);

View file

@ -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 (
<PasteableTextInput
ref={this.input}
style={{...style.input, maxHeight}}
onChangeText={this.handleTextChange}
onSelectionChange={this.handlePostDraftSelectionChanged}
placeholder={formatMessage(placeholder, {channelDisplayName})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
multiline={true}
blurOnSubmit={false}
underlineColorAndroid='transparent'
keyboardType={this.state.keyboardType}
onEndEditing={this.handleEndEditing}
disableFullscreenUI={true}
editable={!readonly}
onPaste={onPasteFiles}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
/>
);
}
}
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,
},
}));

View file

@ -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(
<PostInput {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should emit the event but no text is save to draft', () => {
const wrapper = shallowWithIntl(
<PostInput {...baseProps}/>,
);
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(
<PostInput {...baseProps}/>,
);
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(
<PostInput {...baseProps}/>,
);
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);
});
});

View file

@ -3,7 +3,13 @@
exports[`CameraButton should match snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[Function]}
style={Object {}}
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
type="opacity"
>
<Icon

View file

@ -8,7 +8,7 @@ import Permissions from 'react-native-permissions';
import Preferences from '@mm-redux/constants/preferences';
import CameraButton from './camera_button';
import CameraQuickAction from './index';
jest.mock('react-intl');
jest.mock('react-native-image-picker', () => ({
@ -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(<CameraButton {...baseProps}/>);
const wrapper = shallow(<CameraQuickAction {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
@ -37,7 +37,7 @@ describe('CameraButton', () => {
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
const wrapper = shallow(
<CameraButton {...baseProps}/>,
<CameraQuickAction {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
@ -53,7 +53,7 @@ describe('CameraButton', () => {
jest.spyOn(Alert, 'alert').mockReturnValue(true);
const wrapper = shallow(
<CameraButton {...baseProps}/>,
<CameraQuickAction {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
@ -82,7 +82,7 @@ describe('CameraButton', () => {
request.mockReturnValue(Permissions.RESULTS.GRANTED);
const wrapper = shallow(
<CameraButton {...baseProps}/>,
<CameraQuickAction {...baseProps}/>,
{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(
<CameraButton {...baseProps}/>,
<CameraQuickAction {...baseProps}/>,
{context: {intl: {formatMessage}}},
);

View file

@ -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 (
<TouchableWithFeedback
onPress={this.attachFileFromCamera}
style={buttonContainerStyle}
disabled={disabled}
onPress={this.handleButtonPress}
style={style.icon}
type={'opacity'}
>
<MaterialCommunityIcons
color={changeOpacity(theme.centerChannelColor, 0.64)}
color={color}
name='camera-outline'
size={ICON_SIZE}
/>
@ -166,3 +170,11 @@ export default class AttachmentButton extends PureComponent {
);
}
}
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});

View file

@ -1,9 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FileUploadButton should match snapshot 1`] = `
exports[`FileQuickAction should match snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[Function]}
style={Object {}}
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
type="opacity"
>
<Icon

View file

@ -8,24 +8,22 @@ import Permissions from 'react-native-permissions';
import Preferences from '@mm-redux/constants/preferences';
import FileUploadButton from './file_upload_button';
import FileQuickAction from './index';
jest.mock('react-intl');
jest.mock('react-native-image-picker', () => ({
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(<FileUploadButton {...baseProps}/>);
const wrapper = shallow(<FileQuickAction {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
@ -47,7 +45,7 @@ describe('FileUploadButton', () => {
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
const wrapper = shallow(
<FileUploadButton {...baseProps}/>,
<FileQuickAction {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
@ -63,7 +61,7 @@ describe('FileUploadButton', () => {
jest.spyOn(Alert, 'alert').mockReturnValue(true);
const wrapper = shallow(
<FileUploadButton {...baseProps}/>,
<FileQuickAction {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
@ -76,7 +74,7 @@ describe('FileUploadButton', () => {
test('hasStoragePermission returns true when permission has been granted', async () => {
const wrapper = shallow(
<FileUploadButton {...baseProps}/>,
<FileQuickAction {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const instance = wrapper.instance();

View file

@ -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 (
<TouchableWithFeedback
disabled={disabled}
onPress={this.handleButtonPress}
style={buttonContainerStyle}
style={style.icon}
type={'opacity'}
>
<MaterialCommunityIcons
color={changeOpacity(theme.centerChannelColor, 0.64)}
color={color}
name='file-document-outline'
size={ICON_SIZE}
/>
@ -167,3 +160,11 @@ export default class FileUploadButton extends PureComponent {
);
}
}
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});

View file

@ -1,9 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImageUploadButton should match snapshot 1`] = `
exports[`ImageQuickAction should match snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[Function]}
style={Object {}}
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
type="opacity"
>
<Icon

View file

@ -8,14 +8,14 @@ import Permissions from 'react-native-permissions';
import Preferences from '@mm-redux/constants/preferences';
import ImageUploadButton from './image_upload_button';
import ImageQuickAction from './index';
jest.mock('react-intl');
jest.mock('react-native-image-picker', () => ({
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(<ImageUploadButton {...baseProps}/>);
const wrapper = shallow(<ImageQuickAction {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
@ -38,7 +37,7 @@ describe('ImageUploadButton', () => {
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
const wrapper = shallow(
<ImageUploadButton {...baseProps}/>,
<ImageQuickAction {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
@ -54,7 +53,7 @@ describe('ImageUploadButton', () => {
jest.spyOn(Alert, 'alert').mockReturnValue(true);
const wrapper = shallow(
<ImageUploadButton {...baseProps}/>,
<ImageQuickAction {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
@ -83,7 +82,7 @@ describe('ImageUploadButton', () => {
request.mockReturnValue(Permissions.RESULTS.GRANTED);
const wrapper = shallow(
<ImageUploadButton {...baseProps}/>,
<ImageQuickAction {...baseProps}/>,
{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(
<ImageUploadButton {...baseProps}/>,
<ImageQuickAction {...baseProps}/>,
{context: {intl: {formatMessage}}},
);

View file

@ -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 (
<TouchableWithFeedback
disabled={disabled}
onPress={this.handleButtonPress}
style={buttonContainerStyle}
style={style.icon}
type={'opacity'}
>
<MaterialCommunityIcons
color={changeOpacity(theme.centerChannelColor, 0.64)}
color={color}
name='image-outline'
size={ICON_SIZE}
/>
@ -184,3 +177,11 @@ export default class ImageUploadButton extends PureComponent {
);
}
}
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});

View file

@ -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);

View file

@ -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 (
<MaterialCommunityIcons
color={color}
name='at'
size={ICON_SIZE}
/>
);
}
return (
<Image
source={require('assets/images/icons/slash-forward-box.png')}
style={[style.slash, disabled ? style.disabled : null]}
/>
);
}
render() {
const {disabled, theme} = this.props;
const style = getStyleSheet(theme);
return (
<TouchableWithFeedback
disabled={disabled}
onPress={this.onPress}
style={style.icon}
type={'opacity'}
>
{this.renderInput(style)}
</TouchableWithFeedback>
);
}
}
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,
},
};
});

View file

@ -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 (
<View style={style.container}>
<View style={style.quickActionsContainer}>
<InputAction
disabled={this.state.atDisabled}
inputType='at'
onTextChange={onTextChange}
theme={theme}
value={this.state.inputValue}
/>
<InputAction
disabled={this.state.slashDisabled}
inputType='slash'
onTextChange={onTextChange}
theme={theme}
/>
<FileAction {...uploadProps}/>
<ImageAction {...uploadProps}/>
<CameraAction {...uploadProps}/>
</View>
<SendAction
disabled={!canSend}
handleSendMessage={onSend}
theme={theme}
/>
</View>
);
}
}
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,
},
});

View file

@ -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`] = `
<View
style={
Object {
@ -35,7 +35,7 @@ exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = `
</View>
`;
exports[`SendButton should match snapshot 1`] = `
exports[`SendAction should match snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[MockFunction]}
style={
@ -67,7 +67,7 @@ exports[`SendButton should match snapshot 1`] = `
</TouchableWithFeedbackIOS>
`;
exports[`SendButton should render theme backgroundColor 1`] = `
exports[`SendAction should render theme backgroundColor 1`] = `
<TouchableWithFeedbackIOS
onPress={[MockFunction]}
style={

View file

@ -5,8 +5,8 @@ import React, {memo} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
let PaperPlane = null;
@ -15,7 +15,7 @@ function SendButton(props) {
const style = getStyleSheet(theme);
if (!PaperPlane) {
PaperPlane = require('app/components/paper_plane').default;
PaperPlane = require('./paper_plane').default;
}
if (props.disabled) {

View file

@ -5,11 +5,11 @@ import React from 'react';
import {shallow} from 'enzyme';
import Preferences from '@mm-redux/constants/preferences';
import {changeOpacity} from '@utils/theme';
import SendButton from 'app/components/send_button';
import {changeOpacity} from 'app/utils/theme';
import SendAction from './index';
describe('SendButton', () => {
describe('SendAction', () => {
const baseProps = {
theme: Preferences.THEMES.default,
handleSendMessage: jest.fn(),
@ -18,7 +18,7 @@ describe('SendButton', () => {
function getWrapper(props = {}) {
return shallow(
<SendButton
<SendAction
{...baseProps}
{...props}
/>,

View file

@ -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),
};
}

View file

@ -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);

View file

@ -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);

View file

@ -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 {
<View style={styles.previewContainer}>
{filePreviewComponent}
{file.failed &&
<FileUploadRetry
<UploadRetry
file={file}
onPress={this.handleRetryFileUpload}
/>
@ -234,7 +232,7 @@ export default class FileUploadItem extends PureComponent {
</View>
}
</View>
<FileUploadRemove
<UploadRemove
theme={this.props.theme}
channelId={channelId}
clientId={file.clientId}

View file

@ -4,17 +4,15 @@ import React from 'react';
import {shallow} from 'enzyme';
import {Preferences} from '@mm-redux/constants';
import ImageCacheManager from 'app/utils/image_cache_manager';
import FileUploadItem from './file_upload_item';
import ImageCacheManager from '@utils/image_cache_manager';
import UploadItem from './upload_item';
describe('FileUploadItem', () => {
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(<FileUploadItem {...props}/>);
const component = shallow(<UploadItem {...props}/>);
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(<FileUploadItem {...props}/>);
const component = shallow(<UploadItem {...props}/>);
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(<FileUploadItem {...props}/>);
const component = shallow(<UploadItem {...props}/>);
component.instance().uploadFile = jest.fn();
component.setProps({file: {

View file

@ -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,

View file

@ -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,

View file

@ -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 (
<FileUploadItem
<UploadItem
key={file.clientId}
channelId={this.props.channelId}
file={file}
@ -89,6 +98,15 @@ export default class FileUploadPreview extends PureComponent {
}, delay || 0);
}
handleAndroidBack = () => {
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) {

View file

@ -1,340 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostTextBox should match, full snapshot 1`] = `
<React.Fragment>
<Connect(Typing) />
<View
onLayout={[Function]}
style={
Array [
Object {
"alignItems": "flex-end",
"backgroundColor": "#ffffff",
"borderTopColor": "rgba(61,60,64,0.2)",
"borderTopWidth": 1,
"flexDirection": "row",
"justifyContent": "center",
"paddingBottom": 2,
},
null,
]
}
>
<ScrollView
contentContainerStyle={
Object {
"alignItems": "stretch",
"paddingTop": 7,
}
}
disableScrollViewPanResponder={true}
keyboardShouldPersistTaps="always"
overScrollMode="never"
pinchGestureEnabled={false}
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
style={
Array [
Object {
"flex": 1,
"flexDirection": "column",
},
]
}
>
<ForwardRef(WrappedPasteableTextInput)
blurOnSubmit={false}
disableFullscreenUI={true}
editable={true}
keyboardAppearance="light"
keyboardType="default"
multiline={true}
onChangeText={[Function]}
onContentSizeChange={null}
onEndEditing={[Function]}
onPaste={[Function]}
onSelectionChange={[Function]}
placeholder="Write to Test Channel"
placeholderTextColor="rgba(61,60,64,0.5)"
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
"lineHeight": 20,
"maxHeight": 150,
"minHeight": 30,
"paddingBottom": 6,
"paddingHorizontal": 12,
"paddingTop": 6,
}
}
underlineColorAndroid="transparent"
value=""
/>
<React.Fragment>
<Connect(FileUploadPreview)
files={Array []}
rootId=""
/>
<View
style={
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "row",
"justifyContent": "space-between",
"paddingBottom": 1,
}
}
>
<View
style={
Object {
"display": "flex",
"flexDirection": "row",
"height": 44,
}
}
>
<Component
disabled={false}
onPress={[Function]}
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
>
<Icon
allowFontScaling={false}
color="rgba(61,60,64,0.64)"
name="at"
size={24}
/>
</Component>
<Component
disabled={false}
onPress={[Function]}
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
>
<Image
source={
Object {
"testUri": "../../../dist/assets/images/icons/slash-forward-box.png",
}
}
style={
Array [
Object {
"height": 24,
"opacity": 1,
"tintColor": "rgba(61,60,64,0.64)",
"width": 24,
},
]
}
/>
</Component>
<FileUploadButton
blurTextBox={[Function]}
browseFileTypes="public.item"
buttonContainerStyle={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
canBrowseFiles={true}
canBrowsePhotoLibrary={true}
canBrowseVideoLibrary={true}
canTakePhoto={true}
canTakeVideo={true}
extraOptions={null}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
<ImageUploadButton
blurTextBox={[Function]}
browseFileTypes="public.item"
buttonContainerStyle={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
canBrowseFiles={true}
canBrowsePhotoLibrary={true}
canBrowseVideoLibrary={true}
canTakePhoto={true}
canTakeVideo={true}
extraOptions={null}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
<AttachmentButton
blurTextBox={[Function]}
buttonContainerStyle={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
canTakePhoto={true}
canTakeVideo={true}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
</View>
<SendButton
disabled={true}
handleSendMessage={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</View>
</React.Fragment>
</ScrollView>
</View>
</React.Fragment>
`;

View file

@ -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 (
<Svg
width={this.props.width}
height={this.props.height}
x='0'
y='0'
viewBox='0 0 48 48'
enableBackground='new 0 0 48 48'
space='preserve'
>
<G>
<Path
d='M43.922,6.653c-2.643-2.644-6.201-4.107-9.959-4.069c-3.774,0.019-7.32,1.497-9.983,4.161l-12.3,12.3l-8.523,8.521 c-4.143,4.144-4.217,10.812-0.167,14.862c1.996,1.996,4.626,2.989,7.277,2.989c2.73,0,5.482-1.055,7.583-3.156l15.547-15.545 c0.002-0.002,0.002-0.004,0.004-0.005l5.358-5.358c1.394-1.393,2.176-3.24,2.201-5.2c0.026-1.975-0.716-3.818-2.09-5.192 c-2.834-2.835-7.496-2.787-10.394,0.108L9.689,29.857c-0.563,0.563-0.563,1.474,0,2.036c0.281,0.28,0.649,0.421,1.018,0.421 c0.369,0,0.737-0.141,1.018-0.421l18.787-18.788c1.773-1.774,4.609-1.824,6.322-0.11c0.82,0.82,1.263,1.928,1.247,3.119 c-0.017,1.205-0.497,2.342-1.357,3.201l-5.55,5.551c-0.002,0.002-0.002,0.004-0.004,0.005L15.814,40.225 c-3.02,3.02-7.86,3.094-10.789,0.167c-2.928-2.929-2.854-7.77,0.167-10.791l0.958-0.958c0.001-0.002,0.004-0.002,0.005-0.004 L26.016,8.78c2.123-2.124,4.951-3.303,7.961-3.317c2.998,0.02,5.814,1.13,7.91,3.226c4.35,4.351,4.309,11.472-0.093,15.873 L25.459,40.895c-0.563,0.562-0.563,1.473,0,2.035c0.281,0.281,0.65,0.422,1.018,0.422c0.369,0,0.737-0.141,1.018-0.422 L43.83,26.596C49.354,21.073,49.395,12.126,43.922,6.653z'
fill={this.props.color}
/>
</G>
</Svg>
);
}
}

View file

@ -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 (
<React.Fragment>
<Typing/>
<Autocomplete
cursorPosition={cursorPosition}
maxHeight={Math.min(top - AUTOCOMPLETE_MARGIN, AUTOCOMPLETE_MAX_HEIGHT)}
onChangeText={this.handleTextChange}
value={this.state.value}
rootId={rootId}
/>
{this.renderTextBox()}
</React.Fragment>
);
}
}

View file

@ -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 (
<>
<Typing/>
{this.renderTextBox()}
</>
);
}
}

View file

@ -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(
<PostTextbox {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should emit the event but no text is save to draft', () => {
const wrapper = shallowWithIntl(
<PostTextbox {...baseProps}/>,
);
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(
<PostTextbox {...baseProps}/>,
);
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(
<PostTextbox {...baseProps}/>,
);
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(
<PostTextbox {...baseProps}/>,
);
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(
<PostTextbox
{...baseProps}
useChannelMentions={false}
/>,
);
const message = '@all';
const instance = wrapper.instance();
instance.handleTextChange(message);
expect(wrapper.state('value')).toBe(message);
instance.sendMessage();
expect(Alert.alert).not.toHaveBeenCalled();
});
test('should return correct @all (same for @channel)', () => {
for (const data of [
{
text: '',
result: false,
},
{
text: 'all',
result: false,
},
{
text: '@allison',
result: false,
},
{
text: '@ALLISON',
result: false,
},
{
text: '@all123',
result: false,
},
{
text: '123@all',
result: false,
},
{
text: 'hey@all',
result: false,
},
{
text: 'hey@all.com',
result: false,
},
{
text: '@all',
result: true,
},
{
text: '@ALL',
result: true,
},
{
text: '@all hey',
result: true,
},
{
text: 'hey @all',
result: true,
},
{
text: 'HEY @ALL',
result: true,
},
{
text: 'hey @all!',
result: true,
},
{
text: 'hey @all:+1:',
result: true,
},
{
text: 'hey @ALL:+1:',
result: true,
},
{
text: '`@all`',
result: false,
},
{
text: '@someone `@all`',
result: false,
},
{
text: '``@all``',
result: false,
},
{
text: '```@all```',
result: false,
},
{
text: '```\n@all\n```',
result: false,
},
{
text: '```````\n@all\n```````',
result: false,
},
{
text: '```code\n@all\n```',
result: false,
},
{
text: '~~~@all~~~',
result: true,
},
{
text: '~~~\n@all\n~~~',
result: false,
},
{
text: ' /not_cmd @all',
result: true,
},
{
text: '@channel',
result: true,
},
{
text: '@channel.',
result: true,
},
{
text: '@channel/test',
result: true,
},
{
text: 'test/@channel',
result: true,
},
{
text: '@all/@channel',
result: true,
},
{
text: '@cha*nnel*',
result: false,
},
{
text: '@cha**nnel**',
result: false,
},
{
text: '*@cha*nnel',
result: false,
},
{
text: '[@chan](https://google.com)nel',
result: false,
},
{
text: '@cha![](https://myimage)nnel',
result: false,
},
]) {
const wrapper = shallowWithIntl(
<PostTextbox {...baseProps}/>,
);
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(
<PostTextbox {...baseProps}/>,
);
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(
<PostTextbox {...props}/>,
);
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(
<PostTextbox {...props}/>,
);
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(
<PostTextbox {...props}/>,
);
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(
<PostTextbox {...props}/>,
);
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(
<PostTextbox {...props}/>,
);
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(<PostTextbox {...baseProps}/>);
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(<PostTextbox {...baseProps}/>);
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(
<PostTextbox
{...baseProps}
maxFileSize={50 * 1024 * 1024}
/>,
);
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(<PostTextbox {...baseProps}/>);
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(<PostTextbox {...baseProps}/>);
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(<PostTextbox {...baseProps}/>);
// @ 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(<PostTextbox {...baseProps}/>);
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(<PostTextbox {...baseProps}/>);
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(<PostTextbox {...props}/>);
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(<PostTextbox {...baseProps}/>);
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(
<PostTextbox {...props}/>,
);
expect(wrapper.find(PasteableTextInput).prop('editable')).toBe(false);
});
});

File diff suppressed because it is too large Load diff

View file

@ -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();

View file

@ -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();

View file

@ -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;

View file

@ -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;
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';

View file

@ -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;
}
});

View file

@ -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 {
<View style={style.flex}>
<ChannelPostList/>
</View>
<PostTextbox
ref={this.postTextbox}
<PostDraft
ref={this.postDraft}
screenId={this.props.componentId}
/>
</KeyboardLayout>

View file

@ -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}
>
<PostTextbox
<PostDraft
cursorPositionEvent={CHANNEL_POST_TEXTBOX_CURSOR_CHANGE}
valueEvent={CHANNEL_POST_TEXTBOX_VALUE_CHANGE}
ref={this.postTextbox}
ref={this.postDraft}
screenId={this.props.componentId}
/>
</KeyboardTrackingView>

View file

@ -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();
};

View file

@ -56,11 +56,10 @@ exports[`thread should match snapshot, has root post 1`] = `
accessoriesContainerID="threadAccessoriesContainer"
scrollViewNativeID="threadPostList"
>
<Connect(PostTextBoxIOS)
<Connect(PostDraft)
channelId="channel_id"
channelIsArchived={false}
cursorPositionEvent="onThreadTextBoxCursorChange"
onCloseChannel={[Function]}
rootId="root_id"
valueEvent="onThreadTextBoxValueChange"
/>

View file

@ -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 = (
<PostList
renderFooter={this.renderFooter()}
indicateNewMessages={false}
postIds={postIds}
currentUserId={myMember && myMember.user_id}
lastViewedAt={this.state.lastViewedAt}
lastPostIndex={-1}
onPostPress={this.hideKeyboard}
location={THREAD}
/>
);
postTextBox = (
<PostTextbox
channelId={channelId}
channelIsArchived={channelIsArchived}
onCloseChannel={this.onCloseChannel}
rootId={rootId}
screenId={this.props.componentId}
/>
<>
<PostList
renderFooter={this.renderFooter()}
indicateNewMessages={false}
postIds={postIds}
currentUserId={myMember && myMember.user_id}
lastViewedAt={this.state.lastViewedAt}
lastPostIndex={-1}
onPostPress={this.hideKeyboard}
location={THREAD}
/>
<PostDraft
ref={this.postDraft}
channelId={channelId}
channelIsArchived={channelIsArchived}
rootId={rootId}
screenId={this.props.componentId}
/>
</>
);
} else {
content = (
@ -64,7 +62,6 @@ export default class ThreadAndroid extends ThreadBase {
<KeyboardLayout>
<View style={style.separator}/>
{content}
{postTextBox}
</KeyboardLayout>
</SafeAreaView>
);

View file

@ -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 = (
<React.Fragment>
<>
<PostList
renderFooter={this.renderFooter()}
indicateNewMessages={false}
@ -59,20 +64,19 @@ export default class ThreadIOS extends ThreadBase {
rootId={rootId}
/>
</View>
</React.Fragment>
</>
);
postTextBox = (
postDraft = (
<KeyboardTrackingView
scrollViewNativeID={SCROLLVIEW_NATIVE_ID}
accessoriesContainerID={ACCESSORIES_CONTAINER_NATIVE_ID}
>
<PostTextbox
<PostDraft
channelId={channelId}
channelIsArchived={channelIsArchived}
cursorPositionEvent={THREAD_POST_TEXTBOX_CURSOR_CHANGE}
onCloseChannel={this.onCloseChannel}
ref={this.postTextbox}
ref={this.postDraft}
rootId={rootId}
screenId={this.props.componentId}
valueEvent={THREAD_POST_TEXTBOX_VALUE_CHANGE}
@ -96,7 +100,7 @@ export default class ThreadIOS extends ThreadBase {
<StatusBar/>
{content}
</SafeAreaView>
{postTextBox}
{postDraft}
</React.Fragment>
);
}

View file

@ -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(
<ThreadIOS
{...baseProps}
/>,
{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(
<ThreadIOS {...baseProps}/>,

View file

@ -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);
};
}

View file

@ -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';