MM-23090 MM-26078 && MM-26876 Refactor post draft component (#4621)

* Refactor post draft component
Re-styled ReadOnly channels
Remove filename from upload error

* Update canSubmit based on file attachment changes

* Fix error message for max file size

* Fix select autocomplete values on iOS

* Style readonly and refactor accessory view for iOS

* Make PostDraft scrollViewNativeID prop not required

* Update ReadOnly to have a background with 0.40 opacity

* Update max file size error message

* Fix shift+enter with HW keyboard
This commit is contained in:
Elias Nahum 2020-08-06 19:39:25 -04:00 committed by GitHub
parent 24f6d2df92
commit 48f1875cf1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 2422 additions and 1335 deletions

View file

@ -44,7 +44,8 @@
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-use-before-define": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/explicit-function-return-type": 0
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/explicit-module-boundary-types": "off"
},
"overrides": [
{

View file

@ -2,7 +2,6 @@
exports[`profile_picture_button should match snapshot 1`] = `
<AttachmentButton
blurTextBox={[MockFunction]}
browseFileTypes="public.item"
canBrowseFiles={true}
canBrowsePhotoLibrary={true}

View file

@ -19,19 +19,19 @@ import DocumentPicker from 'react-native-document-picker';
import ImagePicker from 'react-native-image-picker';
import Permissions from 'react-native-permissions';
import {showModalOverCurrentContext} from '@actions/navigation';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {NavigationTypes} from '@constants';
import emmProvider from '@init/emm_provider';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {lookupMimeType} from '@mm-redux/utils/file_utils';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import emmProvider from 'app/init/emm_provider';
import {changeOpacity} from 'app/utils/theme';
import {t} from 'app/utils/i18n';
import {showModalOverCurrentContext} from 'app/actions/navigation';
import {t} from '@utils/i18n';
import {changeOpacity} from '@utils/theme';
const ShareExtension = NativeModules.MattermostShare;
export default class AttachmentButton extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
browseFileTypes: PropTypes.string,
validMimeTypes: PropTypes.array,
canBrowseFiles: PropTypes.bool,
@ -414,7 +414,7 @@ export default class AttachmentButton extends PureComponent {
return;
}
this.props.blurTextBox();
EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT);
const items = [];
if (canTakePhoto) {

View file

@ -23,7 +23,6 @@ describe('AttachmentButton', () => {
const formatMessage = jest.fn();
const baseProps = {
theme: Preferences.THEMES.default,
blurTextBox: jest.fn(),
maxFileSize: 10,
uploadFiles: jest.fn(),
};

View file

@ -5,13 +5,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import {NativeEventEmitter, NativeModules, Platform, TextInput} from 'react-native';
import {PASTE_FILES} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
const {OnPasteEventManager} = NativeModules;
const OnPasteEventEmitter = new NativeEventEmitter(OnPasteEventManager);
export class PasteableTextInput extends React.PureComponent {
static propTypes = {
...TextInput.PropTypes,
onPaste: PropTypes.func,
forwardRef: PropTypes.any,
}
@ -26,7 +28,6 @@ export class PasteableTextInput extends React.PureComponent {
}
onPaste = (event) => {
const {onPaste} = this.props;
let data = null;
let error = null;
@ -38,7 +39,7 @@ export class PasteableTextInput extends React.PureComponent {
data = event;
}
return onPaste?.(error, data);
EventEmitter.emit(PASTE_FILES, error, data);
}
render() {

View file

@ -4,6 +4,9 @@ import React from 'react';
import {NativeEventEmitter} from 'react-native';
import {shallow} from 'enzyme';
import {PASTE_FILES} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {PasteableTextInput} from './index';
const nativeEventEmitter = new NativeEventEmitter();
@ -19,22 +22,21 @@ describe('PasteableTextInput', () => {
});
test('should call onPaste props if native onPaste trigger', () => {
const onPaste = jest.fn();
const event = {someData: 'data'};
const text = 'My Text';
const onPaste = jest.spyOn(EventEmitter, 'emit');
shallow(
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>,
<PasteableTextInput>{text}</PasteableTextInput>,
);
nativeEventEmitter.emit('onPaste', event);
expect(onPaste).toHaveBeenCalledWith(null, event);
expect(onPaste).toHaveBeenCalledWith(PASTE_FILES, null, event);
});
test('should remove onPaste listener when unmount', () => {
const mockRemove = jest.fn();
const onPaste = jest.fn();
const text = 'My Text';
const component = shallow(
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>,
<PasteableTextInput>{text}</PasteableTextInput>,
);
component.instance().subscription.remove = mockRemove;

View file

@ -0,0 +1,661 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostDraft Should render the Archived for channelIsArchived 1`] = `
<View
style={
Object {
"backgroundColor": "#ffffff",
"borderTopColor": "rgba(61,60,64,0.2)",
"borderTopWidth": 1,
"paddingBottom": 10,
"paddingLeft": 20,
"paddingRight": 20,
"paddingTop": 10,
}
}
>
<Text>
<Text
style={
Array [
Object {
"color": "#3d3c40",
"textAlign": "center",
},
Array [
undefined,
],
]
}
>
You are viewing an
</Text>
<Text
style={
Array [
Object {
"color": "#3d3c40",
"textAlign": "center",
},
Array [
undefined,
Object {
"fontWeight": "bold",
},
],
]
}
>
archived channel
</Text>
<Text
style={
Array [
Object {
"color": "#3d3c40",
"textAlign": "center",
},
Array [
undefined,
],
]
}
>
. New messages cannot be posted.
</Text>
</Text>
<View
accessibilityRole="button"
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"backgroundColor": "#166de0",
"borderRadius": 4,
"height": 40,
"marginTop": 10,
"opacity": 1,
"paddingBottom": 5,
"paddingTop": 5,
}
}
>
<Text
style={
Object {
"color": "white",
"fontWeight": "bold",
"marginTop": 7,
}
}
>
Close Channel
</Text>
</View>
</View>
`;
exports[`PostDraft Should render the Archived for deactivatedChannel 1`] = `
<View
style={
Object {
"backgroundColor": "#ffffff",
"borderTopColor": "rgba(61,60,64,0.2)",
"borderTopWidth": 1,
"paddingBottom": 10,
"paddingLeft": 20,
"paddingRight": 20,
"paddingTop": 10,
}
}
>
<Text>
<Text
style={
Array [
Object {
"color": "#3d3c40",
"textAlign": "center",
},
Array [
undefined,
],
]
}
>
You are viewing an
</Text>
<Text
style={
Array [
Object {
"color": "#3d3c40",
"textAlign": "center",
},
Array [
undefined,
Object {
"fontWeight": "bold",
},
],
]
}
>
archived channel
</Text>
<Text
style={
Array [
Object {
"color": "#3d3c40",
"textAlign": "center",
},
Array [
undefined,
],
]
}
>
. New messages cannot be posted.
</Text>
</Text>
<View
accessibilityRole="button"
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"backgroundColor": "#166de0",
"borderRadius": 4,
"height": 40,
"marginTop": 10,
"opacity": 1,
"paddingBottom": 5,
"paddingTop": 5,
}
}
>
<Text
style={
Object {
"color": "white",
"fontWeight": "bold",
"marginTop": 7,
}
}
>
Close Channel
</Text>
</View>
</View>
`;
exports[`PostDraft Should render the DraftInput 1`] = `
<KeyboardTrackingView>
<View
style={
Object {
"bottom": 0,
}
}
>
<View
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"marginLeft": 0,
"marginRight": 0,
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Object {
"backgroundColor": "transparent",
"color": "#3d3c40",
"fontSize": 11,
"marginBottom": 2,
"paddingLeft": 10,
"paddingTop": 3,
"position": "absolute",
}
}
/>
<View
style={
Object {
"backgroundColor": "#ffffff",
"height": 0,
}
}
/>
</View>
</View>
<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,
]
}
>
<RCTScrollView
contentContainerStyle={
Object {
"alignItems": "stretch",
"paddingTop": 7,
}
}
disableScrollViewPanResponder={true}
keyboardShouldPersistTaps="always"
overScrollMode="never"
pinchGestureEnabled={false}
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
style={
Object {
"flex": 1,
"flexDirection": "column",
}
}
>
<View>
<TextInput
allowFontScaling={true}
blurOnSubmit={false}
disableFullscreenUI={true}
keyboardAppearance="light"
keyboardType="default"
multiline={true}
onChangeText={[Function]}
onEndEditing={[Function]}
onPaste={[Function]}
onSelectionChange={[Function]}
placeholder="Write to "
placeholderTextColor="rgba(61,60,64,0.5)"
rejectResponderTermination={true}
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
"lineHeight": 20,
"maxHeight": 150,
"minHeight": 30,
"paddingBottom": 6,
"paddingHorizontal": 12,
"paddingTop": 6,
}
}
underlineColorAndroid="transparent"
/>
<View
style={
Object {
"display": "flex",
"flexDirection": "column",
}
}
>
<View
forwardedRef={[Function]}
isInteraction={true}
style={
Object {
"display": "flex",
"flexDirection": "row",
"height": 0,
"paddingBottom": 0,
}
}
>
<RCTScrollView
contentContainerStyle={
Object {
"alignItems": "flex-end",
"paddingRight": 12,
}
}
horizontal={true}
keyboardShouldPersistTaps="handled"
style={
Object {
"flex": 1,
}
}
>
<View />
</RCTScrollView>
</View>
<View
forwardedRef={[Function]}
isInteraction={true}
style={
Object {
"height": 0,
}
}
>
<View
forwardedRef={[Function]}
isInteraction={true}
style={
Object {
"flex": 1,
"marginHorizontal": 12,
"marginTop": 4,
}
}
/>
</View>
</View>
<View
style={
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "row",
"justifyContent": "space-between",
"paddingBottom": 1,
}
}
>
<View
style={
Object {
"display": "flex",
"flexDirection": "row",
"height": 44,
}
}
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"opacity": 1,
"padding": 10,
}
}
/>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"opacity": 1,
"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,
},
null,
]
}
/>
</View>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"opacity": 1,
"padding": 10,
}
}
/>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"opacity": 1,
"padding": 10,
}
}
/>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"opacity": 1,
"padding": 10,
}
}
/>
</View>
<View
style={
Object {
"justifyContent": "flex-end",
"paddingRight": 8,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"backgroundColor": "#166de0",
"borderRadius": 4,
"height": 32,
"justifyContent": "center",
"width": 80,
},
Object {
"backgroundColor": "rgba(22,109,224,0.3)",
},
]
}
>
<RNSVGSvgView
align="xMidYMid"
bbHeight={16}
bbWidth={19}
focusable={false}
height={16}
meetOrSlice={0}
minX={0}
minY={0}
style={
Array [
Object {
"backgroundColor": "transparent",
"borderWidth": 0,
},
Object {
"flex": 0,
"height": 16,
"width": 19,
},
]
}
vbHeight={14}
vbWidth={16}
width={19}
>
<RNSVGGroup>
<RNSVGPath
d="M1.09222552,5.76354703 L0.405413926,0.94983436 C0.379442059,0.7702001 0.452067095,0.59056584 0.594431403,0.479386798 C0.737276671,0.368693254 0.927737029,0.343932856 1.09318744,0.414815564 C3.7432798,1.54942439 12.1192069,5.13676911 15.0920238,6.40974487 C15.2723839,6.48693905 15.3892573,6.66560231 15.3892573,6.8632 C15.3892573,7.06079769 15.2723839,7.23946095 15.0920238,7.31665513 C12.0961208,8.59982635 3.6105347,12.2337789 1.0316245,13.3378013 C0.878198098,13.4033436 0.701685594,13.3810106 0.569902417,13.2785706 C0.43763828,13.1761305 0.37030381,13.0096047 0.393870874,12.8430789 L1.07875863,7.96479496 L8.93669128,6.8632 L1.09222552,5.76354703 Z"
fill={2164260863}
propList={
Array [
"fill",
]
}
/>
</RNSVGGroup>
</RNSVGSvgView>
</View>
</View>
</View>
</View>
</RCTScrollView>
</View>
</KeyboardTrackingView>
`;
exports[`PostDraft Should render the ReadOnly for canPost 1`] = `
<RCTSafeAreaView
emulateUnlessSupported={true}
style={
Object {
"backgroundColor": "rgba(61,60,64,0.04)",
}
}
>
<View
style={
Object {
"alignItems": "center",
"borderTopColor": "rgba(61,60,64,0.2)",
"borderTopWidth": 1,
"flexDirection": "row",
"height": 50,
"paddingHorizontal": 12,
}
}
>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
"lineHeight": 20,
"marginLeft": 9,
"opacity": 0.56,
}
}
>
This channel is read-only.
</Text>
</View>
</RCTSafeAreaView>
`;
exports[`PostDraft Should render the ReadOnly for channelIsReadOnly 1`] = `
<RCTSafeAreaView
emulateUnlessSupported={true}
style={
Object {
"backgroundColor": "rgba(61,60,64,0.04)",
}
}
>
<View
style={
Object {
"alignItems": "center",
"borderTopColor": "rgba(61,60,64,0.2)",
"borderTopWidth": 1,
"flexDirection": "row",
"height": 50,
"paddingHorizontal": 12,
}
}
>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
"lineHeight": 20,
"marginLeft": 9,
"opacity": 0.56,
}
}
>
This channel is read-only.
</Text>
</View>
</RCTSafeAreaView>
`;

View file

@ -0,0 +1,514 @@
// 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, ScrollView, View} from 'react-native';
import {intlShape} from 'react-intl';
import HWKeyboardEvent from 'react-native-hw-keyboard-event';
import Autocomplete from '@components/autocomplete';
import PostInput from '@components/post_draft/post_input';
import QuickActions from '@components/post_draft/quick_actions';
import SendAction from '@components/post_draft/send_action';
import Typing from '@components/post_draft/typing';
import Uploads from '@components/post_draft/uploads';
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} from '@constants/post_draft';
import {NOTIFY_ALL_MEMBERS} from '@constants/view';
import EventEmitter from '@mm-redux/utils/event_emitter';
import EphemeralStore from '@store/ephemeral_store';
import * as DraftUtils from '@utils/draft';
import {confirmOutOfOfficeDisabled} from '@utils/status';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const AUTOCOMPLETE_MARGIN = 20;
const AUTOCOMPLETE_MAX_HEIGHT = 200;
const HW_SHIFT_ENTER_TEXT = Platform.OS === 'ios' ? '\n' : '';
const HW_EVENT_IN_SCREEN = ['Channel', 'Thread'];
export default class DraftInput extends PureComponent {
static propTypes = {
registerTypingAnimation: PropTypes.func.isRequired,
addReactionToLatestPost: PropTypes.func.isRequired,
getChannelMemberCountsByGroup: PropTypes.func.isRequired,
channelDisplayName: PropTypes.string,
channelId: PropTypes.string.isRequired,
createPost: PropTypes.func.isRequired,
currentUserId: PropTypes.string.isRequired,
cursorPositionEvent: PropTypes.string,
enableConfirmNotificationsToChannel: PropTypes.bool,
executeCommand: PropTypes.func.isRequired,
files: PropTypes.array,
getChannelTimezones: PropTypes.func.isRequired,
handleClearFiles: PropTypes.func.isRequired,
handleClearFailedFiles: PropTypes.func.isRequired,
isLandscape: PropTypes.bool.isRequired,
isTimezoneEnabled: PropTypes.bool,
maxMessageLength: 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,
useGroupMentions: PropTypes.bool.isRequired,
channelMemberCountsByGroup: PropTypes.object,
groupsWithAllowReference: PropTypes.object,
};
static defaultProps = {
cursorPositionEvent: CHANNEL_POST_TEXTBOX_CURSOR_CHANGE,
files: [],
rootId: '',
valueEvent: CHANNEL_POST_TEXTBOX_VALUE_CHANGE,
};
static contextTypes = {
intl: intlShape,
};
constructor(props) {
super(props);
this.input = React.createRef();
this.quickActions = React.createRef();
this.state = {
canSubmit: false,
channelTimezoneCount: 0,
sendingMessage: false,
top: 0,
};
}
componentDidMount() {
const {getChannelMemberCountsByGroup, channelId, isTimezoneEnabled, useGroupMentions, value} = this.props;
HWKeyboardEvent.onHWKeyPressed(this.handleHardwareEnterPress);
if (value) {
this.setInputValue(value);
}
if (useGroupMentions) {
getChannelMemberCountsByGroup(channelId, isTimezoneEnabled);
}
}
componentDidUpdate(prevProps) {
const {channelId, rootId, value, files, useGroupMentions, getChannelMemberCountsByGroup, isTimezoneEnabled} = this.props;
const diffChannel = channelId !== prevProps?.channelId;
const diffTimezoneEnabled = isTimezoneEnabled !== prevProps?.isTimezoneEnabled;
if (this.input.current) {
const diffThread = rootId !== prevProps.rootId;
if (diffChannel || diffThread) {
const trimmed = value.trim();
this.setInputValue(trimmed);
this.updateQuickActionValue(trimmed);
}
}
if (diffTimezoneEnabled || diffChannel) {
this.numberOfTimezones();
if (useGroupMentions) {
getChannelMemberCountsByGroup(channelId, isTimezoneEnabled);
}
}
if (prevProps.files !== files) {
this.updateCanSubmit();
}
}
componentWillUnmount() {
HWKeyboardEvent.removeOnHWKeyPressed();
}
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 = () => {
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,
};
createPost(post, postFiles);
if (postFiles.length) {
handleClearFiles(channelId, rootId);
}
if (this.input.current) {
this.setInputValue('');
this.input.current.changeDraft('');
}
this.setState({sendingMessage: false});
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',
};
const callback = () => this.setState({keyboardType: 'default'});
this.setState(nextState, callback);
}
EventEmitter.emit('scroll-to-bottom');
};
handleHardwareEnterPress = (keyEvent) => {
if (HW_EVENT_IN_SCREEN.includes(EphemeralStore.getNavigationTopComponentId())) {
switch (keyEvent.pressedKey) {
case 'enter':
this.handleSendMessage();
break;
case 'shift-enter':
this.onInsertTextToDraft(HW_SHIFT_ENTER_TEXT);
break;
}
}
}
handleInputQuickAction = (inputValue) => {
if (this.input.current) {
this.setInputValue(inputValue, true);
this.input.current.focus();
}
};
onInsertTextToDraft = (text) => {
if (this.input.current) {
this.input.current.handleInsertTextToDraft(text);
}
};
handleLayout = (e) => {
this.setState({
top: e.nativeEvent.layout.y,
});
};
handleSendMessage = () => {
if (!this.input.current) {
return;
}
this.input.current.resetTextInput();
requestAnimationFrame(() => {
const value = this.input.current.getValue();
if (!this.isSendButtonEnabled()) {
this.input.current.setValue(value);
return;
}
this.setState({sendingMessage: true});
const {channelId, files, handleClearFailedFiles, rootId} = this.props;
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 {formatMessage} = this.context.intl;
const cancel = () => {
this.setInputValue(value);
this.setState({sendingMessage: false});
};
const accept = () => {
// Remove only failed files
handleClearFailedFiles(channelId, rootId);
this.sendMessage();
};
DraftUtils.alertAttachmentFail(formatMessage, accept, cancel);
} else {
this.sendMessage();
}
});
}
isFileLoading = () => {
const {files} = this.props;
return files.some((file) => file.loading);
};
isSendButtonEnabled = () => {
return this.canSend() && !this.isFileLoading() && !this.state.sendingMessage;
};
numberOfTimezones = async () => {
const {channelId, getChannelTimezones} = this.props;
const {data} = await getChannelTimezones(channelId);
this.setState({channelTimezoneCount: data?.length || 0});
};
sendCommand = async (msg) => {
const {intl} = this.context;
const {channelId, executeCommand, rootId, userIsOutOfOffice} = this.props;
const status = DraftUtils.getStatusFromSlashCommand(msg);
if (userIsOutOfOffice && DraftUtils.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) {
this.setInputValue(msg);
DraftUtils.alertSlashCommandFailed(intl.formatMessage, error.message);
return;
}
this.setInputValue('');
this.input.current.changeDraft('');
};
sendMessage = () => {
const value = this.input.current?.getValue() || '';
const {channelMemberCountsByGroup, enableConfirmNotificationsToChannel, groupsWithAllowReference, membersCount, useGroupMentions, useChannelMentions} = this.props;
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions;
const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value);
const groupMentions = (!toAllOrChannel && notificationsToGroups) ? DraftUtils.groupsMentionedInText(groupsWithAllowReference, value) : [];
if (value.indexOf('/') === 0) {
this.sendCommand(value);
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) {
this.showSendToAllOrChannelAlert(membersCount, value);
} else if (groupMentions.length > 0) {
const {groupMentionsSet, memberNotifyCount, channelTimezoneCount} = DraftUtils.mapGroupMentions(channelMemberCountsByGroup, groupMentions);
if (memberNotifyCount > 0) {
this.showSendToGroupsAlert(Array.from(groupMentionsSet), memberNotifyCount, channelTimezoneCount, value);
} else {
this.doSubmitMessage();
}
} else {
this.doSubmitMessage();
}
};
sendReaction = (emoji) => {
const {addReactionToLatestPost, rootId} = this.props;
addReactionToLatestPost(emoji, rootId);
this.setInputValue('');
this.input.current.changeDraft('');
this.setState({sendingMessage: false});
};
setInputValue = (value, autocomplete = false) => {
if (this.input.current) {
this.input.current.setValue(value, autocomplete);
this.updateCanSubmit();
}
}
showSendToAllOrChannelAlert = (membersCount, msg) => {
const {formatMessage} = this.context.intl;
const {channelTimezoneCount} = this.state;
const {isTimezoneEnabled} = this.props;
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(formatMessage, membersCount, isTimezoneEnabled, channelTimezoneCount);
const cancel = () => {
this.setInputValue(msg);
this.setState({sendingMessage: false});
};
DraftUtils.alertChannelWideMention(formatMessage, notifyAllMessage, this.doSubmitMessage, cancel);
};
showSendToGroupsAlert = (groupMentions, memberNotifyCount, channelTimezoneCount, msg) => {
const {formatMessage} = this.context.intl;
const notifyAllMessage = DraftUtils.buildGroupMentionsMessage(formatMessage, groupMentions, memberNotifyCount, channelTimezoneCount);
const cancel = () => {
this.setInputValue(msg);
this.setState({sendingMessage: false});
};
DraftUtils.alertSendToGroups(formatMessage, notifyAllMessage, this.doSubmitMessage, cancel);
};
updateCanSubmit = () => {
const {canSubmit} = this.state;
const enabled = this.isSendButtonEnabled();
if (canSubmit !== enabled) {
this.setState({canSubmit: enabled});
}
}
updateQuickActionValue = (value) => {
if (this.quickActions.current) {
this.quickActions.current.handleInputEvent(value);
}
this.updateCanSubmit();
}
updateStatus = (status) => {
const {currentUserId, setStatus} = this.props;
setStatus({user_id: currentUserId, status});
};
render() {
const {
channelDisplayName,
channelId,
cursorPositionEvent,
isLandscape,
files,
maxMessageLength,
screenId,
valueEvent,
registerTypingAnimation,
rootId,
theme,
} = this.props;
const style = getStyleSheet(theme);
return (
<>
<Typing
theme={theme}
registerTypingAnimation={registerTypingAnimation}
/>
{Platform.OS === 'android' &&
<Autocomplete
cursorPositionEvent={cursorPositionEvent}
maxHeight={Math.min(this.state.top - AUTOCOMPLETE_MARGIN, AUTOCOMPLETE_MAX_HEIGHT)}
onChangeText={this.handleInputQuickAction}
valueEvent={valueEvent}
rootId={rootId}
channelId={channelId}
/>
}
<View
style={[style.inputWrapper, padding(isLandscape)]}
onLayout={this.handleLayout}
>
<ScrollView
style={style.inputContainer}
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}
ref={this.input}
rootId={rootId}
screenId={screenId}
theme={theme}
updateInitialValue={this.updateQuickActionValue}
/>
<Uploads
files={files}
rootId={rootId}
screenId={screenId}
theme={theme}
/>
<View style={style.actionsContainer}>
<QuickActions
ref={this.quickActions}
fileCount={files.length}
inputEventType={valueEvent}
onTextChange={this.handleInputQuickAction}
theme={theme}
/>
<SendAction
disabled={!this.state.canSubmit}
handleSendMessage={this.handleSendMessage}
theme={theme}
/>
</View>
</ScrollView>
</View>
</>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
actionsContainer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: Platform.select({
ios: 1,
android: 2,
}),
},
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),
},
};
});

View file

@ -0,0 +1,194 @@
// 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 DraftInput from './draft_input';
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn(),
}));
describe('DraftInput', () => {
const baseProps = {
registerTypingAnimation: jest.fn(),
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(),
getChannelMemberCountsByGroup: 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,
useGroupMentions: true,
groupsWithAllowReference: new Map([
['@developers', {
id: 'developers',
name: 'developers',
}],
['@qa', {
id: 'qa',
name: 'qa',
}],
]),
channelMemberCountsByGroup: {
developers: {
channel_member_count: 10,
channel_member_timezones_count: 0,
},
qa: {
channel_member_count: 3,
channel_member_timezones_count: 0,
},
},
membersCount: 10,
};
const ref = React.createRef();
test('should send an alert when sending a message with a channel mention', () => {
const wrapper = shallowWithIntl(
<DraftInput
{...baseProps}
ref={ref}
/>,
);
const message = '@all';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
},
};
instance.sendMessage();
expect(Alert.alert).toBeCalled();
expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything());
});
test('should send an alert when sending a message with a group mention with group with count more than NOTIFY_ALL', () => {
const wrapper = shallowWithIntl(
<DraftInput
{...baseProps}
ref={ref}
/>,
);
const message = '@developers';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
},
};
instance.sendMessage();
expect(Alert.alert).toBeCalled();
});
test('should not send an alert when sending a message with a group mention with group with count less than NOTIFY_ALL', () => {
const wrapper = shallowWithIntl(
<DraftInput
{...baseProps}
ref={ref}
/>,
);
const message = '@qa';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
},
};
instance.sendMessage();
expect(Alert.alert).not.toBeCalled();
});
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(
<DraftInput
{...baseProps}
useChannelMentions={false}
ref={ref}
/>,
);
const message = '@all';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
},
};
instance.sendMessage();
expect(Alert.alert).not.toHaveBeenCalled();
});
test('should not send an alert when sending a message with a channel mention when the user does not have group mentions permission', () => {
const wrapper = shallowWithIntl(
<DraftInput
{...baseProps}
useGroupMentions={false}
ref={ref}
/>,
);
const message = '@developer';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
},
};
instance.sendMessage();
expect(Alert.alert).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {executeCommand} from '@actions/views/command';
import {addReactionToLatestPost} from '@actions/views/emoji';
import {handleClearFiles, handleClearFailedFiles} from '@actions/views/file_upload';
import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {getChannelTimezones, getChannelMemberCountsByGroup} from '@mm-redux/actions/channels';
import {createPost} from '@mm-redux/actions/posts';
import {setStatus} from '@mm-redux/actions/users';
import {General, Permissions} from '@mm-redux/constants';
import {getCurrentChannel, getChannel, getChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from '@mm-redux/selectors/entities/channels';
import {getConfig, getLicense} from '@mm-redux/selectors/entities/general';
import {getAssociatedGroupsForReferenceMap} from '@mm-redux/selectors/entities/groups';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles';
import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {isLandscape} from '@selectors/device';
import {getCurrentChannelDraft, getThreadDraft} from '@selectors/views';
import PostDraft from './draft_input';
export function mapStateToProps(state, ownProps) {
const channelId = ownProps.channelId;
const currentDraft = ownProps.rootId ? getThreadDraft(state, ownProps.rootId) : getCurrentChannelDraft(state);
const config = getConfig(state);
const channel = ownProps.rootId ? getChannel(state, channelId) : 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 = getChannelStats(state, channelId);
const membersCount = currentChannelStats?.member_count || 0; // eslint-disable-line camelcase
const isTimezoneEnabled = config?.ExperimentalTimezone === 'true';
const channelTeamId = channel ? channel.team_id : '';
const license = getLicense(state);
let useChannelMentions = true;
let useGroupMentions = false;
const channelMemberCountsByGroup = selectChannelMemberCountsByGroup(state, channelId);
let groupsWithAllowReference = new Map();
if (channel && isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) {
useChannelMentions = haveIChannelPermission(
state,
{
channel: channel.id,
permission: Permissions.USE_CHANNEL_MENTIONS,
default: true,
},
);
}
if (isMinimumServerVersion(state.entities.general.serverVersion, 5, 24) && license && license.IsLicensed === 'true') {
useGroupMentions = haveIChannelPermission(
state,
{
channel: channel.id,
team: channel.team_id,
permission: Permissions.USE_GROUP_MENTIONS,
},
);
if (useGroupMentions) {
groupsWithAllowReference = getAssociatedGroupsForReferenceMap(state, channelTeamId, channelId);
}
}
return {
currentChannel: channel,
channelId,
channelTeamId,
channelDisplayName: state.views.channel.displayName || (channel ? channel.display_name : ''),
currentUserId,
enableConfirmNotificationsToChannel,
files: currentDraft.files,
isLandscape: isLandscape(state),
isTimezoneEnabled,
maxMessageLength: (config && parseInt(config.MaxPostSize || 0, 10)) || MAX_MESSAGE_LENGTH_FALLBACK,
membersCount,
theme: getTheme(state),
useChannelMentions,
userIsOutOfOffice,
value: currentDraft.draft,
groupsWithAllowReference,
useGroupMentions,
channelMemberCountsByGroup,
};
}
const mapDispatchToProps = {
addReactionToLatestPost,
createPost,
executeCommand,
getChannelTimezones,
handleClearFiles,
handleClearFailedFiles,
setStatus,
getChannelMemberCountsByGroup,
};
export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft);

View file

@ -5,89 +5,38 @@ import {connect} from 'react-redux';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {General, Permissions} from '@mm-redux/constants';
import {createPost} from '@mm-redux/actions/posts';
import {setStatus} from '@mm-redux/actions/users';
import {getCurrentChannel, isCurrentChannelReadOnly, getCurrentChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from '@mm-redux/selectors/entities/channels';
import {getCurrentChannel, getChannel, isCurrentChannelReadOnly} from '@mm-redux/selectors/entities/channels';
import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles';
import {getConfig, getLicense} 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, getChannelMemberCountsByGroup} from '@mm-redux/actions/channels';
import {getAssociatedGroupsForReferenceMap} from '@mm-redux/selectors/entities/groups';
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 {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {getChannelMembersForDm} from '@selectors/channel';
import {getAllowedServerMaxFileSize} from '@utils/file';
import {isLandscape} from '@selectors/device';
import PostDraft from './post_draft';
export function mapStateToProps(state, ownProps) {
const currentDraft = ownProps.rootId ? getThreadDraft(state, ownProps.rootId) : getCurrentChannelDraft(state);
const config = getConfig(state);
const currentChannel = getCurrentChannel(state);
const channel = ownProps.rootId ? getChannel(state) : 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';
const channelId = ownProps.channelId || (currentChannel ? currentChannel.id : '');
const channelTeamId = currentChannel ? currentChannel.team_id : '';
const license = getLicense(state);
const channelId = ownProps.channelId || (channel ? channel.id : '');
let canPost = true;
let useChannelMentions = true;
let deactivatedChannel = false;
let useGroupMentions = false;
const channelMemberCountsByGroup = selectChannelMemberCountsByGroup(state, channelId);
let groupsWithAllowReference = new Map();
if (currentChannel && currentChannel.type === General.DM_CHANNEL) {
const teammate = getChannelMembersForDm(state, currentChannel);
if (channel && channel.type === General.DM_CHANNEL) {
const teammate = getChannelMembersForDm(state, channel);
if (teammate.length && teammate[0].delete_at) {
deactivatedChannel = true;
}
}
if (currentChannel && isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) {
if (channel && isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) {
canPost = haveIChannelPermission(
state,
{
channel: currentChannel.id,
team: currentChannel.team_id,
channel: channel.id,
team: channel.team_id,
permission: Permissions.CREATE_POST,
default: true,
},
);
useChannelMentions = haveIChannelPermission(
state,
{
channel: currentChannel.id,
permission: Permissions.USE_CHANNEL_MENTIONS,
default: true,
},
);
}
if (isMinimumServerVersion(state.entities.general.serverVersion, 5, 24) && license && license.IsLicensed === 'true') {
useGroupMentions = haveIChannelPermission(
state,
{
channel: currentChannel.id,
team: currentChannel.team_id,
permission: Permissions.USE_GROUP_MENTIONS,
},
);
if (useGroupMentions) {
groupsWithAllowReference = getAssociatedGroupsForReferenceMap(state, channelTeamId, channelId);
}
}
let channelIsReadOnly = false;
@ -97,41 +46,12 @@ export function mapStateToProps(state, ownProps) {
return {
canPost,
currentChannel,
channelId,
channelTeamId,
channelDisplayName: state.views.channel.displayName || (currentChannel ? currentChannel.display_name : ''),
channelIsArchived: ownProps.channelIsArchived || (currentChannel ? currentChannel.delete_at !== 0 : false),
channelIsArchived: ownProps.channelIsArchived || (channel ? channel.delete_at !== 0 : false),
channelIsReadOnly,
currentUserId,
deactivatedChannel,
enableConfirmNotificationsToChannel,
files: currentDraft.files,
isLandscape: isLandscape(state),
isTimezoneEnabled,
maxMessageLength: (config && parseInt(config.MaxPostSize || 0, 10)) || MAX_MESSAGE_LENGTH_FALLBACK,
maxFileSize: getAllowedServerMaxFileSize(config),
membersCount,
theme: getTheme(state),
useChannelMentions,
userIsOutOfOffice,
value: currentDraft.draft,
groupsWithAllowReference,
useGroupMentions,
channelMemberCountsByGroup,
};
}
const mapDispatchToProps = {
addReactionToLatestPost,
createPost,
executeCommand,
getChannelTimezones,
handleClearFiles,
handleClearFailedFiles,
initUploadFiles,
setStatus,
getChannelMemberCountsByGroup,
};
export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft);
export default connect(mapStateToProps, null, null, {forwardRef: true})(PostDraft);

View file

@ -5,11 +5,8 @@
import {Permissions} from '@mm-redux/constants';
import * as channelSelectors from '@mm-redux/selectors/entities/channels';
import * as userSelectors from '@mm-redux/selectors/entities/users';
import * as generalSelectors from '@mm-redux/selectors/entities/general';
import * as preferenceSelectors from '@mm-redux/selectors/entities/preferences';
import * as roleSelectors from '@mm-redux/selectors/entities/roles';
import * as deviceSelectors from '@selectors/device';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
@ -22,12 +19,8 @@ jest.mock('./post_draft', () => ({
channelSelectors.getCurrentChannel = jest.fn().mockReturnValue({});
channelSelectors.isCurrentChannelReadOnly = jest.fn();
channelSelectors.getCurrentChannelStats = jest.fn();
userSelectors.getStatusForUserId = jest.fn();
generalSelectors.canUploadFilesOnMobile = jest.fn();
preferenceSelectors.getTheme = jest.fn();
roleSelectors.haveIChannelPermission = jest.fn();
deviceSelectors.isLandscape = jest.fn();
describe('mapStateToProps', () => {
const baseState = {
@ -102,12 +95,6 @@ describe('mapStateToProps', () => {
permission: Permissions.CREATE_POST,
default: true,
});
expect(roleSelectors.haveIChannelPermission).toHaveBeenCalledWith(state, {
channel: undefined,
permission: Permissions.USE_CHANNEL_MENTIONS,
default: true,
});
});
test('haveIChannelPermission is not called when isMinimumServerVersion is 5.22v but currentChannel is null', () => {

View file

@ -3,642 +3,73 @@
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 {Platform} from 'react-native';
import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view';
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 {AT_MENTION_REGEX_GLOBAL, CODE_REGEX} from 'app/constants/autocomplete';
import {General} from '@mm-redux/constants';
import {UPDATE_NATIVE_SCROLLVIEW} from '@constants/post_draft';
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;
import DraftInput from './draft_input';
import ReadOnly from './read_only';
export default class PostDraft extends PureComponent {
static propTypes = {
registerTypingAnimation: PropTypes.func.isRequired,
addReactionToLatestPost: PropTypes.func.isRequired,
getChannelMemberCountsByGroup: PropTypes.func.isRequired,
accessoriesContainerID: PropTypes.string,
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,
registerTypingAnimation: PropTypes.func.isRequired,
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,
scrollViewNativeID: PropTypes.string,
valueEvent: PropTypes.string,
useGroupMentions: PropTypes.bool.isRequired,
channelMemberCountsByGroup: PropTypes.object,
groupsWithAllowReference: PropTypes.object,
theme: PropTypes.object.isRequired,
};
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,
};
}
draftInput = React.createRef();
keyboardTracker = React.createRef();
componentDidMount() {
const {getChannelMemberCountsByGroup, channelId, isTimezoneEnabled, useGroupMentions} = this.props;
if (useGroupMentions) {
getChannelMemberCountsByGroup(channelId, isTimezoneEnabled);
}
EventEmitter.on(UPDATE_NATIVE_SCROLLVIEW, this.updateNativeScrollView);
}
componentDidUpdate(prevProps) {
const {channelId, rootId, value, useGroupMentions, getChannelMemberCountsByGroup, isTimezoneEnabled} = this.props;
const diffChannel = channelId !== prevProps?.channelId;
const diffTimezoneEnabled = isTimezoneEnabled !== prevProps?.isTimezoneEnabled;
if (this.input.current) {
const diffThread = rootId !== prevProps.rootId;
if (diffChannel || diffThread) {
const trimmed = value.trim();
this.input.current.setValue(trimmed);
this.updateInitialValue(trimmed);
}
}
if (diffTimezoneEnabled || diffChannel) {
this.numberOfTimezones().then((channelTimezoneCount) => this.setState({channelTimezoneCount}));
if (useGroupMentions) {
getChannelMemberCountsByGroup(channelId, isTimezoneEnabled);
}
}
componentWillUnmount() {
EventEmitter.off(UPDATE_NATIVE_SCROLLVIEW, this.updateNativeScrollView);
}
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;
};
showSendToGroupsAlert = (groupMentions, memberNotifyCount, channelTimezoneCount, msg) => {
const {intl} = this.context;
let notifyAllMessage = '';
if (groupMentions.length === 1) {
if (channelTimezoneCount > 0) {
notifyAllMessage = (
intl.formatMessage(
{
id: 'mobile.post_textbox.one_group.message.with_timezones',
defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?',
},
{
mention: groupMentions[0],
totalMembers: memberNotifyCount,
timezones: channelTimezoneCount,
},
)
);
} else {
notifyAllMessage = (
intl.formatMessage(
{
id: 'mobile.post_textbox.one_group.message.without_timezones',
defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?',
},
{
mention: groupMentions[0],
totalMembers: memberNotifyCount,
},
)
);
}
} else if (channelTimezoneCount > 0) {
notifyAllMessage = (
intl.formatMessage(
{
id: 'mobile.post_textbox.multi_group.message.with_timezones',
defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?',
},
{
mentions: groupMentions.slice(0, -1).join(', '),
finalMention: groupMentions[groupMentions.length - 1],
totalMembers: memberNotifyCount,
timezones: channelTimezoneCount,
},
)
);
} else {
notifyAllMessage = (
intl.formatMessage(
{
id: 'mobile.post_textbox.multi_group.message.without_timezones',
defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?',
},
{
mentions: groupMentions.slice(0, -1).join(', '),
finalMention: groupMentions[groupMentions.length - 1],
totalMembers: memberNotifyCount,
},
)
);
}
Alert.alert(
intl.formatMessage({
id: 'mobile.post_textbox.groups.title',
defaultMessage: 'Confirm sending notifications to groups',
}),
notifyAllMessage,
[
{
text: intl.formatMessage({
id: 'mobile.post_textbox.entire_channel.cancel',
defaultMessage: 'Cancel',
}),
onPress: () => {
this.input.current.setValue(msg);
this.setState({sendingMessage: false});
},
},
{
text: intl.formatMessage({
id: 'mobile.post_textbox.entire_channel.confirm',
defaultMessage: 'Confirm',
}),
onPress: () => this.doSubmitMessage(),
},
],
);
};
doSubmitMessage = () => {
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,
};
createPost(post, postFiles);
if (postFiles.length) {
handleClearFiles(channelId, rootId);
}
if (this.input.current) {
this.input.current.setValue('');
this.input.current.changeDraft('');
}
this.setState({sendingMessage: false});
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',
};
const 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();
handleInputQuickAction = (value) => {
if (this.draftInput?.current) {
this.draftInput.current.handleInputQuickAction(value);
}
};
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);
updateNativeScrollView = (scrollViewNativeID) => {
if (this.keyboardTracker?.current) {
this.keyboardTracker.current.resetScrollView(scrollViewNativeID);
}
};
handleSendMessage = () => {
if (!this.input.current) {
return;
}
this.input.current.resetTextInput();
requestAnimationFrame(() => {
const value = this.input.current.getValue();
if (!this.isSendButtonEnabled()) {
this.input.current.setValue(value);
return;
}
this.setState({sendingMessage: true});
const {channelId, files, handleClearFailedFiles, rootId} = this.props;
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.input.current.setValue(value);
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) {
this.input.current.setValue(msg);
Alert.alert(
intl.formatMessage({
id: 'mobile.commands.error_title',
defaultMessage: 'Error Executing Command',
}),
error.message,
);
return;
}
this.input.current.setValue('');
this.input.current.changeDraft('');
};
mapGroupMentions = (groupMentions) => {
const {channelMemberCountsByGroup} = this.props;
let memberNotifyCount = 0;
let channelTimezoneCount = 0;
const groupMentionsSet = new Set();
groupMentions.
forEach((group) => {
const mappedValue = channelMemberCountsByGroup[group.id];
if (mappedValue?.channel_member_count > NOTIFY_ALL_MEMBERS && mappedValue?.channel_member_count > memberNotifyCount) {
memberNotifyCount = mappedValue.channel_member_count;
channelTimezoneCount = mappedValue.channel_member_timezones_count;
}
groupMentionsSet.add(`@${group.name}`);
});
return {groupMentionsSet, memberNotifyCount, channelTimezoneCount};
}
sendMessage = () => {
const value = this.input.current?.getValue() || '';
const {enableConfirmNotificationsToChannel, membersCount, useGroupMentions, useChannelMentions} = this.props;
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions;
const toAllOrChannel = this.textContainsAtAllAtChannel(value);
const groupMentions = (!toAllOrChannel && notificationsToGroups) ? this.groupsMentionedInText(value) : [];
if (value.indexOf('/') === 0) {
this.sendCommand(value);
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) {
this.showSendToAllOrChannelAlert(membersCount, value);
} else if (groupMentions.length > 0) {
const {groupMentionsSet, memberNotifyCount, channelTimezoneCount} = this.mapGroupMentions(groupMentions);
if (memberNotifyCount > 0) {
this.showSendToGroupsAlert(Array.from(groupMentionsSet), memberNotifyCount, channelTimezoneCount, value);
} else {
this.doSubmitMessage();
}
} else {
this.doSubmitMessage();
}
};
sendReaction = (emoji) => {
const {addReactionToLatestPost, rootId} = this.props;
addReactionToLatestPost(emoji, rootId);
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, msg) => {
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.input.current.setValue(msg);
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(CODE_REGEX, '');
return (/(?:\B|\b_+)@(channel|all)(?!(\.|-|_)*[^\W_])/i).test(textWithoutCode);
};
groupsMentionedInText = (text) => {
const {groupsWithAllowReference} = this.props;
const groups = [];
if (groupsWithAllowReference.size > 0) {
const textWithoutCode = text.replace(CODE_REGEX, '');
const mentions = textWithoutCode.match(AT_MENTION_REGEX_GLOBAL) || [];
mentions.forEach((mention) => {
const group = groupsWithAllowReference.get(mention);
if (group) {
groups.push(group);
}
});
}
return groups;
}
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;
const {
accessoriesContainerID,
canPost,
channelId,
channelIsArchived,
channelIsReadOnly,
cursorPositionEvent,
deactivatedChannel,
registerTypingAnimation,
rootId,
screenId,
scrollViewNativeID,
theme,
valueEvent,
} = this.props;
if (channelIsArchived || deactivatedChannel) {
return (
<Archived
@ -649,120 +80,39 @@ export default class PostDraft extends PureComponent {
);
}
const {
canPost,
channelDisplayName,
channelId,
channelIsReadOnly,
cursorPositionEvent,
isLandscape,
files,
maxFileSize,
maxMessageLength,
screenId,
valueEvent,
registerTypingAnimation,
} = this.props;
const style = getStyleSheet(theme);
const readonly = channelIsReadOnly || !canPost;
if (readonly) {
return (
<ReadOnly theme={theme}/>
);
}
const draftInput = (
<DraftInput
ref={this.draftInput}
channelId={channelId}
cursorPositionEvent={cursorPositionEvent}
registerTypingAnimation={registerTypingAnimation}
rootId={rootId}
screenId={screenId}
theme={theme}
valueEvent={valueEvent}
/>
);
if (Platform.OS === 'android') {
return draftInput;
}
return (
<>
<Typing
theme={theme}
registerTypingAnimation={registerTypingAnimation}
/>
{Platform.OS === 'android' &&
<Autocomplete
cursorPositionEvent={cursorPositionEvent}
maxHeight={Math.min(this.state.top - AUTOCOMPLETE_MARGIN, AUTOCOMPLETE_MAX_HEIGHT)}
onChangeText={this.handleInputQuickAction}
valueEvent={valueEvent}
rootId={rootId}
channelId={channelId}
/>
}
<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>
</>
<KeyboardTrackingView
accessoriesContainerID={accessoriesContainerID}
ref={this.keyboardTracker}
scrollViewNativeID={scrollViewNativeID}
>
{draftInput}
</KeyboardTrackingView>
);
};
}
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

@ -2,348 +2,133 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Alert} from 'react-native';
import assert from 'assert';
import {shallowWithIntl} from 'test/intl-test-helper';
import TestRenderer from 'react-test-renderer';
import {IntlProvider} from 'react-intl';
import {Provider} from 'react-redux';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import Preferences from '@mm-redux/constants/preferences';
import intitialState from '@store/initial_state';
import PostDraft from './post_draft';
const mockStore = configureMockStore([thunk]);
const state = {
...intitialState,
entities: {
...intitialState.entities,
channels: {
...intitialState.entities.channels,
channels: {
'channel-id': {
id: 'channel-id',
name: 'test-channel',
display_name: 'Display Name',
type: 'O',
},
},
channelMemberCountsByGroup: {},
},
},
device: {
...intitialState.device,
dimension: {
deviceWidth: 375,
deviceHeight: 812,
},
},
};
const store = mockStore(state);
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn(),
}));
describe('PostDraft', () => {
const baseProps = {
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(),
getChannelMemberCountsByGroup: 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,
useGroupMentions: true,
groupsWithAllowReference: new Map([
['@developers', {
id: 'developers',
name: 'developers',
}],
['@qa', {
id: 'qa',
name: 'qa',
}],
]),
channelMemberCountsByGroup: {
developers: {
channel_member_count: 10,
channel_member_timezones_count: 0,
},
qa: {
channel_member_count: 3,
channel_member_timezones_count: 0,
},
},
membersCount: 10,
channelId: 'channel-id',
channelIsArchived: false,
channelIsReadOnly: false,
deactivatedChannel: false,
registerTypingAnimation: jest.fn(),
rootId: '',
screenId: 'NavigationScreen1',
theme: Preferences.THEMES.default,
};
const ref = React.createRef();
test('should send an alert when sending a message with a channel mention', () => {
const wrapper = shallowWithIntl(
test('Should render the DraftInput', () => {
const wrapper = renderWithRedux(
<PostDraft
{...baseProps}
ref={ref}
/>,
);
const message = '@all';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
},
};
instance.sendMessage();
expect(Alert.alert).toBeCalled();
expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything());
expect(wrapper.toJSON()).toMatchSnapshot();
const element = wrapper.root.find((el) => el.type === 'TextInput');
expect(element).toBeTruthy();
});
test('should send an alert when sending a message with a group mention with group with count more than NOTIFY_ALL', () => {
const wrapper = shallowWithIntl(
test('Should render the Archived for channelIsArchived', () => {
const wrapper = renderWithRedux(
<PostDraft
{...baseProps}
ref={ref}
channelIsArchived={true}
/>,
);
const message = '@developers';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
},
};
instance.sendMessage();
expect(Alert.alert).toBeCalled();
expect(wrapper.toJSON()).toMatchSnapshot();
const element = wrapper.root.find((el) => el.type === 'Text' && el.children && el.children[0] === 'archived channel');
expect(element).toBeTruthy();
});
test('should not send an alert when sending a message with a group mention with group with count less than NOTIFY_ALL', () => {
const wrapper = shallowWithIntl(
test('Should render the Archived for deactivatedChannel', () => {
const wrapper = renderWithRedux(
<PostDraft
{...baseProps}
ref={ref}
deactivatedChannel={true}
/>,
);
const message = '@qa';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
},
};
instance.sendMessage();
expect(Alert.alert).not.toBeCalled();
expect(wrapper.toJSON()).toMatchSnapshot();
const element = wrapper.root.find((el) => el.type === 'Text' && el.children && el.children[0] === 'archived channel');
expect(element).toBeTruthy();
});
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(
test('Should render the ReadOnly for channelIsReadOnly', () => {
const wrapper = renderWithRedux(
<PostDraft
{...baseProps}
useChannelMentions={false}
ref={ref}
channelIsReadOnly={true}
/>,
);
const message = '@all';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
},
};
instance.sendMessage();
expect(Alert.alert).not.toHaveBeenCalled();
expect(wrapper.toJSON()).toMatchSnapshot();
const element = wrapper.root.find((el) => el.type === 'Text' && el.children && el.children[0] === 'This channel is read-only.');
expect(element).toBeTruthy();
});
test('should not send an alert when sending a message with a channel mention when the user does not have group mentions permission', () => {
const wrapper = shallowWithIntl(
test('Should render the ReadOnly for canPost', () => {
const wrapper = renderWithRedux(
<PostDraft
{...baseProps}
useGroupMentions={false}
ref={ref}
canPost={false}
/>,
);
const message = '@developer';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
},
};
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(
<PostDraft {...baseProps}/>,
);
const containsAtChannel = wrapper.instance().textContainsAtAllAtChannel(data.text);
assert.equal(containsAtChannel, data.result, data.text);
}
expect(wrapper.toJSON()).toMatchSnapshot();
const element = wrapper.root.find((el) => el.type === 'Text' && el.children && el.children[0] === 'This channel is read-only.');
expect(element).toBeTruthy();
});
});
function renderWithRedux(component) {
return TestRenderer.create(
<Provider store={store}>
<IntlProvider locale='en'>
{component}
</IntlProvider>
</Provider>,
);
}

View file

@ -4,13 +4,11 @@ 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)"

View file

@ -5,20 +5,17 @@ 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 {NavigationTypes} from '@constants';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
import EphemeralStore from '@store/ephemeral_store';
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' : '';
const HW_EVENT_IN_SCREEN = ['Channel', 'Thread'];
export default class PostInput extends PureComponent {
static contextTypes = {
@ -38,9 +35,6 @@ export default class PostInput extends PureComponent {
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,
@ -63,8 +57,8 @@ export default class PostInput extends PureComponent {
componentDidMount() {
const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT;
EventEmitter.on(event, this.handleInsertTextToDraft);
EventEmitter.on(NavigationTypes.BLUR_POST_DRAFT, this.blur);
AppState.addEventListener('change', this.handleAppStateChange);
HWKeyboardEvent.onHWKeyPressed(this.handleHardwareEnterPress);
if (Platform.OS === 'android') {
Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard);
@ -73,10 +67,9 @@ export default class PostInput extends PureComponent {
componentWillUnmount() {
const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT;
EventEmitter.off(NavigationTypes.BLUR_POST_DRAFT, this.blur);
EventEmitter.off(event, this.handleInsertTextToDraft);
AppState.removeEventListener('change', this.handleAppStateChange);
HWKeyboardEvent.removeOnHWKeyPressed();
if (Platform.OS === 'android') {
Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard);
@ -139,12 +132,10 @@ export default class PostInput extends PureComponent {
}
getPlaceHolder = () => {
const {readonly, rootId} = this.props;
const {rootId} = this.props;
let placeholder;
if (readonly) {
placeholder = {id: t('mobile.create_post.read_only'), defaultMessage: 'This channel is read-only.'};
} else if (rootId) {
if (rootId) {
placeholder = {id: t('create_comment.addComment'), defaultMessage: 'Add a comment...'};
} else {
placeholder = {id: t('create_post.write'), defaultMessage: 'Write to {channelDisplayName}'};
@ -161,19 +152,6 @@ export default class PostInput extends PureComponent {
this.blur();
};
handleHardwareEnterPress = (keyEvent) => {
if (HW_EVENT_IN_SCREEN.includes(EphemeralStore.getNavigationTopComponentId())) {
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());
@ -249,7 +227,8 @@ export default class PostInput extends PureComponent {
};
handleInsertTextToDraft = (text) => {
const {cursorPosition, value} = this.state;
const {cursorPosition} = this.state;
const value = this.getValue();
let completed;
if (value.length === 0) {
@ -260,8 +239,9 @@ export default class PostInput extends PureComponent {
completed = `${firstPart}${text}${secondPart}`;
}
this.setState({
value: completed,
this.value = completed;
this.input.current.setNativeProps({
text: completed,
});
};
@ -285,7 +265,7 @@ export default class PostInput extends PureComponent {
render() {
const {formatMessage} = this.context.intl;
const {channelDisplayName, isLandscape, onPasteFiles, readonly, theme} = this.props;
const {channelDisplayName, isLandscape, theme} = this.props;
const style = getStyleSheet(theme);
const placeholder = this.getPlaceHolder();
let maxHeight = 150;
@ -308,8 +288,6 @@ export default class PostInput extends PureComponent {
keyboardType={this.state.keyboardType}
onEndEditing={this.handleEndEditing}
disableFullscreenUI={true}
editable={!readonly}
onPaste={onPasteFiles}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
/>
);

View file

@ -18,7 +18,6 @@ jest.mock('react-native-image-picker', () => ({
describe('CameraButton', () => {
const formatMessage = jest.fn();
const baseProps = {
blurTextBox: jest.fn(),
fileCount: 0,
maxFileCount: 5,
onShowFileMaxWarning: jest.fn(),

View file

@ -15,16 +15,16 @@ import ImagePicker from 'react-native-image-picker';
import Permissions from 'react-native-permissions';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {NavigationTypes} from '@constants';
import {ICON_SIZE, MAX_FILE_COUNT_WARNING} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {changeOpacity} from '@utils/theme';
export default class CameraQuickAction extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
disabled: PropTypes.bool,
fileCount: PropTypes.number,
maxFileCount: PropTypes.number,
onShowFileMaxWarning: PropTypes.func,
onUploadFiles: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
};
@ -88,18 +88,16 @@ export default class CameraQuickAction extends PureComponent {
handleButtonPress = () => {
const {
blurTextBox,
fileCount,
maxFileCount,
onShowFileMaxWarning,
} = this.props;
if (fileCount === maxFileCount) {
onShowFileMaxWarning();
EventEmitter.emit(MAX_FILE_COUNT_WARNING);
return;
}
blurTextBox();
EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT);
this.attachFileFromCamera();
};

View file

@ -18,7 +18,6 @@ jest.mock('react-native-image-picker', () => ({
describe('FileQuickAction', () => {
const formatMessage = jest.fn();
const baseProps = {
blurTextBox: jest.fn(),
fileCount: 0,
maxFileCount: 5,
onShowFileMaxWarning: jest.fn(),

View file

@ -11,18 +11,18 @@ import DocumentPicker from 'react-native-document-picker';
import Permissions from 'react-native-permissions';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {NavigationTypes} from '@constants';
import {ICON_SIZE, MAX_FILE_COUNT_WARNING} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {changeOpacity} from '@utils/theme';
const ShareExtension = NativeModules.MattermostShare;
export default class FileQuickAction extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
disabled: PropTypes.bool,
fileCount: PropTypes.number,
maxFileCount: PropTypes.number,
onShowFileMaxWarning: PropTypes.func,
onUploadFiles: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
};
@ -125,18 +125,16 @@ export default class FileQuickAction extends PureComponent {
handleButtonPress = () => {
const {
blurTextBox,
fileCount,
maxFileCount,
onShowFileMaxWarning,
} = this.props;
if (fileCount === maxFileCount) {
onShowFileMaxWarning();
EventEmitter.emit(MAX_FILE_COUNT_WARNING);
return;
}
blurTextBox();
EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT);
this.attachFileFromFiles();
};

View file

@ -18,7 +18,6 @@ jest.mock('react-native-image-picker', () => ({
describe('ImageQuickAction', () => {
const formatMessage = jest.fn();
const baseProps = {
blurTextBox: jest.fn(),
fileCount: 0,
maxFileCount: 5,
onShowFileMaxWarning: jest.fn(),

View file

@ -10,16 +10,16 @@ import ImagePicker from 'react-native-image-picker';
import Permissions from 'react-native-permissions';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {NavigationTypes} from '@constants';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {ICON_SIZE, MAX_FILE_COUNT_WARNING} from '@constants/post_draft';
import {changeOpacity} from '@utils/theme';
export default class ImageQuickAction extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
disabled: PropTypes.bool,
fileCount: PropTypes.number,
maxFileCount: PropTypes.number,
onShowFileMaxWarning: PropTypes.func,
onUploadFiles: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
};
@ -95,18 +95,16 @@ export default class ImageQuickAction extends PureComponent {
handleButtonPress = () => {
const {
blurTextBox,
fileCount,
maxFileCount,
onShowFileMaxWarning,
} = this.props;
if (fileCount === maxFileCount) {
onShowFileMaxWarning();
EventEmitter.emit(MAX_FILE_COUNT_WARNING);
return;
}
blurTextBox();
EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT);
this.attachFileFromLibrary();
};

View file

@ -3,14 +3,18 @@
import {connect} from 'react-redux';
import {canUploadFilesOnMobile} from '@mm-redux/selectors/entities/general';
import {canUploadFilesOnMobile, getConfig} from '@mm-redux/selectors/entities/general';
import {getAllowedServerMaxFileSize} from '@utils/file';
import QuickActions from './quick_actions';
function mapStateToProps(state) {
const config = getConfig(state);
return {
canUploadFiles: canUploadFilesOnMobile(state),
maxFileSize: getAllowedServerMaxFileSize(config),
};
}
export default connect(mapStateToProps)(QuickActions);
export default connect(mapStateToProps, null, null, {forwardRef: true})(QuickActions);

View file

@ -5,36 +5,27 @@ 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 {MAX_FILE_COUNT, UPLOAD_FILES} 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) {
@ -42,8 +33,8 @@ export default class QuickActions extends PureComponent {
this.state = {
inputValue: '',
atDisabled: props.readonly,
slashDisabled: props.readonly,
atDisabled: false,
slashDisabled: false,
};
}
@ -51,76 +42,58 @@ export default class QuickActions extends PureComponent {
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;
const atDisabled = inputValue[inputValue.length - 1] === '@';
const slashDisabled = inputValue.length > 0;
this.setState({atDisabled, slashDisabled, inputValue});
};
onShowFileMaxWarning = () => {
EventEmitter.emit('fileMaxWarning');
};
handleOnTextChange = (newValue) => {
this.handleInputEvent(newValue);
this.props.onTextChange(newValue);
}
handleUploadFiles(files) {
EventEmitter.emit(UPLOAD_FILES, files);
}
render() {
const {
blurTextBox,
canUploadFiles,
canSend,
fileCount,
onSend,
onTextChange,
onShowFileMaxWarning,
readonly,
theme,
onUploadFiles,
} = this.props;
const uploadProps = {
blurTextBox,
disabled: !canUploadFiles || readonly,
disabled: !canUploadFiles,
fileCount,
maxFileCount: MAX_FILE_COUNT,
onShowFileMaxWarning,
theme,
onUploadFiles,
onUploadFiles: this.handleUploadFiles,
};
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}
<View style={style.quickActionsContainer}>
<InputAction
disabled={this.state.atDisabled}
inputType='at'
onTextChange={this.handleOnTextChange}
theme={theme}
value={this.state.inputValue}
/>
<InputAction
disabled={this.state.slashDisabled}
inputType='slash'
onTextChange={this.handleOnTextChange}
theme={theme}
/>
<FileAction {...uploadProps}/>
<ImageAction {...uploadProps}/>
<CameraAction {...uploadProps}/>
</View>
);
}

View file

@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostDraft ReadOnly Should match snapshot 1`] = `
<RCTSafeAreaView
emulateUnlessSupported={true}
style={
Object {
"backgroundColor": "rgba(61,60,64,0.04)",
}
}
>
<View
style={
Object {
"alignItems": "center",
"borderTopColor": "rgba(61,60,64,0.2)",
"borderTopWidth": 1,
"flexDirection": "row",
"height": 50,
"paddingHorizontal": 12,
}
}
>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
"lineHeight": 20,
"marginLeft": 9,
"opacity": 0.56,
}
}
>
This channel is read-only.
</Text>
</View>
</RCTSafeAreaView>
`;

View file

@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode} from 'react';
import {SafeAreaView, View} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome5';
import FormattedText from '@components/formatted_text';
import type {Theme} from '@mm-redux/types/preferences';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
interface ReadOnlyProps {
theme: Theme;
}
const ReadOnlyChannnel = ({theme}: ReadOnlyProps): ReactNode => {
const style = getStyle(theme);
return (
<SafeAreaView style={style.background}>
<View style={style.container}>
<Icon
name='glasses'
style={style.icon}
color={theme.centerChannelColor}
/>
<FormattedText
id='mobile.create_post.read_only'
defaultMessage='This channel is read-only.'
style={style.text}
/>
</View>
</SafeAreaView>
);
};
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
background: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
},
container: {
alignItems: 'center',
borderTopColor: changeOpacity(theme.centerChannelColor, 0.20),
borderTopWidth: 1,
flexDirection: 'row',
height: 50,
paddingHorizontal: 12,
},
icon: {
fontSize: 20,
lineHeight: 22,
opacity: 0.56,
},
text: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20,
marginLeft: 9,
opacity: 0.56,
},
}));
export default ReadOnlyChannnel;

View file

@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import TestRenderer from 'react-test-renderer';
import {IntlProvider} from 'react-intl';
import Preferences from '@mm-redux/constants/preferences';
import ReadOnly from './index';
describe('PostDraft ReadOnly', () => {
test('Should match snapshot', () => {
const wrapper = renderWithIntl(
<ReadOnly
theme={Preferences.THEMES.default}
/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});
function renderWithIntl(component) {
return TestRenderer.create(
<IntlProvider locale='en'>
{component}
</IntlProvider>,
);
}

View file

@ -3,27 +3,32 @@
import {connect} from 'react-redux';
import {handleRemoveLastFile} from '@actions/views/file_upload';
import {handleRemoveLastFile, initUploadFiles} from '@actions/views/file_upload';
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getDimensions} from '@selectors/device';
import {checkForFileUploadingInChannel} from '@selectors/file';
import {getAllowedServerMaxFileSize} from '@utils/file';
import Uploads from './uploads';
function mapStateToProps(state, ownProps) {
const {deviceHeight} = getDimensions(state);
const channelId = getCurrentChannelId(state);
const config = getConfig(state);
return {
channelId,
channelIsLoading: state.views.channel.loading,
deviceHeight,
filesUploadingForCurrentChannel: checkForFileUploadingInChannel(state, channelId, ownProps.rootId),
maxFileSize: getAllowedServerMaxFileSize(config),
};
}
const mapDispatchToProps = {
handleRemoveLastFile,
initUploadFiles,
};
export default connect(mapStateToProps, mapDispatchToProps)(Uploads);

View file

@ -4,6 +4,7 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Alert,
BackHandler,
ScrollView,
Text,
@ -11,10 +12,14 @@ import {
Platform,
} from 'react-native';
import * as Animatable from 'react-native-animatable';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {intlShape} from 'react-intl';
import RNFetchBlob from 'rn-fetch-blob';
import FormattedText from '@components/formatted_text';
import {MAX_FILE_COUNT, MAX_FILE_COUNT_WARNING, UPLOAD_FILES, PASTE_FILES} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {getFormattedFileSize} from '@mm-redux/utils/file_utils';
import EphemeralStore from '@store/ephemeral_store';
import {makeStyleSheetFromTheme} from '@utils/theme';
import UploadItem from './upload_item';
@ -30,7 +35,10 @@ export default class Uploads extends PureComponent {
files: PropTypes.array.isRequired,
filesUploadingForCurrentChannel: PropTypes.bool.isRequired,
handleRemoveLastFile: PropTypes.func.isRequired,
initUploadFiles: PropTypes.func.isRequired,
maxFileSize: PropTypes.number.isRequired,
rootId: PropTypes.string,
screenId: PropTypes.string,
theme: PropTypes.object.isRequired,
};
@ -38,6 +46,10 @@ export default class Uploads extends PureComponent {
files: [],
};
static contextTypes = {
intl: intlShape,
};
state = {
fileSizeWarning: null,
showFileMaxWarning: false,
@ -48,8 +60,9 @@ export default class Uploads extends PureComponent {
containerRef = React.createRef();
componentDidMount() {
EventEmitter.on('fileMaxWarning', this.handleFileMaxWarning);
EventEmitter.on('fileSizeWarning', this.handleFileSizeWarning);
EventEmitter.on(MAX_FILE_COUNT_WARNING, this.handleFileMaxWarning);
EventEmitter.on(UPLOAD_FILES, this.handleUploadFiles);
EventEmitter.on(PASTE_FILES, this.handlePasteFiles);
if (this.props.files.length) {
this.showOrHideContainer();
@ -61,8 +74,9 @@ export default class Uploads extends PureComponent {
}
componentWillUnmount() {
EventEmitter.off('fileMaxWarning', this.handleFileMaxWarning);
EventEmitter.off('fileSizeWarning', this.handleFileSizeWarning);
EventEmitter.off(MAX_FILE_COUNT_WARNING, this.handleFileMaxWarning);
EventEmitter.off(UPLOAD_FILES, this.handleUploadFiles);
EventEmitter.off(PASTE_FILES, this.handlePasteFiles);
if (Platform.OS === 'android') {
BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack);
@ -117,14 +131,75 @@ export default class Uploads extends PureComponent {
}
};
handleFileSizeWarning = (message) => {
handleFileSizeWarning = () => {
if (this.errorRef.current) {
if (message) {
this.setState({fileSizeWarning: message.replace(': ', ':\n')});
this.makeErrorVisible(true, 40);
} else {
const {formatMessage} = this.context.intl;
const message = formatMessage({
id: 'file_upload.fileAbove',
defaultMessage: 'Files must be less than {max}',
}, {
max: getFormattedFileSize({size: this.props.maxFileSize}),
});
this.setState({fileSizeWarning: message});
this.makeErrorVisible(true, 20);
setTimeout(() => {
this.makeErrorVisible(false, 20);
}, 5000);
}
};
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.handleFileMaxWarning();
return;
}
const largeFile = files.find((image) => image.fileSize > maxFileSize);
if (largeFile) {
this.handleFileSizeWarning();
return;
}
this.handleUploadFiles(files);
}
};
handleUploadFiles = async (files) => {
let exceed = false;
const totalFiles = files.length;
let i = 0;
while (i < totalFiles) {
const file = files[i];
if (!file.fileSize | !file.fileName) {
const path = (file.path || file.uri).replace('file://', '');
// eslint-disable-next-line no-await-in-loop
const fileInfo = await RNFetchBlob.fs.stat(path);
file.fileSize = fileInfo.size;
file.fileName = fileInfo.filename;
}
if (file.fileSize > this.props.maxFileSize) {
exceed = true;
break;
}
i++;
}
if (exceed) {
this.handleFileSizeWarning();
} else {
this.props.initUploadFiles(files, this.props.rootId);
}
};
@ -154,6 +229,28 @@ export default class Uploads extends PureComponent {
}
}
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',
}),
},
],
);
};
render() {
const {fileSizeWarning, showFileMaxWarning} = this.state;
const {theme, files} = this.props;

View file

@ -24,7 +24,6 @@ describe('profile_picture_button', () => {
nickname: 'Dragon',
position: 'position',
},
blurTextBox: jest.fn(),
maxFileSize: 20 * 1024 * 1024,
uploadFiles: jest.fn(),
};

View file

@ -17,7 +17,7 @@ export default class ProgressiveImage extends PureComponent {
static propTypes = {
isBackgroundImage: PropTypes.bool,
children: CustomPropTypes.Children,
defaultSource: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), // this should be provided by the component
defaultSource: PropTypes.oneOfType([PropTypes.string, PropTypes.object, PropTypes.number]), // this should be provided by the component
imageUri: PropTypes.string,
imageStyle: CustomPropTypes.Style,
onError: PropTypes.func,

View file

@ -20,7 +20,7 @@ describe('ProgressiveImage', () => {
resizeMode: 'contain',
theme: Preferences.THEMES.default,
tintDefaultSource: false,
defaultSource: null,
defaultSource: undefined,
};
const wrapper = shallow(<ProgressiveImage {...baseProps}/>);

View file

@ -12,3 +12,7 @@ export const MAX_FILE_COUNT = 5;
export const MAX_MESSAGE_LENGTH_FALLBACK = 4000;
export const TYPING_VISIBLE = 'typingVisible';
export const TYPING_HEIGHT = 18;
export const MAX_FILE_COUNT_WARNING = 'onMaxFileCountWarning';
export const UPLOAD_FILES = 'onUploadFiles';
export const PASTE_FILES = 'onPasteFiles';
export const UPDATE_NATIVE_SCROLLVIEW = 'onUpdateNativeScrollView';

View file

@ -147,6 +147,9 @@ export const getMyChannelMember: (b: GlobalState, a: string) => ChannelMembershi
export const getCurrentChannelStats: (a: GlobalState) => ChannelStats = createSelector(getAllChannelStats, getCurrentChannelId, (allChannelStats: RelationOneToOne<Channel, ChannelStats>, currentChannelId: string): ChannelStats => {
return allChannelStats[currentChannelId];
});
export const getChannelStats: (a: GlobalState, b: string) => ChannelStats = createSelector(getAllChannelStats, (state: GlobalState, channelId: string): string => channelId, (allChannelStats: RelationOneToOne<Channel, ChannelStats>, channelId: string): ChannelStats => {
return allChannelStats[channelId];
});
export const isCurrentChannelFavorite: (a: GlobalState) => boolean = createSelector(getMyPreferences, getCurrentChannelId, (preferences: {
[x: string]: PreferenceType;
}, channelId: string): boolean => isFavoriteChannel(preferences, channelId));

View file

@ -3,7 +3,6 @@
import React from 'react';
import {View} from 'react-native';
import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view';
import LocalConfig from '@assets/config';
import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from '@components/autocomplete';
@ -93,19 +92,15 @@ export default class ChannelIOS extends ChannelBase {
{component}
</SafeAreaView>
{renderDraftArea &&
<KeyboardTrackingView
ref={this.keyboardTracker}
scrollViewNativeID={currentChannelId}
accessoriesContainerID={ACCESSORIES_CONTAINER_NATIVE_ID}
>
<PostDraft
accessoriesContainerID={ACCESSORIES_CONTAINER_NATIVE_ID}
cursorPositionEvent={CHANNEL_POST_TEXTBOX_CURSOR_CHANGE}
valueEvent={CHANNEL_POST_TEXTBOX_VALUE_CHANGE}
ref={this.postDraft}
screenId={this.props.componentId}
registerTypingAnimation={this.registerTypingAnimation}
screenId={this.props.componentId}
scrollViewNativeID={currentChannelId}
valueEvent={CHANNEL_POST_TEXTBOX_VALUE_CHANGE}
/>
</KeyboardTrackingView>
}
</>
);

View file

@ -9,8 +9,7 @@ import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import {showModal, showModalOverCurrentContext} from '@actions/navigation';
import LocalConfig from '@assets/config';
import {NavigationTypes} from '@constants';
import {TYPING_VISIBLE} from '@constants/post_draft';
import {UPDATE_NATIVE_SCROLLVIEW, TYPING_VISIBLE} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
import EphemeralStore from '@store/ephemeral_store';
import {unsupportedServer} from '@utils/supported_server';
@ -56,7 +55,6 @@ export default class ChannelBase extends PureComponent {
super(props);
this.postDraft = React.createRef();
this.keyboardTracker = React.createRef();
this.state = {
channelsRequestFailed: false,
@ -80,7 +78,7 @@ export default class ChannelBase extends PureComponent {
showTermsOfService,
skipMetrics,
} = this.props;
EventEmitter.on(NavigationTypes.BLUR_POST_DRAFT, this.blurPostDraft);
EventEmitter.on('leave_team', this.handleLeaveTeam);
EventEmitter.on(TYPING_VISIBLE, this.runTypingAnimations);
@ -151,7 +149,6 @@ export default class ChannelBase extends PureComponent {
}
componentWillUnmount() {
EventEmitter.off(NavigationTypes.BLUR_POST_DRAFT, this.blurPostDraft);
EventEmitter.off('leave_team', this.handleLeaveTeam);
EventEmitter.off(TYPING_VISIBLE, this.runTypingAnimations);
}
@ -172,12 +169,6 @@ export default class ChannelBase extends PureComponent {
).start();
}
blurPostDraft = () => {
if (this.postDraft?.current) {
this.postDraft.current.blurTextBox();
}
};
goToChannelInfo = preventDoubleTap(() => {
const {intl} = this.context;
const {theme} = this.props;
@ -296,9 +287,7 @@ export default class ChannelBase extends PureComponent {
};
updateNativeScrollView = () => {
if (this.keyboardTracker?.current) {
this.keyboardTracker.current.resetScrollView(this.props.currentChannelId);
}
EventEmitter.emit(UPDATE_NATIVE_SCROLLVIEW, this.props.currentChannelId);
};
render() {

View file

@ -14,7 +14,6 @@ import {Client4} from '@mm-redux/client';
import {getFormattedFileSize} from '@mm-redux/utils/file_utils';
import {buildFileUploadData, encodeHeaderURIStringToUTF8} from 'app/utils/file';
import {emptyFunction} from 'app/utils/general';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {t} from 'app/utils/i18n';
@ -294,14 +293,13 @@ export default class EditProfile extends PureComponent {
});
};
onShowFileSizeWarning = (filename) => {
onShowFileSizeWarning = () => {
const {formatMessage} = this.context.intl;
const fileSizeWarning = formatMessage({
id: 'file_upload.fileAbove',
defaultMessage: 'File above {max}MB cannot be uploaded: {filename}',
defaultMessage: 'Files must be less than {max}',
}, {
max: getFormattedFileSize({size: MAX_SIZE}),
filename,
});
Alert.alert(fileSizeWarning);
@ -541,7 +539,6 @@ export default class EditProfile extends PureComponent {
<ProfilePictureButton
currentUser={currentUser}
theme={theme}
blurTextBox={emptyFunction}
browseFileTypes={DocumentPicker.types.images}
canTakeVideo={false}
canBrowseVideoLibrary={false}

View file

@ -62,19 +62,16 @@ exports[`thread should match snapshot, has root post 1`] = `
</View>
</React.Fragment>
</Connect(SafeAreaIos)>
<KeyboardTrackingView
<Connect(PostDraft)
accessoriesContainerID="threadAccessoriesContainer"
channelId="channel_id"
channelIsArchived={false}
cursorPositionEvent="onThreadTextBoxCursorChange"
registerTypingAnimation={[Function]}
rootId="root_id"
scrollViewNativeID="threadPostList"
>
<Connect(PostDraft)
channelId="channel_id"
channelIsArchived={false}
cursorPositionEvent="onThreadTextBoxCursorChange"
registerTypingAnimation={[Function]}
rootId="root_id"
valueEvent="onThreadTextBoxValueChange"
/>
</KeyboardTrackingView>
valueEvent="onThreadTextBoxValueChange"
/>
</React.Fragment>
`;

View file

@ -3,7 +3,6 @@
import React from 'react';
import {Animated, 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';
@ -71,21 +70,18 @@ export default class ThreadIOS extends ThreadBase {
);
postDraft = (
<KeyboardTrackingView
scrollViewNativeID={SCROLLVIEW_NATIVE_ID}
<PostDraft
accessoriesContainerID={ACCESSORIES_CONTAINER_NATIVE_ID}
>
<PostDraft
channelId={channelId}
channelIsArchived={channelIsArchived}
cursorPositionEvent={THREAD_POST_TEXTBOX_CURSOR_CHANGE}
ref={this.postDraft}
rootId={rootId}
screenId={this.props.componentId}
valueEvent={THREAD_POST_TEXTBOX_VALUE_CHANGE}
registerTypingAnimation={this.registerTypingAnimation}
/>
</KeyboardTrackingView>
channelId={channelId}
channelIsArchived={channelIsArchived}
cursorPositionEvent={THREAD_POST_TEXTBOX_CURSOR_CHANGE}
ref={this.postDraft}
rootId={rootId}
screenId={this.props.componentId}
scrollViewNativeID={SCROLLVIEW_NATIVE_ID}
valueEvent={THREAD_POST_TEXTBOX_VALUE_CHANGE}
registerTypingAnimation={this.registerTypingAnimation}
/>
);
} else {
content = (

View file

@ -3,19 +3,22 @@
import {createSelector} from 'reselect';
export const checkForFileUploadingInChannel = createSelector(
(state, channelId, rootId) => {
if (rootId) {
return state.views.thread.drafts[rootId];
}
import {selectDraft} from './views';
return state.views.channel.drafts[channelId];
},
export const selectFilesFromDraft = createSelector(
selectDraft,
(draft) => {
if (!draft || !draft.files) {
return false;
return [];
}
return draft.files.some((f) => f.loading);
return draft.files;
},
);
export const checkForFileUploadingInChannel = createSelector(
selectFilesFromDraft,
(files) => {
return files.some((f) => f.loading);
},
);

View file

@ -10,6 +10,14 @@ const emptyDraft = {
files: [],
};
export function selectDraft(state, channelId, rootId) {
if (rootId) {
return state.views.thread.drafts[rootId];
}
return state.views.channel.drafts[channelId];
}
function getChannelDrafts(state) {
return state.views.channel.drafts;
}

238
app/utils/draft.js Normal file
View file

@ -0,0 +1,238 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Alert} from 'react-native';
import {AT_MENTION_REGEX_GLOBAL, CODE_REGEX} from '@constants/autocomplete';
import {NOTIFY_ALL_MEMBERS} from '@constants/view';
import {General} from '@mm-redux/constants';
export function alertAttachmentFail(formatMessage, accept, cancel) {
Alert.alert(
formatMessage({
id: 'mobile.post_textbox.uploadFailedTitle',
defaultMessage: 'Attachment failure',
}),
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: formatMessage({id: 'mobile.channel_info.alertNo', defaultMessage: 'No'}),
onPress: cancel,
}, {
text: formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}),
onPress: accept,
}],
);
}
export function alertChannelWideMention(formatMessage, notifyAllMessage, accept, cancel) {
Alert.alert(
formatMessage({
id: 'mobile.post_textbox.entire_channel.title',
defaultMessage: 'Confirm sending notifications to entire channel',
}),
notifyAllMessage,
[
{
text: formatMessage({
id: 'mobile.post_textbox.entire_channel.cancel',
defaultMessage: 'Cancel',
}),
onPress: cancel,
},
{
text: formatMessage({
id: 'mobile.post_textbox.entire_channel.confirm',
defaultMessage: 'Confirm',
}),
onPress: accept,
},
],
);
}
export function alertSendToGroups(formatMessage, notifyAllMessage, accept, cancel) {
Alert.alert(
formatMessage({
id: 'mobile.post_textbox.groups.title',
defaultMessage: 'Confirm sending notifications to groups',
}),
notifyAllMessage,
[
{
text: formatMessage({
id: 'mobile.post_textbox.entire_channel.cancel',
defaultMessage: 'Cancel',
}),
onPress: cancel,
},
{
text: formatMessage({
id: 'mobile.post_textbox.entire_channel.confirm',
defaultMessage: 'Confirm',
}),
onPress: accept,
},
],
);
}
export function alertSlashCommandFailed(formatMessage, error) {
Alert.alert(
formatMessage({
id: 'mobile.commands.error_title',
defaultMessage: 'Error Executing Command',
}),
error,
);
}
export function buildChannelWideMentionMessage(formatMessage, membersCount, isTimezoneEnabled, channelTimezoneCount) {
let notifyAllMessage = '';
if (isTimezoneEnabled && channelTimezoneCount) {
notifyAllMessage = (
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 = (
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,
},
)
);
}
return notifyAllMessage;
}
export function buildGroupMentionsMessage(formatMessage, groupMentions, memberNotifyCount, channelTimezoneCount) {
let notifyAllMessage = '';
if (groupMentions.length === 1) {
if (channelTimezoneCount > 0) {
notifyAllMessage = (
formatMessage(
{
id: 'mobile.post_textbox.one_group.message.with_timezones',
defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?',
},
{
mention: groupMentions[0],
totalMembers: memberNotifyCount,
timezones: channelTimezoneCount,
},
)
);
} else {
notifyAllMessage = (
formatMessage(
{
id: 'mobile.post_textbox.one_group.message.without_timezones',
defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?',
},
{
mention: groupMentions[0],
totalMembers: memberNotifyCount,
},
)
);
}
} else if (channelTimezoneCount > 0) {
notifyAllMessage = (
formatMessage(
{
id: 'mobile.post_textbox.multi_group.message.with_timezones',
defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?',
},
{
mentions: groupMentions.slice(0, -1).join(', '),
finalMention: groupMentions[groupMentions.length - 1],
totalMembers: memberNotifyCount,
timezones: channelTimezoneCount,
},
)
);
} else {
notifyAllMessage = (
formatMessage(
{
id: 'mobile.post_textbox.multi_group.message.without_timezones',
defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?',
},
{
mentions: groupMentions.slice(0, -1).join(', '),
finalMention: groupMentions[groupMentions.length - 1],
totalMembers: memberNotifyCount,
},
)
);
}
return notifyAllMessage;
}
export const getStatusFromSlashCommand = (message) => {
const tokens = message.split(' ');
if (tokens.length > 0) {
return tokens[0].substring(1);
}
return '';
};
export const groupsMentionedInText = (groupsWithAllowReference, text) => {
const groups = [];
if (groupsWithAllowReference.size > 0) {
const textWithoutCode = text.replace(CODE_REGEX, '');
const mentions = textWithoutCode.match(AT_MENTION_REGEX_GLOBAL) || [];
mentions.forEach((mention) => {
const group = groupsWithAllowReference.get(mention);
if (group) {
groups.push(group);
}
});
}
return groups;
};
export const isStatusSlashCommand = (command) => {
return command === General.ONLINE || command === General.AWAY ||
command === General.DND || command === General.OFFLINE;
};
export const mapGroupMentions = (channelMemberCountsByGroup, groupMentions) => {
let memberNotifyCount = 0;
let channelTimezoneCount = 0;
const groupMentionsSet = new Set();
groupMentions.
forEach((group) => {
const mappedValue = channelMemberCountsByGroup[group.id];
if (mappedValue?.channel_member_count > NOTIFY_ALL_MEMBERS && mappedValue?.channel_member_count > memberNotifyCount) {
memberNotifyCount = mappedValue.channel_member_count;
channelTimezoneCount = mappedValue.channel_member_timezones_count;
}
groupMentionsSet.add(`@${group.name}`);
});
return {groupMentionsSet, memberNotifyCount, channelTimezoneCount};
};
export const textContainsAtAllAtChannel = (text) => {
const textWithoutCode = text.replace(CODE_REGEX, '');
return (/(?:\B|\b_+)@(channel|all)(?!(\.|-|_)*[^\W_])/i).test(textWithoutCode);
};

160
app/utils/draft.test.js Normal file
View file

@ -0,0 +1,160 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import assert from 'assert';
import * as DraftUtils from './draft';
describe('DraftUtils', () => {
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 containsAtChannel = DraftUtils.textContainsAtAllAtChannel(data.text);
assert.equal(containsAtChannel, data.result, data.text);
}
});
});

View file

@ -79,7 +79,7 @@
"edit_post.editPost": "Edit the post...",
"edit_post.save": "Save",
"file_attachment.download": "Download",
"file_upload.fileAbove": "File above {max} cannot be uploaded: {filename}",
"file_upload.fileAbove": "Files must be less than {max}",
"get_post_link_modal.title": "Copy Link",
"integrations.add": "Add",
"intro_messages.anyMember": " Any member can join and read this channel.",

View file

@ -27,7 +27,7 @@ import {Preferences} from '@mm-redux/constants';
import {getFormattedFileSize, lookupMimeType} from '@mm-redux/utils/file_utils';
import Loading from '@components/loading';
import PaperPlane from '@components/post_draft/quick_actions/send_action/paper_plane';
import PaperPlane from '@components/post_draft/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';

View file

@ -110,6 +110,9 @@ jest.doMock('react-native', () => {
}, ReactNative);
});
jest.mock('react-native-vector-icons/MaterialCommunityIcons');
jest.mock('react-native-vector-icons/FontAwesome5');
jest.mock('react-native/Libraries/Animated/src/NativeAnimatedHelper');
jest.mock('../node_modules/react-native/Libraries/EventEmitter/NativeEventEmitter');

View file

@ -34,6 +34,7 @@
"paths": {
"@actions/*": ["app/actions/*"],
"@assets/*": ["dist/assets/*"],
"@components/*": ["app/components/*"],
"@constants/*": ["app/constants/*"],
"@constants": ["app/constants/index"],
"@i18n": ["app/i18n/index"],