// 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 { ActivityIndicator, DeviceEventEmitter, Dimensions, Image, NativeModules, ScrollView, Text, TextInput, TouchableHighlight, View, } from 'react-native'; import IonIcon from 'react-native-vector-icons/Ionicons'; 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 {General} from 'mattermost-redux/constants'; import {getChannel, getDefaultChannel} from 'mattermost-redux/selectors/entities/channels'; import {getFormattedFileSize, lookupMimeType} from 'mattermost-redux/utils/file_utils'; import {isMinimumServerVersion} from 'mattermost-redux/utils/helpers'; import mattermostBucket from 'app/mattermost_bucket'; import {generateId, getAllowedServerMaxFileSize} from 'app/utils/file'; import {preventDoubleTap} from 'app/utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import Config from 'assets/config'; import { ExcelSvg, GenericSvg, PdfSvg, PptSvg, ZipSvg, } from 'share_extension/common/icons'; import ExtensionChannels from './extension_channels'; import ExtensionNavBar from './extension_nav_bar'; import ExtensionTeams from './extension_teams'; const ShareExtension = NativeModules.MattermostShare; const MAX_INPUT_HEIGHT = 95; const MAX_MESSAGE_LENGTH = 4000; const MAX_FILE_SIZE = 20 * 1024 * 1024; const extensionSvg = { csv: ExcelSvg, pdf: PdfSvg, ppt: PptSvg, pptx: PptSvg, xls: ExcelSvg, xlsx: ExcelSvg, zip: ZipSvg, }; export default class ExtensionPost extends PureComponent { static propTypes = { authenticated: PropTypes.bool.isRequired, entities: PropTypes.object, navigator: PropTypes.object.isRequired, onClose: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, title: PropTypes.string, }; static contextTypes = { intl: intlShape, }; constructor(props, context) { super(props, context); const {height, width} = Dimensions.get('window'); const isLandscape = width > height; const entities = props.entities; this.useBackgroundUpload = props.authenticated ? isMinimumServerVersion(entities.general.serverVersion, 4, 8) : false; this.state = { entities, error: null, files: [], isLandscape, exceededSize: 0, value: '', sending: false, }; } componentWillMount() { this.event = DeviceEventEmitter.addListener('extensionPostFailed', this.handlePostFailed); this.loadData(); } componentDidMount() { this.focusInput(); } componentWillUnmount() { this.event.remove(); } componentDidUpdate() { this.focusInput(); } emmAuthenticationIfNeeded = async () => { try { const emmSecured = await mattermostBucket.getPreference('emm', Config.AppGroupId); if (emmSecured) { const {intl} = this.context; await LocalAuth.authenticate({ reason: intl.formatMessage({ id: 'mobile.managed.secured_by', defaultMessage: 'Secured by {vendor}', }, {emmSecured}), fallbackToPasscode: true, suppressEnterPassword: true, }); } } catch (error) { this.props.onClose(); } }; focusInput = () => { if (this.input && !this.input.isFocused()) { this.input.focus(); } }; getInputRef = (ref) => { this.input = ref; }; getScrollViewRef = (ref) => { this.scrollView = ref; }; goToChannels = preventDoubleTap(() => { const {navigator, theme} = this.props; const {channel, entities, team} = this.state; if (!entities || !team || !channel) { return; } navigator.push({ component: ExtensionChannels, wrapperStyle: { borderRadius: 10, backgroundColor: theme.centerChannelBg, }, passProps: { currentChannelId: channel.id, entities, onSelectChannel: this.selectChannel, teamId: team.id, theme, title: team.display_name, }, }); }); goToTeams = preventDoubleTap(() => { const {navigator, theme} = this.props; const {formatMessage} = this.context.intl; const {entities, team} = this.state; if (!entities || !team) { return; } navigator.push({ component: ExtensionTeams, title: formatMessage({id: 'quick_switch_modal.teams', defaultMessage: 'Teams'}), wrapperStyle: { borderRadius: 10, backgroundColor: theme.centerChannelBg, }, passProps: { entities, currentTeamId: team.id, onSelectTeam: this.selectTeam, theme, }, }); }); handleCancel = preventDoubleTap(() => { this.props.onClose(); }); handleTextChange = (value) => { this.setState({value}); }; handlePostFailed = () => { const {formatMessage} = this.context.intl; this.setState({ error: { message: formatMessage({ id: 'mobile.share_extension.post_error', defaultMessage: 'An error has occurred while posting the message. Please try again.', }), }, sending: false, }); }; loadData = async () => { const {entities} = this.state; if (this.props.authenticated) { try { const {config, credentials} = entities.general; const {currentUserId} = entities.users; const team = entities.teams.teams[entities.teams.currentTeamId]; let channel = entities.channels.channels[entities.channels.currentChannelId]; const items = await ShareExtension.data(Config.AppGroupId); const serverMaxFileSize = getAllowedServerMaxFileSize(config); const maxSize = Math.min(MAX_FILE_SIZE, serverMaxFileSize); const text = []; const urls = []; const files = []; let totalSize = 0; let exceededSize = false; if (channel && (channel.type === General.GM_CHANNEL || channel.type === General.DM_CHANNEL)) { channel = getChannel({entities}, channel.id); } if (channel.delete_at !== 0) { channel = getDefaultChannel({entities}); } for (let i = 0; i < items.length; i++) { const item = items[i]; switch (item.type) { case 'public.plain-text': text.push(item.value); break; case 'public.url': urls.push(item.value); break; default: { const fullPath = item.value; const filePath = decodeURIComponent(fullPath.replace('file://', '')); const fileSize = await RNFetchBlob.fs.stat(filePath); const filename = decodeURIComponent(fullPath.replace(/^.*[\\/]/, '')); const extension = filename.split('.').pop(); if (this.useBackgroundUpload) { if (!exceededSize) { exceededSize = fileSize.size >= maxSize; } } else { totalSize += fileSize.size; } files.push({ extension, filename, filePath, fullPath, mimeType: lookupMimeType(filename.toLowerCase()), size: getFormattedFileSize(fileSize), type: item.type, }); break; } } } let value = text.join('\n'); if (urls.length) { value += text.length ? `\n${urls.join('\n')}` : urls.join('\n'); } Client4.setUrl(credentials.url); Client4.setToken(credentials.token); Client4.setUserId(currentUserId); if (!this.useBackgroundUpload) { exceededSize = totalSize >= maxSize; } this.setState({channel, files, team, value, exceededSize}); } catch (error) { this.setState({error}); } } }; onLayout = async () => { const isLandscape = await ShareExtension.getOrientation() === 'LANDSCAPE'; if (this.state.isLandscape !== isLandscape) { if (this.scrollView) { setTimeout(() => { this.scrollView.scrollTo({y: 0, animated: false}); }, 250); } this.setState({isLandscape}); } }; renderBody = (styles) => { const {formatMessage} = this.context.intl; const {authenticated, theme} = this.props; const {entities, error, sending, exceededSize, value} = this.state; const {config} = entities.general; const serverMaxFileSize = getAllowedServerMaxFileSize(config); const maxSize = Math.min(MAX_FILE_SIZE, serverMaxFileSize); if (error) { return ( {error.message} ); } if (sending) { return ( {formatMessage({ id: 'mobile.extension.posting', defaultMessage: 'Posting...', })} ); } if (exceededSize) { return ( {formatMessage({ id: 'mobile.extension.max_file_size', defaultMessage: 'File attachments shared in Mattermost must be less than {size}.', }, {size: getFormattedFileSize({size: maxSize})})} ); } if (authenticated && !error) { return ( {this.renderFiles(styles)} ); } return ( {formatMessage({ id: 'mobile.extension.authentication_required', defaultMessage: 'Authentication required: Please first login using the app.', })} ); }; renderChannelButton = (styles) => { const {formatMessage} = this.context.intl; const {authenticated, theme} = this.props; const {channel, sending} = this.state; const channelName = channel ? channel.display_name : ''; if (sending) { return null; } if (!authenticated) { return null; } return ( {formatMessage({id: 'mobile.share_extension.channel', defaultMessage: 'Channel'})} {channelName} ); }; renderFiles = (styles) => { const {files} = this.state; return files.map((file, index) => { let component; switch (file.type) { case 'public.image': component = ( ); break; case 'public.movie': component = ( ); break; case 'public.file-url': { let SvgIcon = extensionSvg[file.extension]; if (!SvgIcon) { SvgIcon = GenericSvg; } component = ( ); break; } } return ( {component} {`${file.size} - ${file.filename}`} ); }); }; renderTeamButton = (styles) => { const {formatMessage} = this.context.intl; const {authenticated, theme} = this.props; const {sending, team} = this.state; const teamName = team ? team.display_name : ''; if (sending) { return null; } if (!authenticated) { return null; } return ( {formatMessage({id: 'mobile.share_extension.team', defaultMessage: 'Team'})} {teamName} ); }; selectChannel = (channel) => { this.setState({channel}); }; selectTeam = (team, channel) => { this.setState({channel, team, error: null}); // Update the channels for the team Client4.getMyChannels(team.id).then((channels) => { const defaultChannel = channels.find((c) => c.name === General.DEFAULT_CHANNEL && c.team_id === team.id); this.updateChannelsInEntities(channels); if (!channel) { this.setState({channel: defaultChannel}); } }).catch((error) => { const {entities} = this.props; if (entities.channels.channelsInTeam[team.id]) { const townSquare = Object.values(entities.channels.channels).find((c) => { return c.name === General.DEFAULT_CHANNEL && c.team_id === team.id; }); if (!channel) { this.setState({channel: townSquare}); } } else { this.setState({error, channel: null}); } }); }; sendMessage = preventDoubleTap(async () => { const {authenticated, onClose} = this.props; const {channel, entities, files, value} = this.state; const {currentUserId} = entities.users; // If no text and no files do nothing if ((!value && !files.length) || !channel) { return; } if (currentUserId && authenticated) { await this.emmAuthenticationIfNeeded(); const certificate = await mattermostBucket.getPreference('cert', Config.AppGroupId); try { // Check to see if the use still belongs to the channel await Client4.getMyChannelMember(channel.id); const post = { user_id: currentUserId, channel_id: channel.id, message: value, }; const data = { files, post, requestId: generateId().replace(/-/g, ''), useBackgroundUpload: this.useBackgroundUpload, certificate: certificate || '', }; this.setState({sending: true}); onClose(data); } catch (error) { this.setState({error}); setTimeout(() => { onClose(); }, 5000); } } }); updateChannelsInEntities = (newChannels) => { const {entities} = this.state; const newEntities = { ...entities, channels: { ...entities.channels, channels: {...entities.channels.channels}, channelsInTeam: {...entities.channels.channelsInTeam}, }, }; const {channels, channelsInTeam} = newEntities.channels; newChannels.forEach((c) => { channels[c.id] = c; const channelIdsInTeam = channelsInTeam[c.team_id]; if (channelIdsInTeam) { if (!channelIdsInTeam.includes(c.id)) { channelsInTeam[c.team_id].push(c.id); } } else { channelsInTeam[c.team_id] = [c.id]; } }); this.setState({entities: newEntities}); mattermostBucket.writeToFile('entities', JSON.stringify(newEntities), Config.AppGroupId); }; render() { const {authenticated, theme, title} = this.props; const {channel, error, totalSize, sending} = this.state; const {formatMessage} = this.context.intl; const styles = getStyleSheet(theme); let postButtonText = formatMessage({id: 'mobile.share_extension.send', defaultMessage: 'Send'}); if (totalSize >= MAX_FILE_SIZE || sending || error || !channel) { postButtonText = null; } let cancelButton = formatMessage({id: 'mobile.share_extension.cancel', defaultMessage: 'Cancel'}); if (sending) { cancelButton = null; } else if (error) { cancelButton = formatMessage({id: 'mobile.share_extension.error_close', defaultMessage: 'Close'}); } return ( {this.renderBody(styles)} {!error && this.renderTeamButton(styles)} {!error && this.renderChannelButton(styles)} ); } } const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { flex: { flex: 1, }, container: { flex: 1, backgroundColor: changeOpacity(theme.centerChannelColor, 0.05), }, input: { color: theme.centerChannelColor, fontSize: 17, marginBottom: 5, width: '100%', }, divider: { backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), height: 1, marginVertical: 5, width: '100%', }, scrollView: { paddingHorizontal: 15, }, buttonContainer: { borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), borderTopWidth: 1, height: 45, paddingHorizontal: 15, }, buttonWrapper: { alignItems: 'center', flex: 1, flexDirection: 'row', }, buttonLabelContainer: { flex: 1, }, buttonLabel: { fontSize: 17, lineHeight: 45, }, buttonValueContainer: { justifyContent: 'flex-end', flex: 1, flexDirection: 'row', }, buttonValue: { color: changeOpacity(theme.centerChannelColor, 0.4), alignSelf: 'flex-end', fontSize: 17, lineHeight: 45, }, arrowContainer: { height: 45, justifyContent: 'center', marginLeft: 15, top: 2, }, unauthenticatedContainer: { alignItems: 'center', flex: 1, justifyContent: 'center', paddingHorizontal: 15, }, 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: { borderBottomLeftRadius: 4, borderTopLeftRadius: 4, height: 48, marginRight: 10, width: 38, }, image: { alignItems: 'center', height: 48, justifyContent: 'center', overflow: 'hidden', width: 38, }, video: { backgroundColor: theme.centerChannelBg, alignItems: 'center', height: 48, justifyContent: 'center', overflow: 'hidden', width: 38, }, sendingContainer: { alignItems: 'center', flex: 1, justifyContent: 'center', paddingHorizontal: 15, }, sendingText: { color: theme.centerChannelColor, fontSize: 16, paddingTop: 10, }, }; });