mattermost-mobile/share_extension/android/extension_post/extension_post.js
Amit Uttam 0f70af7b16 MM-15469 (Android) Alert if shared text is longer than message limit (#3264)
* MM-15469 Alert if shared text is longer than message limit

One can't manually enter text into the share extension's text box past 4000 characters (the default/defined text size limit). However, this restriction can still be circumvented by sharing a large item of text. In this latter case, the textarea displays just the first 4000 characters, but the value buffer still contains the entire shared text. On hitting "Send", the oversize text can still be shared.

This commit implements [MM-15469](https://mattermost.atlassian.net/browse/MM-15469) to better align the Android sharing user experience with the iOS experience.

Since a pre-existing i18n string was used, no additional translations are required.

Fixes https://github.com/mattermost/mattermost-server/issues/11431

* Add available characters remaining counter in Android Share textarea

Shows number of available characters remaining until textarea limit (default
= 4000). Similar to iOS experience.

**iOS (normal)**

![iOS (normal)](https://user-images.githubusercontent.com/887849/65007527-d6d1f700-d8dc-11e9-9382-7e4b3c6f4f5d.png)

**Android (normal)**

![Android (normal)](https://user-images.githubusercontent.com/887849/65007570-f2d59880-d8dc-11e9-9795-ed137e4dd506.jpg)

**iOS (past limit)**

![iOS (past limit)](https://user-images.githubusercontent.com/887849/65007638-2b757200-d8dd-11e9-987d-f5e89b2aa0d3.png)

**Android (past limit)**

![Android (past limit)](https://user-images.githubusercontent.com/887849/65007720-5e1f6a80-d8dd-11e9-97ba-370fa6c8db58.jpg)

* Don't limit size of Android Share extension text input box

Pasted shared text was getting cut off after hitting the `MAX_MESSAGE_LENGTH` for shared posts. Alert validation now checks this limit before allowing content to be posted, which made the `maxLength` limit redundant.

Context: https://github.com/mattermost/mattermost-mobile/pull/3264#issuecomment-540962933
2019-10-16 09:51:06 +03:00

791 lines
24 KiB
JavaScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {NavigationActions} from 'react-navigation';
import TouchableItem from 'react-navigation-stack/dist/views/TouchableItem';
import PropTypes from 'prop-types';
import {intlShape} from 'react-intl';
import {
Alert,
Image,
NativeModules,
PermissionsAndroid,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import Video from 'react-native-video';
import LocalAuth from 'react-native-local-auth';
import RNFetchBlob from 'rn-fetch-blob';
import {Client4} from 'mattermost-redux/client';
import {Preferences} from 'mattermost-redux/constants';
import {getFormattedFileSize, lookupMimeType} from 'mattermost-redux/utils/file_utils';
import Loading from 'app/components/loading';
import PaperPlane from 'app/components/paper_plane';
import {MAX_FILE_COUNT} from 'app/constants/post_textbox';
import {getCurrentServerUrl, getAppCredentials} from 'app/init/credentials';
import mattermostManaged from 'app/mattermost_managed';
import {getExtensionFromMime} from 'app/utils/file';
import {emptyFunction} from 'app/utils/general';
import {setCSRFFromCookie} from 'app/utils/security';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {
ExcelSvg,
GenericSvg,
PdfSvg,
PptSvg,
ZipSvg,
} from 'share_extension/common/icons';
import ChannelButton from './channel_button';
import TeamButton from './team_button';
const defaultTheme = Preferences.THEMES.default;
const extensionSvg = {
csv: ExcelSvg,
pdf: PdfSvg,
ppt: PptSvg,
pptx: PptSvg,
xls: ExcelSvg,
xlsx: ExcelSvg,
zip: ZipSvg,
};
const ShareExtension = NativeModules.MattermostShare;
const INPUT_HEIGHT = 150;
const MAX_MESSAGE_LENGTH = 4000;
export default class ExtensionPost extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
getTeamChannels: PropTypes.func.isRequired,
}).isRequired,
channelId: PropTypes.string,
channels: PropTypes.object.isRequired,
currentUserId: PropTypes.string.isRequired,
maxFileSize: PropTypes.number.isRequired,
navigation: PropTypes.object.isRequired,
teamId: PropTypes.string.isRequired,
};
static contextTypes = {
intl: intlShape,
};
static navigationOptions = ({navigation}) => {
const {params = {}} = navigation.state;
const title = params.title || '';
const headerLeft = (
<TouchableItem
accessibilityComponentType='button'
accessibilityTraits='button'
borderless={true}
delayPressIn={0}
pressColorAndroid='rgba(0, 0, 0, .32)'
onPress={params.close ? params.close : emptyFunction}
>
<View style={styles.left}>
<MaterialIcon
name='close'
style={styles.closeButton}
/>
</View>
</TouchableItem>
);
let headerRight = null;
if (params.post) {
headerRight = (
<TouchableItem
accessibilityComponentType='button'
accessibilityTraits='button'
borderless={true}
delayPressIn={0}
pressColorAndroid='rgba(0, 0, 0, .32)'
onPress={params.post}
>
<View style={styles.left}>
<PaperPlane
color={defaultTheme.sidebarHeaderTextColor}
height={20}
width={20}
/>
</View>
</TouchableItem>
);
}
return {headerLeft, headerRight, title};
};
constructor(props, context) {
super(props, context);
props.navigation.setParams({
title: context.intl.formatMessage({
id: 'mobile.extension.title',
defaultMessage: 'Share in Mattermost',
}),
});
this.state = {
channelId: props.channelId,
files: [],
hasPermission: null,
teamId: props.teamId,
totalSize: 0,
value: '',
};
}
componentDidMount() {
this.props.navigation.setParams({
close: this.onClose,
});
this.auth();
}
auth = async () => {
try {
const {formatMessage} = this.context.intl;
const config = await mattermostManaged.getConfig();
if (config) {
const authNeeded = config.inAppPinCode && config.inAppPinCode === 'true';
const vendor = config.vendor || 'Mattermost';
if (authNeeded) {
const isSecured = await mattermostManaged.isDeviceSecure();
if (isSecured) {
try {
await LocalAuth.auth({
reason: formatMessage({
id: 'mobile.managed.secured_by',
defaultMessage: 'Secured by {vendor}',
}, {vendor}),
fallbackToPasscode: true,
suppressEnterPassword: false,
});
} catch (err) {
return this.onClose({nativeEvent: true});
}
} else {
await this.showNotSecuredAlert(vendor);
return this.onClose({nativeEvent: true});
}
}
}
} catch (e) {
// do nothing
}
return this.initialize();
};
showNotSecuredAlert(vendor) {
const {formatMessage} = this.context.intl;
return new Promise((resolve) => {
Alert.alert(
formatMessage({
id: 'mobile.managed.blocked_by',
defaultMessage: 'Blocked by {vendor}',
}, {vendor}),
formatMessage({
id: 'mobile.managed.not_secured.android',
defaultMessage: 'This device must be secured with a screen lock to use Mattermost.',
}),
[
{
text: formatMessage({
id: 'mobile.managed.settings',
defaultMessage: 'Go to settings',
}),
onPress: () => {
mattermostManaged.goToSecuritySettings();
},
},
{
text: formatMessage({
id: 'mobile.managed.exit',
defaultMessage: 'Exit',
}),
onPress: resolve,
style: 'cancel',
},
],
{onDismiss: resolve}
);
});
}
getAppCredentials = async () => {
try {
const url = await getCurrentServerUrl();
const credentials = await getAppCredentials();
if (credentials) {
const token = credentials.password;
if (url && url !== 'undefined' && token && token !== 'undefined') {
this.token = token;
this.url = url;
Client4.setUrl(url);
Client4.setToken(token);
await setCSRFFromCookie(url);
}
}
} catch (error) {
return null;
}
return null;
};
getInputRef = (ref) => {
this.input = ref;
};
goToChannels = preventDoubleTap(() => {
const {formatMessage} = this.context.intl;
const {navigation} = this.props;
const navigateAction = NavigationActions.navigate({
routeName: 'Channels',
params: {
title: formatMessage({
id: 'mobile.routes.selectChannel',
defaultMessage: 'Select Channel',
}),
currentChannelId: this.state.channelId,
onSelectChannel: this.handleSelectChannel,
},
});
navigation.dispatch(navigateAction);
});
goToTeams = preventDoubleTap(() => {
const {formatMessage} = this.context.intl;
const {navigation} = this.props;
const navigateAction = NavigationActions.navigate({
routeName: 'Teams',
params: {
title: formatMessage({
id: 'mobile.routes.selectTeam',
defaultMessage: 'Select Team',
}),
currentTeamId: this.state.teamId,
onSelectTeam: this.handleSelectTeam,
},
});
navigation.dispatch(navigateAction);
});
handleBlur = () => {
if (this.input) {
this.input.setNativeProps({
autoScroll: false,
});
}
};
handleFocus = () => {
if (this.input) {
this.input.setNativeProps({
autoScroll: true,
});
}
};
handleSelectChannel = (channelId) => {
this.setState({channelId});
};
handleSelectTeam = (teamId, defaultChannelId) => {
this.setState({teamId, channelId: defaultChannelId});
};
handleTextChange = (value) => {
this.setState({value});
};
initialize = async () => {
await this.getAppCredentials();
const hasPermission = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE);
let granted;
if (!hasPermission) {
granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE
);
}
if (hasPermission || granted === PermissionsAndroid.RESULTS.GRANTED) {
const data = await ShareExtension.data();
return this.loadData(data);
}
return this.setState({hasPermission: false});
};
loadData = async (items) => {
const {actions, maxFileSize, teamId} = this.props;
if (this.token && this.url) {
const text = [];
const files = [];
let totalSize = 0;
let error;
actions.getTeamChannels(teamId);
for (let i = 0; i < items.length; i++) {
const item = items[i];
switch (item.type) {
case 'text/plain':
text.push(item.value);
break;
default: {
let fileSize = {size: 0};
const fullPath = item.value;
try {
fileSize = await RNFetchBlob.fs.stat(fullPath); // eslint-disable-line no-await-in-loop
} catch (e) {
const {formatMessage} = this.context.intl;
error = formatMessage({
id: 'mobile.extension.file_error',
defaultMessage: 'There was an error reading the file to be shared.\nPlease try again.',
});
break;
}
let filename = fullPath.replace(/^.*[\\/]/, '');
let extension = filename.split('.').pop();
if (extension === filename) {
extension = getExtensionFromMime(item.type);
filename = `${filename}.${extension}`;
}
totalSize += fileSize.size;
files.push({
extension,
filename,
fullPath,
mimeType: item.type || lookupMimeType(filename.toLowerCase()),
size: getFormattedFileSize(fileSize),
type: item.type,
});
break;
}
}
}
const value = text.join('\n');
if (!error && files.length <= MAX_FILE_COUNT && totalSize <= maxFileSize) {
this.props.navigation.setParams({
post: this.onPost,
});
}
this.setState({error, files, value, hasPermission: true, totalSize});
}
this.setState({loaded: true});
};
onClose = (data) => {
ShareExtension.close(data.nativeEvent ? null : data);
};
onPost = () => {
const {channelId, files, value} = this.state;
const {currentUserId} = this.props;
const {formatMessage} = this.context.intl;
if (value.length > MAX_MESSAGE_LENGTH) {
Alert.alert(
formatMessage({
id: 'mobile.share_extension.too_long_title',
defaultMessage: 'Message is too long',
}),
formatMessage({
id: 'mobile.share_extension.too_long_message',
defaultMessage: 'Character count: {count}/{max}',
}, {
count: value.length,
max: MAX_MESSAGE_LENGTH,
})
);
} else {
const data = {
channelId,
currentUserId,
files,
token: this.token,
url: this.url,
value,
};
this.onClose(data);
}
};
renderBody = () => {
const {formatMessage} = this.context.intl;
const {channelId, value} = this.state;
const channel = this.props.channels[channelId];
const channelDisplayName = channel?.display_name || ''; //eslint-disable-line camelcase
return (
<ScrollView
ref={this.getScrollViewRef}
contentContainerStyle={styles.scrollView}
style={styles.flex}
>
<TextInput
ref={this.getInputRef}
autoCapitalize='sentences'
multiline={true}
onBlur={this.handleBlur}
onChangeText={this.handleTextChange}
onFocus={this.handleFocus}
placeholder={formatMessage({id: 'create_post.write', defaultMessage: 'Write to {channelDisplayName}'}, {channelDisplayName})}
placeholderTextColor={changeOpacity(defaultTheme.centerChannelColor, 0.5)}
style={styles.input}
underlineColorAndroid='transparent'
value={value}
/>
{this.renderFiles()}
</ScrollView>
);
};
renderChannelButton = () => {
const {channelId} = this.state;
return (
<ChannelButton
channelId={channelId}
onPress={this.goToChannels}
theme={defaultTheme}
/>
);
};
renderErrorMessage = (message) => {
return (
<View
style={styles.flex}
>
<View style={styles.unauthenticatedContainer}>
<Text style={styles.unauthenticated}>
{message}
</Text>
</View>
</View>
);
};
renderFiles = () => {
const {files} = this.state;
return files.map((file, index) => {
let component;
if (file.type.startsWith('image')) {
component = (
<View
key={`item-${index}`}
style={styles.imageContainer}
>
<Image
source={{uri: file.fullPath, isStatic: true}}
resizeMode='cover'
style={styles.image}
/>
</View>
);
} else if (file.type.startsWith('video')) {
component = (
<View
key={`item-${index}`}
style={styles.imageContainer}
>
<Video
ref={`video-${index}`}
style={styles.video}
resizeMode='cover'
source={{uri: file.fullPath}}
volume={0}
paused={true}
onLoad={() => this.refs[`video-${index}`].seek(0)}
/>
</View>
);
} else {
let SvgIcon = extensionSvg[file.extension];
if (!SvgIcon) {
SvgIcon = GenericSvg;
}
component = (
<View
key={`item-${index}`}
style={styles.otherContainer}
>
<View style={styles.otherWrapper}>
<View style={styles.fileIcon}>
<SvgIcon
width={19}
height={48}
/>
</View>
</View>
</View>
);
}
return (
<View
style={styles.fileContainer}
key={`item-${index}`}
>
{component}
<Text
ellipsisMode='tail'
numberOfLines={1}
style={styles.filename}
>
{`${file.size} - ${file.filename}`}
</Text>
</View>
);
});
};
renderTeamButton = () => {
const {teamId} = this.state;
return (
<TeamButton
onPress={this.goToTeams}
teamId={teamId}
theme={defaultTheme}
/>
);
};
lengthCounterColor = (count) => {
if (count < 0) {
return styles.textTooLong;
}
return styles.textLengthOk;
}
renderMessageLengthRemaining = () => {
const {value} = this.state;
const messageLengthRemaining = MAX_MESSAGE_LENGTH - value.length;
if (value.length === 0) {
return null;
}
const renderStyle = [styles.messageLengthRemaining, this.lengthCounterColor(messageLengthRemaining)];
return (
<Text style={renderStyle}>
{messageLengthRemaining}
</Text>
);
};
render() {
const {formatMessage} = this.context.intl;
const {maxFileSize} = this.props;
const {error, hasPermission, files, totalSize, loaded} = this.state;
if (!loaded) {
return (
<Loading/>
);
}
if (error) {
return this.renderErrorMessage(error);
}
if (this.token && this.url) {
if (hasPermission === false) {
const storage = formatMessage({
id: 'mobile.extension.permission',
defaultMessage: 'Mattermost needs access to the device storage to share files.',
});
return this.renderErrorMessage(storage);
} else if (files.length > MAX_FILE_COUNT) {
const fileCount = formatMessage({
id: 'mobile.extension.file_limit',
defaultMessage: 'Sharing is limited to a maximum of 5 files.',
});
return this.renderErrorMessage(fileCount);
} else if (totalSize > maxFileSize) {
const maxSize = formatMessage({
id: 'mobile.extension.max_file_size',
defaultMessage: 'File attachments shared in Mattermost must be less than {size}.',
}, {size: getFormattedFileSize({size: maxFileSize})});
return this.renderErrorMessage(maxSize);
}
return (
<View style={styles.container}>
<View style={styles.wrapper}>
{this.renderBody()}
<View style={styles.flex}>
{this.renderMessageLengthRemaining()}
{this.renderTeamButton()}
{this.renderChannelButton()}
</View>
</View>
</View>
);
}
const loginNeeded = formatMessage({
id: 'mobile.extension.authentication_required',
defaultMessage: 'Authentication required: Please first login using the app.',
});
return this.renderErrorMessage(loginNeeded);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
left: {
alignItems: 'center',
height: 50,
justifyContent: 'center',
width: 50,
},
closeButton: {
color: theme.sidebarHeaderTextColor,
fontSize: 20,
},
flex: {
flex: 1,
},
container: {
flex: 1,
backgroundColor: theme.centerChannelBg,
},
wrapper: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.05),
flex: 1,
},
scrollView: {
flex: 1,
padding: 15,
},
messageLengthRemaining: {
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 15,
paddingRight: 15,
opacity: 0.5,
},
textLengthOk: {
color: theme.centerChannelColor,
},
textTooLong: {
color: theme.errorTextColor,
},
input: {
flex: 1,
color: theme.centerChannelColor,
fontSize: 17,
height: INPUT_HEIGHT,
marginBottom: 5,
textAlignVertical: 'top',
width: '100%',
},
unauthenticatedContainer: {
alignItems: 'center',
flex: 1,
paddingHorizontal: 20,
paddingTop: 35,
},
unauthenticated: {
color: theme.errorTextColor,
fontSize: 14,
},
fileContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRadius: 4,
borderWidth: 1,
flexDirection: 'row',
height: 48,
marginBottom: 10,
width: '100%',
},
filename: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 13,
flex: 1,
},
otherContainer: {
borderBottomLeftRadius: 4,
borderTopLeftRadius: 4,
height: 48,
marginRight: 10,
paddingVertical: 10,
width: 38,
},
otherWrapper: {
borderRightWidth: 1,
borderRightColor: changeOpacity(theme.centerChannelColor, 0.2),
flex: 1,
},
fileIcon: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
},
imageContainer: {
justifyContent: 'center',
borderBottomLeftRadius: 4,
borderTopLeftRadius: 4,
height: 48,
marginRight: 10,
width: 38,
overflow: 'hidden',
},
image: {
alignItems: 'center',
borderBottomLeftRadius: 4,
borderTopLeftRadius: 4,
height: 46,
justifyContent: 'center',
overflow: 'hidden',
width: 38,
},
video: {
alignItems: 'center',
height: 48,
justifyContent: 'center',
overflow: 'hidden',
width: 38,
},
};
});
const styles = getStyleSheet(defaultTheme);