MM-21368 Notify user when editing message that's too long (#4371)

* MM-21368 Notify user when editing message that's too long

* Disable 'Save' button to prevent edits from being posted
* Display error message specific to the message size and allows max, instead of the current, default "Invalid Message" error.

* Split message length error into text (left justified) and counts (right justified)

UX guidance from PR review

* UX Review: Wrap for longer error text + spacing adjustment

* UX Review: Align split messages to top + add spacing in-between

* UX Review: Align error message box padding with content
This commit is contained in:
Amit Uttam 2020-06-10 13:01:40 -03:00 committed by GitHub
parent 6de0a30f70
commit 446f95ff6b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 95 additions and 45 deletions

View file

@ -14,18 +14,17 @@ 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 {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 {executeCommand} from '@actions/views/command';
import {addReactionToLatestPost} from '@actions/views/emoji';
import {handleClearFiles, handleClearFailedFiles, initUploadFiles} from '@actions/views/file_upload';
import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {getCurrentChannelDraft, getThreadDraft} from '@selectors/views';
import {getChannelMembersForDm} from '@selectors/channel';
import {getAllowedServerMaxFileSize} from '@utils/file';
import {isLandscape} from '@selectors/device';
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);
@ -81,7 +80,7 @@ export function mapStateToProps(state, ownProps) {
files: currentDraft.files,
isLandscape: isLandscape(state),
isTimezoneEnabled,
maxMessageLength: (config && parseInt(config.MaxPostSize || 0, 10)) || MAX_MESSAGE_LENGTH,
maxMessageLength: (config && parseInt(config.MaxPostSize || 0, 10)) || MAX_MESSAGE_LENGTH_FALLBACK,
maxFileSize: getAllowedServerMaxFileSize(config),
membersCount,
theme: getTheme(state),

View file

@ -1,11 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
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 ACCESSORIES_CONTAINER_NATIVE_ID = 'channelAccessoriesContainer';
export const CHANNEL_POST_TEXTBOX_CURSOR_CHANGE = 'onChannelTextBoxCursorChange';
export const CHANNEL_POST_TEXTBOX_VALUE_CHANGE = 'onChannelTextBoxValueChange';
export const CHANNEL_POST_TEXTBOX_VALUE_CHANGE = 'onChannelTextBoxValueChange';
export const ICON_SIZE = 24;
export const INSERT_TO_COMMENT = 'insert_to_comment';
export const INSERT_TO_DRAFT = 'insert_to_draft';
export const IS_REACTION_REGEX = /(^\+:([^:\s]*):)$/i;
export const MAX_FILE_COUNT = 5;
export const MAX_MESSAGE_LENGTH_FALLBACK = 4000;

View file

@ -35,9 +35,10 @@ export default class EditPost extends PureComponent {
closeButton: PropTypes.object,
deviceHeight: PropTypes.number,
deviceWidth: PropTypes.number,
isLandscape: PropTypes.bool.isRequired,
maxMessageLength: PropTypes.number,
post: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
isLandscape: PropTypes.bool.isRequired,
};
static contextTypes = {
@ -57,10 +58,10 @@ export default class EditPost extends PureComponent {
super(props);
this.state = {
message: props.post.message,
cursorPosition: 0,
autocompleteVisible: false,
cursorPosition: 0,
keyboardType: 'default',
message: props.post.message,
};
this.rightButton.color = props.theme.sidebarHeaderTextColor;
@ -128,6 +129,7 @@ export default class EditPost extends PureComponent {
this.setState({
editing: true,
error: null,
errorExtra: null,
});
this.emitEditing(true);
@ -144,6 +146,11 @@ export default class EditPost extends PureComponent {
}
};
isMessageTooLong = (message) => {
const {maxMessageLength} = this.props;
return (message && message.trim().length > maxMessageLength);
}
onPostChangeText = (message) => {
// Workaround to avoid iOS emdash autocorrect in Code Blocks
if (Platform.OS === 'ios') {
@ -153,8 +160,21 @@ export default class EditPost extends PureComponent {
this.setState({message});
}
const tooLong = this.isMessageTooLong(message);
if (tooLong) {
const errorLine = this.context.intl.formatMessage({
id: 'mobile.message_length.message_split_left',
defaultMessage: 'Message exceeds the character limit',
});
const errorExtra = `${message.trim().length} / ${this.props.maxMessageLength}`;
this.setState({error: errorLine, errorExtra});
} else {
this.setState({error: null, errorExtra: null});
}
if (message) {
this.emitCanEditPost(true);
this.emitCanEditPost(!tooLong);
} else {
this.emitCanEditPost(false);
}
@ -181,7 +201,7 @@ export default class EditPost extends PureComponent {
render() {
const {deviceHeight, deviceWidth, theme, isLandscape} = this.props;
const {editing, message, error, autocompleteVisible} = this.state;
const {editing, message, error, errorExtra, autocompleteVisible} = this.state;
const style = getStyleSheet(theme);
@ -194,15 +214,31 @@ export default class EditPost extends PureComponent {
);
}
let inputContainerStyle = style.inputContainer;
let displayError;
if (error) {
displayError = (
<View style={[style.errorContainer, {width: deviceWidth}]}>
<View style={style.errorWrapper}>
<ErrorText error={error}/>
inputContainerStyle = [inputContainerStyle, {marginTop: 0}];
if (errorExtra) {
displayError = (
<View style={[style.errorContainerSplit, {width: deviceWidth}]}>
<ErrorText
error={error}
textStyle={style.errorWrap}
/>
<ErrorText error={errorExtra}/>
</View>
</View>
);
);
} else {
displayError = (
<View style={[style.errorContainer, {width: deviceWidth}]}>
<View style={style.errorWrapper}>
<ErrorText error={error}/>
</View>
</View>
);
}
}
const height = Platform.OS === 'android' ? (deviceHeight / 2) - 40 : (deviceHeight / 2);
@ -216,7 +252,7 @@ export default class EditPost extends PureComponent {
<StatusBar/>
<View style={style.scrollView}>
{displayError}
<View style={[style.inputContainer, padding(isLandscape), {height}]}>
<View style={[inputContainerStyle, padding(isLandscape), {height}]}>
<TextInputWithLocalizedPlaceholder
ref={this.messageRef}
value={message}
@ -262,9 +298,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
errorContainer: {
paddingHorizontal: 10,
},
errorContainerSplit: {
paddingHorizontal: 15,
flexDirection: 'row',
justifyContent: 'space-between',
},
errorWrapper: {
alignItems: 'center',
},
errorWrap: {
flexShrink: 1,
paddingRight: 20,
},
inputContainer: {
borderTopWidth: 1,
borderBottomWidth: 1,

View file

@ -62,7 +62,7 @@ describe('EditPost', () => {
[false],
]);
expect(instance.setState.mock.calls).toEqual([
[{editing: true, error: null}],
[{editing: true, error: null, errorExtra: null}],
[{editing: false, error}, instance.focus],
]);
});
@ -89,8 +89,8 @@ describe('EditPost', () => {
[false],
]);
expect(instance.setState.mock.calls).toEqual([
[{editing: true, error: null}],
[{editing: true, error: null, errorExtra: null}],
[{editing: false}, instance.close],
]);
});
});
});

View file

@ -5,18 +5,23 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {editPost} from '@mm-redux/actions/posts';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getDimensions, isLandscape} from 'app/selectors/device';
import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {getDimensions, isLandscape} from '@selectors/device';
import EditPost from './edit_post';
function mapStateToProps(state, ownProps) {
const config = getConfig(state);
return {
...getDimensions(state),
isLandscape: isLandscape(state),
maxMessageLength: (config && parseInt(config.MaxPostSize || 0, 10)) || MAX_MESSAGE_LENGTH_FALLBACK,
post: ownProps.post,
theme: getTheme(state),
isLandscape: isLandscape(state),
};
}

View file

@ -300,6 +300,7 @@
"mobile.markdown.link.copy_url": "Copy URL",
"mobile.mention.copy_mention": "Copy Mention",
"mobile.message_length.message": "Your current message is too long. Current character count: {count}/{max}",
"mobile.message_length.message_split_left": "Message exceeds the character limit",
"mobile.message_length.title": "Message Length",
"mobile.more_dms.add_more": "You can add {remaining, number} more users",
"mobile.more_dms.cannot_add_more": "You cannot add more users",

View file

@ -26,15 +26,15 @@ import {Client4} from '@mm-redux/client';
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/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 Loading from '@components/loading';
import PaperPlane from '@components/post_draft/quick_actions/send_action/paper_plane';
import {MAX_FILE_COUNT, MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {getCurrentServerUrl, getAppCredentials} from '@init/credentials';
import {getExtensionFromMime} from '@utils/file';
import {setCSRFFromCookie} from '@utils/security';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import mattermostManaged from 'app/mattermost_managed';
import {getExtensionFromMime} from 'app/utils/file';
import {setCSRFFromCookie} from 'app/utils/security';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {
ExcelSvg,
@ -59,7 +59,6 @@ const extensionSvg = {
};
const ShareExtension = NativeModules.MattermostShare;
const INPUT_HEIGHT = 150;
const MAX_MESSAGE_LENGTH = 4000;
export default class ExtensionPost extends PureComponent {
static propTypes = {
@ -386,7 +385,7 @@ export default class ExtensionPost extends PureComponent {
const {currentUserId} = this.props;
const {formatMessage} = this.context.intl;
if (value.length > MAX_MESSAGE_LENGTH) {
if (value.length > MAX_MESSAGE_LENGTH_FALLBACK) {
Alert.alert(
formatMessage({
id: 'mobile.share_extension.too_long_title',
@ -397,7 +396,7 @@ export default class ExtensionPost extends PureComponent {
defaultMessage: 'Character count: {count}/{max}',
}, {
count: value.length,
max: MAX_MESSAGE_LENGTH,
max: MAX_MESSAGE_LENGTH_FALLBACK,
}),
);
} else {
@ -588,7 +587,7 @@ export default class ExtensionPost extends PureComponent {
renderMessageLengthRemaining = () => {
const {value} = this.state;
const messageLengthRemaining = MAX_MESSAGE_LENGTH - value.length;
const messageLengthRemaining = MAX_MESSAGE_LENGTH_FALLBACK - value.length;
if (value.length === 0) {
return null;