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 (
-
-
+
+ this.refs.controls.fadeInControls()}
+ >
+
+
{
color: theme.centerChannelColor,
flex: 1,
fontSize: 14,
- lineHeight: 43,
+ ...Platform.select({
+ android: {
+ lineHeight: 68,
+ },
+ ios: {
+ lineHeight: 43,
+ },
+ }),
},
};
});
diff --git a/app/screens/settings/settings_item/style.android.js b/app/screens/settings/settings_item/style.android.js
index ec460e870..a42779f91 100644
--- a/app/screens/settings/settings_item/style.android.js
+++ b/app/screens/settings/settings_item/style.android.js
@@ -46,6 +46,7 @@ export default makeStyleSheetFromTheme((theme) => {
height: 1,
},
arrowContainer: {
+ alignItems: 'center',
justifyContent: 'center',
paddingRight: 15,
},
diff --git a/app/utils/file.js b/app/utils/file.js
index 03f4f633e..8794d222c 100644
--- a/app/utils/file.js
+++ b/app/utils/file.js
@@ -1,13 +1,37 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import {Platform} from 'react-native';
import RNFetchBlob from 'react-native-fetch-blob';
+
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
+
import {DeviceTypes} from 'app/constants/';
-const {DOCUMENTS_PATH, VIDEOS_PATH} = DeviceTypes;
+const {DOCUMENTS_PATH, IMAGES_PATH, VIDEOS_PATH} = DeviceTypes;
const DEFAULT_SERVER_MAX_FILE_SIZE = 50 * 1024 * 1024;// 50 Mb
+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 SUPPORTED_VIDEO_FORMAT = Platform.select({
+ ios: ['video/mp4', 'video/x-m4v', 'video/quicktime'],
+ android: ['video/3gpp', 'video/x-matroska', 'video/mp4', 'video/webm'],
+});
+
export function generateId() {
// Implementation taken from http://stackoverflow.com/a/2117523
let id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx';
@@ -30,6 +54,7 @@ export function generateId() {
export async function getFileCacheSize() {
const isDocsDir = await RNFetchBlob.fs.isDir(DOCUMENTS_PATH);
+ const isImagesDir = await RNFetchBlob.fs.isDir(IMAGES_PATH);
const isVideosDir = await RNFetchBlob.fs.isDir(VIDEOS_PATH);
let size = 0;
@@ -40,6 +65,13 @@ export async function getFileCacheSize() {
}, size);
}
+ if (isImagesDir) {
+ const imagesStats = await RNFetchBlob.fs.lstat(IMAGES_PATH);
+ size = imagesStats.reduce((accumulator, stat) => {
+ return accumulator + parseInt(stat.size, 10);
+ }, size);
+ }
+
if (isVideosDir) {
const videoStats = await RNFetchBlob.fs.lstat(VIDEOS_PATH);
size = videoStats.reduce((accumulator, stat) => {
@@ -52,6 +84,7 @@ export async function getFileCacheSize() {
export async function deleteFileCache() {
await RNFetchBlob.fs.unlink(DOCUMENTS_PATH);
+ await RNFetchBlob.fs.unlink(IMAGES_PATH);
await RNFetchBlob.fs.unlink(VIDEOS_PATH);
return true;
}
@@ -84,3 +117,30 @@ export const encodeHeaderURIStringToUTF8 = (string) => {
export const getAllowedServerMaxFileSize = (config) => {
return config && config.MaxFileSize ? parseInt(config.MaxFileSize, 10) : DEFAULT_SERVER_MAX_FILE_SIZE;
};
+
+export const isGif = (file) => {
+ let mime = file.mime_type || file.type || '';
+ if (mime && mime.includes(';')) {
+ mime = mime.split(';')[0];
+ }
+
+ return mime === 'image/gif';
+};
+
+export const isDocument = (file) => {
+ let mime = file.mime_type || file.type || '';
+ if (mime && mime.includes(';')) {
+ mime = mime.split(';')[0];
+ }
+
+ return SUPPORTED_DOCS_FORMAT.includes(mime);
+};
+
+export const isVideo = (file) => {
+ let mime = file.mime_type || file.type || '';
+ if (mime && mime.includes(';')) {
+ mime = mime.split(';')[0];
+ }
+
+ return SUPPORTED_VIDEO_FORMAT.includes(mime);
+};
diff --git a/app/utils/image_cache_manager.js b/app/utils/image_cache_manager.js
new file mode 100644
index 000000000..16e3da377
--- /dev/null
+++ b/app/utils/image_cache_manager.js
@@ -0,0 +1,81 @@
+// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+// Based on the work done by https://github.com/wcandillon/react-native-expo-image-cache/
+
+import base64 from 'base-64';
+import RNFetchBlob from 'react-native-fetch-blob';
+
+import {DeviceTypes} from 'app/constants';
+
+const {IMAGES_PATH} = DeviceTypes;
+
+export default class ImageCacheManager {
+ static listeners = {};
+
+ static cache = async (filename, uri, listener) => {
+ const {path, exists} = await getCacheFile(filename, uri);
+ if (isDownloading(uri)) {
+ addListener(uri, listener);
+ } else if (exists) {
+ listener(path);
+ } else {
+ addListener(uri, listener);
+ try {
+ const options = {
+ session: base64.encode(uri),
+ timeout: 10000,
+ indicator: true,
+ overwrite: true,
+ path,
+ };
+
+ this.downloadTask = await RNFetchBlob.config(options).fetch('GET', uri);
+ if (this.downloadTask.respInfo.respType === 'text') {
+ throw new Error();
+ }
+
+ notifyAll(uri, path);
+ } catch (e) {
+ RNFetchBlob.fs.unlink(path);
+ notifyAll(uri, uri);
+ }
+ unsubscribe(uri);
+ }
+ };
+}
+
+export const getCacheFile = async (name, uri) => {
+ const filename = name || uri.substring(uri.lastIndexOf('/'), uri.indexOf('?') === -1 ? uri.length : uri.indexOf('?'));
+ const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
+ const path = `${IMAGES_PATH}/${base64.encode(uri)}${ext}`;
+
+ try {
+ const isDir = await RNFetchBlob.fs.isDir(IMAGES_PATH);
+ if (!isDir) {
+ await RNFetchBlob.fs.mkdir(IMAGES_PATH);
+ }
+ } catch (error) {
+ // do nothing
+ }
+
+ const exists = await RNFetchBlob.fs.exists(path);
+ return {exists, path};
+};
+
+const isDownloading = (uri) => Boolean(ImageCacheManager.listeners[uri]);
+
+const addListener = (uri, listener) => {
+ if (!ImageCacheManager.listeners[uri]) {
+ ImageCacheManager.listeners[uri] = [];
+ }
+ ImageCacheManager.listeners[uri].push(listener);
+};
+
+const unsubscribe = (uri) => Reflect.deleteProperty(ImageCacheManager.listeners, uri);
+
+const notifyAll = (uri, path) => {
+ ImageCacheManager.listeners[uri].forEach((listener) => {
+ listener(path);
+ });
+};
diff --git a/package.json b/package.json
index 45f36c247..d8572ee3c 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"dependencies": {
"analytics-react-native": "1.2.0",
"babel-polyfill": "6.26.0",
+ "base-64": "0.1.0",
"commonmark": "mattermost/commonmark.js",
"commonmark-react-renderer": "mattermost/commonmark-react-renderer",
"deep-equal": "1.0.1",
@@ -31,6 +32,7 @@
"react-native-exception-handler": "2.7.1",
"react-native-fast-image": "4.0.0",
"react-native-fetch-blob": "enahum/react-native-fetch-blob.git",
+ "react-native-image-gallery": "enahum/react-native-image-gallery",
"react-native-image-picker": "0.26.7",
"react-native-keyboard-aware-scroll-view": "0.5.0",
"react-native-linear-gradient": "2.4.0",
diff --git a/yarn.lock b/yarn.lock
index d38e8e142..c26322a04 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5598,7 +5598,7 @@ promise@^7.1.1:
dependencies:
asap "~2.0.3"
-prop-types@15.6.1:
+prop-types@15.6.1, prop-types@^15.6.0:
version "15.6.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca"
dependencies:
@@ -5613,7 +5613,7 @@ prop-types@^15.5.0, prop-types@^15.5.8:
fbjs "^0.8.9"
loose-envify "^1.3.1"
-prop-types@^15.5.10, prop-types@^15.5.6, prop-types@^15.6.0:
+prop-types@^15.5.10, prop-types@^15.5.6:
version "15.6.0"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
dependencies:
@@ -5852,6 +5852,13 @@ react-lifecycles-compat@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-1.0.2.tgz#551d8b1d156346e5fcf30ffac9b32ce3f78b8850"
+react-mixin@^3.0.5:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/react-mixin/-/react-mixin-3.1.1.tgz#68749756bfe32699e561372a4aeecb926db72b7f"
+ dependencies:
+ object-assign "^4.0.1"
+ smart-mixin "^2.0.0"
+
react-native-animatable@1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/react-native-animatable/-/react-native-animatable-1.2.4.tgz#b5fb7657e8f6edadbc26697057a327fb920b3039"
@@ -5929,6 +5936,14 @@ react-native-fetch-blob@enahum/react-native-fetch-blob.git:
base-64 "0.1.0"
glob "7.0.6"
+react-native-image-gallery@enahum/react-native-image-gallery:
+ version "2.1.5"
+ resolved "https://codeload.github.com/enahum/react-native-image-gallery/tar.gz/7dd88b037cbb7ee36866585357b33bddb20bd12e"
+ dependencies:
+ prop-types "^15.6.0"
+ react-mixin "^3.0.5"
+ react-timer-mixin "^0.13.3"
+
react-native-image-picker@0.26.7:
version "0.26.7"
resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-0.26.7.tgz#ad2ee957f7f6cc01396893ea03d84cb2adb2e376"
@@ -7021,6 +7036,10 @@ slide@^1.1.5:
version "1.1.6"
resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
+smart-mixin@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/smart-mixin/-/smart-mixin-2.0.0.tgz#a34a1055e32a75b30d2b4e3ca323dc99cb53f437"
+
snapdragon-node@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"