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:
parent
24f6d2df92
commit
48f1875cf1
51 changed files with 2422 additions and 1335 deletions
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
exports[`profile_picture_button should match snapshot 1`] = `
|
||||
<AttachmentButton
|
||||
blurTextBox={[MockFunction]}
|
||||
browseFileTypes="public.item"
|
||||
canBrowseFiles={true}
|
||||
canBrowsePhotoLibrary={true}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ describe('AttachmentButton', () => {
|
|||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
theme: Preferences.THEMES.default,
|
||||
blurTextBox: jest.fn(),
|
||||
maxFileSize: 10,
|
||||
uploadFiles: jest.fn(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
661
app/components/post_draft/__snapshots__/post_draft.test.js.snap
Normal file
661
app/components/post_draft/__snapshots__/post_draft.test.js.snap
Normal 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>
|
||||
`;
|
||||
514
app/components/post_draft/draft_input/draft_input.js
Normal file
514
app/components/post_draft/draft_input/draft_input.js
Normal 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),
|
||||
},
|
||||
};
|
||||
});
|
||||
194
app/components/post_draft/draft_input/draft_input.test.js
Normal file
194
app/components/post_draft/draft_input/draft_input.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
104
app/components/post_draft/draft_input/index.js
Normal file
104
app/components/post_draft/draft_input/index.js
Normal 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);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: '@channel',
|
||||
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>,
|
||||
);
|
||||
}
|
||||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
62
app/components/post_draft/read_only/index.tsx
Normal file
62
app/components/post_draft/read_only/index.tsx
Normal 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;
|
||||
30
app/components/post_draft/read_only/read_only.test.js
Normal file
30
app/components/post_draft/read_only/read_only.test.js
Normal 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>,
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ describe('profile_picture_button', () => {
|
|||
nickname: 'Dragon',
|
||||
position: 'position',
|
||||
},
|
||||
blurTextBox: jest.fn(),
|
||||
maxFileSize: 20 * 1024 * 1024,
|
||||
uploadFiles: jest.fn(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe('ProgressiveImage', () => {
|
|||
resizeMode: 'contain',
|
||||
theme: Preferences.THEMES.default,
|
||||
tintDefaultSource: false,
|
||||
defaultSource: null,
|
||||
defaultSource: undefined,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<ProgressiveImage {...baseProps}/>);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
238
app/utils/draft.js
Normal 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
160
app/utils/draft.test.js
Normal 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: '@channel',
|
||||
result: false,
|
||||
},
|
||||
]) {
|
||||
const containsAtChannel = DraftUtils.textContainsAtAllAtChannel(data.text);
|
||||
assert.equal(containsAtChannel, data.result, data.text);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue