[MM-16263] UI/UX Improvements to the mobile post draft area (#3807)

* Adding base button functionality

Moving file upload previews to be under textbox

* Ensuring textbox is scrollable when in landscape mode

* Updated image picker to use mixed camera option

* Added unit tests, fixed other tests affected by dependency update

* Updated patch for react-native-image-picker to 1.1.0

* Fixing incorrect import of DocumentPicker

* MM-20989: Ensuring keyboard doesn't dismiss while submitting post (#3758)

* Ensuring keyboard doesn't dismiss while submitting post

* Update snapshot

* Preventing the @ icon from being repeatedly tappable (#3777)

* Fix snapshot from merge

* MM-21736 Select/Take images and videos for Android

* MM-21737 Fix attachment error message position on iOS

* Remove FileUploadPreview from the iOS Thread screen

* Fix android camera permissions

* Fix post input box sizing and disable scrollview

* Fix iOS photo gallery videos

Co-authored-by: Andre Vasconcelos <andre.onogoro@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Amit Uttam 2020-01-16 22:17:03 -03:00 committed by Elias Nahum
parent ac048428cb
commit ef0274cad8
28 changed files with 1373 additions and 298 deletions

View file

@ -16,11 +16,11 @@ exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = `
Object {
"alignItems": "center",
"backgroundColor": "#166de0",
"borderRadius": 18,
"borderRadius": 4,
"height": 28,
"justifyContent": "center",
"paddingLeft": 3,
"width": 28,
"width": 72,
},
Object {
"backgroundColor": "rgba(22,109,224,0.3)",
@ -54,11 +54,11 @@ exports[`SendButton should match snapshot 1`] = `
Object {
"alignItems": "center",
"backgroundColor": "#166de0",
"borderRadius": 18,
"borderRadius": 4,
"height": 28,
"justifyContent": "center",
"paddingLeft": 3,
"width": 28,
"width": 72,
}
}
>
@ -88,11 +88,11 @@ exports[`SendButton should render theme backgroundColor 1`] = `
Object {
"alignItems": "center",
"backgroundColor": "#166de0",
"borderRadius": 18,
"borderRadius": 4,
"height": 28,
"justifyContent": "center",
"paddingLeft": 3,
"width": 28,
"width": 72,
}
}
>

View file

@ -14,6 +14,9 @@ import {PermissionTypes} from 'app/constants';
import AttachmentButton from './index';
jest.mock('react-intl');
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn(),
}));
describe('AttachmentButton', () => {
const formatMessage = jest.fn();

View file

@ -186,6 +186,12 @@ const style = StyleSheet.create({
smallImageOverlay: {
...StyleSheet.absoluteFill,
justifyContent: 'center',
borderRadius: 4,
},
loaderContainer: {
position: 'absolute',
height: '100%',
width: '100%',
alignItems: 'center',
},
singleSmallImageWrapper: {

View file

@ -234,6 +234,7 @@ export default class FileUploadItem extends PureComponent {
}
</View>
<FileUploadRemove
theme={this.props.theme}
channelId={channelId}
clientId={file.clientId}
onPress={this.handleRemoveFile}

View file

@ -4,11 +4,12 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
@ -20,7 +21,6 @@ export default class FileUploadPreview extends PureComponent {
static propTypes = {
channelId: PropTypes.string.isRequired,
channelIsLoading: PropTypes.bool,
deviceHeight: PropTypes.number.isRequired,
files: PropTypes.array.isRequired,
filesUploadingForCurrentChannel: PropTypes.bool.isRequired,
rootId: PropTypes.string,
@ -75,10 +75,10 @@ export default class FileUploadPreview extends PureComponent {
const {
channelIsLoading,
filesUploadingForCurrentChannel,
deviceHeight,
files,
} = this.props;
const {fileSizeWarning, showFileMaxWarning} = this.state;
const style = getStyleSheet(this.props.theme);
if (
!fileSizeWarning && !showFileMaxWarning &&
(channelIsLoading || (!files.length && !filesUploadingForCurrentChannel))
@ -87,8 +87,8 @@ export default class FileUploadPreview extends PureComponent {
}
return (
<View>
<View style={[style.container, {height: deviceHeight}]}>
<View style={style.previewContainer}>
<View style={style.fileContainer}>
<ScrollView
horizontal={true}
style={style.scrollView}
@ -97,6 +97,8 @@ export default class FileUploadPreview extends PureComponent {
>
{this.buildFilePreviews()}
</ScrollView>
</View>
<View style={style.errorContainer}>
{showFileMaxWarning && (
<FormattedText
style={style.warning}
@ -115,25 +117,34 @@ export default class FileUploadPreview extends PureComponent {
}
}
const style = StyleSheet.create({
container: {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
left: 0,
bottom: 0,
position: 'absolute',
width: '100%',
},
scrollView: {
flex: 1,
marginBottom: 10,
},
scrollViewContent: {
alignItems: 'flex-end',
marginLeft: 14,
},
warning: {
color: 'white',
marginLeft: 14,
marginBottom: 10,
},
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
fileContainer: {
display: 'flex',
flexDirection: 'row',
},
errorContainer: {
height: 18,
},
previewContainer: {
display: 'flex',
flexDirection: 'column',
},
scrollView: {
flex: 1,
marginBottom: 10,
},
scrollViewContent: {
alignItems: 'flex-end',
marginLeft: 14,
},
warning: {
color: theme.errorTextColor,
marginLeft: 14,
marginBottom: Platform.select({
android: 14,
ios: 0,
}),
},
};
});

View file

@ -3,8 +3,9 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Platform, StyleSheet} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import {Platform} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
@ -14,6 +15,7 @@ export default class FileUploadRemove extends PureComponent {
clientId: PropTypes.string,
onPress: PropTypes.func.isRequired,
rootId: PropTypes.string,
theme: PropTypes.object.isRequired,
};
handleOnPress = () => {
@ -23,6 +25,7 @@ export default class FileUploadRemove extends PureComponent {
};
render() {
const style = getStyleSheet(this.props.theme);
return (
<TouchableWithFeedback
style={style.removeButtonWrapper}
@ -30,9 +33,9 @@ export default class FileUploadRemove extends PureComponent {
type={'opacity'}
>
<Icon
name='md-close'
color='#fff'
size={18}
name='close-circle'
color={this.props.theme.centerChannelColor}
size={20}
style={style.removeButtonIcon}
/>
</TouchableWithFeedback>
@ -40,28 +43,27 @@ export default class FileUploadRemove extends PureComponent {
}
}
const style = StyleSheet.create({
removeButtonIcon: Platform.select({
ios: {
marginTop: 2,
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
removeButtonIcon: Platform.select({
ios: {
marginTop: 2,
},
}),
removeButtonWrapper: {
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
overflow: 'hidden',
elevation: 11,
top: 7,
right: 7,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: theme.centerChannelBg,
borderWidth: 2,
borderColor: theme.centerChannelBg,
},
android: {
marginLeft: 1,
},
}),
removeButtonWrapper: {
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
overflow: 'hidden',
elevation: 11,
top: 7,
right: 7,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#000',
borderWidth: 1,
borderColor: '#fff',
},
};
});

View file

@ -11,7 +11,7 @@ import {ViewTypes} from 'app/constants';
const {OnPasteEventManager} = NativeModules;
const OnPasteEventEmitter = new NativeEventEmitter(OnPasteEventManager);
export class PasteableTextInput extends React.Component {
export class PasteableTextInput extends React.PureComponent {
static propTypes = {
...TextInput.PropTypes,
onPaste: PropTypes.func,
@ -42,8 +42,8 @@ export class PasteableTextInput extends React.Component {
const {height} = event.nativeEvent.contentSize;
const {style} = this.props;
const {inputHeight} = this.state;
const newHeight = height > style.maxHeight ? inputHeight : height + ViewTypes.INPUT_VERTICAL_PADDING;
const transitionSpeed = height === ViewTypes.INPUT_LINE_HEIGHT ? 500 : 100;
const newHeight = Math.min(style.maxHeight, height + ViewTypes.INPUT_VERTICAL_PADDING);
const transitionSpeed = height === ViewTypes.INPUT_LINE_HEIGHT ? 500 : 1;
Animated.timing(inputHeight, {
toValue: newHeight,

View file

@ -20,60 +20,26 @@ exports[`PostTextBox should match, full snapshot 1`] = `
]
}
>
<AttachmentButton
blurTextBox={[Function]}
browseFileTypes="public.item"
canBrowseFiles={true}
canBrowsePhotoLibrary={true}
canBrowseVideoLibrary={true}
canTakePhoto={true}
canTakeVideo={true}
extraOptions={null}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
<ScrollView
contentContainerStyle={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
"alignItems": "stretch",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
<View
disableScrollViewPanResponder={true}
keyboardShouldPersistTaps="always"
overScrollMode="never"
pinchGestureEnabled={false}
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
style={
Array [
Object {
"alignItems": "stretch",
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"flexDirection": "column",
"marginLeft": 10,
"marginRight": 10,
},
]
@ -95,9 +61,8 @@ exports[`PostTextBox should match, full snapshot 1`] = `
style={
Object {
"color": "#3d3c40",
"flex": 1,
"fontSize": 14,
"maxHeight": 100,
"maxHeight": 150,
"paddingBottom": 8,
"paddingLeft": 12,
"paddingRight": 12,
@ -107,9 +72,209 @@ exports[`PostTextBox should match, full snapshot 1`] = `
underlineColorAndroid="transparent"
value=""
/>
<Fade
visible={false}
<Connect(FileUploadPreview)
files={Array []}
rootId=""
/>
<View
style={
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
<View
style={
Object {
"display": "flex",
"flexDirection": "row",
}
}
>
<TouchableOpacity
activeOpacity={0.2}
disabled={false}
onPress={[Function]}
style={
Object {
"paddingLeft": 10,
"paddingRight": 10,
}
}
>
<Icon
allowFontScaling={false}
color="#3d3c40"
name="at"
size={20}
/>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.2}
disabled={false}
onPress={[Function]}
style={
Object {
"paddingLeft": 10,
"paddingRight": 10,
}
}
>
<Image
source={
Object {
"testUri": "../../../dist/assets/images/icons/slash-forward-box.png",
}
}
style={
Array [
Object {
"height": 20,
"opacity": 1,
"tintColor": "#3d3c40",
"width": 20,
},
]
}
/>
</TouchableOpacity>
<FileUploadButton
blurTextBox={[Function]}
browseFileTypes="public.item"
canBrowseFiles={true}
canBrowsePhotoLibrary={true}
canBrowseVideoLibrary={true}
canTakePhoto={true}
canTakeVideo={true}
extraOptions={null}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
<ImageUploadButton
blurTextBox={[Function]}
browseFileTypes="public.item"
canBrowseFiles={true}
canBrowsePhotoLibrary={true}
canBrowseVideoLibrary={true}
canTakePhoto={true}
canTakeVideo={true}
extraOptions={null}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
<AttachmentButton
blurTextBox={[Function]}
canTakePhoto={true}
canTakeVideo={true}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
</View>
<SendButton
disabled={true}
handleSendMessage={[Function]}
@ -143,8 +308,8 @@ exports[`PostTextBox should match, full snapshot 1`] = `
}
}
/>
</Fade>
</View>
</View>
</ScrollView>
</View>
</React.Fragment>
`;

View file

@ -0,0 +1,207 @@
// 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 {intlShape} from 'react-intl';
import {
Alert,
Platform,
StyleSheet,
} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import DeviceInfo from 'react-native-device-info';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import ImagePicker from 'react-native-image-picker';
import Permissions from 'react-native-permissions';
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {PermissionTypes} from 'app/constants';
export default class AttachmentButton extends PureComponent {
static propTypes = {
validMimeTypes: PropTypes.array,
fileCount: PropTypes.number,
maxFileCount: PropTypes.number.isRequired,
maxFileSize: PropTypes.number.isRequired,
onShowFileMaxWarning: PropTypes.func,
onShowFileSizeWarning: PropTypes.func,
onShowUnsupportedMimeTypeWarning: PropTypes.func,
theme: PropTypes.object.isRequired,
uploadFiles: PropTypes.func.isRequired,
};
static defaultProps = {
validMimeTypes: [],
canTakePhoto: true,
canTakeVideo: true,
maxFileCount: 5,
};
static contextTypes = {
intl: intlShape.isRequired,
};
getPermissionDeniedMessage = () => {
const {formatMessage} = this.context.intl;
const applicationName = DeviceInfo.getApplicationName();
return {
title: formatMessage({
id: 'mobile.camera_photo_permission_denied_title',
defaultMessage: '{applicationName} would like to access your camera',
}, {applicationName}),
text: formatMessage({
id: 'mobile.camera_photo_permission_denied_description',
defaultMessage: 'Take photos and upload them to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your camera.',
}),
};
}
attachFileFromCamera = async () => {
const {formatMessage} = this.context.intl;
const {
fileCount,
maxFileCount,
onShowFileMaxWarning,
} = this.props;
const {title, text} = this.getPermissionDeniedMessage();
if (fileCount === maxFileCount) {
onShowFileMaxWarning();
return;
}
const options = {
quality: 0.8,
videoQuality: 'high',
noData: true,
mediaType: 'mixed',
storageOptions: {
cameraRoll: true,
waitUntilSaved: true,
},
permissionDenied: {
title,
text,
reTryTitle: formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
okTitle: formatMessage({id: 'mobile.permission_denied_dismiss', defaultMessage: 'Don\'t Allow'}),
},
};
const hasCameraPermission = await this.hasCameraPermission();
if (hasCameraPermission) {
ImagePicker.launchCamera(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
}
};
hasCameraPermission = async () => {
if (Platform.OS === 'ios') {
const {formatMessage} = this.context.intl;
let permissionRequest;
const targetSource = 'camera';
const hasPermissionToStorage = await Permissions.check(targetSource);
switch (hasPermissionToStorage) {
case PermissionTypes.UNDETERMINED:
permissionRequest = await Permissions.request(targetSource);
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
return false;
}
break;
case PermissionTypes.DENIED: {
const canOpenSettings = await Permissions.canOpenSettings();
let grantOption = null;
if (canOpenSettings) {
grantOption = {
text: formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
onPress: () => Permissions.openSettings(),
};
}
const {title, text} = this.getPermissionDeniedMessage();
Alert.alert(
title,
text,
[
grantOption,
{
text: formatMessage({
id: 'mobile.permission_denied_dismiss',
defaultMessage: 'Don\'t Allow',
}),
},
],
);
return false;
}
}
}
return true;
};
uploadFiles = 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.type) {
file.type = lookupMimeType(file.fileName);
}
const {validMimeTypes} = this.props;
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
this.props.onShowUnsupportedMimeTypeWarning();
} else if (file.fileSize > this.props.maxFileSize) {
this.props.onShowFileSizeWarning(file.fileName);
} else {
this.props.uploadFiles(files);
}
};
render() {
const {theme} = this.props;
return (
<TouchableWithFeedback
onPress={this.attachFileFromCamera}
style={style.buttonContainer}
type={'opacity'}
>
<MaterialCommunityIcons
color={theme.centerChannelColor}
name='camera-outline'
size={20}
/>
</TouchableWithFeedback>
);
}
}
const style = StyleSheet.create({
buttonContainer: {
paddingLeft: 10,
paddingRight: 10,
},
});

View file

@ -0,0 +1,204 @@
// 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 {intlShape} from 'react-intl';
import {
Alert,
NativeModules,
Platform,
StyleSheet,
} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import DeviceInfo from 'react-native-device-info';
import AndroidOpenSettings from 'react-native-android-open-settings';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import DocumentPicker from 'react-native-document-picker';
import Permissions from 'react-native-permissions';
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {PermissionTypes} from 'app/constants';
const ShareExtension = NativeModules.MattermostShare;
export default class FileUploadButton extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
browseFileTypes: PropTypes.string,
validMimeTypes: PropTypes.array,
fileCount: PropTypes.number,
maxFileCount: PropTypes.number.isRequired,
maxFileSize: PropTypes.number.isRequired,
onShowFileMaxWarning: PropTypes.func,
onShowFileSizeWarning: PropTypes.func,
onShowUnsupportedMimeTypeWarning: PropTypes.func,
theme: PropTypes.object.isRequired,
uploadFiles: PropTypes.func.isRequired,
};
static defaultProps = {
browseFileTypes: Platform.OS === 'ios' ? 'public.item' : '*/*',
validMimeTypes: [],
canBrowseFiles: true,
canBrowsePhotoLibrary: true,
canBrowseVideoLibrary: true,
canTakePhoto: true,
canTakeVideo: true,
maxFileCount: 5,
extraOptions: null,
};
static contextTypes = {
intl: intlShape.isRequired,
};
getPermissionDeniedMessage = () => {
const {formatMessage} = this.context.intl;
const applicationName = DeviceInfo.getApplicationName();
return {
title: formatMessage({
id: 'mobile.storage_permission_denied_title',
defaultMessage: '{applicationName} would like to access your files',
}, {applicationName}),
text: formatMessage({
id: 'mobile.storage_permission_denied_description',
defaultMessage: 'Upload files to your Mattermost instance. Open Settings to grant Mattermost Read and Write access to files on this device.',
}),
};
}
attachFileFromFiles = async () => {
const {browseFileTypes} = this.props;
const hasPermission = await this.hasStoragePermission();
if (hasPermission) {
try {
const res = await DocumentPicker.pick({type: [browseFileTypes]});
if (Platform.OS === 'android') {
// For android we need to retrieve the realPath in case the file being imported is from the cloud
const newUri = await ShareExtension.getFilePath(res.uri);
if (newUri.filePath) {
res.uri = newUri.filePath;
} else {
return;
}
}
// Decode file uri to get the actual path
res.uri = decodeURIComponent(res.uri);
this.uploadFiles([res]);
} catch (error) {
// Do nothing
}
}
};
hasStoragePermission = async () => {
if (Platform.OS === 'android') {
const {formatMessage} = this.context.intl;
let permissionRequest;
const hasPermissionToStorage = await Permissions.check('storage');
switch (hasPermissionToStorage) {
case PermissionTypes.UNDETERMINED:
permissionRequest = await Permissions.request('storage');
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
return false;
}
break;
case PermissionTypes.DENIED: {
const {title, text} = this.getPermissionDeniedMessage();
Alert.alert(
title,
text,
[
{
text: formatMessage({
id: 'mobile.permission_denied_dismiss',
defaultMessage: 'Don\'t Allow',
}),
},
{
text: formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
onPress: () => AndroidOpenSettings.appDetailsSettings(),
},
]
);
return false;
}
}
}
return true;
};
uploadFiles = 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.type) {
file.type = lookupMimeType(file.fileName);
}
const {validMimeTypes} = this.props;
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
this.props.onShowUnsupportedMimeTypeWarning();
} else if (file.fileSize > this.props.maxFileSize) {
this.props.onShowFileSizeWarning(file.fileName);
} else {
this.props.uploadFiles(files);
}
};
handleButtonPress = () => {
const {
fileCount,
maxFileCount,
onShowFileMaxWarning,
} = this.props;
if (fileCount === maxFileCount) {
onShowFileMaxWarning();
return;
}
this.props.blurTextBox();
this.attachFileFromFiles();
};
render() {
const {theme} = this.props;
return (
<TouchableWithFeedback
onPress={this.handleButtonPress}
style={style.buttonContainer}
type={'opacity'}
>
<MaterialCommunityIcons
color={theme.centerChannelColor}
name='file-document-outline'
size={20}
/>
</TouchableWithFeedback>
);
}
}
const style = StyleSheet.create({
buttonContainer: {
paddingLeft: 10,
paddingRight: 10,
},
});

View file

@ -0,0 +1,224 @@
// 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 {intlShape} from 'react-intl';
import {
Alert,
Platform,
StyleSheet,
} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import DeviceInfo from 'react-native-device-info';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import ImagePicker from 'react-native-image-picker';
import Permissions from 'react-native-permissions';
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {PermissionTypes} from 'app/constants';
export default class ImageUploadButton extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
validMimeTypes: PropTypes.array,
fileCount: PropTypes.number,
maxFileCount: PropTypes.number.isRequired,
maxFileSize: PropTypes.number.isRequired,
onShowFileMaxWarning: PropTypes.func,
onShowFileSizeWarning: PropTypes.func,
onShowUnsupportedMimeTypeWarning: PropTypes.func,
theme: PropTypes.object.isRequired,
uploadFiles: PropTypes.func.isRequired,
};
static defaultProps = {
browseFileTypes: Platform.OS === 'ios' ? 'public.item' : '*/*',
validMimeTypes: [],
canBrowseFiles: true,
canBrowsePhotoLibrary: true,
canBrowseVideoLibrary: true,
canTakePhoto: true,
canTakeVideo: true,
maxFileCount: 5,
extraOptions: null,
};
static contextTypes = {
intl: intlShape.isRequired,
};
getPermissionDeniedMessage = () => {
const {formatMessage} = this.context.intl;
const applicationName = DeviceInfo.getApplicationName();
if (Platform.OS === 'android') {
return {
title: formatMessage({
id: 'mobile.android.photos_permission_denied_title',
defaultMessage: '{applicationName} would like to access your photos',
}, {applicationName}),
text: formatMessage({
id: 'mobile.android.photos_permission_denied_description',
defaultMessage: 'Upload photos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your photo library.',
}),
};
}
return {
title: formatMessage({
id: 'mobile.ios.photos_permission_denied_title',
defaultMessage: '{applicationName} would like to access your photos',
}, {applicationName}),
text: formatMessage({
id: 'mobile.ios.photos_permission_denied_description',
defaultMessage: 'Upload photos and videos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your photo and video library.',
}),
};
}
attachFileFromLibrary = async () => {
const {formatMessage} = this.context.intl;
const {title, text} = this.getPermissionDeniedMessage();
const options = {
quality: 0.8,
mediaType: 'mixed',
noData: true,
permissionDenied: {
title,
text,
reTryTitle: formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
okTitle: formatMessage({id: 'mobile.permission_denied_dismiss', defaultMessage: 'Don\'t Allow'}),
},
};
const hasPhotoPermission = await this.hasPhotoPermission();
if (hasPhotoPermission) {
ImagePicker.launchImageLibrary(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
}
};
hasPhotoPermission = async () => {
if (Platform.OS === 'ios') {
const {formatMessage} = this.context.intl;
let permissionRequest;
const targetSource = 'photo';
const hasPermissionToStorage = await Permissions.check(targetSource);
switch (hasPermissionToStorage) {
case PermissionTypes.UNDETERMINED:
permissionRequest = await Permissions.request(targetSource);
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
return false;
}
break;
case PermissionTypes.DENIED: {
const canOpenSettings = await Permissions.canOpenSettings();
let grantOption = null;
if (canOpenSettings) {
grantOption = {
text: formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
onPress: () => Permissions.openSettings(),
};
}
const {title, text} = this.getPermissionDeniedMessage();
Alert.alert(
title,
text,
[
grantOption,
{
text: formatMessage({
id: 'mobile.permission_denied_dismiss',
defaultMessage: 'Don\'t Allow',
}),
},
],
);
return false;
}
}
}
return true;
};
uploadFiles = 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.type) {
file.type = lookupMimeType(file.fileName);
}
const {validMimeTypes} = this.props;
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
this.props.onShowUnsupportedMimeTypeWarning();
} else if (file.fileSize > this.props.maxFileSize) {
this.props.onShowFileSizeWarning(file.fileName);
} else {
this.props.uploadFiles(files);
}
};
handleButtonPress = () => {
const {
fileCount,
maxFileCount,
onShowFileMaxWarning,
} = this.props;
if (fileCount === maxFileCount) {
onShowFileMaxWarning();
return;
}
this.props.blurTextBox();
this.attachFileFromLibrary();
};
render() {
const {theme} = this.props;
return (
<TouchableWithFeedback
onPress={this.handleButtonPress}
style={style.buttonContainer}
type={'opacity'}
>
<MaterialCommunityIcons
color={theme.centerChannelColor}
name='image-outline'
size={20}
/>
</TouchableWithFeedback>
);
}
}
const style = StyleSheet.create({
buttonContainer: {
paddingLeft: 10,
paddingRight: 10,
},
});

View file

@ -4,7 +4,6 @@
import React from 'react';
import Autocomplete from 'app/components/autocomplete';
import FileUploadPreview from 'app/components/file_upload_preview';
import Typing from './components/typing';
import PostTextBoxBase from './post_textbox_base';
@ -16,7 +15,6 @@ export default class PostTextBoxAndroid extends PostTextBoxBase {
render() {
const {
deactivatedChannel,
files,
rootId,
} = this.props;
@ -29,10 +27,6 @@ export default class PostTextBoxAndroid extends PostTextBoxBase {
return (
<React.Fragment>
<Typing/>
<FileUploadPreview
files={files}
rootId={rootId}
/>
<Autocomplete
cursorPosition={cursorPosition}
maxHeight={Math.min(top - AUTOCOMPLETE_MARGIN, AUTOCOMPLETE_MAX_HEIGHT)}

View file

@ -2,20 +2,28 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Alert} from 'react-native';
import {Alert, Image} from 'react-native';
import assert from 'assert';
import {shallowWithIntl} from 'test/intl-test-helper';
import Preferences from 'mattermost-redux/constants/preferences';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import Fade from 'app/components/fade';
import SendButton from 'app/components/send_button';
import PasteableTextInput from 'app/components/pasteable_text_input';
import EphemeralStore from 'app/store/ephemeral_store';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import FileUploadButton from './components/fileUploadButton';
import ImageUploadButton from './components/imageUploadButton';
import CameraButton from './components/cameraButton';
import PostTextbox from './post_textbox.ios';
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn(),
}));
describe('PostTextBox', () => {
const baseProps = {
actions: {
@ -270,7 +278,6 @@ describe('PostTextBox', () => {
<PostTextbox {...baseProps}/>,
);
expect(wrapper.find(Fade).prop('visible')).toBe(false);
expect(wrapper.find(SendButton).prop('disabled')).toBe(true);
});
@ -284,7 +291,6 @@ describe('PostTextBox', () => {
<PostTextbox {...props}/>,
);
expect(wrapper.find(Fade).prop('visible')).toBe(true);
expect(wrapper.find(SendButton).prop('disabled')).toBe(true);
});
@ -298,7 +304,6 @@ describe('PostTextBox', () => {
<PostTextbox {...props}/>,
);
expect(wrapper.find(Fade).prop('visible')).toBe(true);
expect(wrapper.find(SendButton).prop('disabled')).toBe(false);
});
@ -312,7 +317,6 @@ describe('PostTextBox', () => {
<PostTextbox {...props}/>,
);
expect(wrapper.find(Fade).prop('visible')).toBe(true);
expect(wrapper.find(SendButton).prop('disabled')).toBe(false);
});
@ -328,7 +332,6 @@ describe('PostTextBox', () => {
wrapper.setState({sendingMessage: true});
expect(wrapper.find(Fade).prop('visible')).toBe(true);
expect(wrapper.find(SendButton).prop('disabled')).toBe(true);
});
});
@ -464,12 +467,58 @@ describe('PostTextBox', () => {
expect(baseProps.actions.initUploadFiles).not.toHaveBeenCalled();
});
test('should change state value on props change', () => {
test('should render all quick action icons', () => {
const wrapper = shallowWithIntl(<PostTextbox {...baseProps}/>);
expect(wrapper.state('value')).toEqual('');
wrapper.setProps({value: 'value', channelId: 'channel-id2'});
expect(wrapper.state('value')).toEqual('value');
// @ button
expect(wrapper.find(MaterialCommunityIcons).exists()).toBe(true);
// slash command button
expect(wrapper.find(Image).exists()).toBe(true);
expect(wrapper.find(FileUploadButton).exists()).toBe(true);
expect(wrapper.find(ImageUploadButton).exists()).toBe(true);
expect(wrapper.find(CameraButton).exists()).toBe(true);
});
test('should trigger text change when @ icon is tapped', () => {
const wrapper = shallowWithIntl(<PostTextbox {...baseProps}/>);
const instance = wrapper.instance();
instance.handleTextChange = jest.fn();
wrapper.find(MaterialCommunityIcons).parent().props().onPress();
expect(instance.handleTextChange).toHaveBeenCalledWith(`${instance.state.value}@`, true);
});
test('should disable slash icon if textbox value is NOT empty', () => {
const wrapper = shallowWithIntl(<PostTextbox {...baseProps}/>);
const instance = wrapper.instance();
instance.setState({value: 'Test'});
expect(wrapper.find(Image).parent().props().disabled).toBe(true);
});
test('should NOT render file upload icons when server forbids it', () => {
const props = {
...baseProps,
canUploadFiles: false,
};
const wrapper = shallowWithIntl(<PostTextbox {...props}/>);
expect(wrapper.find(MaterialCommunityIcons).exists()).toBe(true);
expect(wrapper.find(Image).exists()).toBe(true);
expect(wrapper.find(FileUploadButton).exists()).toBe(false);
expect(wrapper.find(ImageUploadButton).exists()).toBe(false);
expect(wrapper.find(CameraButton).exists()).toBe(false);
});
});
test('should change state value on props change', () => {
const wrapper = shallowWithIntl(<PostTextbox {...baseProps}/>);
expect(wrapper.state('value')).toEqual('');
wrapper.setProps({value: 'value', channelId: 'channel-id2'});
expect(wrapper.state('value')).toEqual('value');
});
});

View file

@ -8,28 +8,37 @@ import {
AppState,
BackHandler,
findNodeHandle,
Image,
InteractionManager,
Keyboard,
NativeModules,
Platform,
Text,
TouchableOpacity,
ScrollView,
View,
} from 'react-native';
import {intlShape} from 'react-intl';
import Button from 'react-native-button';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import slashForwardBoxIcon from 'assets/images/icons/slash-forward-box.png';
import {General, RequestStatus} from 'mattermost-redux/constants';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {getFormattedFileSize} from 'mattermost-redux/utils/file_utils';
import AttachmentButton from 'app/components/attachment_button';
import Fade from 'app/components/fade';
import FileUploadButton from './components/fileUploadButton';
import ImageUploadButton from './components/imageUploadButton';
import CameraButton from './components/cameraButton';
import FormattedMarkdownText from 'app/components/formatted_markdown_text';
import FormattedText from 'app/components/formatted_text';
import PasteableTextInput from 'app/components/pasteable_text_input';
import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing';
import SendButton from 'app/components/send_button';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_CONTENT_HEIGHT, MAX_FILE_COUNT} from 'app/constants/post_textbox';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_FILE_COUNT} from 'app/constants/post_textbox';
import {NOTIFY_ALL_MEMBERS} from 'app/constants/view';
import FileUploadPreview from 'app/components/file_upload_preview';
import EphemeralStore from 'app/store/ephemeral_store';
import {t} from 'app/utils/i18n';
import {confirmOutOfOfficeDisabled} from 'app/utils/status';
@ -159,6 +168,12 @@ export default class PostTextBoxBase extends PureComponent {
}
};
focus = () => {
if (this.input.current) {
this.input.current.focus();
}
}
numberOfTimezones = async () => {
const {data} = await this.props.actions.getChannelTimezones(this.props.channelId);
return data?.length || 0;
@ -224,37 +239,109 @@ export default class PostTextBoxBase extends PureComponent {
}
};
getAttachmentButton = () => {
getTextInputButton = (actionType) => {
const {channelIsReadOnly, theme} = this.props;
const style = getStyleSheet(theme);
let button = null;
const buttonStyle = [];
let iconColor = theme.centerChannelColor;
let isDisabled = false;
if (!channelIsReadOnly) {
switch (actionType) {
case 'at':
isDisabled = this.state.value[this.state.value.length - 1] === '@';
if (isDisabled) {
iconColor = changeOpacity(theme.centerChannelColor, 0.6);
}
button = (
<TouchableOpacity
disabled={isDisabled}
onPress={() => {
this.handleTextChange(`${this.state.value}@`, true);
this.focus();
}}
style={style.iconWrapper}
>
<MaterialCommunityIcons
color={iconColor}
name='at'
size={20}
/>
</TouchableOpacity>
);
break;
case 'slash':
isDisabled = this.state.value.length > 0;
buttonStyle.push(style.slashIcon);
if (isDisabled) {
buttonStyle.push(style.iconDisabled);
}
button = (
<TouchableOpacity
disabled={isDisabled}
onPress={() => {
this.handleTextChange('/', true);
this.focus();
}}
style={style.iconWrapper}
>
<Image
source={slashForwardBoxIcon}
style={buttonStyle}
/>
</TouchableOpacity>
);
break;
}
}
return button;
}
getMediaButton = (actionType) => {
const {canUploadFiles, channelIsReadOnly, files, maxFileSize, theme} = this.props;
let attachmentButton = null;
let button = null;
const props = {
blurTextBox: this.blur,
fileCount: files.length,
maxFileCount: MAX_FILE_COUNT,
onShowFileMaxWarning: this.onShowFileMaxWarning,
onShowFileSizeWarning: this.onShowFileSizeWarning,
uploadFiles: this.handleUploadFiles,
maxFileSize,
theme,
};
if (canUploadFiles && !channelIsReadOnly) {
attachmentButton = (
<AttachmentButton
blurTextBox={this.blur}
theme={theme}
fileCount={files.length}
maxFileSize={maxFileSize}
maxFileCount={MAX_FILE_COUNT}
onShowFileMaxWarning={this.onShowFileMaxWarning}
onShowFileSizeWarning={this.onShowFileSizeWarning}
uploadFiles={this.handleUploadFiles}
/>
);
switch (actionType) {
case 'file':
button = (
<FileUploadButton {...props}/>
);
break;
case 'image':
button = (
<ImageUploadButton {...props}/>
);
break;
case 'camera':
button = (
<CameraButton {...props}/>
);
}
}
return attachmentButton;
};
return button;
}
getInputContainerStyle = () => {
const {canUploadFiles, channelIsReadOnly, theme} = this.props;
const {channelIsReadOnly, theme} = this.props;
const style = getStyleSheet(theme);
const inputContainerStyle = [style.inputContainer];
if (!canUploadFiles) {
inputContainerStyle.push(style.inputContainerWithoutFileUpload);
}
if (channelIsReadOnly) {
inputContainerStyle.push(style.readonlyContainer);
}
@ -737,7 +824,7 @@ export default class PostTextBoxBase extends PureComponent {
renderTextBox = () => {
const {intl} = this.context;
const {channelDisplayName, channelIsArchived, channelIsLoading, channelIsReadOnly, theme, isLandscape} = this.props;
const {channelDisplayName, channelIsArchived, channelIsLoading, channelIsReadOnly, theme, isLandscape, files, rootId} = this.props;
const style = getStyleSheet(theme);
if (channelIsArchived) {
@ -753,8 +840,17 @@ export default class PostTextBoxBase extends PureComponent {
style={[style.inputWrapper, padding(isLandscape)]}
onLayout={this.handleLayout}
>
{this.getAttachmentButton()}
<View style={this.getInputContainerStyle()}>
<ScrollView
style={this.getInputContainerStyle()}
contentContainerStyle={style.inputContentContainer}
keyboardShouldPersistTaps={'always'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
pinchGestureEnabled={false}
overScrollMode={'never'}
disableScrollViewPanResponder={true}
>
<PasteableTextInput
ref={this.input}
value={textValue}
@ -773,14 +869,33 @@ export default class PostTextBoxBase extends PureComponent {
onPaste={this.handlePasteFiles}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
/>
<Fade visible={this.isSendButtonVisible()}>
<FileUploadPreview
files={files}
rootId={rootId}
/>
<View style={style.buttonsContainer}>
<View style={style.quickActionsContainer}>
{this.getTextInputButton('at')}
{this.getTextInputButton('slash')}
{this.getMediaButton('file')}
{this.getMediaButton('image')}
{this.getMediaButton('camera')}
</View>
<SendButton
disabled={!this.isSendButtonEnabled()}
handleSendMessage={this.handleSendMessage}
theme={theme}
/>
</Fade>
</View>
</View>
</ScrollView>
</View>
);
};
@ -788,34 +903,48 @@ export default class PostTextBoxBase extends PureComponent {
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
buttonsContainer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
slashIcon: {
width: 20,
height: 20,
opacity: 1,
tintColor: theme.centerChannelColor,
},
iconDisabled: {
tintColor: changeOpacity(theme.centerChannelColor, 0.6),
},
iconWrapper: {
paddingLeft: 10,
paddingRight: 10,
},
quickActionsContainer: {
display: 'flex',
flexDirection: 'row',
},
input: {
color: theme.centerChannelColor,
flex: 1,
fontSize: 14,
maxHeight: MAX_CONTENT_HEIGHT,
paddingBottom: 8,
paddingLeft: 12,
paddingRight: 12,
paddingTop: 8,
},
hidden: {
position: 'absolute',
top: 10000, // way off screen
left: 10000, // way off screen
backgroundColor: 'transparent',
borderColor: 'transparent',
color: 'transparent',
maxHeight: 150,
},
inputContainer: {
flex: 1,
flexDirection: 'row',
flexDirection: 'column',
backgroundColor: theme.centerChannelBg,
alignItems: 'stretch',
marginRight: 10,
},
inputContainerWithoutFileUpload: {
marginLeft: 10,
},
inputContentContainer: {
alignItems: 'stretch',
},
inputWrapper: {
alignItems: 'flex-end',
flexDirection: 'row',

View file

@ -9,6 +9,10 @@ import ProfilePictureButton from './profile_picture_button.js';
import {Client4} from 'mattermost-redux/client';
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn(),
}));
describe('profile_picture_button', () => {
const baseProps = {
theme: Preferences.THEMES.default,

View file

@ -70,9 +70,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
},
sendButton: {
backgroundColor: theme.buttonBg,
borderRadius: 18,
borderRadius: 4,
height: 28,
width: 28,
width: 72,
alignItems: 'center',
justifyContent: 'center',
paddingLeft: 3,

View file

@ -1,7 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const MAX_CONTENT_HEIGHT = 100;
export const MAX_FILE_COUNT = 5;
export const IS_REACTION_REGEX = /(^\+:([^:\s]*):)$/i;
export const INSERT_TO_DRAFT = 'insert_to_draft';

View file

@ -7,7 +7,6 @@ import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view';
import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from 'app/components/autocomplete';
import ChannelLoader from 'app/components/channel_loader';
import FileUploadPreview from 'app/components/file_upload_preview';
import NetworkIndicator from 'app/components/network_indicator';
import PostTextbox from 'app/components/post_textbox';
import SafeAreaView from 'app/components/safe_area_view';
@ -57,7 +56,6 @@ export default class ChannelIOS extends ChannelBase {
updateNativeScrollView={this.updateNativeScrollView}
/>
<View nativeID={ACCESSORIES_CONTAINER_NATIVE_ID}>
<FileUploadPreview/>
<Autocomplete
maxHeight={AUTOCOMPLETE_MAX_HEIGHT}
onChangeText={this.handleAutoComplete}

View file

@ -15,6 +15,9 @@ jest.mock('app/utils/theme', () => {
changeOpacity: jest.fn(),
};
});
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn(),
}));
describe('edit_profile', () => {
const actions = {

View file

@ -42,9 +42,6 @@ exports[`thread should match snapshot, has root post 1`] = `
<View
nativeID="threadAccessoriesContainer"
>
<Connect(FileUploadPreview)
rootId="root_id"
/>
<ForwardRef(forwardConnectRef)
cursorPositionEvent="onThreadTextBoxCursorChange"
maxHeight={200}

View file

@ -8,7 +8,6 @@ import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view';
import {getLastPostIndex} from 'mattermost-redux/utils/post_list';
import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from 'app/components/autocomplete';
import FileUploadPreview from 'app/components/file_upload_preview';
import Loading from 'app/components/loading';
import PostList from 'app/components/post_list';
import PostTextbox from 'app/components/post_textbox';
@ -52,9 +51,6 @@ export default class ThreadIOS extends ThreadBase {
scrollViewNativeID={SCROLLVIEW_NATIVE_ID}
/>
<View nativeID={ACCESSORIES_CONTAINER_NATIVE_ID}>
<FileUploadPreview
rootId={rootId}
/>
<Autocomplete
maxHeight={AUTOCOMPLETE_MAX_HEIGHT}
onChangeText={this.handleAutoComplete}

View file

@ -13,6 +13,9 @@ import * as NavigationActions from 'app/actions/navigation';
import ThreadIOS from './thread.ios';
jest.mock('react-intl');
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn(),
}));
describe('thread', () => {
const baseProps = {

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

View file

@ -206,16 +206,12 @@ PODS:
- React
- react-native-document-picker (3.2.4):
- React
- react-native-image-picker (0.28.1):
- React
- react-native-local-auth (1.5.0):
- react-native-image-picker (2.0.0):
- React
- react-native-netinfo (4.4.0):
- React
- react-native-notifications (2.0.6):
- React
- react-native-passcode-status (1.1.2):
- React
- react-native-safe-area (0.5.1):
- React
- react-native-video (5.0.2):
@ -330,10 +326,8 @@ DEPENDENCIES:
- react-native-cookies (from `../node_modules/react-native-cookies/ios`)
- react-native-document-picker (from `../node_modules/react-native-document-picker`)
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
- react-native-local-auth (from `../node_modules/react-native-local-auth`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-notifications (from `../node_modules/react-native-notifications`)
- react-native-passcode-status (from `../node_modules/react-native-passcode-status`)
- react-native-safe-area (from `../node_modules/react-native-safe-area`)
- react-native-video (from `../node_modules/react-native-video`)
- react-native-webview (from `../node_modules/react-native-webview`)
@ -422,14 +416,10 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-document-picker"
react-native-image-picker:
:path: "../node_modules/react-native-image-picker"
react-native-local-auth:
:path: "../node_modules/react-native-local-auth"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-notifications:
:path: "../node_modules/react-native-notifications"
react-native-passcode-status:
:path: "../node_modules/react-native-passcode-status"
react-native-safe-area:
:path: "../node_modules/react-native-safe-area"
react-native-video:
@ -511,11 +501,9 @@ SPEC CHECKSUMS:
react-native-cameraroll: ad20f5a93c25cb83a76455df57a2c62fbb63aaed
react-native-cookies: 854d59c4135c70b92a02ca4930e68e4e2eb58150
react-native-document-picker: c36bf5f067a581657ecaf7124dcd921a8be19061
react-native-image-picker: fd93361c666f397bdf72f9c6c23f13d2685b9173
react-native-local-auth: 5081a70211643de74bb207e007401a0c81b37a20
react-native-image-picker: ba7fe85b3373ff33d4827210d989dfcbbd68f7f9
react-native-netinfo: 892a5130be97ff8bb69c523739c424a2ffc296d1
react-native-notifications: d5cb54ef8bf3004dcb56c887650dea08ecbddee7
react-native-passcode-status: 88c4f6e074328bc278bd127646b6c694ad5a530a
react-native-safe-area: e8230b0017d76c00de6b01e2412dcf86b127c6a3
react-native-video: 961749da457e73bf0b5565edfbaffc25abfb8974
react-native-webview: 0d1c2b4e7ffb0543a74fa0512f2f8dc5fb0e49e2

6
package-lock.json generated
View file

@ -9937,9 +9937,9 @@
}
},
"react-native-image-picker": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-0.28.1.tgz",
"integrity": "sha512-CW2dm+cjsdW2fjBW2WD/cSufNG0x0UpljwGHrjSzyB0TckoW+tjYv44UWtckCWxr1JtCg+QrYDO/MzlRyFcjwQ=="
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-2.0.0.tgz",
"integrity": "sha512-svmQJ5bBuCgX9RG5DU3A38udgemze1/HtYUXgUAvP2i3LzrzW5ub5GT3L8n87pg/DNj2rRHi1wLtNaNEoV4ZAw=="
},
"react-native-iphone-x-helper": {
"version": "1.2.1",

View file

@ -45,7 +45,7 @@
"react-native-fast-image": "7.0.2",
"react-native-haptic-feedback": "1.8.2",
"react-native-image-gallery": "github:mattermost/react-native-image-gallery#c1a9f7118e90cc87d47620bc0584c9cac4b0cf38",
"react-native-image-picker": "0.28.1",
"react-native-image-picker": "2.0.0",
"react-native-keyboard-aware-scroll-view": "0.9.1",
"react-native-keyboard-tracking-view": "github:mattermost/react-native-keyboard-tracking-view#169676811a32f82cb674a52d7857a14043f7b8f7",
"react-native-keychain": "4.0.1",

View file

@ -1,92 +0,0 @@
diff --git a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java
index b4311eb..9d3f3d2 100644
--- a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java
+++ b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java
@@ -47,6 +47,7 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.lang.ref.WeakReference;
import java.util.List;
+import java.util.ArrayList;
import com.facebook.react.modules.core.PermissionListener;
import com.facebook.react.modules.core.PermissionAwareActivity;
@@ -542,14 +543,23 @@ public class ImagePickerModule extends ReactContextBaseJavaModule
@NonNull final Callback callback,
@NonNull final int requestCode)
{
- final int writePermission = ActivityCompat
- .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
- final int cameraPermission = ActivityCompat
+ int selfCheckResult = 0;
+ switch (requestCode) {
+ case REQUEST_PERMISSIONS_FOR_CAMERA:
+ selfCheckResult = ActivityCompat
.checkSelfPermission(activity, Manifest.permission.CAMERA);
+ if (selfCheckResult == PackageManager.PERMISSION_GRANTED) {
+ selfCheckResult = ActivityCompat
+ .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ }
+ break;
+ case REQUEST_PERMISSIONS_FOR_LIBRARY:
+ selfCheckResult = ActivityCompat
+ .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ break;
+ }
- final boolean permissionsGrated = writePermission == PackageManager.PERMISSION_GRANTED &&
- cameraPermission == PackageManager.PERMISSION_GRANTED;
-
+ final boolean permissionsGrated = selfCheckResult == PackageManager.PERMISSION_GRANTED;
if (!permissionsGrated)
{
final Boolean dontAskAgain = ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) && ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.CAMERA);
@@ -598,7 +608,18 @@ public class ImagePickerModule extends ReactContextBaseJavaModule
}
else
{
- String[] PERMISSIONS = {Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA};
+ List<String> permissions = new ArrayList<String>();
+ if (requestCode == REQUEST_PERMISSIONS_FOR_CAMERA )
+ {
+ permissions.add(Manifest.permission.CAMERA);
+ permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ }
+ if (requestCode == REQUEST_PERMISSIONS_FOR_LIBRARY )
+ {
+ permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ }
+
+ String[] PERMISSIONS = permissions.toArray(new String[0]);
if (activity instanceof ReactActivity)
{
((ReactActivity) activity).requestPermissions(PERMISSIONS, requestCode, listener);
diff --git a/node_modules/react-native-image-picker/ios/ImagePickerManager.m b/node_modules/react-native-image-picker/ios/ImagePickerManager.m
index 28d5870..3f70983 100644
--- a/node_modules/react-native-image-picker/ios/ImagePickerManager.m
+++ b/node_modules/react-native-image-picker/ios/ImagePickerManager.m
@@ -455,12 +455,19 @@ - (void)imagePickerController:(UIImagePickerController *)picker didFinishPicking
}
if (videoURL) { // Protect against reported crash
- NSError *error = nil;
- [fileManager moveItemAtURL:videoURL toURL:videoDestinationURL error:&error];
- if (error) {
- self.callback(@[@{@"error": error.localizedFailureReason}]);
- return;
- }
+ NSError *error = nil;
+
+ // If we have write access to the source file, move it. Otherwise use copy.
+ if ([fileManager isWritableFileAtPath:[videoURL path]]) {
+ [fileManager moveItemAtURL:videoURL toURL:videoDestinationURL error:&error];
+ } else {
+ [fileManager copyItemAtURL:videoURL toURL:videoDestinationURL error:&error];
+ }
+
+ if (error) {
+ self.callback(@[@{@"error": error.localizedFailureReason}]);
+ return;
+ }
}
}

View file

@ -0,0 +1,184 @@
diff --git a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java
index ef62bed..7379605 100644
--- a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java
+++ b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java
@@ -49,6 +49,7 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.lang.ref.WeakReference;
import java.util.List;
+import java.util.ArrayList;
import com.facebook.react.modules.core.PermissionListener;
import com.facebook.react.modules.core.PermissionAwareActivity;
@@ -69,6 +70,7 @@ public class ImagePickerModule extends ReactContextBaseJavaModule
public static final int REQUEST_LAUNCH_IMAGE_LIBRARY = 13002;
public static final int REQUEST_LAUNCH_VIDEO_LIBRARY = 13003;
public static final int REQUEST_LAUNCH_VIDEO_CAPTURE = 13004;
+ public static final int REQUEST_LAUNCH_MIXED_CAPTURE = 13005;
public static final int REQUEST_PERMISSIONS_FOR_CAMERA = 14001;
public static final int REQUEST_PERMISSIONS_FOR_LIBRARY = 14002;
@@ -266,26 +268,23 @@ public class ImagePickerModule extends ReactContextBaseJavaModule
cameraIntent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, videoDurationLimit);
}
}
+ else if (pickBoth) {
+ Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+ this.setImageCaptureUri(takePictureIntent);
+ Intent takeVideoIntent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);
+ cameraIntent = new Intent(Intent.ACTION_CHOOSER);
+ Intent contentSelectionIntent = new Intent(Intent.ACTION_GET_CONTENT);
+ Intent[] intentArray = new Intent[]{takePictureIntent,takeVideoIntent};
+ cameraIntent.putExtra(Intent.EXTRA_INTENT, contentSelectionIntent);
+ cameraIntent.putExtra(Intent.EXTRA_TITLE, "Choose an action");
+ cameraIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, intentArray);
+ requestCode = REQUEST_LAUNCH_MIXED_CAPTURE;
+ }
else
{
requestCode = REQUEST_LAUNCH_IMAGE_CAPTURE;
cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
-
- final File original = createNewFile(reactContext, this.options, false);
- imageConfig = imageConfig.withOriginalFile(original);
-
- if (imageConfig.original != null) {
- cameraCaptureURI = RealPathUtil.compatUriFromFile(reactContext, imageConfig.original);
- }else {
- responseHelper.invokeError(callback, "Couldn't get file path for photo");
- return;
- }
- if (cameraCaptureURI == null)
- {
- responseHelper.invokeError(callback, "Couldn't get file path for photo");
- return;
- }
- cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, cameraCaptureURI);
+ this.setImageCaptureUri(cameraIntent);
}
if (cameraIntent.resolveActivity(reactContext.getPackageManager()) == null)
@@ -444,14 +443,20 @@ public class ImagePickerModule extends ReactContextBaseJavaModule
callback = null;
return;
+ case REQUEST_LAUNCH_MIXED_CAPTURE:
case REQUEST_LAUNCH_VIDEO_CAPTURE:
- final String path = getRealPathFromURI(data.getData());
- responseHelper.putString("uri", data.getData().toString());
- responseHelper.putString("path", path);
- fileScan(reactContext, path);
- responseHelper.invokeResponse(callback);
- callback = null;
- return;
+ if (data == null || data.getData() == null) {
+ uri = cameraCaptureURI;
+ break;
+ } else {
+ final String path = getRealPathFromURI(data.getData());
+ responseHelper.putString("uri", data.getData().toString());
+ responseHelper.putString("path", path);
+ fileScan(reactContext, path);
+ responseHelper.invokeResponse(callback);
+ callback = null;
+ return;
+ }
}
final ReadExifResult result = readExifInterface(responseHelper, imageConfig);
@@ -551,7 +556,8 @@ public class ImagePickerModule extends ReactContextBaseJavaModule
{
return callback == null || (cameraCaptureURI == null && requestCode == REQUEST_LAUNCH_IMAGE_CAPTURE)
|| (requestCode != REQUEST_LAUNCH_IMAGE_CAPTURE && requestCode != REQUEST_LAUNCH_IMAGE_LIBRARY
- && requestCode != REQUEST_LAUNCH_VIDEO_LIBRARY && requestCode != REQUEST_LAUNCH_VIDEO_CAPTURE);
+ && requestCode != REQUEST_LAUNCH_VIDEO_LIBRARY && requestCode != REQUEST_LAUNCH_VIDEO_CAPTURE
+ && requestCode != REQUEST_LAUNCH_MIXED_CAPTURE);
}
private void updatedResultResponse(@Nullable final Uri uri,
@@ -571,22 +577,23 @@ public class ImagePickerModule extends ReactContextBaseJavaModule
@NonNull final Callback callback,
@NonNull final int requestCode)
{
- final int writePermission = ActivityCompat
- .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
- final int cameraPermission = ActivityCompat
- .checkSelfPermission(activity, Manifest.permission.CAMERA);
-
- boolean permissionsGranted = false;
-
+ int selfCheckResult = 0;
switch (requestCode) {
case REQUEST_PERMISSIONS_FOR_LIBRARY:
- permissionsGranted = writePermission == PackageManager.PERMISSION_GRANTED;
- break;
+ selfCheckResult = ActivityCompat
+ .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ break;
case REQUEST_PERMISSIONS_FOR_CAMERA:
- permissionsGranted = cameraPermission == PackageManager.PERMISSION_GRANTED;
+ selfCheckResult = ActivityCompat
+ .checkSelfPermission(activity, Manifest.permission.CAMERA);
+ if (selfCheckResult == PackageManager.PERMISSION_GRANTED) {
+ selfCheckResult = ActivityCompat
+ .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ }
break;
}
+ final boolean permissionsGranted = selfCheckResult == PackageManager.PERMISSION_GRANTED;
if (!permissionsGranted)
{
final Boolean dontAskAgain = ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) && ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.CAMERA);
@@ -641,7 +648,7 @@ public class ImagePickerModule extends ReactContextBaseJavaModule
PERMISSIONS = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE};
break;
case REQUEST_PERMISSIONS_FOR_CAMERA:
- PERMISSIONS = new String[]{Manifest.permission.CAMERA};
+ PERMISSIONS = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA};
break;
default:
PERMISSIONS = new String[]{};
@@ -781,4 +788,22 @@ public class ImagePickerModule extends ReactContextBaseJavaModule
videoDurationLimit = options.getInt("durationLimit");
}
}
+
+ private void setImageCaptureUri(Intent cameraIntent) {
+ final File original = createNewFile(reactContext, this.options, false);
+ imageConfig = imageConfig.withOriginalFile(original);
+
+ if (imageConfig.original != null) {
+ cameraCaptureURI = RealPathUtil.compatUriFromFile(reactContext, imageConfig.original);
+ }else {
+ responseHelper.invokeError(callback, "Couldn't get file path for photo");
+ return;
+ }
+ if (cameraCaptureURI == null)
+ {
+ responseHelper.invokeError(callback, "Couldn't get file path for photo");
+ return;
+ }
+ cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, cameraCaptureURI);
+ }
}
diff --git a/node_modules/react-native-image-picker/ios/ImagePickerManager.m b/node_modules/react-native-image-picker/ios/ImagePickerManager.m
index 46b2c11..70bb8a5 100644
--- a/node_modules/react-native-image-picker/ios/ImagePickerManager.m
+++ b/node_modules/react-native-image-picker/ios/ImagePickerManager.m
@@ -460,7 +460,14 @@ - (void)imagePickerController:(UIImagePickerController *)picker didFinishPicking
if (videoURL) { // Protect against reported crash
NSError *error = nil;
- [fileManager moveItemAtURL:videoURL toURL:videoDestinationURL error:&error];
+
+ // If we have write access to the source file, move it. Otherwise use copy.
+ if ([fileManager isWritableFileAtPath:[videoURL path]]) {
+ [fileManager moveItemAtURL:videoURL toURL:videoDestinationURL error:&error];
+ } else {
+ [fileManager copyItemAtURL:videoURL toURL:videoDestinationURL error:&error];
+ }
+
if (error) {
self.callback(@[@{@"error": error.localizedFailureReason}]);
return;