diff --git a/app/actions/views/file_preview.js b/app/actions/views/file_preview.js deleted file mode 100644 index 7b61aa6d2..000000000 --- a/app/actions/views/file_preview.js +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import {ViewTypes} from 'app/constants'; - -export function addFileToFetchCache(url) { - return { - type: ViewTypes.ADD_FILE_TO_FETCH_CACHE, - url, - }; -} diff --git a/app/components/file_attachment_list/file_attachment.js b/app/components/file_attachment_list/file_attachment.js index 737e0e8bc..f97238d1b 100644 --- a/app/components/file_attachment_list/file_attachment.js +++ b/app/components/file_attachment_list/file_attachment.js @@ -9,19 +9,22 @@ import { View, } from 'react-native'; -import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import * as Utils from 'mattermost-redux/utils/file_utils.js'; -import FileAttachmentDocument, {SUPPORTED_DOCS_FORMAT} from './file_attachment_document'; +import {isDocument, isGif} from 'app/utils/file'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import FileAttachmentDocument from './file_attachment_document'; import FileAttachmentIcon from './file_attachment_icon'; import FileAttachmentImage from './file_attachment_image'; export default class FileAttachment extends PureComponent { static propTypes = { - addFileToFetchCache: PropTypes.func.isRequired, deviceWidth: PropTypes.number.isRequired, - fetchCache: PropTypes.object.isRequired, file: PropTypes.object.isRequired, + index: PropTypes.number.isRequired, + onCaptureRef: PropTypes.func, + onCapturePreviewRef: PropTypes.func, onInfoPress: PropTypes.func, onPreviewPress: PropTypes.func, theme: PropTypes.object.isRequired, @@ -33,8 +36,24 @@ export default class FileAttachment extends PureComponent { onPreviewPress: () => true, }; + handleCaptureRef = (ref) => { + const {onCaptureRef, index} = this.props; + + if (onCaptureRef) { + onCaptureRef(ref, index); + } + }; + + handleCapturePreviewRef = (ref) => { + const {onCapturePreviewRef, index} = this.props; + + if (onCapturePreviewRef) { + onCapturePreviewRef(ref, index); + } + }; + handlePreviewPress = () => { - this.props.onPreviewPress(this.props.file); + this.props.onPreviewPress(this.props.index); }; renderFileInfo() { @@ -63,32 +82,33 @@ export default class FileAttachment extends PureComponent { } render() { - const {deviceWidth, file, onInfoPress, theme, navigator} = this.props; + const { + deviceWidth, + file, + onInfoPress, + theme, + navigator, + } = this.props; const style = getStyleSheet(theme); - let mime = file.mime_type || file.type; - if (mime && mime.includes(';')) { - mime = mime.split(';')[0]; - } - let fileAttachmentComponent; - if (file.has_preview_image || file.loading || mime === 'image/gif') { + if (file.has_preview_image || file.loading || isGif(file)) { fileAttachmentComponent = ( ); - } else if (SUPPORTED_DOCS_FORMAT.includes(mime)) { + } else if (isDocument(file)) { fileAttachmentComponent = ( ); } else { @@ -96,6 +116,8 @@ export default class FileAttachment extends PureComponent { diff --git a/app/components/file_attachment_list/file_attachment_document.js b/app/components/file_attachment_list/file_attachment_document.js index 5e3cc9ce1..49a2af9c9 100644 --- a/app/components/file_attachment_list/file_attachment_document.js +++ b/app/components/file_attachment_list/file_attachment_document.js @@ -5,7 +5,10 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { Alert, + NativeModules, + NativeEventEmitter, Platform, + StatusBar, StyleSheet, TouchableOpacity, View, @@ -14,30 +17,16 @@ import OpenFile from 'react-native-doc-viewer'; import RNFetchBlob from 'react-native-fetch-blob'; import {CircularProgress} from 'react-native-circular-progress'; import {intlShape} from 'react-intl'; +import tinyColor from 'tinycolor2'; -import {changeOpacity} from 'app/utils/theme'; import {getFileUrl} from 'mattermost-redux/utils/file_utils.js'; import {DeviceTypes} from 'app/constants/'; +import {changeOpacity} from 'app/utils/theme'; import FileAttachmentIcon from './file_attachment_icon'; const {DOCUMENTS_PATH} = DeviceTypes; -export const SUPPORTED_DOCS_FORMAT = [ - 'application/json', - 'application/msword', - 'application/pdf', - 'application/rtf', - 'application/vnd.ms-excel', - 'application/vnd.ms-powerpoint', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/x-x509-ca-cert', - 'application/xml', - 'text/csv', - 'text/plain', -]; const TEXT_PREVIEW_FORMATS = [ 'application/json', @@ -75,10 +64,13 @@ export default class FileAttachmentDocument extends PureComponent { componentDidMount() { this.mounted = true; + this.eventEmitter = new NativeEventEmitter(NativeModules.RNReactNativeDocViewer); + this.eventEmitter.addListener('DoneButtonEvent', () => this.setStatusBarColor()); } componentWillUnmount() { this.mounted = false; + this.eventEmitter.removeListener(); } cancelDownload = () => { @@ -91,6 +83,22 @@ export default class FileAttachmentDocument extends PureComponent { } }; + setStatusBarColor = (style) => { + if (Platform.OS === 'ios') { + if (style) { + StatusBar.setBarStyle(style, true); + } else { + const {theme} = this.props; + const headerColor = tinyColor(theme.sidebarHeaderBg); + let barStyle = 'light-content'; + if (headerColor.isLight() && Platform.OS === 'ios') { + barStyle = 'dark-content'; + } + StatusBar.setBarStyle(barStyle, true); + } + } + }; + downloadAndPreviewFile = async (file) => { const path = `${DOCUMENTS_PATH}/${file.name}`; @@ -207,6 +215,7 @@ export default class FileAttachmentDocument extends PureComponent { if (!this.state.didCancel && this.mounted) { const prefix = Platform.OS === 'android' ? 'file:/' : ''; const path = `${DOCUMENTS_PATH}/${file.name}`; + this.setStatusBarColor('dark-content'); OpenFile.openDoc([{ url: `${prefix}${path}`, fileName: file.name, @@ -233,8 +242,10 @@ export default class FileAttachmentDocument extends PureComponent { }), }] ); + this.setStatusBarColor(); RNFetchBlob.fs.unlink(path); } + this.setState({downloading: false, progress: 0}); }); } diff --git a/app/components/file_attachment_list/file_attachment_icon.js b/app/components/file_attachment_list/file_attachment_icon.js index e95b898b9..f2d60e096 100644 --- a/app/components/file_attachment_list/file_attachment_icon.js +++ b/app/components/file_attachment_list/file_attachment_icon.js @@ -5,12 +5,13 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { View, - Image, StyleSheet, } from 'react-native'; import * as Utils from 'mattermost-redux/utils/file_utils'; +import ProgressiveImage from 'app/components/progressive_image'; + import audioIcon from 'assets/images/icons/audio.png'; import codeIcon from 'assets/images/icons/code.png'; import excelIcon from 'assets/images/icons/excel.png'; @@ -40,6 +41,8 @@ export default class FileAttachmentIcon extends PureComponent { file: PropTypes.object.isRequired, iconHeight: PropTypes.number, iconWidth: PropTypes.number, + onCaptureRef: PropTypes.func, + onCapturePreviewRef: PropTypes.func, wrapperHeight: PropTypes.number, wrapperWidth: PropTypes.number, }; @@ -56,16 +59,35 @@ export default class FileAttachmentIcon extends PureComponent { return ICON_PATH_FROM_FILE_TYPE[fileType] || ICON_PATH_FROM_FILE_TYPE.other; } + handleCaptureRef = (ref) => { + const {onCaptureRef} = this.props; + + if (onCaptureRef) { + onCaptureRef(ref); + } + }; + + handleCapturePreviewRef = (ref) => { + const {onCapturePreviewRef} = this.props; + + if (onCapturePreviewRef) { + onCapturePreviewRef(ref); + } + }; + render() { const {file, iconHeight, iconWidth, wrapperHeight, wrapperWidth} = this.props; const source = this.getFileIconPath(file); return ( - - + ); diff --git a/app/components/file_attachment_list/file_attachment_image.js b/app/components/file_attachment_list/file_attachment_image.js index 8c4c45dcf..eeeaa1de2 100644 --- a/app/components/file_attachment_list/file_attachment_image.js +++ b/app/components/file_attachment_list/file_attachment_image.js @@ -4,18 +4,17 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { - ActivityIndicator, Animated, View, - Image, StyleSheet, } from 'react-native'; import {Client4} from 'mattermost-redux/client'; -import imageIcon from 'assets/images/icons/image.png'; - -const {View: AnimatedView} = Animated; +import ProgressiveImage from 'app/components/progressive_image'; +import {isGif} from 'app/utils/file'; +import {emptyFunction} from 'app/utils/general'; +import ImageCacheManager from 'app/utils/image_cache_manager'; const IMAGE_SIZE = { Fullsize: 'fullsize', @@ -25,9 +24,7 @@ const IMAGE_SIZE = { export default class FileAttachmentImage extends PureComponent { static propTypes = { - addFileToFetchCache: PropTypes.func.isRequired, - fetchCache: PropTypes.object.isRequired, - file: PropTypes.object, + file: PropTypes.object.isRequired, imageHeight: PropTypes.number, imageSize: PropTypes.oneOf([ IMAGE_SIZE.Fullsize, @@ -35,10 +32,10 @@ export default class FileAttachmentImage extends PureComponent { IMAGE_SIZE.Thumbnail, ]), imageWidth: PropTypes.number, - loadingBackgroundColor: PropTypes.string, + onCaptureRef: PropTypes.func, + onCapturePreviewRef: PropTypes.func, resizeMode: PropTypes.string, resizeMethod: PropTypes.string, - wrapperBackgroundColor: PropTypes.string, wrapperHeight: PropTypes.number, wrapperWidth: PropTypes.number, }; @@ -49,70 +46,30 @@ export default class FileAttachmentImage extends PureComponent { imageSize: IMAGE_SIZE.Preview, imageWidth: 80, loading: false, - loadingBackgroundColor: '#fff', resizeMode: 'cover', resizeMethod: 'resize', - wrapperBackgroundColor: '#fff', wrapperHeight: 80, wrapperWidth: 80, }; - state = { - opacity: new Animated.Value(0), - requesting: true, - retry: 0, - }; + constructor(props) { + super(props); - // Sometimes the request after a file upload errors out. - // We'll up to three times to get the image. - // We have to add a timestamp so fetch will retry the call. - handleLoadError = () => { - if (this.state.retry < 4) { - setTimeout(() => { - this.setState({ - retry: (this.state.retry + 1), - timestamp: Date.now(), - }); - }, 300); + const {file} = props; + if (file && file.id) { + ImageCacheManager.cache(file.name, Client4.getFileThumbnailUrl(file.id), emptyFunction); + + if (isGif(file)) { + ImageCacheManager.cache(file.name, Client4.getFileUrl(file.id), emptyFunction); + } } - }; - handleLoad = () => { - this.setState({ - requesting: false, - }); - - Animated.timing(this.state.opacity, { - toValue: 1, - duration: 300, - }).start(() => { - this.props.addFileToFetchCache(this.handleGetImageURL()); - }); - }; - - handleLoadStart = () => { - this.setState({ + this.state = { + opacity: new Animated.Value(0), requesting: true, - }); - }; - - handleGetImageURL = () => { - const {file, imageSize} = this.props; - - if (file.localPath && this.state.retry === 0) { - return file.localPath; - } - - switch (imageSize) { - case IMAGE_SIZE.Fullsize: - return Client4.getFileUrl(file.id, this.state.timestamp); - case IMAGE_SIZE.Preview: - return Client4.getFilePreviewUrl(file.id, this.state.timestamp); - case IMAGE_SIZE.Thumbnail: - default: - return Client4.getFileThumbnailUrl(file.id, this.state.timestamp); - } - }; + retry: 0, + }; + } calculateNeededWidth = (height, width, newHeight) => { const ratio = width / height; @@ -125,40 +82,34 @@ export default class FileAttachmentImage extends PureComponent { return newWidth; }; + handleCaptureRef = (ref) => { + const {onCaptureRef} = this.props; + + if (onCaptureRef) { + onCaptureRef(ref); + } + }; + + handleCapturePreviewRef = (ref) => { + const {onCapturePreviewRef} = this.props; + + if (onCapturePreviewRef) { + onCapturePreviewRef(ref); + } + }; + render() { const { - fetchCache, file, imageHeight, imageWidth, imageSize, - loadingBackgroundColor, resizeMethod, resizeMode, - wrapperBackgroundColor, wrapperHeight, wrapperWidth, } = this.props; - let source = {}; - - if (this.state.retry === 4) { - source = imageIcon; - } else if (file.failed || file.localPath) { - source = {uri: file.localPath}; - } else if (file.id) { - source = {uri: this.handleGetImageURL()}; - } - - const isInFetchCache = fetchCache[source.uri] || Boolean(file.localPath); - - const imageComponentLoaders = { - onError: isInFetchCache ? null : this.handleLoadError, - onLoadStart: isInFetchCache ? null : this.handleLoadStart, - onLoad: isInFetchCache ? null : this.handleLoad, - }; - const opacity = isInFetchCache ? 1 : this.state.opacity; - let height = imageHeight; let width = imageWidth; let imageStyle = {height, width}; @@ -168,22 +119,27 @@ export default class FileAttachmentImage extends PureComponent { imageStyle = {height, width, position: 'absolute', top: 0, left: 0, borderBottomLeftRadius: 2, borderTopLeftRadius: 2}; } + const imageProps = {}; + if (file.localPath) { + imageProps.defaultSource = {uri: file.localPath}; + } else { + imageProps.thumbnailUri = Client4.getFileThumbnailUrl(file.id); + imageProps.imageUri = Client4.getFilePreviewUrl(file.id); + } + return ( - - - - - {(!isInFetchCache && !file.failed && (file.loading || this.state.requesting)) && - - - - } + + ); } diff --git a/app/components/file_attachment_list/file_attachment_list.js b/app/components/file_attachment_list/file_attachment_list.js index db52649de..0da912547 100644 --- a/app/components/file_attachment_list/file_attachment_list.js +++ b/app/components/file_attachment_list/file_attachment_list.js @@ -5,22 +5,27 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import { Keyboard, + Platform, ScrollView, StyleSheet, TouchableOpacity, View, } from 'react-native'; +import {Client4} from 'mattermost-redux/client'; import {RequestStatus} from 'mattermost-redux/constants'; +import {isDocument, isGif, isVideo} from 'app/utils/file'; +import {getCacheFile} from 'app/utils/image_cache_manager'; import {preventDoubleTap} from 'app/utils/tap'; + import FileAttachment from './file_attachment'; export default class FileAttachmentList extends Component { static propTypes = { actions: PropTypes.object.isRequired, + deviceHeight: PropTypes.number.isRequired, deviceWidth: PropTypes.number.isRequired, - fetchCache: PropTypes.object.isRequired, fileIds: PropTypes.array.isRequired, files: PropTypes.array.isRequired, hideOptionsContext: PropTypes.func.isRequired, @@ -34,11 +39,30 @@ export default class FileAttachmentList extends Component { filesForPostRequest: PropTypes.object.isRequired, }; + constructor(props) { + super(props); + + this.items = []; + this.previewItems = []; + + this.buildGalleryFiles(props).then((results) => { + this.galleryFiles = results; + }); + } + componentDidMount() { const {postId} = this.props; this.props.actions.loadFilesForPostIfNecessary(postId); } + componentWillReceiveProps(nextProps) { + if (this.props.files !== nextProps.files) { + this.buildGalleryFiles(nextProps).then((results) => { + this.galleryFiles = results; + }); + } + } + componentDidUpdate() { const {fileIds, files, filesForPostRequest, postId} = this.props; @@ -48,34 +72,121 @@ export default class FileAttachmentList extends Component { } } - goToImagePreview = (postId, fileId) => { + buildGalleryFiles = async (props) => { + const {files} = props; + const results = []; + + if (files && files.length) { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const caption = file.name; + + if (isDocument(file) || isVideo(file) || (!file.has_preview_image && !isGif(file))) { + results.push({ + caption, + data: file, + }); + continue; + } + + let uri; + let cache; + if (file.localPath) { + uri = file.localPath; + } else if (isGif(file)) { + cache = await getCacheFile(file.name, Client4.getFileUrl(file.id)); + } else { + cache = await getCacheFile(file.name, Client4.getFilePreviewUrl(file.id)); + } + + if (cache) { + let path = cache.path; + if (Platform.OS === 'android') { + path = `file://${path}`; + } + + uri = path; + } + + results.push({ + caption, + source: {uri}, + data: file, + }); + } + } + + return results; + }; + + getItemMeasures = (index, cb) => { + const activeComponent = this.items[index]; + + if (!activeComponent) { + cb(null); + return; + } + + activeComponent.measure((rx, ry, width, height, x, y) => { + cb({ + origin: {x, y, width, height}, + }); + }); + }; + + getPreviewProps = (index) => { + const previewComponent = this.previewItems[index]; + return previewComponent ? {...previewComponent.props} : {}; + }; + + goToImagePreview = (passProps) => { this.props.navigator.showModal({ screen: 'ImagePreview', title: '', animationType: 'none', - passProps: { - fileId, - postId, - }, + passProps, navigatorStyle: { navBarHidden: true, statusBarHidden: false, statusBarHideWithNavBar: false, - screenBackgroundColor: 'black', + screenBackgroundColor: 'transparent', modalPresentationStyle: 'overCurrentContext', }, }); }; + handleCaptureRef = (ref, idx) => { + this.items[idx] = ref; + }; + + handleCapturePreviewRef = (ref, idx) => { + this.previewItems[idx] = ref; + }; + handleInfoPress = () => { this.props.hideOptionsContext(); this.props.onPress(); }; - handlePreviewPress = preventDoubleTap((file) => { + handlePreviewPress = preventDoubleTap((idx) => { this.props.hideOptionsContext(); Keyboard.dismiss(); - this.goToImagePreview(this.props.postId, file.id); + const component = this.items[idx]; + + if (!component) { + return; + } + + component.measure((rx, ry, width, height, x, y) => { + this.goToImagePreview({ + index: idx, + origin: {x, y, width, height}, + target: {x: 0, y: 0, opacity: 1}, + files: this.galleryFiles, + getItemMeasures: this.getItemMeasures, + getPreviewProps: this.getPreviewProps, + }); + }); }); handlePressIn = () => { @@ -86,43 +197,46 @@ export default class FileAttachmentList extends Component { this.props.toggleSelected(false); }; - render() { - const {deviceWidth, fileIds, files, isFailed, navigator} = this.props; + renderItems = () => { + const {deviceWidth, fileIds, files, navigator} = this.props; - let fileAttachments; if (!files.length && fileIds.length > 0) { - fileAttachments = fileIds.map((id) => ( + return fileIds.map((id, idx) => ( )); - } else { - fileAttachments = files.map((file) => ( - - - - )); } + return files.map((file, idx) => ( + + + + )); + }; + + render() { + const {fileIds, isFailed} = this.props; + return ( 1} style={[styles.flex, (isFailed && styles.failed)]} > - {fileAttachments} + {this.renderItems()} ); diff --git a/app/components/file_attachment_list/index.js b/app/components/file_attachment_list/index.js index 8fd6a09c3..e17c21c89 100644 --- a/app/components/file_attachment_list/index.js +++ b/app/components/file_attachment_list/index.js @@ -8,7 +8,6 @@ import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {loadFilesForPostIfNecessary} from 'app/actions/views/channel'; -import {addFileToFetchCache} from 'app/actions/views/file_preview'; import {getDimensions} from 'app/selectors/device'; import FileAttachmentList from './file_attachment_list'; @@ -18,7 +17,6 @@ function makeMapStateToProps() { return function mapStateToProps(state, ownProps) { return { ...getDimensions(state), - fetchCache: state.views.fetchCache, files: getFilesForPost(state, ownProps.postId), theme: getTheme(state), filesForPostRequest: state.requests.files.getFilesForPost, @@ -29,7 +27,6 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ - addFileToFetchCache, loadFilesForPostIfNecessary, }, dispatch), }; diff --git a/app/components/file_upload_preview/file_upload_item/file_upload_item.js b/app/components/file_upload_preview/file_upload_item/file_upload_item.js index 0d4afec23..793a32257 100644 --- a/app/components/file_upload_preview/file_upload_item/file_upload_item.js +++ b/app/components/file_upload_preview/file_upload_item/file_upload_item.js @@ -18,14 +18,12 @@ import {buildFileUploadData, encodeHeaderURIStringToUTF8} from 'app/utils/file'; export default class FileUploadItem extends PureComponent { static propTypes = { actions: PropTypes.shape({ - addFileToFetchCache: PropTypes.func.isRequired, handleRemoveFile: PropTypes.func.isRequired, retryFileUpload: PropTypes.func.isRequired, uploadComplete: PropTypes.func.isRequired, uploadFailed: PropTypes.func.isRequired, }).isRequired, channelId: PropTypes.string.isRequired, - fetchCache: PropTypes.object.isRequired, file: PropTypes.object.isRequired, rootId: PropTypes.string, theme: PropTypes.object.isRequired, @@ -157,22 +155,17 @@ export default class FileUploadItem extends PureComponent { render() { const { - actions, channelId, - fetchCache, file, rootId, theme, } = this.props; - const {addFileToFetchCache} = actions; const {progress} = this.state; let filePreviewComponent; if (this.isImageType()) { filePreviewComponent = ( { + const activeComponent = this.refs.item; + + if (!activeComponent) { + cb(null); + return; + } + + activeComponent.measure((rx, ry, width, height, x, y) => { + cb({ + origin: {x, y, width, height}, + }); + }); + }; + + getPreviewProps = () => { + const previewComponent = this.refs.image; + return previewComponent ? {...previewComponent.props} : {}; + }; + getSource = (props = this.props) => { let source = props.source; @@ -88,8 +112,20 @@ export default class MarkdownImage extends React.Component { return source; }; - loadImageSize = (source) => { - Image.getSize(source, this.handleSizeReceived, this.handleSizeFailed); + goToImagePreview = (passProps) => { + this.props.navigator.showModal({ + screen: 'ImagePreview', + title: '', + animationType: 'none', + passProps, + navigatorStyle: { + navBarHidden: true, + statusBarHidden: false, + statusBarHideWithNavBar: false, + screenBackgroundColor: 'transparent', + modalPresentationStyle: 'overCurrentContext', + }, + }); }; handleSizeReceived = (width, height) => { @@ -153,8 +189,61 @@ export default class MarkdownImage extends React.Component { Clipboard.setString(this.props.linkDestination); }; + handlePreviewImage = () => { + const component = this.refs.item; + + if (!component) { + return; + } + + component.measure((rx, ry, width, height, x, y) => { + const {uri} = this.state; + const link = this.getSource(); + let filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?')); + const extension = filename.split('.').pop(); + + if (extension === filename) { + const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.')); + filename = `${filename}${ext}`; + } + + const files = [{ + caption: filename, + source: {uri}, + data: { + localPath: uri, + }, + }]; + + this.goToImagePreview({ + index: 0, + origin: {x, y, width, height}, + target: {x: 0, y: 0, opacity: 1}, + files, + getItemMeasures: this.getItemMeasures, + getPreviewProps: this.getPreviewProps, + }); + }); + }; + + loadImageSize = (source) => { + Image.getSize(source, this.handleSizeReceived, this.handleSizeFailed); + }; + + setImageUrl = (imageURL) => { + let uri = imageURL; + + if (Platform.OS === 'android') { + uri = `file://${imageURL}`; + } + + this.setState({uri}); + this.loadImageSize(uri); + }; + render() { let image = null; + const {uri} = this.state; if (this.state.width && this.state.height && this.state.maxWidth) { let {width, height} = this.state; @@ -190,12 +279,24 @@ export default class MarkdownImage extends React.Component { } // React Native complains if we try to pass resizeMode as a style + let source = null; + if (uri) { + source = {uri}; + } + image = ( - + + + ); } } else if (this.state.failed) { @@ -224,7 +325,8 @@ export default class MarkdownImage extends React.Component { return ( {image} diff --git a/app/components/post_attachment_opengraph/post_attachment_opengraph.js b/app/components/post_attachment_opengraph/post_attachment_opengraph.js index 679825cf9..2b99dcc3e 100644 --- a/app/components/post_attachment_opengraph/post_attachment_opengraph.js +++ b/app/components/post_attachment_opengraph/post_attachment_opengraph.js @@ -7,11 +7,15 @@ import { Dimensions, Image, Linking, + Platform, Text, TouchableOpacity, + TouchableWithoutFeedback, View, } from 'react-native'; +import ProgressiveImage from 'app/components/progressive_image'; +import ImageCacheManager from 'app/utils/image_cache_manager'; import {getNearestPoint} from 'app/utils/opengraph'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; @@ -24,6 +28,7 @@ export default class PostAttachmentOpenGraph extends PureComponent { }).isRequired, isReplyPost: PropTypes.bool, link: PropTypes.string.isRequired, + navigator: PropTypes.object.isRequired, openGraphData: PropTypes.object, theme: PropTypes.object.isRequired, }; @@ -32,7 +37,8 @@ export default class PostAttachmentOpenGraph extends PureComponent { super(props); this.state = { - imageLoaded: false, + hasImage: false, + imageUrl: null, }; } @@ -47,7 +53,7 @@ export default class PostAttachmentOpenGraph extends PureComponent { componentWillReceiveProps(nextProps) { if (nextProps.link !== this.props.link) { - this.setState({imageLoaded: false}); + this.setState({hasImage: false}); this.fetchData(nextProps.link, nextProps.openGraphData); } @@ -96,37 +102,120 @@ export default class PostAttachmentOpenGraph extends PureComponent { width: Dimensions.get('window').width - 88, height: MAX_IMAGE_HEIGHT, }; + const bestImage = getNearestPoint(bestDimensions, data.images, 'width', 'height'); const imageUrl = bestImage.secure_url || bestImage.url; + this.setState({ + hasImage: true, + ...bestDimensions, + openGraphImageUrl: imageUrl, + }); + if (imageUrl) { - this.getImageSize(imageUrl); + ImageCacheManager.cache(null, imageUrl, this.getImageSize); } } getImageSize = (imageUrl) => { - if (!this.state.imageLoaded) { - Image.getSize(imageUrl, (width, height) => { - const dimensions = this.calculateLargeImageDimensions(width, height); - - if (this.mounted) { - this.setState({ - ...dimensions, - imageLoaded: true, - imageUrl, - }); - } - }, () => null); + let prefix = ''; + if (Platform.OS === 'android') { + prefix = 'file://'; } + + const uri = `${prefix}${imageUrl}`; + + Image.getSize(uri, (width, height) => { + const dimensions = this.calculateLargeImageDimensions(width, height); + + if (this.mounted) { + this.setState({ + ...dimensions, + imageUrl: uri, + }); + } + }, () => null); + }; + + getItemMeasures = (index, cb) => { + const activeComponent = this.refs.item; + + if (!activeComponent) { + cb(null); + return; + } + + activeComponent.measure((rx, ry, width, height, x, y) => { + cb({ + origin: {x, y, width, height}, + }); + }); + }; + + getPreviewProps = () => { + const previewComponent = this.refs.image; + return previewComponent ? {...previewComponent.props} : {}; + }; + + goToImagePreview = (passProps) => { + this.props.navigator.showModal({ + screen: 'ImagePreview', + title: '', + animationType: 'none', + passProps, + navigatorStyle: { + navBarHidden: true, + statusBarHidden: false, + statusBarHideWithNavBar: false, + screenBackgroundColor: 'transparent', + modalPresentationStyle: 'overCurrentContext', + }, + }); }; goToLink = () => { Linking.openURL(this.props.link); }; + handlePreviewImage = () => { + const component = this.refs.item; + + if (!component) { + return; + } + + component.measure((rx, ry, width, height, x, y) => { + const {imageUrl: uri, openGraphImageUrl: link} = this.state; + let filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?')); + const extension = filename.split('.').pop(); + + if (extension === filename) { + const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.')); + filename = `${filename}${ext}`; + } + + const files = [{ + caption: filename, + source: {uri}, + data: { + localPath: uri, + }, + }]; + + this.goToImagePreview({ + index: 0, + origin: {x, y, width, height}, + target: {x: 0, y: 0, opacity: 1}, + files, + getItemMeasures: this.getItemMeasures, + getPreviewProps: this.getPreviewProps, + }); + }); + }; + render() { const {isReplyPost, openGraphData, theme} = this.props; - const {height, imageLoaded, imageUrl, width} = this.state; + const {hasImage, height, imageUrl, width} = this.state; if (!openGraphData || !openGraphData.description) { return null; @@ -168,12 +257,20 @@ export default class PostAttachmentOpenGraph extends PureComponent { {openGraphData.description} - {imageLoaded && - + {hasImage && + + + + + } ); diff --git a/app/components/post_body_additional_content/post_body_additional_content.js b/app/components/post_body_additional_content/post_body_additional_content.js index bb5ff3e37..1ba6c13e2 100644 --- a/app/components/post_body_additional_content/post_body_additional_content.js +++ b/app/components/post_body_additional_content/post_body_additional_content.js @@ -5,7 +5,6 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { Image, - ImageBackground, Linking, Platform, StyleSheet, @@ -17,10 +16,13 @@ import youTubeVideoId from 'youtube-video-id'; import youtubePlayIcon from 'assets/images/icons/youtube-play-icon.png'; -import PostAttachmentOpenGraph from 'app/components/post_attachment_opengraph'; import MessageAttachments from 'app/components/message_attachments'; +import PostAttachmentOpenGraph from 'app/components/post_attachment_opengraph'; +import ProgressiveImage from 'app/components/progressive_image'; + import CustomPropTypes from 'app/constants/custom_prop_types'; import {emptyFunction} from 'app/utils/general'; +import ImageCacheManager from 'app/utils/image_cache_manager'; import {isImageLink, isYoutubeLink} from 'app/utils/url'; const MAX_IMAGE_HEIGHT = 150; @@ -56,6 +58,8 @@ export default class PostBodyAdditionalContent extends PureComponent { this.state = { linkLoadError: false, linkLoaded: false, + width: 0, + height: 0, }; this.mounted = false; @@ -63,7 +67,7 @@ export default class PostBodyAdditionalContent extends PureComponent { componentWillMount() { this.mounted = true; - this.getImageSize(); + this.load(this.props); } componentWillUnmount() { @@ -71,16 +75,29 @@ export default class PostBodyAdditionalContent extends PureComponent { } componentWillReceiveProps(nextProps) { - if (nextProps.message !== this.props.message) { - this.setState({ - linkLoadError: false, - linkLoaded: false, - }, () => { - this.getImageSize(); - }); + if (this.props.link !== nextProps.link) { + this.load(nextProps); } } + load = (props) => { + const {link} = props; + if (link) { + let imageUrl; + if (isImageLink(link)) { + imageUrl = link; + } else if (isYoutubeLink(link)) { + const videoId = youTubeVideoId(link); + imageUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`; + ImageCacheManager.cache(null, `https://i.ytimg.com/vi/${videoId}/default.jpg`, () => true); + } + + if (imageUrl) { + ImageCacheManager.cache(null, imageUrl, this.getImageSize); + } + } + }; + calculateDimensions = (width, height) => { const {deviceHeight, deviceWidth} = this.props; let maxHeight = MAX_IMAGE_HEIGHT; @@ -108,7 +125,7 @@ export default class PostBodyAdditionalContent extends PureComponent { return null; } - const {isReplyPost, link, openGraphData, showLinkPreviews, theme} = this.props; + const {isReplyPost, link, navigator, openGraphData, showLinkPreviews, theme} = this.props; const attachments = this.getMessageAttachment(); if (attachments) { return attachments; @@ -119,6 +136,7 @@ export default class PostBodyAdditionalContent extends PureComponent { @@ -128,35 +146,105 @@ export default class PostBodyAdditionalContent extends PureComponent { return null; }; - getImageSize = () => { + generateToggleableEmbed = (isImage, isYouTube) => { const {link} = this.props; - const {linkLoaded} = this.state; + const {width, height, uri} = this.state; + const imgHeight = height || MAX_IMAGE_HEIGHT; if (link) { - let imageUrl; - if (isImageLink(link)) { - imageUrl = link; - } else if (isYoutubeLink(link)) { + if (isYouTube) { const videoId = youTubeVideoId(link); - imageUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`; + const imgUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`; + const thumbUrl = `https://i.ytimg.com/vi/${videoId}/default.jpg`; + + return ( + + + + + + + + ); } - if (imageUrl && !linkLoaded) { - Image.getSize(imageUrl, (width, height) => { - if (!this.mounted) { - return; - } - - if (!width && !height) { - this.setState({linkLoadError: true}); - return; - } - - const dimensions = this.calculateDimensions(width, height); - this.setState({...dimensions, linkLoaded: true}); - }, () => null); + if (isImage) { + return ( + + + + + + ); } } + + return null; + }; + + getImageSize = (path) => { + const {link} = this.props; + + if (link && path) { + let prefix = ''; + if (Platform.OS === 'android') { + prefix = 'file://'; + } + + const uri = `${prefix}${path}`; + Image.getSize(uri, (width, height) => { + if (!this.mounted) { + return; + } + + if (!width && !height) { + this.setState({linkLoadError: true}); + return; + } + + const dimensions = this.calculateDimensions(width, height); + this.setState({...dimensions, linkLoaded: true, uri}); + }, () => this.setState({linkLoadError: true})); + } + }; + + getItemMeasures = (index, cb) => { + const activeComponent = this.refs.item; + + if (!activeComponent) { + cb(null); + return; + } + + activeComponent.measure((rx, ry, width, height, x, y) => { + cb({ + origin: {x, y, width, height}, + }); + }); }; getMessageAttachment = () => { @@ -191,54 +279,59 @@ export default class PostBodyAdditionalContent extends PureComponent { return null; }; - generateToggleableEmbed = (isImage, isYouTube) => { - const {link} = this.props; - const {width, height} = this.state; - const imgHeight = height || MAX_IMAGE_HEIGHT; + getPreviewProps = () => { + const previewComponent = this.refs.image; + return previewComponent ? {...previewComponent.props} : {}; + }; - if (link) { - if (isYouTube) { - const videoId = youTubeVideoId(link); - const imgUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`; + goToImagePreview = (passProps) => { + this.props.navigator.showModal({ + screen: 'ImagePreview', + title: '', + animationType: 'none', + passProps, + navigatorStyle: { + navBarHidden: true, + statusBarHidden: false, + statusBarHideWithNavBar: false, + screenBackgroundColor: 'transparent', + modalPresentationStyle: 'overCurrentContext', + }, + }); + }; - return ( - - - - - - - - ); - } + handleLinkLoadError = () => { + this.setState({linkLoadError: true}); + }; - if (isImage) { - return ( - - - - ); - } + handlePreviewImage = () => { + const component = this.refs.item; + + if (!component) { + return; } - return null; + component.measure((rx, ry, width, height, x, y) => { + const {link} = this.props; + const {uri} = this.state; + const filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?')); + const files = [{ + caption: filename, + source: {uri}, + data: { + localPath: uri, + }, + }]; + + this.goToImagePreview({ + index: 0, + origin: {x, y, width, height}, + target: {x: 0, y: 0, opacity: 1}, + files, + getItemMeasures: this.getItemMeasures, + getPreviewProps: this.getPreviewProps, + }); + }); }; playYouTubeVideo = () => { @@ -262,10 +355,6 @@ export default class PostBodyAdditionalContent extends PureComponent { } }; - handleLinkLoadError = () => { - this.setState({linkLoadError: true}); - }; - render() { const {link, openGraphData, postProps} = this.props; const {linkLoadError} = this.state; diff --git a/app/components/profile_picture/profile_picture.js b/app/components/profile_picture/profile_picture.js index c7089f8e3..8816748fb 100644 --- a/app/components/profile_picture/profile_picture.js +++ b/app/components/profile_picture/profile_picture.js @@ -6,13 +6,14 @@ import PropTypes from 'prop-types'; import {Image, Platform, View} from 'react-native'; import FontAwesomeIcon from 'react-native-vector-icons/FontAwesome'; +import {Client4} from 'mattermost-redux/client'; + import UserStatus from 'app/components/user_status'; +import ImageCacheManager from 'app/utils/image_cache_manager'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import placeholder from 'assets/images/profile.jpg'; -import {Client4} from 'mattermost-redux/client'; - const STATUS_BUFFER = Platform.select({ ios: 3, android: 2, @@ -42,25 +43,35 @@ export default class ProfilePicture extends PureComponent { edit: false, }; + state = { + pictureUrl: null, + }; + + componentWillMount() { + const {edit, imageUri, user} = this.props; + + if (edit && imageUri) { + this.setImageURL(imageUri); + } else { + ImageCacheManager.cache('', Client4.getProfilePictureUrl(user.id, user.last_picture_update), this.setImageURL); + } + } + componentDidMount() { if (!this.props.status && this.props.user) { this.props.actions.getStatusForId(this.props.user.id); } } + setImageURL = (pictureUrl) => { + this.setState({pictureUrl}); + }; + render() { - const {edit, imageUri, showStatus, theme} = this.props; + const {edit, showStatus, theme} = this.props; + const {pictureUrl} = this.state; const style = getStyleSheet(theme); - let pictureUrl; - if (this.props.user) { - pictureUrl = Client4.getProfilePictureUrl(this.props.user.id, this.props.user.last_picture_update); - } - - if (edit && imageUri) { - pictureUrl = imageUri; - } - let statusIcon; let statusStyle; if (edit) { @@ -86,12 +97,24 @@ export default class ProfilePicture extends PureComponent { ); } + let source = null; + if (pictureUrl) { + let prefix = ''; + if (Platform.OS === 'android') { + prefix = 'file://'; + } + + source = { + uri: `${prefix}${pictureUrl}`, + }; + } + return ( {(showStatus || edit) && diff --git a/app/components/progressive_image.js b/app/components/progressive_image.js new file mode 100644 index 000000000..aa6e1977c --- /dev/null +++ b/app/components/progressive_image.js @@ -0,0 +1,163 @@ +// 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 {Animated, Image, ImageBackground, Platform, View, StyleSheet} from 'react-native'; + +import CustomPropTypes from 'app/constants/custom_prop_types'; +import ImageCacheManager from 'app/utils/image_cache_manager'; + +const AnimatedImageBackground = Animated.createAnimatedComponent(ImageBackground); + +export default class ProgressiveImage extends PureComponent { + static propTypes = { + isBackgroundImage: PropTypes.bool, + children: CustomPropTypes.Children, + defaultSource: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), // this should be provided by the component + filename: PropTypes.string, + imageUri: PropTypes.string, + style: CustomPropTypes.Style, + thumbnailUri: PropTypes.string, + }; + + constructor(props) { + super(props); + + this.subscribedToCache = true; + + this.state = { + intensity: null, + thumb: null, + uri: null, + }; + } + + componentWillMount() { + const intensity = new Animated.Value(100); + this.setState({intensity}); + this.load(this.props); + } + + componentWillReceiveProps(props) { + this.load(props); + } + + componentDidUpdate(prevProps, prevState) { + const {intensity, thumb, uri} = this.state; + if (uri && thumb && uri !== thumb && prevState.uri !== uri) { + Animated.timing(intensity, { + duration: 300, + toValue: 0, + useNativeDriver: Platform.OS === 'android', + }).start(); + } + } + + componentWillUnmount() { + this.subscribedToCache = false; + } + + load = (props) => { + const {filename, imageUri, style, thumbnailUri} = props; + this.style = [ + StyleSheet.absoluteFill, + ...style, + ]; + + if (thumbnailUri) { + ImageCacheManager.cache(filename, thumbnailUri, this.setThumbnail); + } else if (imageUri) { + ImageCacheManager.cache(filename, imageUri, this.setImage); + } + }; + + setImage = (uri) => { + if (this.subscribedToCache) { + let path = uri; + + if (Platform.OS === 'android') { + path = `file://${uri}`; + } + + this.setState({uri: path}); + } + }; + + setThumbnail = (thumb) => { + if (this.subscribedToCache) { + const {filename, imageUri} = this.props; + let path = thumb; + + if (Platform.OS === 'android') { + path = `file://${thumb}`; + } + + this.setState({thumb: path}, () => { + setTimeout(() => { + ImageCacheManager.cache(filename, imageUri, this.setImage); + }, 300); + }); + } + }; + + render() { + const {style, defaultSource, isBackgroundImage, ...otherProps} = this.props; + const {style: computedStyle} = this; + const {uri, intensity, thumb} = this.state; + const hasDefaultSource = Boolean(defaultSource); + const hasPreview = Boolean(thumb); + const hasURI = Boolean(uri); + const isImageReady = uri && uri !== thumb; + const opacity = intensity.interpolate({ + inputRange: [50, 100], + outputRange: [0.5, 1], + }); + + let DefaultComponent; + let ImageComponent; + if (isBackgroundImage) { + DefaultComponent = ImageBackground; + ImageComponent = AnimatedImageBackground; + } else { + DefaultComponent = Image; + ImageComponent = Animated.Image; + } + + return ( + + {(hasDefaultSource && !hasPreview && !hasURI) && + + {this.props.children} + + } + {hasPreview && !isImageReady && + + {this.props.children} + + } + {isImageReady && + + {this.props.children} + + } + {hasPreview && + + } + + ); + } +} diff --git a/app/components/video_controls.js b/app/components/video_controls.js index 5bdd11f9e..ba2656665 100644 --- a/app/components/video_controls.js +++ b/app/components/video_controls.js @@ -10,7 +10,6 @@ import { AppState, Image, TouchableOpacity, - TouchableWithoutFeedback, StyleSheet, Text, View, @@ -53,6 +52,7 @@ export default class VideoControls extends PureComponent { this.state = { opacity: new Animated.Value(1), isVisible: true, + isSeeking: false, }; } @@ -79,7 +79,12 @@ export default class VideoControls extends PureComponent { fadeInControls = (loop = true) => { this.setState({isVisible: true}); - Animated.timing(this.state.opacity, {toValue: 1, duration: 250, delay: 0}).start(() => { + Animated.timing(this.state.opacity, { + toValue: 1, + duration: 250, + delay: 0, + useNativeDriver: true, + }).start(() => { if (loop) { this.fadeOutControls(2000); } @@ -87,7 +92,12 @@ export default class VideoControls extends PureComponent { }; fadeOutControls = (delay = 0) => { - Animated.timing(this.state.opacity, {toValue: 0, duration: 250, delay}).start((result) => { + Animated.timing(this.state.opacity, { + toValue: 0, + duration: 250, + delay, + useNativeDriver: true, + }).start((result) => { if (result.finished) { this.setState({isVisible: false}); } @@ -136,10 +146,6 @@ export default class VideoControls extends PureComponent { }; renderControls() { - if (!this.state.isVisible) { - return null; - } - return ( @@ -160,8 +166,9 @@ export default class VideoControls extends PureComponent { { - if (this.props.onSeeking) { - this.props.onSeeking(false); - } + seekVideo = (value) => { + this.setState({isSeeking: true}); + this.props.onSeek(value); }; - seekVideo = (value) => { + seekVideoEnd = (value) => { + this.setState({isSeeking: false}); + if (this.props.playerState === PLAYER_STATE.PLAYING) { + this.toggleControls(); + } this.props.onSeek(value); if (this.props.onSeeking) { this.props.onSeeking(true); } }; + seekVideoStart = () => { + this.setState({isSeeking: true}); + this.cancelAnimation(); + if (this.props.onSeeking) { + this.props.onSeeking(false); + } + }; + setPlayerControls = (playerState) => { const icon = this.getPlayerStateIcon(playerState); const pressAction = playerState === PLAYER_STATE.ENDED ? this.onReplay : this.onPause; @@ -231,12 +249,14 @@ export default class VideoControls extends PureComponent { }; render() { + if (!this.state.isVisible) { + return null; + } + return ( - - - {this.renderControls()} - - + + {this.renderControls()} + ); } } @@ -249,7 +269,7 @@ const styles = StyleSheet.create({ paddingVertical: 13, flexDirection: 'column', alignItems: 'center', - backgroundColor: 'rgba(45, 59, 62, 0.4)', + backgroundColor: 'transparent', justifyContent: 'space-between', top: 0, left: 0, diff --git a/app/constants/device.js b/app/constants/device.js index 9f4ea7ef4..a60da371a 100644 --- a/app/constants/device.js +++ b/app/constants/device.js @@ -15,5 +15,6 @@ const deviceTypes = keyMirror({ export default { ...deviceTypes, DOCUMENTS_PATH: `${RNFetchBlob.fs.dirs.CacheDir}/Documents`, + IMAGES_PATH: `${RNFetchBlob.fs.dirs.CacheDir}/Images`, VIDEOS_PATH: `${RNFetchBlob.fs.dirs.CacheDir}/Videos`, }; diff --git a/app/constants/view.js b/app/constants/view.js index 82f3b2c7c..471e823b1 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -38,8 +38,6 @@ const ViewTypes = keyMirror({ REMOVE_FILE_FROM_POST_DRAFT: null, REMOVE_LAST_FILE_FROM_POST_DRAFT: null, - ADD_FILE_TO_FETCH_CACHE: null, - SET_CHANNEL_LOADER: null, SET_CHANNEL_REFRESHING: null, SET_CHANNEL_RETRY_FAILED: null, diff --git a/app/initial_state.js b/app/initial_state.js index 5680735d6..7fc908623 100644 --- a/app/initial_state.js +++ b/app/initial_state.js @@ -263,7 +263,6 @@ const state = { channel: { drafts: {}, }, - fetchCache: {}, i18n: { locale: '', }, diff --git a/app/reducers/views/fetch_cache.js b/app/reducers/views/fetch_cache.js deleted file mode 100644 index 393147827..000000000 --- a/app/reducers/views/fetch_cache.js +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import {ViewTypes} from 'app/constants'; - -export default function fetchCache(state = {}, action) { - switch (action.type) { - case ViewTypes.ADD_FILE_TO_FETCH_CACHE: - return { - ...state, - [action.url]: true, - }; - default: - return state; - } -} diff --git a/app/reducers/views/index.js b/app/reducers/views/index.js index 431ebbd31..e9abcc4ca 100644 --- a/app/reducers/views/index.js +++ b/app/reducers/views/index.js @@ -7,7 +7,6 @@ import announcement from './announcement'; import channel from './channel'; import clientUpgrade from './client_upgrade'; import extension from './extension'; -import fetchCache from './fetch_cache'; import i18n from './i18n'; import login from './login'; import recentEmojis from './recent_emojis'; @@ -23,7 +22,6 @@ export default combineReducers({ channel, clientUpgrade, extension, - fetchCache, i18n, login, recentEmojis, diff --git a/app/screens/image_preview/downloader.android.js b/app/screens/image_preview/downloader.android.js index 64e1a4f64..20e6f30d0 100644 --- a/app/screens/image_preview/downloader.android.js +++ b/app/screens/image_preview/downloader.android.js @@ -15,9 +15,12 @@ import {intlShape} from 'react-intl'; import {Client4} from 'mattermost-redux/client'; +import {DeviceTypes} from 'app/constants/'; import FormattedText from 'app/components/formatted_text'; +import {isDocument, isVideo} from 'app/utils/file'; import {emptyFunction} from 'app/utils/general'; +const {DOCUMENTS_PATH, VIDEOS_PATH} = DeviceTypes; const EXTERNAL_STORAGE_PERMISSION = 'android.permission.WRITE_EXTERNAL_STORAGE'; const HEADER_HEIGHT = 64; const OPTION_LIST_WIDTH = 39; @@ -85,24 +88,56 @@ export default class Downloader extends PureComponent { ToastAndroid.show(started, ToastAndroid.SHORT); onDownloadStart(); - const imageUrl = Client4.getFileUrl(file.id); + const dest = `${RNFetchBlob.fs.dirs.DownloadDir}/${file.caption}`; + let downloadFile = true; - const task = RNFetchBlob.config({ - fileCache: true, - addAndroidDownloads: { - useDownloadManager: true, - notification: true, - path: `${RNFetchBlob.fs.dirs.DownloadDir}/${file.name}`, - title: `${file.name} ${title}`, - mime: file.mime_type, - description: file.name, - mediaScannable: true, - }, - }).fetch('GET', imageUrl, { - Authorization: `Bearer ${Client4.token}`, - }); + const {data} = file; - await task; + if (data.localPath) { + const exists = await RNFetchBlob.fs.exists(data.localPath); + + if (exists) { + downloadFile = false; + await RNFetchBlob.fs.cp(data.localPath, dest); + } + } else if (isVideo(data)) { + const path = `${VIDEOS_PATH}/${data.id}.${data.extension}`; + const exists = await RNFetchBlob.fs.exists(path); + + if (exists) { + downloadFile = false; + await RNFetchBlob.fs.cp(path, dest); + } + } else if (isDocument(data)) { + const path = `${DOCUMENTS_PATH}/${data.name}`; + const exists = await RNFetchBlob.fs.exists(path); + + if (exists) { + downloadFile = false; + await RNFetchBlob.fs.cp(path, dest); + } + } + + if (downloadFile) { + const imageUrl = Client4.getFileUrl(data.id); + + const task = RNFetchBlob.config({ + fileCache: true, + addAndroidDownloads: { + useDownloadManager: true, + notification: true, + path: dest, + title: `${data.name} ${title}`, + mime: data.mime_type, + description: data.name, + mediaScannable: true, + }, + }).fetch('GET', imageUrl, { + Authorization: `Bearer ${Client4.token}`, + }); + + await task; + } ToastAndroid.show(complete, ToastAndroid.SHORT); onDownloadSuccess(); diff --git a/app/screens/image_preview/downloader.ios.js b/app/screens/image_preview/downloader.ios.js index ce9eeb2af..7f9bd9138 100644 --- a/app/screens/image_preview/downloader.ios.js +++ b/app/screens/image_preview/downloader.ios.js @@ -261,49 +261,65 @@ export default class Downloader extends PureComponent { startDownload = async () => { const {file, downloadPath, prompt, saveToCameraRoll} = this.props; + const {data} = file; + let downloadFile = true; try { if (this.state.didCancel) { this.setState({didCancel: false}); } - const imageUrl = Client4.getFileUrl(file.id); - const options = { - session: file.id, - timeout: 10000, - indicator: true, - overwrite: true, - }; - - if (downloadPath && prompt) { - const isDir = await RNFetchBlob.fs.isDir(downloadPath); - if (!isDir) { - try { - await RNFetchBlob.fs.mkdir(downloadPath); - } catch (error) { - this.showDownloadFailedAlert(); - return; - } - } - - options.path = `${downloadPath}/${file.id}.${file.extension}`; - } else { - options.fileCache = true; - options.appendExt = file.extension; + let path; + let res; + if (data.localPath) { + path = data.localPath; + downloadFile = false; + this.setState({ + progress: 100, + started: true, + }); } - this.downloadTask = RNFetchBlob.config(options).fetch('GET', imageUrl); - this.downloadTask.progress((received, total) => { - const progress = (received / total) * 100; - if (this.mounted) { - this.setState({ - progress, - started: true, - }); + if (downloadFile) { + const imageUrl = Client4.getFileUrl(data.id); + const options = { + session: data.id, + timeout: 10000, + indicator: true, + overwrite: true, + }; + + if (downloadPath && prompt) { + const isDir = await RNFetchBlob.fs.isDir(downloadPath); + if (!isDir) { + try { + await RNFetchBlob.fs.mkdir(downloadPath); + } catch (error) { + this.showDownloadFailedAlert(); + return; + } + } + + options.path = `${downloadPath}/${file.caption}`; + } else { + options.fileCache = true; + options.appendExt = data.extension; } - }); - const res = await this.downloadTask; - let path = res.path(); + + this.downloadTask = RNFetchBlob.config(options).fetch('GET', imageUrl); + this.downloadTask.progress((received, total) => { + const progress = (received / total) * 100; + if (this.mounted) { + this.setState({ + progress, + started: true, + }); + } + }); + + res = await this.downloadTask; + path = res.path(); + } if (saveToCameraRoll) { path = await CameraRoll.saveToCameraRoll(path, 'photo'); @@ -328,9 +344,10 @@ export default class Downloader extends PureComponent { }); } - if (saveToCameraRoll) { + if (saveToCameraRoll && res) { res.flush(); // remove the temp file } + this.downloadTask = null; } catch (error) { // cancellation throws so we need to catch diff --git a/app/screens/image_preview/image_preview.js b/app/screens/image_preview/image_preview.js index f5d5e8854..92a3ea0c0 100644 --- a/app/screens/image_preview/image_preview.js +++ b/app/screens/image_preview/image_preview.js @@ -6,9 +6,9 @@ import PropTypes from 'prop-types'; import { Alert, Animated, - InteractionManager, - PanResponder, Platform, + SafeAreaView, + ScrollView, StatusBar, StyleSheet, Text, @@ -20,49 +20,40 @@ 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 EventEmitter from 'mattermost-redux/utils/event_emitter'; import {DeviceTypes} from 'app/constants/'; -import FileAttachmentDocument, {SUPPORTED_DOCS_FORMAT} from 'app/components/file_attachment_list/file_attachment_document'; +import FileAttachmentDocument from 'app/components/file_attachment_list/file_attachment_document'; import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon'; -import SafeAreaView from 'app/components/safe_area_view'; -import Swiper from 'app/components/swiper'; import {NavigationTypes, PermissionTypes} from 'app/constants'; +import {isDocument, isVideo} from 'app/utils/file'; import {emptyFunction} from 'app/utils/general'; import Downloader from './downloader'; -import Previewer from './previewer'; import VideoPreview from './video_preview'; +import ProgressiveImage from 'app/components/progressive_image'; + const {VIDEOS_PATH} = DeviceTypes; const {View: AnimatedView} = Animated; -const DRAG_VERTICAL_THRESHOLD_START = 25; // When do we want to start capturing the drag -const DRAG_VERTICAL_THRESHOLD_END = 100; // When do we want to navigate back -const DRAG_HORIZONTAL_THRESHOLD = 50; // Make sure that it's not a sloppy horizontal swipe -const HEADER_HEIGHT = 64; -const STATUSBAR_HEIGHT = Platform.select({ - ios: 0, - android: 20, -}); -const SUPPORTED_VIDEO_FORMAT = Platform.select({ - ios: ['video/mp4', 'video/x-m4v', 'video/quicktime'], - android: ['video/3gpp', 'video/x-matroska', 'video/mp4', 'video/webm'], -}); +const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView); +const HEADER_HEIGHT = 48; +const ANIM_CONFIG = {duration: 300}; export default class ImagePreview extends PureComponent { static propTypes = { - actions: PropTypes.shape({ - addFileToFetchCache: PropTypes.func.isRequired, - }), canDownloadFiles: PropTypes.bool.isRequired, deviceHeight: PropTypes.number.isRequired, deviceWidth: PropTypes.number.isRequired, - fetchCache: PropTypes.object.isRequired, - fileId: PropTypes.string.isRequired, - files: PropTypes.array.isRequired, + files: PropTypes.array, + getItemMeasures: PropTypes.func.isRequired, + getPreviewProps: PropTypes.func.isRequired, + index: PropTypes.number.isRequired, navigator: PropTypes.object, - statusBarHeight: PropTypes.number, + origin: PropTypes.object, + target: PropTypes.object, theme: PropTypes.object.isRequired, }; @@ -73,219 +64,388 @@ export default class ImagePreview extends PureComponent { constructor(props) { super(props); - this.zoomableImages = {}; + props.navigator.setStyle({ + screenBackgroundColor: '#000', + }); - const currentFile = props.files.findIndex((file) => file.id === props.fileId); - this.initialPage = currentFile; + this.openAnim = new Animated.Value(0); + this.headerFooterAnim = new Animated.Value(1); + this.documents = []; this.state = { - currentFile, - drag: new Animated.ValueXY(), - files: props.files, - footerOpacity: new Animated.Value(1), - pagingEnabled: true, - showFileInfo: true, - wrapperViewOpacity: new Animated.Value(0), - limitOpacity: new Animated.Value(0), + index: props.index, + origin: props.origin, + showDownloader: false, + target: props.target, }; } - componentWillMount() { - this.mainViewPanResponder = PanResponder.create({ - onMoveShouldSetPanResponderCapture: this.mainViewMoveShouldSetPanResponderCapture, - onPanResponderMove: Animated.event([null, { - dx: 0, - dy: this.state.drag.y, - }]), - onPanResponderRelease: this.mainViewPanResponderRelease, - onPanResponderTerminate: this.mainViewPanResponderRelease, - }); - } - componentDidMount() { - InteractionManager.runAfterInteractions(() => { - Animated.timing(this.state.wrapperViewOpacity, { - toValue: 1, - duration: 100, - }).start(); - }); - } - - componentWillReceiveProps(nextProps) { - if (!nextProps.files.length) { - this.showDeletedFilesAlert(); - } + this.startOpenAnimation(); } componentWillUnmount() { - if (Platform.OS === 'ios') { - StatusBar.setHidden(false, 'fade'); - } + StatusBar.setHidden(false, 'fade'); } - close = () => { - this.props.navigator.dismissModal({animationType: 'none'}); - }; - - getPreviews = () => { - return this.state.files.map((file, index) => { - let mime = file.mime_type; - if (mime && mime.includes(';')) { - mime = mime.split(';')[0]; + animateOpenAnimToValue = (toValue, onComplete) => { + Animated.timing(this.openAnim, { + ...ANIM_CONFIG, + toValue, + }).start(() => { + this.setState({animating: false}); + if (onComplete) { + onComplete(); } - - let component; - - if (file.has_preview_image || file.mime_type === 'image/gif') { - component = this.renderPreviewer(file, index); - } else if (SUPPORTED_DOCS_FORMAT.includes(mime)) { - component = this.renderAttachmentDocument(file); - } else if (SUPPORTED_VIDEO_FORMAT.includes(file.mime_type)) { - component = this.renderVideoPreview(file); - } else { - component = this.renderAttachmentIcon(file); - } - - return ( - - {component} - - ); }); }; - handleClose = () => { - if (this.state.showFileInfo) { + close = () => { + const {getItemMeasures, navigator} = this.props; + const {index} = this.state; + + this.setState({animating: true, gallery: false, hide: false}); + navigator.setStyle({ + screenBackgroundColor: 'transparent', + }); + + getItemMeasures(index, (origin) => { + if (origin) { + this.setState(origin); + } + + this.animateOpenAnimToValue(0, () => { + navigator.dismissModal({animationType: 'none'}); + }); + }); + }; + + handleChangeImage = (index) => { + this.setState({index}); + }; + + handleGalleryLayout = () => { + this.setState({hide: true}); + }; + + 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.setHeaderAndFileInfoVisible(true); + this.setHeaderAndFooterVisible(true); } }; - handleLayout = () => { - if (this.refs.swiper) { - this.refs.swiper.runOnLayout = true; - } + getCurrentFile = () => { + const {files} = this.props; + const {index} = this.state; + const file = files[index]; + + return file; }; - handleImageDoubleTap = (x, y) => { - this.zoomableImages[this.state.currentFile].toggleZoom(x, y); + getFullscreenOpacity = () => { + const {target} = this.props; + + return { + opacity: this.openAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0, target.opacity], + }), + }; }; - handleImageTap = () => { - this.hideDownloader(false); - this.setHeaderAndFileInfoVisible(!this.state.showFileInfo); + getHeaderFooterStyle = () => { + return { + start: this.headerFooterAnim.interpolate({ + inputRange: [0, 1], + outputRange: [-80, 0], + }), + opacity: this.headerFooterAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0, 1], + }), + }; }; - handleIndexChanged = (currentFile) => { - if (Number.isInteger(currentFile)) { - this.setState({currentFile, limitOpacity: new Animated.Value(0)}); - } + getSwipeableStyle = () => { + const {deviceHeight, deviceWidth} = this.props; + const {origin, target} = this.state; + const inputRange = [0, 1]; + + return { + left: this.openAnim.interpolate({ + inputRange, + outputRange: [origin.x, target.x], + }), + top: this.openAnim.interpolate({ + inputRange, + outputRange: [origin.y, target.y], + }), + width: this.openAnim.interpolate({ + inputRange, + outputRange: [origin.width, deviceWidth], + }), + height: this.openAnim.interpolate({ + inputRange, + outputRange: [origin.height, deviceHeight], + }), + }; }; - handleScroll = () => { - Animated.timing(this.state.limitOpacity, { - toValue: 1, - duration: 100, - }).start(); + renderAttachmentDocument = (file) => { + const {theme, navigator} = this.props; + + return ( + + { + this.documents[this.state.index] = ref; + }} + file={file} + theme={theme} + navigator={navigator} + iconHeight={120} + iconWidth={120} + wrapperHeight={200} + wrapperWidth={200} + /> + + ); }; - handleVideoSeek = (seeking) => { - this.setState({ - isZooming: !seeking, - }); + renderAttachmentIcon = (file) => { + return ( + + + + ); }; - imageIsZooming = (zooming) => { - if (zooming !== this.state.isZooming) { - this.setHeaderAndFileInfoVisible(!zooming); - this.setState({ - isZooming: zooming, - }); - } - }; + renderDownloadButton = () => { + const {canDownloadFiles} = this.props; + const file = this.getCurrentFile(); - mainViewMoveShouldSetPanResponderCapture = (evt, gestureState) => { - if (gestureState.numberActiveTouches === 2 || this.state.isZooming) { - return false; + 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} + + ); } - const {dx, dy} = gestureState; - const isVerticalDrag = Math.abs(dy) > DRAG_VERTICAL_THRESHOLD_START && dx < DRAG_HORIZONTAL_THRESHOLD; - if (isVerticalDrag) { - this.setHeaderAndFileInfoVisible(false); - return true; - } - - return false; + return null; }; - mainViewPanResponderRelease = (evt, gestureState) => { - if (Math.abs(gestureState.dy) > DRAG_VERTICAL_THRESHOLD_END) { - this.close(); - } else { - this.setHeaderAndFileInfoVisible(true); - Animated.spring(this.state.drag, { - toValue: {x: 0, y: 0}, - }).start(); + 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()} + + + + ); + } + + renderOtherItems = (index) => { + const {files} = this.props; + const file = files[index]; + + if (file.data) { + if (isDocument(file.data)) { + return this.renderAttachmentDocument(file.data); + } else if (isVideo(file.data)) { + return this.renderVideoPreview(file.data); + } + + return this.renderAttachmentIcon(file.data); } + + return ; }; - saveVideo = () => { - const file = this.state.files[this.state.currentFile]; + renderSelectedItem = () => { + const {hide, index} = this.state; + const file = this.getCurrentFile(); + + if (hide || isDocument(file.data) || isVideo(file.data)) { + return null; + } + + const {getPreviewProps} = this.props; + const containerStyle = this.getSwipeableStyle(); + const previewProps = getPreviewProps(index); + + return ( + + + + + + ); + }; + + renderVideoPreview = (file) => { + const {deviceHeight, deviceWidth, theme} = this.props; + + return ( + + ); + }; + + saveVideoIOS = () => { + const file = this.getCurrentFile(); + const {data} = file; + if (this.refs.downloader) { EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL); - this.refs.downloader.saveVideo(`${VIDEOS_PATH}/${file.id}.${file.extension}`); + this.refs.downloader.saveVideo(`${VIDEOS_PATH}/${data.id}.${data.extension}`); } }; - setHeaderAndFileInfoVisible = (show) => { - this.setState({ - showFileInfo: show, - }); + setHeaderAndFooterVisible = (show) => { + const toValue = show ? 1 : 0; - if (Platform.OS === 'ios') { - StatusBar.setHidden(!show, 'fade'); + if (!show) { + this.hideDownloader(); } - const opacity = show ? 1 : 0; + this.setState({showHeaderFooter: show}); + StatusBar.setHidden(!show, 'slide'); - Animated.timing(this.state.footerOpacity, { - toValue: opacity, - duration: 300, + Animated.timing(this.headerFooterAnim, { + ...ANIM_CONFIG, + toValue, }).start(); }; - showDeletedFilesAlert = () => { - const {intl} = this.context; - - Alert.alert( - intl.formatMessage({ - id: 'mobile.image_preview.deleted_post_title', - defaultMessage: 'Post Deleted', - }), - intl.formatMessage({ - id: 'mobile.image_preview.deleted_post_message', - defaultMessage: 'This post and its files have been deleted. The previewer will now be closed.', - }), - [{ - text: intl.formatMessage({ - id: 'mobile.server_upgrade.button', - defaultMessage: 'OK', - }), - onPress: this.close, - }] - ); - }; - showDownloader = () => { EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL); @@ -302,13 +462,13 @@ export default class ImagePreview extends PureComponent { this.showDownloader(); } } else { - this.showIOSDownloadOptions(); + this.showDownloadOptionsIOS(); } }; - showIOSDownloadOptions = async () => { + showDownloadOptionsIOS = async () => { const {formatMessage} = this.context.intl; - const file = this.state.files[this.state.currentFile]; + const file = this.getCurrentFile(); const items = []; let permissionRequest; @@ -346,19 +506,19 @@ export default class ImagePreview extends PureComponent { } } - if (SUPPORTED_VIDEO_FORMAT.includes(file.mime_type)) { - const path = `${VIDEOS_PATH}/${file.id}.${file.extension}`; + if (isVideo(file.data)) { + const path = `${VIDEOS_PATH}/${file.data.id}.${file.data.extension}`; const exist = await RNFetchBlob.fs.exists(path); if (exist) { items.push({ - action: this.saveVideo, + action: this.saveVideoIOS, text: { id: 'mobile.image_preview.save_video', defaultMessage: 'Save Video', }, }); } else { - this.showVideoDownloadRequiredAlert(); + this.showVideoDownloadRequiredAlertIOS(); } } else { items.push({ @@ -371,13 +531,13 @@ export default class ImagePreview extends PureComponent { } const options = { - title: file.name, + title: file.caption, items, - onCancelPress: () => this.setHeaderAndFileInfoVisible(true), + onCancelPress: () => this.setHeaderAndFooterVisible(true), }; if (items.length) { - this.setHeaderAndFileInfoVisible(false); + this.setHeaderAndFooterVisible(false); this.props.navigator.showModal({ screen: 'OptionsModal', @@ -397,7 +557,7 @@ export default class ImagePreview extends PureComponent { } }; - showVideoDownloadRequiredAlert = () => { + showVideoDownloadRequiredAlertIOS = () => { const {intl} = this.context; Alert.alert( @@ -418,238 +578,49 @@ export default class ImagePreview extends PureComponent { ); }; - renderAttachmentDocument = (file) => { - const {theme} = this.props; - - return ( - - ); - }; - - renderAttachmentIcon = (file) => { - return ( - - - - ); - }; - - renderDownloadButton = () => { - const {canDownloadFiles} = this.props; - const {currentFile, files} = this.state; - - const file = files[currentFile]; - - if (file) { - let icon; - let action = emptyFunction; - if (canDownloadFiles) { - if (Platform.OS === 'android') { - action = this.showDownloadOptions; - icon = ( - - ); - } else if (file.has_preview_image || SUPPORTED_VIDEO_FORMAT.includes(file.mime_type)) { - action = this.showDownloadOptions; - icon = ( - - ); - } - } - - return ( - - {icon} - - ); - } - - return null; - }; - - renderPreviewer = (file, index) => { - const maxImageHeight = this.props.deviceHeight - STATUSBAR_HEIGHT; - - return ( - { - this.zoomableImages[index] = c; - }} - addFileToFetchCache={this.props.actions.addFileToFetchCache} - fetchCache={this.props.fetchCache} - file={file} - theme={this.props.theme} - imageHeight={Math.min(maxImageHeight, file.height)} - imageWidth={Math.min(this.props.deviceWidth, file.width)} - shrink={this.state.shouldShrinkImages} - wrapperHeight={this.props.deviceHeight} - wrapperWidth={this.props.deviceWidth} - onImageTap={this.handleImageTap} - onImageDoubleTap={this.handleImageDoubleTap} - onZoom={this.imageIsZooming} - /> - ); - }; - - renderVideoPreview = (file) => { - const {deviceHeight, deviceWidth, theme} = this.props; - - return ( - - ); - }; - - renderSwiper = () => { - return ( - - {this.getPreviews()} - - ); + startOpenAnimation = () => { + this.animateOpenAnimToValue(1, () => { + this.setState({gallery: true}); + }); }; render() { - const {currentFile, files} = this.state; - const file = files[currentFile]; - - if (!file) { - return null; - } - - const fileName = file ? file.name : ''; + const opacity = this.getFullscreenOpacity(); return ( - - - - {this.renderSwiper()} - - - - - - - - {`${currentFile + 1}/${files.length}`} - - {this.renderDownloadButton()} - - - - - - - {fileName} - - - - - - - + + + {this.renderSelectedItem()} + {this.state.gallery && this.renderGallery()} + {this.renderHeader()} + {this.renderFooter()} + + {this.renderDownloader()} + ); } } const style = StyleSheet.create({ - wrapper: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.8)', - }, - scrollView: { + container: { flex: 1, backgroundColor: '#000', }, - scrollViewContent: { - backgroundColor: '#000', + flex: { + flex: 1, }, - pageWrapper: { + center: { alignItems: 'center', justifyContent: 'center', - flex: 1, + }, + fullWidth: { + width: '100%', }, headerContainer: { position: 'absolute', - top: 0, height: HEADER_HEIGHT, - zIndex: 2, + width: '100%', + overflow: 'hidden', }, header: { backgroundColor: 'rgba(0, 0, 0, 0.8)', @@ -663,7 +634,6 @@ const style = StyleSheet.create({ alignItems: 'center', justifyContent: 'space-around', flexDirection: 'row', - marginTop: 18, }, headerIcon: { height: 44, @@ -679,10 +649,11 @@ const style = StyleSheet.create({ textAlign: 'center', }, footerContainer: { - position: 'absolute', - bottom: 0, height: 64, - zIndex: 2, + justifyContent: 'center', + overflow: 'hidden', + position: 'absolute', + width: '100%', }, footer: { position: 'absolute', diff --git a/app/screens/image_preview/image_view.android.js b/app/screens/image_preview/image_view.android.js deleted file mode 100644 index 124ad1049..000000000 --- a/app/screens/image_preview/image_view.android.js +++ /dev/null @@ -1,292 +0,0 @@ -// This control is based on Leonti's Stack Overflow post: -// http://stackoverflow.com/users/219449/leonti -// http://stackoverflow.com/questions/36368919/scrollable-image-with-pinch-to-zoom - -import React, {Component} from 'react'; -import PropTypes from 'prop-types'; -import { - Animated, - View, - PanResponder, -} from 'react-native'; - -const {Image: AnimatedImage} = Animated; - -function calcDistance(x1, y1, x2, y2) { - const dx = Math.abs(x1 - x2); - const dy = Math.abs(y1 - y2); - return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)); -} - -function calcCenter(x1, y1, x2, y2) { - function middle(p1, p2) { - return p1 > p2 ? p1 - (p1 - p2) / 2 : p2 - (p2 - p1) / 2; // eslint-disable-line - } - - return { - x: middle(x1, x2), - y: middle(y1, y2), - }; -} - -function maxOffset(offset, windowDimension, imageDimension) { - const max = windowDimension - imageDimension; - if (max >= 0) { - return 0; - } - return offset < max ? max : offset; -} - -function calcOffsetByZoom(width, height, imageWidth, imageHeight, zoom) { - const xDiff = (imageWidth * zoom) - width; - const yDiff = (imageHeight * zoom) - height; - return { - left: -xDiff / 2, - top: -yDiff / 2, - }; -} - -export default class ImageView extends Component { - static propTypes = { - imageHeight: PropTypes.number, - imageWidth: PropTypes.number, - maximumZoomScale: PropTypes.number, - minimumZoomScale: PropTypes.number, - onImageTap: PropTypes.func, - onZoom: PropTypes.func, - style: PropTypes.object.isRequired, - wrapperHeight: PropTypes.number.isRequired, - wrapperWidth: PropTypes.number.isRequired, - }; - - static defaultProps = { - onZoom: () => false, - }; - - constructor(props) { - super(props); - - this.onLayout = this.onLayout.bind(this); - - this.state = { - zoom: 1, - maxZoom: 3, - minZoom: 1, - layoutKnown: false, - isZooming: false, - isMoving: false, - initialDistance: 0, - initialX: 0, - initalY: 0, - offsetTop: 0, - offsetLeft: 0, - initialTop: 0, - initialLeft: 0, - initialTopWithoutZoom: 0, - initialLeftWithoutZoom: 0, - initialZoom: 1, - top: 0, - left: 0, - height: props.wrapperHeight, - width: props.wrapperWidth, - }; - } - - componentWillMount() { - this.panResponder = PanResponder.create({ - onStartShouldSetPanResponderCapture: (evt, gestureState) => { - if (gestureState.numberActiveTouches === 2 || this.state.zoom > 1) { - return true; - } - - return false; - }, - onMoveShouldSetPanResponder: (evt, gestureState) => { - return gestureState.numberActiveTouches === 2 || this.state.zoom > 1; - }, - onMoveShouldSetPanResponderCapture: (evt, gestureState) => { - return gestureState.numberActiveTouches === 2 || this.state.zoom > 1; - }, - onPanResponderGrant: () => { - return; - }, - onPanResponderMove: (evt) => { - const touches = evt.nativeEvent.touches; - - if (touches.length === 2) { - const touch1 = touches[0]; - const touch2 = touches[1]; - - this.processPinch(touch1.pageX, touch1.pageY, - touch2.pageX, touch2.pageY); - } else if (touches.length === 1 && !this.state.isZooming) { - this.processTouch(touches[0].pageX, touches[0].pageY); - } - }, - onPanResponderTerminationRequest: () => { - return this.state.zoom === 1; - }, - onPanResponderRelease: () => { - this.props.onZoom(this.state.zoom > 1); - this.setState({ - isZooming: false, - isMoving: false, - }); - }, - onPanResponderTerminate: () => { - return; - }, - onShouldBlockNativeResponder: () => false, - }); - } - - componentWillReceiveProps(nextProps) { - if (nextProps.wrapperWidth !== this.state.width || nextProps.wrapperHeight !== this.state.height) { - this.setState({ - height: nextProps.wrapperHeight, - width: nextProps.wrapperWidth, - }); - } - } - - setZoom = (zoom = true) => { - const zoomScale = zoom ? this.props.maximumZoomScale : this.props.minimumZoomScale; - const offsetByZoom = calcOffsetByZoom(this.state.width, this.state.height, - this.props.wrapperWidth, this.props.wrapperHeight, zoomScale); - - this.setState({ - zoom: zoomScale, - left: offsetByZoom.left, - top: offsetByZoom.top, - initialX: this.state.width / 2, - initialY: this.state.height / 2, - initialZoom: zoomScale, - initialTopWithoutZoom: this.state.top - offsetByZoom.top, - initialLeftWithoutZoom: this.state.left - offsetByZoom.left, - }); - this.props.onZoom(true); - } - - processPinch(x1, y1, x2, y2) { - const distance = calcDistance(x1, y1, x2, y2); - const center = calcCenter(x1, y1, x2, y2); - - if (this.state.isZooming) { - const touchZoom = distance / this.state.initialDistance; - const zoom = touchZoom * this.state.initialZoom > this.state.minZoom ? touchZoom * this.state.initialZoom : this.state.minZoom; - if (zoom > this.state.maxZoom) { - return; - } - const offsetByZoom = calcOffsetByZoom(this.state.width, this.state.height, - this.props.wrapperWidth, this.props.wrapperHeight, zoom); - const left = (this.state.initialLeftWithoutZoom * touchZoom) + offsetByZoom.left; - const top = (this.state.initialTopWithoutZoom * touchZoom) + offsetByZoom.top; - - this.setState({ - zoom, - left: left > 0 ? 0 : maxOffset(left, this.state.width, this.props.wrapperWidth * zoom), - top: top > 0 ? 0 : maxOffset(top, this.state.height, this.props.wrapperHeight * zoom), - }); - } else { - const offsetByZoom = calcOffsetByZoom(this.state.width, this.state.height, - this.props.wrapperWidth, this.props.wrapperHeight, this.state.zoom); - this.setState({ - isZooming: true, - initialDistance: distance, - initialX: center.x, - initialY: center.y, - initialTop: this.state.top, - initialLeft: this.state.left, - initialZoom: this.state.zoom, - initialTopWithoutZoom: this.state.top - offsetByZoom.top, - initialLeftWithoutZoom: this.state.left - offsetByZoom.left, - }); - } - } - - processTouch(x, y) { - if (this.state.isMoving) { - const left = this.state.initialLeft + x - this.state.initialX; // eslint-disable-line - const top = this.state.initialTop + y - this.state.initialY; // eslint-disable-line - - this.setState({ - left: left > 0 ? 0 : maxOffset(left, this.state.width, this.props.wrapperWidth * this.state.zoom), - top: top > 0 ? 0 : maxOffset(top, this.state.height, this.props.wrapperHeight * this.state.zoom), - }); - } else { - this.setState({ - isMoving: true, - initialX: x, - initialY: y, - initialTop: this.state.top, - initialLeft: this.state.left, - }); - } - } - - onLayout(event) { - if (this.state.layoutKnown) { - return; - } - - const layout = event.nativeEvent.layout; - - if (layout.width === this.state.width && layout.height === this.state.height) { - return; - } - - const offsetTop = 0; // eslint-disable-line - - this.setState({ - layoutKnown: true, - width: this.props.wrapperWidth, - height: this.props.wrapperHeight, - offsetTop, - }); - } - - render() { - const { - imageHeight, - imageWidth, - style, - ...otherProps - } = this.props; - - let height = style.height; - let width = style.width; - if (this.state.zoom > 1) { - height = imageHeight * this.state.zoom; - width = imageWidth * this.state.zoom; - } - - return ( - { - if (Date.now() - this.tap < 100) { - this.props.onImageTap(); - } - - this.props.onZoom(this.state.zoom > 1); - this.setState({ - isZooming: false, - isMoving: false, - }); - }} - style={{ - position: 'absolute', - top: this.state.offsetTop + this.state.top, - left: this.state.offsetLeft + this.state.left, - width: this.state.width * this.state.zoom, - height: this.state.height * this.state.zoom, - }} - > - - - ); - } -} diff --git a/app/screens/image_preview/image_view.ios.js b/app/screens/image_preview/image_view.ios.js deleted file mode 100644 index f7dba9c3f..000000000 --- a/app/screens/image_preview/image_view.ios.js +++ /dev/null @@ -1,98 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import { - Animated, - ScrollView, - StyleSheet, -} from 'react-native'; - -const {Image: AnimatedImage} = Animated; - -export default class ImageView extends PureComponent { - static propTypes = { - maximumZoomScale: PropTypes.number, - minimumZoomScale: PropTypes.number, - onZoom: PropTypes.func, - showsHorizontalScrollIndicator: PropTypes.bool, - showsVerticalScrollIndicator: PropTypes.bool, - wrapperHeight: PropTypes.number.isRequired, - wrapperWidth: PropTypes.number.isRequired, - } - - static defaultProps = { - maximumZoomScale: 3, - minimumZoomScale: 1, - onZoom: () => true, - showsHorizontalScrollIndicator: false, - showsVerticalScrollIndicator: false, - } - - attachScrollView = (c) => { - if (c) { - this.scrollView = c; - this.scrollResponder = c.getScrollResponder(); - } - } - - setZoom = (zoom, x, y) => { - const rect = {}; - if (zoom) { - rect.x = x; - rect.y = y; - } else { - rect.height = this.props.wrapperHeight; - rect.width = this.props.wrapperWidth; - } - - this.scrollResponder.scrollResponderZoomTo({ - ...rect, - animated: true, - }); - } - - handleScroll = (evt) => { - const {nativeEvent} = evt; - - clearTimeout(this.scrollEventTimeout); - this.scrollEventTimeout = setTimeout(() => { - this.props.onZoom(nativeEvent.zoomScale > 1); - }, 100); - } - - render() { - const { - maximumZoomScale, - minimumZoomScale, - showsHorizontalScrollIndicator, - showsVerticalScrollIndicator, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -const style = StyleSheet.create({ - content: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, -}); diff --git a/app/screens/image_preview/index.js b/app/screens/image_preview/index.js index b6505a787..a830c7119 100644 --- a/app/screens/image_preview/index.js +++ b/app/screens/image_preview/index.js @@ -1,40 +1,20 @@ // Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {Platform} from 'react-native'; -import {addFileToFetchCache} from 'app/actions/views/file_preview'; -import {getDimensions, getStatusBarHeight} from 'app/selectors/device'; +import {getDimensions} from 'app/selectors/device'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {canDownloadFilesOnMobile} from 'mattermost-redux/selectors/entities/general'; -import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files'; import ImagePreview from './image_preview'; -const STATUSBAR_HEIGHT = 20; - -function makeMapStateToProps() { - const getFilesForPost = makeGetFilesForPost(); - return function mapStateToProps(state, ownProps) { - return { - ...getDimensions(state), - canDownloadFiles: canDownloadFilesOnMobile(state), - fetchCache: state.views.fetchCache, - files: getFilesForPost(state, ownProps.postId), - theme: getTheme(state), - statusBarHeight: Platform.OS === 'ios' ? getStatusBarHeight(state) : STATUSBAR_HEIGHT, - }; - }; -} - -function mapDispatchToProps(dispatch) { +function mapStateToProps(state) { return { - actions: bindActionCreators({ - addFileToFetchCache, - }, dispatch), + ...getDimensions(state), + canDownloadFiles: canDownloadFilesOnMobile(state), + theme: getTheme(state), }; } -export default connect(makeMapStateToProps, mapDispatchToProps)(ImagePreview); +export default connect(mapStateToProps)(ImagePreview); diff --git a/app/screens/image_preview/previewer.js b/app/screens/image_preview/previewer.js deleted file mode 100644 index 84ebafddc..000000000 --- a/app/screens/image_preview/previewer.js +++ /dev/null @@ -1,307 +0,0 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import React, {Component} from 'react'; -import PropTypes from 'prop-types'; -import { - ActivityIndicator, - Animated, - PanResponder, - Platform, - View, - StyleSheet, -} from 'react-native'; -import {Client4} from 'mattermost-redux/client'; - -import imageIcon from 'assets/images/icons/image.png'; - -import ImageView from './image_view'; - -const {View: AnimatedView} = Animated; - -const DOUBLE_CLICK_THRESHOLD = 250; - -export default class Previewer extends Component { - static propTypes = { - addFileToFetchCache: PropTypes.func.isRequired, - fetchCache: PropTypes.object.isRequired, - file: PropTypes.object, - gutter: PropTypes.number, - imageHeight: PropTypes.number, - imageWidth: PropTypes.number, - onImageTap: PropTypes.func, - onImageDoubleTap: PropTypes.func, - onZoom: PropTypes.func, - shrink: PropTypes.bool, - wrapperHeight: PropTypes.number, - wrapperWidth: PropTypes.number, - }; - - static defaultProps = { - fadeInOnLoad: false, - gutter: 20, - loading: false, - onImageTap: () => true, - onImageDoubleTap: () => true, - onZoom: () => true, - }; - - constructor(props) { - super(props); - - this.state = { - imageHeight: new Animated.Value(props.imageHeight), - imageWidth: new Animated.Value(props.imageWidth), - opacity: new Animated.Value(0), - requesting: true, - retry: 0, - zooming: false, - }; - } - - componentWillMount() { - this.panResponder = PanResponder.create({ - onStartShouldSetPanResponderCapture: (evt, gestureState) => { - const {numberActiveTouches} = gestureState; - if (numberActiveTouches === 1 && !this.state.isZooming) { - return true; - } else if (numberActiveTouches === 1) { - this.handleResponderRelease(evt); - } - - return false; - }, - onPanResponderGrant: () => { - return; - }, - onPanResponderTerminate: () => { - return; - }, - onShouldBlockNativeResponder: () => false, - }); - } - - componentDidMount() { - this.mounted = true; - } - - componentWillReceiveProps(nextProps) { - if (this.props.shrink && !nextProps.shrink) { - this.setShrink(); - } else if (!this.props.shrink && nextProps.shrink) { - this.setShrink(true); - } else if ( - nextProps.imageHeight !== this.props.imageHeight || - nextProps.imageWidth !== this.props.imageWidth - ) { - this.setState({ - imageHeight: new Animated.Value(nextProps.imageHeight), - imageWidth: new Animated.Value(nextProps.imageWidth), - }); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - setShrink = (shrink = false) => { - const {gutter, imageHeight, imageWidth} = this.props; - - let height = imageHeight; - let width = imageWidth; - const duration = 150; - if (shrink) { - height = height - gutter; - width = width - gutter; - } - - const animations = [ - Animated.timing(this.state.imageWidth, { - toValue: width, - duration, - }), - ]; - - if (Platform.OS === 'android') { - animations.push( - Animated.timing(this.state.imageHeight, { - toValue: height, - duration, - }) - ); - } - - Animated.parallel(animations).start(); - } - - handleResponderRelease = (evt) => { - clearTimeout(this.singleTap); - let cancelSingleTap = false; - if (this.lastTap && Date.now() - this.lastTap < DOUBLE_CLICK_THRESHOLD) { - cancelSingleTap = true; - } else { - this.lastTap = Date.now(); - } - - if (cancelSingleTap) { - const {nativeEvent} = evt; - const x = nativeEvent.locationX; - const y = nativeEvent.locationY; - - cancelSingleTap = false; - this.lastTap = null; - - this.props.onImageDoubleTap(x, y); - } else if (!this.state.isZooming) { - this.singleTap = setTimeout(() => { - this.props.onImageTap(); - }, DOUBLE_CLICK_THRESHOLD); - } - } - - handleLoadError = () => { - if (this.state.retry < 4) { - setTimeout(() => { - this.setState((prevState) => { - return { - retry: (prevState.retry + 1), - timestamp: Date.now(), - }; - }); - }, 300); - } - }; - - handleLoad = () => { - this.setState({ - requesting: false, - }); - - Animated.timing(this.state.opacity, { - toValue: 1, - duration: 300, - }).start(() => { - this.props.addFileToFetchCache(this.handleGetImageURL()); - }); - }; - - handleLoadStart = () => { - this.setState({ - requesting: true, - }); - }; - - handleGetImageURL = () => { - const {file} = this.props; - - if (file.mime_type === 'image/gif') { - return Client4.getFileUrl(file.id, this.state.timestamp); - } - - return Client4.getFilePreviewUrl(file.id, this.state.timestamp); - }; - - attachImageView = (c) => { - this.imageView = c; - }; - - handleZoom = (zoom) => { - if (!this.mounted) { - return; - } - - this.setState({ - isZooming: zoom, - }); - - this.props.onZoom(zoom); - }; - - toggleZoom = (x, y) => { - const zoom = !this.state.isZooming; - - this.imageView.setZoom(zoom, x, y); - this.handleZoom(zoom); - }; - - render() { - const { - fetchCache, - imageHeight, - imageWidth, - wrapperHeight, - wrapperWidth, - } = this.props; - - let source = {}; - let usingIcon = false; - if (this.state.retry === 4) { - source = imageIcon; - usingIcon = true; - } else { - source = {uri: this.handleGetImageURL()}; - } - - let isInFetchCache = fetchCache[source.uri]; - if (usingIcon) { - isInFetchCache = true; - } - - const imageComponentLoaders = { - onError: (isInFetchCache) ? null : this.handleLoadError, - onLoadStart: isInFetchCache ? null : this.handleLoadStart, - onLoad: isInFetchCache ? null : this.handleLoad, - }; - - const opacity = isInFetchCache ? 1 : this.state.opacity; - const containerStyle = Platform.select({ - android: {height: imageHeight, width: this.state.imageWidth, backgroundColor: '#000'}, - ios: {flex: 1, backgroundColor: '#000'}, - }); - - return ( - - - - - {(!isInFetchCache && this.state.requesting) && - - - - } - - ); - } -} - -const style = StyleSheet.create({ - fileImageWrapper: { - alignItems: 'center', - justifyContent: 'center', - }, - loaderContainer: { - position: 'absolute', - height: '100%', - width: '100%', - alignItems: 'center', - justifyContent: 'center', - }, -}); diff --git a/app/screens/image_preview/video_preview.js b/app/screens/image_preview/video_preview.js index 24f08037c..cae038fc5 100644 --- a/app/screens/image_preview/video_preview.js +++ b/app/screens/image_preview/video_preview.js @@ -6,6 +6,7 @@ import PropTypes from 'prop-types'; import { Alert, StyleSheet, + TouchableOpacity, View, } from 'react-native'; import Video from 'react-native-video'; @@ -27,7 +28,7 @@ export default class VideoPreview extends PureComponent { deviceWidth: PropTypes.number.isRequired, file: PropTypes.object.isRequired, onFullScreen: PropTypes.func.isRequired, - onSeeking: PropTypes.func.isRequired, + onSeeking: PropTypes.func, theme: PropTypes.object.isRequired, }; @@ -71,12 +72,12 @@ export default class VideoPreview extends PureComponent { exitFullScreen = () => { this.setState({isFullScreen: false}); - this.props.onFullScreen(); + this.props.onFullScreen(true); }; enterFullScreen = () => { this.setState({isFullScreen: true}); - this.props.onFullScreen(); + this.props.onFullScreen(false); }; onDownloadSuccess = () => { @@ -88,7 +89,7 @@ export default class VideoPreview extends PureComponent { onEnd = () => { if (this.state.isFullScreen) { - this.props.onFullScreen(); + this.props.onFullScreen(true); } this.setState({playerState: PLAYER_STATE.ENDED, isFullScreen: false, paused: true}); @@ -154,7 +155,9 @@ export default class VideoPreview extends PureComponent { onSeek = (seek) => { if (this.refs.videoPlayer) { - this.refs.videoPlayer.seek(seek); + this.setState({currentTime: seek}, () => { + this.refs.videoPlayer.seek(seek); + }); } }; @@ -191,20 +194,26 @@ export default class VideoPreview extends PureComponent { } return ( - -