// 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 { Alert, Animated, Platform, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View, findNodeHandle, } from 'react-native'; import RNFetchBlob from 'rn-fetch-blob'; import Icon from 'react-native-vector-icons/Ionicons'; import LinearGradient from 'react-native-linear-gradient'; import {intlShape} from 'react-intl'; import Permissions from 'react-native-permissions'; import Gallery from 'react-native-image-gallery'; import DeviceInfo from 'react-native-device-info'; import FastImage from 'react-native-fast-image'; import EventEmitter from '@mm-redux/utils/event_emitter'; import FileAttachmentDocument from 'app/components/file_attachment_list/file_attachment_document'; import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon'; import {DeviceTypes, NavigationTypes} from 'app/constants'; import {getLocalFilePathFromFile, isDocument, isVideo} from 'app/utils/file'; import {emptyFunction} from 'app/utils/general'; import {calculateDimensions} from 'app/utils/images'; import {t} from 'app/utils/i18n'; import BottomSheet from 'app/utils/bottom_sheet'; import { dismissModal, mergeNavigationOptions, } from 'app/actions/navigation'; import Downloader from './downloader'; import VideoPreview from './video_preview'; const {VIDEOS_PATH} = DeviceTypes; const {View: AnimatedView} = Animated; const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView); const HEADER_HEIGHT = 48; const ANIM_CONFIG = {duration: 300, userNativeDriver: true}; export default class ImagePreview extends PureComponent { static propTypes = { componentId: PropTypes.string.isRequired, canDownloadFiles: PropTypes.bool.isRequired, deviceHeight: PropTypes.number.isRequired, deviceWidth: PropTypes.number.isRequired, files: PropTypes.array, getItemMeasures: PropTypes.func.isRequired, index: PropTypes.number.isRequired, origin: PropTypes.object, target: PropTypes.object, theme: PropTypes.object.isRequired, }; static defaultProps = { files: [], }; static contextTypes = { intl: intlShape, }; constructor(props) { super(props); const options = { layout: { backgroundColor: '#000', }, }; mergeNavigationOptions(props.componentId, options); this.openAnim = new Animated.Value(0); this.headerFooterAnim = new Animated.Value(1); this.documents = []; this.state = { index: props.index, origin: props.origin, showDownloader: false, target: props.target, }; this.headerRef = React.createRef(); } componentDidMount() { this.startOpenAnimation(); } componentWillUnmount() { StatusBar.setHidden(false, 'fade'); } setDocumentRef = (ref) => { this.documents[this.state.index] = ref; } setDownloaderRef = (ref) => { this.downloaderRef = ref; } animateOpenAnimToValue = (toValue, onComplete) => { Animated.timing(this.openAnim, { ...ANIM_CONFIG, toValue, }).start(() => { this.setState({animating: false}); if (onComplete) { onComplete(); } }); }; close = () => { const {getItemMeasures, componentId} = this.props; const {index} = this.state; this.setState({animating: true}); const options = { layout: { backgroundColor: 'transparent', }, }; mergeNavigationOptions(componentId, options); getItemMeasures(index, (origin) => { if (origin) { this.setState(origin); } this.animateOpenAnimToValue(0, () => { dismissModal(); }); }); }; handleChangeImage = (index) => { this.setState({index}); }; handleSwipedVertical = (evt, gestureState) => { if (Math.abs(gestureState.dy) > 150) { this.close(); } }; handleTapped = () => { const {showHeaderFooter} = this.state; this.setHeaderAndFooterVisible(!showHeaderFooter); }; hideDownloader = (hideFileInfo = true) => { this.setState({showDownloader: false}); if (hideFileInfo) { this.setHeaderAndFooterVisible(true); } }; getCurrentFile = () => { const {files} = this.props; const {index} = this.state; const file = files[index]; return file; }; getFullscreenOpacity = () => { const {target} = this.props; return { opacity: this.openAnim.interpolate({ inputRange: [0, 1], outputRange: [0, target.opacity], }), }; }; getHeaderFooterStyle = () => { return { start: this.headerFooterAnim.interpolate({ inputRange: [0, 1], outputRange: [-80, 0], }), opacity: this.headerFooterAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], }), }; }; renderAttachmentDocument = (file) => { const {canDownloadFiles, theme} = this.props; return ( ); }; renderAttachmentIcon = (file) => { return ( ); }; renderDownloadButton = () => { const {canDownloadFiles} = this.props; const file = this.getCurrentFile(); if (file?.data?.localPath) { // we already have the file locally we don't need to download it return null; } if (file) { let icon; let action = emptyFunction; if (canDownloadFiles) { action = this.showDownloadOptions; if (Platform.OS === 'android') { icon = ( ); } else if (file.source || isVideo(file.data)) { icon = ( ); } } return ( {icon} ); } return null; }; renderDownloader = () => { const {deviceHeight, deviceWidth} = this.props; const file = this.getCurrentFile(); return ( ); }; renderFooter = () => { const {files} = this.props; const {index} = this.state; const footer = this.getHeaderFooterStyle(); return ( {(files[index] && files[index].caption) || ''} ); }; renderGallery = () => { return ( ); }; renderHeader = () => { const {files} = this.props; const {index} = this.state; const header = this.getHeaderFooterStyle(); return ( {`${index + 1}/${files.length}`} {this.renderDownloadButton()} ); }; renderImageComponent = (imageProps, imageDimensions) => { if (imageDimensions) { const {deviceHeight, deviceWidth} = this.props; const {height, width} = imageDimensions; const {style, source} = imageProps; const statusBar = DeviceTypes.IS_IPHONE_WITH_INSETS ? 0 : 20; const flattenStyle = StyleSheet.flatten(style); const calculatedDimensions = calculateDimensions(height, width, deviceWidth, deviceHeight - statusBar); const imageStyle = {...flattenStyle, ...calculatedDimensions}; return ( ); } return null; }; renderOtherItems = (index) => { const {files} = this.props; const file = files[index]; if (file.data) { if (isDocument(file.data)) { return this.renderAttachmentDocument(file); } else if (isVideo(file.data)) { return this.renderVideoPreview(file); } return this.renderAttachmentIcon(file.data); } return ; }; renderVideoPreview = (file) => { const {deviceHeight, deviceWidth, theme} = this.props; const statusBar = DeviceTypes.IS_IPHONE_WITH_INSETS ? 0 : 20; return ( ); }; saveVideoIOS = () => { const file = this.getCurrentFile(); if (this.downloaderRef) { EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL); this.downloaderRef.saveVideo(getLocalFilePathFromFile(VIDEOS_PATH, file)); } }; setHeaderAndFooterVisible = (show) => { const toValue = show ? 1 : 0; if (!show) { this.hideDownloader(); } this.setState({showHeaderFooter: show}); if (Platform.OS === 'ios') { StatusBar.setHidden(!show, 'slide'); } Animated.timing(this.headerFooterAnim, { ...ANIM_CONFIG, toValue, }).start(); }; showDownloader = () => { EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL); this.setState({ showDownloader: true, }); }; showDownloadOptions = () => { if (Platform.OS === 'android') { if (this.state.showDownloader) { this.hideDownloader(); } else { this.showDownloader(); } } else { this.showDownloadOptionsIOS(); } }; showDownloadOptionsIOS = async () => { const {formatMessage} = this.context.intl; const file = this.getCurrentFile(); const options = []; const actions = []; let permissionRequest; const photo = Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY; const hasPermissionToStorage = await Permissions.check(photo); switch (hasPermissionToStorage) { case Permissions.RESULTS.DENIED: permissionRequest = await Permissions.request(photo); if (permissionRequest !== Permissions.RESULTS.GRANTED) { return; } break; case Permissions.RESULTS.BLOCKED: { const grantOption = { text: formatMessage({id: 'mobile.permission_denied_retry', defaultMessage: 'Settings'}), onPress: () => Permissions.openSettings(), }; const applicationName = DeviceInfo.getApplicationName(); Alert.alert( formatMessage({ id: 'mobile.photo_library_permission_denied_title', defaultMessage: '{applicationName} would like to access your photo library', }, {applicationName}), formatMessage({ id: 'mobile.photo_library_permission_denied_description', defaultMessage: 'To save images and videos to your library, please change your permission settings.', }), [ grantOption, {text: formatMessage({id: 'mobile.permission_denied_dismiss', defaultMessage: 'Don\'t Allow'})}, ], ); return; } } if (isVideo(file.data)) { const path = getLocalFilePathFromFile(VIDEOS_PATH, file); const exist = await RNFetchBlob.fs.exists(path); if (exist) { options.push(formatMessage({ id: t('mobile.image_preview.save_video'), defaultMessage: 'Save Video', })); actions.push(this.saveVideoIOS); } else { this.showVideoDownloadRequiredAlertIOS(); } } else { options.push(formatMessage({ id: t('mobile.image_preview.save'), defaultMessage: 'Save Image', })); actions.push(this.showDownloader); } if (options.length) { options.push(formatMessage({ id: 'mobile.post.cancel', defaultMessage: 'Cancel', })); actions.push(emptyFunction); BottomSheet.showBottomSheetWithOptions({ options, cancelButtonIndex: options.length - 1, title: file.caption, anchor: this.headerRef.current ? findNodeHandle(this.headerRef.current) : null, }, (buttonIndex) => { actions[buttonIndex](); }); } }; showVideoDownloadRequiredAlertIOS = () => { const {intl} = this.context; Alert.alert( intl.formatMessage({ id: 'mobile.video.save_error_title', defaultMessage: 'Save Video Error', }), intl.formatMessage({ id: 'mobile.video.save_error_message', defaultMessage: 'To save the video file you need to download it first.', }), [{ text: intl.formatMessage({ id: 'mobile.server_upgrade.button', defaultMessage: 'OK', }), }], ); }; startOpenAnimation = () => { this.animateOpenAnimToValue(1); }; render() { const opacity = this.getFullscreenOpacity(); return ( {this.renderGallery()} {this.renderHeader()} {this.renderFooter()} {this.renderDownloader()} ); } } const style = StyleSheet.create({ container: { flex: 1, backgroundColor: '#000', }, flex: { flex: 1, }, center: { alignItems: 'center', justifyContent: 'center', }, fullWidth: { width: '100%', }, headerContainer: { position: 'absolute', height: HEADER_HEIGHT, width: '100%', overflow: 'hidden', }, header: { backgroundColor: 'rgba(0, 0, 0, 0.8)', height: HEADER_HEIGHT, position: 'absolute', top: 0, left: 0, width: '100%', }, headerControls: { alignItems: 'center', justifyContent: 'space-around', flexDirection: 'row', }, headerIcon: { height: 44, width: 48, alignItems: 'center', justifyContent: 'center', }, title: { flex: 1, marginHorizontal: 10, color: 'white', fontSize: 15, textAlign: 'center', }, footerContainer: { minHeight: 32, maxHeight: 64, justifyContent: 'center', overflow: 'hidden', position: 'absolute', width: '100%', }, footer: { maxHeight: 64, justifyContent: 'flex-end', paddingHorizontal: 24, paddingBottom: 0, }, filename: { color: 'white', fontSize: 14, marginBottom: 10, }, });